Commit Graph
521 Commits
Author SHA1 Message Date
James Dansereau 73768a0357 Merge pull request #37 from jkdansereau/fix/d215-viewmodel-renderdata-flags
fix(D215): 1P weapon viewmodel never drew in-level — renderdata.flags read 0 on x86-64
2026-09-09 20:52:40 -04:00
James Dansereau 9c623858c1 regen findings-index (validate gate); M-84 verdict tokens on D211/D214/D217 status cells 2026-09-09 20:49:57 -04:00
James Dansereau 6e5a4e4e7f Merge main into fix/d215-viewmodel-renderdata-flags (M-84 findings rows)
# Conflicts:
#	docs/dev/findings-index.csv
#	docs/dev/findings.md
2026-09-09 20:37:22 -04:00
James Dansereau 9a667bd0b0 Merge pull request #36 from jkdansereau/feat/pc-qol-fov-aniso-fps
feat(D211-D214): PC QoL wave — FOV slider, anisotropic, on-screen FPS, keyboard rebinding
2026-09-09 20:35:03 -04:00
James Dansereau b3bf7786ff M-84: fold D211 FOV refix + user-test findings into the QoL wave
D211 didn't affect the in-level view (user test): the fast3d
gfx_apply_fov_scale hook only recognised *pure* perspective matrix loads,
but GE draws the world through g_CurrentPlayer->field_10E0 -- a
projection x view matrix pre-combined on the CPU (bondview2.c:8224) --
which failed the guard. Only the pause/watch model + sky FOV changed.

