0.1.0
CyberpunkVRPort 0.1.0
The proxy is gone, the second eye is a real view, and the port is a RED4ext plugin
============================================================================
- ARCHITECTURE
============================================================================
1.1 Why the proxy had to go
As dxgi.dll we stood in front of DXGI and owned what we assumed was the process's only
swapchain. Every path grew around that assumption, and the moment a second swapchain
appeared - the VRCAM mirror window - it broke in a different way on each attempt: our own
IAT hooks recursing back through our module, the overlay drawing into the mirror's
backbuffer, a shared surface returning DXGI_ERROR_ACCESS_DENIED. None of those were mirror
bugs. They were consequences of being the proxy.
Almost nothing had to move to fix it. The engine hooks, the OpenXR submit, the capture, the
overlay and the stereo module never needed to be a proxy - they needed a device, a queue and
Present. Only the way those three are acquired changed:
device + queue sync_stereo hooks d3d12!D3D12CreateDevice before the game creates its
device and keeps both; the plugin waits for them instead of being
handed them by CreateSwapChain.
Present the swapchain vtable is shared process-wide, so a throwaway swapchain
exposes the same table the game presents through.
game window learned from the first Present that is not one of ours.
Everything ships as two RED4ext plugins - CyberpunkVR_Stereo and CyberpunkVR_Hands - so the
game loads us through its own mod loader and uninstalling is deleting a folder. Running both
a dxgi.dll and the plugin means two copies of every hook fighting for the same addresses, so
the installer removes the proxy.
src/dxgi/ became src/vr/, because nothing in it proxies DXGI any more:
core/dxgi_proxy.cpp became core/vr_core.cpp (the ~7.5k-line hub: logger, live controls,
camera hooks, pose, menu detection), and core/dxgi_factory_wrapper.h/.cpp became
core/swapchain_hooks.h/.cpp (the swapchain vtable hook - Present, ResizeBuffers - which the
plugin needs and always did).
1.2 AER is deleted
Alternate-eye rendering with NVIDIA Optical Flow synthesis is gone in full: src/dxgi/aer_v2/,
openxr_aer_v2_worker.cpp, the optical_flow_d3d12 / stereo_reproject / mv_warp / warp_pass
passes, the CUDA Toolkit and Optical Flow SDK from CMake (48 vendored files),
EnsureAERCaptureResources, CapturePresentedFrame, DepthResolve::RecordResolveColor, the AER
submit arm of the frame loop, the 13 xr_aer ini keys plus xr_nvof_perf, and the overlay's
"Stereo / AER" combo. Roughly -9900 lines.
Things that were dead by construction went with it, and are worth naming because older notes
still mention them: syncSequential and the synced-pose machinery (AER-gated), the per-eye
display cant ComputeCantPoseDelta / ApplyCantToPose (mono renders one frame, so a per-eye
cant was never applied at all), and OnPatchBufferCallback's body, which sat behind an AER
gate and had therefore never executed once.
============================================================================
2. NATIVE STEREO - HOW IT ACTUALLY WORKS
The second eye is a real engine view: a render-to-texture camera component on the player
entity, running the frame graph for its own eye, from its own position, with its own
projection. Not a reprojection, not a copy of the first. This is the whole chain, in order.
2.1 The view exists because of an authored asset
tools/gen_vrcam_assets.py authors one entRenderToTextureCameraComponent per render resolution
into the player entity, packed into cyberpunkvrport.archive:
component vrcam_<W>x<H>
virtualCamera vrcam_feed_<W>x<H> isEnabled = 0
All ship disabled. The launcher offers exactly the resolutions the archive carries a camera
for - offering one that does not exist used to fail silently, with every log line still
naming the component you expected.
2.2 Identity: one hash, never a heuristic
The entire stereo path recognises the second view by one value: the CName hash of the
component's virtualCameraName, which the engine stores at view-context + 0x28. MAIN is key 0.
That hash differs per resolution, so a literal would only ever match one of them; it is
derived at init from the selection file instead. Nothing anywhere looks at aspect ratio or
resolution numbers to find the view - in VR MAIN renders square exactly like VRCAM, so the
old "key == 0 && aspect > 1.3" test either missed MAIN or latched some unrelated wide helper
view.
2.3 Selection plumbing, and why it goes through a file
launcher --writes--> bin\x64\plugins\cyber_engine_tweaks\mods\
CyberpunkVRPort_Stereo\vrcam.json
|
CET Lua (modules/vrcam_select.lua) reads it and flips isEnabled
on the matching component through the game's own RTTI
|
the plugin reads the same file and hashes the camera name into
g_vrcam_ctx_key
CET sandboxes Lua file IO to the mod folder, which is why the canonical copy lives there and
the plugin reaches out to it rather than the reverse. Four things describe this one choice -
component name, camera name, the key hashed from it, and the resolution the RTV filter matches
on - and vrcam_config.h moves all four together, shared with the launcher dialog. Updating a
subset is not a small bug: a key that names no live view means no second eye and no mirror
window, while every log line still prints the component you expected. Deleting vrcam.json
falls back to the legacy single component, which is the escape hatch when the expanded entity
has not been imported yet.
2.4 Making the second view render what MAIN renders
A fresh RTT view does not get the full frame graph. Three separate gates had to be told apart,
and only the third was the real one:
-
Frame-graph feature flags (f0/f1 at the builder). Forcing them changed nothing - VRCAM's
pair was already a superset of MAIN's (3C00017F vs 3C00017D). -
The view's draw-block list, which came back empty every frame (blocks null == vrcam
dispatch count, exactly). -
The per-view RenderMask, which is the answer. 32 qwords at view + 0x18A0, where
view = the pointer at work_context + 0x18 - the same context whose key at +0x28
identifies the view. The test sub_14021BE28(wc, desc) passes when
(mask[i] & required[i]) == required[i] for i in 0..31, and the required words start at
descriptor + 8, not descriptor + 0. Granting from +0 does nothing at all, and produced a
confident, wrong "the mask is not what blocks
it".
The descriptors are named by the engine - one registration function each,
sub_1400F76B0: "Rendering/RenderMask/DistantLights" -> word_143487D70 - 196 of them dumped to
engine_re/dumps/_render_mask.md by engine_re/scripts/re_render_mask.py. "Pass X does not run
for the second eye" became a table lookup instead of a hunt: GBuffer, GBufferLate,
DepthPrepass, GeometryStatic/Skinned/Proxies, WeaponPlane, Forward, ForwardNoTXAA, Unlit, HUD,
GameplayPostProcess, Particles, DistantLights and the rest.
The feature bitset at view + 0x17D0 and the mask at view + 0x18A0 are one bitset, 26 qwords
apart - which is why a diff over feature words 0..23 never saw the mask and came back clean,
and why the [fgflags-all] comparison was misleading.
For the HUD the shortfall was exactly one bit:
MAIN w11 req=...0080 have=0000000000CFFFBF missing=0
VRCAM w11 req=...0080 have=0000000000CF6E3F missing=0000000000000080
word 11, bit 7 - absolute feature 711. Granting the capability early and leaving it set is not
the same thing as overriding the test result deep inside the node: the override answered one
question at the moment it was asked, long after the work it guards had been skipped. The bit is
what the rest of the engine reads, so the view genuinely declares the capability and the engine
populates the state itself. The proof was the block list ceasing to be null.
2.5 Giving it the right camera
-
Vertical FOV goes into the component at comp + 0x128, the only input the RTT projection
has: the producer builds the projection from it and nothing else, and cot(68.238/2) =
1.47593 reproduces the matrix exactly. The RTTI zoom field at +0x15C is never read on this
path, and the zoom ratio at +0x424 is an output of the per-view setup, not an input. That
producer runs every frame, standing still included, so writing the FOV is sufficient -
forcing comp+0xA00 or calling sub_140AC316C drags view-create in, which hitched the game
and hung the GPU. -
View-context scalars - fov +0x90, zoom +0x9C, near +0xB0, far +0xB4 - are mirrored from
MAIN so the second view's LOD and culling, which are screen-space-error driven, match
MAIN's automatically instead of being tuned to match. -
ADS magnification never touches the FOV field: measured, +0x90 reads 68.238 both at rest
and while aiming. It goes through the projection matrix at +0x214, and the factor is
recovered as projYY · tan(fov/2) - 1.0000 at rest, 1.4998 aiming. MAIN's projection already
carries it, so it must not be applied a second time when the second view follows MAIN. -
The eye separation is applied to the component's WORLD POSITION (component + 0xE0), above
the view producer, so culling, shadows, the distant pass and motion vectors all see the eye
they are being drawn for. The older write into +0x100/+0x110 ("posA/posB") is off by
default: measured, it moved the rendered viewpoint by 23 micrometres - that is, not at all. -
The camera write site is a compare-exchange plus a seqlock, because the writes arrive on
job-worker threads; a counter tracks how often the thread changes, so the assumption is
checked rather than assumed.
2.6 Getting the picture out of the engine
The true VRCAM final colour is written by RenderFinal2D (sub_140209FF0). Not by CopyToTexture,
which runs earlier in build order and whose target is the pre-final black frame - that one fact
cost a round of "the capture is black regardless of copy state".
hk_CreateRenderTargetView -> record descriptor handle -> resource, in two tables
(a dims-filtered candidate list, and a broad map with
no filter)
node dispatch hook -> t_active_view_key = ctx+0x28, per recording thread
hk_OMSetRenderTargets -> inside the ctx-keyed vrcam RenderFinal2D node, resolve the
bound handle and redirect it to a committed target WE own
node epilogue -> publish that output, and record a copy into g_stable_tex on
the ENGINE'S OWN command list, while the final write is
still the last thing recorded on it and no later pass has
aliased it
Recording the copy into the engine's own list is what makes the picture correct without a
fence: by the time Present runs, the copy is already ordered behind the final write and the
resource rests in COMMON. Our target is committed rather than a transient from the frame-graph
heap, so nothing can alias it - the redirect exists precisely because the engine's own output
rests in a state that varies frame to frame.
The submit reads that snapshot through a liveness clock, not an existence test: the accessor
returns null once the second view has gone quiet for StereoEyeMaxAgeMs. One eye frozen while
the other moves is far worse to look at than mono, so menus, loads, and a disabled VRCAM
component all fall back to the mono path byte for byte - which is also what makes real stereo
safe as a default.
2.7 Submitting two eyes
The OpenXR side publishes one frame in a single locked step - image, serial, both poses, both
FOVs - so the pose and the picture cannot drift apart by construction, and re-submitting a
stale frame reuses its own pose rather than the current one. The second eye is stamped with
the same serial as the colour it belongs to, so a frame where the VRCAM blit did not happen
falls back to MAIN in that eye instead of pairing two images from different instants.
Format matters here: RenderFinal2D's output is R11G11B10_FLOAT holding linear values, while
MAIN's backbuffer holds sRGB-encoded R8G8B8A8. Eye 1 needs an encode pass, not a raw copy.
2.8 render/color_blit.cpp - the four passes that reach the second eye
The overlay draws into the backbuffer and therefore cannot reach eye 1 at all. Everything the
second eye needs is a D3D12 pass of ours:
RecordBlit encode + scale VRCAM's linear output into the eye swapchain's sRGB
format
RecordOverlay composite a separate surface (the scanner outline) with straight or
premultiplied alpha
RecordHudComposite the engine's own HUD composite, ported - see 2.10
RecordDot the barrel dot, a filled disc at a point in NDC, rasterising only its
own bounding box
pixelExact matters and is not cosmetic: the second eye's image is the top 2444 rows of a
2444x2560 render, so an overlay stretched across the target lands 4.7% too high at the bottom
of the frame. Mapping source texel (x,y) onto destination texel (x,y) is the correct operation
whenever the two differ in size for a reason other than resolution.
2.9 Which pose, aimed at which instant
The XR loop is paced by xrWaitFrame at the headset rate on its own thread while the game
presents at its own, so the offset between "the cycle that located this pose" and "the cycle
that shows the frame built from it" drifts continuously. There is no constant to pick, so there
are three modes and the default is the measured one:
0 = this cycle's predictedDisplayTime
1 = predictedDisplayTime + period · scale (a constant offset, the UEVR shape)
2 = a rolling least-squares fit of display time against frame serial, and locate at that
Mode 2 falls back to mode 1's arithmetic until the fit has samples, and reports its slope so a
fit being fed garbage is visible rather than silent.
Render-ahead depth is 0, and that is not a preference. The exact path matches
m_framePoseSerial == S, where that serial is (the interval the write happened in) + 1 - so it
returns the write made during interval S-1. A ring lookup with lag L returns the newest entry
stamped <= S-1-L. The two agree only at L = 0. With L = 1 the ring handed back a pose a whole
interval older, so the frames that hit the exact path and the frames that fell through were
labelled a frame apart from each other; every fallback frame submitted an image with a stale
orientation, the compositor re-warped it by a rotation already baked in, and that is judder
that only shows on head turns. Measured live: PoseExact 29570 against PoseEstimated 6363.
The frame-ahead count is one integer applied in both places that must agree - the serial the
pose slot is published under and the serial the camera's locate is aimed at - because REDengine
has its own render thread and the frame presented at N may have been simulated with the camera
written two intervals back. The counters distinguish a pose read back from the write site
(correct) from the frame loop's own locate (a different sample, right only when the written one
is missing).
2.10 The HUD in the second eye
The engine composites a HUD for MAIN only. Established from two Nsight captures rather than
from theory - REDengine names its command lists after the frame-graph node
("[addr Default] PostFX (uid: 113311)"), so an EventList export maps every draw to a pass for
free. The HUD draws live in the PostFX list, outside the DrawHUD node's dynamic extent, which
is why a node-scoped OMSetRenderTargets probe sees nothing and why the first conclusion -
"there is no separable HUD surface" - was wrong.
Per frame, at output resolution:
-
A dedicated HUD surface - RGBA8_UNORM_SRGB, 5 mips, ALLOW_RENDER_TARGET - is discarded,
cleared transparent, then filled by ~33 ink quads blended ONE / INV_SRC_ALPHA, i.e.
premultiplied alpha. Across all 23209 resources in the capture, exactly one is an RGBA8
render target with MipLevels != 1. That is the entire identification signature. -
Mips 1..4 are rendered - the glow source.
-
A separate half-res 4-mip pyramid is built - the wide halo, and it is HUD, not scene
bloom: the shadow term lerps its own alpha against the HUD's, and scene colour has no
meaningful alpha. -
Two indirect compute dispatches write the final colour. PipelineState_576 is the composite
- not the single DrawInstanced(3) in RenderFinal2D, which is only the display encode and
reads one texture. 576 is ported here shader for shader:
- not the single DrawInstanced(3) in RenderFinal2D, which is only the display encode and
out = hud·2·scan
+ (glow·(1-luma(scene)·0.7)·gain·expo + scene) · (1-mean_alpha) · shadow
+ halo
The x2 is because the HUD is stored at half intensity. Its constants are found by
fingerprinting the engine's upload ring: a 256-byte-aligned block qualifies only if register
16.zw is exactly the HUD surface size and the rest is in range for what it claims to be -
curvature small, glow weights and saturation in [0,1], aberration tiny. Nothing else in the
engine's constant traffic satisfies all of that at once. Fingerprinting is necessary because
the CBV points straight into the ring: the constants are never copied anywhere a
CopyBufferRegion hook could see them, and watching that hook was looking in the wrong place
entirely. They are also optional - the shader validates whatever is bound at b6 and uses its
own captured values when the block does not look real, which is why the HUD no longer takes
minutes to appear.
The HUD is placed at a distance rather than at optical infinity. Pasting MAIN's surface into
the second eye at the same pixel coordinates gives it zero disparity, and zero disparity is
infinity: look at the world an arm's length away and every icon splits by the full vergence
angle. Only this eye can move, so the whole disparity goes here, which also slides the HUD's
apparent centre by half of it - about 0.4 degrees at the default distance, well under what
reads as off-centre.
Identification is by frame-graph node, with descriptor matching as the fallback, because the
descriptor test alone cannot tell the HUD from the inventory's character portrait - both are
mip-chained RGBA8 render targets. A liveness hold protects the producing surface from being
displaced by a newcomer that has not yet produced anything, and is self-correcting: a wrong
surface produces nothing, the hold lapses, the next candidate gets its turn.
2.11 Everything else the two views fight over
A second view running the same graph exposes every place the engine keeps one copy of something
per frame and assumes one consumer. Each of these was found as a visible artefact and fixed by
mirroring MAIN's state onto the second view at the right point:
-
Atmosphere. The RTT view gets its own, cheaper block; MAIN's is mirrored across in
bisectable pieces, and some fields must never be copied. -
Volumetric clouds. Only the primary view's wind offsets advance, so six floats are mirrored
per frame; without it the two eyes show the same clouds at different times. -
Sky. Amortised across frames, and the two views were fighting over whose turn it was.
-
Colour grading. The second eye is given the first eye's grading source, after establishing
which constant buffers the LUT build actually reads and what its 688-byte upload contains. -
Auto-exposure, distant fog, the light-cull tile grid (does it fill for VRCAM at all, and how
many lights does each view's cull output), the occlusion gate, GI reuse (GiReuseMode must
stay 1), and the light-volume pass, which is lent MAIN's draw-block list. -
Night lighting. Rain and puddles came back through a hole in the view data; street lamps
through the RenderMask DistantLights grant. -
The scanner outline is written by RenderVisionElements by compute, and is overlaid
pixel-exact with straight-alpha blend on both the mirror and the headset composite paths,
with a guard against leaking a stale snapshot. -
DLSS gets its own Streamline viewport per view, decided automatically from MAIN's own
upscaler groups rather than from a setting - a switch there could only ever disagree with
the engine - plus a D3D12-level fix for a vrcam-only post-DLSS crop. -
The barrel dot is projected once by the overlay and published in NDC, so both eyes draw an
identical mark by construction rather than by two projections agreeing.
2.12 Measuring the cost
A per-node CPU profiler accumulates rdtsc cycles and call counts inside each work function, per
view, so a node that dispatches but internally early-outs shows near-zero average cycles rather
than looking like work. node_names.inc is generated from engine_re/dumps/nodes/nodes_index.md
and sorted by RVA for binary search, so the audit reads in node names rather than addresses.
docs/vrcam_node_audit_v2.md is the resulting VRCAM-vs-MAIN table, with the rule that only the
self column may be ranked or summed - SceneDrv_ALL_SCENE_PASSES contains every scene pass, so
inclusive columns overlap. mods/config/vrcam_cpu_tweaks.ini ships the settings that came out of
it.
============================================================================
3. GEOMETRY, WHICH IS MOST OF WHAT A VR PORT IS
Submit the frustum that was rendered, on both axes. A projection view is a promise that this
rectangle contains exactly this frustum. The engine renders symmetric about the camera axis on
both axes, so the honest submit is symmetric on both; passing the runtime's raw asymmetric
vertical through instead was a 1.8% lie, and the compositor squashes the difference.
it keeps the runtime's asymmetric numbers and crops the rectangle to match. Submitting the
rendered frustum is the same result
with no crop: the extra degree of vertical simply falls outside the panel.
The engine's FOV field is VERTICAL. Measured in x64dbg on the live MAIN view context, found
through the node dispatcher at ctx+0x28 == 0: write 94.0 and read back tan(V/2) = 1.072369 -
exactly tan 47, i.e. it took the number as the vertical - and tan(H/2) = tan(V/2) · 2064/2208.
The horizontal is derived from the render aspect and never set:
tan(H/2) = tan(V/2) · renderWidth / renderHeight
Rendered H was 90.14 while the submit claimed 94, and the compositor stretches what it is
handed to fill what it was promised - the "world too big" symptom. There are two globals now
and keeping them distinct is the whole fix: the vertical that the engine's field receives (also
mirrored to +0x21E0 by the unifix hook), and the derived horizontal that the submit uses. Three
equalities define correct, all readable from the log plus the probe summary, and all three hold
now:
rendered H == submitted H
rendered V == submitted V
rect aspect == frustum tangent aspect
The launcher's resolution ladders follow FOV, not the panel. The old ladder was the panel
aspect - 2064x2208 is the Quest 3's physical per-eye panel and every other entry copied its
~0.936 - and that is the wrong shape to render at, because the aspect of the render target is
the lever on the H/V ratio. For a Quest 3 the target comes out of the runtime's own numbers:
horizontal de-canted from -54/+40 to a symmetric +-47 (tan 1.072369), vertical symmetrised by
the larger half-tangent of U+50/D-49 (tan 1.191754), ideal AR = 0.8998. Pimax Dream Air is a
preset alongside it.
Depth planes are the game's real ones - near 16000, far 0.02, reversed-Z.
Menus are mono in the headset. A menu is one surface drawn into the backbuffer; giving one eye
the menu and the other a frozen street means the street is what reads.
Canted headsets. With byte-identical submissions PimaxXR is clean and SteamVR is not - it lays
our symmetric image into its asymmetric panel window. The Witcher-3 mod's recipe (submit the
runtime's true asymmetric FOV and shift the copy to the optical centre, -tanL/(tanR-tanL),
about +-211 px at 4.768 degrees of cant, needing ~2926 px of render width) is documented as the
eventual fix and is not done. PimaxXR is the answer today.
Flat-content convergence appears twice in this history and both times it was the regression.
The switch survives, defaulted off, with the reason written beside it: "both eyes share one
image" is true of the intro logo and of gameplay whenever the second view has not produced a
frame, and there is no signal at that point that tells the two apart. The first version also
rotated one eye and not the other - caught by our own probe as 1.130 degrees on the right eye
alone.
============================================================================
4. FOUR FIXED-SIZE TABLES THAT STOPPED WORKING IN SILENCE
The recurring defect of this codebase. Found four times in one week, every one of them only by
measuring something far downstream:
upload heap map was 8 slots, shared with refusals
composite constants never found at all, for a whole session
vrcam output list was 8 slots
targets used with no reference held - a raw pointer into freeable
memory
RTV candidate table was 512, blind wrapping cursor
the vrcam output's entry evicted -> second eye goes mono after a menu
RTV descriptor map was 2048, silent stop
the HUD node's binds unresolvable after a graph rebuild -> HUD lost
for the session
All four announce saturation now, and none of them stop accepting. Measured fill reached 6028
of 8192 descriptors in one ordinary session, so 2048 was never going to be enough. Published
vrcam outputs are pinned against eviction - which the comment on that table had always claimed
and the code had never done. The descriptor map's handle field is atomic so publication is
ordered rather than hoped for, because readers deliberately do not take the mutex: it is
consulted on every OMSetRenderTargets.
The method matters as much as the fix: a watchdog placed inside a function cannot report the
case where the function stops being called. That mistake cost three wrong diagnoses in a row.
What worked was a counter on every exit of the function and the tally printed at the point the
user-visible decision is made - [stereo-eye] and [hud] composite waiting on. Both name the
failing branch on the first try now.
============================================================================
5. HANDS, VRIK AND THE SMOKING SYSTEM
5.1 The finger-hold grip, without a workspot
VRIK owns the arm and the wrist and never touches finger bones, and the pose buffer holds
parent-local transforms. So a finger-local rotation captured once and replayed every pose pass
curls the fingers relative to a controller-driven wrist - a native-looking grip with no
full-body workspot and no new animation. The curl is captured live from the vanilla
hold-cigarette workspot (played once through AMM): VRSmokeCaptureFingers() latches the current
finger locals, SetVRSmokeFingers(1) replays them.
The cigarette rides the WeaponRight bone (28, child of RightHand), captured and applied with a
full local transform rather than rotation only, with a live nudge so the pose can be tuned in
VR and then baked into CyberpunkVR_SmokeGrip_right.ini. The left hand mirrors all of it for the
lighter, with a thumb flick scaled by the left trigger, and a separate left-hand cigarette pose
selected by a flag so the cig can be taken from the mouth into the left hand.
Hands-free is a mouth anchor: the cig's bone is pinned to a head-anchored point so the arm can
drop, with a general variant that pins an arbitrary non-hand bone (Neck1/Head/Neck) for props
attached to a non-weapon slot, leaving both weapon slots free. Exhale smoke gets its own
HMD-local pose, composed with the real head pose.
5.2 The redscript side
vrport_smoking.reds spawns the vanilla prop item-entities - Items.cigarette_i_stick and
Items.apparel_lighter_a, both already canDrop/savable = false and already carrying
WeaponLeft/Right placement slots - drives their existing EffectSpawner FX (ember, smoke, ash,
ignition), plays the stock Wwise events and runs the hands-free auto-puff. No new meshes, no
new FX, no TweakXL or ArchiveXL entries.
5.3 A pose-coherence fix worth naming
VRIK now prefers the head delta that the plugin built from the same sample as the hand offsets,
inside the hands seqlock, rather than the render packet's delta - measured 21 ms against 33 ms.
A hand reconstructed from two different instants lands in the wrong world place by exactly the
head motion between them. It falls back to the packet when that channel is absent.
5.4 Input
The left trigger and grip got real named slots (154/155). They previously read [67]/[68] on the
strength of a map comment calling them free - they are not: [67] carries a millisecond stamp and
[68] a QPC timestamp, both far above any threshold, so the lighter read as permanently at full
trigger and the left grip as permanently held. The left trigger no longer eats the vehicle brake
either: the gate is "on foot with empty hands", not "no weapon equipped", because no weapon is
equipped while driving.
============================================================================
6. THE SIGHT SHADER
A collimated reflex-sight shader replaces the stock one (identified live as PS hash
66394C5F4B95AB9A, confirmed by removal - dropping that draw removes the reticle and nothing
else).
The stock shader samples the reticle atlas four times: once at the quad's own uv and three
displaced along the view direction. The crisp layer is the one with zero displacement - painted
on the glass, not moving with the eye. On a flat screen the eye is always on the sight's axis
so nothing gives it away; in VR you look at the glass from the side and the dot follows you,
which a collimated sight never does.
The replacement is an angular lookup:
uv = 0.5 + dist · (dot(V,T), dot(V,B)) / glassSizePerUv
with V the direction from eye to pixel. The scale is derived, not tuned: the vertex shader
measures the glass from the mesh's own bounding box and passes it down, which also removed
ddx/ddy from the coordinate - the two earlier attempts derived the scale from screen-space
derivatives, and DLSS's per-frame jitter moved the 2x2 derivative quads, so the reticle's texels
visibly ran back and forth.
The optical axis is the window mesh's thin bounding-box axis (the slab is 25.6 x 0.2 x 26.5 mm,
so the thin axis is the one you look along by construction), carried into the world by the
instance transform and resolved into the glass's tangent frame. The pixel shader previously
collimated along the interpolated vertex normal, which carries the mesh's authored 0.43 degree
tilt - 15 cm sideways at 20 m, and exactly the "dot sits slightly right of the barrel" that was
reported.
SIGHT_RETICLE_DISTANCE blends exactly (k = D/(d+D)), so 0 reproduces the stock painted-on
reticle and a large value is full collimation.
============================================================================
7. LOGGING
One switch - the launcher's DEBUG box - for the whole port. It already gated the probes; it now
also gates the routine per-frame chatter: in the plugin through LOG_THROTTLED (a per-site timer,
with the count of swallowed calls available to the message so a storm still reads as a storm),
and on the Lua side through shared slot 156, republished every frame so a bridge can be silenced
live rather than by editing a file.
A session with DEBUG off used to leave 4512 plugin lines - 1877 of them one repeating message -
and 14 MB across the CET bridges, 26 449 lines of that from a single per-frame state print. What
is never throttled: the first occurrence, a state change, and anything reporting a failure.