Files
goldeneye-pc-port/docs/SMALL-FIXES.md
T
James Dansereau 99c5926924 D165/D166: front-end 1:1 pointer P-controller + speed-proportional hipfire pitch pulses
port/src/input.c only (port-layer, no game-code change):

D165 - the M-30 menu 'pointer mode' fed mouse velocity into the stick, but
front.c frontUpdateControlStickPosition integrates the stick as a velocity
into a screen-pixel cursor -> velocity^2 feel. Replace with a P-controller:
maintain menuEst{H,V} (our estimate of the game cursor, stepped each poll with
front.c's exact recurrence via menuCursorStep), accumulate menuTgt{H,V} from
raw mouse motion, emit stick = clamp(6.0*(target-est), +/-70). Gain 6.0 =>
per-poll error decay 0.45, no overshoot, ~8-poll settle, parks under the
game deadzone with no drift. Input.MenuPointerMode (default 1) keeps the old
velocity mode as a fallback; Input.MenuPointerSpeed scales the mapping;
GE_INPUTLOG prints an est/tgt/eff/stick trace. No mouse motion => emitted
stick unchanged => prior behaviour exactly.

D166 - hipfire mouse pitch was a fixed digital threshold while yaw is analog.
Emit the C-button as a duty-cycled pulse whose frequency scales with mouse-Y
speed (fast = solid hold as before, slow = sparse taps). Input.HipfirePitchSpeed
tunes it. Aim-mode (RMB, already analog) untouched.

Build green; -level_09 (~360 frames) + GE_STARTMENU=6 smoke crash-free.
Feel-check owed (headless can't drive the mouse).
2026-08-31 12:18:15 -04:00

24 KiB
Raw Blame History

SMALL-FIXES — ready-to-execute fix notes for small / low-level open items

Read-only document. This file records proposed fixes and verification procedures for the small, low-level bugs and noted issues that are parked in BACKLOG.md, GRAPHICS-BACKLOG.md, HANDOFF.md, and the §F/§H finding log. No code was changed when writing it (M-28). Each item is self-contained enough to be picked up cold; items marked VERIFY-ONLY already have their fix in the tree and only need confirmation + commit hygiene.

Standing rules for every item below:

  • Game-code edits must stay inside the narrow #ifdef PORT ABI/layout exception (AGENTS.md #2); port-layer work has no such limit. Anything bigger than described here → stop, write it up with a confidence rating.
  • New findings get the next free Dxx in docs/PCPortResearch.md §F/§H + an index row; recurring bug classes go to docs/PORT-LEARNINGS.md.
  • After any converter change: full regen chain python tools_pc/d43_emit.py ntsc-final && python tools_pc/d69_emit.py ntsc-final && python tools_pc/d88_emit.py ntsc-final --regen.
  • Verification anchors (quiet machine only — D153): target level boots to ≥1 non-degenerate frame (tools_pc/pixcount.py), -level_09 + -level_20 unregressed (tools_pc/framediff.py, use --mask for the HUD).
  • Crashes: tools_pc/debug.ps1 (gdb + FATAL line + backtrace → gdb.txt).

A. VERIFY-ONLY — fix is in the tree, needs confirmation

A1. D154 — bg.c room-geometry hit-test GBI parser (committed WIP 072b5c44)

Status: written + builds green; never exercised (a no-input GE_PCDUMP capture fires no bullets, and the M-28 box was thrashed).

What it is: bgTestRayIntersectionInRoom (src/game/bg.c:~3331, called from bgTestBulletHitBackground on every shot that resolves against background geometry) was the D135 sibling: raw 8-byte-N64-Gfx byte/word indexing into the 16-byte PC room DL → desync → OOB vtxbase[idx] / walk-off AV the first time a wall or floor is shot. The #ifdef PORT port reads gdl->words.w0/.w1 (G_TRI1 from (w1>>16/8/0)&0xff/10; G_TRI4 nibbles from (u32)words.w0/.w1 with the byte-mapping table in the code comment); both texturenum recoveries return -1 (generic impact decal/sound, parked w/ D77).

Procedure:

  1. Quiet machine. ./tools_pc/debug.ps1 -level_09.
  2. Get into a firefight and shoot walls/floor deliberately (not just guards) for ~60 s — this is the only trigger.
  3. Pass = no AV in bgTestRayIntersectionInRoom / bgTestBulletHitBackground, bullets still resolve hits (decal/sound may be generic).
  4. -level_09 + -level_20 framediff unregressed.
  5. Then fix up the commit (072b5c44 is labelled "WIP, unverified") and close the §F D154 row.

Risk: low — #ifdef PORT only, N64 path verbatim under #else. If a new fault appears in this function, the nibble-mapping table is the thing to re-derive against rsp/graphics/gmain.s (ground truth for GBI decoding).

A2. D139 — stage-unload cleanupObjects type byte (fix committed M-23)

Status: fix in (src/game/cleanup_objects.c, #ifdef PORT CLEANUP_PDTYPE(o) = ((PropDefHeaderRecord*)o)->type instead of (u8)obj[0], which on LE reads extrascale and walks off the propDef blob). Never verified — the fast teardown test hit D140 first (since fixed), and M-27's level-exit loop was playtested through the real front-end flow, which does exercise lvlUnloadStageTextData → cleanupObjects on level exit.

Procedure:

  1. Re-check whether M-27's Dam→Facility playthrough already exercised it: if a real level exit completed without a teardown crash after the D139 commit, this is done — just record "verified via M-27 exit loop" on the §F row and stop.
  2. Otherwise: debug.ps1 -level_09, complete/abort a real level exit (not pause-abort) and confirm no AV in objFree / objFreeEmbedmentOrProjectile.

Risk: low — one-line read-source change, every other propDef consumer already uses the struct member.


B. Root-caused small fixes (not yet applied)

B1. D74 — dead + OOB texture-wrap block in fast3d (port/fast3d/gfx_pc.cpp:~1570)

Status: latent. Harmless for CLAMP glyphs today; could corrupt wrapped textures on triangles (room panels, model skins).

Root cause (confirmed against the tree): two independent defects in the D74 pre-wrap block inside the per-vertex UV loop:

  1. Always-false guard. The codebase's gbi.h (include/PR/gbi.h, used by both game and fast3d via port/shim/PR/gbi.h) encodes wrap mode as a 2-bit field: G_TX_NOMIRROR|G_TX_WRAP = 0x0, G_TX_MIRROR = 0x1, G_TX_CLAMP = 0x2. So cms & G_TX_WRAP is & 0 — always false. The "is this tile in wrap mode" test must be "clamp bit not set": (cms & G_TX_CLAMP) == 0. (Compare the correct switch at gfx_opengl.cpp:706-712, which keys on the four combinations.)
  2. OOB index. tex_width2[i] / tex_height2[i] are per-texunit [2] arrays (set up at gfx_pc.cpp:~1471), but the block is inside the per-vertex loop where i is 0..2 → read of slot [2] if the guard ever fires.

Fix:

  1. Hoist the tile-window computation out of the vertex loop: for each used texunit t, compute once — tw = tex_width2[t], th = tex_height2[t], wrapS = (cms & G_TX_CLAMP) == 0 && tw < tex_width[t], same for T.
  2. In the vertex loop, apply u = fmodf(u, tw); if (u<0) u += tw; u += uls/4 (and V) only when the precomputed flag is set.
  3. Delete nothing else — leave the surrounding D74 comment, extend it with a line noting the G_TX_WRAP == 0 encoding trap (PORT-LEARNINGS §D class: "N64 hardware idioms fast3d does not emulate" — add the bit-encoding note).

Verify: build; -level_09 + -level_20 framediff; then a level with visible wrapped triangle textures — Depot (-level_30) is the known worst case (overlaps B2, but D74's block only fires for WRAP tiles). Check tools_pc/golden/ dumps for no new garbage.

Risk: low-medium. The block has never run, so this is new behavior — if wrapped textures suddenly look wrong, the fmod window size (tw from lrs-uls+4/4) is the thing to validate against rsp/graphics/gmain.s's tile-window handling before "fixing" it further.

B2. D152 — find the leaking osSetIntMask site + proper narrow fix

Status: mitigated (self-healing lock in port/src/libultra.c, M-28) — a leaked section now costs ~2 s + a log line instead of a permanent black screen. The root leak is still unidentified.

Procedure to root it:

  1. Replay the trigger: Facility (-level_34), kill Trevelyan → mission-fail fade-out, under tools_pc/debug.ps1.
  2. When the log shows D152: ... stealing from owner=..., take the logged stale-owner address and the stealing caller's return address; addr2line -e build-pc/ge007.x86_64.exe -f -C <both> (image base 0x140000000). The owner is the leak site.
  3. Audit target: libaudio's alEvtqPostEvent / alEvtqNextEvent / sndSetSfxSlotVolume paths (src/libultra/audio/event.c, src/snd.c) — look for early returns between an osSetIntMask(OS_IM_NONE) and its matching OS_IM_ALL (the D147 assumption that "the decomp never blocks / never early-returns while masked" is falsified on this path). Also check transient threads that acquire-and-exit (gdb.txt showed New Thread / exited churn every few k frames).

Proper narrow fix (once the site is known), pick one:

  • (a) Preferred: a dedicated ALEventQueue lock (mutex per queue or one global in the port layer) taken by alEvtqPostEvent/alEvtqNextEvent, so the audio event paths stop using the process-wide osSetIntMask section at all; then the self-healing steal becomes dead weight that can be reverted to the plain D147 recursive mutex.
  • (b) Cheaper: make the leak site balanced (the actual bug), keep the self-healing lock as belt-and-braces.

Verify: replay the mission-fail 3×: no D152: steal log lines, fade-out completes, screen returns to the mission-failed UI. -level_09 framediff.

Risk: (a) is port-layer only if the lock lives in the shimmed alEvtq* wrappers — but note event.c is compiled game-side libultra, so the lock likely has to go inside event.c under #ifdef PORT (ABI-exception class, document as Dxx). Keep the self-healing lock either way until audio (Phase 3) lands.

B3. D120 — blood-stain hang: extend d43_emit.py for opcode-0x18 — DONE (M-30)

Status: FIXED (tools_pc/d43_emit.py, commit da98cdf1 + validation 4a7ab609). The CollisionVertices sub-array + its .index / CollisionRelatedNode / CollisionRelatedIndex fields were already converted (op24_is_collision path). The real gap was narrower than this note assumed: PointUsage[] was reserved in the layout pass (add_region(puo, 2*nv)) but the emit pass never wrote it → all-zero → chr.c's decal walk index = PointUsage[index] cycled 0→0 (capped by the guard). Fix: op24_pointusage[puo] = nv + a byteswap loop in the emit pass. Converter now validates: 1469 regions / 70177 s16 entries round-trip, every index < numVertices, none all-zero. The chr.c guard is kept as a safety net. Verify: BUNKER1 firefight — blood decals on shot guards should appear, no hang. Regen chain must be re-run for the sidecar to update.

Procedure:

  1. Byte-spec the record: dump one converted guard model's opcode-0x18 region from a sidecar (data/pccg-*) and diff against the raw ROM bytes; write out the N64 layout (fields, sub-array strides, terminators) in the converter comment — same method as the D69/D88 audits.
  2. Extend d43_emit.py: emit the widened struct with the 6 pointers via put_ptr, and convert both sub-arrays (endianness + PC stride).
  3. Keep the chr.c guard until verification passes, then decide whether to keep it as a safety net (recommended — same philosophy as D85's bounds check) or revert.
  4. Regen full chain; verify: BUNKER1 (-level_09) guard firefight — blood decals now appear and no hang; framediff anchors.

Risk: medium — converter work with a runtime guard underneath, so a bad conversion degrades to today's behavior rather than crashing. Good subagent brief on its own (per HANDOFF-ARCHIVE M-9).

B4. B3 — RMB aim too sensitive (port/src/input.c)

Status: user-reported (M-28 playtest). Port-only.

Current math (aim mode, input.c:~471): m = 61 + |dx| * (MouseAimSpeed/100) * AIM_GAIN(4.0), clamped to 60 + AIM_BAND(20) = 80. At the default MouseAimSpeed=50, 25 px/poll saturates the band — a fast flick is "max stick" for many polls, and GE's aim curve (stick-60)/10 then runs at full rate the whole time.

Fix (in order of cheapness):

  1. Lower the default: mouseAimSpeed = 50 → 25 (input.c:~128). One line; users can still dial it back up in ge007.ini.
  2. Make the band itself tunable: register Input.AimBand (int, 5..40, default 20) and replace the AIM_BAND constant in the two clamp sites — a smaller band = finer control near the gate, a larger one = more headroom.
  3. Optional curve change if it still feels bad: make the mapping into the band sub-linear (m = 61 + AIM_BAND * sqrt(gx / saturating_px)) so small movements get proportionally less stick — but only after 1+2 are playtested; don't stack changes.

Verify: playtest-only (no headless signal): aim at a guard in BUNKER1, flick the mouse, confirm no overshoot orbiting the target; hipfire turn (MouseTurnSpeed) is untouched by this.

Risk: trivial — port-only, config-backed, default-reversible.

B5. B4 — menu screens need an absolute pointer mode (port/src/input.c)

Status: user-reported (M-28). Port-only design item; no code yet.

Problem: front-end / watch menus with the crosshair cursor behave like an analog-stick game, not a PC pointer. Today all mouse input is delta based (turn/look), and menu navigation goes through joyGetStickX/Y level checks in front.c / options.c — synthetic stick deltas make menu cursors crawl or slam.

Design (port-only, no src/ change):

  1. Add a menu-pointer mode to the input layer: when active, emit absolute stick values derived from the cursor position — sx = (mouseX / windowW) * 2 - 1 → [-80..80], same for Y. GE's front-end maps stick deflection to on-screen cursor position, so absolute→absolute gives true pointer behavior with zero game-code changes.
  2. Mode detection is the open question — the port doesn't know when the game is in a menu. Options, cheapest first:
    • (a) Heuristic: mouse is ungrabbed (cursor visible) ⇒ menu mode. Pair with a rule that front-end screens release the grab and gameplay grabs it (the grab state already flips on focus loss; extend it to be driven by a key, e.g. hold-to-release, or auto-detect via the same signals D145 used for ESC-as-B). Simple, no game-state peeking.
    • (b) Peek a game-side "am I in the front end" flag through an existing exported symbol (check front.c / lv.c globals) and branch on it in inputPoll. Read-only access to a global is arguably inside the documented exception class, but record a Dxx if used.
    • (c) Explicit user toggle (config key + hotkey). Ugly but zero risk.
  3. While in menu mode: suppress aim/turn delta emission entirely; wheel and clicks map to A/B as today.

Verify: drive the mission-select / SELECT FILE screens with the mouse — cursor should track 1:1, click selects; in-game aim unaffected.

Risk: low (port-only) under option (a)/(c); (b) needs a Dxx write-up. Sequence after B4 so the gameplay feel is settled first.

B6. D118d latent sibling — front.c menu-nav stick level checks

Status: latent, noted M-27. Same bug class as the fixed watch-menu over-scroll (options.c, GE_WATCH_STICK_FAST{UP,DOWN} → 0 under PORT).

What to do: find the joyGetStick*InRange / raw joyGetStickY < -0x46 style level checks in front.c menu navigation (main menu, mode-select, mission-select paging) and apply the same treatment: under #ifdef PORT drop the stick fast-scroll term so keyboard/digital input goes through the latched single-step path only. Keep N64 verbatim under #else.

Verify: keyboard W/S (and D-pad) paging on every front-end menu = one step per press, no multi-skip; controller sticks still fast-scroll.

Risk: low — mechanical copy of an already-landed fix. Do it together with B5 so the menu-input area is touched once.

B7. D118a residual — hipfire pitch is digital, yaw is analog

Status: parked minor feel issue. The full fix needs a game-side hook; the deferred design is:

An #ifdef PORT analog-aim hook in bondview.c/bondview2.c where GE reads digital C-up/C-down for hipfire pitch — replace the digital read with the analog stick-Y value (which our input layer can emit even in hipfire, since stick-Y is "move" only when the game consumes it as such; validate that emitting a small stick-Y does not also make Bond walk). This is the first behavioral src/ edit of the aim path — it exceeds pure ABI/layout; write it up with a confidence rating before implementing (AGENTS.md #2: "stop and write it up"), and gate it behind a config key defaulting to current behavior.

Cheaper alternative worth trying first: raise MOUSE_PITCH_THRESH granularity by emitting the hipfire pitch as repeated C-button pulses with dwell proportional to speed — pure port-layer, approximates analog. Try this before touching src/.


C. Cosmetic / graphics — investigation plans (root cause not fully known)

These are parked below crash/level work (GRAPHICS-BACKLOG.md). Each entry is the next diagnostic step, because the root cause is not yet pinned. Do not treat them as ready-to-apply fixes.

C1. D114/D116 — HUD text + ammo digits X-mirrored per glyph

Hard rule (PORT-LEARNINGS §D2): do NOT re-run the static trace (four sessions did; every stage verified non-mirrored at the GL boundary) and do NOT apply a global texrect S-swap (mirrors the whole screen).

Next attempt — pick one, in order:

  1. RenderDoc (or apitrace) capture of exactly one glyph's texrect draw. The contradiction is "VBO verified non-mirrored but pixels mirrored" — a GPU-side capture resolves it in one shot: inspect the bound texture's upload bytes vs the gDPLoadTextureBlock source, and the final quad's UVs.
  2. Asymmetric-1-texel experiment: temporarily force one HUD glyph's image to a 2×1 or 3×1 asymmetric pattern (e.g. left half white) in the port layer; if it renders right-half-white, the flip is between upload and sample (texture origin / glPixelStorei(GL_UNPACK_ROW_LENGTH) / Y-flip); if it renders correctly, the mirror is applied per-glyph placement (texrect S-range inversion in textRenderGlyph's GBI — check the gDPTile/gDPSetTile uls/lrs signs for the HUD tiles specifically).

Suspects to check with the capture in hand: 4-bit CI tile upload stride vs G_IM_SIZ_4b sampling, TLUT index endianness (wrong-but-symmetric TLUT would not mirror — deprioritize), and the uls/lrs sign convention for rect draws (D116 is a rect path; triangles were never suspected).

C2. D75 — front-end 3D models misplaced / absent (logo, gun-barrel Bond, cast)

Triage per §F D75 — distinguish (a) vs (b) before any fix:

  1. For the Nintendo logo and one cast model, determine which matrix path builds their transforms: guRotate/guLookAt (→ candidate (a), D73 scope gap in guint.h DVAL()) or the animInit + embedded raw-offsets into struct player path (→ candidate (b), independent model/RW-pool bug, cf. D56/D100/D102 — all now-fixed struct-pun classes; a new instance of the class is the leading hypothesis).
  2. Capture GE_PCDUMP frames across the logo transition and gun-barrel/cast segments to localize which frame first diverges.
  3. Files: src/libultra/gu/guint.h, src/game/model.c, bondview2.c (animInit / modelSetScale), port/fast3d/gfx_pc.cpp.

Note: D148/D149 below are the same family — fixing D75's root cause will likely take them with it. The D149 garbled geometry is also worth one check on the converter side: the malformed sub-DL (seg5+0x9ee4, unresolved matrix ptr + garbage opcodes) may be a d43_emit.py conversion defect rather than a runtime transform bug — dump that sub-DL's raw ROM bytes vs the sidecar before assuming it's runtime.

C3. D76 — disclaimer screen only partially drawn

Likely the D68 image-table class, incomplete: some sImageTableEntrys the disclaimer references are missed by port/src/gimgfixup.c's bswap/shadow-sync.

Procedure:

  1. Enumerate the image entries the disclaimer screen references (trace textRender/display_image_at_position calls in the legal-screen code, or log every gDPLoadTextureBlock source during PPM frames f20–f100).
  2. For each, confirm it is covered by the D68 fixup (bswapped IMAGESEG w1 + synced compiled globalDL shadow). The "only the classification line + one line below draw" symptom = exactly the covered subset rendering.
  3. Files: port/src/gimgfixup.c, src/game/tex.c, image-table consumers.

C4. D148 / D149 — Dam exit cutscene absent; MISSION COMPLETE models garbled

Both are D75 family (see C2). Additional notes:

  • D148: the missing piece is a scripted sequence — cinematic camera + Bond rappel anim. Once D75's model path is fixed, verify the cutscene trigger still fires (check whether the exit trigger even dispatches the cinematic on PC — it may be gated on a front-end state flag our flow skips; GE_STARTMENU tooling can crash-test the screen in isolation).
  • D149: see the converter-side check in C2. D144/D146 already keep the game alive on the corrupt DL; the fix should make the model correct, then D146's "unknown opcode → end DL" guard can stay as a safety net.

C5. D85 (residual) — room DL texLoadFromGdl decode

Status: OPEN but safety-netted; geometry renders, so this is below level work. Remaining: decode the room-specific opcodes / CC-RM-LUT markers that texLoadFromGdl still handles generically, and verify csize_*_DL_binary sizing. Only worth touching if a level shows placeholder/garbage textures on room geometry that can't be explained by B2 (Depot wrong-colors).


D. Housekeeping / cleanup (no gameplay effect)

D1. Stale §F index row — D132 says "proposed fix (not applied)"

The D132 fix is applied in the tree: tools_pc/d88_propdefs.py (PROPDEF_PC_BYTES[14/19]=32, [44]=40; the (14,19,38,44) handler emits each Index{k} into the low 4 B of the 8-aligned slot at PC 8+8*k) and src/game/loadobjectmodel.c sizepropdef() (LINK/SWITCH/LOCK_DOOR → 8 words, SAFE_ITEM → 10). The §F index row still reads "proposed fix (not applied) — see below" while the subsection says "APPLIED (M-20, commit pending)". Action: update the index row to resolved (M-20) and confirm the M-20 commit landed (git-blame both files). Docs-only.

D2. Stale BACKLOG technical-cleanup items

docs/BACKLOG.md "Technical cleanup" lists:

  • "Add a D24-implications bug class to PORT-LEARNINGS" — done M-19 (PORT-LEARNINGS §E, lines ~374).
  • "Watch item: osYieldThread = Sleep(0)" — done M-19 (§E, line ~407). Keep the watch item itself; it's still accurate (the one place we fake cooperative scheduling — first suspect for level-sweep hot-loop misbehavior under host load).
  • "Remove TEMP D63 debug scaffolding" — partially done M-19 (libultra.c
    • game-side probe blocks removed). Remaining: port/fast3d/gfx_pc.cpp trail code — the /* TEMP D63 (strip before commit) */ block at ~2462, the GE_D63-gated dram-branch trace at ~2516-2531, and the unknown-opcode dump at ~2759-2765. All env-gated / inert; strip in one pass (keep the cached = getenv("GE_D63") pattern only if a probe is retained — recommend removing all of it).

Action: one docs+code cleanup commit: mark the two done items, strip the gfx_pc.cpp D63 trail, rebuild + -level_09 framediff.

D3. osYieldThread = Sleep(0) watch item (no action — recorded)

When a flaky timing bug appears (especially Phase 3 audio underruns): reach first for host thread priorities (SetThreadPriority: scheduler thread time-critical, tick normal) and the deferred GE_DETERM mode (D117) — not for kernel changes. See PORT-LEARNINGS §E.


Status (updated M-30)

  • B3 / D120 — DONE (PointUsage emit + validation).
  • B4 — DONE (M-29 0bd0ceec).
  • B5 — DONE (M-30 velocity mode; M-31 D165 replaced it with a true ~1:1 P-controller pointer — Input.MenuPointerMode 1=pointer default, 0=old velocity). Feel-check owed.
  • B7 / D118a — DONE (M-31 D166, cheaper port-only alternative): hipfire pitch is now speed-proportional C-button pulses, not a digital threshold. Input.HipfirePitchSpeed tunes it. The full game-side analog hook remains unimplemented (not needed unless the pulse still feels off).
  • B6 — investigated, the front.c stick checks are already edge-gated / toggle-guarded, not a real over-scroll (unlike the fixed options.c watch case). No-op.
  • RC4 (palette off-by-one, was in C / TEXTURE-GLITCH-ANALYSIS) — RETRACTED: the current RGBA5551 decode is correct.
  • A2 / D139 — likely closeable: M-27's Dam→Facility playthrough ran a real lvlUnloadStageTextData → cleanupObjects with no teardown crash after the fix. Confirm the commit landed, then close the §F row.
  • D2 (D63 scaffolding strip) — the port/fast3d/gfx_pc.cpp trail is stripped (M-30 d0789358). The remaining #if defined(PORT) GE_D63 probe blocks in ~10 src/game/*.c files are inert but some sibling getenv/static lines nearby are NOT #if defined(PORT)-guarded (e.g. bg.c:2880 d63bgprimarycount) — a strip pass must verify N64-build safety first (Non-Negotiable #1). Low value; defer.

Suggested order for what's left

  1. A1 (D154 wall-shoot GBI parser) — needs a firefight-into-a-wall playtest, then close/commit.
  2. B1 (D74 wrap-block) — the in-place fix boot-crashes; needs the hoist-out-of-vertex-loop rework + per-level visual check. Cosmetic, gate behind a config flag like RC2. Risky, low priority.
  3. B2 (Depot ceiling blue-speckle) — RC2 didn't touch it; needs GE_DTEX / RenderDoc on that specific surface. RC1 (wallet-Bond) is the D75 front-end-model track.
  4. C1–C4 cosmetics — after in-level crashes / campaign playtest.