fix(D209): AI locomotion locked to walk anim — union byte alias shifts at 64-bit

Root cause of D193 ("NPC AI locomotion is too slow", the user's biggest
playtest complaint; broke Cradle completability).

get_sound_at_range() (chraction.c:3573) is the locomotion-animation
selector: it picks ANIM_DATA_sprinting/running/walking from an arg1 speed
tier. Its only caller passed that tier as self->act_ubytes.padding[45] — a
raw byte alias into the ChrRecord action union rather than the named field.

On N64 offset 45 is exactly act_gopos.unk59:
  act_gopos = {coord3d targetpos@0, StandTile *target@12,
               waypoint *target_path@16, waypoint *waypoints[6]@20,
               u8 curindex@44, u8 unk59@45}
At 64-bit the three pointer members widen 4->8B (target@16, target_path@24,
waypoints@32), pushing unk59 to 81 while the literal 45 lands inside
waypoints[1] (40..47) — byte 5 of a heap pointer. The arena is low-4GB
(0x00000000_70xxxxxx), so that byte is always 0x00: arg1 reads 0 forever
and every AI chr in the game was bound to ANIM_DATA_walking /
ANIM_DATA_walking_unarmed regardless of what the ailist commanded. GE
character travel is animation-root-motion driven, so all AI moved at walk
pace on every level. Bond is unaffected (explicit physics, no root motion),
which is the reported "player fine / all AI slow" split.

Fix: PORT-only read of the named field; N64 line kept verbatim under #else.
ABI/pointer-width only, no game-logic change (rule-2 narrow exception, D3x).
Both callers reach the site with actiontype == ACT_GOPOS, so it is the same
byte N64 reads.

Evidence (Cradle -level_41, GE_D193A extended with tier/aidx): all six chrs
report ACT_GOPOS tier=1 SPEED_RUN on 420/440 samples, while the live
model->anim reverse-resolves to animation_table_ptrs1 index 40
(PTR_ANIM_walking) for Trevelyan and 107 (PTR_ANIM_walking_unarmed) for the
guards on every sample; 42 (running) and 41 (sprinting) never appear.
Travel sat in a flat 140-155 u/s band for all of them.

User playtest-verified on the fix build: Trevelyan and the guards run.

Also:
- GE_D193A extended with tier / gspd / typ / tick / move / frz / hid / aidx
  (aidx reverse-resolves model->anim against animation_table_ptrs1).
- bondtypes.h: unk59 was commented "guess: room" — it is the SPEED tier.
- porting-notes.md A1: new bug class — raw-byte aliases into a union read a
  plausible constant rather than garbage, so they misbehave silently.
- findings.md: D209 added; D193 closed against it.
This commit is contained in:
James Dansereau committed 2026-09-08 19:50:45 -04:00
1 parent 47d6dfa9c3
commit a63efe184b
6 files changed
+99 -7

No files matched your search

