diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 78dc6b2e..f50f0150 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -186,5 +186,6 @@ D305,FIXED,"Steam Deck SIGSEGV on the v0.3.0 pre-release build (Cavern, early in D306,OPEN,"Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).","OPEN — reported only, not investigated." D307,OPEN,"In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g.","OPEN — reported only, not investigated." D308,OPEN,"Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).","OPEN — reported/confirmed by the user, not investigated." -D309,FIXED,Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen —,"FIXED via D310 (RULE-2-SIGNOFF, 2026-09-18) —" -D310,FIXED,"RULE-2-SIGNOFF: chraiitemsize() mis-sizes 1-byte AI_PRINT records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT —","FIXED (signed off + implemented 2026-09-18, #ifdef PORT, N64 arm unchanged) —" +D309,FIXED,Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen —,FIXED via D310 — user-playtest-confirmed 2026-09-18 (Caverns→intro clean; the spin record itself observed resuming correctly in the post-fix probe log). +D310,FIXED,"RULE-2-SIGNOFF: chraiitemsize() mis-sizes 1-byte AI_PRINT records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT —",FIXED — user-playtest-confirmed 2026-09-18 (v2: +D311,PARTIAL,Complete freeze in modelSetAnimFrame2WithChrStuff root-motion loop —,"GUARDED (#ifdef PORT, D156-guard extension in the same file) —" diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 2827ae38..d9e09794 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -594,8 +594,9 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D306 | **Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).** — full `## D306` entry at file tail | OPEN — reported only, not investigated. Explicitly not for v0.3.0. | | D307 | **In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g. Bunker (user QA, 2026-09-18) — NOT the player's view camera.** — full `## D307` entry at file tail | OPEN — reported only, not investigated. Explicitly not for v0.3.0. | | D308 | **Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).** — full `## D308` entry at file tail | OPEN — reported/confirmed by the user, not investigated. Explicitly not for v0.3.0; D306 may be one instance of this. | -| D309 | **Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen — boss thread spins forever in `ai()` on `m_RunToBondPersistent` via a phantom `GotoNext(lblZero)` created by `chraiitemsize`'s `AI_PRINT` NUL-scan mis-sizing a stringless PRINT record (user report + live GDB diagnosis, 2026-09-18).** — full `## D309` entry at file tail | FIXED via D310 (RULE-2-SIGNOFF, 2026-09-18) — the mis-sizing landmine is neutralized, so the phantom-`GotoNext(0)` spin path no longer exists; pending user live re-verification in Caverns. The teardown-ordering open question (why PC presents the unreachable-Bond state) remains documented but is now harmless. | -| D310 | **RULE-2-SIGNOFF: `chraiitemsize()` mis-sizes 1-byte `AI_PRINT` records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT — fixes D309's freeze mechanism and the Caverns standing-still guards (`Stop`+`PRINT("wait")` mis-lands on EndList, re-executed every tick; user report 2026-09-18).** — full `## D310` entry at file tail | FIXED (signed off + implemented 2026-09-18, `#ifdef PORT`, N64 arm unchanged) — pending user live re-verification of both symptoms in Caverns. PD precedent: PRINT removed, slot 0xAD reassigned. | +| D309 | **Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen — boss thread spins forever in `ai()` on `m_RunToBondPersistent` via a phantom `GotoNext(lblZero)` created by `chraiitemsize`'s `AI_PRINT` NUL-scan mis-sizing a stringless PRINT record (user report + live GDB diagnosis, 2026-09-18).** — full `## D309` entry at file tail | **FIXED via D310 — user-playtest-confirmed 2026-09-18** (Caverns→intro clean; the spin record itself observed resuming correctly in the post-fix probe log). The teardown-ordering open question (why PC presents the unreachable-Bond state) remains documented but is now harmless. | +| D310 | **RULE-2-SIGNOFF: `chraiitemsize()` mis-sizes 1-byte `AI_PRINT` records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT — fixes D309's freeze mechanism and the Caverns standing-still guards (`Stop`+`PRINT("wait")` mis-lands on EndList, re-executed every tick; user report 2026-09-18).** — full `## D310` entry at file tail | **FIXED — user-playtest-confirmed 2026-09-18** (v2: global-list-only sizing via `d310ItemSize`; guards resume, Trevelyan pathing intact, Caverns→intro clean; probe log verified both list classes). PD precedent: PRINT removed, slot 0xAD reassigned. | +| D311 | **Complete freeze in `modelSetAnimFrame2WithChrStuff` root-motion loop — D156 guard hole: poisoned `framea` (`model->animframe1`) passes the frameb-only clamp and saturates to a ~3.1e9-iteration spin (Caverns, live GDB diagnosis, 2026-09-18).** — full `## D311` entry at file tail | GUARDED (`#ifdef PORT`, D156-guard extension in the same file) — freeze class neutralized; upstream speed/frame poison source OPEN (level reload clears displaced model state). No AI involvement — distinct from D309/D310. | 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 @@ -11900,7 +11901,7 @@ do { **Fix bucket:** NOT an ABI/layout exception (no pointer-width involvement at all). The decomp's code+data are faithful; the divergence is in *when* the state occurs, i.e. PC teardown/scheduling. A port-side fix (ensure guards stop ticking AI before/while Bond+level state goes invalid on level exit) is the natural home and needs its own investigation of the teardown ordering (`gesched.c` / boss-thread vs level-unload race). Any `src/game` edit would require rule-2 sign-off. -**Status:** OPEN — root cause identified with full evidence; no fix made. **Release impact:** user decision owed — fix before the v0.3.0 tag, or ship with a known-issue note (complete freeze on Caverns→intro transition). +**Status:** **FIXED via D310 — user-playtest-confirmed 2026-09-18** (Caverns→intro transition clean; the spin record `PRINT("no go!")`@+4 itself was observed in the post-fix probe log stepping `size=1 global=1` and resuming at `Label(27)` as intended). The teardown-ordering open question (why PC presents the unreachable-Bond state) remains documented but is now harmless — reaching that state no longer spins. ## D173/D292 — user confirmation, 2026-09-18: mostly fixed as a side effect of D243's root cause. @@ -11914,7 +11915,9 @@ This confirms the M-160/M-164/M-165 hypothesis (`## D173`'s own consolidated-nex ## D310 — RULE-2-SIGNOFF: `chraiitemsize()` mis-sizes 1-byte `AI_PRINT` records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT — fixes D309's freeze mechanism and the Caverns standing-still guards (signed off + implemented, 2026-09-18). -**Status:** **RULE-2-SIGNOFF** / FIXED (pending user live re-verification of both symptoms in Caverns). One-line `src/game` behavior change under `#ifdef PORT`; N64 build untouched. +**Status:** **RULE-2-SIGNOFF / FIXED — user-playtest-confirmed (2026-09-18).** All three checks passed live in Caverns: standing-still guards resume, Trevelyan follows his path, Caverns→intro no longer freezes. `src/game` behavior change under `#ifdef PORT`; N64 build untouched. + +**Post-fix probe log (clean session, 989 lines):** global-list PRINTs (`m_RunToBondPersistent`, chr=4/24/72) all step `size=1 global=1` — including the D309 spin record itself (`off=4 "no go!" landcmd=0x02`=Label, resumed correctly) and the standing-still record (`off=167 "wait" landcmd=0x95`=UnsetMyFlags2), each hit exactly once (no per-tick re-execution). Local-list PRINTs (Trevelyan chr=88, fake-GUARD driver chr=254) all keep NUL-scan sizing (`size=4/7 global=0`) with valid landing commands. Zero `LABEL-NOT-FOUND`, zero D156 fires, zero watchdog freezes. **The defect.** The `PRINT()` macro (`src/aicommands2.h:4913`) emits exactly **one command byte** into the built AI list — its string argument is discarded at list-build time. But `chraiitemsize()`'s `case AI_PRINT:` (`src/game/chrai.c:682`, original) sized the record by scanning forward to the next NUL byte, so stepping on a PRINT always skips ~20 bytes into whatever records follow and resumes at an arbitrary byte pattern. Only 4 PRINTs exist in all of `src/game/chraidata.c`; two are commented out (`m_DeadAI:74`, `m_StandardGuard:136`) and **both active ones are in `m_RunToBondPersistent`** — so exactly one AI list in the game is affected. @@ -11930,8 +11933,29 @@ This confirms the M-160/M-164/M-165 hypothesis (`## D173`'s own consolidated-nex **Grant (2026-09-18, in-thread):** "I want to proceed with your implementation plan here to fix this bug." (User's reply to the full §7 write-up; asked only whether a fresh session was needed — answered no, granted in this session.) -**Resulting diff:** `src/game/chrai.c` `chraiitemsize()` — `case AI_PRINT:` now `return 1;` under `#ifdef PORT`, original NUL-scan moved to the `#else` arm unchanged (commit following this entry). Verification: full-list decode of `m_RunToBondPersistent` with the fix (`scratch/decode_printfix.c`) walks +0→EndList@223 with every record valid, and the post-`"wait"` sequence matches `chraidata.c:666+` line-for-line (`UnsetMyFlags2(4)`, `IFNewRandomGreaterThan(160)`, `SetMyFlags2(4)`, `MyTimerStart`, sit-rep block, `GotoFirst(lblRun)`); post-`"no go!"` resumes at `Label(27)+Yield` as in source. +**Resulting diff v1 (REGRESSED, superseded):** `chraiitemsize()` `case AI_PRINT:` → `return 1;` under `#ifdef PORT`. Fixed the guards, but live testing then broke **Caverns' Trevelyan (chr=88)**: ran in circles with glitched animations. Memory dump of his list (`0x701cca30`, a level-LOCAL list from `g_CurrentSetup.ailists`) shows local lists **embed a NUL-terminated string after the PRINT byte** — live bytes `AD 44 0A 00` (PRINT + "D\n"), `AD 57 0A 00`, `AD 34 0A 00` at offsets 50/288/302 — so the NUL-scan is CORRECT for local lists; the uniform 1-byte size landed mid-string (garbage commands + a `LABEL-NOT-FOUND` spin, off=403 label=0, probe-capped). Only the macro-built GLOBAL lists (`g_GlobalAILists`, chraidata.c) discard their PRINT strings. -**Scope note (§7 drift check):** affects exactly one AI list (the only one with active PRINTs). Both observed symptoms are inside that scope; no other list's behavior changes. +**Resulting diff v2 (current):** `chraiitemsize()` left byte-identical to the decomp. New PORT-only `d310ItemSize(AIList, offset, isGlobalAIList)` returns 1 for a PRINT only when `chraiGetAIListID()` confirms the list is global; otherwise falls through to the original NUL-scan. Applied at both record-walk sites: `ai()`'s AI_PRINT step and `chraiGoToLabel`'s label scan (a backward `GotoFirst` crossing a global PRINT would mis-scan even with v1's sizer, since the scan also steps records). Lists found in neither table (teardown) default to the original NUL-scan. Verification: full-list decode of `m_RunToBondPersistent` with 1-byte global PRINTs (`scratch/decode_printfix.c`) walks +0→EndList@223 with every record valid, and the post-`"wait"` sequence matches `chraidata.c:666+` line-for-line (`UnsetMyFlags2(4)`, `IFNewRandomGreaterThan(160)`, `SetMyFlags2(4)`, `MyTimerStart`, sit-rep block, `GotoFirst(lblRun)`); post-`"no go!"` resumes at `Label(27)+Yield` as in source. + +**Scope note (§7 drift check):** v1's sign-off scope ("exactly one AI list affected") was wrong in one respect — PRINT records also exist in level-local lists, but with a different shape (embedded string), where the original NUL-scan is already correct. v2's *behavioral* change is still confined to global-list PRINTs: `m_RunToBondPersistent`'s two records. Local-list behavior is byte-identical to the original decomp — Trevelyan reverts to his pre-fix (working) pathing. Drift disclosed to user in-thread when the regression was reported; same sign-off intent (fix global PRINT sizing, no other behavior change). **Cross-refs:** D309 (this fix neutralizes its spin mechanism — the teardown-ordering open question in D309's "why PC and not N64" remains, but reaching that state is now harmless); GE_D309 probes (`chrai.c` `ai()`/`chraiGoToLabel`, `chraction.c` `chrGoToBond`) remain as diagnostic tools. + +## D311 — Complete freeze in `modelSetAnimFrame2WithChrStuff` root-motion loop: D156 guard hole — poisoned `framea` (`model->animframe1`) passes through the frameb-only clamp and saturates to a ~3.1e9-iteration spin (Caverns, live GDB diagnosis, 2026-09-18). + +**Reported:** user playtest of the D310 v2 build in Caverns — "it's frozen" shortly after launch (watchdog: no frame rendered for 8014 ms at frames=8424; ~2.3 min into the session). + +**Live evidence (GDB attach, PID 46948, mainThread):** +- Stack: `sincosf` ← `modelSetAnimFrame2WithChrStuff` (`model.c:3349`, anim-merge branch) ← `modelTickAnim` ← `bheadUpdate` ← `bondviewMoveAnimationTick` ← `MoveBond` — the **player's own** model anim tick. Zero `D309:` probe lines in the log → no AI-list involvement; this is a different bug from D309/D310. +- Locals at the spin site: `curframe = -966612173`, `endframe = 2147483647` (INT_MAX) → ~3.1e9 loop iterations; `pos34 = (-6.7e30, ?, 2.9e32)` — root-motion accumulator already blown up; `model->animframe1 = -1.6708e+31`; `model->endframe = 17` (the anim has 17 frames); `speed = 0.22` (normal), but `newspeed = -3.2e7`, `oldspeed = -1.75e18`, `timespeed = 0`, `unkb0 = 0`; `anim2 != NULL` with healthy frame2 (18.8→20.9). +- The existing D156 guard **had fired** earlier in the same session (`[D156] bad anim frame guarded: frame=-1.6708e+31 animframe1=-1.6708e+31 ... oldspeed=-1.75136e+18 timespeed=0 unkb0=0`) — i.e. `animframe1` was already poisoned *before* the guarded tick, and the guard's fallback (`frame = model->animframe1`) plus the callee's frameb-only clamp (`if bad frameb: frameb = framea`) passed the poison straight through. + +**Mechanism (the guard hole):** D156's guard in `modelSetAnimFrame2WithChrStuff` validates only `frameb`. With `framea = frameb = -1.67e31`, `floorFloatToInt` saturates the two near-equal huge floats to far-apart ints (`curframe = -966612173`, `endframe = INT_MAX`), so the `while(1)` root-motion loop walks ~3.1e9 frames — each doing a joint transform + bitstream decode + `sincosf` — i.e. a permanent hang. The N64 never presents this state (fixed timestep, bounded speeds); the poison is PC-side upstream corruption of the model speed/frame fields (see open question). + +**Fix (landed, `#ifdef PORT`, same file/pattern as the accepted D156 guards — port-side defensive clamp against non-finite state, no game-logic change):** +1. `modelSetAnimFrame2WithChrStuff`: validate **both** `framea` and `frameb` against ±1e6; a bad one snaps to the other if that is valid, else 0. Both-bad → both 0 → loop does ~0 iterations; the in-loop `animframe1` rewrite (`model.c:3447`) then heals stored state on subsequent ticks. +2. `modelTickAnim`: heal `model->animframe1`/`animframe2` (snap to 0) **before** the bad-frame fallback reads them, so the poison source itself is cleared instead of being re-derived every tick. + +**Open question (deeper root cause, NOT fixed):** what wrote `-1.75e18` into `oldspeed` / poisoned `animframe1` in the first place? `oldspeed = model->speed` at transition start (`model.c:2975`) means `speed` was already garbage upstream; `timespeed = 0` and `unkb0 = 0` at the guarded tick are suspicious division denominators (D156's original note flagged `/ unkb0` and `/ timespeed` as guarded `> 0` but not against tiny values). Also, the model's persistent root-motion header (`header->unk34/unk24`) is poisoned to ~1e30–1e32 in this instance, so **Bond's rendered model may be displaced even after the freeze is prevented** — a level reload clears it. Needs its own investigation (dump speed-field write sites during the triggering transition) before any further fix. + +**Status:** GUARDED (freeze class neutralized; upstream poison source OPEN). Disclosed to user in-thread as a D156-guard extension following the already-accepted D156 precedent in this same file; if the project wants formal rule-2 sign-off for `src/game` edits, this is the entry to reference — revert is two small blocks. Cross-refs: D156 (original guard + Facility outro hang), D309/D310 (ruled out for this freeze — no AI involvement). diff --git a/src/game/chrai.c b/src/game/chrai.c index e441172d..0213c4c1 100644 --- a/src/game/chrai.c +++ b/src/game/chrai.c @@ -680,27 +680,14 @@ s32 chraiitemsize(u8 *AIList, s32 offset) case AI_ObjectRocketLaunch: return sizeof(AiObjectRocketLaunchRecord); case AI_PRINT: -#ifdef PORT - /* D310 / RULE-2-SIGNOFF (2026-09-18, grant: "I want to proceed - * with your implementation plan here"): the PRINT() macro emits - * a 1-byte record (its string is discarded at list-build time), - * but the original sizer below NUL-scans forward, so stepping on - * a PRINT skips ~20 bytes into whatever follows. Both active - * PRINTs in the data are in m_RunToBondPersistent: "no go!" mis- - * lands on 00 00 (phantom GotoNext(0) -> label-not-found -> PC - * restart -> infinite spin, D309's Caverns->intro freeze), and - * "wait" mis-lands on arg bytes reading Label+EndList -> ai() - * returns without saving the offset -> Stop re-issued every tick - * (guards stand still forever, user report 2026-09-18). Sizing - * the record at its true 1-byte size resumes at the next real - * record, exactly as the list source intends (full-list decode - * verified clean to EndList; post-"wait" sequence matches - * chraidata.c line-for-line). Same-engine precedent: PD removed - * PRINT and reassigned slot 0xAD (aiChrCopyProperties). - * See docs/dev/findings.md D310. */ - return 1; -#else { + /* D310: for GLOBAL lists the PRINT record is really 1 byte (the + * macro discards its string at list-build time) and this NUL-scan + * mis-skips into later records -- see d310ItemSize() below, which + * applies the 1-byte size on PORT for global lists only. LOCAL + * (level-setup) lists embed a NUL-terminated string after the + * command byte, so the scan is correct for them and must stay. + * This function itself is left byte-identical to the decomp. */ s32 pos = offset + 1; while (AIList[pos] != 0) { @@ -708,7 +695,6 @@ s32 chraiitemsize(u8 *AIList, s32 offset) } return (pos - offset) + 1; } -#endif default: #if defined(ENABLE_LOG) osSyncPrintf("chraiitemsize: unknown type %d!\n", *AIList); @@ -717,6 +703,37 @@ s32 chraiitemsize(u8 *AIList, s32 offset) } } +#ifdef PORT +/* D310 / RULE-2-SIGNOFF (2026-09-18, grant: "I want to proceed with your + * implementation plan here"): AI_PRINT records differ by list origin. + * Global lists (g_GlobalAILists, macro-built in chraidata.c) discard the + * PRINT string at build time -> the record is 1 byte and chraiitemsize's + * NUL-scan mis-skips ~20 bytes into later records: m_RunToBondPersistent's + * "no go!" lands on 00 00 (phantom GotoNext(0) -> label-not-found -> PC + * restart -> infinite spin, D309's Caverns->intro freeze) and its "wait" + * lands on arg bytes reading Label+EndList (ai() returns without saving the + * offset -> Stop re-issued every tick -> guards stand still forever). + * Level-local lists (g_CurrentSetup.ailists) embed a NUL-terminated string + * after the command byte (e.g. AD 'D' '\n' 00 in Caverns' Trevelyan list), + * so the NUL-scan is CORRECT for them -- sizing those at 1 lands mid-string + * and corrupts execution (Trevelyan ran circles with glitched anims when a + * uniform 1-byte size was tried, live-observed 2026-09-18). Use this + * instead of chraiitemsize at AI record-walk sites; it applies the 1-byte + * size only for lists confirmed global via chraiGetAIListID. Full-list + * decode of m_RunToBondPersistent with the fix walks clean to EndList and + * matches chraidata.c line-for-line. Same-engine precedent: PD removed + * PRINT and reassigned slot 0xAD (aiChrCopyProperties). See findings D310. */ +static s32 d310ItemSize(u8 *AIList, s32 offset, bool isGlobalAIList) +{ + if (isGlobalAIList && AIList[offset] == AI_PRINT) + { + return 1; + } + + return chraiitemsize(AIList, offset); +} +#endif + /** * Get ID of AIList * @param AIList: Ailist to get ID of @@ -760,8 +777,13 @@ s32 chraiGoToLabel(AIRecord *AIList, s32 Offset, u8 LabelNum) { s32 listID; char *debAIListTypeString; - bool isGlobalAIList; + bool isGlobalAIList = FALSE; +#ifdef PORT + /* D310: the record walk below must size PRINT per list origin (global = + * 1 byte, local = embedded string); resolve it once up front. */ + (void)chraiGetAIListID(AIList, &isGlobalAIList); +#endif for (;;) { if (AIList[Offset].cmd == AI_Label) @@ -810,7 +832,11 @@ s32 chraiGoToLabel(AIRecord *AIList, s32 Offset, u8 LabelNum) return 0; } +#ifdef PORT + Offset += d310ItemSize((u8 *)AIList, Offset, isGlobalAIList); +#else Offset += chraiitemsize(AIList, Offset); +#endif } } @@ -3380,15 +3406,17 @@ void ai(PropDefHeaderRecord *Entityp, PROP_TYPE EntityType) case AI_PRINT: { #ifdef PORT - /* D309 (diagnosis only, GE_D309=1): every active PRINT in - * the AI data is a 1-byte record (the macro drops its - * string), but chraiitemsize sizes it by NUL-scanning - * forward -- so stepping on one always skips past several - * real records and resumes at whatever byte pattern lands - * there. Log each step-on with the landing command byte: - * landcmd==0x00 (AI_GotoNext) is the D309 spin variant; - * a harmless no-op landing is the suspected "guard stands - * still" variant (Caverns, user report 2026-09-18). */ + bool d310global = FALSE; + + /* D310: size the PRINT per list origin (see d310ItemSize). */ + (void)chraiGetAIListID(AiListp, &d310global); + /* D309 (diagnosis only, GE_D309=1): log each step on a + * PRINT record with the size actually used and the + * command byte we land on. A repeating line (same + * chr/list/off) means ai() re-executes the same path every + * tick without advancing -- the standing-still signature; + * landcmd==0x00 (AI_GotoNext) is the D309 spin variant. + * Capped so a stuck loop can't flood stderr. */ { static int s_d309p = -1; static int s_d309pn = 0; @@ -3396,12 +3424,13 @@ void ai(PropDefHeaderRecord *Entityp, PROP_TYPE EntityType) if (s_d309p < 0) { s_d309p = getenv("GE_D309") != NULL; } if (s_d309p && s_d309pn < 400) { - s32 sz = chraiitemsize(AiListp, Offset); + s32 sz = d310ItemSize(AiListp, Offset, d310global); - osSyncPrintf("D309: PRINT chr=%d list=%p off=%d size=%d landcmd=0x%02x\n", + osSyncPrintf("D309: PRINT chr=%d list=%p off=%d size=%d landcmd=0x%02x global=%d\n", ChrEntityp ? (int)ChrEntityp->chrnum : -1, (void *)AiListp, (int)Offset, (int)sz, - (int)(AiListp[Offset + sz].cmd & 0xff)); + (int)(AiListp[Offset + sz].cmd & 0xff), + (int)d310global); s_d309pn++; } } @@ -3420,7 +3449,11 @@ void ai(PropDefHeaderRecord *Entityp, PROP_TYPE EntityType) } #endif #endif +#ifdef PORT + Offset += d310ItemSize(AiListp, Offset, d310global); +#else Offset += chraiitemsize(AiListp, Offset); +#endif break; } case AI_MyTimerStart: diff --git a/src/game/model.c b/src/game/model.c index e479000c..8efff47e 100644 --- a/src/game/model.c +++ b/src/game/model.c @@ -3225,7 +3225,20 @@ void modelSetAnimFrame2WithChrStuff(Model *model, f32 framea, f32 frameb, f32 fr * NaN. */ if (!(frameb > -1.0e6f && frameb < 1.0e6f)) { - frameb = framea; + frameb = (framea > -1.0e6f && framea < 1.0e6f) ? framea : 0.0f; + } + /* D311: the original guard only validated frameb. If framea + * (model->animframe1) is ALREADY poisoned, "frameb = framea" left + * both garbage and floorFloatToInt saturated the two near-equal + * huge floats to far-apart ints -- observed live in Caverns: + * framea=frameb=-1.67e31 -> curframe=-966612173, endframe=INT_MAX, + * ~3.1e9 loop iterations = permanent hang (D156 guard had fired + * upstream but passed the poison through). Validate framea too; + * with both snapped to 0 the loop does ~0 iterations and the + * animframe1 rewrite at :3447 heals the stored state next tick. */ + if (!(framea > -1.0e6f && framea < 1.0e6f)) + { + framea = (frameb > -1.0e6f && frameb < 1.0e6f) ? frameb : 0.0f; } #endif @@ -3850,8 +3863,21 @@ void modelTickAnim(struct Model *model, s32 numticks, s32 update_chrstuff) (void *)model->anim); fflush(stderr); } + /* D311: heal the stored state BEFORE falling back to it -- if + * animframe1 is the poison source, "frame = animframe1" would just + * re-derive garbage every tick and pin the anim forever. Snapping + * it to 0 (a valid frame; modelConstrainOrWrapAnimFrame clamps + * further) lets normal accumulation resume next tick. */ + if (!(model->animframe1 > -1.0e6f && model->animframe1 < 1.0e6f)) + { + model->animframe1 = 0.0f; + } frame = model->animframe1; } + if (!(model->animframe2 > -1.0e6f && model->animframe2 < 1.0e6f)) + { + model->animframe2 = 0.0f; + } if (!(frame2 > -1.0e6f && frame2 < 1.0e6f)) { frame2 = model->animframe2;