From 7af146a1d03ba3b3131a9badd2fe9091bb5ebbbe Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:53:10 -0400 Subject: [PATCH] D329: fix 60 fps guard anim pin (D318/D320 root cause) - tick-granularity divergence The D318/D320 guard freeze is not a platform-invariant lock. modelTickAnim folds numticks into one modelSetAnimFrame2WithChrStuff call, which only advances framea/frameb when floor(frame) crosses an integer. At 1 tick/frame (port at 60 fps) and attack speed 0.5-0.8, a pair left equal by the aim-hold endframe clamp is re-emitted stale after the endframe is raised, so the model pins forever. At >=2 ticks/frame (N64's normal regime) it cannot. - model.c (#ifdef PORT, Rule-2 sign-off): when the loop did not run and the current endframe no longer blocks the next frame, re-derive frameb as modelSetAnimFrame does. GE_D329=0 disables, GE_D329LOG=1 logs triggers. - Verified: lockstep clk=1 no longer pins (Facility ai_19, Control ai_9, WSL + native Windows, watchdog off); 0 triggers at clk=2; user playtest confirms N64-matching Ourumov timing. Bunker1 golden failure predates it (identical with the fix off). - d318bprobe (TEMP, env-gated): attack-state probe + GE_D318B_CLK lockstep. - d320repro: fix jump=,... parser (documented form never parsed). - findings D329 + D318/D320/D321 corrections, porting-notes D13. --- docs/dev/findings-index.csv | 7 +- docs/dev/findings.md | 94 ++++++++- docs/porting-notes.md | 9 + port/include/d318bprobe.h | 13 ++ port/src/d318bprobe.c | 377 ++++++++++++++++++++++++++++++++++++ port/src/d320repro.c | 14 ++ src/game/lv.c | 2 + src/game/model.c | 58 ++++++ 8 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 port/include/d318bprobe.h create mode 100644 port/src/d318bprobe.c diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index d639bb05..2ad8cc33 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -195,10 +195,10 @@ D314,OPEN,"F10 menu items flicker randomly/intermittently (reported on the main- D315,OPEN,Menu scaling differs between main menu and in-game —,"OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix)." D316,FIXED,"Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).",FIXED — root-caused to two compounding bugs (naive mouse->logical mapping ignoring the safe-area crop; asymmetric row hit-test band) in port/src/optionsoverlay.c + port/fast3d/gfx_pc.cpp. D317,CLOSED,"Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) —","CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on ""All unlocked"" fall through to the adjacent ""Quit to desktop"" row)." -D318,RESOLVED,Facility (regular play path):,RESOLVED (2026-09-19): +D318,OPEN,Facility (regular play path):,"OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped." D319,FIXED,"Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).",FIXED — new ROW_FPSCAP cycle (30/60 only) in port/src/optionsoverlay.c; default changed to 60 in port/src/video.c. -D320,RESOLVED,"D318-class sweep: aim-hold → _update AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).",RESOLVED (2026-09-22): -D321,CLOSED,D318 trigger chain fully mapped:,CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play. +D320,OPEN,"D318-class sweep: aim-hold → _update AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).","OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped." +D321,OPEN,D318 trigger chain fully mapped:,"OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped." D322,OPEN,Long-session audio degradation:,"OPEN — static triage done (teardown audit, mixer statelessness, D202-coverage check); ranked hypotheses:" D323,OPEN,G_TL_LOD bindings of explicit-LOD textures:,"OPEN / parked — mechanism confirmed in code; census (tools_pc/bg_lod_audit.py) shows 15 levels with explicit-LOD textures bound under TEXTLOD-on classes (stat 378, azt 806 binds heaviest); overwhelmi…" D324,FIXED,"GE's stub declares nothing, so TUs that include it get NO CRT prototypes —",FIXED (2026-09-22) — proven impact: @@ -206,3 +206,4 @@ D325,CLOSED,ROM-mod (xdelta) compatibility:,CLOSED as a question — D326,CLOSED,Random Eye randomizer repacks the ROM too —,CLOSED as a question — D327,RESOLVED,"Mod support, resolved plan:",RESOLVED — superseded by D328; approach (a) is NOT ruled out (these scans missed the 1172-compressed data segment). D328,CLOSED,"Mod support is viable and D327's ""ruled out"" was wrong:",CLOSED as a question — +D329,FIXED,The D318/D320 guard anim pin is a tick-granularity divergence:,"FIXED — Rule-2 high-FPS fix in model.c; clk=1 no longer pins (WSL + native Windows), 0 triggers at clk=2; bunker1 golden failure predates it (on/off A/B); user playtest confirms N64-matching Ourumov…" diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 9f7adcd5..9ebbf6a0 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -603,10 +603,10 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D315 | **Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D315` entry at file tail | OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix). | | D316 | **Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D316` entry at file tail | FIXED — root-caused to two compounding bugs (naive mouse->logical mapping ignoring the safe-area crop; asymmetric row hit-test band) in `port/src/optionsoverlay.c` + `port/fast3d/gfx_pc.cpp`. Build-verified (golden diff PASS, worst_cell=0); interactive click verification owed. | | D317 | **Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) — no dump left behind.** — full `## D317` entry at file tail | CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on "All unlocked" fall through to the adjacent "Quit to desktop" row). Same fix resolves it. | -| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | RESOLVED (2026-09-19): as-authored latent race (gas bit mid-monologue derails c78's pre-aim hold into a permanent ACT_ATTACK anim pin), NOT a port bug; port-side watchdog `port/src/d318watchdog.c` re-inits the attack on confirm. D263 mechanism map stands (different failure mode). **N64 manifestation unverified** (2026-09-22 correction: user's empirical prior is no such N64 freeze; upstream-port-divergence vs unobserved — A/B vs N64 RAM capture is the discriminating test). | +| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. | | D319 | **Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).** — full `## D319` entry at file tail | FIXED — new `ROW_FPSCAP` cycle (30/60 only) in `port/src/optionsoverlay.c`; default changed to 60 in `port/src/video.c`. Root cause was D186 (sim tick ceiling + low-cap guard); this just fixes the menu's exposure of it. Build-verified; interactive confirmation owed. | -| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | RESOLVED (2026-09-22): forced repro confirms the pin on Facility ai_19 (off=483) and Control ai_9 (off=69, ~2/3 variants); Depot ai_12 clean 3/3 (scripted kill progresses — not covered). D318T detector fixed to scan `g_ChrSlots` as well as `g_ActiveChrs`; watchdog generalized to a table with uncapped repeat rescue. Open threads: N64-fidelity A/B via unthrottled emulator (per D318 correction), single Depot ai_10/TREED pin observation, ai_13/14/15 pad lists. | -| D321 | **D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).** — full `## D321` entry at file tail | CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play. Optional deferred tuning: `D318_DEADLOCK_TICKS` 600→300 (user's call). | +| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. | +| D321 | **D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).** — full `## D321` entry at file tail | OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. | | D322 | **Long-session audio degradation: full campaign on v0.3.0 — audio progressively worsens from Silo, by Caverns/Cradle the OST is inaudible and SFX "come and go"; restarting the game restores it (issue #87, user report, 2026-09-20).** — full `## D322` entry at file tail | OPEN — static triage done (teardown audit, mixer statelessness, D202-coverage check); ranked hypotheses: voice-pool exhaustion/counter drift > queue starvation > evtq saturation. `GE_D322` pool-telemetry probe shipped; needs a campaign capture with `GE_D322=1 GE_D204=1`. | | D323 | **G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0, N64 samples the authored mip chain (static census, 2026-09-21).** — full `## D323` entry at file tail | OPEN / parked — mechanism confirmed in code; census (`tools_pc/bg_lod_audit.py`) shows 15 levels with explicit-LOD textures bound under TEXTLOD-on classes (stat 378, azt 806 binds heaviest); overwhelmingly CI8/IA8 (subtle), the only RGBA5551 cut-outs are the D236 set (already fixed). No reported artifact; fix path documented (upload GL mip chains for TEXTLOD-on bindings) but not worth doing speculatively. Also documents GE's OMH field-write encoding (op 0xBA, value at absolute bit position in w1). | | D324 | **GE's stub declares nothing, so TUs that include it get NO CRT prototypes — implicit `int f()` declarations silently corrupt non-int returns: `atof` garbage in every float ini parse (D38 class).** — full `## D324` entry at file tail | FIXED (2026-09-22) — proven impact: `Game.ScreenShakeIntensity` (the only float ini option) silently parsed to garbage whenever a user set it; caught via the D294 `GE_ROOMPOOL` hook. Fix: `port/shim/stdlib.h` now declares atof/strtod/strtol/atexit/getenv unconditionally (K&R form); pc_protos.h gains atof/strtod for game TUs; glass2.c PORT block uses `__builtin_memset`. Residual harmless implicit decls (atoi/floorf/sqrtf/memset builtins, lround) documented. | @@ -614,6 +614,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D326 | **Random Eye randomizer repacks the ROM too — the hypothesised "in-place data-only" mod tier is empty in practice; the in-ROM file table is the single keystone for any mod support (2026-09-22).** — full `## D326` entry at file tail | CLOSED as a question — identical ROM size but 96.5% of bytes and 803/803 file slices differ (content preserved, shifted +7216/+7392/−229888); same `d43_emit` zlib failure. D325's cheap tier disproven. Two bounded scans failed to find the in-ROM `fileentry` table (likely inside the compressed code image); that recovery question gates everything. Backlogged (local notes §5, extends T3.6). | | D327 | **Mod support, resolved plan: reading the file table out of the ROM is ruled out by measurement; content-addressed offset remapping (gap inference over contiguously-packed files) is the viable route (1964GEPD fork review, 2026-09-22).** — full `## D327` entry at file tail | RESOLVED — superseded by D328; approach (a) is NOT ruled out (these scans missed the 1172-compressed data segment). `hw_address` absent from the ROM in all 4 encodings tested (0 hits for the segment-relative form); `file_resource_table.inc.c` is a decomp reconstruction. Viable route: locate unchanged files by content, bound changed ones by neighbour gaps (`obInit` differencing proves contiguity). Verified primitive: GE's `0x7F000000` segment maps uncompressed from ROM `0x34b30`, uniquely signature-findable. 1964 fork is GPL-2.0 — facts only, no code reuse. | | D328 | **Mod support is viable and D327's "ruled out" was wrong: GE's file table IS in the ROM, inside the 1172-compressed data segment, and locating it needs no hardcoded offsets (PD dabs fork review + verification on vanilla + 2 mods, 2026-09-22).** — full `## D328` entry at file tail | CLOSED as a question — table at `0x252c4` inside the segment inflated from ROM `0x21990` (727 rows; 681 exact offset+size matches vs filelist, 0 mismatches). Hardcode-free scan reads Random Eye's own table -> **704/726 files byte-identical** (vs 0/803 at vanilla offsets in D326) and GF64's 24 MB table correctly. PD's architecture is an offline importer + mod-dir override, not a runtime ROM loader; its MIT `rompatch.c` removes the GPL xdelta3 constraint. Remaining work is the mod-dir override layer. | +| D329 | **The D318/D320 guard anim pin is a tick-granularity divergence: it exists only at 1 sim tick per rendered frame (port at 60 fps); at 2+ ticks/frame (GE's normal N64 regime) it cannot persist (lockstep A/B, 2026-09-23).** — full `## D329` entry at file tail | FIXED — Rule-2 high-FPS fix in model.c; clk=1 no longer pins (WSL + native Windows), 0 triggers at clk=2; bunker1 golden failure predates it (on/off A/B); user playtest confirms N64-matching Ourumov timing; watchdog stays shipped for now. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software @@ -12460,6 +12461,8 @@ Same fix as D316 resolves this (no separate code change). Verification: toggle ` ## D318 — Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18). +**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329. + **Reported (user, 2026-09-18):** on Facility (`-level_34`), following the **regular play path**, Ourumov **freezes up and will not shoot Trevelyan when Objective C — destroying the bottling tanks — is completed**. The user states this is **definitely a bug** despite prior agent conclusions to the contrary. **Contradicts D263's resolution.** D263 (v0.2.1) closed the same surface symptom as "not a port bug — proximity/LOS-triggered exactly as on N64", verified via a *teleport probe* that dropped Bond 65 units from Trevelyan and confirmed the full chain incl. the execution shot firing. But that verification was **synthetic** (teleport, not the authored play path), and D263's own refinement already noted the trigger is "significantly sensitive to Bond's precise location" and "not confirmed byte-identical to N64 for every approach". This report reaches the beat via the **authored objective sequence** (Objective C = bottling tanks destroyed) and sees Ourumov frozen with no shot — a materially different entry point than D263 tested. **Do not re-assert the "not a bug / finicky trigger" conclusion from D263 without fresh evidence from this specific path.** @@ -12527,6 +12530,8 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA ## D320 — D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20). +**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329. + **Context.** D318 (Facility ai_22) root-caused a permanent softlock to a *generic* game-code mechanism, not to anything Facility-specific: `actor_fire_or_aim_at_target_update` (`chraction.c:4882`) succeeds whenever the chr is in ACT_ATTACK with `attacktype & (TARGET_AIM_ONLY | TARGET_DONTTURN)`, retargets, and calls `chrlvAttackActionRelated` — which only changes the model's **endframe** and **never calls `modelSetAnimation`**. If the chr is pinned on a non-looping aim-hold pose whose endframe H sits *before* the variant's shoot window (`shoot_start_frame`), and the new endframe E lands above H, nothing ever re-inits the animation (the `chrlvTickAttackCommon` "endframe <= frame" block's re-init sub-branches are all gated out: `unk31==0`, `unk33 != unk34`) → the model stays pinned below the shoot window → the timer/frame-gated fire events (`shoot_start <= frame < shoot_end`) never open → the chr never fires. If the surrounding AI script's only escape is an `if_guard_has_stopped_moving` gate, it can never pass: `chrHasStoppedOrPatroling` (`chraction.c:4291`) returns FALSE for ACT_ATTACK. **Permanent softlock.** Every link is byte-matched ROM logic. (N64 manifestation unverified — see D318's 2026-09-22 correction: the user's empirical prior is that no such N64 freeze exists; the same upstream-divergence-vs-unobserved question applies to every list in this sweep.) **Sweep.** All 21 levels' AI script sources (`assets/obseg/setup/*.c`) scanned for lists containing both an aim command (`guard_try_fire_or_aim_at_target[_kneel]`, BITFIELD byte with `TARGET_AIM_ONLY` 0x20 set — the hold pose) and a `guard_try_fire_or_aim_at_target_update` reachable while the hold is active. 17 lists in 8 levels match; control-flow checked per list: @@ -12568,6 +12573,8 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA ## D321 — D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20). +**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329. + **Question.** User's N64 memory: Ourumov shoots Trevelyan the moment the tanks are blown up. PC playtests looked like he waited for the countdown to finish, then paused ~10 s, then shot. D318 covered the pin + watchdog; this entry closes the upstream question — *when and why does the combat bit `0x04` land, and is the PC trigger chain faithful?* **Probes (all env-gated, port-only, capped, read-only; post-release removal candidates per M-192 item 7):** @@ -13082,3 +13089,84 @@ to end on two real mods, no research blocker remains. Amends **D327** (approach updated in the local notes backlog §5. Cross-ref: D325 (GF64 probe + taxonomy), D326 (Random Eye repack measurement this re-reads correctly), D327 (the scans whose conclusion this corrects), D43/D69/D88 (converters), D179. + +--- + +### M-199 (2026-09-22): adversarial review of the proposed D318/D320 fix plan (frame-data audit → runtime trace → conformance sweep → watchdog excision). **Verdict: SOUND-WITH-CORRECTIONS** — direction and constraint posture are right, but step 1 mischaracterizes the data path and is ~80% already done, the plan's "unhandled branch" is actually the *expected* outcome of step 1 (with no decision rule for it), and step 2 as written hunts for a mechanism that cannot exist in byte-matched code. No code changed; review only. + +**Pre-flight (mechanism re-verified against source this session, all links hold):** +- `chrlvInitActAttack` (chraction.c ~1258–1376): `next_anim = randomGetNext() % len`, `modelSetAnimation(panim_float->anim, flip, panim_float->start_frame, …)`; `unk36`/`unk32`/`unk34` set from weapon props + PRNG. ✓ +- `chrlvAttackActionRelated` (chraction.c:1126–1163): **only ever calls `modelSetAnimEndFrame`** — never `modelSetAnimation`. E-selection branches verified verbatim: AIM_ONLY → `H = (0 ≤ recoil < shoot_start) ? recoil : shoot_start`; live-fire → `E = recoil (if ≥ 0, both unk36 arms) else shoot_start / end_frame / −1`. ✓ +- Re-init block (chraction.c ~7126+): `modelGetAnimEndFrame <= frame` gates everything; the unk31 path (~7209) is the only `modelSetAnimation` re-init and is unreachable while E > H. ✓ +- `modelSetAnimEndFrame` (model.c:2936): accepts `endframe` only if `endframe < ModelAnimation->unk04 − 1`, else stores −1 (unbounded). Named fields throughout. ✓ + +**New finding 1 — there is no conversion step for this data; step 1's premise is wrong.** The firing-animation groups are byte-matched decompiled C in `src/game/chr.c` (`pistol_firing_animation_groupN[]`, named-field access → A1-immune; `len = −1` resolved at runtime by `initResolveAnimGroups`/`initResolveAnimGroupTable`, initactorpropstuff.c:82–158, counting to the sentinel row). The `ModelAnimation` records come from **raw ROM via `romCopy`** (D34, port/src/romdata.c; `g_pc_animdata_base`) — the PC reads the *same bytes* the N64 does, and `ModelAnimation` has no pointer members (bondtypes.h:629; D32 already u32-ized `bitDescriptors`/`bitStream`) so the layout is stable. "Decode from raw ROM and compare against the port's converted assets" compares two things that are identical by construction; no ROM-decode tooling is needed or in `tools_pc/`. + +**New finding 2 — the audit was effectively pre-run, and its outcome is knowable now.** Applying `chrlvAttackActionRelated`'s exact selection logic to `pistol_firing_animation_group1` (the group matching both probe signatures): + +| variant | start | shoot_start | recoil_start | H (pre-aim) | E (post-derail) | outcome | +|---|---|---|---|---|---|---| +| fire_hip_…_slow | 12 | 19 | 24 | **19** | **24** | **PIN** — exact match to frozen-run probe (H=19.00/E=24.00) | +| fire_hip_…_fast | 17 | 25 | 30 | 25 | 30 | PIN | +| fire_standing_… | 12 | 58 | 60 | 58 | 60 | PIN | +| fire_hip_forward_… | 4 | 22 | 22 | **22** | 22 | ESCAPE (E==H → `endframe <= frame` re-init) — exact match to escape-run probe (H=22.00) | + +Group1 is **3/4 pin-capable** — consistent with the observed ~2/3–3/4 forced-repro rate. Other pistol groups: g2 1/2, g3 3/4, g4 1/1, g5 1/1, g6 3/4 — **no all-escape group exists**. Consequence: byte-matched code + ROM-identical data ⇒ under identical ticks the N64 should pin on ≥½ (typically ¾) of derails. The user's spec (0 pins in ~30 y / all 1964 trials escape) is therefore **in direct conflict with the byte-matched data** — this is the plan's "unhandled branch," and it is the *expected* outcome of step 1, not a corner case. The plan has no decision rule for it. + +**New finding 3 — the pin sits exactly ON the fire-window boundary; D318/D320's stated condition is imprecise.** In every E>H variant above, **H == shoot_start exactly** (19/25/58) — not "H < shoot_start" as D318/D320 state. The pinned frame therefore sits at the edge of the fire gate `shoot_start <= frame < shoot_end` (chrlvTickAttackCommon), and whether c78 fires from that state hinges on **sub-frame settling**: `modelTickAnim`'s s32 `framea/frameb` clamp (`modelConstrainOrWrapAnimFrame`) + the `if (vb == va) animframe1 = va` sub-progress discard. The D318 probe printed `%.2f` ("19.00") — 18.99x would display identically. Working hypothesis: on N64 the frame settles at exactly shoot_start → gate opens → every "pin" variant fires (N64 pin rate = 0%, matches spec); on PC it settles just below (or a downstream gate in the fire loop — `unk38[i]` weapon slots, `unk44`/`attack_time` priming — stays closed) → ¾ pin (matches observation). **A single deterministic per-derail divergence explains the full 0%-vs-~66% rate gap with no PRNG argument** — which also confirms the brief's claim that a stream difference cannot explain it. Note this makes "find what re-inits the animation on N64 that the port misses" (plan step 2) misdirected: there is **no** re-init path for E>H in byte-matched code, so if data+code are identical, nothing can re-init on N64 either — the N64 escape must come from the fire gate opening at the boundary (or from the derail never landing), not from a missed re-init. + +**Verdict per attack surface:** +1. **Candidate completeness.** {data, runtime} is exhaustive only if "runtime" is expanded to include (a) fire-gate boundary settling in `modelTickAnim`/`modelConstrainOrWrapAnimFrame` (new finding 3 — the prime suspect) and (b) the upstream derail window (hypothesis i). The §A1 concern (E/H misread at runtime from correct data) is *ruled out by construction* for this chain — compiled named-field access + raw-ROM `ModelAnimation` with no pointer members — but the plan should record that ruling explicitly instead of running a ROM decode. +2. **The unhandled branch.** Expected outcome, not corner case (finding 2). "The user's spec is wrong" is **not** an acceptable first conclusion: byte-matched code + identical data vs a 30-year community record means one link in the pin chain carries an unverified platform assumption; hunt runtime/reachability first (boundary settling is cheap and port-probeable). If the boundary hypothesis fails, one *bounded* emulator artifact — a single `g_randomSeed` memory read feeding `GE_RSEED_LV`, or one trial with frame-precision observation — is a different cost class from the 10-trial series the user ruled out; needs user sign-off before use. +3. **Ordering/feasibility.** Data-audit-first is right but ~80% complete after this review (finding 2). Remainder: extend the H/E table to the ai_19/ai_9 groups and confirm which group index c78 actually draws (`anim_index` from `chrlvDistanceToChrRelated`, distance-bucketed over the 32-entry `ptr_pistol_firing_animation_groups[]`). Effort: ~1–2 h, no new tooling. +4. **ai_19 / ai_9.** Different anim groups (kneel/crouched tables; probes pinned at frames 30.00/23.00 — neither matches a group1 row). Fixing ai_22's root cause implies theirs only if the cause is shared (boundary settling would be; per-group data is not). Step 4's "re-verify first" must be an explicit **per-entry H/E table + boundary check + forced-repro sweep**, and each watchdog entry excised on its own evidence — not on ai_22's result. Depot ai_12 (clean) and the untested ai_13/14/15 (LOW-MED) stay out of scope for excision. +5. **Emulator A/B.** Cutting the trial series is correct (given identical data it can only re-test "does the derail happen on N64," which the spec's +3.5 s fire signature already answers — the monologue-completion path takes ~22 s, so a +3.5 s shot is itself evidence the derail landed). Preserve `GE_RSEED_LV` + DET logging armed as the fallback oracle for an upstream stream hunt (cost: one memory-viewer read + an env var); do not burn it until the boundary hypothesis fails. +6. **Constraint compliance.** All steps port-side; no Rule-2 exposure *as written*. Two cautions: (a) watchdog excision changes shipped v0.4.0 behavior mid-release — keep the watchdog on `release/v0.4.0` until step 3 passes and land the excision post-release (M-192 item 7 precedent); (b) pre-decide the fix bucket for the boundary root cause: if it turns out to be FMA contraction / float-rounding in compiled game code, a **build-flag** change (CMakeLists, e.g. `-ffp-contract=off`) is port-side, but any arithmetic edit in `src/game/model.c` requires Rule-2 sign-off. + +**Corrected plan sketch:** (1) finish the static H/E table for all three confirmed entries' groups + c78's group index — ~1–2 h; (2) env-gated probe: full-precision (`%f`/bit-exact) `animframe1` + fire-gate operands (`shoot_start`, `phi_f20`, `unk38[i]`, `unk3a[i]`, `unk44`) in a frozen forced-repro run, plus an A1/float pass over `modelTickAnim`/`modelConstrainOrWrapAnimFrame` — ~half a day; (3) decision gate: boundary divergence found → port-side fix + per-entry conformance (static table + existing ai_19/ai_9 forced sweeps; c78 headless materialization is NOT available today — d320repro cannot spawn the proximity-spawned c78, so a "headless forced-derail seed sweep" of the Trevelyan beat as written in plan step 3 is infeasible without a ~1-day harness extension; cost it or drop it) → excision post-release; boundary hypothesis dead → upstream hunt with `GE_RSEED_LV` fallback (user sign-off for the single bounded emulator artifact); (4) D318/D320/D321 rewrite only on pass, per-entry. + +**Status:** review recorded; plan amended per above. No code changed. Cross-ref: D318 (mechanism + 2026-09-22 correction + probe evidence), D320 (sweep + forced repros), D321 (trigger chain), D34 (animationtable ROM-copy data path), D32 (ModelAnimation u32 fields), D94 (firing-table pointer arithmetic), porting-notes §A1 (ruled out for this chain by construction). + +## D329 — The D318/D320 guard "anim pin" is a tick-granularity divergence, not a platform-invariant lock: it exists only at 1 sim tick per rendered frame (the port at 60 fps); at ≥2 ticks/frame (GE's normal N64 regime) it cannot persist (lockstep A/B, 2026-09-23) + +**Question (M-199 step 2 decision gate).** Is the pinned ACT_ATTACK state behind D318 (Facility ai_22), D320 ai_19 (Facility) and ai_9 (Control) a lock that would also happen on N64, or a platform divergence? M-199 expected the former. It is the latter. + +**Mechanism (source, model.c).** `modelTickAnim` accumulates `frame += playspeed * speed` once per tick inside its `numticks` loop, then calls `modelSetAnimFrame2WithChrStuff(model, animframe1, frame, …)` **once per rendered frame**. That function only writes `framea`/`frameb` inside its per-frame loop, which runs only when `floor(frame)` crosses an integer boundary; its tail re-emits the stored pair and, when `vb == va`, sets `animframe1 = va` and discards all sub-frame progress. During the aim hold the endframe clamp (`modelConstrainOrWrapAnimFrame`: `frame = ceil(endframe)`) leaves `framea == frameb == H`. When `actor_fire_or_aim_at_target_update` then raises the endframe (`chrlvAttackActionRelated`, endframe only, no `modelSetAnimation`), the model can move again **only if one call advances by ≥ 1.0 frame**. Guard attack speed is 0.5–0.8 (`chrlvGetGuard007SpeedRating(0.5, 0.8)`; the fire gate also re-sets 0.5 via `modelSetAnimSpeed`). So: +- `numticks` = `g_ClockTimer` = 1: advance 0.5–0.8 < 1, no crossing, stale pair re-emitted, pinned for ever. +- `numticks` ≥ 2: advance ≥ 1.0, crossing, loop runs, `frameb` becomes `framea+1`, the anim plays on. **A single ≥2-tick frame breaks any such pin.** + +`g_ClockTimer = speedgraphframes` = 60 Hz ticks elapsed since the last rendered frame (`lvlManageMpGame`, `waitForNextFrame`). The port renders at 60 fps → 1 (D193 telemetry's "healthy" 60 render / 60 sim). GE on N64 runs gameplay at roughly 20–30 fps → 2–3. For the pin to survive on N64 the console would have to hold a steady 60 fps on every frame from the update onward, with guards on screen. This matches the user's spec (no such freeze on N64) without any PRNG, variant-lottery or reachability argument. + +**Evidence (lockstep A/B, WSL Linux build).** Harness: `GE_D318B_CLK=N` in the TEMP probe `port/src/d318bprobe.c` rewrites `g_ClockTimer`/`g_GlobalTimer`/`g_GlobalTimerDelta` in the existing lv.c hook (before any chr tick), so every rendered frame is exactly N ticks regardless of host speed. The probe log's `clk` field confirms it: 100% `clk=N` in every run. Same forced repros as D320 (`GE_D320R`), same PRNG path (the first variant drawn matches across N). + +| Run | ticks/frame | Post-update state | Watchdog | +|---|---|---|---| +| Facility ai_19 (c5) | 1 | `fr=19` exact, `fa=fb=19`, `ef=-1`, `spd=0.5`, frozen for ever | confirmed t=1561, then re-pin rescue every ~600 ticks (6×) | +| Facility ai_19 (c5) | 2 | first post-update frame 19→20 (`fa=20 fb=21`), advances each frame | 6 detections, **0 confirms**; clean aim→fire→re-aim cycle | +| Control ai_9 (c3) | 1 | `fr=23` exact, `fa=fb=23`, `ef=-1`, `spd=0.5`, frozen | confirmed t=1021, rescue (2) t=1679 | +| Control ai_9 (c3) | 2 | 24→25→26… (`fa≠fb`) | 6 detections, **0 confirms** | + +Logs: `scratch/d318b_{fac,ctrl}_clk{1,2}.log`. Summariser: `scratch/d318b_summ.py`. + +**Corrections to earlier framing:** +1. **Not "clamp at endframe".** After the update the endframe is often *unbounded* (`ef=-1`: rifle variants with `recoil_start = end_frame = -1`). The pin is purely the stale `va == vb` pair from the pre-update clamp, which only a ≥1.0-frame step (or a `modelSetAnimation` re-init) clears. +2. **Weapon class does not decide it.** Both pinned guards carry an **automatic** weapon (`wr=3`). While pinned at frame == `shoot_start` the per-hand fire gate (`shoot_start <= frame < shoot_end`) is open, so the gun **fires every tick** (`unk44` = current tick). The guard "fires for ever and never finishes the animation"; it is not "never fires". The escape conditions all read false (`efle=0`: endframe unbounded; `trm=0`: `recoil_end < 0`; unk31 re-arm path unreachable). "Semi-auto with E>H = deterministic lock on any platform" (M-199 plan) is refuted as the explanation. At ≥2 ticks/frame no escape path is needed, because the model just plays on. +3. **The M-199 "sub-frame settling at the fire-gate boundary" suspect is also refuted** (as the probe header predicted). Frozen values are exact integers and the gate is open, not closed. +4. **Earlier WSL wall-clock repros are not calibrated evidence.** WSLg renders this game at ~5 fps, so wall-clock runs mix clk 1–6. Whether they pinned depended on host speed. D320's "PIN CONFIRMED" and ai_9's "~2/3 of seed variants" rates came from such runs. The first run of this session (42 detections, zero pins) is the same artifact in the other direction. `GE_DETERM=1` does **not** hold 1 tick/frame either (clock advances per VI-retrace request; measured clk 1–6), so use `GE_D318B_CLK` for any granularity-sensitive repro. +5. **D318's variant split still holds, but only at clk=1.** E == H variants escape via the endframe re-init; E > H variants pin. That is where the ~2/3 PC rate comes from. At clk ≥ 2 every variant escapes (e.g. ai_22 hip-slow H=19/E=24 at speed 0.68: 19 + 1.36 crosses, plays to 24, normal re-init fires). + +**Float / A1 pass (M-199 task 3).** Nothing in the pin path is precision-sensitive: `floorFloatToInt`/`ceilFloatToInt` are truncation-based on both targets; the stored pair and endframe comparisons are integer-valued floats; the build uses no `-march`/`-ffast-math` (x86-64 baseline SSE2 single precision, no FMA contraction), and the R4300 has no fused multiply-add either. `Model`, `act_attack` and the firing tables are accessed by named fields (A1 ruled out, M-199). The divergence is purely the number of ticks folded into one `modelTickAnim` call. + +**Scope (implication, not yet swept).** The trigger is generic: any chr whose non-looping anim is clamped (`framea == frameb`) at an endframe that is then raised without a `modelSetAnimation`, at speed < 1. D320's "combat loops are likely safe" inference and the Depot ai_10 one-off freeze (c0 pinned at 33.00, atk=0x1) should be re-read in this light. The three-entry watchdog covers only the known scripted cases. + +**Classification.** A genuine port behavioural divergence caused by frame pacing (the port sim runs finer tick granularity per render than the N64 ever did), acting on byte-matched, non-associative game code. Not a data or ABI issue, not in the game logic's inputs. The N64-side claim "GE runs 2–3 ticks/frame in these scenes" rests on GE's well-known frame rate, not an in-session measurement. It is not needed for the PC-side A/B, but it is what connects the result to the user's spec. + +**Fix options (decision owed — user; none applied):** +- (a) **Keep the table-driven watchdog** (ships in v0.4.0). Correct for the three known entries; blind to other instances. +- (b) **Generic port-side pin breaker**: detect a chr model in the stale state (`framea == frameb`, non-looping, raised/unbounded endframe above the frame, speed > 0) and nudge it the way a 2-tick frame would. Port-side, but it writes game state from a hook (watchdog class). +- (c) **Rule-2 edit in `modelSetAnimFrame2WithChrStuff`**: keep sub-frame progress when the per-frame loop does not run. This is the standard "high-frame-rate fix" pattern. It changes behaviour only where N64 would have taken ≥2 ticks. Needs sign-off. +- Workaround available now (expected, not verified): `Video.FpsCap=30` gives 2 ticks/frame, which by the above makes the pin impossible. + +**Fix applied (2026-09-23, RULE-2-SIGNOFF: user approved option (c)).** `src/game/model.c` `modelSetAnimFrame2WithChrStuff`, `#ifdef PORT`: when the per-frame loop did not run, progress was requested, and `vb == va`, re-derive `frameb = constrain(va±1)` against the CURRENT endframe (as `modelSetAnimFrame` does); applied only if that next frame is unclamped. Genuine holds, anim ends and looping wraps are untouched. Env: `GE_D329=0` disables (A/B), `GE_D329LOG=1` logs triggers (cached getenv). **Verified (WSL, lockstep):** clk=1 with watchdog off (`GE_D318W=0`): Facility ai_19 and Control ai_9 no longer pin (frame 19→19.5→20…, 0.5/tick = the N64 clk=2 trajectory; aim→fire→re-aim cycle ≈192 ticks vs ≈195 at clk=2/3); authored holds intact. Trigger census: clk=1 Facility, 9 triggers, all on the repro guard, one per attack update; **clk=2: 0 triggers** over ~5300 ticks (Facility) and ~3600 (Control), so the fix is inert in the N64 regime. **Windows-native validation (2026-09-23):** Facility forced repro, native exe, `GE_D318B_CLK=1`, watchdog off: 48 post-update fire episodes, **0 pinned**, 23 D329 triggers. Bunker 1 golden (640x480 pinned, direct launch): REGRESSION with the fix ON (worst dmean 113.6) and equally with it OFF (`GE_D329=0`, 126.4), so it predates this fix (stale golden; re-baseline already owed). **Interactive playtest (2026-09-23, user, native Windows, shipping config + GE_D329LOG):** Ourumov reacts with N64-matching timing (user-confirmed on screen). Log: 4 D329 triggers (four guards, same tick, stale-pin state, released at 0.5/tick), 3 watchdog detections that cleared on their own, 0 watchdog rescues, no crash (`scratch/d329_playtest.log`). + +**Status:** FIXED + playtest-verified (2026-09-23). Next: demote the D318 watchdog to detect-and-log post-release (M-192 item 7), then remove the TEMP probes (d318bprobe, d320repro). Watchdog stays shipped on `release/v0.4.0`; plan to demote it to detect-and-log after playtests. Cross-ref: D318, D320, D321, M-199, D155, D186/D193. diff --git a/docs/porting-notes.md b/docs/porting-notes.md index 7c29398f..8eadeca2 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -29,6 +29,7 @@ good that you are looking at one of these. - [D4. N64 "interrupts off" must be a real lock on PC](#d4-n64-interrupts-off-is-not-free-on-pc--it-must-be-a-real-lock) - [D5. Loop bounds that assume linker adjacency of two globals](#d5-loop-bounds-that-assume-linker-adjacency-of-two-file-scope-globals) - [D12. Collapsing an LOD binding to tile 0 breaks a DETAIL binding](#d12-collapse-an-lod-binding-to-tile-0-is-wrong-for-a-two-texture-detail-binding-d236) +- [D13. Per-render accumulation is not associative (1 vs 2–3 ticks/frame)](#d13-per-render-accumulation-is-not-associative-the-ports-1-tickframe-can-reach-states-the-n64s-23-ticksframe-never-did-d329) - [E. Process / method notes](#e-process--method-notes) ## A. Pointer-width struct growth (32→64): the dominant class @@ -1207,6 +1208,14 @@ When a user reports z-fighting, do NOT assume the software renderer has less dep **Probe technique that worked (vs. three variants that didn't):** detecting z-fighting from emitted triangles requires filtering out (a) tessellation gradients — adjacent tris of one surface have near-equal depths by construction — and (b) camera-motion coincidences — as the camera moves, unrelated world points pass through the same screen cell and their depths happen to sit close. What survives both: a coarse screen-cell grid keeping a short per-cell depth history, flagging only **non-monotonic** (a-b-a) oscillation within a small tolerance that **recurs many times at the same cell**. Monotonic drift = gradient; one-off close pairs = motion artifact; sustained recurrence = a real fight. `GE_ZF` in `port/fast3d/gfx_pc.cpp` implements this (`GE_ZFTOL` tolerance, `GE_ZFBOX` region filter). +## D13. Per-render accumulation is not associative: the port's 1 tick/frame can reach states the N64's 2–3 ticks/frame never did (D329) + +GE on N64 renders gameplay at ~20–30 fps, so `g_ClockTimer` (= `speedgraphframes`, 60 Hz ticks per rendered frame) is normally 2–3. The port at 60 fps gives 1. Code that folds `numticks` ticks into **one** downstream call is not equivalent at different granularities. `modelTickAnim` sums `playspeed*speed` over the ticks, then makes one `modelSetAnimFrame2WithChrStuff` call, which only moves `framea`/`frameb` when `floor(frame)` crosses an integer. At speed < 1 and 1 tick/frame a clamped (`framea == frameb`) non-looping anim never crosses, so it stays pinned even after its endframe is raised. At 2+ ticks it crosses on the next frame. Result: permanent guard freezes on PC (D318/D320) that the console could not sustain. + +- **Tell:** a state that holds forever at 60 fps, is frame-exact (integer frame, `fa == fb`), and has a per-tick step < 1.0 unit. +- **Repro correctly:** force the granularity with `GE_D318B_CLK=N` (TEMP probe, lockstep N ticks/frame) and A/B N=1 vs N=2. Wall-clock runs on a slow host (WSLg renders ~5 fps → mixed clk 1–6) and `GE_DETERM=1` (advances per VI-retrace request, also mixed clk) both give uncalibrated results. +- **Where else to look:** any `for/while (numticks)` accumulator followed by a single threshold/floor consumer; D193 (AI locomotion rate) and D243 (cutscene `numticks` bursts) touched the same axis from the other side. + ## E. Process / method notes - Investigation loop is: reproduce → env-gated capped probe → root-cause diff --git a/port/include/d318bprobe.h b/port/include/d318bprobe.h new file mode 100644 index 00000000..d2f1c2a7 --- /dev/null +++ b/port/include/d318bprobe.h @@ -0,0 +1,13 @@ +/* d318bprobe.h -- D318/D320 boundary probe (M-199 step 2). + * + * Env-gated per-tick ground-truth logger for the aim-hold / derail / + * anim-pin state machine of chrlvTickAttack + chrlvTickAttackCommon. + * Read-only; inert without GE_D318B. TEMP: remove when D318/D320 + * conclude (same class as d320repro.c). + */ +#ifndef D318BPROBE_H +#define D318BPROBE_H + +void d318bProbeTick(void); + +#endif /* D318BPROBE_H */ diff --git a/port/src/d318bprobe.c b/port/src/d318bprobe.c new file mode 100644 index 00000000..618ebfbc --- /dev/null +++ b/port/src/d318bprobe.c @@ -0,0 +1,377 @@ +/* d318bprobe.c -- D318/D320 boundary probe (M-199 step 2). + * + * Purpose: capture per-tick ground truth of the aim-hold / derail / + * anim-pin state machine so the D318/D320/D321 writeups can be rewritten + * on verified evidence and each watchdog entry judged for excision on its + * own data. Static analysis (this session) established: + * + * - The hold-pose pin is topologically stable, not a float knife-edge: + * once modelConstrainOrWrapAnimFrame clamps both framea and frameb to + * ceil(endframe), the tail of modelSetAnimFrame2WithChrStuff re-emits + * the stale va==vb pair every tick (the per-frame loop only runs when + * floor(frame) crosses an integer boundary, which a pinned animframe1 + * never does). All comparisons in the frozen state are exact integers. + * - Post-derail, escape requires a modelSetAnimation re-init from one of + * exactly three paths: chrlvTickAttack's unk36==0 branch (auto + * weapons, endframe<=frame), chrlvTickAttackCommon re-init block + * branch 1 (unk37!=0, i.e. auto weapon -> kneel-stop) or branch 3 + * (unk31 armed by the fire gate while frame sits in the recoil/shoot + * window). None is reachable in the observed pins (auto weapons, + * endframe unbounded) -- BUT the pin itself only persists at 1 sim + * tick per rendered frame: a >=2-tick frame advances >=1.0 and + * crosses the integer boundary, so no escape path is needed (D329, + * lockstep A/B via GE_D318B_CLK below). Not a platform-invariant lock. + * + * This probe logs, for env-selected chrs, every operand those decisions + * read: the drawn variant (identified by pointer against all six firing- + * group sets), its frame fields, the unk30..unk44 gate bytes, the weapon + * auto-firing rates, full-precision model frame/endframe/speed state, and + * each branch condition recomputed from the same fields. Read-only; + * inert without GE_D318B. + * + * Usage: GE_D318B="5,9" (comma-separated chrnums, max 4) + * GE_D318B_ALL=1 (log every ACT_ATTACK tick, not just changes) + * GE_D318B_CLK=N (lockstep: exactly N sim ticks per rendered frame) + * Combined with the D320R forced repro, e.g.: + * GE_D320R="900:jump=5,list=0x0414,off=341" GE_D318B="5" + * + * TEMP: investigation probe, remove when D318/D320 conclude. + */ +#include +#include +#include + +#include "PR/os.h" +#include "bondtypes.h" +#include "bondaicommands.h" +#include "chr.h" +#include "chraction.h" +#include "gun.h" +#include "lv.h" +#include "model.h" + +#ifdef PORT + +#define D318B_MAX_CHRS 4 +#define D318B_LINE_CAP 6000 + +typedef struct +{ + s16 chr; + /* last-logged state (change detection) */ + int act; + u32 atk; + u32 ent; + char animid[8]; + u32 framebits; + s32 stable; /* consecutive ticks with unchanged frame bits */ + s32 hb; /* ticks since last heartbeat log */ +} D318BState; + +static int s_on = -1; +static int s_all = 0; /* GE_D318B_ALL=1: log every attack tick */ +static int s_n = 0; +static D318BState s_s[D318B_MAX_CHRS]; +static long s_lines = 0; +static int s_capped = 0; + +#define d318bLog osSyncPrintf + +static void d318bInit(void) +{ + const char *e; + char buf[128]; + char *p, *save; + + if (s_on != -1) + { + return; + } + s_on = 0; + e = getenv("GE_D318B"); + if (!e || !e[0]) + { + return; + } + s_on = 1; + s_all = (getenv("GE_D318B_ALL") != NULL); + strncpy(buf, e, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + p = buf; + while (p && *p && s_n < D318B_MAX_CHRS) + { + save = strchr(p, ','); + if (save) + { + *save = 0; + } + memset(&s_s[s_n], 0, sizeof(D318BState)); + s_s[s_n].chr = (s16)strtol(p, NULL, 0); + s_s[s_n].act = -1; + s_n++; + p = save ? save + 1 : NULL; + } + d318bLog("D318B: probe active for %d chr(s): %s\n", s_n, buf); +} + +static ChrRecord *d318bFindChr(s16 chrnum) +{ + s32 i; + + for (i = 0; i < g_NumChrSlots; i++) + { + if (g_ChrSlots[i].chrnum == chrnum) + { + return &g_ChrSlots[i]; + } + } + for (i = 0; i < g_ActiveChrsCount; i++) + { + if (g_ActiveChrs[i].chrnum == chrnum) + { + return &g_ActiveChrs[i]; + } + } + return NULL; +} + +/* Identify act_attack.animfloats by pointer against all six firing-group + * sets. Returns 1 and fills id (e.g. "P3.2" = pistol set, bearing group + * index 3, variant row 2) on success. */ +static int d318bIdentifyAnim(struct weapon_firing_animation_table *row, char *id, int idsz) +{ + static const struct + { + const char tag; + struct anim_group_info *const *set; + } sets[] = { + { 'P', ptr_pistol_firing_animation_groups }, + { 'R', ptr_rifle_firing_animation_groups }, + { 'D', ptr_doubles_firing_animation_groups }, + { 'C', ptr_crouched_rifle_firing_animation_groups }, + { 'K', ptr_crouched_pistol_firing_animation_groups }, + { 'L', ptr_crouched_doubles_firing_animation_groups }, + }; + s32 si, gi, ri; + + for (si = 0; si < 6; si++) + { + for (gi = 0; gi < 32; gi++) + { + struct anim_group_info *g = sets[si].set[gi]; + + if (g == NULL) + { + continue; + } + for (ri = 0; ri < g->len; ri++) + { + if (&(*g->table)[ri] == row) + { + snprintf(id, idsz, "%c%d.%d", sets[si].tag, gi, ri); + return 1; + } + } + } + } + snprintf(id, idsz, "-"); + return 0; +} + +static s32 d318bAutoRate(ChrRecord *self, GUNHAND hand) +{ + PropRecord *prop; + s32 item; + + prop = chrGetEquippedWeaponPropWithCheck(self, hand); + if (prop == NULL || prop->chr == NULL) + { + return -99; + } + item = (s32)prop->chr->act_attack.attack_item; + if (item < 0) + { + return -98; + } + return (s32)bondwalkItemGetAutomaticFiringRate((ITEM_IDS)item); +} + +/* GE_D318B_CLK=N (test-only): force exactly N sim ticks per rendered frame, + * independent of host render speed -- a lockstep model of a console frame + * rate of 60/N fps. Rewrites the three values lvlManageMpGame just derived + * from speedgraphframes (the hook runs after them, before any chr tick). + * Pause/lock frames (g_ClockTimer == 0) are left alone. */ +static void d318bClockLock(void) +{ + static s32 s_clk = -1; + + if (s_clk < 0) + { + const char *e = getenv("GE_D318B_CLK"); + + s_clk = (e && strtol(e, NULL, 10) > 0) ? (s32)strtol(e, NULL, 10) : 0; + if (s_clk) + { + d318bLog("D318B: clock lock active, %d tick(s)/frame\n", s_clk); + } + } + if (s_clk && g_ClockTimer > 0 && g_ClockTimer != s_clk) + { + g_GlobalTimer += s_clk - g_ClockTimer; + g_ClockTimer = s_clk; + g_GlobalTimerDelta = (f32)s_clk; + } +} + +void d318bProbeTick(void) +{ + s32 i; + + d318bClockLock(); + d318bInit(); + if (!s_on || s_capped) + { + return; + } + + for (i = 0; i < s_n; i++) + { + D318BState *st = &s_s[i]; + ChrRecord *c; + Model *m; + struct act_attack *a; + struct weapon_firing_animation_table *f; + f32 fr, ef; + u32 framebits; + int chg, atpin, dolog, gate = 0, k; + char animid[8]; + + c = d318bFindChr(st->chr); + if (c == NULL || c->model == NULL) + { + continue; + } + m = c->model; + a = &c->act_attack; + f = a->animfloats; + + if (c->actiontype != ACT_ATTACK && c->actiontype != ACT_ATTACKROLL) + { + /* reset change-detection so the next attack logs its entry */ + st->act = -1; + continue; + } + + fr = m->animframe1; + ef = m->endframe; + memcpy(&framebits, &fr, sizeof(framebits)); + + d318bIdentifyAnim(f, animid, sizeof(animid)); + + chg = (st->act != (int)c->actiontype) || (st->atk != a->attacktype) + || (st->ent != a->entityid) || (strcmp(st->animid, animid) != 0); + + if (framebits == st->framebits) + { + st->stable++; + } + else + { + st->stable = 0; + } + st->hb++; + + atpin = ((ef >= 0.0f) && (ef <= fr + 1e-6f)); + dolog = s_all || chg || atpin || (st->stable >= 20) || (st->hb >= 60); + if (!dolog) + { + goto update; + } + if (++s_lines >= D318B_LINE_CAP) + { + s_capped = 1; + d318bLog("D318B: line cap (%ld) reached, probe silent\n", + (long) D318B_LINE_CAP); + return; + } + + /* per-hand fire-loop outcome in chrlvTickAttackCommon (source + * order): bit0/1 = unk3a[k]==0 arm, frame in shoot window + * [ss,se) -> ToggleHidden(1) (gun fires) + unk44=now; + * bit4/5 = unk3a[k]!=0 arm, unk31 re-arm conditions met + * (unk31==0, unk32 turn, frame in recoil window or >=ss). */ + for (k = 0; k < 2; k++) + { + if (a->unk38[k] == 0) + { + continue; + } + if (a->unk3a[k] == 0) + { + if (f->shoot_start_frame <= fr && fr < f->shoot_end_frame) + { + gate |= (1 << k); + } + } + else if (a->unk31 == 0 + && (k == a->unk32 || a->unk3a[(s32)a->unk32] == 0) + && ((f->recoil_start_frame >= 0.0f && f->recoil_start_frame <= fr + && fr <= f->recoil_end_frame) + || (f->recoil_start_frame < 0.0f && f->shoot_start_frame <= fr))) + { + gate |= (0x10 << k); + } + } + + d318bLog( + "D318B: t=%d clk=%d c%d off=%d act=%d atk=0x%x ent=%u toM=%d | v=%s " + "st=%.9g end=%.9g ss=%.9g se=%.9g rs=%.9g re=%.9g as=%.9g ae=%.9g | " + "31=%d 32=%d 33=%d 34=%d 36=%d 37=%d 38=%d/%d 3a=%d/%d 40=%u 44=%d atkT=%d | " + "fr=%.9g ef=%.9g fa=%d fb=%d spd=%.9g ps=%.9g nn=%.9g oo=%.9g ts=%.9g es=%.9g " + "b0=%.9g b4=%.9g ac=%.9g loop=%d a2=%d a2len=%u wr=%d wl=%d | " + "aim=%d dnt=%d efle=%d trm=%d b1=%d b2=%d b3=%d gate=%d cf1=%d\n", + (int)g_GlobalTimer, (int)g_ClockTimer, st->chr, (int)c->aioffset, (int)c->actiontype, + (unsigned)a->attacktype, (unsigned)a->entityid, (int)a->type_of_motion, + animid, + (double)f->start_frame, (double)f->end_frame, + (double)f->shoot_start_frame, (double)f->shoot_end_frame, + (double)f->recoil_start_frame, (double)f->recoil_end_frame, + (double)f->aim_start_frame, (double)f->aim_end_frame, + (int)a->unk31, (int)a->unk32, (int)a->unk33, (int)a->unk34, + (int)a->unk36, (int)a->unk37, (int)a->unk38[0], (int)a->unk38[1], + (int)a->unk3a[0], (int)a->unk3a[1], (unsigned)a->unk40, + (int)a->unk44, (int)a->attack_time, + (double)fr, (double)ef, (int)m->framea, (int)m->frameb, + (double)m->speed, (double)m->playspeed, (double)m->newspeed, + (double)m->oldspeed, (double)m->timespeed, (double)m->elapsespeed, + (double)m->unkb0, (double)m->unkb4, (double)m->unkac, + (int)m->animlooping, (int)(m->anim2 != NULL), + m->anim2 ? (unsigned)m->anim2->unk04 : 0u, + d318bAutoRate(c, GUNRIGHT), d318bAutoRate(c, GUNLEFT), + (int)((a->attacktype & TARGET_AIM_ONLY) != 0), + (int)((a->attacktype & TARGET_DONTTURN) != 0), + (int)((ef >= 0.0f) && (ef <= fr)), + (int)(a->unk36 == 0 && f->recoil_end_frame > 0.0f && fr <= f->recoil_end_frame + && (ef >= 0.0f) && (ef <= fr)), + (int)(a->unk37 != 0 || a->unk34 < a->unk33), + (int)(a->unk33 == a->unk34), + (int)(a->unk31 != 0), + gate, + (int)(a->attack_time < a->unk44 - 30 && m->anim2 == NULL + && f->shoot_start_frame + 10.0f < fr && fr < f->shoot_end_frame)); + + update: + st->act = (int)c->actiontype; + st->atk = a->attacktype; + st->ent = a->entityid; + strncpy(st->animid, animid, sizeof(st->animid) - 1); + st->animid[sizeof(st->animid) - 1] = 0; + st->framebits = framebits; + if (dolog) + { + st->hb = 0; + } + } +} + +#endif /* PORT */ diff --git a/port/src/d320repro.c b/port/src/d320repro.c index 7723c0c3..f2320c7c 100644 --- a/port/src/d320repro.c +++ b/port/src/d320repro.c @@ -193,6 +193,20 @@ void d320ReproTick(void) { const char *q = colon + 6; + /* documented form "jump=,list=..": a leading bare + * number (no '=' before the first ',') is the chr */ + { + char *eq0 = strchr(q, '='); + char *cm0 = strchr(q, ','); + + if (cm0 && (!eq0 || cm0 < eq0)) + { + *cm0 = 0; + a->chr = (s16)strtol(q, NULL, 0); + q = cm0 + 1; + } + } + while (q && *q) { char *eq = strchr(q, '='); diff --git a/src/game/lv.c b/src/game/lv.c index 46c4cb34..cdf4b0f1 100644 --- a/src/game/lv.c +++ b/src/game/lv.c @@ -1054,9 +1054,11 @@ void lvlManageMpGame(void) extern void d318WatchdogTick(void); extern void d318TimelineTick(void); extern void d320ReproTick(void); + extern void d318bProbeTick(void); d318WatchdogTick(); /* D318 permanent port-side deadlock recovery (findings D318) */ d318TimelineTick(); /* D318T env-gated diagnostic timeline (GE_D318T=1) */ d320ReproTick(); /* D320R env-gated forced-repro harness (GE_D320R, TEMP) */ + d318bProbeTick(); /* D318B env-gated attack-state boundary probe (GE_D318B, TEMP) */ } #endif if ((g_CurrentStageToLoad != LEVELID_TITLE) && (D_80048394 == 0) && (g_ClockTimer > 0)) diff --git a/src/game/model.c b/src/game/model.c index 94765919..f07ec0df 100644 --- a/src/game/model.c +++ b/src/game/model.c @@ -3485,6 +3485,64 @@ void modelSetAnimFrame2WithChrStuff(Model *model, f32 framea, f32 frameb, f32 fr va = modelptr->framea; vb = modelptr->frameb; +#ifdef PORT + /* D329 (RULE-2-SIGNOFF 2026-09-23, user approved): tick- + * granularity fix. The per-frame loop above only writes + * framea/frameb when floor(frame) crosses an integer. N64 GE + * renders at ~20-30 fps, so modelTickAnim folds 2-3 ticks into + * this call and any attack-speed advance (0.5-0.8/tick) crosses + * at once; the port at 60 fps folds 1 tick, which does not. A + * pair clamped equal by an earlier endframe (aim hold: both at + * ceil(H)) is then re-emitted stale after the endframe is raised + * (actor_fire_or_aim_at_target_update -> chrlvAttackActionRelated + * changes only the endframe) and the sub-frame progress below is + * discarded every call -> permanent pin (D318/D320). Only when + * the loop did not run, progress was requested, and the CURRENT + * endframe no longer blocks the next frame, re-derive frameb the + * way modelSetAnimFrame does, so the model advances at the rate + * the N64 does. Genuine holds (endframe still at the frame) and + * every >=2-tick call are untouched. */ + /* GE_D329=0 disables (A/B only); GE_D329LOG=1 logs each trigger + * (capped). Both cached -- this is a per-model per-frame path. */ + static s32 s_d329 = -1; + static s32 s_d329log = 0; + static s32 s_d329n = 0; + + if (s_d329 < 0) + { + const char *e = getenv("GE_D329"); + + s_d329 = !(e && e[0] == '0'); + s_d329log = (getenv("GE_D329LOG") != NULL); + } + + if (s_d329 && (vb == va) + && (forward ? ((endframe < floorFloatToInt(framea) + 1) && (framea < frameb)) + : ((ceilFloatToInt(framea) - 1 < endframe) && (frameb < framea)))) + { + s32 next = modelConstrainOrWrapAnimFrame(forward ? va + 1 : va - 1, modelptr->anim, modelptr->endframe); + + if (next == (forward ? va + 1 : va - 1)) + { + if (s_d329log && s_d329n < 2000) + { + extern s32 g_GlobalTimer; + extern s32 g_ClockTimer; + + s_d329n++; + osSyncPrintf("D329: t=%d clk=%d model=%p va=%d next=%d framea=%.4f frameb=%.4f " + "endframe=%.4f speed=%.4f playspeed=%.4f\n", + (int) g_GlobalTimer, (int) g_ClockTimer, (void *) modelptr, (int) va, + (int) next, (double) framea, (double) frameb, + (double) modelptr->endframe, (double) modelptr->speed, + (double) modelptr->playspeed); + } + modelptr->frameb = next; + vb = next; + } + } +#endif + if (vb == va) { modelptr->unk2c = 0.0f;