Skip to content

Single-pass stereo: render both eyes in one geometry walk - #45

Merged
philpax merged 211 commits into
mainfrom
single-pass-stereo
Jul 30, 2026
Merged

Single-pass stereo: render both eyes in one geometry walk#45
philpax merged 211 commits into
mainfrom
single-pass-stereo

Conversation

@philpax

@philpax philpax commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What this is

Rendering both eyes in one geometry walk into a double-wide target, by rewriting the game's vertex-shader bytecode in flight and routing draws into per-eye viewport halves. The motivation: the frame is draw-submission-bound — roughly 20k draws per frame at ~41% GPU utilisation, measured in docs/mod/performance/performance.md — so halving the walk is the lever that matters.

Single-pass stereo is incomplete and ships default-off. The master switch (stereo.single_pass.enabled) is off; nothing in the feature runs without it, and the ten COM detours it needs are never installed. It's further gated behind a viewport-routing capability probe that falls back to the double-draw path. This branch merges as a foundation to resume from, not a finished feature.

What's actually finished

A good deal of the branch isn't SPS and is ready to use:

  • dxbc-stereo — a standalone, platform-independent DXBC parser and rewriter with four shader transforms (cb13 remap, clip-space reprojection, terrain tessellation eye-threading, SS-decal UV separation), validated against all 455 vertex shaders in the shipped bundle and unit-tested natively.
  • Mod-owned back buffer (vr.own_back_buffer, default on) — the scene renders at per-eye VR resolution while the DXGI swapchain stays at window size, so the present stops rescaling every frame. The ownership handoff and free-ordering invariants live in vr/back_buffer.rs.
  • Desktop mirror overhaul — fills the window by default instead of letterboxing (vr.mirror_framing Fill/Fit, plus vr.mirror_zoom). The egui overlay tracks the live window size, so the UI no longer stretches once render and window sizes diverge. Mirror-present failures distinguish transient from fatal instead of disabling on the first error.
  • Per-run session directories — everything the mod writes (logs, crash reports, profiler captures, render traces, telemetry, screenshots) lands under sessions/<timestamp>/ instead of loose files beside the DLL.
  • Shutdown hardening — eject waits, bounded, for the background writer threads, and pins the DLL rather than unmapping under a live thread. Native resolution restores synchronously to the live window size, and cleanups run even when the debug UI never came up. panic = "abort" workspace-wide: unwinding across a detour trampoline is UB.
  • Headpose one-tick lag fix (Rico's shadow head detaches from body during movement #44) — the published pose now shifts with the anchor delta, so the head no longer trails the torso.
  • HUD resolution control decoupled from the back buffer's shape; a frozen-pose diagnostic (cockpit vs full-camera, with a hand-editable pose grid); a puffin profiler with a GPU work-vs-starvation decomposition; and F12 screenshots with a JSON matrix sidecar. The old F10 capture path is now off by default — it was finicky, and F12 replaces it.
  • Terrain black-tile fix (Terrain walls and cave ceilings render black at grazing angles in VR #40) and the clustered froxel tile-bounds fix for off-axis projections (Clustered-light froxel tiling in VR: light assignment uses a symmetric frustum, not the off-axis eye projection #35). Terrain walls and cave ceilings render black at grazing angles in VR #40 recurred under the collapse via the terrain hull's FOV-dependent culls — fixed by relax_terrain_patch_hull_culls (default on in VR), not yet confirmed in-headset.

Fixed along the way

  • A latent blend-state hazard in the foveated-rendering fill-in (Static foveated rendering #29, shipped on main). The fill-in is a raw fullscreen draw that never bound a blend state, so it inherited whatever the engine's last block left on the context. This branch's frame restructuring changed which state that is — and surfaced the defect as a pose-latched ~2x global brightness step, an additive leftover adding the reconstruction over the already-shaded frame. The fix is one line: bind opaque explicitly. Validated in playtesting, and SFR's first in-headset validation.
  • The hunt corrected several defs. The "DrawHistogramWindow" the mod hooked is the raw final-scene meter; the HDR→LDR composite is the DoF composite. The applied-exposure field, the camera-underwater flag bit, and the duplicate render-context def are now named for what they are.

Known-incomplete, deliberately

The four documented gaps are in docs/mod/stereo/single-pass-stereo.md under "known gaps". The largest: the legacy-water intercept may be unreachable, and confirming that needs one in-headset session with the decline diagnostic this branch adds. Several of the ~25 single-pass sub-flags are blind-implemented and unvalidated (occluder, for one); each flag's doc names the specific artifact it addresses.

Tooling and hygiene

  • A brightness-pipeline debug kit, all default-off: MainColor mean brackets at fixed pipeline seams, a per-pass mean ladder, FP/VP global-constant dumps, and a one-shot auto-capture that starts a trace the moment the frame brightness steps. These are the instruments that localised the foveation fix.
  • dxbc-tool, a Rust compile/disasm CLI, replaces the old C shader harnesses. One shared, disposable wine prefix serves all Linux-side Windows tooling; xwin_test.sh runs the cross-compiled unit tests under wine; the OpenXR loader is fetched pinned and checksum-verified.
  • Docs reorganised by subject under the engine/mod split, mirroring the payload's module map. Conventions adopted from the developed style — crate::-anchored paths, file-size thresholds, import rules — and applied across the tree: the five 1000+-line modules are split by concern, and hooks/graphics_engine is grouped by pipeline stage. Pure moves, no behavioural change.
  • CI rustfmt now actually enforces imports_granularity. Stable rustfmt silently no-ops on it, so past runs false-passed; the check runs on nightly now.

Reviewer notes

  • jc3gi/src is entirely generated — review the pyxis-defs submodule pin, not the lines.
  • The module-split and docs-move commits inflate the diff but are pure moves; skim for structure only. The commit messages mark them.

philpax added 30 commits July 18, 2026 05:40
The tools/ scripts generate __pycache__/*.pyc on import.
The plan for single-pass stereo -- instance-doubled, double-wide,
viewport-routed, G-buffer-range only -- with the exact vertex-shader
transform (remap the per-eye cb0 view rows to a relative-indexed cb13
selected by SV_InstanceID & 1, emit SV_ViewportArrayIndex) and the
phased build.

Records the operand census the transform is sized against, established by
walking the SM5 token stream of the whole shader corpus: 0 parse failures on
455 vertex shaders, and a 155-shader model family that reads the global
view-projection.
A pure, portable crate for the vertex-shader bytecode surgery that makes
one instanced draw render both eyes. This lands the analysis foundation:
a DXBC container parser and an SM5 token-stream walker that locate the
per-eye cb0 operands (camera position cb0[4], view-projection cb0[29..32])
the rewrite must retarget to a per-eye cb13.

Kept free of platform deps so it unit-tests natively against the game's
extracted vertex shaders (which stay git-ignored): the tests reproduce
the sh_0067 fingerprint (17 refs, in order) and sweep the whole corpus
(455 VS, 0 parse failures, the 155-shader model family), and skip cleanly
where the shaders are absent.
… shader

Compiling a reference VS through a D3DCompile-under-wine harness settles the
encodings the transform must produce, recorded in the design doc: vs_5_0
accepts SV_ViewportArrayIndex but adds an SFI0 chunk (bit 13,
viewport/RT-array-index-from-any-shader), the ISGN/OSGN sysvalue encodings for
SV_InstanceID / SV_ViewportArrayIndex, and cb13 as dynamicIndexed.
patch_vertex_shader rewrites a model vertex shader for instance-doubled,
viewport-routed single-pass stereo: binds both eyes' five cb0 position
rows at cb13, remaps every per-eye cb0 operand to cb13[rBase.x + k],
injects the SV_InstanceID input, the SV_ViewportArrayIndex output, and
the SFI0 feature bit, then fixes the SHEX length and the container
checksum.

Hoists the DXBC checksum out of the payload into the crate so the
rewrite and the fragment-program patching in hooks/graphics_engine/
shader.rs share one implementation.
Replace disasm.c (and its clang+xwin .sh builder) with a dxbc-tool workspace
crate that calls D3DCompile/D3DDisassemble through the windows crate, driven
by one scripts/dxbc.sh wrapper. No more manual LoadLibrary/GetProcAddress,
and Rust's stdout writes the DXBC blob verbatim -- the text-mode CR-LF
corruption a C harness writing binary to stdout has to guard against cannot
occur.
Groundwork for single-pass stereo, all observation-only so it is safe to
inject with rendering unchanged:

- config: stereo.single_pass (master, off) + single_pass_patch_dryrun.
- stereo::single_pass: the DXVK VPAndRTArrayIndexFromAnyShaderFeeding-
  Rasterizer capability probe, and a census of the vertex-shader rewrite
  (patched / no-per-eye-refs / errored).
- a CreateVertexProgram detour that runs patch_vertex_shader on every VS
  and tallies the outcome without substituting the bytecode.
- a Render-tab section reporting the probe result and the live census.

The census validates the DXBC rewriter against the game's real shader set
and reports the true Phase-1 scope before the rest of the pipeline lands.
Run patch_vertex_shader over every vertex shader in the extracted bundle
and structurally validate each success (re-parses, SFI0 viewport bit set,
no residual per-eye cb0 operand, clean token walk). The test drove out
three real bugs the single-shader tests missed:

- immediate_component_count mapped the 1-component enum to 4 dwords, so
  the parser over-read every scalar l(x) immediate by 3 tokens and
  drifted -- silently corrupting the ~17 shaders that use them.
- parse_operand could return a next index past the token slice, which the
  operand iterator tolerated but the rewriter sliced with, panicking.
  Made parse_operand total via end_operand's bounds check.
- per_eye_refs counted a dcl_constantbuffer operand (whose element is the
  buffer size, e.g. cb0[29]) as a per-eye reference. Skip declarations,
  via a shared Instruction::is_declaration.

Corpus result: 455 VS -> 196 patched, 245 no-per-eye-refs, 14 deferred
(already declare SV_InstanceID), 0 errored, 0 structurally invalid.
…hecklist

Capture the in-game architecture synthesised from the engine RE: the
hook points with addresses, the six-stage pipeline (what's built vs
unbuilt), the corpus-validated shader breakdown (196 patched / 245
no-refs / 14 deferred / 0 errored), a ranked wake-up test checklist, and
the deliberately-deferred later-phase work.
All 196 patched blobs pass real Microsoft D3DDisassemble under wine, and
a patched game shader disassembles to the exact expected stereo idiom.
Live in-game census confirmed the offline corpus exactly (392/490/28 =
2× 196/245/14, the ×2 from the reload's bundle bounce). Refine the UI it
surfaced: classify the SV_InstanceID deferrals into their own bucket so
"errored" means genuinely-unexpected (and should read 0), and reset the
census after the throwaway away-bundle pass so the count is a clean 1×.
The first testable slice of the in-game pipeline, expected to render
identically to the double-draw (no double-wide target, instancing, or
viewport routing yet):

- When single-pass is active (master on, dry-run off, capability present),
  the CreateVertexProgram hook substitutes the patched bytecode, so the
  ~196 model-family VS read their position from cb13 instead of cb0.
- A SetAllGlobalShaderProgramConstants detour mirrors the current view's
  per-eye cb0 rows (m_VPGlobalConstData[29..32] + [4]) into a mod-owned
  cb13 and binds it at b13, per pass. Both eye slots get the same
  current-view rows, so a patched shader renders exactly what it would
  from cb0 -- in the G-buffer and in the shadow/reflection passes that
  reuse the same model shaders (the shadow-safety reason cb13 tracks the
  live view rather than being written once).

Two details the slice does not work without. The substitution repoints
CreateVertexProgramParams.m_Size as well as m_Code: the rewrite grows the
DXBC (added SFI0 chunk, cb13 declaration, prologue, signature entries), and
leaving the original length hands the D3D stack a truncated container whose
chunk table runs past its declared size, which DXVK aborts reading out of
bounds. And viewport slot 1 is mirrored by a detour on
ID3D11DeviceContext::RSSetViewports (with RSSetScissorRects) rather than at
any engine-level seam, because the shadow cascades set their per-cascade
atlas sub-rect with a raw RSSetViewports between render-setup binds that no
engine call observes -- so a single-viewport set has to become two identical
slots wherever it originates, or odd-instance shadow casters route to a
stale region and the shadows flicker. The detours install lazily on the
first active frame, under a thread suspender, and only when single-pass is
switched on, so a normal session never patches the vtable.

Still gated behind stereo.single_pass (off). Milestone A validates the
patch + cb13 path end to end before the doubling/routing lands.
…, instancing

The render-thread half of Milestone B, gated behind stereo.single_pass_dual_eye
(itself under the master), applied only in the G-buffer geometry pass range
(RP_Z_OCCLUDERS..RP_FIRST_SCENE, tracked around DrawRenderPassRange):

- cb13 filled with DISTINCT per-eye view-projections: replicate the double-draw
  per-eye camera math (offset the pristine center transform by each eye's
  world_offset + orientation_delta, invert, zero-translation for the OffsetVP,
  reverse-Z eye projection) purely in mod code, so one walk produces both eyes.
  Falls back to the Milestone A mirror outside the scene range.
- RSSetViewports detour splits the bound viewport into left/right halves for
  SV_ViewportArrayIndex routing (instead of two identical copies).
- a DrawIndexed COM-vtable detour promotes non-instanced draws to 2 instances,
  so SV_InstanceID & 1 selects the eye. Already-instanced draws are left alone.

Still needs the double-wide target + the single-walk collapse (next) for a
finished image; on its own each eye renders into half a per-eye target. All off
by default.
…up order

Record that Milestone A is confirmed in-game, the Milestone B render
machinery is built (dual-eye cb13, eye-half viewports, instance doubling)
and testable as a diagnostic, and the two coupled remaining pieces
(double-wide RT + capture split, single-walk collapse) with the exact
approach and the wake-up bring-up order.
The linchpin for handling unpatched geometry. Two more COM-vtable detours
(installed with the others, only when single-pass is active): the device's
CreateVertexShader (slot 12) records the ID3D11VertexShader created from a
patched blob into a set (the CreateVertexProgram hook flags the pending
patched creation via PATCH_PENDING), and the context's VSSetShader (slot
11) caches whether the now-bound shader is in that set. DrawIndexed then
gates on it: patched shaders are instance-doubled (both eyes in one draw),
unpatched are left single (their per-eye double-draw is the next step).

Logs the per-frame patched/unpatched G-buffer draw split so the gating can
be verified before building on it. Patched-VS set clears on shader reload
(pointers are released and could be reused).
Single-pass leaves the game holding its edits: substituted (cb13-reading)
vertex shaders, the fragment PCF/dissolve patches, and inline COM-vtable
detours on the DXVK functions. Without teardown the render thread also keeps
running through the whole eject, racing the hook uninstall and D3D release --
the crash-on-uninject.

Gate the single-pass render path on `is_shutting_down()` so it goes inert the
instant F5 is pressed; disable the COM detours (under a thread suspender, like
install) before the payload unloads so no dangling jump survives into freed
code; and on eject bounce the shader bundle to re-create the pristine
originals, with both the vertex substitution and the fragment patches skipped
while shutting down.
The main G-buffer render setup binds its viewport before DrawRenderPassRange
raises the in-range flag, so rs_set_viewports_detour identical-dups it instead
of splitting into eye halves -- and that dup covers the bulk of the geometry.
Re-split the bound viewport the moment the range flag goes up (dual-eye only),
and add per-frame split/identical-dup counters to the draw-split log for
bring-up visibility.
Catching a hard crash under Wine needs two traps handled: Wine fires SIGUSR1
and realtime signals constantly for its own scheduling, and gdb asserts if it
reports a signal while the game's threads are exiting mid-crash. catch_crash.py
auto-resolves the game PID and exe/payload address bands, silences the Wine
signals, stops on the first genuine SIGSEGV/SIGABRT, and dumps registers,
faulting instructions, a backtrace, and a stack scan -- tagging any address in
the exe or payload band, the signature of a dangling detour after teardown.
Documented in the skill alongside the wine-signal and thread-exit traps.
One `game.Draw` now produces both eyes when `single_pass_collapse` is on
(requires `single_pass_dual_eye`): `game_update_render` runs a single dispatch
(`&[(0, false)]`), dropping the per-eye loop and the between-eye
snapshot/restore; `setup_render_camera` keeps the render camera centered (both
eyes come from `cb13`, shadow-anchor delta zeroed); and `render_engine_post_draw`
splits the one back buffer into the two eye textures.

Eye routing spans the whole camera scene, not just the G-buffer: with no second
dispatch the later geometry passes (water, sky, transparents) must route to the
eyes too, while the interleaved fullscreen deferred-lighting/post passes must
keep the full width. `render_pass.rs` marks the whole scene (`first >=
RP_Z_OCCLUDERS`, excluding the shadow/reflection prepasses), and the viewport
split moves from pass-level to draw-level: `draw_indexed_detour` splits into the
eye halves for a patched geometry draw, while a new `Draw` (slot 13) detour
resets the fullscreen passes to full width.

Two properties of the eye split that are load-bearing, both found by getting
them wrong first:

- `ensure_collapse_viewport` re-binds the split unconditionally before every
  routed draw rather than caching whether it is already split. The engine can
  change the viewport underneath it (a `count != 1` set, or a path the detour
  does not observe), and a stale cached flag lands both instances of a patched
  draw in one eye-half -- geometry appearing twice in one eye and missing from
  the other. Re-binding is cheap at a few hundred geometry draws per frame,
  far below the draw budget the collapse is cutting.
- The flip is exempt from eye-parity gating. Collapsed, the dispatch list is a
  single entry tagged eye 0 while `present_eye_0` defaults to false and so
  selects eye 1, making `present_eye != eye` true on the only dispatch there
  is, every frame: the engine runs on at full rate, submitting draws and
  logging stats, while nothing reaches the screen. Because `graphics_flip` also
  renders the egui overlay, that presents as a total lockup. Parity is
  meaningless here -- the one dispatch carries both eyes.

Squished until `single_pass_double_wide` (each half is half a per-eye target);
particle `DrawInstanced` / already-instanced draws are not yet routed.
`single_pass_double_wide` (requires `single_pass_collapse` + native resolution)
re-creates the scene render targets at 2x per-eye width, so the collapsed single
walk renders both eye-halves side by side at full resolution instead of squished.

`vr::engine_render_resolution` returns 2x the per-eye width, which the existing
deferred-`ApplyResize` native-resolution driver targets -- so the whole scene RT
set (back buffer, `m_BackBufferLinear`, the G-buffers) goes double-wide and the
per-pass viewport follows automatically. The XR swapchain stays per-eye width
(`native_eye_resolution` is unchanged), and the per-eye capture textures are sized
to half the back buffer, so the collapse's capture split copies each full-width
half straight into its eye texture.

Unpatched geometry (which reads `cb0`, not the per-eye `cb13`) now renders at the
2x-wide aspect, and screen-space/post passes run once over the double-wide target
-- both known bring-up limitations.
The native-resolution shutdown restore was deferred (write the pending size, let
the engine's `HandleModeChange` service it in a later `Draw` prologue) -- but eject
renders no further frames, so it never completed and the game was left stuck at the
mod's render size. With double-wide that is a glaring 2x-wide stretch; it affected
every native-resolution eject.

`on_shutdown` now calls `ApplyResize` directly on the (already-drained) idle context
and clears the engine's deferred mode-change flag, so the original size is restored
before the hooks are torn down.
F12 writes a PNG of the linear back buffer to a DLL-adjacent `screenshots/` folder
(under collapse, the full side-by-side render -- both eyes) plus a `.json` sidecar
dumping the frame's single-pass matrix state: per-eye `world_offset`,
`orientation_delta`, `projection_reverse_z`, the uploaded `cb13` view-projection
and camera position, the centre transform, the view divergence, and the config.
A robust alternative to the F10 fullscreen capture for in-headset diagnosis, and
it turns matrix debugging into reading numbers instead of squinting at captures.

Also: a rate-limited `cb13 eye divergence` log line, and a one-click "WIP setup"
button in the Render tab that enables the full single-pass config (single-pass +
dual-eye + collapse + double-wide + native resolution) and reloads the shaders.
In the collapsed single walk the render camera is centered and the target is
double-wide, so the mod's overlays were drawn once and stretched: the floating
HUD/egui panels projected at the centre across both eye-halves, and the flat egui
debug overlay laid out at double width (half-relative-size).

`render_engine_post_draw` now draws the HUD panel and egui panel once per eye, each
into its eye-half viewport (bound via the un-detoured `RSSetViewports` so the
collapse detour does not dup it) with that eye's own full view-projection
(`single_pass::collapse_ui_eye_override` / `full_eye_view_projection`). The flat
egui overlay lays out and renders at per-eye width, and the mirror composite
stretches it across the double-wide buffer, which the window's 2x squish cancels.

All collapse-gated, so the double-draw VR path is unchanged.
Records the RE of the non-cb0 geometry families and how to single-pass them:
which are already shader-patched and working (buildings, depth, occluders),
which are patched but not yet routed (skinned characters/NPCs -- a draw-path
fix, not a shader one), and which need clip-space output reprojection (baked-WVP,
tessellated terrain, GPU-indirect vegetation), with the DXBC recipe and order.
Bump the pyxis dependency and the pyxis-defs submodule for pyxis's improved
constant support, and regenerate the bindings. Generated string constants are now
`&'static CStr` rather than `&'static str`, matching how the game consumes them.

Downstream: the two sites that rebuilt a `CString` from those constants now pass
the `&CStr` straight through -- `WeatherController::EVENT_*` in the weather UI and
`EventIdSymbolTable::ACT_*` in `resolve_act_id`.
… directory

Every disk artifact -- the log, crash log, profiler captures, render traces,
grapple telemetry, and screenshots -- now lands under one timestamped folder
beside the DLL, sessions/<stamp>/, instead of scattering timestamped files
across the DLL directory.

A new session module resolves the root once at startup (before logging installs)
and hands out the root, for the single-file log and crash log, and per-kind
subdirectories, created lazily so a run only materializes the folders it uses.
Timestamp generation is centralized in session::stamp, replacing the three
ad-hoc jiff strftime sites. Screenshots gain their own <stamp>/ subfolder,
mirroring the render-trace layout.

The crash-log docs are corrected: under per-session folders jc3vrs.log is no
longer truncated across runs, so the crash log's reason to exist is that it is a
handler-safe, allocation-free raw-handle sink, not that it survives truncation.
The capture window's fullscreen toggle is finicky under some setups; gate it
behind a const F10_CAPTURE_ENABLED. It is a runtime `if`, so VK_F10 stays
compiled and it is a one-line flip to restore. The F12 screenshot path covers
in-headset diagnosis meanwhile.
Replace the one-shot "WIP setup" button with a single toggle at the top of the
Single-pass section that flips the whole configuration -- single-pass, dual-eye,
collapse, double-wide, and native resolution -- on or off together, clears the
census dry-run, and reloads the shaders either way (on to apply the patches, off
to restore the pristine shaders). The label tracks the current state; the
individual bring-up levers stay below.
Instrument the CreateVertexShader detour to classify every shader creation --
whether it arrived pre-substituted from CreateVertexProgram (pending) or was
analyzed here -- into patched / already-cb13 / no-refs / errored buckets, and
surface the tally, alongside the recorded-shader count and the
CreateVertexProgram census, in the per-frame log, the screenshot JSON sidecar,
and the debug UI.

The detour also substitutes a patchable-but-unpatched blob in place, a defensive
catch for any shader-creation path that skips CreateVertexProgram. In practice
the shader bounce re-creates every shader through CreateVertexProgram (the census
sees all of them), so it catches nothing today -- but this instrumentation is
what proved that, and shows the character shaders fall in the no-refs family,
which the clip-space reprojection pass will handle rather than the cb0 remap.

Also install the COM detours before the trampoline creates the shader, so a
shader patched at the blob level is recorded even when created before the
detours' lazy first-frame install.
reproject_vertex_shader is the fallback for the baked-WVP / terrain / GPU-indirect
families that have no per-eye cb0 operands (skinned characters, props, tree
trunks, ...). Instead of remapping cb0, it renames the shader's own SV_Position
writes to a temp rClip and post-multiplies by a per-eye M_eye before each ret, so
o0 = M_eye · clip_center lands the shader's own centre-clip position in each eye.
M_eye = VP_eye · VP_center-inverse rides in a four-rows-per-eye cb13 block after
the 10 remap rows (STEREO_REPROJ_CB_ROWS = 18, addressed cb13[rBase + 10 + j] with
rBase = 4*eye). It reuses the remap's whole scaffold: the SV_InstanceID input,
SV_ViewportArrayIndex output, SFI0 bit, signature append, and checksum.

The injection point is the first executable instruction, which means
is_declaration has to cover the SM5 declaration block (0x91..=0xA2,
dcl_function_table through dcl_resource_structured) and not only SM4's
0x58..=0x6A. Without it the SM5 declarations the terrain and vegetation shaders
use classify as executable, and the prologue lands mid-declarations -- silently,
on every structured-buffer shader.

Shader-agnostic -- it works on any vertex shader that writes SV_Position from a
scene view-projection, whatever buffer that came from. 225 of the corpus's 245
no-cb0 vertex shaders reproject cleanly (the other 20 write no position -- the
terrain VS whose clip is built in the domain shader); real D3DDisassemble accepts
the character shader with the exact expected idiom.
philpax added 27 commits July 29, 2026 07:47
dir() and subdir() now return Option<Result<PathBuf, io::Error>> instead of
swallowing mkdir failures via .ok()?. None still means the root is unavailable
(DLL path unresolved); Err distinguishes a directory-creation failure so
callers can report it rather than silently disabling. All call sites updated
to and_then(|r| r.ok()) or match, and crash.rs now prints the io error to
stderr at startup.
…h RAII

The payload's detours hold raw pointers into engine state that is not safe
to drop through, and unwinding across an FFI boundary (the detour
trampolines) is UB. Set panic = "abort" in dev and release profiles; the
panic hook in crash.rs still fires before the abort so crash logs are written.

Gate the two should_panic tests on not(panic = "abort") since they abort
the test process under that strategy.

Add CritSecGuard (engine_context.rs) and RenderContextRestore (water.rs) so
the engine's critical section and render-context matrices are restored on
drop even if the closure unwinds. The guards keep the invariant honest if
the panic strategy ever changes or a catch-unwind is introduced.
Replace the bare u32 foveal_first_pass and foveal_last_pass fields in
FoveationConfig with the RenderPassId enum, with custom serde that
serializes as the i32 discriminant so existing config files stay readable.
Add a validate() method that checks the pass range and mask-bit power-of-two
invariant, with FoveationConfigError and unit tests. Wire validation into
foveation_plan so an invalid config disables foveation with a warning.

Replace the debug UI DragValue with a ComboBox listing all scene render
passes by hex value and engine debug name. Also fix two minor hover-text
whitespace issues in the same file.
Replace the per-frame warning on transient mirror-present failures with a
transition log (first failure), a periodic reminder (every 90 frames), and
a recovery notice when the streak ends. Mirrors the pattern already used by
frame_begin's failure-rate limiting.
The SinglePassConfig doc comments referenced the old single_pass_-prefixed
accessor names (e.g. single_pass_water_uv_per_eye) that no longer exist after
the config refactor. Update them to the current names (water_uv_per_eye,
ssdecal_per_eye, ssao_per_eye, reconstruct_per_eye, clustered_per_eye).
…r terrain startup flag

clustered_lighting: add state.ctx == ctx guard to the geometry-constant
substitution so a cross-block upload with the same (cb_index, offset, count)
shape that lands while the split is active is not mis-substituted. Mirrors the
guard already present in substitute_assignment_view.

terrain: set STARTUP_APPLY_DONE only after the patcher and engine-singleton
checks succeed, so an early return retries the startup apply on the next
frame instead of permanently skipping it.

reconstruction and ss_decal: add safety-justification comments for the
SetScissorEnable and staging-restore paths.
Move BYPASS_RESIZE_SUBSTITUTE.store(false) after the device-info restore so
the flag covers the entire save-call-restore window. A concurrent resize
substitute that reads device info while the bypass was prematurely cleared
would see the new swapchain size instead of the original render size.
- wine-debug SKILL.md: .claude/ → .polytoken/ for the catch_crash.py path
- CONTRIBUTING.md: fix 'cargo clippy -all' to 'cargo clippy --all'
- docs/mod/hud.md: update the HUD composite-exclusion explanation to match
  the engine's actual back-buffer aliasing path
- tools/shaders/README.md: point the wine-prefix instructions at the shared
  scripts/wine_prefix.sh provisioning rather than the old per-tool path
…r flag

Pull the pyxis-defs mapping of the post-scene lighting seams (the exposure
publish pair, the atmosphere block and its sky-lighting SH targets, the
low-res particle compose, the render context's lighting fields) and adapt
dof_no_reproject to the corrected flag name: bit 0 of the post render
context's flags is the engine's camera-underwater bit, not a motion-vector
reprojection toggle. Same bit, same validated behaviour, honest name.
The foveation fill-in is a raw fullscreen draw on the immediate context and
never set a blend state, so it inherited whatever the engine's last block
left bound. An inherited additive/alpha state made the fill add its
neighbour-averaged reconstruction over the whole already-shaded frame (~2x
brightness) instead of replacing the dropped peripheral pixels -- latched for
as long as the scene kept handing it the same leftover state, and flipping
with head pose. Bind opaque blend explicitly; the existing state backup
still restores the engine's blend afterwards.
Instruments distilled from the foveation blend-state hunt, kept for future
pipeline debugging (single-pass stereo especially):

- MainColor mean brackets at the fixed pipeline stages (post-resolve, around
  the aerial-perspective composite, post-block entry, post-chain start),
  under diagnose_main_color_means -- bracketing a global change between two
  stages names the frame segment that injects it.
- A per-pass MainColor mean ladder over the late-scene passes, under
  diagnose_pass_sweep, to walk a change to the exact pass.
- FP/VP global-constant staging dumps at scene time and frame end, under
  diagnose_global_constants, for good-vs-bad frame diffing.
- A one-shot auto-capture: arm a checkbox and a render trace starts the
  moment the frame brightness steps against its recent median, then disarms.
  The CPU histogram lags too far behind short flips to serve as the trigger,
  so this reads the MainColor mean directly at the post-chain seam.
The first in-headset validation caught the fill-in inheriting the engine's
leftover blend state (the pose-latched ~2x global brightness step); note the
finding and the generalized lesson in the doc, and stop describing the
feature as wholly untested on hardware.
Sync pyxis-defs: 0x140_119_440 is ToneMappingEffect::Apply (publishes the
applied exposure pair into the frame context and runs the exposure-weighted
meter) and 0x140_119_8F0 is GenerateHistogramForFinalScene (the raw
final-scene meter) -- the DrawHistogramWindow name belonged to a debug
overlay. The applied exposure field at +0xBF0 is m_ExposureBrightPoint, and
PostEffectRenderContext folds into RenderContext (one engine struct, one
def).

Rename the hooks and trace events to match, serve the eye-1 skip path's
out-params through the typed exposure fields instead of raw word copies, and
correct the docs that placed the HDR->LDR tonemap composite in
"DrawHistogramWindow": the composite is CDepthOfFieldEffect::Apply,
multiplying the scene by the published exposure (fragment constant c2.x).
Group docs/engine/ and docs/mod/ into mirrored subject subdirectories
matching the payload's module map -- rendering/, performance/, character/ /
body/, gameplay/ / input/, and mod/stereo/ for the single-pass family --
with single-doc subjects staying at the split's root. The engine-vs-mod
nature split stays the top level per the documentation convention, which
now records the subject layer too. All intra-doc links, code-comment
references, and the index follow the moves; the rendering \xc2\xa7N anchors are
untouched.
rt_hash had accreted the MainColor mean brackets, the per-pass ladder, the
constant-staging dumps, and the brightness auto-capture alongside its actual
job (per-eye render-target hashing and trace screenshots). Give the probes
their own module, pipeline_probes, with the instrument catalogue in the
module doc; rt_hash returns to hashing.
…ntions

Pull in the conventions this style has since grown elsewhere: intra-crate
paths anchor at crate:: (never super::, except a test module's
use super::*), repeated or unwieldy paths get top-of-module imports,
pub(in ...) reads as a smell, comments describe the present state, file
splitting at the 1000-line threshold along concern seams, wide folders get
subfolders, parking_lot::Mutex as the synchronous-lock default, .as_ref() /
.as_mut() over manual double-derefs, and a testing section (tools,
no serde-only tests, anonymized fixtures). Also correct the Logging note
that still described another project's frontend.
Apply the newly adopted rule across the hand-written crates: every
super::-anchored import and call path becomes its crate::-anchored
equivalent, leaving use super::* in test modules (the sanctioned idiom) and
pub(super) visibilities untouched.
A detour's signature mirrors the hooked function's ABI and cannot be
bundled into a parameter struct, so the too_many_arguments allow is the
honest annotation there -- the payload's four allows are all detours.
Hoist the function-local use statements to module tops, import the parent
modules for the deep intra-crate call paths the crate:: sweep surfaced
(module imports where item imports would collide on leaf names), and
replace the one manual double-deref with the direct copy it was.
crash.rs sat at 1112 lines; split it along its natural seams into a folder
module -- the install/VEH/panic-hook root, the allocation-free line writer,
the memory probes and module resolution, the frame-phase breadcrumb ring,
the exception record writer, and the stack/thread dumps. A move, not a
rewrite: the public surface (install, uninstall, Phase, mark) is unchanged
and no consumer needed edits.
profiler/gpu.rs sat at 1047 lines; split it into a folder module -- the
seam/entry-point root, the timestamp-query ring, the starvation-gap
subdivision, the puffin stream bridge, and the rolling summary. A move, not
a rewrite: the public surface is unchanged and no consumer needed edits;
the one structural change folds teardown's pool destruction into the
profiler type so the pools stay private.
ui/render.rs sat at 1714 lines; split it into a folder module -- the
capture machinery, and one section module per debug-UI area (stereo,
far-field, stereo corrections, single-pass bring-up, reduced resolution,
post-FX, and render-pass presentation), with the root composing the
sections in the original order. A move, not a rewrite: bodies are verbatim,
the public capture surface is unchanged, and sibling reach that pub(super)
would have cut off at the new depth uses pub(in crate::ui).
vr/mod.rs sat at 1698 lines; split it into sibling files -- the VrState
singleton and session lifecycle, the per-frame loop, session creation, the
stereo swapchain, cross-inject persistence, the loader search, eye
resolution, and recentering -- leaving the root as declarations,
re-exports, and the two shared view constants. A move, not a rewrite: the
public surface is byte-identical and no consumer needed edits.
clustered_lighting.rs sat at 1010 lines; split it into a folder module --
the DrawClustered detour and run driver, the per-eye froxel split state
machine, the constant-upload interception detours, and the off-axis tile
bounds derivation with its tests. Each detour stays beside the
hook_library() that binds it, composed at the module root. A move, not a
rewrite: the pub(crate) surface sibling hooks consume is unchanged.
hooks/graphics_engine sat at twenty flat entries; group the leaves into
scene/ (world geometry and culling), screen/ (the depth-reconstruction
basis and its screen-space/fullscreen consumers), and post/ (the post
chain and metering), with the engine-core seams staying at the root. Each
group composes its children's hook libraries, and the root re-exports the
externally referenced leaves so every existing graphics_engine::<leaf>
path resolves unchanged.
The crate:: sweep left same-crate imports as separate use statements, and
imports_granularity only merges within a contiguous group -- a blank line
splits it. Adopt group_imports = "StdExternalCrate" (nightly-only, like the
granularity option already in use) so rustfmt owns the std/external/crate
grouping and the merge, and reformat: 30 files had split or misplaced
groups; the rest were already canonical.
The crate:: sweep left repeated fully-qualified call paths inline; the
import-once rule wants them imported at the module top. Hoist each repeated
path -- functions and statics as item imports, associated functions at
their type, with a parent-module import where the leaf would collide -- and
let rustfmt fold the new lines into the existing groups. The handful of
paths left qualified collide with same-named locals or fields, where the
qualified form is the clearer one.
@philpax
philpax merged commit 5aae512 into main Jul 30, 2026
2 checks passed
@philpax
philpax deleted the single-pass-stereo branch July 30, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant