454 Commits
Author SHA1 Message Date
James Dansereau a001f1c137 docs(D294): new finding - Statue ground briefly renders black near the elevated CCCP-monument ramp, self-recovers
User report at Video.FovScale~130 + max draw distance/LOD: ground
geometry near an elevated wide-visibility vantage point drops out to
solid black, then recovers 10-30s later on its own. Doesn't cleanly
match D222 (NPC/object culling, FIXED), D249 (default-FOV far
geometry, doesn't self-recover), or D218 (fog/tint boundary, not
missing geometry) -- logged as its own finding with those three as
neighbors, not a confirmed duplicate of any. Leading unconfirmed
guess: a portal/room-visibility BFS not accounting for the widened
FOV frustum.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:37:28 -04:00
James Dansereau b6ed6b1d36 docs: fold D289 into D255 (likely same terminal crash), consolidate D173/D292 as one Bond-positioning thread
User corrections on the playtest batch: D289 (Dam computer-room
crash) is most likely the same computer-terminal SIGSEGV D255 already
fixed this session, not a separate mystery -- needs a re-test on the
post-fix build before treating as new. D292 (Bond sunk into floor on
death) is confirmed the same "Bond's world model positioned wrong"
family as D173/D148, not a separate bug -- cross-referenced both
directions so the split-across-two-files writeup (GRAPHICS-BACKLOG.md
D148 + findings.md D173) is easier to find as one thread covering
level-start floating, cutscene spin, and now death-pose sinking.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:35:52 -04:00
James Dansereau 4c2643c239 docs: log Steam Deck playtest feedback batch (D289-D293 new, cross-refs to D173/D219/D243/D246)
User completed a Dam-through-Runway Steam Deck playtest and reported
9 items. Four corroborate already-open findings (D173 third-person
Bond floating at level start, D246 fullscreen edge strips -- Deck's
own default preset sets MSAA=4, exactly this row's leading suspect,
D219 purple/blue particle colors widened to blood decals, D243 Dam
end-cutscene camera glitch -- second independent confirmation). Five
are new: D289 (Dam computer-room crash, non-reproducing), D290 (watch
controller-page missing pad graphic), D291 (Dam truck texture
glitches), D292 (death-pose Bond sunk into floor, possibly D173-
adjacent), D293 (no way to quit the game from the UI -- confirmed in
code, SDL_QUIT via window-close/Alt+F4 is the only path; a QoL gap on
Deck/controller-only setups).

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:33:07 -04:00
James Dansereau d475884456 docs(D288): promote D171 (Silo stray triangle) with user-reproduced, visually-documented repro
User reported a stray triangle during the Silo missile-shaft camera
pan (previously D171, parked since M-34 as "observed, not
investigated"). Reproduced headlessly, precisely bounded to frames
~562-741 of a bare -level_20 boot: a large, flat-shaded, untextured
brown triangle (uniform RGB(83,62,44)) grows then shrinks across the
pan, consistent with a real world-space triangle mis-transformed near
the camera plane rather than a HUD element or garbage vertex-color
read. Points at the D106/D233 near-plane/w-clip neighborhood in
port/fast3d/gfx_pc.cpp, not yet confirmed at the code level. Evidence
saved to docs/img/bugs/. Also noted: foreground `timeout N ...` launches
of the exe from this session's Bash tool produce zero GE_PCDUMP output;
backgrounding the launch works reliably -- worth remembering next time.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:27:54 -04:00
James Dansereau a2b028737b docs: fix Ourumov spelling in D263 quoted report title
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:19:28 -04:00
James Dansereau 791bf6b7c0 docs(D263): confirm Ourumov/Trevelyan standoff timeout fallback is by design, not a bug
User reproduced the "stuck standoff" 3-4 times; traced to a hard
timeout in ai_22 (label 0x13, UsetuparkZ.c) that fires the execution
unconditionally after ~2.5 min if no other trigger fires first. User
waited it out and confirmed it resolves normally with dialogue -- not
a freeze, not a new bug. Documented so it isn't re-flagged.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:17:17 -04:00
James Dansereau 86fdcc1db6 docs(D287): new finding - real non-recovering freeze after long idle + refocus; D252 confirmed on bullet-impact sparks too
D287: user hit a live freeze under debug.ps1 after ~15-20 min idle,
refocus-triggered, non-self-recovering (distinct from D286's single
fire-then-recover shape). Evidence preserved at
scratch/d287_freeze_gdb.txt (untracked, matches this repo's existing
scratch/ convention). Leading lead: gfxFrameMsgQ (32-slot scheduler
client queue) full while the VI-retrace pacemaker keeps ticking,
pointing at the main/render thread's consumer stalling rather than
the scheduler. Not root-caused; GE_D60 diagnostic already exists and
is the recommended next probe.

D252: user confirmed live the rainbow-hue bug still reproduces on
plain bullet-impact sparks, not just explosions -- a much cheaper
repro for the next GE_D252 capture.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 20:00:43 -04:00
James Dansereau 07b25f4b45 fix(D285): lock sndHandleEvent's preempt-scan against sndSetupSound's writer
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
2026-09-15 19:35:22 -04:00
James Dansereau 556f4434f3 D243: port GE_D243 front-end MENU_* trace onto release/v0.3.0, build-verify, fix uncached getenv
Cherry-picks the GE_D243 diagnostic (764a1f26, previously stuck on an
unmerged, un-build-verified branch) onto release/v0.3.0. The prior
session's "toolchain blocker" was this session's own TMP/TEMP-vs-Bash-tool
gotcha (documented in porting-notes.md this session), not a real
CMake/ninja issue -- now explained and worked around. Also fixes an
uncached getenv("GE_D243") per menu_init() call (D250's own rule: this
runs ~once/frame during front-end states) to a single cached check.
Build-verified; smoke-tested via a bare -level_33 boot (no crash, no
D243 lines, as expected since bare -level_XX skips the front end).
Still needs a real interactive playthrough to actually correlate
against the D146 stale-DL burst -- documented as the next step in
findings.md D243 (M-141), including an unconfirmed D173-connection
hypothesis raised by the user.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 19:30:58 -04:00
James Dansereau bb1e658ea5 fix(D282): front-end menu nav always uses left stick, matching F10-overlay convention
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
2026-09-15 19:28:03 -04:00
James Dansereau 4b1d9bc21c docs(D252): refute width/height suspect via authored asset data; add GE_D252 diag probe
The prior "prime suspect" (import_texture_rgba16 width/height derivation)
assumed line_size_bytes comes from the tile's MASK_16 wrap period, but
assets/oddtextures.c's actual gsDPSetTile for the fire render tile sets
line=4 explicitly, giving the correct 16x14 dimensions -- matching the
pre-existing is_fire_shape==16x14 filter already hardcoded into the
GE_TEXDUMP dumper. Neither named D252 hypothesis (width/height, GEVR's
byte-group-order alternative) survives; root cause stays open. Adds an
env-gated GE_D252 diagnostic (dims + raw texel dump) for whoever next
gets an interactive session to trigger the actual fire draw.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 19:25:15 -04:00
James Dansereau 8df8ad3c35 docs: adopt cheap GEVR-triage items (crash heuristic, frame-rate notes, process lesson, widescreen pre-flight)
Folds in the zero-code-risk ADOPT items from scratchpad/GEVR-TRIAGE.md
(a prior survey of the sibling GEVR VR port, same n64decomp/007
lineage, reference-only): a crash-triage heuristic for non-canonical
x86-64 pointer faults, two frame-rate-assumption notes that pre-empt a
future uncapped/high-refresh rabbit hole, a process lesson about
grep-built site lists standing in for a real read, and the
portal-scissor site map as pre-flight for whenever widescreen work
resumes.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 19:18:07 -04:00
James Dansereau 65728e9f0e docs(D286): new finding - user-confirmed real freeze on Caverns, Steam Deck
Caught during the same live Deck session used to fix D255: the game's
own hang watchdog (portHeartbeatCheck) fired once, no frame rendered
for ~8s at frames=46404, dumped all-thread state, then rendering
resumed and the session continued to a clean close - not a crash, a
self-recovered stall. User confirmed by direct observation this was a
real, visible freeze (not a Deck-side system hiccup), happening early
in a Caverns playthrough, not tied to an explosion or terminal (rules
out D255/D285 as the cause).

Not root-caused - no distinguishing log line precedes it. Logged as
open with no repro trigger identified rather than guessed at further.

Also fixes a cosmetic units bug spotted while reading the watchdog
code: the heartbeat log printed a raw microsecond delta with a "ms"
label (port/src/libultra.c, no /1000 conversion) - now divided
correctly. No behavior change, log output only.
2026-09-15 18:55:26 -04:00
James Dansereau 7ef0395a3f fix(D255): correct 32-bit pointer truncation in vtxstore_fix_refs
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.
2026-09-15 18:42:08 -04:00
James Dansereau 49f18ad1e6 docs(D285): new finding - audio-thread SIGSEGV, separate from D255
Caught a real crash on the user's Steam Deck during D255 live-test
setup: a fresh ge007.crash.log from the published v0.2.1 binary,
inside the user's active testing window. Symbolized directly on-device
with addr2line against the crashing binary's own debug symbols.

FAULT ADDR 0x62 inside sndHandleEvent (src/snd.c:366), on the audio
thread (amMain/portThreadWrapper) - not D255's 0x10/vtxstore_fix_refs
on the render thread. Different bug, different subsystem, likely
conflated with D255 in reports since both surface during the same
explosion/combat testing.

Root cause (static, code-read only): sndHandleEvent's pool-exhaustion
preempt-scan walks the shared D_800243E4 ALSoundState list with no
osSetIntMask lock anywhere in the function, while the list's insert
path (sndSetupSound, reached from 15 different game files' direct
sndPlaySfx calls) does lock it - an asymmetric-locking gap matching
the established D4/D147/D152 "N64 interrupts-off isn't a real lock on
PC" race class. Fits the repro pattern (explosion SFX bursts = high
thread-contention window).

Not fixed - flagged two open questions before it should be: whether
the missing lock is a genuine decomp-fidelity gap (N64 assembly may
have had it) vs a legitimate new PC-only lock, and it needs gdb
confirmation on a live crash, not just this static read.
2026-09-15 18:12:30 -04:00
James Dansereau edcfb1f163 docs(D255): consolidate real-Deck evidence - destroy-via-explosion, not activation
Further Steam Deck testing on the published v0.2.1 release reconciles
the two competing D255 repro theories: the trigger is an object dying
via explosion, not terminal activation. Confirmed on four levels:

- Facility: every terminal crashes consistently when destroyed
- Caverns: terminals near the radio objective crash when they explode
- Archives: a similar crash when a guard's grenade detonates (no
  terminal involved at all - narrows the real trigger to "object dies
  via explosion damage" in general)
- Frigate: negative control - shooting a room of terminals there did
  not reproduce it, so it isn't simply "any destructible OBJ record
  exploding" either

Updated the D255 finding with a static lead for the next session (the
objFree destroy path never explicitly nulls ObjectRecord.model, so the
NULL likely comes from a freed-slot-reuse or list-modify-during-iterate
race rather than a direct field write - untested, needs a live Deck
capture with the GE_D255 probes already in tree). Updated
.github/release-notes.md's D255 bullet to match.
2026-09-15 17:58:25 -04:00
James Dansereau b322b55a70 docs(D255): log user's real-hardware Deck report (destroy-trigger, not just activation)
User reproduced on real Steam Deck hardware, v0.2.1 published release:
Facility crashes consistently when terminals are DESTROYED (shot), not
only on activation as the M-139 "corrected" framing concluded. Frigate
crashed once in the same pass, specifics not recorded.

Logging as a dated, evidence-stamped addition rather than overwriting
the existing activation-repro entry - both triggers are plausible
routes into the same underlying destroy-path-ordering race and are not
necessarily contradictory; neither has been cross-verified against the
other yet. Next Linux/Deck session should test both triggers with
GE_D255=1 before revising the "reliable repro" framing again.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 17:35:51 -04:00
James Dansereau 126fe75d7f diag(D255): env-gated NULL-model probe for the Facility terminal crash
Adds a live-repro diagnostic for D255 (Facility door-opening terminal
SIGSEGV, Linux/Steam-Deck-only): vtxstore.c's vtxstore_fix_refs
(sub_GAME_7F09BAC4) skips-and-logs instead of dereferencing when it
finds a NULL model on an active PROP_TYPE_OBJ record, logging the
record's identity/state; propobj.c's objFree logs every free call so
the two logs can be correlated by prop/obj pointer. Both gated behind
GE_D255 (off by default, getenv-cached per the D250 lesson) - zero
behavior change unless set.

Attempted a live Windows repro this session (5 terminal activations
across 3 direct -level_34 launches): zero probe hits, zero crashes.
Combined with the existing WSL-negative result, this is now two
negatives against one Linux/Steam-Deck positive - the bug does not
reproduce on Windows, likely a genuine allocator/timing difference.
Landing the probes anyway since they're inert by default and are the
fastest path to a real trace whenever a Linux/Deck session is
available; no more Windows time should go toward a live repro here.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 17:12:35 -04:00
James Dansereau 0de99b4db4 fix(prng): correct MIPS64 dsll32/dsrl32 semantics in port/src/random.c (D284)
random.c's dsll32/dsrl32 helpers modelled the real MIPS64 dsll32/dsrl32
instructions as ordinary 32-bit shift-by-n operations, masked to 32
bits. Real MIPS64 dsll32/dsrl32 shift the FULL 64-bit register by
n+32 with no masking - they exist specifically to encode a 32-63 shift
amount in a 5-bit immediate field. The bug was bit-exact-looking but
wrong: it produced a deterministic, self-consistent stream that was
never the N64's actual stream, from the very first draw.

