No discoverable in-UI way to quit (Deck/controller-only setups had only
window-close/Alt+F4). Adds a ROW_ACTION row kind (not config-backed, same
pattern as the existing __Resolution row) that triggers the identical
configSave()+exit(0) path SDL_QUIT already uses. Build-verified on Windows
and Linux; verify.sh dam PASS, no regression.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
sndHandleEvent's pool-exhaustion preempt-scan read/walked the shared
D_800243E4 active-ALSoundState list with no osSetIntMask lock, while
sndSetupSound (the list's insert path, reached from sndPlaySfx calls
across 15 game files) already locks it -- an asymmetric-locking gap,
the same D4/D147/D152 "N64 interrupts-off isn't a real lock on PC"
class. Wraps the scan in the identical lock/unlock pair sndSetupSound
already uses, unconditionally (no #ifdef PORT, matching that function's
own style -- osSetIntMask is genuine N64 API and the port's shim is a
process-wide recursive lock, safe to nest). Audited the rest of the
function: the scan is the only D_800243E4 touch point in it.
Build green; -level_09/-level_20 unregressed (1400+ frames, no crash,
no D152 lock-stuck warnings). Not yet gdb/live-confirmed against a
real reproduced crash -- a GE_INPUTSCRIPT-driven stress attempt this
session didn't visibly activate the scripted-input harness for a
reason not root-caused; the honest bar met here is "correct by D4/D152
precedent, build/sweep-clean," not "reproduced-then-fixed."
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Menus read the same stick_x/stick_y channel as in-level gameplay, and
naturalPitchMode (SOLITARE-style, default-on) routes that channel to
the right stick for continuous look. Since front.c's menu nav shares
the channel, it silently inherited the right-stick routing, which is
unintuitive on a modern pad/Deck outside a running stage. The gamepad
poll already computed an unused padMenuMode flag for shoulder-button
gating; reused it to always source stick_x/stick_y from the left
stick outside RUN_STAGE, regardless of naturalPitchMode. Port-layer
only, inert during gameplay -- verified via -level_09/-level_20
unregressed; in-menu feel needs a human controller playtest.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Root-caused via a live Steam Deck session over SSH: sub_GAME_7F09BAC4
(vtxstore_fix_refs) read an active OBJ record's Model* through
((Model*)((ChrRecord*)prop->chr)->chrflags) - a union pun onto
ChrRecord.chrflags, which is typed CHRFLAG, a 4-byte enum. On 32-bit
N64 this is bit-exact (the enum and a pointer are both 4 bytes, so
casting the enum's value to Model* reproduces the real stored bytes).
On 64-bit PC, Model* is 8 bytes but the enum read is still only 4 -
the cast reads just the pointer's low 32 bits as an int and
zero-extends it back to a 64-bit address, discarding the real high
bits. This is the D3x pointer-width ABI class from AGENTS.md - a
struct field misread across the 32-to-64-bit pointer-width transition.
This was actually flagged as suspect (a) in the very first D255
investigation, then wrongly deprioritized in favor of a "destroy-path
race" theory once a register capture happened to show Rdi=(nil) and
looked like it confirmed a different, unrelated NULL-model theory. It
didn't: (nil) is exactly what a 32-bit-truncated read produces
whenever the real pointer's low 32 bits happen to be zero - a
coincidence that pointed two sessions' worth of investigation at the
wrong field. Proof: reading the same record through the correctly
64-bit-typed ObjectRecord.model field (this session's diagnostic
probe) was always a valid, non-NULL pointer - the object's model was
never actually missing.
Fix: read the pointer through ObjectRecord.model (the same union
memory, correctly typed) instead of through the mistyped
ChrRecord.chrflags alias, at both use sites in the function. Identical
bytes and identical result on N64; correct on 64-bit PC.
Verified live on the user's own Steam Deck hardware over SSH, running
their own prepared ROM assets: held through the entire Facility level
including repeated terminal destruction (the most consistent prior
repro) and through Caverns' radio-objective terminals (the second
confirmed level), zero crashes. Windows rebuild + full 21-level
verify.sh sweep also clean.
The separate audio-thread crash (D285, caught during this same live
session) is unrelated and unaffected by this fix.
Batched pass across README/release-notes/Pages (one commit instead of the
scattered per-fix pushes earlier this session, per new commit-cadence
preference):
- New known issues surfaced that weren't documented anywhere: no real
widescreen (16:9 stretches 4:3 rather than expanding FOV), no controller
rebinding UI / macOS-ARM builds, culling/LOD dropout on Streets/Egyptian
(D249), gunshot SFX oddities under sustained fire (D240/D241).
- Fixed a stale claim (in three places) that a wrong-sounding-instrument bug
was still open; it was fixed in v0.2.0 (D230).
- Softened the D259 "All unlocked" fix to reflect reality: still breaks
audio/aim if enabled before any save exists; flagged experimental
(new finding D281).
- Corrected D255's repro to the precise, deterministic trigger found this
session (Facility's door-opening computer terminals, Linux-wide not
Deck-specific) instead of the vaguer original combat-crash description.
- Logged D282 (front-end menus need right stick not left on gamepad) and
D283 (Steam Deck first-run preset skipped by an early Desktop Mode
launch) with user-facing workarounds.
- Refined D263 (Ourumov/Trevelyan) internally with a position-sensitivity
caveat; no public bullet since it doesn't block progression.
- Added pre-1.0 framing and a new README Roadmap section (known-issues
cleanup, PAL/JP, real widescreen, rebinding, macOS/ARM, LAN multiplayer
explicitly "under consideration, no ETA", plus an explicit "not currently
planned" list) across README/release-notes/Pages.
- Added an AI-development disclosure (agentic development as a genuine
study of the process, not just a port) across the same three docs.
- Pinned the release-notes hero GIF to the v0.2.1 tag instead of v0.2.0.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Bing Webmaster re-flagged the site after the first SEO pass (32f193a0):
- Every Jekyll-processed doc page (building.md, dev-process.md, internals.md,
porting-notes.md, dev/agentic-development.md, plus index.md caught last
time) opens with its own "# Heading" on top of the Cayman theme's masthead
<h1 class="project-name">, so all of them emitted two h1 tags, not just the
homepage. Demoted each page's opening heading to h2 -- anchor slugs are
unaffected (kramdown ids don't depend on heading level).
- index.md's meta description was 161 chars (1 over Bing's 160 limit) and
still said v0.2.0; re-trimmed to 133 chars and bumped to v0.2.1.
- README.md and docs/index.md's Status lines/comparison table bumped from
v0.2.0 to v0.2.1 to match the actual current release.
Verified against a live, cache-busted fetch of the deployed Pages site
(the first Bing report was reading a stale CDN edge cache from before the
32f193a0 deploy, per its own Last-Modified header) that the underlying fix
was already live; this pass addresses what was still genuinely wrong.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
User testing found the slider "basically useless" -- viShake() only scales
explosion/effect shake (explosion.c), never the always-on walking head-bob
or a getting-shot reaction, which is what "Screen shake" implies to a
player. Removed the menu row until it covers all screen-shake/view-bob
sources, not just explosions. Config var + fr.c hook untouched.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
User spot-check of the v0.2.1 draft found that enabling Skip intro breaks
audio. It was already off by default (D216); removing the menu row so
players can't enable it until the audio interaction is root-caused. The
config var and lv.c hook are untouched (dead unless an existing ini already
has it set).
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
The v0.2.1 docs commit (572ab7bf) added GE-ENV-PROBES.md rows for the
D266-D280 tree-investigation probes, but that probe code itself was
deliberately left uncommitted (still mid-investigation, unswept). CI's
tools_pc/gen_env_probes.py --check caught the mismatch: 13 documented
vars with no live getenv() site in the committed tree. Reverting the
doc rows to match what's actually committed; they'll return together
with the probe code once the D236 investigation lands.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Records the v0.2.1 hotfix batch (D259-D264, this commit's code) and the
ongoing D236 Surface-1 tree-wall investigation (D265-D280): still open as of
D280 -- fog math verified N64-faithful, the noise class confirmed as the
weave's painter, the portal-culling over-draw fix (D271) real but
insufficient, no quick win identified. The D266-D280 probe code and the
D266 IA4 decode / portal-culling fixes themselves stay uncommitted pending
the game-wide regression sweep GRAPHICS-BACKLOG.md flags.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
All six TCRF article bodies were extracted (backlog/cut-content/articles/,
gitignored) and cross-referenced against src/ + assets/obseg/. Full verified
findings with file:line refs land in backlog/cut-content/RESEARCH.md.
Verified 1:1: the 11 unused cheats (CHEAT_ID enum vs cheat.c grant table),
music track index (MUSIC_TRACKS enum vs Notes-page GS index), MP character
table (~40 entries, 1.0446 eye height), all unused text strings (LmiscE/LgunE/
LpropobjE/LtitleE), build dates (compiletime.c), Spectrum emulator plumbing
(front.c MENU_SPECTRUM_EMU — only the transition is missing).
Corrections to TCRF/first pass: bg_dest is shipped Frigate geometry (not an
early version); cryp/dish are shipped Egypt/Temple; Library/Stack share
Basement's bg_ame; 28 of 29 unused items exist in the object table.
Tracked doc updated to point at the research file; backlog/ gitignored.
Cross-reference the local TCRF 'cutting room floor' export
(scratchpad/backlog-scrape/restore-cut-content-mod, gitignored) against the
decomp source and extracted asset tables. Article bodies were lost to a
Cloudflare challenge at scrape time; findings rest on the ~170 intact article
images + all image filenames, each mapped to ROM object/level evidence:
- A: full debug menu (U+D C-hold) decompiled in debugmenu_handler.c — free cam,
level switch, ramrom record/replay, screenshot grabs; all compiled into PC build
- B: cut weapons (taser/rocket/grenade/P90/spectre/autoshot) have models + stats
+ SFX; rocket/grenade/P90 grantable via existing cheat codes (cheat.c)
- C: 'unused items' page maps 1:1 to file_resource_table entries with use-logic
- D: 8 commented-out MP map entries in front.c with dev notes (Citadel/Statue/
Cradle/Aztec/Runway/Dam/Depot/Frigate)
- E: per-level unused props + orphaned level geometry (Crypt has bg+briefing but
no LEVELID; len/dish/cat/oat/arec/ame/ark/ref dirs beyond shipped set)
- F/G/H: unused faces, prerelease comparison pairs, dev-build video embeds
Feasibility tiers 0-3 with the port-layer-only constraint called out.
- Drop-in ROM wording everywhere (README quick start, Pages landing,
release notes, bundled README): first launch auto-generates the
derived assets via the frozen converter; no Python/toolchain needed.
PAL/JP marked not-yet-supported with issue #85 pointers.
- building.md: Linux build is CI-compiled and ships in the bundle
(was 'never built or run on Linux'); bundle note now describes the
frozen ge007-convert auto-spawn; PAL/JP sidecar caveat (D258).
- Status rewording across README/Pages/release notes: drop the
'first open-source port to our knowledge' claim; cutscene glitch
description aligned to 'mostly with James Bond'; index.md status
no longer contradicts the open D255 Deck crash.
- Em-dash cleanup across all GitHub Pages docs (index, building,
internals, porting-notes, dev-process, agentic-development,
release notes); internal dev logs untouched.
* Drop-in ROM: first-run sidecar conversion, no Python (issue #6, Part A + interim)
The release bundle now turns a dropped-in NTSC-U ROM into a playable game
with zero tooling: when data/pcmodels-* / data/pccg-* are missing at boot,
the engine spawns the frozen prepare-assets converter shipped in the bundle
(prepare-assets/ge007-convert[.exe], PyInstaller --onefile), waits for it,
and re-checks before continuing (port/src/romconvert.c, called from
romdataInit after ROM validation). Failure aborts boot with a message
instead of the later D179-class crash.
- prepare-assets.py: runs in-process via runpy when frozen (no user Python
to exec); NTSC-U-only gate with a clear message for PAL/JP ROMs; d88's
sibling import needs an explicit sys.path entry (run_path does not set
one). Verified: frozen exe output byte-identical to the unfrozen path and
to the shipped sidecars.
- CI (linux + windows): PyInstaller freeze step before bundling
(ensurepip / --break-system-packages for MSYS2's pip-less python).
- bundle scripts: copy ge007-convert into prepare-assets/ (hard-fail if not
frozen), size guard 60 -> 120 MB.
- Release notes: US-only drop-in instructions, no Python required.
- D258: PAL/JP filelist naming defects documented; multi-region deferred to
the Part B follow-up.
* CI: install pip via pacman on Windows (MSYS2 ensurepip is disabled); regen findings index for D258
The one broken bass instrument on Control/Cavern/Runway is a plain
byte-order bug in a corner the earlier passes never looked at. The
instrument bank's only two AL_RAW16_WAVE wavetables (instruments 2 and
51, sharing base 0x2118 / len 790) decode as a clean ~110 Hz soft-attack
bass sample when read big-endian s16 -- which is how the N64 CPU reads
them. The PC mixer's resampler consumes host-native (little-endian) s16,
so every sample arrives byte-swapped: deterministic per-instrument
corruption, exactly the M-106 repro. ADPCM nibble data is byte-order
agnostic, which is why every other instrument sounds right.
romdataFinishCartMap() now walks both bank trees (same ALBankFile layout
as romdataFixupAudioBank) and bswap16s each RAW16 [tbl+base,+len) range
in place in the cart map -- where alBnkfNew() points the mixer at it
directly. Shared ranges are deduped (swapping twice undoes the swap);
all reads bounds-checked. D55 in-place-fixup idiom; port-only, no game
logic touched.
Startup log: 'romdataInit: D230 byte-swapped 1 RAW16 wavetable range(s)
in place'. Owes a by-ear pass on the three repro tracks.
findings.md D230 -> FIXED with full root cause; release-notes.md drops
the music known-issue bullet.
* README: 32 s gameplay preview montage (mp4) + preview-video runbook
* Prune: drop superseded goldeneye-demo.gif (replaced by gh-preview montage)
* Docs: v0.2.0 finalization pass — final wording, aligned known issues, Deck-verified line, social preview card
- README + pages site: 'v0.2.0 (pre-release)' -> v0.2.0 throughout; the
stale 'not yet verified on real Deck hardware' line is replaced with the
playtest fact (D253 fixed, D255 still open).
- Known-issues lists in README + site now match the release notes one-for-one
(adds the F10 bottom-row duplicate D251 and the intermittent Deck SIGSEGV
D255; D243/D230/D252/D245/D246/D75 carry their finding labels).
- Working list mentions AllUnlocked default-ON (D257) with the F10 opt-out.
- First-public-release note added to the README hero and site status line,
hedged with 'to our knowledge'.
- social-preview.png (1280x640 GitHub link card) composed from a gameplay
frame of the preview montage; scratchpad/make-social-preview.py is the
local recipe (gitignored, like make-preview.py).
- Commit docs/img/shots/manifest.csv (gallery -> source recording map);
gitignore the regenerable CONTACT-SHEET.jpg; drop scratch-gdb-cmds.txt.
* Social preview: regenerate card from updated briefing HTML (v0.2.0 final wording)
The PNG committed earlier was a screenshot of the rendered site (stale
wording, video element unrendered). The canonical source is
.github/social-preview.html per .github/social-preview.md; update its
Status/Statistics lines to the v0.2.0 final state and re-render:
- Status: RELEASED v0.2.0 — WINDOWS · LINUX · STEAM DECK
- All 21 solo missions — full campaign completed in playtesting
- ~22k lines C/C++ · ~10k Python (port layer + tools)
- ~250 bugs root-caused · two coding agents, one human, part-time
Verified by pixel-diff against the previous render: only the four text
bands changed; title/fonts/layout identical. Drop the unreferenced
root-level social-preview.png.
* docs: refresh social preview + status for v0.2.0 playtest results
- Social card: 'RELEASED v0.2.0', 21/21 missions completed in playtesting,
~250 bugs fixed, two agents + one human; re-rendered the PNG from the
canonical .github/social-preview.html source (pixel-diff vs the old render
shows only the four edited text bands changed)
- README Status: campaign now claimed as completable end to end (user
playtest: full campaign, Agent difficulty, PC); Working list matches
- findings: D255 updated with the Deck register capture (Rdi=nil -> NULL
ObjectRecord.model, destroy-path ordering case confirmed; truncation ruled
out) and the rule-#2 sign-off question for a guard; D254 field-verified
* docs: regenerate findings index (D254/D255 status updates)
Port-layer only (no game-code edits):
- main.c seeds debug_enable_all_levels_flag + debug_007_unlock_flag
after configLoad() when Game.AllUnlocked is set (same class as the
existing GE_UNLOCK_ALL getenv hook).
- libultra.c eep shim patches the save block at read time (the exact
fileValidateSaves pattern: block 4, 5x96 bytes): sets all 20
progression-gated cheat-unlock bits per slot and fills every empty
stage/difficulty completion time with the max (0x3FF), preserving
existing player times, then recomputes each slot's CRC with the
game's own fileGenerateCRC so validation passes. The completion-time
half is required: with any SP cheat active, g_AppendCheatSinglePlayer
makes mission select accept only STAGESTATUS_COMPLETED levels, so an
unlock-only save locks every level once a cheat is turned on.
- optionsoverlay.c: 'All unlocked' toggle row (default ON for this cut).
Verified: fresh-eep GE_SAVELOG shows all 5 slots stored==calc; user
playtest confirms all levels at each difficulty, 007 mode, populated
cheat menu, cheats working, mission select intact with all cheats on.
romdataInit finds the ROM in CWD data/, exe-dir data/, or ./, but the
POSIX $S branch only ever produced a CWD-relative path, so any launch
from another directory ran fine while every EEPROM and ini write failed
silently (no ./data/ to fopen into) — saves never persisted and F10
settings reset on every launch. Mirror the Windows branch via
readlink(/proc/self/exe) and mkdir the target dir best-effort. Verified
on WSL with exe+data in /tmp and CWD=/: config, eeprom, and ROM all
land in <exedir>/data/.
The POSIX handler re-entered on SIGABRT (sysFatalError -> abort) and
truncated ge007.crash.log, so field crashes came back as bare abort
records with no faulting PC or registers. Guard against re-entry,
terminate directly with _exit(), and dump si_addr + the general register
set from the ucontext — a Deck crash now names the bad pointer on its
face.
Also documents D255 (new SIGSEGV in vtxstore's active-object walk during
a Facility firefight on the Deck; disassembly shows the fault is a
32-bit-zero-extended read of ObjectRecord.model, misnamed ChrRecord.
chrflags by the decompiler) and updates the release notes' Deck status.
- D197 (Silo face wrap): closed per user playtest, dropped
- D75: reworded — Nintendo logo still broken, Rareware logo filtering off;
mission-complete/mode-select screens have no 3D models (old framing wrong)
- F10 ghost row: now deterministic bottom-row duplicate of the selected item
(D251 addendum); earlier intermittent 4K framing superseded
stanCheckLinkedSpecialTile was decompiled with an all-s32 middle-arg
prototype while its callers pass it through the standTileLocusCallback_B_t
typedef (f32 f32 f32). On x86-64 SysV the int/float class split puts the
6th arg (outFlags/record) in %rdx per the typedef but %r9 per the
definition, so the FORCECROUCH/LADDER store hit a stale scratch register.
MIPS o32 passes both through GPRs a0-a5 identically, which is why the
mismatch was invisible on N64 and in the decompilation.
ABI-class edit (AGENTS.md #2 narrow exception): body never uses the three
middle params; N64 codegen unaffected. Verified under gdb on WSL:
pre-fix SIGSEGV at stan.c:2441 with r9=0x3 and a valid record in the
caller frame; post-fix the same walk route completes clean.
User playtest report: bullet-impact sparks and lingering smoke/explosion
residue drift through the full hue wheel over time instead of holding their
intended grey/orange palette. Logged in findings §F (D252) with this
session's mechanism work: particle colour comes entirely from the 15
distinct IMAGE_SMOKE_N x IMAGE_FIRE_N (RGBA16) pairs the particles age
through (assets/oddtextures.c records, white default vertex); the M-82/D172
tile-offset fix and the 5551 decode are both verified correct for this path;
prime suspect is import_texture_rgba16 deriving width/height from the tile
line size (32x7 instead of 16x14 for the 16px-wide fire tiles), which would
scramble each aging stage into a different wrong hue -- exactly the observed
slow rainbow sweep. Per user instruction: documented only, ships as a known
issue (README + release notes + GRAPHICS-BACKLOG); index csv regenerated.
Per the M-82/M-106 user decision: click-to-lock is now the only cursor
capture mode. Port-only deletion, no game-code touch:
port/src/input.c drop mouseCaptureMode + Input.MouseCaptureMode key;
simplify reconcileGrab/notifyClick/releaseCapture/
aimGepdCompute guards; delete the !haveAbs relative
P-controller menu path and its dead state (menuEst/Tgt,
menuCursorStep, MENU_P_GAIN/EST_DELTA/MOUSE_TO_PX);
drop the unused inputMouseCaptureActive() export
port/include/input.h update WI-1 comment, remove the dead declaration
port/src/optionsoverlay.c remove the 'Mouse capture' toggle row + labels
Existing ini files that set Input.MouseCaptureMode are ignored (config layer
skips unknown keys). Closes D192 (outer grid cells unreachable under mode 0)
and D239 (always-grab parity) by removing the bugged path rather than
patching it.
Known issue (logged in D251): intermittent ghost repeat of a top row at the
panel bottom in true 4K fullscreen; local repro clean, MSAA-off bisection
pending with the user.
* D222/D218: fix high-FovScale culling + add Video.DrawDistance
D222 (high FovScale breaks screen-edge culling): currentPlayerSetCameraScale()
derives both the frustum-cull plane normals and the fog/LOD
distance-visibility-fade scale (c_scalelod/c_lodscalez) from c_perspfovy,
which every viSetFovY/viSetAspect/viSetFov/viSetZRange call in fr.c was
setting from the nominal fovy -- never the portFovScale-widened value D211
already computes for the actual render projection two lines away. Factored
the widen-FOV math into a shared port/src/video.c helper (portScaleFovY,
same logic D211 already had inline) and fed it into all 4 currentPlayerSetPerspective
call sites. Identity at FovScale=100 by construction. Audited: the affected
fields are consumed only by render-side fog-visibility-fade and screen-space
transform code (propobj.c/bondview.c), not NPC AI visibility/behavior.
D218 (wide FOV exposes the fog/far-clip boundary): implements the
previously-parked plan. Video.DrawDistance (percent, 100-400, explicit
override) and Video.DrawDistanceAutoFov (couples the multiplier to FovScale
by default) applied in fogLoadCurrentEnvironment (bgfog.c) to both the
far-clip plane and the fog-blend distance read, so they stay consistent.
Clamped to [1.0, 2.5] per the plan's own far-field z-fighting caution.
Both new knobs added to the F10 overlay alongside FovScale.
Rebuilt clean, crash-free at FovScale=150/DrawDistanceAutoFov=1 on Dam.
bunker1 golden framediff was inconclusive in this dev box -- confirmed via
an A/B stash/rebuild that the unmodified baseline also fails it by a
similar/larger margin (pre-existing frame nondeterminism, not a regression
from this change).
D249 (default-FOV culling, sometimes) is unrelated/still open -- different
mechanism, not touched here.
Needs a human's eyes: does high-FOV screen-edge culling and the wide-FOV
fog/draw-distance artifacting actually look fixed now.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
* D249 follow-up: add Video.LodDistance geometry-LOD scaling
Found a real, distinct geometry-LOD system in src/game/model.c:
MODELNODE_OPCODE_LOD nodes whose children are shown/hidden per-frame by
modelUpdateDistanceRelations() testing a view-space distance (scaled by
getPlayer_c_lodscalez(), now FOV-consistent per this branch's D222 fix)
against each node's MinDistance/MaxDistance -- separate from the
fog-visibility-fade D218 already covers. It has an existing ground-truth
multiplier hook, g_ModelDistanceScale, already used transiently by the
game itself (chr.c's 0.3125f close-up override, front.c/title.c's menu
modelSetDistanceDisabled calls).
Rather than write to that shared global (would fight those existing
overrides), compose a second multiplier at the same call site:
portLodDistanceMultiplier() (port/src/video.c), driven by new
Video.LodDistance (percent, 25-400, default 100) and
Video.LodDistanceAutoFov (default OFF, unlike DrawDistanceAutoFov --
this is meant as a standalone perf lever to pull down, not something
that silently costs more as FovScale widens). Inverse-scaled: 200%
keeps full detail twice as far (more cost), 50% drops to the lower LOD
twice as soon (less cost). Both new keys in the F10 overlay.
Identity at LodDistance=100. Rebuilt clean, crash-free at LodDistance=40
(aggressive) on Dam and at defaults on bunker1.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
* D222/D218 follow-up: fix guard fade-early regression, retune draw-distance coupling
Live playtest on Dam found two issues in the D222/D218/D249 work:
1. Regression: D222's c_lodscalez widening (correct for the screen-edge
cull planes) is also read directly by propobj.c's
chrobjFogVisRangeRelated()/sub_GAME_7F054C58() -- the character/prop
fog-visibility-fade cutoff. Uncompensated, this made guards fade
sooner at wide FOV than before D222. Fixed by dividing that same
c_lodscalez reading by portDrawDistanceMultiplier() at both call
sites, so the "how far can you see" question stays answered
consistently across both consumers.
2. Tuning: a straight 1:1 FovScale auto-coupling on
portDrawDistanceMultiplier() wasn't aggressive enough -- live-tested
up twice (1x -> 2x -> 4x FovScale, clamp ceiling raised 2.5x -> 4.0x
-> 6.0x each time) until guards held up and the Dam tunnel's blue fog
artifact receded. Re-verified the fog ramp itself (not just the
far-clip cutoff) scales with the multiplier end-to-end.
User confirms this is good for now.
* D250: fix sysSleep unit-conversion bug in frame-pacing; add GE_D250 lvlRender probe
port/fast3d/gfx_sdl2.cpp sync_framerate_with_timer() computed the
remaining frame budget in 100ns units (qpc_to_100ns) and passed it
straight into sysSleep(), which takes microseconds -- a real 10x
oversleep whenever that branch fires. Genuine bug, but only active
when target_fps is nonzero (tied to Input/Video FpsCap); confirmed via
live testing that it is NOT the active cause of the user's reported
~45-50fps with FpsCap=0/VSync=0, where this pacing path never runs.
Added GE_D250 (src/boss.c, #ifdef PORT) to isolate lvlRender()'s
DL-building cost (culling/animation/scene traversal) from gfx_run's
already-measured software-RSP interpretation cost. Live test on Dam
shows lvlRender averaging ~0.5-1.5ms -- also not the bottleneck.
Documented in docs/dev/GE-ENV-PROBES.md (GE_D250 row). findings.md
D250 update to follow after rebase.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
* docs(D250): live A/B session -- MSAA/aniso/FOV/resolution/vsync all ruled out
Live session with the user driving the game directly, confirmed the
sim-healthy/render-sagging pattern from earlier idle testing reproduces
under real play on Dam. One-at-a-time A/B via the F10 overlay ruled out
MSAA, anisotropy, FOV, resolution (1280x960 fullscreen vs 640x480
windowed), and VSync (render never exceeds ~60 even uncapped) as the
cause -- the cost is resolution/GPU-setting-independent.
gfx_run (~6-10ms) + lvlRender (~0.5-1.5ms, via the new GE_D250 probe)
together don't account for the real ~20-22ms/frame. Leading remaining
suspect: the emulated N64 thread-handoff/scheduler round-trip
(gesched.c/sched.c) -- real OS context-switch/wake latency standing in
for what was free hardware handoff on N64. Needs a real profiler
(Visual Studio / WPR) attached during a live bad-fps moment to confirm;
ad-hoc per-call timers have now ruled out the two most obvious CPU call
sites without finding the cost.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
* D250: fix uncached getenv() in fast3d hot paths -- root cause of the fps drop
A live WPR/xperf CPU trace (tools_pc/perf.ps1, new sister script to
debug.ps1) with Microsoft public symbols resolved found the dominant
render thread spending only 5.2% of its CPU time in the game's own
code -- 43% was in msvcrt.dll, and getenv()/_getenv_helper_nolock
alone accounted for ~50% of the thread's total CPU time.
Root cause: port/fast3d/gfx_pc.cpp's import_texture() (runs on every
texture bind) and several per-triangle/per-G_SETCOMBINE debug-probe
checks (GE_DTEX, GE_TEXDUMP, GE_TEXRAW, GE_D229 x2, GE_D172 x2, plus
GE_TEXDUMP/GE_PCDUMP in gfx_opengl.cpp) called getenv() uncached on
every call, unlike the safe static-cached pattern already used
elsewhere in this codebase. On a texture-heavy scene that's tens of
thousands of locked, locale-aware Windows CRT environment lookups per
second -- invisible to both of the timers added earlier this session
(gfx_run's own cost measurement and the new GE_D250 lvlRender probe),
since neither isolates CRT/library time from the game's own code.
Fix: cache every getenv() site in gfx_pc.cpp/gfx_opengl.cpp with the
established static-int pattern. Narrow, mechanical, no logic change.
Verified: the exact idle-Dam test that was pinned at 45-50fps before
the fix now holds a rock-steady 60-61fps, zero dips. Also explains why
the earlier MSAA/aniso/FOV/resolution/vsync A/B pass all showed "no
change" -- the getenv() cost was a large per-call constant that
swamped any real, smaller resolution-dependent GPU cost at every
setting tried.
Residual, separate issue found once this was fixed: still runs badly
at 1440p+ (640x480 now holds a clean 60fps) -- likely the real GPU
fill-rate cost the getenv() bug was masking, now exposed on its own.
Not yet investigated.
docs/dev/findings.md D250 updated with the full root-cause writeup.
docs/porting-notes.md gets a new lesson under section E (cache
getenv() in any hot-path probe). tools_pc/perf.ps1 automates the
trace-and-analyze workflow for next time.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
* docs(D250): correct the 1440p+ follow-up -- it was a stale-build false alarm
User re-tested 1440p/4K on the actual getenv-fix build and confirmed
it holds up fine too. The "still runs badly at 1440p+" report was
from before the fix was live, same stale-build class as the DLL-load
false alarm earlier in the session. D250 is fully resolved across all
resolutions tested, not just 640x480.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
* docs: add missing GE_D219RAW row to GE-ENV-PROBES.md (CI validate fix)
Pre-existing gap unrelated to this branch's own changes -- gen_env_probes.py
--check caught it now since it runs on every PR. GE_D219RAW has had live
getenv() sites in src/game/image.c since the D219 investigation but was
never added to the probe doc.
Build flags confirmed as -O2 RelWithDebInfo by default, not the cause.
GE_D193 idle-load probe on bunker1 and streets both hold a healthy
55-60fps in this dev box, well under the 16.7ms/60fps budget -- does
not reproduce the user's reported ~45fps at 720p on a high-end PC, so
the real cost is likely gameplay-load-dependent (AI/combat/particles)
or resolution/GPU-fill-rate-bound in a way idle testing doesn't
exercise. This session had no interactive input tooling to drive real
gameplay headlessly.
Ruled out in passing: the D51 vi-post debug log is throttled to
~once/sec, not a per-frame cost.
Next step needs a human: capture GE_D193=1 output during a live
session at the moment fps visibly tanks, to tell sim-side cost from
render/GL-side cost.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
User A/B playtest of PR #62 (fix/d194-mouse-dt-decouple, rebased onto
main this session) confirms hipfire/primary mouse-look feels right now
(dt-decouple + SOLITAIRE natural-pitch), but RMB/Shift aim-mode is
still too sensitive and not usable for precision aiming.
Static re-read of the aimHeld branch in port/src/input.c finds a likely
cause the M-107 curve fix didn't reach: AIM_STICK_MIN=61 is a hard
floor the stick snaps to on any motion past a 0.3px/poll threshold --
the aimCurveGamma response curve only shapes the range above that
floor, so tiny mouse nudges still jump straight to 10% of max aim-turn
speed instead of ramping from zero, unlike the (working) hipfire path.
Not build/runtime-verified -- a hypothesis, not a confirmed fix.
PR #62 stays held, unmerged, pending another aim-mode iteration.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
M-120: dispatched a local-Qwen triage pass on D230 (music quality) and a
Claude subagent on D243 (Dam cutscene race), per the roadmap triage.
D230: the dispatched hypothesis (afFixupBook's BE->LE book-coefficient
conversion in port/src/romdata.c is backwards) does not survive review —
it's the same conversion pattern used for every other field in the same
function, and if it were wrong every ADPCM sound would be corrupted, not
one bass instrument on three tracks. Ruled out; the standing M-108 leads
(predictor-count truncation / order mismatch in port/src/mixer.c) remain
the live hypothesis.
D243: the subagent wrote the planned GE_D243 env-gated MENU_* transition
trace (src/game/front.c only, statically reviewed sound) but could not
build-verify it — hit a reproducible CMake/ninja-vs-bare-gcc environment
failure ("Cannot create temporary file in C:\Windows\"). Independently
reproduced the same failure in a fresh worktree this session. Diagnostic
is pushed on investigate/d243-cutscene-race (2a47f110), not merged —
needs a session with a working toolchain to build, run the -level_33
repro, and correlate against the D146 burst.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
Findings-index regen (tools_pc/gen_findings_index.py) — only D194's row
had actually drifted (fix direction implemented since, on PR #62, not
"not implemented" as the cell said). GRAPHICS-BACKLOG.md's D233/D234
rows were also stale ("not investigated") despite both being fixed and
verified since M-107/M-108 per findings.md.
New findings from user QA (M-119):
- D249: LOD/culling breaks on far geometry on big open levels (Streets,
Egyptian) at default FOV; distinct from D222/D218. Also a QoL ask for
a togglable LOD improvement, N64-accurate LOD kept selectable.
- D250: FPS now drops to 20-40 intermittently on many levels, a likely
direct consequence of D248 (M-118) exposing real per-frame cost that
was invisible while the port silently ran at half rate.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
osScAddClient() stashes a per-client "every retrace vs every other
retrace" flag in the next array slot's .next field (c[1].next = next);
__scHandleRetrace read it back via a hardcoded 8-byte offset that only
lands on the right memory when OSScClient is 8 bytes (32-bit
pointers). On this 64-bit port the struct is 16 bytes, so the read
landed inside the gfx client's own msgQ pointer (always non-NULL)
instead, silently collapsing the gfx client onto the audio client's
30Hz path even though the VI-retrace pacemaker itself was always
correctly ticking at 60Hz.
Fix indexes the struct (client[1].next) instead of hand-rolling the
byte offset, so it scales with pointer width on either platform --
same class as D122/D126/D132/D209, just in the scheduler instead of a
ROM-serialized struct. Verified via the Video.DisplayFPS overlay:
30 FPS pre-fix, 60 FPS post-fix, same build/scene (-level_09).
-level_09/-level_20 both crash-free post-fix.
GE_TEXDUMP on Facility (-level_34) found ~14% of LOD-textured CI uploads
whose palette is IA16 (intensity-alpha, not RGBA16) rendering solid
near-white. A GE_D228 correlation probe ruled out the texpool/RC2-clip
hypothesis this row started with -- decoded size exactly matched the base
tile every time, so the data reaches import_texture fine.
The real bug: palette_to_rgba32's G_TT_IA16 branch read
intensity = palentry & 0xff (low byte), alpha = palentry >> 8 (high byte)
-- backwards relative to the already-correct direct-IA16 decode
(import_texture_ia16) a few lines above it in the same file, which reads
intensity from the first byte (high, after gfx_dp_load_tlut's PD_BE16
swap) and alpha from the second. A dim, fully-opaque palette entry (e.g.
intensity=0x58, alpha=0xff) decoded backwards as bright and
mostly-transparent -- the white-patch symptom.
Fix: swap the two lines to match the working convention.
User-verified live: AK47 and other metallic guns render correctly.
Headless -level_34 run crash-free pre-verification; a full sweep is
still owed (the user's own game instance was running concurrently this
session, so verify.sh/taskkill were avoided).
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
User report on Frigate post-fix: water still glitches between two patterns
and still changes with view angle, unlike the sky (which uses the same
skyPortBeginFan/skyPortEmitTileShift mechanism post-D227 and no longer has
this problem). The mechanistic writeup and GE_D227V trace from the same
session looked correct but were evidently not sufficient evidence -- a
single static frame/angle capture never exercised whatever actually causes
the live symptom. Reopening honestly rather than leaving a stale FIXED
label; the superseded analysis is kept in the row for reference but flagged
as unconfirmed. No code change in this commit (docs only).
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
GE uses real N64 RSP environment-mapping (G_TEXTURE_GEN) for every
shiny/reflective material: gold/silver weapon skins, chrome trim, and
Control's reflective console glass. An earlier session (D72.1) removed
the PD-inherited envmap UV-generation block wholesale from gfx_sp_vertex
to fix the Rareware logo, reasoning "GE never generates texture
coordinates from vertex normals" -- true for the logo (lit, but doesn't
set G_TEXTURE_GEN) but false in general. Every envmap draw was left
sampling whatever raw tc[] happened to be authored (near 0), landing on
a fixed corner texel of the reflection texture regardless of view angle
-- reading as solid black for a typically dark-edged reflection map.
Restored the envmap UV generation (ported from the reference Perfect
Dark PC port's already-correct implementation), gated specifically on
rsp.geometry_mode & G_TEXTURE_GEN so D72.1's actual fix is untouched
(the logo never sets this bit) while every other envmap draw gets real
reflection coordinates back.
User-verified live: console glass and gold/silver guns are now visibly
reflective instead of flat black. The remaining "not translucent"
complaint on the glass is confirmed NOT a bug -- it was never see-through
on the original N64 either (reflective by design, not transparent).
Full 21-level sweep crash-free post-fix.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
Display pipeline pass on the two remaining M-116 QA reports.
D247 (black bars top/bottom): traced to bondviewGetCurrentPlayerViewportHeight()
returning VIEWPORT_HEIGHT_DEFAULT (220 of 240 NTSC scanlines, fr.h) for the
decomp's own default SCREEN_SIZE_FULLSCREEN option -- unmodified ground-truth
code, no #ifdef PORT anywhere near it. Verified against both the existing
save and a fresh EEPROM (rules out a stale Screen=Wide/Cinema setting from
earlier sessions) -- same inset either way. This is GoldenEye's real
TV-safe-area letterbox, present on original hardware. No code change.
D246 (left/right edge strips): GE_PCDUMP captures of the native render show
clean edge columns, ruling out an RDP/game-DL scissor bug and the D227/D245
tc-overflow class -- the defect must be introduced later, in the SDL window
presentation path GE_PCDUMP bypasses (MSAA resolve blit or a live-window
scale remainder). Still needs the user's window size/MSAA setting to chase
further; documented as a narrowed OPEN with concrete next steps.
New porting-notes.md entry: check the decomp's own constants before
assuming a rendering "defect" is a port bug.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
The water quad's fan was the one skyPortBeginFan() caller passing
allowShift=FALSE, on the theory that a matched tc-shift on both texunits
would flatten the sinf()-driven cross-fade the effect depends on. That
theory was wrong: skyPortEmitTileShift() replays both tiles' SETTILE
commands with the identical k, rescaling both texunits' decoded S/T by
the same factor before each tile's own (unshifted) uls/ult offset is
applied by the RDP -- the offset between tile 0 and tile 1's sample
points is preserved, not flattened.
Confirmed with the GE_D227V probe on Frigate (-level_26): a water fan
vertex spans S=-166..299317, T=-6451..288410 in S10.5 units (same order
of magnitude as D227's pre-fix sky overflow), now correctly getting
kS=4 kT=4 instead of being forced to 0.
verify.sh frigate and "surface 2" (the two IsWater levels) both PASS
crash-free. Live playtest of the mouse-tracking seam itself still owed.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
texSelect loads the water image as ZLIB+CI8 (raw 8-bit palette indices);
GE's custom RSP ucode expands them to RGBA16 in TMEM at load time, so a
later RGBA/16b tile re-declaration over the same slot samples real blue
pixels. fast3d has no such expansion and read the raw index byte-pairs as
16-bit texels (g dominates -> green mottle); the "pulsing" was always the
pre-existing PRIM_LOD_FRAC sinf() cross-fade between two mis-decoded tiles.
Track each TMEM load's source G_IM_FMT in LoadedTexture.src_fmt; in
import_texture, an RGBA/16b tile over a CI-sourced slot now routes through
the CI8 palette import path instead. Fires only on the genuine format
disagreement, so real RGBA16 textures are untouched.
Verified live on Frigate (-level_26): water renders blue, user confirms
"looks better". Sweep + Surface 2 by-eye still owed.
Also files D245 (water seam), D246 (edge strips), D247 (black bars) from
the same playtest session.
Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
M-115: re-apply M-113's store-time bswap scoped to the 16-bit GBI-size
family only (PORT_PIXEL16 = bswap16 under #ifdef PORT; PORT_PIXEL32 stays
identity). The fast3d importers disagree on pool byte order:
import_texture_rgba16/_ia16 read manually big-endian bytes, while
import_texture_rgba32 does PD_BE32() on a native load -- M-113's blanket
swap hit both and regressed muzzle flash/ammo HUD (M-114).
Format is the structural discriminator, not compmethod (fire frames span
LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) or texnum range. Offline census of all
2698 ROM images (scratch/d219_headers.py / d219_census.txt): 94 non-zlib
wide-pixel; the 16-bit family is IMAGE_FIRE_0..14 + texnum 1198-1201/2430/
2510-2523, every ammo/flare/crosshair/muzzle-flash image is RGBA32.
Runtime-verified on Dam (scripted fire): all 15 frames decode format=1,
pool dumps warm R-dominant (mean opaque RGB 255,238,139). RGBA32 pool
bytes bit-identical to the M-114-revert build. User playtest confirmed;
D219 closed. GE_D219RAW probes generalized (no texnum filter), inert.
Live-tested against 4 real user playtests (not scripted) tonight, each
confirming purple/blue explosions still reproduce on current main:
1. G_LIGHTING hypothesis (previous session's lead) RULED OUT: extended the
D172 probe to flag lit particle draws uncapped (the original 40-hit cap,
sized for a short scripted repro, silently went dark after ~40 ordinary
bullet-impact particles well before any real explosion -- masking the
result, not confirming it). Fixed with an always-on periodic counter.
Result: 5000+ particle draws logged across a real playtest with
explosions triggered, 100% LIGHTING=off, 0 suspect hits.
2. Texture-cache-collision hypothesis RULED OUT: extended the same probe to
log the actual bound TEXEL1 address/GL-texture-id/byte-count. 7 distinct,
internally-consistent 448-byte (16x14 RGBA16) textures observed -- no
aliasing, no stale-slot signature.
3. ROOT CAUSE: used the existing GE_TEXDUMP feature (extended to exempt
16x14 RGBA images from its own 400-image cap, same masking problem as
above) to dump all 15 IMAGE_FIRE_0..14 particle textures as PPM during a
live playtest with confirmed-purple explosions. Every single one decodes
blue/purple-dominant; swapping the decoded R and B channels turns all 15
into a plausible warm fire palette, consistently. This is NOT a bug in
the shared RGBA16 decoder (port/fast3d/gfx_pc.cpp import_texture_rgba16)
-- that function is proven correct by every other RGBA16 texture in the
game rendering fine, and the general C-array byte-order normalization
already runs upstream of it. The wrongness is in the decoded bytes
themselves: either the Globalimagetable entries for these 15 image ids
resolve to the wrong ROM offset, or these assets use a sub-format the
shared decoder doesn't special-case. Do NOT blanket-swap R/B in the
shared decoder -- it would break every correctly-rendering RGBA16
texture in the game.
Also fixes a mistake made mid-session: a follow-up raw-byte-dump probe
(GE_TEXRAW) initially keyed its own cap-exemption on the wrong field
(gsDPSetTileSize's logical 56x56 wrap size instead of the real 16x14 pixel
content), so it never matched and burned a live-playtest cycle for nothing.
Fixed to key on the real signature: a 448-byte load (16*14*2, RGBA16).
Diagnostic infra left in tree, env-gated and inert by default:
GE_D172=1 (uncapped lit-case + periodic counter in gfx_sp_tri1),
GE_TEXDUMP=1 (16x14-exempt-from-cap fire dumper in gfx_opengl_upload_texture),
GE_TEXRAW=1 (fixed 448-byte-exempt raw dump in the texture import path).
* docs+probe(D219): new lead -- explosion.c never clears G_LIGHTING before particle draws
explosion.c's 15 particle DLs (assets/oddtextures.c globalDL_0x078..0x9a8,
all G_CC_INTERFERENCE+G_CC_MODULATEIA2) never get G_LIGHTING cleared by any
of explosion.c's own gSPClearGeometryMode calls. If lighting is left on from
a prior draw (e.g. a nearby lit character model), the port's vertex path
(gfx_pc.cpp:1301-1346) reinterprets the particle's authored RGBA tint as a
normal vector, turning SHADE into a scene-light color instead of red/orange
-- state-dependent, would explain "game-wide, live-play-only" exactly as
reported, and why the M-90 scripted repro (no nearby lit models) came back
clean.
Extends the existing D172 multitex-tri probe (GE_D172=1, already proven
reliable and uniquely keyed to this particle family) to also flag
geometry_mode & G_LIGHTING. Sanity-checked against the known-clean M-90
repro: still fires 40/40, all LIGHTING=off, matching the prior clean result
-- confirms the probe works without false-positiving.
Not a fix -- needs one live GE_D172=1 capture during a real explosion to
confirm or rule out before touching any rendering logic.
* fix: regenerate findings-index.csv via tools_pc/gen_findings_index.py
Hand-edited the CSV directly in the previous commit instead of running the
generator -- CI's "validate" job caught the drift.
Those two PRs edited the CSV by hand instead of running
tools_pc/gen_findings_index.py. Both were docs-only, so CI's "validate" job
(which only runs when non-doc files also change) never caught the drift.
D219 (purple/blue explosions): user confirmed 2026-09-12 this is not
Dam-exclusive -- game-wide -- and wants an active fix, not further triage.
Static/scripted analysis (M-89/M-90) is exhausted; next step is a live
GE_PCDUMP capture during a real explosion.
D234 (Dam guard towers): already marked VISUALLY VERIFIED FIXED in both the
findings.md row and findings-index.csv -- no change needed, just confirming
consistency per the user's note that this is resolved.
D230 (bass-instrument audio corruption): narrowed suspect to port/src/mixer.c's
hardcoded order=2/npredictors<=8 ADPCM decoder assumptions; not yet confirmed
against real ROM book data.
D235 (wrong textures after death/restart): the leading "CI-cache survives
restart" theory is likely refuted -- lvlStageLoad (the only call site for both
set_mt_tex_alloc and proplvreset2) runs on every stage transition including
restarts, so the cache reset isn't actually skipped. Real mechanism still
unknown.
D243 (Dam end-cutscene D146 corruption burst): corrects the prior write-up's
timing claim by re-reading the same session's raw logs at the frame-counter
level -- the corruption fires ~1600-3900 frames after the camera-cut, not
during it, in both runs (not just one). This also weakens the D134/pendingGfx
race citation, since osSpTaskStartGo runs gfx_run() synchronously. New lead:
the mission-complete/briefing screen chain, not the cutscene render itself.
Claude-Session: https://claude.ai/code/session_01CUFSnpgrAyQSDVHoQQ6Sx3