+6 -1
View File
@@ -2188,7 +2188,12 @@ typedef union
// recalculated, the array replaced with a new one and index set to 0.
/* player offset 0x58 */
u8 curindex;
u8 unk59; // guess: room
// SPEED tier for this GOPOS: 0 = walk, 1 = run, 2 = sprint. Set from
// the SPEED enum in plot_course_for_actor(); read back by
// chrlvApplySpeed() and (via a raw-byte union alias on N64 -- see
// D209) by the locomotion-animation selector get_sound_at_range().
// NB: the old "guess: room" comment here was wrong.
u8 unk59;
// g_ClockTimer related
u16 unk5a;
+42 -4
View File
@@ -47,7 +47,8 @@
* GE_D193A_CHR=<n> restricts output to that chrnum. Test-only, env-gated. */
static int d193a_mode = -1;
static int d193a_only = -1;
struct D193aSlot { s16 chrnum; u8 used; f32 px, py, pz, accdist; OSTime t0; };
struct D193aSlot { s16 chrnum; u8 used; f32 px, py, pz, accdist; OSTime t0;
u32 ticks, moved, frozen; };
static struct D193aSlot d193a_slots[48];
static struct D193aSlot *d193aSlot(s16 n) {
int free = -1;
@@ -60,8 +61,18 @@ static struct D193aSlot *d193aSlot(s16 n) {
d193a_slots[free].chrnum = n;
d193a_slots[free].accdist = 0.0f;
d193a_slots[free].t0 = 0;
d193a_slots[free].ticks = d193a_slots[free].moved = d193a_slots[free].frozen = 0;
return &d193a_slots[free];
}
static s32 d193aAnimIndex(void *anim) {
extern s32 animation_table_ptrs1[];
s32 i;
if (anim == NULL) return -1;
for (i = 0; animation_table_ptrs1[i] != 0; i++) {
if ((void *)(uintptr_t)(u32)animation_table_ptrs1[i] == anim) return i;
}
return -1;
}
static void d193aSample(ChrRecord *chr, Model *model) {
if (d193a_mode < 0) {
d193a_mode = getenv("GE_D193A") != NULL;
@@ -72,6 +83,7 @@ static void d193aSample(ChrRecord *chr, Model *model) {
if (d193a_only >= 0 && chr->chrnum != d193a_only) return;
struct D193aSlot *s = d193aSlot(chr->chrnum);
if (!s) return;
s->ticks++;
f32 x = chr->prop->pos.x, y = chr->prop->pos.y, z = chr->prop->pos.z;
OSTime now = osGetTime();
if (s->t0 == 0) { s->t0 = now; s->px = x; s->py = y; s->pz = z; s->accdist = 0.0f; return; }
@@ -81,14 +93,36 @@ static void d193aSample(ChrRecord *chr, Model *model) {
if (now - s->t0 >= 1000000) {
f32 secs = (f32)(now - s->t0) / 1e6f;
fprintf(stderr,
"[D193A] chr=%d act=%d speed=%.1fu/s gtd=%.2f | scale=%.4f xlscale=%.4f "
"animrate=%.4f playspeed=%.4f mspeed=%.4f endframe=%.1f anim=%p\n",
"[D193A] chr=%d act=%d speed=%.1fu/s gtd=%.2f | tier=%d gspd=%.2f | typ=%d tick=%u move=%u frz=%u hid=%d | scale=%.4f xlscale=%.4f "
"animrate=%.4f playspeed=%.4f mspeed=%.4f endframe=%.1f anim=%p aidx=%d\n",
chr->chrnum, (int)chr->actiontype, s->accdist / secs, g_GlobalTimerDelta,
(chr->actiontype == ACT_GOPOS) ? (int)chr->act_gopos.unk59 : -1,
(chr->actiontype == ACT_GOPOS) ? (double)chr->act_gopos.speed : -1.0,
(int)chr->prop->type, s->ticks, s->moved, s->frozen,
(int)((chr->chrflags & CHRFLAG_HIDDEN) != 0),
model->scale, model->anim_translation_scale, model->animrate,
model->playspeed, model->speed, model->endframe, (void *)model->anim);
model->playspeed, model->speed, model->endframe, (void *)model->anim,
(int)d193aAnimIndex((void *)model->anim));
s->t0 = now; s->accdist = 0.0f;
s->ticks = s->moved = s->frozen = 0;
}
}
/* GE_D193A companion: called from chrUpdateAnim -- the ONLY path that
* advances prop->pos from anim root motion. chrTick gates it behind
* posIsOnScreen() for ACT_ANIM/ACT_STAND (chr.c:2540-2565): an off-screen
* chr gets modelTickAnim(model, ticks, 0) instead, which skips
* modelSetAnimFrame2WithChrStuff entirely and so does not move at all.
* move << tick in the per-second line = the chr is being visibility-culled
* out of its own locomotion; move == tick = the gate is clean and the
* slowdown is a rate/magnitude problem inside the root-motion decode. */
static void d193aMoved(ChrRecord *chr, int frozen) {
if (d193a_mode <= 0 || chr->prop == NULL) return;
if (d193a_only >= 0 && chr->chrnum != d193a_only) return;
struct D193aSlot *s = d193aSlot(chr->chrnum);
if (!s) return;
if (frozen) s->frozen++; else s->moved++;
}
#endif
#ifdef VERSION_EU
@@ -2369,6 +2403,10 @@ void chrUpdateAnim(ChrRecord *chr, s32 tickamount)
model = chr->model;
prop = chr->prop;
#ifdef PORT
d193aMoved(chr, (chr->hidden & CHRHIDDEN_FREEZE) != 0);
#endif
if (!(chr->hidden & CHRHIDDEN_FREEZE))
{
getsuboffset(model, &chr->prevpos);
+21
View File
@@ -3662,7 +3662,28 @@ void get_sound_at_range(ChrRecord *self, s32 arg1, s32 arg2)
*/
void play_hit_soundeffect_and_proper_volume( ChrRecord *self)
{
#ifdef PORT
/* D209: `act_ubytes.padding[45]` is a raw-byte alias into the action
* union for act_gopos.unk59 -- the SPEED tier (0 walk / 1 run / 2
* sprint) that get_sound_at_range() uses to pick the locomotion
* animation. On N64 the alias is exact: act_gopos is
* {coord3d targetpos@0, StandTile *target@12, waypoint *target_path@16,
* waypoint *waypoints[6]@20, u8 curindex@44, u8 unk59@45}. On x86-64 the
* three pointer members widen 4->8 B, so unk59 moves 45 -> 81 while the
* literal 45 now lands inside waypoints[1] (40..47) -- byte 5 of a heap
* pointer, which for the low-4GB arena (0x00000000_70xxxxxx) is always
* 0x00. arg1 therefore reads 0 forever and EVERY AI chr in the game is
* bound to ANIM_DATA_walking / ANIM_DATA_walking_unarmed no matter what
* speed the ailist commanded. Since chr travel is animation-root-motion
* driven, they all move at walk pace -- D193. Both callers of this
* function reach it with actiontype == ACT_GOPOS (chraction.c:3723 right
* after `actiontype = ACT_GOPOS; act_gopos.unk59 = speed;`, and :9087 in
* the explicit else of `actiontype == ACT_PATROL`), so the named field is
* exactly the byte N64 reads. ABI/layout only, no logic change (D3x). */
get_sound_at_range(self, self->act_gopos.unk59, c_item_entries[self->bodynum].isMale);
#else
get_sound_at_range(self, self->act_ubytes.padding[45], c_item_entries[self->bodynum].isMale);
#endif
}