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

+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, '=');