Fix: scale fovy game-side at the guPerspectiveF chokepoint
(src/fr.c, #ifdef PORT, frFovY *= portFovScale, clamp 160deg), which
lands before the CPU combine. Removed the fast3d hook + gfx_api.h decl;
port/src/video.c now sets f32 portFovScale. Gated to
lvlGetCurrentStageToLoad() != LEVELID_TITLE so the front end (which
renders through the same path) keeps its authored FOV.

User-verified: menu unaffected, in-level view widens (Dam tunnel).
Caveat: currentPlayerSetCameraScale keeps nominal FOV for frustum-cull
/ screen<->world math (edge geometry culls slightly early at high scale).

Adds src/fr.c as a #ifdef PORT game-code touch (D181 policy class).

Docs (this test pass):
- D214: config.c sections by the last dot, so binds live in [Input.Bind]
  not [Bind]. Doc-only fix (finding, index, QOL-INVENTORY); code unchanged.
- D217 (OPEN): viewmodel + 3rd-person Bond model textures render garbled
  -- stale-palette CI-texture bug (D161 family), surfaced by D215.
- D218 (LOGGED, parked): wide FOV exposes the per-level FarFog
  draw-distance boundary; Video.DrawDistance fix proposed.
2026-09-09 20:30:21 -04:00
James Dansereau 152b70a760 docs(D215): add GE_DVM to GE-ENV-PROBES.md (validate gate) 2026-09-09 00:58:29 -04:00
James Dansereau f602b1738f fix(D215): 1P weapon viewmodel never drew in-level — renderdata.flags read 0
gunRenderFirstPersonGunModels built its ModelRenderData with
  renderdata = *(ModelRenderData *)&D_80035CC0;
a reinterpret that reads the lone `u32 D_80035CC0 = 0` and continues into the
next declared global `u32 D_80035CC4[] = {1, 3, 0, ...}` (gun.c). On N64 that
yields { basemtx=0, zbufferenabled=1, flags=3, rest=0 } because the linker
lays the two globals out contiguously in declaration order and pointers are
4 bytes. On x86-64 it breaks twice: ModelRenderData's pointer members widen
4->8 B (so `flags` is no longer at struct offset 8) and C guarantees nothing
about the two globals' placement. Measured (GE_DVM=1, BUNKER1): flags == 0,
which gates every weapon-model DL node off in modelRenderNodeGundl
((renderdata->flags & 1)) -> subdraw emits 1 gfx command instead of ~150 ->
no gun visible while playing.

All three VIEWMODEL-RESEARCH.md ranked suspects were ruled out first by the
same probe: field_87F==1, RootNode valid, numRecords 22 < pool 192, node walk
visits 24+ valid DL/SWITCH/BSP nodes.

Fix: #ifdef PORT -> set the fields explicitly (zbufferenabled=TRUE; flags=3);
N64 line verbatim under #else. ABI/layout only (D3x class), no logic change.

Verified (headless): GE_DVM=1 post-fix shows flags=0x3 and the weapon subdraw
now emits 153 gfx commands (was 1); bunker1 framediff worst_cell 12.2 -> 28.5
(gun renders in-frame); Dam/Facility/Bunker1/Archives/Cradle/Aztec sweep
crash-free. Owed: one BUNKER1 eyeball; the bunker1 golden needs re-baselining.

GE_DVM gate+render probes left in, env-gated (cf. GE_D193*).
findings.md D215 + index; GRAPHICS-BACKLOG + VIEWMODEL-RESEARCH marked resolved.
2026-09-09 00:56:19 -04:00
James Dansereau de31f2874b feat(D214): keyboard rebinding via [Bind] config section
port/src/input.c: 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/
TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each bound to a
comma-separated list of up to 4 SDL scancode names via Input.Bind.* keys.
inputRebuildBinds() parses them in inputInit() after configLoad();
SDL_GetScancodeFromName resolves names, unknowns warn + skip. The hardcoded
keyDown(ks, SDL_SCANCODE_*) block in inputComputePad is replaced with
actHeld(ks, IA_*). Mouse buttons (fire/aim) stay hardwired.

File-driven, no UI -- matches the PD port. A fresh ge007.ini gets the full
[Bind] block with defaults visible.

Defaults reproduce the previous FPS layout exactly: all 20 default key names
round-trip to the identical SDL_Scancode the old code used (0 mismatches in a
SDL_GetScancodeFromName test). Build clean; verify.sh dam/facility/bunker1
PASS crash-free.

findings.md D214 + index; QOL-INVENTORY row marked done.
2026-09-09 00:09:26 -04:00
James Dansereau 7bcfbf313c feat(D211-D213): PC QoL image options — FOV slider, anisotropic filtering, on-screen FPS
Three port-only knobs, each defaulting to byte-identical behaviour:

- Video.FovScale (D211): multiplicative half-angle scale on every perspective
  projection matrix as it is loaded (gfx_apply_fov_scale, gfx_pc.cpp). Columns
  0+1 scaled together so H/V FOV widen uniformly and aspect is preserved; ortho
  (HUD/menu) matrices untouched. Composes with the game's per-frame aim-zoom
  rewrites for free. Percent of vertical FOV, 50-150, default 100 => guarded
  early-return (zero FP ops, golden dumps byte-identical). WIDESCREEN-FOV-PLAN
  Phase 4, landed standalone.

- Video.Anisotropy (D212): expose fast3d's already-implemented GL anisotropy
  hook as config (1-16). New gfx_set_anisotropy_level() extern-C wrapper clamps
  to the GL max. Default 4 = the value fast3d already hardcoded => no delta.

- Video.DisplayFPS (D213): PD-parity top-right FPS readout, sampled once per
  emitted frame (optionsoverlay.c fpsTick). Config-only; default 0 => emit
  still returns NULL with the panel closed.

F10 overlay gains 'Anisotropic' and 'FOV scale %' rows (DisplayFPS is file-only
- the panel is at its 13-row layout limit, matching PD).

Build clean (ntsc-final); bunker1 framediff worst_cell 12.219 vs 12.221
baseline = render noise, no-op confirmed. Interactive eyeballs owed (see
docs/HANDOFF.md M-83).

findings.md: D211-D213 rows + index regen. WIDESCREEN-FOV-PLAN Phase 4 and
QOL-INVENTORY rows marked done.
2026-09-09 00:02:47 -04:00
James Dansereau 8742b93f29 Merge pull request #34 from jkdansereau/docs/d172-0xb9-withdrawn
fix(D172): particle magenta/cyan — gfx_lod_tile_offset collapses non-LOD 2-texture combines
2026-09-08 23:40:52 -04:00
James Dansereau fb8557b2da docs(D172): add GE_D172 to GE-ENV-PROBES.md (validate gate) 2026-09-08 23:37:49 -04:00
James Dansereau 9b649b025d docs(D172): ROOT CAUSE FOUND + FIXED — gfx_lod_tile_offset collapse; findings D172 row + index 2026-09-08 23:31:47 -04:00
James Dansereau 157a8de59a fix(D172): stop gfx_lod_tile_offset collapsing non-LOD 2-texture combines
ROOT CAUSE FOUND (GE_D172 probe, user Silo firefight capture): particle
draws (G_CC_INTERFERENCE = TEXEL0*TEXEL1) configure tile 0 = IA8 smoke @
tmem 0 and tile 1 = RGBA16 fire @ tmem 0x188, but gfx_lod_tile_offset()
returns 0 unconditionally on the !gfx_detail_textures_enabled path (a D107
blurry-ceiling fix), so texunit 1 samples the smoke texture too. smoke *
smoke -> the magenta/cyan blood/spark colour.

Probe evidence, 40/40 particle tris:
  cfg1[tile=1 tmem=392 fmt=RGBA siz=16b]  SAMPLED1=tile0(tmem=0 fmt=IA)

Fix: only fold texunit i to the base tile when LOD is actually active
(rdp.tex_lod). D107's mip case (tex_lod=1) is unchanged; genuine non-LOD
multitexture now gets tile i. bunker1 verify PASS (worst_cell 11.3, the
D107 repro level, no regression).

Needs an in-game eyeball: blood/sparks should read dark red, not magenta.
2026-09-08 23:30:45 -04:00
James Dansereau 9ad69679b4 diag(D172): probe only non-LOD multitex (tex_lod=0) — the particle case 2026-09-08 23:25:26 -04:00
James Dansereau e65830d980 diag(D172): sharper probe — filter to real multitex, log tex_lod + sampled vs configured tile
The 16-line budget filled during the Silo intro with LOD-bilerp tris
(TEXEL1 = mip level, T0==T1 is correct there). New probe: only fires when
the DL configured tile first+1 at a DISTINCT tmem (genuine 2nd material),
logs tex_lod + which tile each texunit actually SAMPLES vs what was
configured, flags TEXEL1==TEXEL0. Budget 40.
2026-09-08 23:22:32 -04:00
James Dansereau 0c6173f1bd diag(D172): replace weak tri probe with a decisive TEXEL1-bind dump
The old 'cyc2 combine at cycletype=1CYC' probe over-fired on trivial
passthrough cyc2 terms. Replace with one that fires only when a 2-cycle
triangle actually consumes TEXEL1 (the G_CC_INTERFERENCE particle case) and
dumps both texunits' tile/tmem/fmt/siz. One Silo capture then shows whether
TEXEL1 resolves to the fire tile (tmem 392, RGBA16) or something wrong.

Build clean; bunker1 verify PASS.
2026-09-08 23:12:09 -04:00
James Dansereau 9215fde4fb diag(D172): rule out cycle-type too; ship GE_D172 probes
Real particle records (assets/oddtextures.c globalDL_0x078..) DO set
gsDPSetCycleType(G_CYC_2CYCLE); GE_D172=1 probe confirms fast3d applies it
(particle combine fc26a004 logged at cycletype=2CYC). Both the 0xB9-abort
and the cycle-type hypotheses are now dead. Bug is the G_CC_INTERFERENCE
two-tile combine / TEXEL1 path (IA8 smoke x RGBA16 fire @ tmem 0x188).

Probes (#ifdef PORT, GE_D172=1, inert otherwise):
- gfx_pc.cpp G_SETCOMBINE: distinct combine + active cycletype
- gfx_pc.cpp gfx_sp_tri1: tris with cyc2 combine drawn at cycletype!=2CYC

Needs a display capture to go further (headless can't see particle colour).
2026-09-08 23:12:09 -04:00
James Dansereau 809560ca1e docs(D172): withdraw 0xB9 root cause — it's G_SETOTHERMODE_L, already handled
0xB9 == G_SETOTHERMODE_L in this non-F3DEX2 build (G_IMMFIRST-6); fast3d
handles it as gsDPSetRenderMode. -level_09 sustained-fire run: zero D146
aborts, zero warnings. Re-scoped to the 2-cycle/cycletype hypothesis
(records never set G_MDSFT_CYCLETYPE -> use_2cyc==0 on PC -> TEXEL1
garbage -> magenta/cyan).
2026-09-08 23:12:09 -04:00
James Dansereau 90641f6305 Merge pull request #18 from jkdansereau/fix/d176a-sky-path-b
D176(a): render the sky on PC (Path B — screen-space emit)
2026-09-08 22:34:27 -04:00
James Dansereau b2efc0af9d fix(D176a): phase-fold sky texcoords to kill the horizon streak (Defect 1)
Cloud tile is s_skywaterimages[0] = 64x64 IA8, G_TX_WRAP -> it tiles.
GE's screen-space textured-quad idiom is tc = width<<5 (glass2.c:678), so
skyPortRenderPoly's *32.0f scale was correct; the bug was (s16) overflow on
the +/-20k-texel horizon S/T (unk20/unk24 are texel coords, not fractions).

Fix: fold every vertex of one primitive by the same multiple of the 64-texel
tile period before scaling. Inter-vertex deltas (hence the interpolated
cloud texture) are preserved; magnitudes stay in s16 range. Exact for any
triangle whose S/T span is < ~1000 texels -- everything except tris
straddling the horizon line, which are Defect 2 (adaptive tessellation).

sky.c #ifdef PORT only; N64 path unchanged. Builds clean; bunker1 + statue
verify PASS, no crash. UNVERIFIED VISUALLY -- owed: one -level_22 screenshot
(D176a-SKY-NOTES.md 'Verification ask').

Also: rebased onto main (porting-notes.md conflict resolved).
2026-09-08 22:29:28 -04:00
James Dansereau 9a6017a784 D176(a): emit screen-space sky geometry on PC (Path B)
skyRenderTri/skyRenderFull emit the sky as a verbatim N64 RDP triangle
command chopped into G_RDPHALF_* immediates, which fast3d deliberately
no-ops -> Surface/Statue/Streets skies render solid black.

Behind #ifdef PORT, substitute a screen-space gSPVertex + gSP1Triangle/
gSP2Triangles emit built from the already-projected SkyRelated38 verts
(new helper skyPortRenderPoly, ortho matrices via dynAllocate*). N64 path
unchanged under #else. Pure RSP-idiom substitution, no behavioural change.

Links 243/243. Render check owed on a display: -level_22 frame ~360.

findings.md D176(a) M-46; porting-notes.mdD.
2026-09-08 22:29:28 -04:00
James Dansereau b7bb08db65 Merge pull request #35 from jkdansereau/fix/d210-patrol-lastvisible-alias
fix(D210): chrToPatrol leaves act_patrol.lastvisible60 uninitialised on PC
2026-09-08 22:28:31 -04:00
James Dansereau 1a07c8201a Merge pull request #33 from jkdansereau/fix/d196-overlay-cursor
fix(D196): re-hide the OS cursor after the F10 options overlay closes
2026-09-08 22:28:28 -04:00
James Dansereau b47eea65e3 fix(D210): chrToPatrol leaves act_patrol.lastvisible60 uninitialised on PC
Sibling of D209 (A1 raw-byte union-alias class). chrToPatrol writes
act_init.padding[0x13] (union byte 0x4c) = -1, which is act_patrol.lastvisible60
on N64. act_patrol starts with a patrol_path* that widens 4->8 on x86-64, so
0x4c now lands in waydata and lastvisible60 (@0x50) stays uninitialised. The
patrol 'haven't seen the player recently' gate (chraction.c:~9347) then reads
garbage on the first ticks after entering PATROL.

Fix: write the named field under #ifdef PORT; N64 line verbatim under #else.
ABI/pointer-width only, no game-logic change.

Found by the post-D209 A1 sweep; the only new instance (act_anim/act_dead
padding aliases are pointer-free -> layout-stable).

Build clean; bunker1 + facility verify PASS (both patrol-heavy), no crash.
findings D210 + porting-notes A1 + findings-index regen.
2026-09-08 20:51:18 -04:00
James Dansereau a17d5466ae fix(D196): re-hide the OS cursor after the F10 options overlay closes
inputSuspendForOverlay() forces the OS cursor visible while the overlay is
open; on close nothing re-hid it unless reconcileGrab() happened to re-grab
(only true if the user had already clicked-to-lock in a stage). Opening the
overlay from a menu, or from a stage not yet click-locked, left the cursor
stuck visible over the game's own crosshair.

Fix: re-assert applyCursorVisibility() once per controller-0 poll, right
after reconcileGrab(). Unreachable while the overlay is open (early return
above), so it only fires once the overlay has closed and self-heals every
poll thereafter. Port-only, no render change; verify.sh bunker1 PASS.

Owed: 5-second visual check - open F10 from a menu, close it, cursor gone.
2026-09-08 20:24:35 -04:00
James Dansereau 97a09665a1 Merge pull request #32 from jkdansereau/diag/d193-timing-keystone-measured
fix(D209): AI locomotion locked to walk animation — union byte alias shifts at 64-bit (fixes D193)
2026-09-08 20:00:18 -04:00
James Dansereau b0b5f948f1 chore(D209): regen findings index
gen_findings_index.py --check gate was red on the new D209 entry.
2026-09-08 19:57:22 -04:00
James Dansereau a63efe184b fix(D209): AI locomotion locked to walk anim — union byte alias shifts at 64-bit
Root cause of D193 ("NPC AI locomotion is too slow", the user's biggest
playtest complaint; broke Cradle completability).

get_sound_at_range() (chraction.c:3573) is the locomotion-animation
selector: it picks ANIM_DATA_sprinting/running/walking from an arg1 speed
tier. Its only caller passed that tier as self->act_ubytes.padding[45] — a
raw byte alias into the ChrRecord action union rather than the named field.

On N64 offset 45 is exactly act_gopos.unk59:
  act_gopos = {coord3d targetpos@0, StandTile *target@12,
               waypoint *target_path@16, waypoint *waypoints[6]@20,
               u8 curindex@44, u8 unk59@45}
At 64-bit the three pointer members widen 4->8B (target@16, target_path@24,
waypoints@32), pushing unk59 to 81 while the literal 45 lands inside
waypoints[1] (40..47) — byte 5 of a heap pointer. The arena is low-4GB
(0x00000000_70xxxxxx), so that byte is always 0x00: arg1 reads 0 forever
and every AI chr in the game was bound to ANIM_DATA_walking /
ANIM_DATA_walking_unarmed regardless of what the ailist commanded. GE
character travel is animation-root-motion driven, so all AI moved at walk
pace on every level. Bond is unaffected (explicit physics, no root motion),
which is the reported "player fine / all AI slow" split.

Fix: PORT-only read of the named field; N64 line kept verbatim under #else.
ABI/pointer-width only, no game-logic change (rule-2 narrow exception, D3x).
Both callers reach the site with actiontype == ACT_GOPOS, so it is the same
byte N64 reads.

Evidence (Cradle -level_41, GE_D193A extended with tier/aidx): all six chrs
report ACT_GOPOS tier=1 SPEED_RUN on 420/440 samples, while the live
model->anim reverse-resolves to animation_table_ptrs1 index 40
(PTR_ANIM_walking) for Trevelyan and 107 (PTR_ANIM_walking_unarmed) for the
guards on every sample; 42 (running) and 41 (sprinting) never appear.
Travel sat in a flat 140-155 u/s band for all of them.

User playtest-verified on the fix build: Trevelyan and the guards run.

Also:
- GE_D193A extended with tier / gspd / typ / tick / move / frz / hid / aidx
  (aidx reverse-resolves model->anim against animation_table_ptrs1).
- bondtypes.h: unk59 was commented "guess: room" — it is the SPEED tier.
- porting-notes.md A1: new bug class — raw-byte aliases into a union read a
  plausible constant rather than garbage, so they misbehave silently.
- findings.md: D209 added; D193 closed against it.
2026-09-08 19:50:45 -04:00
James Dansereau 47d6dfa9c3 docs(D193): anim-data endianness (D33) audited complete; blocked on N64 reference
Static review of the full anim-root-motion path finds no port-side bug:
frame clock ruled out (measured), all model/chr scalars nominal,
ModelSkeleton ruled out (static C not serialized), and the D33
romdataFixupAnimationData byte-swap covers every field the locomotion
decode reads -- walk/run anim records ARE in animation_table_ptrs1,
_ptrs2 is 3 non-overlapping anims (no double-swap), no fixup errors,
sizeof(ModelAnimation)=64 and sizeof(ModelAnimBitField)=6 both platforms,
bitstream walked byte-MSB-first (endianness neutral, matches PD).

A magnitude error, if present, is only visible against an N64 reference.
Two paths documented (both need a fresh session; no MIPS toolchain /
emulator in this env): (A) build ROM with matching logging + diff,
(B) frame-count N64 reference footage of a measured guard traversal.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 10:03:16 -04:00
James Dansereau 33667c72d7 docs(D193): rule out ModelSkeleton (static C, not serialized) — bitstream/anim-table is the remaining surface
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 09:51:14 -04:00
James Dansereau 25b46e020a diag(D193): instrument the locomotion / anim-root-motion path
Two #ifdef PORT env-gated probes (default-inert, no golden regression):

- GE_D193A (chr.c chrTick): per-wall-second per-chr travel speed +
  the root-motion scalars (scale / anim_translation_scale / animrate /
  playspeed / model->speed / g_GlobalTimerDelta). GE_D193A_CHR=<n>
  narrows to one chrnum.
- GE_D193B (model.c sub_GAME_7F06D3F4): rate-limited raw anim-bitstream
  root-motion decode (joint / mtx base channel / decoded s16 triple /
  angle / the D32 bitDescriptors+bitStream addrs).

Silo + Facility captures: every port-side input to the locomotion math
reads nominal -- g_GlobalTimerDelta=2.0, playspeed=1.0, scale=0.1,
anim_translation_scale=1.0, and the C in this path is identical to N64
(only the non-firing D156 NaN guards are #ifdef PORT). animrate=0.0 is
the default (only set on blended transitions). Patrolling guards travel
~60-99 u/s. GE_D193B shows base=0 / angle=0 on every call.

So the divergence is either subtle wrong magnitudes in the decoded
bitstream values, or in the pos34 accumulation -- neither visible
without an N64-decomp reference trace. Findings D193 + GE-ENV-PROBES
updated with the numbers and the next step (build n64decomp/007 with
matching logging and diff, or frame-count reference footage). One
explicit structural check flagged: ModelSkeleton.Joints (ModelJoint*,
4->8 B) reached via model->obj->Skeleton from D43/D45-converted data.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 09:50:06 -04:00
James Dansereau 7206bf8cc1 docs(D193): live-load confirms the pivot — locomotion/anim bug, not the frame clock
User ran Cradle (-level_41; the "-level_51" in the docs was wrong -> that
id is LEVELID_EAR, an unused slot that SIGSEGVs in stanBuildRoomData)
with GE_D193=1 and drove the Trevelyan sequence while Trevelyan walked
visibly slow. Probe held render=30 / sim=60 / clamped=0 / maxraw=2 dead
steady, no [D156] fires. So under the exact failing scene the sim clock
is perfect -> D193 is in the character locomotion/animation path.

Best hypothesis: GE character travel is anim-root-motion driven
(modelTickAnim -> modelSetAnimFrame2WithChrStuff, translation scaled by
model->scale * model->anim_translation_scale); the player uses explicit
physics instead, which explains "player fine / all AI slow". Next:
instrument sub_GAME_7F06D3F4 pos magnitude + scale + playspeed/speed per
chrTick for one walking NPC. Findings D193 + HANDOFF updated; Cradle
level-id corrected to 41.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 09:38:07 -04:00
James Dansereau c723207d3a diag(D193): measure the timing keystone — "sim sums low" hypothesis contradicted
Add GE_D193=1 per-wall-second probe in waitForNextFrame (#ifdef PORT,
env-gated, default path byte-identical): render frames, sim ticks
delivered (Σ deltaFrames == Σ g_ClockTimer), catch-up clamp hits, max
raw pre-clamp delta.

Measured -level_09/_20/_27/_34 idle: rock-steady render=30 fps /
sim=60 ticks/s / clamped=0 / maxraw=2 on every level, VSync on or off.
The per-second sim sum is exactly the N64's ~60 and FRAMETIMING_PORT_
MAX_CATCHUP never bites in normal play, so B1's premise
(g_GlobalTimerDelta / g_ClockTimer sum low over a real second) is wrong
for the steady-state case. Findings D193 updated with the measurement,
the residual quantization risk (no remainder carry in the per-frame
round; only matters if a real AI scene drops into the 20-30 fps band),
and the revised next step (instrument under real AI load, then pivot to
the locomotion/anim path). GE_D193 catalogued in GE-ENV-PROBES.md.

No game-logic change; no golden regression (level_09 f203 91.52% vs
~91.6% ref).

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 09:30:49 -04:00
James Dansereau 3eca8ba79b Merge pull request #31 from jkdansereau/fix/d191-bondview-cuff-switch-stride
fix(model): D191 — Bunker ii / Statue mid-mission crash (bondviewSelectCuff Switches stride)
2026-09-08 09:15:25 -04:00
James Dansereau cf477c20cf docs(D191): findings + porting-notes + regen findings-index
Playtest-verified (Bunker ii + Statue). 21/21 solo levels crash-free.
Regenerated findings-index.csv (the validate CI check requires it current).
2026-09-08 09:12:12 -04:00
James Dansereau 1ad03b3d53 fix(model): D191 — Bunker ii / Statue crash: bondviewSelectCuff Switches stride
bondviewSelectCuff byte-indexed header->Switches (a ModelNode* array,
8-byte stride on PC per D43/D45) with `offset = switchindex << 2`, the
N64 4-byte-pointer stride. Every base[N] deref then landed mid-slot and
produced a half-word-swapped pointer (0x70267a90 -> 0x70267a9000000000),
non-NULL by luck, handed straight into modelGetNodeRwData -> SIGSEGV on
the frame the player fires (gunUpdateAndFire -> bondviewSelectCuff,
switchindex=29). Confirmed by a gdb backtrace from the shipped v0.1.0
bundle on the Bunker ii first-guard-kill repro.

Identical to D141, which fixed the same Switches byte-index bug in
gunfire.c; this call site was missed. Fix is PORT-only:
`offset = switchindex * sizeof(ModelNode *)`. The downstream
`base = (u8*)switches + offset` math and the #else N64 line are unchanged.
ABI / pointer-width only (D3x class) — no game-logic change.

Verified: build clean; -level_09 golden framediff 3/3; -level_27 +
scripted-fire crash-free; user playtest of Bunker ii + Statue both
crash-free -> 21/21 solo levels crash-free (v0.2.0 gate).
2026-09-08 09:12:12 -04:00
James Dansereau aecad2ac91 Merge pull request #30 from jkdansereau/docs/d208-verified
docs: D208 FIXED + playtest-verified
2026-09-08 08:13:54 -04:00
James Dansereau 15c374813e docs(audio): D208 FIXED + playtest-verified (M-78d)
Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 08:13:01 -04:00
James Dansereau 15b32d73d9 Merge pull request #29 from jkdansereau/audio/d207-drop-probe
D205 closed; D208 player-fire-sound fix (field_A48 pointer width); D207 [D207-DROP] probe re-armed.

https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 08:10:17 -04:00
James Dansereau 76910cbf19 fix(audio): D208 — player fire sound goes permanently silent (field_A48 ptr width)
Root cause of the "doesn't recover after the alarm stops" half of the user's
D207 report (distinct bug from D207's voice-pool question).

struct field bondview.h:200 `s32 field_A48` is used as a SECOND
ALSoundState* sound-handle slot for the player's double-buffered per-hand
fire sound (gunfire.c:3186-3200). sndPlaySfx stores the new state through
`&field_A48` as an 8-byte `pendingState->link.next = nextState`; as s32 that
write tears into the adjacent field_A4C (the auto-fire cadence timer, rewritten
every frame). After enough double-buffered fire cycles field_A48 holds a value
that is neither 0 nor a valid pointer, so the `else if (field_A48 == 0)`
re-arm gate never fires again and no further player gunshot is issued.

Evidence: user Bunker audiotrace.log — silenced-PP7 idx-46 sndPlaySfx requests
stop dead at log line 18930/25086 while all other SFX continue; the player-gun
slot 0x70076630 is then caught holding a torn value 0x000001db00000000
(field_A48=0 + field_A4C=0x1db read as one word).

Fix (D3x ABI/layout, #ifdef PORT): declare field_A48 as ALSoundState* under
PORT, s32 verbatim under #else. hands[] is runtime-only state (g_CurrentPlayer),
never ROM-serialized, and all downstream fields are accessed by member name —
widening 4 bytes is layout-safe. The (struct ALSoundState *) casts at the use
sites become no-ops and stay for a minimal diff.

Build clean (gunfire.c + full link). Runtime verification owed: replay the
Bunker firefight, confirm idx-46 requests continue for its full duration.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 08:06:28 -04:00
James Dansereau 0f7fbcecdf docs(audio): D207 M-78b — user Bunker repro; player-gun double-buffer lead
User repro: player gunshots specifically go permanently muted mid-firefight
and stay muted after the alarm ends; all other SFX fine. Not generic pool
starvation — a stuck-state bug in the gunfire.c:3178 two-slot double buffer
(audioHandle / field_A48). Leading mechanism: asynchronous sndDeactivate
posts to the 64-slot SFX event queue; under firefight+alarm load the queue
saturates, alEvtqPostEvent drops the deactivate, the state never disposes,
the slot never clears — once both slots stick, no player gunshot can play.
All three pool sizes are byte-match stock GE => port timing bug, not a value
to bump. Next: read the user's audiotrace.log to confirm evtq saturation +
uncleared slots.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 07:56:02 -04:00
James Dansereau 2f503f007d diag(audio): D207 — re-arm [D207-DROP] counter; close D205
D205: CLOSED (M-78). User re-tested explosion->scream on Bunker after the
PR #28 merge — the explosion SFX is correct by ear now. All four D205
symptoms were D206 (the +1 bank-slot alias); the "scream" was a coincident
guard death-yelp (faithful N64 behaviour). M-74's pan audit already ruled
out a presentation-layer bug.

D207: re-add ONLY the [D207-DROP] trace line (not the reverted last-resort
LOOPED-preemption pass) at both sndDisposeSound no-voice sites in snd.c,
gated on GE_AUDIOTRACE, logging state/prio/flags/count=N/max — so a user
capture on the real failing level is decisive between (a) heavy-level 8/8
starvation, (b) a leak on an alarm-stop path, (c) perceptual. Static
re-check noted in findings D207: ALARM3 slot 162 sampleVolume=80 (below
gunfire), keyMax=8 => not RETRIGGER => continuous-loop by design on N64 too.

findings-index.csv regenerated.

Claude-Session: https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 07:44:29 -04:00
James Dansereau e3bebfc454 Merge pull request #28 from jkdansereau/feat/phase3-audio
Merge Phase 3 audio: software mixer + SFX fidelity (D198–D206), D202 closed.

https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
2026-09-08 07:32:23 -04:00
James Dansereau 1dcc518e75 docs(dev): GE-ENV-PROBES.md — add GE_FORCEALARM (D207) row
Fixes the validate CI job (gen_env_probes.py --check drift).
2026-09-07 22:35:57 -04:00
James Dansereau c855625643 docs(dev): D202 closed, D206 by-ear PASS, D207 monitoring (M-77c)
User by-ear A/B vs N64 confirms D206: armour pickup, unarmed melee whiff
and silenced PP7 all correct. D202's silenced-PPK 'slap' was D206 (SFX
bank index +1) — CLOSED; M-65/M-66b ownerless-loop mitigations stay.
D205 narrows to symptom (2) explosion->scream only. D207 (alarm klaxon)
acceptable in play per user; voice-starvation hypothesis didn't hold up
(M-77b), no fix shipped, GE_FORCEALARM probe kept for follow-up.

Regenerated findings-index.csv.
2026-09-07 22:30:54 -04:00
James Dansereau 0845d70953 port(audio): D207 diag probe — GE_FORCEALARM
Temporary env-gated probe in handle_alarm_gas_timer_calldamage to force
and trace the alarm klaxon while investigating D207 (user report: alarm
SFX overrides other audio). #ifdef PORT + getenv, inert unless set.
Remove once D207 is resolved (see findings.md D207 / HANDOFF).
2026-09-07 22:23:01 -04:00
James Dansereau 9bf0c49151 fix(audio): D206 — every SFX resolved one bank slot too high
GE's sndPlaySfx reads the on-disk sound bank through the cast alias
struct ALInstrumentAlt_s { s32 unk0, unk4, unk8; ALSound *soundArray[1]; }.
On N64 (4-byte pointers) soundArray sits at struct offset 12 and
deliberately aliases the on-disk ALInstrument's bendRange/soundCount
words, so soundArray[N] == on-disk table entry [N-1] — GE's SFX_IDs are
1-based into the table by design (ID 0 = NOTHING_SFX, short-circuited).
On PC the 8-byte pointer + 8-byte alignment push soundArray to offset 16,
and the converted bank (romdata.c afFixupInst) is packed to match, so
every SFX_ID resolved one bank slot too high.

Root cause of D202's silenced-PPK 'slap' (id 46 -> PUNCH1 instead of
GUN_SILPPK_A) and D205 (3) armour + (4) melee->Klobb. M-61's rejected
'index 45, +0.999' was correct all along.

Fix: src/snd.c:1001, #ifndef PORT / #else -> soundArray[soundIndex - 1],
N64 line kept verbatim. ABI/layout only (D3x class), no game-logic change.
Also serves the retrigger chain (same 1-based space).

Verified: build clean; BUNKER1 GE_AUDIOTRACE 60s crash-free, all ~90
distinct requested indices resolve to rom_sfx_decode.py slot N-1 (0/90 at
N); -level_09 golden framediff 3/3. User playtest: 'most audio is good now.'

findings.md D206 + D205 (M-73/M-74) + D207; porting-notes.md §E.
2026-09-07 22:22:55 -04:00
James Dansereau 044d24a15f docs(dev): D205 M-73 correction — Klobb is a specific in-game SMG (Skorpion, idx 106); the user's sound identifications stand; capture shows no 106 while user was punching (47-49/105 all voiced), so melee->Klobb identification is OPEN (H3 vs H4), not resolved by the 109 barrage 2026-09-07 15:23:50 -04:00
James Dansereau fb037b3c0c docs(dev): D205 M-73 — user combat capture root-causes all three wrong-sound moments to guards stuck in sustained fire-sound re-triggering (idx 109 barrage, no bullets); LOS/seen_bond_time on converted geometry is the leading suspect 2026-09-07 15:17:03 -04:00
James Dansereau 087c60d05f docs(dev): D205 M-72 — 4th symptom (melee→Klobb idx 106); controlled GE_AUDIOTRACE runs exonerate all request paths; mechanism (b) extra-events leading suspect 2026-09-07 14:47:25 -04:00