From 75e59027171d2a1b6e58ca3b0c41be0c79adb15a Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:21:24 -0400 Subject: [PATCH] D321: map the D318 trigger chain (tanks -> chr 254 gas cascade -> combat bit, ~3.5s authored delay; derail same-tick; PC faithful to N64) + GE_OBJT probe extensions (AI-list ID, GAS prop timestamps) --- docs/dev/findings.md | 23 +++++++++++++++++++- src/game/chraction.c | 51 ++++++++++++++++++++++++++++++++++++++++++++ src/game/propobj.c | 18 ++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 1abac05a..c3cca461 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -12124,7 +12124,7 @@ Per-tick check (hooked at the `g_GlobalTimer += g_ClockTimer` site in lv.c). Sig - Interactive playtest (`scratch/d318_playtest3.log`, 2026-09-19): organic derail occurred; the run drew the escape variant, so c78 broke the pin on his own and killed Trevelyan within seconds. The watchdog detected the signature twice (t=7496, t=9270) but never confirmed — both windows cleared before 600 consecutive ticks → zero false positives in live play. - Diagnostic probes removed (`port/src/d318probe.c`, `port/include/d318probe.h`, and the `#ifdef PORT` hooks in chrai.c/chraction.c/lv.c/model.c); only the permanent watchdog hook remains (lv.c). -**Status:** RESOLVED (2026-09-19). As-authored latent race documented; port-side watchdog ships by default. Cross-ref: D263 (mechanism map — stands), D202 M-66b (port-side expiration precedent). +**Status:** RESOLVED (2026-09-19). As-authored latent race documented; port-side watchdog ships by default. Trigger chain upstream of the derail (tanks → combat bit) fully mapped in D321 — PC behavior confirmed faithful to N64. Cross-ref: D263 (mechanism map — stands), D321 (trigger chain + live validation), D202 M-66b (port-side expiration precedent). ## D319 — Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18). @@ -12175,3 +12175,24 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA **Second-pass verification (2026-09-20, review of this entry).** All mechanism claims re-checked against source and confirmed: `chrHasStoppedOrPatroling` (chraction.c:4291) returns TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL; `actor_fire_or_aim_at_target_update` (chraction.c:4882) succeeds on ACT_ATTACK + (AIM_ONLY\|DONTTURN) and calls `chrlvAttackActionRelated`, which only ever calls `modelSetAnimEndFrame` (recoil/shoot start or end frame) — never `modelSetAnimation`. The three high/med-high lists were re-read in full: ai_19 (UsetuparkZ.c 2630–2826), ai_9 (UsetupcontrolZ.c 2279–2313), ai_12 (UsetupdepoZ.c 2174+) — structures match the table above, with the two precision corrections applied to the ai_19/ai_12 rows. **AI command-ID mapping settled:** `bondaicommands.h`'s explicit IDs are the cmdbuilder enum values from `aicommands.def` order (cross-checked on four known commands; a naive `#define CMDNAME` count is off by 2). In particular `if_guard_bitfield_is_set_on` = ID 0x96 = `AI_IFMyFlags2Has` — it tests the **executing guard's own** flags2 (`AiIFMyFlags2HasRecord {cmd, BITS, GOTOLABEL}`, handler chrai.c:3146), and `guard_bitfield_set_on` = 0x94 = `AI_SetMyFlags2`. The per-chr-targeting family is separate (0x97–0x99, `chr_bitfield_*`). `AI_TRYFireOrAimAtTarget[Kneel]` jumps to GOTOLABEL on success, falls through on failure (chrai.c:1372+). TARGET_ flag decode for the hold commands: 0x24 = CHR\|AIM_ONLY, 0x28 = PAD\|AIM_ONLY (hold poses); updates set 0x04/0x08 (live fire, no AIM_ONLY). **Status:** OPEN — static sweep complete and second-pass verified; no live confirmation yet for any list beyond D318 itself. Cross-ref: D318 (mechanism + watchdog), D263 (ai_22 mechanism map). + +## 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). + +**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):** +- `GE_OBJT` in `chrSetStageFlags`/`chrUnsetStageFlags` (`chraction.c`): logs every low-16-bit write to `objectiveregisters1` with tick, writing chr, its actiontype/aioffset, and its AI-list ID (`chraiGetAIListID`). +- `GE_OBJT` in the `PROPDEF_GAS_RELEASING` destruction handler (`propobj.c`): timestamps each tank destruction. +- Existing `GE_D318T` timeline (`port/src/d318watchdog.c`) for correlation. + +**Findings (three playtest captures, Facility `-level_34`):** +1. The level setup script (`UsetuparkZ.c`) never writes the low bits of `objectiveregisters1` — only character-action scripts can, via `chrSetStageFlags`. Writers observed: chr 67 (Trevelyan) sets `0x100`, `0x02`, and `0x20` (monologue); **chr 254** (`ACT_BONDMULTI`, aiid=0x1001, off=15 — a scripted gas-cascade entity) sets **`0x40` (flee) + `0x04` (combat) together, same tick**. +2. With tank-destruction timestamps: all 10 tanks destroyed t=7544–7634; the bits land t=7846 — **Δ = 212 ticks (~3.5 s) after the last tank**. This is a fixed ROM script delay in chr 254's cascade, identical data on the N64 — not a port-side lag. +3. The derail at ai_22 off=75 fires **on the same tick the bit lands** (run A: t=8807→8808; run B: t=7846→7847). While c78 waits in the monologue loop at off=75 the check is re-evaluated continuously (heartbeat-verified), so there is no additional polling latency. +4. Run B took the **natural escape**: Trevelyan died the same tick as the derail, zero `D318W` lines — i.e. "blow up the tanks → shot ~3.5 s later" is exactly what this build does, matching the user's N64 memory. Runs A and the original 09-18 report rolled the **pin branch** (random aim variant, D318 step 4), where the ~10 s felt by the user was the watchdog's 600-tick confirmation window breaking an anim pin the N64 would soft-lock on permanently. + +**Conclusion.** No port bug; no `src/game` change warranted or needed. The perceived delay decomposes into (a) the authored ~3.5 s gas cascade + (b), only on the pin branch, the watchdog confirmation window. The PC trigger chain is faithful to the N64 end-to-end. + +**Optional tuning (deferred, user's call).** `D318_DEADLOCK_TICKS` 600→300 would halve the worst-case pin-branch pause (10 s → 5 s); the margin over the natural escape (<2 s; run B: 0 ticks) stays ≥2.5×. One-line `#define` in `port/src/d318watchdog.c`; not done pre-ship to avoid re-validating the watchdog on the release branch. + +**Status:** CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play (fired + recovered in run A, correctly inert in run B). Cross-ref: D318 (pin mechanism + watchdog), D320 (sweep of the same race in 8 levels), D263 (ai_22 mechanism map), M-192 (release review). diff --git a/src/game/chraction.c b/src/game/chraction.c index 9c6d4c88..e560b19f 100644 --- a/src/game/chraction.c +++ b/src/game/chraction.c @@ -9994,6 +9994,35 @@ bool chrHasFlags2ById(ChrRecord *self, s32 chrNum, u8 flags2) */ void chrSetStageFlags(ChrRecord *self, s32 arg1) { +#ifdef PORT + /* D318-followup (diagnosis only, GE_OBJT=1): attribute writes to the LOW + * bits of objectiveregisters1 (the Facility execution derail triggers: + * 0x04 gas/combat, 0x20 surrender/monologue, 0x40 flee -- none of which + * the level setup script ever sets; only character-action scripts do). + * Logs the writing chr + its AI position so the writer's script state is + * correlatable against a simultaneous D318T capture. Read-only, capped, + * no behavior change; N64 build unaffected. */ + extern char *getenv(const char *); + extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList); /* chrai.c */ + static int s_objt = -1; + static int s_objtn = 0; + int objtlog; + s32 aid; + bool aig; + + if (s_objt < 0) { s_objt = getenv("GE_OBJT") != NULL; } + objtlog = s_objt && (arg1 & 0x0000FFFF) && (s_objtn < 500); + if (objtlog) + { + aid = self->ailist ? chraiGetAIListID(self->ailist, &aig) : -1; + osSyncPrintf("OBJT: t=%d SET mask=0x%08x by chr %d (act=%d off=%d aiid=0x%04x%s ailist=%p) reg=0x%08x\n", + (int)g_GlobalTimer, (unsigned)arg1, (int)self->chrnum, + (int)self->actiontype, (int)self->aioffset, (unsigned)aid, + aig ? "G" : "", (void *)self->ailist, + (unsigned)objectiveregisters1); + s_objtn++; + } +#endif objectiveregisters1 |= arg1; } @@ -10003,6 +10032,28 @@ void chrSetStageFlags(ChrRecord *self, s32 arg1) */ void chrUnsetStageFlags(ChrRecord *self, u32 flags) { +#ifdef PORT + extern char *getenv(const char *); + extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList); /* chrai.c */ + static int s_objtu = -1; + static int s_objtun = 0; + int objtulog; + s32 aid; + bool aig; + + if (s_objtu < 0) { s_objtu = getenv("GE_OBJT") != NULL; } + objtulog = s_objtu && (flags & 0x0000FFFF) && (s_objtun < 500); + if (objtulog) + { + aid = self->ailist ? chraiGetAIListID(self->ailist, &aig) : -1; + osSyncPrintf("OBJT: t=%d UNS mask=0x%08x by chr %d (act=%d off=%d aiid=0x%04x%s ailist=%p) reg=0x%08x\n", + (int)g_GlobalTimer, (unsigned)flags, (int)self->chrnum, + (int)self->actiontype, (int)self->aioffset, (unsigned)aid, + aig ? "G" : "", (void *)self->ailist, + (unsigned)objectiveregisters1); + s_objtun++; + } +#endif objectiveregisters1 = ~flags & objectiveregisters1; //shorthand does not match } diff --git a/src/game/propobj.c b/src/game/propobj.c index 1c38e18b..7acc4213 100644 --- a/src/game/propobj.c +++ b/src/game/propobj.c @@ -9427,6 +9427,24 @@ apply_damage: { if (objGetDestroyedLevel(obj) == 1) { +#ifdef PORT + /* D318-followup (diagnosis only, GE_OBJT=1): timestamp each + * GAS_RELEASING prop destruction so the tank-blow tick is + * correlatable against the OBJT stage-flag writes. Read-only, + * capped, no behavior change; N64 build unaffected. */ + extern char *getenv(const char *); + static int s_gas = -1; + static int s_gasn = 0; + + if (s_gas < 0) { s_gas = getenv("GE_OBJT") != NULL; } + if (s_gas && (s_gasn < 200)) + { + osSyncPrintf("OBJT: t=%d GAS prop destroyed at (%.0f,%.0f,%.0f) [gas effect armed]\n", + (int)g_GlobalTimer, (double)obj->runtime_pos.x, + (double)obj->runtime_pos.y, (double)obj->runtime_pos.z); + s_gasn++; + } +#endif init_trigger_toxic_gas_effect(&obj->runtime_pos); } }