From 8e5d4bcd5ad5e930178adc0cc4a87bf19e467e61 Mon Sep 17 00:00:00 2001 From: dolent Date: Mon, 28 Sep 2026 20:30:44 -0700 Subject: [PATCH 1/3] fix: D420 -- crouched-rifle firing animation never resolved on PC (linker adjacency) -> SIGSEGV in MP matches crouched_rifle_firing_animation_group1 (chr.c) has one entry and no terminator; on the N64, .data is in source order, so crouched_rifle_firing_animation_groupA follows it and the single initResolveAnimGroupTable pass over group1 also resolves groupA (len 2). GCC places groupA below group1, so groupA[0] kept the raw anim offset 0xB84 (PTR_ANIM_fire_kneel_left_leg). playerTick (bondview2.c:11021) passes it to modelSetAnimation for a player's crouched rifle fire (firing_animation_groups[1][5]) -> SIGSEGV in modelConstrainOrWrapAnimFrame (fault 0xb88). Crouching rifle guards also saw len 1 instead of 2. #ifdef PORT: one array {right_leg, left_leg, terminator} (the N64 layout), groupA = &group1[1] (chr.h). ABI/layout exception, porting-notes D5 class (D164 precedent). Verified: 4-seat repro (M16, Complex, frame 5030) runs 5 min x3 SH-identical, no crash; gdb scan of all firing_animation_groups entries: 1 bad pointer before, 0 after; P1 corpus (1324 recordings) verdicts identical; solo/couch frames byte-identical; netplay 2P/4P identical. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 8595a06edd40ca22ac9ba4955b13a9ce61064475) --- src/game/chr.c | 20 ++++++++++++++++++++ src/game/chr.h | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/src/game/chr.c b/src/game/chr.c index f0c6f819..43a37a10 100644 --- a/src/game/chr.c +++ b/src/game/chr.c @@ -884,6 +884,25 @@ struct anim_group_info *ptr_doubles_firing_animation_groups[] = { &ptr_doubles_firing_animation_group1 }; +#ifdef PORT +/* D420 (ABI/layout, porting-notes §D5 linker adjacency): on the N64, + * crouched_rifle_firing_animation_group1 has NO terminator and the linker + * places crouched_rifle_firing_animation_groupA right after it, so the one + * resolver pass over group1 (initResolveAnimGroupTable, via + * ptr_crouched_rifle_firing_animation_groups) walks into groupA and turns its + * anim offset into a pointer too; the table len is 2. GCC does not keep the + * two arrays adjacent (x86-64: groupA BELOW group1), so groupA[0] kept the raw + * offset 0xB84 (PTR_ANIM_fire_kneel_left_leg) and playerTick handed it to + * modelSetAnimation as a ModelAnimation* (SIGSEGV, crouched rifle fire), and + * crouching rifle guards saw len 1 instead of 2. One array restores the N64 + * layout; groupA is its second entry (chr.h). Same data, same order. */ +struct weapon_firing_animation_table crouched_rifle_firing_animation_group1[] = { + { PTR_ANIM_fire_kneel_right_leg, 27.0, 0, 0, 0, -1.0, 35.0, 75.0, -1.0, -1.0, 31.0, 75.0, 0.87266463, -0.69813174, 0.90757126, -0.69813174, 1.5, 1.5 }, + /* = crouched_rifle_firing_animation_groupA[0] */ + { PTR_ANIM_fire_kneel_left_leg, 24.0, 0, 0, 0, -1.0, 46.0, 98.0, -1.0, -1.0, 41.0, 98.0, 0.87266463, -0.52359879, 1.134464, -0.69813174, 1.6, 1.6 }, + {0, 0.0, 0, 0, 0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0} +}; +#else struct weapon_firing_animation_table crouched_rifle_firing_animation_group1[] = { { PTR_ANIM_fire_kneel_right_leg, 27.0, 0, 0, 0, -1.0, 35.0, 75.0, -1.0, -1.0, 31.0, 75.0, 0.87266463, -0.69813174, 0.90757126, -0.69813174, 1.5, 1.5 }, }; @@ -892,6 +911,7 @@ struct weapon_firing_animation_table crouched_rifle_firing_animation_groupA[] = { PTR_ANIM_fire_kneel_left_leg, 24.0, 0, 0, 0, -1.0, 46.0, 98.0, -1.0, -1.0, 41.0, 98.0, 0.87266463, -0.52359879, 1.134464, -0.69813174, 1.6, 1.6 }, {0, 0.0, 0, 0, 0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0} }; +#endif struct anim_group_info ptr_crouched_rifle_firing_animation_group1 = { &crouched_rifle_firing_animation_group1, -1 }; diff --git a/src/game/chr.h b/src/game/chr.h index 03abfe61..2617ff1e 100644 --- a/src/game/chr.h +++ b/src/game/chr.h @@ -181,7 +181,12 @@ extern struct weapon_firing_animation_table doubles_firing_animation_group1[]; extern struct weapon_firing_animation_table doubles_firing_animation_group2[]; extern struct weapon_firing_animation_table doubles_firing_animation_group3[]; extern struct weapon_firing_animation_table crouched_rifle_firing_animation_group1[]; +#ifdef PORT +/* D420: groupA is the second entry of group1 (N64 adjacency, see chr.c). */ +#define crouched_rifle_firing_animation_groupA (&crouched_rifle_firing_animation_group1[1]) +#else extern struct weapon_firing_animation_table crouched_rifle_firing_animation_groupA[]; +#endif extern struct weapon_firing_animation_table crouched_rifle_firing_animation_group2[]; extern struct weapon_firing_animation_table crouched_rifle_firing_animation_group3[]; extern struct weapon_firing_animation_table crouched_pistol_firing_animation_group1[]; From 4705b0e4e83f5af651dbdfaf4fb247bab6503127 Mon Sep 17 00:00:00 2001 From: dolent Date: Tue, 29 Sep 2026 03:40:10 -0700 Subject: [PATCH 2/3] fix: D430 -- stale per-texture anisotropy on recycled GL ids made watch text layout-dependent gfx_opengl_set_sampler_parameters set GL_TEXTURE_MAX_ANISOTROPY only for mipmapped samplers and never reset it. GL ids freed by gfx_texture_cache_delete_range are recycled in unordered_map order (keyed by texture address), so a non-mipmapped glyph could inherit a world texture's 4x anisotropy depending on the binary's data layout -> +-1-2 per channel on the MP pause-menu text in couch 2P (frame 2400). Now always written (level when mipmapped, 1 otherwise). #ifdef PORT, render-only. Verified: couch 2P frames 2300-2500 fix vs fix+3KB array 0/21 differ (before: 13/21); base vs fix: couch 2P 0/21, solo -level_09 f1800 and couch 4P f2400 byte-identical; netplay 2P SH identical (2 671 ticks). Findings D430 + index row, porting note. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit ec5057f026259f35d4075833e89bf6fd0b6c467f) --- docs/dev/findings-index.csv | 1 + docs/dev/findings.md | 42 +++++++++++++++++++++++++++++++++++++ port/fast3d/gfx_opengl.cpp | 12 +++++++++++ 3 files changed, 55 insertions(+) diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 4e1c35b0..06bb1735 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -285,3 +285,4 @@ D405,CLOSED,"fast3d/OpenGL: MSAA above the driver max black-screens; two glBindF D406,RESOLVED,front-end PC Options screen:,"RESOLVED (2026-09-28, port-layer only, Steam Deck-validated):" D407,PARTIAL,Tanks cannot be boarded/exited on PC —,"PARTIAL (fix landed in v0.4.0, port-only):" D408,OPEN,Game.SkipIntro skips the post-mission failure dossier:,"OPEN (cosmetic, not root-caused past the handoff site)." +D430,FIXED,Couch 2P watch-menu text (the MP pause page) differs by +-1-2 per channel between builds whose only difference is data layout (2026-09-29).,FIXED — port-only (fast3d): diff --git a/docs/dev/findings.md b/docs/dev/findings.md index f23ee5ee..e9025d1b 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -693,6 +693,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D406 | **front-end PC Options screen: long sections (INPUT, 17 visible rows) run off the bottom of the 440x330 paper, and rows past the silent 14-row page cap (Crouch mode, Reset to defaults, Bindings…) are unreachable from the front screen** (user report 2026-09-28, v0.4.0 pre-push review) — full `## D406` entry at file tail | RESOLVED (2026-09-28, port-layer only, Steam Deck-validated): in-section pagination in `frontoptions.c` (11 rows/page; the vertical stepper crosses page boundaries both ways; dim 'Page p/N' marker + bottom hint on every page; slider bars start past long labels; F10 overlay untouched). D406b/c/d same-cycle refinements (label/track overlap, hint visibility/wording, hint position). | | D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). | | D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | OPEN (cosmetic, not root-caused past the handoff site). v0.4.0 decision: SkipIntro stays EXPERIMENTAL / not recommended for regular users — F10 row relabelled "Skip intro (EXPERIMENTAL)". | +| D430 | **Couch 2P watch-menu text (the MP pause page) differs by +-1-2 per channel between builds whose only difference is data layout (2026-09-29).** — full `## D430` entry at file tail | FIXED — port-only (fast3d): `GL_TEXTURE_MAX_ANISOTROPY` is per-texture-object state that `gfx_opengl_set_sampler_parameters` set only when mipmapped and never reset; a GL texture id freed by `gfx_texture_cache_delete_range` (unordered_map order = texture address order) and reused for a non-mipmapped glyph kept 4x. Now always written (level or 1). Render-only; sim never affected. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS @@ -15524,3 +15525,44 @@ included; D281 keeps the shipped mitigation + disclosure, zero-saves root cause **v0.4.0 decision (user, 09-28):** `Game.SkipIntro` stays **EXPERIMENTAL — not recommended for regular users yet**, alongside its older unrooted audio-break report (D216 status note). The F10 row is relabelled "Skip intro (EXPERIMENTAL)" (`port/src/optionsoverlay.c`, mirroring the D387 AllUnlocked labelling precedent); the config var, default (off), and `lv.c` hook are unchanged. **Status:** OPEN — cosmetic (no crash, no data loss; the mission still records as failed/aborted and the menus are fully functional). Root-causing needs a side-by-side of front-end state (`current_menu`, `g_CurrentStageToLoad`, `mission_failed_or_aborted`, `g_isBondKIA`) on the fail return with SkipIntro off vs on — candidate for v0.5.0 alongside the D216 audio half. + +## D430 — couch 2P watch-menu text differs between builds that only differ in data layout: stale per-texture anisotropy (2026-09-29) + +**Status:** FIXED, port-only (`port/fast3d/gfx_opengl.cpp`, `#ifdef PORT`). Render-only; the sim was never involved (state hashes identical throughout). + +**Symptom.** The couch 2P script (MULTIPLAYER-DEV §6), frame 2400 (the MP pause/watch menu, "PLAY / RANK: 1ST / SCORES") is not byte-identical between two builds that differ only in data layout: +- 8b080caa vs 8b080caa + an unused 3 KB array in `expinput.c`; +- the controller-pages merge; +- R1's front.c/front.h array widening (programmer 1's bisect). + +About 55-922 pixels differ, a few glyphs only, each by +-1-2 (mostly green). Every build is deterministic against itself. A 4 KB array at the end of `expinput.c` did not reproduce it, and neither did the pre-sync base with R1: only some layouts trigger it. + +**Method (probes in fast3d, removed).** A (6c8915d7) vs B (A + the 3 KB array), frames 2398-2401: +1. Every texture import (format, size, content hash): **identical** sequences in A and B. +2. Every `gfx_flush` batch (VBO hash, depth mode, blend, viewport, scissor, combine mode, other modes, and for both texture slots the GL texture id, filter, clamp, content hash): **identical except the GL texture ids.** The same content is bound under different ids in A and B. +3. So the difference lives in GL texture-object state. + +**Root cause.** +- `gfx_opengl_set_sampler_parameters` (`gfx_opengl.cpp`, the `if (mipmaps) glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, current_anisotropy_level);` line) sets anisotropy only for mipmapped samplers and never resets it. Anisotropy is per-texture-object state; `Video.Anisotropy` defaults to 4. +- GL ids are recycled: `gfx_texture_cache_delete_range` (`gfx_pc.cpp`) pushes freed ids onto `free_texture_ids` while walking the `std::unordered_map` in hash-bucket order. The key hash is the texture **address**, so the recycling order depends on where textures live. +- A glyph texture (bilinear, no mipmaps) that inherits an id last used by a mipmapped world texture keeps `MAX_ANISOTROPY = 4`. llvmpipe then filters it slightly differently: the +-1-2 values. +- A layout shift changes which glyph inherits which id, hence which frames differ. +- Nothing in `src/game` is involved: no uninitialised read, no out-of-bounds read, no stale texture content (all ruled out by the probes and the earlier UBSan run). + +**Fix.** Always write the anisotropy when sampler parameters are set: the level when mipmapped, 1.0 (off) otherwise, so a recycled id carries nothing over. This is identity for any texture whose id never had a mipmapped owner, which is why the base frames are unchanged. + +**Verified (headless, silent, llvmpipe, scratch HOME, mp-sandbox data; 6c8915d7 base):** + +| Check | Result | +|---|---| +| Couch 2P frames 2300-2500 (step 10), fix vs fix + 3 KB array (the reproducing variant) | **0 of 21 frames differ** (before the fix: frames 2300-2420 differed) | +| Couch 2P frames 2300-2500, base vs fix | 0 of 21 differ | +| Solo `-level_09` frame 1800, base vs fix | byte-identical | +| Couch 4P frame 2400, base vs fix | byte-identical | +| Netplay 2P (local relay, 60 s) | SH identical over 2 671 ticks | + +**Residual risk (not fixed, noted).** Other per-object state can still follow a recycled id: +- mip levels 1..n of a previous owner, if a texture uploaded without mipmaps is later sampled with a mipmapped min filter; +- the free-id order is still address-dependent. + +The general fix would reset the whole object on reuse, or sort the ids that `delete_range` frees. Neither is needed for this symptom. **Confidence: [H]** for the cause and the fix (A/B reproduced, fixed, bisected to the one GL parameter). diff --git a/port/fast3d/gfx_opengl.cpp b/port/fast3d/gfx_opengl.cpp index c3881e12..5aea334e 100644 --- a/port/fast3d/gfx_opengl.cpp +++ b/port/fast3d/gfx_opengl.cpp @@ -787,9 +787,21 @@ static void gfx_opengl_set_sampler_parameters(int tile, bool linear_filter, uint glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, min_filter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, max_filter); +#ifdef PORT + /* D430: anisotropy is per-texture-object state. It was only ever SET + * (when mipmapped), never reset, so a GL texture id freed by + * gfx_texture_cache_delete_range and reused for a non-mipmapped texture + * (HUD / watch text glyphs) kept the previous owner's 4x -- llvmpipe then + * filters that glyph differently (+-1..2 per channel). Which id a texture + * inherits follows the unordered_map iteration order, i.e. the texture + * ADDRESSES, so the frame changed with the binary's data layout. Always + * write it: the level when mipmapped, 1 (off) otherwise. */ + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, mipmaps ? (float)current_anisotropy_level : 1.0f); +#else if (mipmaps) { glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, current_anisotropy_level); } +#endif glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, gfx_cm_to_opengl(cms)); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, gfx_cm_to_opengl(cmt)); From d954dfb581900eff9aa0dad58b337d6bc990b726 Mon Sep 17 00:00:00 2001 From: dolent Date: Tue, 29 Sep 2026 13:26:20 -0700 Subject: [PATCH 3/3] docs: D420 findings entry (crouched-rifle firing animation table: linker adjacency) Co-Authored-By: Claude Fable 5.1 --- docs/dev/findings-index.csv | 1 + docs/dev/findings.md | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 4e1c35b0..d3019a04 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -285,3 +285,4 @@ D405,CLOSED,"fast3d/OpenGL: MSAA above the driver max black-screens; two glBindF D406,RESOLVED,front-end PC Options screen:,"RESOLVED (2026-09-28, port-layer only, Steam Deck-validated):" D407,PARTIAL,Tanks cannot be boarded/exited on PC —,"PARTIAL (fix landed in v0.4.0, port-only):" D408,OPEN,Game.SkipIntro skips the post-mission failure dossier:,"OPEN (cosmetic, not root-caused past the handoff site)." +D420,FIXED,Crouched-rifle firing animation (crouched_rifle_firing_animation_groupA) never resolved on PC -> SIGSEGV in modelConstrainOrWrapAnimFrame when an MP player cro…,"FIXED — layout only (§D5 linker adjacency, D164 class):" diff --git a/docs/dev/findings.md b/docs/dev/findings.md index f23ee5ee..40a9fe2d 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -693,6 +693,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D406 | **front-end PC Options screen: long sections (INPUT, 17 visible rows) run off the bottom of the 440x330 paper, and rows past the silent 14-row page cap (Crouch mode, Reset to defaults, Bindings…) are unreachable from the front screen** (user report 2026-09-28, v0.4.0 pre-push review) — full `## D406` entry at file tail | RESOLVED (2026-09-28, port-layer only, Steam Deck-validated): in-section pagination in `frontoptions.c` (11 rows/page; the vertical stepper crosses page boundaries both ways; dim 'Page p/N' marker + bottom hint on every page; slider bars start past long labels; F10 overlay untouched). D406b/c/d same-cycle refinements (label/track overlap, hint visibility/wording, hint position). | | D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). | | D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | OPEN (cosmetic, not root-caused past the handoff site). v0.4.0 decision: SkipIntro stays EXPERIMENTAL / not recommended for regular users — F10 row relabelled "Skip intro (EXPERIMENTAL)". | +| D420 | **Crouched-rifle firing animation (`crouched_rifle_firing_animation_groupA`) never resolved on PC -> SIGSEGV in modelConstrainOrWrapAnimFrame when an MP player crouch-fires a rifle (2026-09-28).** — full `## D420` entry at file tail | FIXED — layout only (§D5 linker adjacency, D164 class): `crouched_rifle_firing_animation_group1` has no zero terminator and on N64 falls through into `...groupA` (source-order .data); GCC places groupA first, so groupA[0] kept the raw anim offset 0xB84. `#ifdef PORT` one array {right, left, terminator} + `groupA` alias (chr.c/chr.h); N64 resolution restored. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS @@ -15524,3 +15525,40 @@ included; D281 keeps the shipped mitigation + disclosure, zero-saves root cause **v0.4.0 decision (user, 09-28):** `Game.SkipIntro` stays **EXPERIMENTAL — not recommended for regular users yet**, alongside its older unrooted audio-break report (D216 status note). The F10 row is relabelled "Skip intro (EXPERIMENTAL)" (`port/src/optionsoverlay.c`, mirroring the D387 AllUnlocked labelling precedent); the config var, default (off), and `lv.c` hook are unchanged. **Status:** OPEN — cosmetic (no crash, no data loss; the mission still records as failed/aborted and the menus are fully functional). Root-causing needs a side-by-side of front-end state (`current_menu`, `g_CurrentStageToLoad`, `mission_failed_or_aborted`, `g_isBondKIA`) on the fail return with SkipIntro off vs on — candidate for v0.5.0 alongside the D216 audio half. + +## D420 — crouched-rifle firing animation never resolved on PC (linker adjacency) -> MP crash (2026-09-28) + +**Symptom.** SIGSEGV in `modelConstrainOrWrapAnimFrame` from `playerTick` +(bondview2.c ~11021) with `anim = 0xb84`, when a multiplayer player +crouch-fires a rifle (found by the 5PLUS bot runs: Complex, 4 seats, M16, +frame 5030; present in the 0.9.x friend bundles). Found on +`exp/5plus-multiplayer` as "D341" (renamed D420 here: D341 is upstream's). + +**Root cause (layout, porting-notes §D5 "linker adjacency", D164 class).** +`firing_animation_groups[1][5]` points at `crouched_rifle_firing_animation_groupA` +(chr.c). The tables hold anim *offsets*; `initWeaponAnimGroups` -> +`initResolveAnimGroupTable` rebases each table up to its zero terminator. +`crouched_rifle_firing_animation_group1` has ONE entry and **no terminator**: +on N64 .data is laid out in source order, so resolving group1 walks on into +groupA (resolving it too, len 2). GCC placed groupA *below* group1, so +groupA[0] kept the raw offset `PTR_ANIM_fire_kneel_left_leg` (0xB84) and +`modelSetAnimation` received it as a `ModelAnimation*`. Side effect before +the fix: crouching rifle guards saw len 1 instead of the N64's 2. + +**Not covered upstream:** upstream D392 (chraction.c firing-table fields) +and D336 (bondhead.c) are different sites; chr.c/chr.h were unchanged on +09278407. + +**Fix (8595a06e, `#ifdef PORT`, `#else` = decomp):** chr.c one array +`{kneel_right_leg, kneel_left_leg, terminator}` (exactly the N64 layout); +chr.h `#define crouched_rifle_firing_animation_groupA +(&crouched_rifle_firing_animation_group1[1])`. Resolution and len are the +N64's; no logic change. Upstream candidate (a real port bug). + +**Verified (programmer, 5plus + main):** repro (4 seats, bots, Complex, +pinned seed, 18 078 frames x3) no crash, SH identical across runs; 8 seats +remote mines no crash; every firing/struck anim table in chr.c scanned -- +this was the only unterminated one; gdb scan of all 24 +`firing_animation_groups` entries at run time: 1 bad pointer before, 0 after +(also 0 on the main-line cherry-pick); P1 gate (1 324 recordings, frame +dumps) unchanged; netplay 2P/4P identical.