docs: single-pass stereo design and milestone plan

This commit is contained in:
simplysanatori committed 2026-10-28 01:56:07 -04:00
1 parent 8c5332bcdc
commit 3104624571
2 files changed
+716

No files matched your search

+539
View File
@@ -0,0 +1,539 @@
# LegendFrame: single-pass stereo for TRLVR on the Steam Frame (design)
Status: design, 2026-10-28. Target source: RototRobot TRLVR **1.1.0** (commit e3657d0),
read at `~/Games/LegendFrame/src` (branch `steam-frame`, = e3657d0 + scaffolding aabf06d).
All `src/...` citations are 1.1.0 paths in that tree; `dxvk-trlvr/...` is the DXVK fork in
the same tree. Nothing here was built or run. Things I could not verify are marked
**UNVERIFIED**, with the probe that settles them.
## 0. Summary
- Measured (owner sessions 3 and 5, logs in `~/Games/TRLVR/logs`): two-pass stereo runs at
about 40 fps with "GPU scene" (SteamVR `m_flPreSubmitGpuMs`) at 17 ms. Alternate-eye runs at
68 fps with GPU scene at 5 ms. The `perf cpu:` lines show where the time goes: proxy draw
calls take **5.9-10.5 ms per frame in two-pass, and 2.9-5.2 ms of that is the mod's per-eye
matrix maths**, against **0.5-1.6 ms and 0.0 ms in alternate-eye**. The submit wait
(TransferSurface waiting for the GPU) is 6-8 ms. The time goes into the per-draw
submission chain, which runs once per eye, and into the mod's own per-eye maths. The GPU
itself is not the limit.
- **Chosen architecture: SPS-VI (single-pass stereo by viewport index).** Each scene draw
from the game becomes **one** D3D9 draw in DXVK and **one** `vkCmdDraw*`, with
`instanceCount x2`. A vertex-shader epilogue in DXVK writes
`ViewportIndex = InstanceIndex & 1`. The VS reads `c0..c3` from a per-view bank
(left/right) instead of the API registers. Two viewports cover the two halves of the
**existing double-wide render target**. The images, textures, StretchRect copies, MSAA
resolve, overlays and both submit backends stay exactly as they are.
- Layered multiview (`VK_KHR_multiview`, `viewMask=0b11`, 2-layer RT) was the brief's
suggestion. I **defer** it to an optional M6: §3.2 explains why. In short, the
double-wide image is consumed by about 25 mod overlay passes, the water half-res split, the
reflection twin and screen-copy split, the MSAA resolve and both submit paths. Multiview
would mean rewriting all of them, and it does not save any more of the CPU time we are
actually short of.
- The per-view transform reproduces the current two-pass maths exactly. The proxy already
computes a **complete replacement c0..c3 per eye** (it does not post-multiply clip space),
so we ship both replacements to the GPU as a bank and select on the view. That is
bit-identical to today's two-pass images.
## 1. How two-pass stereo works today (1.1.0)
### 1.1 Surfaces and sizing
- `CreateDevice` asks the runtime for the per-eye size (`vr_render_target_size`,
`src/vr/vr_session.cpp:3639`, now via `xr_recommended_size`). It records the eye size
(`stereo_set_eye_size`, `src/proxy/proxy_d3d9.cpp:238`) and then **doubles the back-buffer
width** (2W x H, same block). `Reset` does the same (`src/proxy/proxy_device.cpp`
~107-151).
- Every RT/depth surface that is exactly one eye in size is widened to 2W
(`stereo_widen`, `src/vr/stereo.cpp:71-81`, called from `CreateTexture` with
RENDERTARGET, `CreateRenderTarget` and `CreateDepthStencilSurface`,
`src/proxy/proxy_device.h:115,120,121`). Shadow maps and half-res post targets are left
alone.
- MSAA is capped by `[vr] msaa` (`msaa_clamp`, `src/proxy/msaa_cap.h`; CreateDevice
`proxy_d3d9.cpp:154`).
- The device is forced to `D3DCREATE_MULTITHREADED` (`proxy_d3d9.cpp:250-258`), so **every**
D3D9 call takes DXVK's device lock. Each extra call per draw therefore also costs a lock
round trip under FEX.
### 1.2 Scene classification
`drawing_the_scene()` (`src/vr/vr_session.cpp:46`) returns true when:
- RT0 is exactly the scene size (2W x H). RT0 size is tracked from `SetRenderTarget`,
`proxy_device.cpp:~214`.
- or the water-reflection pass is active (`reflection_stereo_pass_active`).
- or the PCWaterEffect half-res mask is bound (RT = W x H/2 while
`water_stereo_active()`, hook at 0x0061AF30, `src/vr/water_stereo.cpp:21`).
Shadow maps, render-to-texture and post targets of other sizes are **mono**: one draw with
the game's matrices. Culling is never done per eye. The game's CPU culling runs once per
frame against a widened frustum: `cull_fov`, and in first person the **union of both eye
frusta** plus offsets (`volume_half_angles`, `src/vr/cull.cpp` ~176-215). Single-pass
changes nothing here.
### 1.3 Where the per-eye matrix comes from
- The game uploads its camera in **VS constants c0..c3**. These are either a pure projection
(view-space geometry such as Lara's skinned mesh) or a full world-view-projection.
`ProxyDevice::SetVertexShaderConstantF` (`src/proxy/proxy_device.cpp:177-207`) intercepts
`StartRegister==0 && Count==4`. It records the game's matrix (`vr_note_c0`,
`vr_session.cpp:366`) and uploads a centre/current-eye substitute (`vr_substitute_c0`,
`:777`).
- At draw time, `vr_substitute_c0_for_eye(game_c0, out, eye)` (`vr_session.cpp:846`)
builds a **full replacement c0..c3 for that eye**:
- UI/HUD/movie/loading/screen-wipe branch (`:882` onward): per-eye frusta from the
runtime tangents, world-locked menu anchor, `hud_follow`, world markers with convergence
shear `converge.m[2][0..1] = -offset*s/depth`.
- PDA and Mission Prep pinned mappings (`mission_prep_pinned`, ~`:800`).
- Pure projection (`if (gp.valid)`, `:1258`):
`paused_head * vr_eye_matrix(eye, gp)`, with first-person hand near=4 and board-scale
hands.
- WVP (`:1390-1470`): recover the draw's projection, then
`M * P^-1 * paused_head * vr_eye_matrix(eye, P)`.
- Sky pinning: `sky_pinned_draw` (`:581`) re-centres camera-pinned draws on the eye with
no eye separation, via `translation(delta) * M * P^-1 * ...` (`:1445`).
- `vr_eye_matrix` (`:3794`) is `[g_head_view *] vr_eye_projection(eye, game)`. The eye
frusta and eye-to-head poses now come from the runtime every frame
(`vr_set_eye_views`, `:295`).
- Fixed-function transforms are **not** made per eye. `SetTransform` only records the
projection for the ImGui detector (`proxy_device.cpp` SetTransform). FF draws in the scene
are drawn identically into both halves.
### 1.4 The duplicated calls per scene draw
`dispatch_draw` (`src/proxy/proxy_device.h:194-414`) handles three cases.
1. **Reflection pass** (`:336`): left eye into the game's target, right eye into a twin
target (`reflection_stereo_bind_twin`), with a per-eye c0 each time. This is two draws
into **two different surfaces**.
2. **Scene, `passes == 2`** (`stereo_passes`, `stereo.cpp:83`: same-frame on, scene target,
game c0 known). The loop at `proxy_device.h:375-402` does this for each pass:
- `stereo_begin_pass` (`stereo.cpp:102-137`): `SetViewport` to the eye half
(X = pass*eye_w, Y=0, W=eye_w, H=eye_h, **the game's MinZ/MaxZ kept**; the eye size
comes from `vr_stereo_target_eye_size`, `vr_session.cpp:498`, which also splits the
water half-res target), then `SetVertexShaderConstantF(0, c0_eye, 4)`;
- `hands_in_front` (diorama): another `SetViewport` with MinZ=0, MaxZ=0.02
(`proxy_device.h:383-392`);
- `reflection_stereo_eye_textures(p)` (`:395`): the right eye **swaps SetTexture** to the
reflection twin, and both eyes swap the WaterFX screen copy to their own half
(`src/vr/reflection_stereo.cpp`, eye_textures);
- `issue()`, the real draw.
After the loop it calls `reflection_stereo_restore_textures` and `stereo_end_draw`, which
restore c0 to the uploaded value and the game viewport (`stereo.cpp:139-157`).
Net cost per scene draw: **2 draws + 2-4 SetViewport + 3 SetVertexShaderConstantF
(+ texture swaps)**, each going through FEX into the DXVK frontend under the device lock.
Each also invalidates DXVK's draw batching (`d3d9_device.cpp:3103-3110`), costs 2
constant-buffer uploads and 2 viewport CS commands on the CS thread, and causes 2x
`vkCmdDraw` + state through the wow64 thunks.
3. **Mono** (`:405-413`): one draw. `vr_draw_c0_override` (`vr_session.cpp:376`) may patch
c0 for interface draws in alternate-eye mode.
Subtleties the new path must keep:
- If one eye's substitution fails, c0 keeps the previous value. The other eye's matrix, or
the uploaded one, carries over (`stereo_begin_pass` only uploads on success).
- `sky_pinned_draw` counts **calls**, not draws (its learning window `kSkyLearnCalls=32` is
"one per eye per upload", `vr_session.cpp:544`).
- `binocular_zoom` (`:3811`) updates an EMA **on every call** of `vr_eye_projection`.
### 1.5 Submit (1.1.0)
Once per frame, `vr_submit_frame` (`src/vr/vr_submit.cpp:5115`) runs on the side-by-side
path (`:5145-5195`). It draws about 25 mod overlays straight into the double-wide back
buffer, each with per-eye-half viewports or quads (`draw_comfort_vignette`,
`draw_settings_panel`, `draw_picture_adjust`, ...). It then resolves MSAA
(`resolved_back_buffer`, `:4755`, StretchRect into a 1x copy) and calls
`submit_side_by_side`, which goes to `submit_surfaces` (`:4648`):
- **OpenVR**: `submit_openvr` (`:4611`). `prepare_openvr` calls
`TransferSurface(surface, TRUE)`, which **waits for GPU idle** (`dxvk-trlvr/src/d3d9/d3d9_vr.cpp`
TransferSurface), then `GetVRDesc`. `ovr_submit` (`src/vr/ovr_runtime.cpp:595-640`)
submits the same VkImage twice with bounds (0..0.5 / 0.5..1).
- **OpenXR**: one swapchain image 2W x H (`oxr_swapchain_create`, `src/vr/xr_runtime.cpp:1019`,
`arraySize = 1` at `:1070`). `CopySurfaceToImage` (new in the 1.1.0 DXVK fork) copies the
frame in through DXVK's context and waits when `xr_copy_wait=1`. `xrEndFrame` gets a
projection layer with two `subImage` rects (`imageArrayIndex = 0`, `:1169`).
## 2. DXVK fork (dxvk-trlvr) facts
- Version: `meson.build` declares **3.1.0** and the Frame log prints `DXVK: 3.1.0`. The tree
has the **DXVK 3.x architecture**:
- D3D9 shaders are compiled by **dxbc-spirv** (`subprojects/dxbc-spirv/sm3/sm3_converter.cpp`
into an IR, then lowered by `D3D9ShaderLowerLegacyInputPass` in
`src/d3d9/d3d9_shader.cpp:13-1090`). There is **no `dxso_compiler.cpp`** any more.
- The FF VS is a prebuilt GLSL ubershader (`src/d3d9/shaders/d3d9_fixed_function_vert.vert`,
wrapped in `d3d9_fixed_function.cpp:25-62`).
- Push data is split into fixed blocks (`D3D9SharedPushData`/`D3D9VsPushData`/
`D3D9FfvsPushData`, `d3d9_state.h:37-88`; limits 256 B total, **40 B per stage**,
`src/dxvk/dxvk_limits.h:22-25`).
- Viewport count is dynamic (`VK_DYNAMIC_STATE_VIEWPORT_WITH_COUNT`, `dxvk_graphics.cpp:777`,
`DxvkContext::setViewports(n, ...)` `dxvk_context.cpp:2789`).
**UNVERIFIED**: an exact match against an upstream v3.1.0 tag. That needs a diff against
upstream git, which was out of scope offline. Do it in M1 (`git diff v3.1.0 --
dxvk-trlvr`).
- Fork modifications found (grep for TRL markers, plus the 1.0.0 to 1.1.0 diff):
- `src/d3d9/d3d9_vr.h/.cpp`: `IDirect3DVR9`. Its **IID changed in 1.1.0**
(`b1e5c0a4-...`), so the proxy and DXVK must ship as a pair. Methods: GetVRDesc,
TransferSurface, Lock/UnlockDevice, Lock/UnlockSubmissionQueue, WaitDeviceIdle, plus
(1.1.0) GetDeviceDesc, CopySurfaceToImage and ReleaseExternalImages. 1.1.0 also fixed
`GetVRDesc`'s QueueFamilyIndex (it was the queue index) and the COM refcount.
- `src/d3d9/d3d9.def`: export `Direct3DCreateVR9 @39`.
- `src/d3d9/d3d9_device.h:231`: `friend class D3D9VR`.
- `src/util/config/config.cpp:667`: a `trl_vr.exe` profile (`cachedWriteOnlyBuffers`).
- `src/dxvk/dxvk_openxr.cpp`: Vulkan extension lists taken from the env vars
`TRLVR_XR_VK_{INSTANCE,DEVICE}_EXTENSIONS`. The proxy fills them for OpenXR
(`xr_runtime.cpp:774`) **and for OpenVR** (`ovr_runtime.cpp:123-184`, from
`GetVulkan*ExtensionsRequired`).
- **Removed in 1.1.0**: `src/dxvk/dxvk_openvr.cpp/.h` (DXVK's own OpenVR extension
provider) and `include/openvr/`.
- Features already enabled on the Frame, from the shipped DLL's log
(`~/Games/TRLVR/logs/trl_vr_d3d9.log`, Turnip Mesa 26.2.99): `multiViewport 1`,
`shaderOutputViewportIndex 1`, `shaderOutputLayer 1`, `multiDrawIndirect 1`,
`drawIndirectFirstInstance 1`. `vk11.multiview` is **not** requested by DXVK at all
(`dxvk_device_info.cpp` lists no `multiview`). The host Turnip binary
(`/usr/lib/libvulkan_freedreno.so`, deckard-mesa 26.3.0_devel) contains the strings
`VK_KHR_multiview`, `VK_QCOM_multiview_per_view_viewports`/`render_areas`,
`VK_EXT_shader_viewport_index_layer` and `VK_EXT_vertex_attribute_divisor`. String
presence does not prove exposure, but the log lines do for the VI features.
`~/Games/LegendFrame/build-m1/vulkan-caps.txt` did **not** exist when I wrote this.
Relevant DXVK paths for the design:
- Draws: `DrawPrimitive` `d3d9_device.cpp:3023` (instanceCount 1 at `:3049`),
`DrawIndexedPrimitive` `:3072` (`GetInstanceCount()` `:3099`, `:7360`), `DrawPrimitiveUP`
`:3135`, `DrawIndexedPrimitiveUP` `:3185`. Consecutive draws are batched into one
multi-draw when no state changes in between.
- `PrepareDraw` `:7366` calls `BindViewportAndScissor` (`:6836`, one viewport,
`ctx->setViewports(1,...)` `:6900`) and `UpdateShaderConstants<VS>` (`:6001`; the static
CBV is copied by the layout, `layout->copyConstantData` `:6074`). It also handles push data
(`UpdatePushData` `:6678`).
- VS constant lowering: `setupStaticCbv` (`d3d9_shader.cpp:266`) and `lowerLegacyConstInput`
(`:346`). The static path maps `cN` to a struct member through the compacted layout. The
dynamic path (relative addressing) loads from `VSDynamicConstants`, clamped by
`vs.floatCount`.
- Shader cache: `D3D9CommonShader` calls `createCachedShader(ShaderKey.toString(), ...)`
(`d3d9_shader.cpp:1188-1196`). Converted IR is cached by **key**, so any change to the
lowering must change the key.
- SPIR-V builtins: `eViewportIndex` maps to `ViewportIndex` plus `MultiViewport` and
`ShaderViewportIndex` caps (`subprojects/dxbc-spirv/spirv/spirv_builder.cpp:975`).
`eInstanceId` maps to `InstanceIndex - BaseInstance` (`:946`). **There is no `eViewIndex`
builtin in the IR** (`ir/ir.h:641-760`), so multiview would need dxbc-spirv changes.
## 3. Chosen architecture
### 3.1 SPS-VI: what happens for one scene draw
1. The proxy decides the draw is a stereo scene draw (same predicate as `stereo_passes()==2`)
and that it is single-pass eligible (§5.2).
2. The proxy computes `c0_L` and `c0_R` with the **existing**
`vr_substitute_c0_for_eye(game_c0, ., EyeLeft/EyeRight)`. It applies the same failure
carry-over rule as `stereo_begin_pass` and calls the functions in the same order (L then
R), so the side effects (sky learning, zoom EMA) match.
3. The proxy calls `IDirect3DVRStereo9::SetStereoDraw(c0_L, c0_R, eye_w, eye_h)`. This is
sticky: the proxy skips the call when both matrices and the size are unchanged since the
last call.
4. The proxy calls `issue()` **once**.
5. In DXVK, when stereo is armed, `PrepareDraw` does three things:
- binds **2 viewports**. Each is the D3D9 viewport rule applied to the eye rect
`{eye*eye_w, 0, eye_w, eye_h}` with the game's MinZ/MaxZ, including DXVK's 0.5 offset
and the scissor-rect intersection per viewport. This is exactly what two
`SetViewport` calls produce today.
- sets the VS push flag `stereo=1`.
- appends the 8-vec4 bank to the VS static CBV upload.
The draw is emitted with `instanceCount = 2*n`.
6. VS epilogue: `view = stereo ? (InstanceId & 1) : 0`, `ViewportIndex = view`. Every read of
c0..c3 becomes `stereo ? bank[view*4 + i] : c[i]`. The API c0..c3 state is never touched,
so nothing has to be restored (two-pass needed `stereo_end_draw` for that).
7. Mono draws (anything not armed): one viewport and `stereo=0`. Shader behaviour is
identical to today, and they cost one extra select per c0 read.
Result per scene draw: 1 proxy-to-DXVK draw call plus at most 1 small private call, instead
of about 8 calls. One constant upload instead of 2. One `vkCmdDrawIndexed` instead of 2.
Draw batching works again for runs of draws with the same state.
GPU work is the same as today: the VS runs twice and the same pixels are shaded. The GPU was
never the limit. Turnip's binning with a VS-written ViewportIndex is **UNVERIFIED** for
cost, so it is probe P3.
### 3.2 Why not layered multiview first
Multiview would make the scene RT a 2-layer W x H image. Everything that today treats the
scene as one 2W-wide 2D image would then need new code paths:
- about 25 `draw_*` overlays in `vr_submit.cpp`, which draw per-eye halves by viewport, quad
or scissor;
- the water half-res split (`vr_stereo_target_eye_size`);
- the WaterFX screen-copy split (`reflection_stereo_note_copy`, StretchRect of halves);
- the reflection twin;
- the MSAA resolve (`resolved_back_buffer`);
- `TransferSurface` and `GetVRDesc` (OpenVR bounds);
- `CopySurfaceToImage` (OpenXR);
- the game's own post passes that sample the scene texture as a `sampler2D`.
In DXVK it would also need:
- 2-layer image creation for "stereo-widened" surfaces, `2D_ARRAY` views and RT binding
with a layer count;
- `viewMask` in rendering info and in the pipeline key (3.x code not inspected in detail:
**UNVERIFIED** where);
- per-layer StretchRect and resolve;
- a new `eViewIndex` IR builtin and `MultiView` capability in dxbc-spirv;
- enabling `vk11.multiview`.
The CPU saving is the same as SPS-VI: one frontend draw and one vkCmdDraw. The only extra
win would be on the GPU (Turnip hardware multiview / multi-position VS), and the GPU is not
the bottleneck. So multiview costs much more effort and risk for no gain on the measured
problem. It stays as an M6 option, and the interface below is shaped so that "view index
source" can later switch from `InstanceId&1` to `ViewIndex`.
### 3.3 Per-view transform: bank versus clip-space post-transform
The brief's `clip_eye = (P_e V_e V_m^-1 P_m^-1) * clip_mono` is mathematically fine. It is
not what the mod does, though, and it would not reproduce the same images:
- several UI branches build matrices that are **not** `X * c0_mono`;
- sky draws change the world translation;
- first-person hands change the near plane;
- it needs an invertible c0 (UI/ortho c0 are not);
- it adds float round-off on the right eye.
The bank carries the proxy's own per-eye results, so it is exact by construction. It also
covers shaders that use c0..c3 rows for something other than position. Those shaders are
treated identically today, because two-pass rewrites the same registers. FF draws get no
bank (FF ignores c0) and fall back to two-pass (§5.2). That matches today's behaviour
exactly with no FF shader work.
## 4. Proxy <-> DXVK private interface
A **new** COM interface that `QueryInterface` returns from the existing `IDirect3DVR9` object.
A DXVK that does not implement it returns `E_NOINTERFACE`, and the proxy then keeps two-pass.
That way a 1.1.0 stock DXVK paired with the new proxy still works.
```cpp
// dxvk-trlvr/src/d3d9/d3d9_vr.h (addition)
struct D3D9_VR_STEREO_CAPS { UINT Version; BOOL ViewportIndex; BOOL Multiview; UINT MaxBankVec4; };
MIDL_INTERFACE("<new uuid>") IDirect3DVRStereo9 : public IUnknown {
// Before the game creates any shader: compiles every VS with the stereo epilogue
// (key-suffixed). Returns D3DERR_INVALIDCALL if VS were already created.
virtual HRESULT STDMETHODCALLTYPE EnableSinglePassStereo(UINT Mode /*1=viewport index*/,
D3D9_VR_STEREO_CAPS* pCaps) = 0;
// Arm stereo for following draws (sticky) with per-view replacements for VS c0..c3
// (D3D register layout, 16 floats each) and the eye rect size in pixels of the current RT.
virtual HRESULT STDMETHODCALLTYPE SetStereoDraw(const float* pC0Left, const float* pC0Right,
UINT EyeWidth, UINT EyeHeight) = 0;
virtual HRESULT STDMETHODCALLTYPE ClearStereoDraw() = 0; // back to mono
virtual HRESULT STDMETHODCALLTYPE GetStereoStats(UINT* pStereoDraws, UINT* pRejected) = 0;
};
```
- **Lock and threading**: same rules as the other `IDirect3DVR9` methods
(`D3D9DeviceLock`). These are called on the game's render thread between D3D9 calls.
- **Stereo target**: no image is designated. "Stereo" is a draw property, not an image
property, because the proxy already knows which RT is the scene (`drawing_the_scene`). The
eye rect is passed explicitly. That covers both the 2W x H scene and the W x H/2 water
mask, where the eye is (W/2) x (H/2).
- **MSAA**: nothing changes. The scene stays one 2W multisampled image, and
`resolved_back_buffer` resolves it with one StretchRect. The two viewports never overlap,
so per-eye coverage is unaffected.
- **State blocks / Reset**: `Reset` clears the armed state. `SetStereoDraw` is not recorded
by state blocks (the game uses PUREDEVICE).
## 5. Changes file by file
### 5.1 DXVK (`dxvk-trlvr/`)
- `src/d3d9/d3d9_vr.h/.cpp`: add `IDirect3DVRStereo9` (implemented on `D3D9VR`, reachable
through QueryInterface). Forward to new `D3D9DeviceEx` members: `m_stereo.enabled`,
`armed`, `eyeW/H`, `bank[32]` floats, and counters.
- `src/d3d9/d3d9_state.h`: new `D3D9StereoPushData { uint32_t flags; }`, VS stage, at
`Offset = sizeof(D3D9VsPushData)+sizeof(D3D9FfvsPushData)` (20). It fits the 40 B VS block
and moves no existing offset, so the FF GLSL push layout is untouched. Add a
`PushDataStereo` dirty flag and handle it in `UpdatePushData` (`d3d9_device.cpp:6678`).
- `src/d3d9/d3d9_shader.h`: `D3D9ShaderOptions.stereoMode` (fix the `static_assert` at
`:39`). It must be folded into `ShaderKey` before `createCachedShader`
(`d3d9_shader.cpp:1188`), for example a `"_sps1"` suffix, so cached mono IR is never
reused.
- `src/d3d9/d3d9_shader.cpp` (`D3D9ShaderLowerLegacyInputPass`, VS only, when `stereoMode`):
- declare a push-data member for the stereo flags next to `setupVsPushData` (`:247`);
- in `setupStaticCbv` (`:266`), append **8 vec4 members** ("vrBank") after the existing
members (struct case), or 8 more `uvec4` elements (dword-array case). Record their
member index. Make sure there is always a static CBV when stereo is on, even if
`totalCount` was 0;
- in `lowerLegacyConstInput` (`:346`), float + static path: for `srcIndex < 4` rewrite the
load to `Select(stereoFlag, BufferLoad(bank, view*4+srcIndex), original)`. Float +
dynamic path: if the clamped index is < 4 and stereo, select the bank element the same
way. `view` is recomputed at each use from `InputLoad(eInstanceId) & 1`, because uses can
sit in SM3 subroutines (other IR functions);
- entry-point prologue: declare `DclOutputBuiltIn(u32, eViewportIndex)` and store `view`.
**UNVERIFIED**: whether the sm3 converter's output lowering tolerates an extra builtin
output. Check with `spirv-dis` of a dumped shader (`DXVK_SHADER_DUMP_PATH`) in M2.
- `src/d3d9/d3d9_device.cpp`:
- `UpdateShaderConstants<VS>` (`:6001`): when the stereo bank is dirty, mark the VS
constants dirty. After `copyConstantData` (`:6074`), memcpy 128 B of bank to the bank
offset (static size grows by 128 B for stereo-compiled shaders).
- `BindViewportAndScissor` (`:6836`): when armed, build 2 `DxvkViewport` from the eye
rects (same cf/zBias/scissor code, factored into a helper) and call
`setViewports(2, ...)`. Arm/disarm sets `ViewportScissor` dirty.
- Draw entry points: when armed, `instanceCount = 2` for non-instanced draws (`:3049`,
`:3099`, `:3170`, `:3236`, `:3361`). If armed and `m_vbSlotTracking.instanced`, keep the
single instance, count a rejection and log once. The proxy avoids this case anyway.
- `Reset`/`ResetState`: disarm.
- `src/d3d9/d3d9_fixed_function.cpp`, `shaders/*.vert`: **no change** in M2 (FF falls
back). Optional M5: FF epilogue in GLSL with the same push flag.
- `src/dxvk/*`: **no change** for SPS-VI. The backend already supports n viewports, dynamic
viewport count, and VS ViewportIndex with the features enabled.
- Queries: occlusion queries spanning a stereo draw count both eyes' samples, which is what
two-pass does (both passes sit inside the query). Clears are not duplicated today and stay
single (they clear the game's 2W viewport). StretchRect, resolve, the swapchain and the
mirror window present are untouched.
- `dxvk.conf` kill switch: `d3d9.trlvrSinglePass = False` makes `EnableSinglePassStereo`
fail (the proxy logs it and uses two-pass).
### 5.2 Proxy (`src/`)
- `src/common/config.h/.cpp`: `[vr] stereo_mode = twopass | singlepass` (default `twopass`
until M5 sign-off, then `singlepass`), `[vr] stereo_stats = 0|1`. `stereo_same_frame=0`
keeps meaning alternate-eye and overrides both.
- `src/proxy/proxy_d3d9.cpp`: right after `vr_submit_init(device)` (`:298`) and before
returning to the game, query `IDirect3DVRStereo9` and call `EnableSinglePassStereo`. Log
the result. Shaders are created later by the game, so the variant is in place in time.
- `src/vr/stereo.cpp/.h`: new `stereo_single_pass_eligible()` and
`stereo_issue_single(device, issue)`. The latter computes L/R with the exact failure
carry-over, applies `hands_in_front` (game viewport MinZ/MaxZ set before, restored after),
arms sticky stereo, issues once and counts stats. Keep `stereo_begin_pass` and
`stereo_end_draw` for fallback.
- `src/proxy/proxy_device.h` `dispatch_draw` (`:375-402`): if `passes > 1 &&
single_pass_on && eligible`, call `stereo_issue_single`. Otherwise use the current loop.
When a mono draw follows a stereo draw, call `ClearStereoDraw()` (sticky state is cleared
in `dispatch_draw`'s mono branch and before the reflection branch).
- **Eligibility** (each draw that fails it falls back to two-pass, counted by reason):
- a programmable VS is bound (`imgui_state().vertex_shader`, `proxy_device.h` SetVertexShader);
- no per-eye texture swap is needed. A new `reflection_stereo_draw_needs_eye_textures()`
returns true when a bound stage holds the reflection texture (twin present) or the
WaterFX copy (split active), using the same tests as `reflection_stereo_eye_textures`;
- no stream instancing is active (track `SetStreamSourceFreq`, `proxy_device.h:448`, which
is a passthrough today);
- not the reflection pass (`proxy_device.h:336` branch stays two-pass into twin surfaces).
- `sample_hand_projection` (debug): log the bank matrices, since the actual c0 per eye can
no longer be read back.
- `SetVertexShaderConstantF` (`proxy_device.cpp:177`): unchanged in M3. In M4 the
centre-eye substitution there can become lazy (it is only needed by mono or fallback
draws).
- Water reflection and shadow maps stay as they are: the reflection uses twin surfaces,
shadow maps are mono. UI/HUD/menus/overlays/ImGui (`imgui_overlay`, `overlay.cpp`) are
unchanged. HUD draws on the scene target take the single-pass path with their per-eye UI
matrices, which is the same output.
- Runtime A/B: a `tune.cpp` hotkey (proposal: Shift+F6) toggles single-pass and two-pass
live. This works because every VS is compiled stereo-capable and two-pass simply never
arms.
### 5.3 M4: shared per-eye maths (proxy CPU)
The 2.9-5.2 ms/frame of matrix maths **does not go away** with single-pass, because we
still need two matrices per draw. M4 adds `vr_substitute_c0_stereo(in, outL, outR)` in
`vr_session.cpp`:
- compute the eye-independent prefix once. `M*P^-1*paused_head` is left-associative in the
current code, so sharing it is bit-exact;
- cache `vr_eye_matrix(eye, gp)` per frame, keyed by gp and pose generation;
- move the `binocular_zoom` EMA to once per frame;
- advance `sky_pinned_draw`'s call counter by 2.
Prove it with `src/test/mathtest.cpp`: compare the dual function bit for bit against two
single calls over captured c0 sets. This also speeds up the existing two-pass path.
## 6. Runtimes (1.1.0 abstraction) and what single-pass implies
- `[vr] vr_runtime = auto | openxr | openvr` (`src/common/config.cpp:38,889`;
`vr_runtime.cpp:84`). `auto` tries OpenXR first, which needs `openxr_loader.dll` beside
the game (delay-loaded) and a runtime that starts, and falls back to OpenVR. Every
`xr_*` call is forwarded to `oxr_*`/`ovr_*` (`src/vr/vr_backend.h`).
- **OpenVR** (`ovr_runtime.cpp`): Vulkan extensions come from the compositor through the env
vars. Submit is in place: TransferSurface(wait), GetVRDesc, and two `Submit` calls with
bounds (`ovr_submit` `:595`). Pacing is `WaitGetPoses` (`:519`).
- **OpenXR** (`xr_runtime.cpp`): a swapchain is created from `GetDeviceDesc`. Each frame the
proxy calls `xrWaitFrame`/`xrBeginFrame`, acquires, `CopySurfaceToImage`s the 2W image
(wait if `xr_copy_wait=1`), releases, and calls `xrEndFrame` with two subImage rects.
- **On the Frame**: Proton 11 ARM64 has **no 32-bit wineopenxr**, so `auto` ends on
**OpenVR**. The design assumes **OpenVR first**. Set `vr_runtime = openvr` in the Frame
ini so it does not probe OpenXR at start-up (and do not ship `openxr_loader.dll`). Proton
Experimental ARM64 does have a 32-bit wineopenxr, so OpenXR is an M6 experiment there:
`vr_runtime=openxr`, plus `openxr_loader.dll`, plus SteamVR as the OpenXR runtime.
- **SPS-VI implications**: none for either runtime. The submitted image is still the 2W
double-wide image, the bounds and rects stay as they are, and `TransferSurface` and
`CopySurfaceToImage` stay as they are.
- **Layered-multiview implications (M6 only)**:
- OpenVR would need either `Submit_VulkanTextureWithArrayData` with
`VRVulkanTextureArrayData_t{m_unArrayIndex, m_unArraySize=2}` (one image, two Submits),
which is **UNVERIFIED** through Proton's vrclient Vulkan translation, or two layer blits
into a 2W staging image (two copies per frame; `prepare_openvr` stays as it is).
- OpenXR would take it more naturally: `arraySize = 2` (`xr_runtime.cpp:1070`),
`imageArrayIndex = eye` (`:1169`), and a layer parameter added to `CopySurfaceToImage`.
- **Frame pacing lever (both runtimes, optional M5)**: the TransferSurface /
`xr_copy_wait` GPU wait serialises CPU and GPU (6-8 ms "submit"). It is a separate
experiment (`TransferSurface(surface, FALSE)` behind a key) and is risky. The compositor
must see finished work; DXVK's `LockSubmissionQueue` already flushes and synchronises the
CS thread before Submit.
## 7. Risks, ranked
1. **Expected gain smaller than hoped.** Single-pass removes the duplicate DXVK frontend
and CS work but not the mod maths or the game's own 6-10 ms update. Estimate: M3 alone
about 50-58 fps, M3+M4 about 60-68 fps (alternate-eye reached 68 with zero mod maths).
*Probe*: compare M3 `perf cpu` draw-call ms with session 3/5. If short, do M4 and the
FEX knobs (`FEX_MAXINST`, Proton Experimental ARM64).
2. **IR lowering correctness in dxbc-spirv** (SM3 subroutines, dynamic indexing, the
extra output builtin, the CBV struct member limit `ir::Type::MaxStructMembers`).
*Mitigation*: the stereotest harness (PLAN M2-C) renders known matrices and reads back
pixels. Check dumped SPIR-V with `spirv-val`. Every shader that fails lowering is
compiled mono and its draws fall back (log `D3D9VR: stereo lowering failed for <key>`).
3. **Hidden per-eye state the proxy changes between passes.** Known: viewport,
c0, texture swaps, hands depth range. Anything else (a future 1.1.x feature calling
`SetRenderState` per pass) would be lost. *Mitigation*: the code review checklist covers
`dispatch_draw` and `stereo_begin_pass` only. A debug key renders frame N two-pass and
frame N+1 single-pass; `F12` captures both for a pixel diff (screenshot helper in
`vr_submit` mirror).
4. **Toolchain**: the proxy uses 187 `__try` blocks in 11 files and MSVC attributes. The M1 mingw build
currently fails in `vr_submit.cpp:992` with the `seh_compat.h` shim
(`build-m1/logs/build-all.log`), and the 1.1.0 source list (overlay, xr/ovr runtimes,
godmode, physics, openxr) is not yet in `container-build.sh`. *Mitigation*: the M1 lane
finishes the shim, or switches the proxy to clang-cl plus an xwin MSVC SDK. DXVK already
builds with mingw.
5. **Turnip cost of VS-written ViewportIndex with binning/LRZ, and instanceCount=2.**
*Probe P3*: compare GPU scene in alternate-eye against single-pass on the same spot. If
GPU time rises a lot, try `ViewportIndex` through `VK_QCOM_multiview_per_view_viewports`
(needs multiview, M6).
6. **Instanced or FF scene draws are common** (unknown). *Probe*: M3 stats line fallback
reasons. If FF is large, add the FF GLSL epilogue (M5).
7. **IID/ABI mismatch** between proxy and DXVK (1.1.0 already changed the IID). *Mitigation*:
separate interface, version field in caps, fallback to two-pass.
8. **IR cache poisoning**. *Mitigation*: key suffix. Delete the DXVK state cache on first
install.
## 8. Test plan on the Frame
Conditions: start each session cool. Session 4 was thermally throttled, so check fancontrol
temps before launching. Use the shortcut (trl_vr.exe, LAA), SteamVR 72 Hz, resolution
override 1440, `vr_runtime=openvr`, `stereo_same_frame=1`, and the same save and spot for
every A/B (the first Bolivia courtyard).
Perf targets, from the `perf:` and `perf cpu:` lines, 60 s medians:
| mode | fps | GPU scene | draw-call CPU | submit |
|---|---|---|---|---|
| two-pass (baseline, self-built M1) | about 40 | about 17 ms | 6-10 ms | 6-8 ms |
| single-pass M3 | >= 50 | <= 9 ms | <= 4 ms | <= 5 ms |
| single-pass + M4 | 60-72 (cap 72) | <= 7 ms | <= 2.5 ms | |
Visual checks, single-pass against two-pass via the live toggle:
- convergence and world scale on near and far geometry;
- HUD position and follow;
- world markers (lock-on, grab hand);
- menus: world-locked pause, PDA, Mission Prep;
- cutscenes (theatre on and off) and movies/loading screens;
- sky (no slide, at infinity);
- water: mask, WaterFX copy, reflection on, if enabled;
- shadows (mono);
- first-person hands, board/diorama hands-in-front;
- MSAA 2 edges;
- ImGui settings panel and picture adjust;
- comfort vignette.
Fallback switches: Shift+F6 live; `[vr] stereo_mode=twopass`; `dxvk.conf`
`d3d9.trlvrSinglePass = False`; restore the shipped DLLs (PLAN rollback).
## 9. Build and packaging
- Container `legendframe-mingw` (Fedora 43, mingw32-gcc, meson, glslang;
`~/Games/LegendFrame/toolchain/Containerfile`, `build.sh`, `container-build.sh`). DXVK
builds (`build-m1/out/dist/d3d9_dxvk.dll`). The proxy needs the SEH fix and the 1.1.0
source list from `build.bat`.
- Deliverables beside the game: `d3d9.dll` (proxy), `d3d9_dxvk.dll`, `trlvr_launcher.exe`,
`trlvr.ini` keys. Proxy and DXVK always ship as a pair. `openvr_api.dll` is unchanged, and
there is no `openxr_loader.dll` on Proton 11.
- Privacy: keep `/PDBALTPATH`-like hygiene (no absolute paths in DLLs; mingw `-s`). Commits
as simplysanatori noreply.
+177
View File
@@ -0,0 +1,177 @@
# LegendFrame plan (single-pass stereo, SPS-VI)
Read DESIGN.md first. Source is TRLVR 1.1.0 (e3657d0) at `~/Games/LegendFrame/src`.
Build is `~/Games/LegendFrame/toolchain` (podman `legendframe-mingw`). Outputs go to
`~/Games/LegendFrame/build-*`. Rules:
- Lanes never edit the same file at the same time.
- Each lane works on its own branch off `steam-frame` and is merged by the overseer.
- Nothing is installed into the game directory except by the M5 lane.
## File ownership (exclusive while the lane is open)
| lane | files |
|---|---|
| M1-T toolchain | `toolchain/*`, `build-m1/*`, build patches only (no `src/` edits except an agreed `src/common/seh_compat.h`) |
| M2-A DXVK shader | `dxvk-trlvr/src/d3d9/d3d9_shader.cpp`, `d3d9_shader.h` |
| M2-B DXVK device | `dxvk-trlvr/src/d3d9/d3d9_vr.h`, `d3d9_vr.cpp`, `d3d9_device.h`, `d3d9_device.cpp`, `d3d9_state.h`, `d3d9_options.*`, `src/util/config/config.cpp` |
| M2-C harness | `src/test/stereotest.cpp` (new), `toolchain/stereotest.sh` (new) |
| M3-P proxy | `src/proxy/proxy_device.h`, `proxy_device.cpp`, `proxy_d3d9.cpp`, `src/vr/stereo.cpp/.h`, `src/vr/reflection_stereo.cpp/.h`, `src/common/config.h/.cpp` |
| M3-K hotkey/panel | `src/vr/tune.cpp/.h`, `src/proxy/overlay.cpp` (settings panel entry) |
| M4-M maths | `src/vr/vr_session.cpp/.h`, `src/vr/vr_math.*`, `src/test/mathtest.cpp` |
| M5-I install/validate | `~/Games/LegendFrame/install/*`, the game dir, `trlvr.ini`, logs |
Contract frozen at the **start** of M2-B, so the other lanes can code against it:
- `IDirect3DVRStereo9` in `d3d9_vr.h` (DESIGN §4);
- `D3D9StereoPushData` at VS push offset 20;
- the bank is 8 vec4 appended to the VS static CBV, L c0..c3 then R c0..c3;
- shader key suffix `_sps1`.
## M1: reproducible baseline (in progress, toolchain lane)
Accept when:
- the container builds 1.1.0 `d3d9_dxvk.dll`, `d3d9.dll` and `trlvr_launcher.exe` from
`~/Games/LegendFrame/src`, using the 1.1.0 `build.bat` source list (overlay, xr/ovr/vr
runtime, menu_*, tr7_menu_hook, finger_input, godmode/, physics/, third_party/openxr
headers, `openxr_loader` delay-load or LoadLibrary-only);
- the `__try` shim compiles all 11 files that use it, or the proxy is built with clang-cl + xwin;
- `upstream-diff.txt` lists the fork's diff against upstream DXVK v3.1.0 (settles DESIGN
§2 UNVERIFIED);
- a headset session with self-built 1.1.0 two-pass, `vr_runtime=openvr`, stays within ±5 %
of 1.0.0 shipped fps at the same spot. Log lines to expect: `vr: using OpenVR / SteamVR`
and `submit: first side-by-side frame accepted`.
Lanes:
- **M1-T** (Sonnet, high; already running): finish the proxy build.
- **M1-V** (Sonnet): `vulkaninfo` to `build-m1/vulkan-caps.txt`. Confirm `multiViewport`,
`maxViewports`, `shaderOutputViewportIndex`, `VK_KHR_multiview` (+`multiviewGeometryShader`
not needed), `VK_QCOM_multiview_per_view_viewports`, and `VK_EXT_vertex_attribute_divisor`.
## M2: DXVK single-pass core (needs only the DXVK build, which already works)
Accept when:
- with `stereotest.exe` under Proton 11 ARM64 on the Frame (DISPLAY=:1, no headset):
- a VS reading c0..c3 renders a quad into a 2W x H RT, once with
`SetStereoDraw(L, R, W, H)` and once as two-pass;
- the readback images are identical (max abs diff 0) for: a static c0 shader, a
relative-addressed (a0) shader, a shader with `call` subroutines, MSAA 2x, and the
scissor test on;
- mono draws after `ClearStereoDraw` are unchanged;
- an instanced draw while armed is rejected and logged;
- `spirv-val` passes on dumped stereo shaders;
- DXVK log lines:
- `D3D9VR: single-pass stereo enabled (viewport index, bank 8 vec4, key _sps1)`
- `D3D9VR: stereo draw rejected (instanced) -- reported once`
- `D3D9VR: stereo lowering failed for <key> -- compiled mono`
- `D3D9VR: single-pass disabled by d3d9.trlvrSinglePass`
Lanes (parallel):
- **M2-B device/interface** (Opus): freeze the header first, then do arming, the bank
upload in `UpdateShaderConstants`, the two viewports in `BindViewportAndScissor`,
instanceCount, the push flag, Reset, stats, and the conf switch.
- **M2-A shader lowering** (Opus): `stereoMode` option and key, bank members in
`setupStaticCbv`, selects in `lowerLegacyConstInput` (static + dynamic), and the
ViewportIndex prologue.
- **M2-C harness** (Sonnet): `stereotest.cpp` loads `d3d9_dxvk.dll` directly, gets
`IDirect3DVR9` via `Direct3DCreateVR9`, and QIs to `IDirect3DVRStereo9`. Add a run script
using the SLR/Proton command from the MCC recipe.
## M3: proxy integration (needs M1 proxy build + M2 header)
Accept when:
- in the headset, `stereo_mode=singlepass` gives identical-looking images to two-pass
across the DESIGN §8 checklist, with the Shift+F6 live toggle;
- the stats line shows >= 90 % single-pass among stereo draws in gameplay;
- fps is >= 50 at the test spot (1440, 72 Hz, cool start).
Config keys, `[vr]` in `trlvr.ini`:
- `stereo_mode = twopass|singlepass` (default `twopass` until M5 sign-off);
- `stereo_stats = 0|1`;
- `vr_runtime = openvr` (Frame default).
Proxy log lines:
- `stereo: single-pass (viewport index) enabled, DXVK stereo interface v1`
- `stereo: DXVK has no single-pass interface (E_NOINTERFACE) -- two-pass`
- `stereo: single-pass requested but EnableSinglePassStereo failed (0x%08X) -- two-pass`
- every 5 s with `stereo_stats=1`: `stereo stats: single %u, fallback %u (eye-textures %u, fixed-function %u, instanced %u, reflection %u), mono %u, arm calls %u, dxvk rejected %u`
- `stereo: live toggle -> single-pass|two-pass (Shift+F6)`
Lanes:
- **M3-P proxy** (Opus): `stereo_issue_single`, eligibility, sticky arm/clear,
`hands_in_front`, the `reflection_stereo_draw_needs_eye_textures` predicate,
`SetStreamSourceFreq` tracking, config, CreateDevice enable, and the debug hand sample.
- **M3-K hotkey/panel** (Sonnet): Shift+F6 toggle, plus an optional settings-panel item.
Calls only `stereo_set_single_pass(bool)` exported by M3-P (agree the signature in the
M3-P first commit).
## M4: shared per-eye maths (can start as soon as the M1 proxy build works; independent of M2/M3)
Accept when:
- `mathtest` shows the dual-eye function bit-identical to 2x single calls on >= 200
captured c0 sets, covering pure projection, WVP, sky, UI, marker, PDA, Mission Prep,
first-person hands and board hands. Captures come from a `vr_capture_c0=1` debug key
writing `c0_capture.bin`;
- `perf cpu` "mod matrix math" is down >= 40 % in **both** two-pass and single-pass;
- there is no visual change.
New log line: `stereo: dual-eye substitution on (shared prefix, eye matrix cache, zoom EMA per frame)`.
Key: `[vr] stereo_shared_math = 1` (0 = legacy per-eye calls, for A/B).
Lane:
- **M4-M** (Opus): `vr_substitute_c0_stereo`, a per-frame `vr_eye_matrix` cache, the
binocular_zoom EMA once per frame, the sky call counter +2, and lazy upload-time
centre-eye substitution.
## M5: Frame validation, install, defaults
Accept when:
- three cool sessions in single-pass + M4 give a median fps >= 60 at the test spot and no
regression in the checklist;
- the owner signs off on the look;
- defaults are flipped (`stereo_mode=singlepass`) and the install script is idempotent;
- the memory file is updated.
Lanes:
- **M5-I** (Sonnet): install/uninstall scripts and backups (rollback below), headset
session log collection, A/B summary table.
- **M5-X** (Sonnet, optional, after sign-off): FEX knobs (`FEX_MAXINST=5000`,
`FEX_PROFILESTATS=0`) and a Proton Experimental ARM64 A/B. Also, behind
`[vr] submit_wait = 1|0`, the no-wait TransferSurface experiment. Revert anything that
shows artefacts.
- **M5-F** (Opus, only if M3 stats show fixed-function fallback > 5 %): FF GLSL epilogue
(`d3d9_fixed_function_vert.vert` + push flag).
## M6: optional research (not scheduled)
- Layered multiview (DESIGN §3.2/§6): dxbc-spirv `eViewIndex`, `vk11.multiview`,
`viewMask`, 2-layer stereo surfaces, OpenXR `arraySize=2`, and the OpenVR array submit
through Proton vrclient. Only if GPU time becomes the limit.
- OpenXR on Proton Experimental ARM64 (32-bit wineopenxr): `vr_runtime=openxr`,
`openxr_loader.dll`, SteamVR OpenXR runtime; compare pacing with OpenVR.
## Rollback
1. Before the first install, M5-I copies the game dir's `d3d9.dll`, `d3d9_dxvk.dll`,
`openvr_api.dll`, `trlvr_launcher.exe`, `trl_vr.exe` and `trlvr.ini` to
`~/Games/LegendFrame/build-m1/shipped/1.0.0/`, with `SHA256SUMS`. These are the
currently installed 1.0.0 files. The zip's sha256 `fd36ec16…a18` is in
`~/Games/TRLVR/dl`, so the zip is a second source.
2. Every install writes `~/Games/LegendFrame/install/last-install.txt` (file, old sha, new
sha) and keeps the previous set in `install/prev/`.
3. Three levels of fallback, fastest first:
- Shift+F6 in the headset;
- `[vr] stereo_mode=twopass`;
- `d3d9.trlvrSinglePass = False` in `dxvk.conf` (in launch options via `DXVK_CONFIG`);
- `install/rollback.sh`, which restores `shipped/1.0.0` and checks the sha256s.
4. Proxy and DXVK are always swapped together (the IID pairing). Never mix a 1.0.0 proxy
with a 1.1.0 DXVK.
5. Delete DXVK's shader/state cache files for trl_vr in the game dir/prefix on every swap
(key-suffix safety net).
## Immediate start
1. **M2-B DXVK device/interface (Opus)**: the header freeze is its first commit.
2. **M2-A DXVK shader lowering (Opus)**: works against the frozen contract above.
Both need only the DXVK container build, which works today (`build-m1/out/dist/d3d9_dxvk.dll`).
M2-C (Sonnet) can follow as soon as the header is frozen. M4-M starts when M1-T has a
working proxy build.