From b47eea65e3e1edcc1765c76b1bf57cfb1d6d9075 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:51:18 -0400 Subject: [PATCH] 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. --- docs/dev/findings-index.csv | 1 + docs/dev/findings.md | 1 + docs/porting-notes.md | 12 ++++++++++++ src/game/chraction.c | 11 +++++++++++ 4 files changed, 25 insertions(+) diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 54522708..e15bfaa4 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -36,6 +36,7 @@ D206,FIXED,PC played every SFX one bank slot too high —,"FIXED (M-76, src/snd. D207,PROPOSED,Alarm klaxon starves combat SFX and (per user) doesn't recover after it stops —,MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — D208,FIXED,Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) —,FIXED + user playtest-verified (M-78d) — D209,FIXED,Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands —,FIXED + user-playtest-verified (M-81). +D210,FIXED,chrToPatrol() leaves act_patrol.lastvisible60 uninitialised on PC —,"FIXED (chraction.c:3872, #ifdef PORT → self->act_patrol.lastvisible60 = -1;, N64 line under #else)." D205,RESOLVED,PC plays wrong/extra SFX for gameplay events the user can A/B against N64:,CLOSED (M-78) — root cause was D206; all four symptoms resolved + user-confirmed by ear vs N64. D152+,FIXED,D152 addendum (M-31):,"FIXED (src/snd.c, port/src/libultra.c, #ifdef PORT) —" D168,FIXED,GE_PCDUMP / F12 PPM captures were vertically flipped —,FIXED (port/fast3d/gfx_opengl.cpp); D114 / D116 reclassified as capture-orientation artifacts (below). diff --git a/docs/dev/findings.md b/docs/dev/findings.md index a5832f56..9b58806d 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -409,6 +409,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D207 | **Alarm klaxon starves combat SFX and (per user) doesn't recover after it stops — surfaced by D206 (M-77, ROOT-CAUSED, fix designed not applied).** Before D206, `ALARM3_SFX` (id 163) resolved to the wrong bank slot 163 — a **one-shot** sample: played once, freed its voice. After D206 it correctly resolves to slot 162 = the **real infinite-loop klaxon** (`decayTime=-1` -> `SOUND_FLAG_LOOPED`; ADPCM `loop.count=-1`). Played by `handle_alarm_gas_timer_calldamage()` (`src/game/propobj.c:14457`) with owner `&ptr_alarm_sfx` while `alarmIsActive()`. It now **permanently holds 1 of only 8 SFX voices** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS=8`, `src/music.c`), at priority `0x41` (looped sounds get `decayTimeFlag + 0x40`, `sndSetupSound` `snd.c:820`) — and is **exempt from the port's preemption scan** (`snd.c` ~369: `!(unk3e & 0x12)` refuses to steal any LOOPED/RETRIGGER voice; the M-65 carve-out only covers *ownerless* loops, and the alarm is owned). **M-77b:** the voice-starvation hypothesis did NOT hold up — a BUNKER1 `GE_FORCEALARM` + scripted-fire soak showed **0 true drops** (`sndDisposeSound` no-voice sites instrumented), the designed last-resort preemption pass **never fired**, and `g_sndAllocatedVoicesCount` recovered to 0 after every alarm-off (no leak). The earlier "~20% drop" was a log-latency artifact. The alarm klaxon (`ALARM3` slot 162) is a *continuous, gapless* infinite loop — a real GE alarm pulses — so the leading remaining explanation is **perceptual** (the drone masking gunfire, and/or slot-162 mixed too loud vs N64), or genuine 8/8 starvation only on a heavy level not yet tested. Needs a user repro. | MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — **the voice-starvation hypothesis did NOT hold up (M-77b).** The designed last-resort LOOPED-preemption pass was built, and instrumented drop counters (`[D207-DROP]` at both `sndDisposeSound` no-voice sites) + a `[D207-YIELD]` trace were added. Result on BUNKER1 (`GE_FORCEALARM` + scripted sustained fire, ~85 s): **zero true drops**, the last-resort pass **never fired** (pass-1 always found a non-looped victim), and `g_sndAllocatedVoicesCount` sat at 1 during quiet alarm and recovered to **0** after every alarm-off — no leak, no permanent starvation. The earlier "~20% dropped" figure was a measurement artifact (log-line latency, not real drops; every requested sound eventually got a voice via the `unk38` retries). So the fix was **reverted** (kept: nothing in `snd.c`; D206 `-1` only). BUNKER1 combat is too light to peg the 8-voice pool. **Next session needs a real repro from the user:** which level + weapon, and a `GE_AUDIOTRACE=1` capture at the failing alarm — is it (a) genuine 8/8 starvation on a heavy level (Facility/Silo/Statue firefight), (b) a leak that only triggers on a specific alarm-stop path, or (c) perceptual — the continuous klaxon drone masking gunfire / the alarm mixed too loud (check `ALARM3` slot 162 volume vs N64). Temp `GE_FORCEALARM` probe kept in `src/game/propobj.c` for that work. **M-78: re-added just the `[D207-DROP]` counter (NOT the reverted last-resort pass) at both `sndDisposeSound` no-voice sites in `snd.c` (gated on `GE_AUDIOTRACE`), logging `state`/`prio`/`flags`/`count=N/max`, so the user's real-level capture is decisive between (a)/(b)/(c). Static re-check of `ALARM3` slot 162 this session: `sampleVolume=80` (vs gunfire 90-110, punches 100-110 — the klaxon is quieter per-sample, not louder, weakening the "mixed too loud" half of (c)); `decayTime=-1` + `keyMax=8` (`&0xf0=0` → NOT `SOUND_FLAG_RETRIGGER`) → it is a continuous looped sound by design on N64 too, the pulse is baked into the 225→7512-sample ADPCM loop body. So (c) narrows to "the continuous drone masks gunfire" or a loop-point playback defect; (a)/(b) still need the heavy-level capture. Launcher `build-pc/d207_run.ps1` added (forced-alarm auto-repro on level_09).** | | D208 | **Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) — `struct` field `bondview.h:200 s32 field_A48` is used as a second `ALSoundState *` sound-handle slot, but is only 32 bits wide on PC (M-78c, ROOT CAUSE FOUND, D3x pointer-width class).** This is the "doesn't recover after the alarm stops" half of the user's D207 report — a distinct bug from D207's voice-pool question. The player's per-hand fire sound is double-buffered across two handle slots: `hands[h].audioHandle` (`ALSoundState *`, correctly pointer-width) and `hands[h].field_A48` (`s32`). `gunfire.c:3186-3200` (`bondfirefunc`) each trigger interval calls `sndPlaySfx(bank, idx, (ALSoundState *)&handptr->field_A48)`, and `sndPlaySfx` writes the new state through it as `pendingState->link.next = nextState` — an **8-byte pointer store** into a 4-byte field, overrunning into the adjacent `field_A4C` (which is the auto-fire sound-cadence timer, written back every frame at `gunfire.c:3168`). `sndUnlinkClearSound` likewise does an 8-byte `state->state->link.next = NULL` there. The two writers stomp each other; after enough double-buffered fire cycles `field_A48` holds a value that is neither `0` nor a valid pointer, so the `else if ((ALSoundState *)handptr->field_A48 == 0)` re-arm gate in the double buffer is **never true again → no further player gunshot is ever issued.** `audioHandle` alone can't cover because the code only replays into it `if (audioHandle == NULL)` and the async `sndDeactivate(audioHandle)` leaves it briefly non-NULL, deferring to the jammed `field_A48` branch. **Evidence (user Bunker capture, `audiotrace.log`, silenced PP7 = idx 46):** idx-46 `sndPlaySfx` requests stop dead at t=776609203515 (log line 18930 of 25086) while guard fire (109), pickups (232), impacts, doors all continue to the end; at line 19159 the player-gun slot `0x70076630` is caught holding `was 0x000001db00000000` — a torn 64-bit value = `field_A48=0x00000000` + `field_A4C=0x1db` (475, a `g_GlobalTimer`-relative cadence stamp) read as one word. The alarm is not required — it just raises `sndPlaySfx` churn so the tear is hit sooner. On N64 both fields are 4 bytes, `audioHandle` is a 4-byte pointer, and `&field_A48` punned as `ALLink*` is a 4-byte `link.next` write — all consistent, no overrun. **`bondview` / `hands[]` is runtime-only state (`g_CurrentPlayer`), never ROM-serialized**, so widening `field_A48` to pointer width is layout-safe (no by-offset or ROM-cast access — grep-verified; only `field_A50`/`field_A4C` sit after it and are plain `s32` timers). | FIXED + user playtest-verified (M-78d) — Bunker firefight: player gunshots persist through the whole engagement and survive the klaxon stopping. Committed 7601b9db (PR #29, merged). | | D209 | **Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands — `act_ubytes.padding[45]`, a raw-byte alias into the action union for `act_gopos.unk59` (the SPEED tier), shifts from offset 45 to 81 at 64-bit (M-81, ROOT CAUSE of D193, D3x pointer-width class).** GE character *travel* is animation-root-motion driven, so the bound locomotion animation sets the speed. `get_sound_at_range()` (`chraction.c:3573`, the locomotion-anim selector — misleading name) picks `ANIM_DATA_sprinting`/`running`/`walking` (and the one-handed/female variants) from its `arg1` tier. Its **only** caller, `play_hit_soundeffect_and_proper_volume()` (`chraction.c:3665`), passes the tier as `self->act_ubytes.padding[45]` — a raw byte read out of the `ChrRecord` action union rather than the named field. On N64 that alias is exact: `struct act_gopos` = `{coord3d targetpos@0, StandTile *target@12, waypoint *target_path@16, waypoint *waypoints[6]@20, u8 curindex@44, u8 unk59@45}`, so `padding[45]` **is** `unk59`. On x86-64 the three pointer members widen 4→8 B (`target@16, target_path@24, waypoints@32`), pushing `curindex`→80 and `unk59`→**81**, while the literal `45` now lands inside `waypoints[1]` (spanning 40..47) — specifically **byte 5 of a heap pointer**. The port's arena is low-4GB (`0x00000000_70xxxxxx`), so byte 5 is **always `0x00`** → `arg1 = 0` → `ANIM_DATA_walking` / `ANIM_DATA_walking_unarmed`, unconditionally, for every AI chr on every level since the 64-bit transition. The named-field reads (`chrlvApplySpeed(self, ..., (s32) self->act_gopos.unk59, ...)` at `:9104`, and the `unk59 == 2` / `== 1` `modelSetAnimSpeed` tiers at `:9106-9130`) resolve correctly, which is why the tier *looked* right everywhere it was inspected — only the alias was broken. **Runtime evidence (Cradle `-level_41`, `GE_D193A` extended with `tier`/`aidx`):** all six chrs report `act=15 ACT_GOPOS tier=1 SPEED_RUN` on 420/440 samples, yet `aidx` (live `model->anim` reverse-resolved against `animation_table_ptrs1`) is **40 = `PTR_ANIM_walking`** for Trevelyan (chr 0) and **107 = `PTR_ANIM_walking_unarmed`** for the guards on every single sample — `aidx 42 (running)` / `41 (sprinting)` never appear. Travel speed sat in a flat 140–155 u/s band for all of them, independent of animation, which is the walk-cycle stride. `act_ubytes` has exactly one use in the whole tree, so this is a single site. Both callers of `play_hit_soundeffect_and_proper_volume` reach it with `actiontype == ACT_GOPOS` (`:3723`, immediately after `actiontype = ACT_GOPOS; act_gopos.unk59 = speed;`; and `:9087`, inside the explicit `else` of `actiontype == ACT_PATROL`, mirroring the `chrlvApplySpeed` split directly below it), so reading the named field is exactly the byte N64 reads — no behavioural change. | **FIXED + user-playtest-verified (M-81).** `chraction.c:3665`, `#ifdef PORT` → `get_sound_at_range(self, self->act_gopos.unk59, ...)`; the N64 line kept verbatim under `#else`. ABI/pointer-width only, no game-logic change (rule-2 narrow exception, D3x class). User ran Cradle on the fix build: **Trevelyan and the guards run.** Also corrected the `// guess: room` comment on `unk59` (`src/bondtypes.h:2191`) — it is the SPEED tier. | +| D210 | **`chrToPatrol()` leaves `act_patrol.lastvisible60` uninitialised on PC — a raw-byte union alias (`act_init.padding[0x13]`) that shifts when `act_patrol` widens (M-82, A1 class, sibling of D209).** `chrToPatrol` (`chraction.c:3872`, sets `ACT_PATROL`) does `self->act_init.padding[0x13] = -1;` — union-relative byte `0x4c`. On N64 `struct act_patrol` = `{patrol_path *path@0, s32 nextstep@4, bool forward@8, waydata@0xc (0x40 B), s32 lastvisible60@0x4c, f32 speed@0x50}`, so byte `0x4c` **is** `lastvisible60`, initialised to `-1`. On x86-64 the leading `path` pointer widens 4→8 B, so everything after shifts +4: `lastvisible60` is now at `0x50`, and the literal `0x13`/byte-`0x4c` write lands in the last 4 bytes of `waydata` (`segdisttotal`). Net PC effect: `lastvisible60` keeps whatever garbage the previous union arm left, and the "chr hasn't seen the player recently → break off / resume patrol" gate at `chraction.c:~9347` (`(act_patrol.lastvisible60 + CHRLV_DEFAULT_TIMER) < g_GlobalTimer`) reads that garbage on the first tick(s) after entering PATROL — until `chr.c:2569` / `chraction.c:3482` / `:9359` overwrite it (only when the chr actually sees the player). Same root class as D209 (raw index into a union arm whose pointer members widened); found by the post-D209 A1 sweep. Only new instance in the sweep — `act_init.padding[0..3]` in `chrlvTickAnim`/`chrlvTickDead` alias `act_anim`/`act_dead`, both pointer-free, so those are layout-stable. | **FIXED (`chraction.c:3872`, `#ifdef PORT` → `self->act_patrol.lastvisible60 = -1;`, N64 line under `#else`). ABI/pointer-width only (D3x/A1 class), no game-logic change.** Build clean; bunker1/facility/archives/dam verify PASS, no crash. **Owed:** a playtest eyeball of patrol-guard behaviour on entry to patrol (do they over/under-react to a player they haven't seen). Low individual impact but exactly the class the D170/D193 "AI behaviour looks off" reports keep landing in. | | D205 | **PC plays wrong/extra SFX for gameplay events the user can A/B against N64: general gunfire has a slap/glass layer audible on PC but silent on N64; an explosion plays a soldier-scream sample; armor pickup plays a wrong sample; unarmed melee (slap) plays Klobb's shooting sound idx 106 instead of the slap effect (M-71/M-72, user by-ear + N64 A/B).** **M-74 WITHDRAWS M-73's root cause.** M-73 read the user's combat capture as guards stuck re-triggering fire sound idx 109 ("256x/29.7 s, no bullets") and pinned it on `stanTestLineUnobstructed` LOS over converted collision geometry. Re-counting the same file: idx 109 is requested **128x** not 256x (the double-log artifact D204 already corrected for M-63 on this very index), = 4.31/s across ~11 guards = ~0.4/s each, **~6x UNDER** the AK47's own `SoundTriggerRate` (`RATE_AK47`=4 ticks => 15/s ceiling) — so the `field_178` gate never binds; and impacts are plentiful in the same window (~30 ricochet/wall-hit 19-41, 69 x2 flesh, 12 body-falls 123-132, 14 yelps 134-147). The eye-catching monotone sweeps 134->147 / 123->132 are the ground-truth round-robin `male_guard_yelp_counter` (`chraction.c:2454-2470`), faithful, not a broken selector. **The 109 traffic is normal in-spec guard combat**; do NOT open the pccg-stan/LOS geometry investigation on this evidence. Everything upstream of presentation is proven: bank 261/261 identical (M-71), sample+pitch exact (M-68/M-70), and every index in the capture correct (182 explosion x1, 81 armor x1, 46/47/48/49/105). **Remaining untested layer = spatial presentation (per-voice volume, pan, concurrency)** — it explains all four complaints at once (a wrongly-placed sound is by ear a sound at your own position) and is the youngest code in the stack (`sndCreatePostEvent` was fully stubbed by D138 until M-65). **M-78: CLOSED.** All four symptoms were D206 (the +1 bank-slot alias). (3) armour + (4) melee->Klobb + (1) slap/glass over gunfire: user by-ear A/B vs N64 = PASS (M-77c). (2) explosion->scream was thought NOT explained by D206 (169-183 -> 168-182, all still explosions) but the user re-tested on Bunker post-merge and the explosion SFX is now correct by ear — the earlier "scream" was a coincident guard death-yelp (GET_HIT_MALE 134-147 seen +/-1s of the blast in M-73's capture), faithful N64 behaviour, not a wrong sample. No presentation-layer bug: pan audit (M-74) already showed GE never spatially pans SFX (252/261 bank sounds `samplePan==64`, no `AL_SNDP_PAN_EVT` poster). | CLOSED (M-78) — root cause was D206; all four symptoms resolved + user-confirmed by ear vs N64. No separate presentation-layer fix needed. | | D152+ | D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_NONE)` — **all balanced**, no unbalanced early-return. Real fixes: `sndSetSfxSlotVolume` now holds the mask across its list walk (matches its twin `sndDeactivateAllSfxByFlag`) + `sndApplyVolumeAllSfxSlot` batches the slot loop under one recursive hold (kills the fade-out lock-acquire storm); `portThreadWrapper` calls `imThreadExitRelease()` on thread exit (kills the "transient thread acquired `OS_IM_NONE` and died" leak + the pthread-id-reuse re-wedge). Steal-lock kept as backstop. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`) — fade-out repro playtest-gated, not headless-verified | | RC3 · D167 | **Non-power-of-two texture wrap period (`docs/dev/TEXTURE-GLITCH-ANALYSIS.md` §6 RC3 — "textures repeat oddly", residual Depot-ceiling noise after D161).** The N64 RDP masks the texel coordinate of a wrapping render tile at `1<act_patrol.forward = 1; self->act_patrol.waydata.age = randomGetNext() % 0x64U; self->act_patrol.waydata.unk03 = 0; + /* D210 (A1 raw-byte-alias class, sibling of D209): `act_init.padding[0x13]` + * (union-relative byte 0x4c) is `act_patrol.lastvisible60` on N64. On PC + * `act_patrol` widened by 4 (the `path` pointer), so byte 0x4c now lands + * in `waydata.segdisttotal` and `lastvisible60` (@0x50) is left + * uninitialised -> the "hasn't seen the player recently" patrol check at + * chraction.c:~9347 reads garbage on the first tick after entering PATROL. + * Write the named field. */ +#ifdef PORT + self->act_patrol.lastvisible60 = -1; +#else self->act_init.padding[0x13] = -1; +#endif self->act_patrol.speed = 0.0f; chrlvSetNextActPatrolStepPadPos(self);