Commit Graph
516 Commits
Author SHA1 Message Date
James Dansereau 04a6372b90 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 01376660ee 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 a04b819378 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 97ac5327c9 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 0b2da7bc2d 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 65b7516434 docs(D172): add GE_D172 to GE-ENV-PROBES.md (validate gate) 2026-09-08 23:37:49 -04:00
James Dansereau d23befb08f 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 109d37a6c2 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 3e2f62b0a3 diag(D172): probe only non-LOD multitex (tex_lod=0) — the particle case 2026-09-08 23:25:26 -04:00
James Dansereau 4f2585245d 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 65d3e025a5 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 ecd77dea79 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 75f39ebe13 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 ce920e1c46 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 f4b35dcc7f 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 ca65635ecc 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 4f38cb6271 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 8c12d4a691 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 a3ca4fb9c4 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 6e758f1daf 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 34ae9f547a 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 316563663d 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 c26335a0a2 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 add8888ec2 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 3b7a0c8e2a 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 43d205386d 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 223a4a10e2 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 b457c0148c 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 2770690ebc 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 2a0d4b8912 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 61a2073470 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 524a95f824 Merge pull request #30 from jkdansereau/docs/d208-verified
docs: D208 FIXED + playtest-verified
2026-09-08 08:13:54 -04:00
James Dansereau 3b383a48e1 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 d52d8914a7 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 7601b9db49 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 2298756b70 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 842d73a799 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 933e264d85 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 d56c15d9df 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 c25285d8c5 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 04a539ba4a 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 378386d19d 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 2645618a4b 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 0181c3f2bf 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 9596eb58b4 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
James Dansereau 4c460e551a docs(dev): D202 M-71 by-ear PASS + close-ready; new D205 wrong/extra SFX indices (bank exonerated); [VOL]/[DISTVOL] probes 2026-09-07 14:18:39 -04:00
James Dansereau 1ac92c7d25 docs(dev): D202 M-67/M-68/M-69 findings writeups + probe catalogue + audio-plan status
- findings.md: D202 §M-67 (static analysis exhausted, GE_VOICEDUMP probe
  motivation), §M-68 (decisive runtime A/B — all three user complaint
  classes play the exact ROM samples at the exact ROM pitches) and new
  §M-69 (320-request full-corpus exact match: 286/320 CC >= 0.85; the
  initial 44 'BAD' were tooling artifacts — voicedump stamps are block-END
  times, coarse-slide missed sharp onsets, refs unclipped at voice death;
  remaining failures = dropped allocations / shared-wavetable wire
  attribution, not corruption) + index row.
- GE-ENV-PROBES.md: GE_VOICEDUMP entry (incl. block-END-stamp gotcha);
  [WIRE]/[VOICEMAP]/[EVT]/[STOP-EVT] documented under GE_AUDIOTRACE.
- AUDIO-PLAN.md: status banner — Phases 0-2 done (D199/D200/D201/D204),
  Phase 3 verification is the active track.
2026-09-06 08:21:20 -04:00
James Dansereau 31935ad968 port(audio): D202 M-67-M-69 runtime probes — GE_VOICEDUMP, [VOICEMAP], timestamped [WIRE]/[EVT]/[STOP-EVT]
Env-gated diagnostics for the D202 per-voice sample-correctness work
(M-67/M-68/M-69), all inert unless the env var is set:

- GE_VOICEDUMP (mixer.c aEnvMixerImpl): per-voice resampled mono stream
  to voicedump.raw, records [stateAddr][nSamples][us][s16 x n]. NB the
  us stamp is mix time — records render ahead, samples END at the stamp.
- [VOICEMAP] (synthesizer.c, under GE_AUDIOTRACE): full PVoice ->
  decoder/resampler/env-state address map at synth init, tying voicedump
  slot addresses to WIRE filter addresses.
- [WIRE] (load.c) now carries a µs timestamp; sndPlaySfx trace gains
  t=µs + wavetable pointer.
- [EVT]/[STOP-EVT] (snd.c): every ALSndpEvent the player processes, plus
  the release ramp a STOP/DEACTIVATE computes (deltaUs).
- audiodebug.ps1: -VoiceDump switch.

With scratchpad/exact_match.py these prove every allocated voice plays
the exact ROM sample at the exact ROM pitch (286/320 CC >= 0.85 on the
M-69 full-corpus run; remaining failures are dropped allocations + wire
attribution, not corruption). Remove once D202 closes.
2026-09-06 08:21:14 -04:00
James Dansereau 7b4386835e tools(pc): audiodebug.ps1 audio-probe driver; gitignore run artifacts
audiodebug.ps1 drives the D202/D204 audio probes (health monitor,
GE_AUDIOTRACE/GE_AUDIODUMP, soundIndex histogram, -AB before/after runs —
how D204 was measured); companion to debug.ps1 (crashes). README table row
added. .gitignore: scratchpad/ + root-level audiodump/audiotrace/mixertrace
artifacts.
2026-09-05 19:07:43 -04:00
James Dansereau 35f9bdda91 docs(dev): add live research/plan docs (D172, D176a, viewmodel, QOL, widescreen, unlocked-fps)
All referenced from HANDOFF.md / GRAPHICS-BACKLOG.md but untracked:
- D172-PARTICLE-COLOR-RESEARCH.md: magenta/cyan blood root-cause candidate
  (unhandled GBI opcode 0xB9 in ROM particle records + D146 guard)
- D176a-SKY-NOTES.md: RDPHALF sky-tri wire format decoded; Path B draft state
- VIEWMODEL-RESEARCH.md: 1P weapon model static audit, three ranked suspects
- QOL-INVENTORY.md / WIDESCREEN-FOV-PLAN.md / UNLOCKED-FPS-PLAN.md: parked
  Phase-4 proposals
2026-09-05 19:07:43 -04:00