Surfaced by M-138's security/decomp-fidelity review (a forked review
agent's numeric trace + a hand trace of random.s both found the same
divergence); fix decided over disclose-as-known-issue per M-139b.

Fix: dsll32(x,n) = x << (n+32), dsrl32(x,n) = x >> (n+32), no masking.
No other line changes - every call site (randomGetNext,
randomGetNextFrom, chrObjRandomGetNext) already mirrors random.s/
chrObjRandom.s line-by-line through these two helpers plus plain <</>>
(which already matched dsll/dsrl correctly).

Verified against a standalone C reproduction outside the game (first
five draws from random.s's real initial seed diverge completely
between the buggy and corrected helpers, confirming the fix actually
changes the output stream). 242/242 clean build; full 21-level
verify.sh sweep PASS, no crashes.

This silently changes every RNG-derived sequence (loot, AI variance,
anything seeded from randomGetNext/randomGetNextFrom) from the first
draw in every build going forward. Savegames are unaffected (seed
isn't save state); existing recorded replays will diverge against a
post-fix build. Called out in v0.3.0 release notes per M-139c.

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 16:40:19 -04:00
James Dansereau 532092c732 fix(render): land D266 IA4/texedge decode + D271 portal-culling fixes (M-139 R2)
R2 extraction from the parked D236 investigation tree
(park/d236-probes-m139): pulls out only the two verified, behavior-real
fixes and drops every D267-D280 debug probe (census counters, PPM
dumpers, per-tri/per-portal logging) that had accumulated alongside them.

- D266: import_texture_ia4 decodes RDP IA4 as I2:A2 (was I3:A1, an
  inherited PD assumption); gfx_opengl.cpp's CVG_X_ALPHA "texedge" path
  now keys off the real alphacompare field (dither/threshold/none)
  instead of a hardcoded 0.19 cutoff, and gfx_pc.cpp excludes AC_DECAL
  tiles from the texedge path entirely (it never discards on real
  hardware).
- D271: the D106 near-plane portal-straddling guard's `lim` constant
  widens from 1e5 to 1e38 so finite out-of-range bounds flow through to
  bgRectIntersect and clamp/cull exactly as they do on N64, instead of
  being force-mapped to full-screen (was drawing whole rooms N64 culls).

Checked D271 against D249 (distant-geometry drop-out on Streets/Egyptian)
before landing per M-139's overlap concern: no overlap, different code
paths (near-plane portal guard vs. far-plane/LOD symptom); D249 stays
open.

Verified: 242/242 clean build; full 21-level verify.sh sweep clean (one
dam NO-FRAMES on the sweep pass reproduced PASS on an immediate solo
re-run - a load-sensitivity flake, not a regression).

The D267-D280 probe code and the rest of the D236 tree-wall
investigation stay parked on park/d236-probes-m139 for whenever that
thread resumes (still no root cause for the tree wall itself - see
D280's negative outcome).

Also documents a build-environment gotcha hit this session: this Bash
tool's shell reports MINGW64 (uname/$MSYSTEM) but is actually
Git-for-Windows Bash, missing cmake/ninja/gcc from PATH until
/c/msys64/mingw64/bin and /c/msys64/usr/bin are prepended explicitly
(docs/porting-notes.md SS E).

Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 16:34:28 -04:00
James Dansereau e7fee0d7a4 docs: v0.2.1 known-issues sweep, pre-1.0/roadmap framing, AI disclosure
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
2026-09-15 12:53:57 -04:00
James Dansereau 4b51ded7d1 docs: SEO round 2 (Bing) - demote per-page h1 to h2 site-wide, re-trim meta description, bump README/Pages to v0.2.1
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
2026-09-15 10:04:02 -04:00
James Dansereau ff677da74d fix(ci): regenerate findings-index.csv (stale after D216/D181 edits)
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
2026-09-15 09:36:44 -04:00
James Dansereau 1e11e1a5fd v0.2.1 polish: pull Game.ScreenShakeIntensity from the F10 menu (D181)
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
2026-09-15 09:28:06 -04:00
James Dansereau 99f4f38e1c v0.2.1 polish: pull Game.SkipIntro from the F10 menu (breaks audio)
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
2026-09-15 09:18:40 -04:00
James Dansereau 06b675b5e8 fix(ci): revert GE-ENV-PROBES.md rows for uncommitted D236 probes
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
2026-09-15 08:59:51 -04:00
James Dansereau 572ab7bf5b docs: findings/backlog through D280 (v0.2.1 batch + D236 tree-wall passes 2-9)
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
2026-09-15 08:21:56 -04:00
James Dansereau de6c875bb8 docs: cut-content research — article bodies recovered, claims verified vs decomp
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.
2026-09-14 01:02:07 -04:00
James Dansereau 395c616fc5 docs: cut-content research backlog from TCRF export (first pass)
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.
2026-09-14 00:48:30 -04:00
James Dansereau e1cb96ef19 v0.2.0 final: AllUnlocked default OFF (D259 audio quirk); raise draw/LOD distance + MSAA defaults 2026-09-14 00:18:59 -04:00
James Dansereau 436484a926 docs: final v0.2.0 pre-tag pass
- 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.
2026-09-13 23:51:27 -04:00
James Dansereau b2d5551ece Drop-in ROM: first-run sidecar conversion, no Python (issue #6) (#84)
* 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
2026-09-13 23:17:29 -04:00
James Dansereau 99bc2a3a6a D230: byte-swap RAW16 wavetable samples at cart-map time (BE ROM -> host LE) (#83)
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.
2026-09-13 23:13:12 -04:00
James Dansereau 608808a212 Docs: v0.2.0 finalization — README/Pages + media pass (#80)
* 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)
2026-09-13 22:12:13 -04:00
James Dansereau c62803f890 D257: Game.AllUnlocked option — all levels, 007 mode, full cheat menu (#79)
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.
2026-09-13 21:48:09 -04:00
James Dansereau 4ca075061e D256: Linux $S (saves/config) now falls back to the exe dir, mirroring Windows
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/.
2026-09-13 20:17:34 -04:00
James Dansereau c7898d1620 D254/D255: stop the Linux crash handler eating its own logs; document the Facility firefight crash
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.
2026-09-13 19:28:25 -04:00
James Dansereau f091619553 Release notes: known-issues pass from v0.2.0-pre playtest
- 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
2026-09-13 19:02:04 -04:00
James Dansereau 49f843a156 Regenerate findings index (D253) 2026-09-13 18:58:20 -04:00
James Dansereau 1db6992e5e Fix D203/D253: Facility SIGSEGV on Linux — callback prototype ABI mismatch
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.
2026-09-13 18:55:26 -04:00
James Dansereau 2f002a31aa D252: document the rainbow particle-palette bug (no fix for v0.2.0-pre)
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.
2026-09-13 15:49:34 -04:00
James Dansereau cc9068bfa1 Remove the legacy always-grab mouse mode (D192 + D239)
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.
2026-09-13 15:33:50 -04:00
James Dansereau d3d968bc0e D251: F10 options overlay QoL pass -- scrollable panel, MSAA wrap, hidden auto-% rows, SensLink toggle, hover-select removed
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.
2026-09-13 15:29:23 -04:00
James Dansereau 774d398263 D194: record full closure (M-123) -- sens co-tuned, aim-mode FPS resolved by user evidence 2026-09-13 15:29:19 -04:00
James Dansereau 6d083c2c7c D194: user playtest verdict -- spazz accepted (95%), remaining work is sensitivity co-tune + FPS control 2026-09-13 14:17:17 -04:00
James Dansereau e7c2ffe52b D194: drop vv_theta [0,360) wrap in GEPD aim (GEPD parity); extend gepdaim log with residue/autoaim terms for spazz diagnosis 2026-09-13 13:59:58 -04:00
James Dansereau 16966df2e3 Merge branch 'main' into fix/d194-mouse-dt-decouple 2026-09-13 10:13:34 -04:00
James Dansereau 7c48f664ca D222/D218/D249: fix high-FovScale culling, draw distance, LOD scaling (#76)
* 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.
2026-09-13 10:13:04 -04:00
James Dansereau 70bc9d65b5 D250: getenv() caching fix -- root cause of the Dam fps drop (plus sysSleep unit fix, GE_D250 probe, perf.ps1) (#75)
* 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.
2026-09-13 09:33:36 -04:00
James Dansereau ef0c0900b4 docs(D250): rule out build flags + idle-load as the fps bottleneck (#74)
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
2026-09-13 08:54:08 -04:00