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=<chr>,... parser (documented form never parsed).
- findings D329 + D318/D320/D321 corrections, porting-notes D13.
This commit is contained in:
James Dansereau committed 2026-09-23 09:53:10 -04:00
1 parent 35483f9892
commit 7af146a1d0
8 files changed
+568 -6

No files matched your search

+4 -3
View File
@@ -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 <stdlib.h> 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…"
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
195 D315
196 D316
197 D317
198 D318
199 D319
200 D320
201 D321
202 D322
203 D323
204 D324
206 D326
207 D327
208 D328
209 D329
+91 -3
View File
@@ -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 <stdlib.h> 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.
+9
View File
@@ -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
+13
View File
@@ -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 */
+377
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 */
+14
View File
@@ -193,6 +193,20 @@ void d320ReproTick(void)
{
const char *q = colon + 6;
/* documented form "jump=<chr>,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, '=');
+2
View File
@@ -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))
+58
View File
@@ -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;