From 6055750467b2d14fa6ca7a3a8f371360120e5937 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 13:14:25 +0000 Subject: [PATCH 1/5] fix: effect-39 spell ring settles onto the boss and tracks it (authored 192->15 scale, alpha 192); stage=2 test boot The ring previously parked at a 240px playfield-wide annulus frozen at the declaration-time position for the whole card. The authored etama script 76 scale interp (192->15 over 120f) at the measured 600px declaration radius gives a 46.875px settled ring; FUN_004152a0 arms the VM on the boss (all.c:9110-9126) so the draw follows the live boss. Verified frame-stepped in headless Chromium across the full Wriggle and Mystia fights; 199 tests pass; replay verifier unchanged at st1 f3192 / st2 f3367 (draw-only). Co-authored-by: AgentMystia --- AGENTS.md | 40 ++++++++++++++++++++++++++++++++++++++++ src/game/snapshot.ts | 2 +- src/game/stage-scene.ts | 25 ++++++++++++++++++++----- src/main.ts | 7 ++++--- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eec78fd..5ccd2b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -791,6 +791,46 @@ HP strip visible), stage-1 tally rows + night clock, bright nonspell reference, 夜盲 full circle, shrunk circle, still-dark bridge gap, and the Last Spell singing in the dark. +### 2026-08-24 boss-presentation visual pass (headless-Chromium host; draw-only) + +Host note: this VM has NO wine and NO podman, but DOES have local headless +Chromium (`/opt/pw-browsers`), so dev-shot/visual acceptance runs locally. +Baseline re-run of committed main on this host: stage 1 EARLIEST DIVERGENCE +f3192, stage 2 f3367 — the 2026-08-25 entry's "none observed" claim does +NOT reproduce here; treat f3192/f3367 as the standing advisory numbers. + +Boss-fight fixes, each verified by frame-stepped browser captures of the +full Wriggle and Mystia fights (probe: alternate held/released Z per +30-frame batch — the dialogue machine confirms on held-bit RISING edges, +so a continuously held Z never advances the pre-boss chat): + +1. **Effect-39 spell ring settle/tracking (the user-visible boss-fight + presentation break).** drawSpellRing parked the ring at a 240px + playfield-wide annulus at the DECLARATION-time boss position, at full + alpha, forever. The authored script's scale interp is 192→15 over the + 120-frame settle and its fade-in tops at alpha 192/255; at the measured + declaration-time ~600px screen radius (3.125 px/scale), the settled + radius is 46.875px — the ring shrinks ONTO the boss and spins there, + and the VM rides its owner (FUN_004152a0 arms it on the boss, + all.c:9110-9126) so the draw now follows the live boss position. The + bake path uses the same settled radius. Verified on 灯符 (familiars + arranged around the settled ring, native look), 蠢符, and Mystia's + 猛毒/鷹符/夜盲 cards. +2. **?stage=2 test boot** (main.ts, test-only): direct probe boot of the + Stage-2 slice so the Mystia fight is reachable without chaining a + Stage-1 clear. snapshot.ts bulletDump cap raised 64→512 (probe + visibility into dense boss patterns). + +Non-defects confirmed this pass (do not re-chase): Wriggle N1's rice +"wing" hugging the boss is the authored EX chain (0x40 decel 60-90f → +turn -1.83rad → 0x10 accel [10023]≈0.045/f — frame-tracked working); the +purple rounded-rect plate behind bosses is the authored enemy.anm boss +aura; the vertical strips + kanji-card sprites above the player are the +Border-Team homing-amulet family; the kanji item boxes (刻/点) are the +authored etama2 item row. Mystia's card chain (声符 midboss → 猛毒 → +鷹符 → 夜盲, finale quota-gated) and 夜盲's player-centred vision circle +render native-faithful in the browser. + ### 2026-08-24 draw-economy audit pass (port-side only; podman runner) A full static/exe audit of the standing stage-2 f677..1237 +8 u16 draw diff --git a/src/game/snapshot.ts b/src/game/snapshot.ts index 5302a76..e59735d 100644 --- a/src/game/snapshot.ts +++ b/src/game/snapshot.ts @@ -125,7 +125,7 @@ export function stageSnapshot(scene: StageScene): Record { } return h; }, {}), - bulletDump: scene.enemyBullets.slice(0, 64).map((b) => ({ + bulletDump: scene.enemyBullets.slice(0, 512).map((b) => ({ id: b.id, x: Math.round(b.x), y: Math.round(b.y), diff --git a/src/game/stage-scene.ts b/src/game/stage-scene.ts index 39c9028..074cbf9 100644 --- a/src/game/stage-scene.ts +++ b/src/game/stage-scene.ts @@ -152,6 +152,10 @@ const PLAYER_BULLET_POOL_CAP = 0x80; // saturated during Wriggle's final card and silently vetoed the familiars' // volleys. const ENEMY_BULLET_POOL_CAP = 0x600; +// Effect 39's settled ring radius: the authored scale interp ends at 15 +// (etama archive script 76, 192->15 over 120 frames) and the declaration- +// time 600px screen radius fixes the strip's px-per-scale at 3.125. +const SPELL_RING_SETTLED_RADIUS = 15 * 3.125; const BOMB_CLEAR_REGION_CAP = 0x60; const EFFECT_POOL_CAP = 400; // TH08's effect pool is 512 slots (FUN_00425430's 0x200-slot scan with 0xd8 @@ -6545,11 +6549,22 @@ export class StageScene implements GameHost { if (!ring || !sc || !img) return; const age = Math.max(0, sc.declAge); const settle = Math.min(1, age / 120); - // The special 3D callback maps the authored 192->15 strip scale to the - // measured ~600->240px screen radius during the settle. - const radius = 240 + 360 * (1 - settle); - const alpha = Math.min(1, age / 70); + // The authored effect-39 scale interp is 192->15 over the 120-frame + // settle (etama archive script 76). At the measured declaration-time + // ~600px screen radius that is 3.125 px per scale unit, so the ring + // SHRINKS ONTO THE BOSS and spins there for the card's duration (the + // native look) instead of parking at a playfield-wide 240px annulus. + const radius = SPELL_RING_SETTLED_RADIUS + (600 - SPELL_RING_SETTLED_RADIUS) * (1 - settle); + // The script's fade-in tops out at the authored alpha 192 (of 255). + const alpha = Math.min(1, age / 70) * (192 / 255); if (alpha <= 0) return; + // FUN_004152a0 arms the VM on the boss (all.c:9110-9126) and the slot VM + // rides its owner: follow the live boss, not the declaration snapshot. + const boss = this.bossActive; + if (boss) { + ring.x = boss.x; + ring.y = boss.y; + } const cx = ox + ring.x; const cy = oy + ring.y; const ctx = r.ctx; @@ -6618,7 +6633,7 @@ export class StageScene implements GameHost { private spellRingCache: HTMLCanvasElement | null = null; private bakeSpellRing(img: HTMLImageElement | HTMLCanvasElement): HTMLCanvasElement | null { - const radius = 240; + const radius = SPELL_RING_SETTLED_RADIUS; const size = Math.ceil(radius + 24) * 2; const surface = document.createElement('canvas'); surface.width = size; diff --git a/src/main.ts b/src/main.ts index b5a849c..c8566c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -203,11 +203,12 @@ async function boot(): Promise { audio.preloadBgm(['th08_01']); audio.playBgm('th08_01'); } else { - // Direct probe boot is limited to the delivered Stage-1 Border Team - // slice; the menu follows the same restriction. + // Direct probe boot defaults to the delivered Stage-1 Border Team + // slice; ?stage=2 (test-only) boots the Stage-2 slice directly so the + // Mystia fight is reachable without chaining a full Stage-1 clear. const difficulty = Math.min(3, Math.max(0, Number(params.get('difficulty') ?? 1))); const team: Th08TeamId = 'reimuYukari'; - const stageNumber = 1; + const stageNumber = Math.min(2, Math.max(1, Number(params.get('stage') ?? 1))); const s = startStage(difficulty, team, stageNumber); // Test-only override so scripts/dev-shot.mjs can snapshot a shot pattern // at an arbitrary power bracket without needing to grind for it in-game. From 734e8b371460b679b2c883ab2fd471939abe4efa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 15:19:38 +0000 Subject: [PATCH 2/5] fix: phase HP strip, HUD label VMs tick in update, miss white-out is the playfield fill The native Gui ticks front.anm scripts -27..-18 every sim frame, so sidebar labels must advance from update() rather than only from draw(). The 2px boss strip is the current phase segment (ins_131 / life-callback latch phaseHpCeiling, drain to the next armed ins_133), not hp/maxHp. Miss white-out stays FUN_0044de60's opaque playfield fill. Co-authored-by: AgentMystia --- src/game/eclvm.ts | 10 ++++- src/game/stage-scene.ts | 62 +++++++++++++++++++-------- src/game/th08-hud-layout.ts | 20 +++++++++ src/game/types.ts | 5 +++ tests/th08-boss-presentation.test.mjs | 13 ++++++ tests/th08-hud-layout.test.mjs | 17 +++++++- 6 files changed, 107 insertions(+), 20 deletions(-) diff --git a/src/game/eclvm.ts b/src/game/eclvm.ts index 98c3c42..27b4217 100644 --- a/src/game/eclvm.ts +++ b/src/game/eclvm.ts @@ -603,6 +603,7 @@ export class StageRuntime { // latter and only appears to improve survival by removing danmaku. hp: hasLife ? life | 0 : 1, maxHp: hasLife ? life | 0 : 1, + phaseHpCeiling: hasLife ? life | 0 : 1, pendingShotDmg: 0, pendingBombDmg: 0, // DAT_018b8a24 is 40 in the v1.00d Border-Team replay runtime; the @@ -665,6 +666,7 @@ export class StageRuntime { e.ecl.itemDrop = item; if (hasScore) e.score = score | 0; e.maxHp = Math.max(1, e.hp); + e.phaseHpCeiling = e.maxHp; return e; } @@ -1394,6 +1396,7 @@ export class StageRuntime { const t = s.lifeThresholds[i]; if (t.threshold >= 0 && t.sub >= 0 && e.hp < t.threshold) { e.hp = t.threshold; + e.phaseHpCeiling = Math.max(1, t.threshold); t.threshold = -1; s.timerCallbackThreshold = -1; // exe: cleared on every life-cb fire s.timerCallbackSub = s.deathCallbackSub; @@ -1408,7 +1411,11 @@ export class StageRuntime { for (let i = 0; i < 4; i++) { if (s.lifeThresholds[i].threshold > best) { best = s.lifeThresholds[i].threshold; bestIdx = i; } } - if (best > 0 && bestIdx >= 0) { e.hp = best; s.lifeThresholds[bestIdx].threshold = -1; } + if (best > 0 && bestIdx >= 0) { + e.hp = best; + e.phaseHpCeiling = Math.max(1, best); + s.lifeThresholds[bestIdx].threshold = -1; + } s.timerCallbackThreshold = -1; s.timerCallbackSub = s.deathCallbackSub; s.bossTimer = 0; @@ -4104,6 +4111,7 @@ export class StageRuntime { case 131: { // set the bullet pool ("HP") + copies e.hp = gi(0); e.maxHp = Math.max(1, e.hp); + e.phaseHpCeiling = e.maxHp; t.poolCopyA = e.hp; t.poolCopyB = e.hp; return null; diff --git a/src/game/stage-scene.ts b/src/game/stage-scene.ts index 074cbf9..9be9f54 100644 --- a/src/game/stage-scene.ts +++ b/src/game/stage-scene.ts @@ -33,6 +33,7 @@ import { TH08_DIFFICULTY_TAG, TH08_FORM_GAUGE, TH08_HUD, + bossLifebarFillRatio, formGaugeCursorX, formGaugePercentX } from './th08-hud-layout'; @@ -2510,6 +2511,12 @@ export class StageScene implements GameHost { for (const runner of this.stageIntroRunners) { if (!runner.removed) runner.update(this.slowRate); } + // Native Gui ticks the front.anm sidebar label VMs every sim frame + // (FUN_0043625d's callers). Headless update() never reaches draw(), so + // these must advance here — otherwise the fade-in scripts stay parked + // at t0 and bomb/replay screenshots stack Player/Spell over missing + // Graze/Point/Time. + this.tickTh08HudRunners(); if (this.dialogueBgmRunner && !this.dialogueBgmRunner.runner.removed) { this.dialogueBgmRunner.runner.update(this.slowRate); } @@ -5738,9 +5745,11 @@ export class StageScene implements GameHost { this.markPass('items'); const p = this.playerObj; // TH08 miss white-out: FUN_0044d2c0 draws FUN_0044de60(player, 768, - // 896, 0xffffffff, 0) EVERY frame while the +0xe2a70 countdown runs — - // a solid white playfield quad through the death squish and 60 frames - // past it (not the deathbomb window; that has no white flash). + // 896, 0xffffffff, 0) EVERY frame while the +0xe2a70 countdown runs. + // 768×896 centered on the player with opaque white always covers the + // 384×448 playfield, so the clipped result is a solid white playfield + // quad (tests/th08-death-white.test.mjs). There is no white during + // the deathbomb window itself. if (this.th08DeathWhiteFrames > 0) { r.ctx.fillStyle = '#ffffff'; r.ctx.fillRect(PLAYFIELD.x, PLAYFIELD.y, PLAYFIELD.width, PLAYFIELD.height); @@ -6774,22 +6783,32 @@ export class StageScene implements GameHost { // themselves at their authored resting coordinates (x~416-448 column). private th08HudRunners: AnmRunner[] | null = null; - private drawSidebarTh08(r: Renderer): void { + private ensureTh08HudRunners(): void { + if (this.th08HudRunners) return; const front = this.assets.anms.front; - if (!this.th08HudRunners) { - const base = front.entries[0].spriteBase; - const mk = (script: number) => - new AnmRunner(front, script, { entryIndex: 0, spriteIndexOffset: base }); - // logo (-27), caption (-26), then the eight value labels (-25..-18). - this.th08HudRunners = [-27, -26, -25, -24, -23, -22, -21, -20, -19, -18].map(mk); - for (const runner of this.th08HudRunners) runner.update(); + if (!front) return; + const base = front.entries[0].spriteBase; + const mk = (script: number) => + new AnmRunner(front, script, { entryIndex: 0, spriteIndexOffset: base }); + // logo (-27), caption (-26), then the eight value labels (-25..-18). + this.th08HudRunners = [-27, -26, -25, -24, -23, -22, -21, -20, -19, -18].map(mk); + } + + private tickTh08HudRunners(): void { + this.ensureTh08HudRunners(); + if (!this.th08HudRunners) return; + for (const runner of this.th08HudRunners) { + if (!runner.removed) runner.update(this.slowRate); } + } + + private drawSidebarTh08(r: Renderer): void { + this.ensureTh08HudRunners(); + if (!this.th08HudRunners) return; const p = this.playerObj; const run = this.runState; const valueX = 488; // TH08_HUD_FIELDS value column - // The label scripts fade themselves in (entry alpha 32 -> 255); they - // must tick every frame like every other ANM VM. - for (const runner of this.th08HudRunners) runner.update(); + // VMs tick from update(); draw only blits the current pose. // Logo panel + caption (authors' own positions, 480,208 / 448,336). r.drawAnmFrame(this.th08HudRunners[0].spriteFrame(), 0, 0); r.drawAnmFrame(this.th08HudRunners[1].spriteFrame(), 0, 0); @@ -6891,17 +6910,24 @@ export class StageScene implements GameHost { } // Native boss HP strip (geometry/colors in TH08_HUD.bossLifebar, - // measured on the native demo captures; fills drains right-to-left). + // measured on the native demo captures; fill drains right-to-left). + // The strip shows the CURRENT PHASE segment, not the whole-fight life: + // it refills to full at every nonspell/spell entry (phaseHpCeiling is + // re-latched at each ins_131 arm and callback clamp) and drains to the + // next armed ins_133 threshold — one attack = one full bar drain. if (this.bossActive) { + const boss = this.bossActive; const bar = TH08_HUD.bossLifebar; - const hp = Math.max(0, this.bossActive.hp); - const max = Math.max(1, this.bossActive.maxHp); + const hp = Math.max(0, boss.hp); + const frac = bossLifebarFillRatio( + hp, boss.phaseHpCeiling || boss.maxHp, boss.ecl.lifeThresholds + ); const px = (n: number): string => `#${((n >> 24) & 0xff).toString(16).padStart(2, '0')}${((n >> 16) & 0xff).toString(16).padStart(2, '0')}${((n >> 8) & 0xff).toString(16).padStart(2, '0')}`; ctx.fillStyle = px(bar.emptyColor); ctx.fillRect(bar.x, bar.y, bar.width, bar.height); ctx.fillStyle = px(bar.fillColor); - ctx.fillRect(bar.x, bar.y, bar.width * (hp / max), bar.height); + ctx.fillRect(bar.x, bar.y, bar.width * frac, bar.height); } } diff --git a/src/game/th08-hud-layout.ts b/src/game/th08-hud-layout.ts index f3fe318..615f2f9 100644 --- a/src/game/th08-hud-layout.ts +++ b/src/game/th08-hud-layout.ts @@ -157,3 +157,23 @@ export function gaugeQuad(power: number): readonly Th08HudPoint[] { { x: TH08_HUD.gauge.x, y: TH08_HUD.gauge.bottom } ]; } + +// Native Gui draws the 2px boss strip as the CURRENT PHASE segment: +// ins_131 (and the life/timer callback clamp) re-latches the ceiling so the +// bar refills to full at every nonspell/spell entry, then drains toward the +// highest still-armed ins_133 threshold below live HP. One attack = one +// full drain of the strip — never the whole-fight hp/maxHp fraction. +export function bossLifebarFillRatio( + hp: number, + phaseHpCeiling: number, + lifeThresholds: readonly { threshold: number; sub: number }[] +): number { + let lower = 0; + for (const t of lifeThresholds) { + if (t.threshold >= 0 && t.sub >= 0 && t.threshold < hp && t.threshold > lower) { + lower = t.threshold; + } + } + const ceiling = Math.max(lower + 1, phaseHpCeiling); + return Math.max(0, Math.min(1, (hp - lower) / (ceiling - lower))); +} diff --git a/src/game/types.ts b/src/game/types.ts index 503220b..0904223 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -789,6 +789,11 @@ export interface Enemy { z: number; hp: number; maxHp: number; + // Current boss-phase HP ceiling for the HUD lifebar: re-latched at every + // ins_131 life arm and at every life/timer callback clamp, so the strip + // refills to full at each nonspell/spell phase entry (native TH08 bar + // behavior — one attack = one full drain of the strip). + phaseHpCeiling: number; // Damage taken this frame, settled once per frame through the exe's // pipeline (Th07.exe FUN_0041ed50: cherry from the pre-cap sum, cap 70, // spell-card /7, op-142 shield /9) — see StageScene#settlePendingDamage. diff --git a/tests/th08-boss-presentation.test.mjs b/tests/th08-boss-presentation.test.mjs index fa88f98..ff769f8 100644 --- a/tests/th08-boss-presentation.test.mjs +++ b/tests/th08-boss-presentation.test.mjs @@ -133,3 +133,16 @@ test('night blindness paints four cover rects plus the rim sprite centered on th scene.setNightBlindness(0, 0); assert.equal(scene.nightBlindIntensity, 0); }); + +test('sidebar label VMs tick from update(), not only from draw()', () => { + const scene = makeScene(1); + for (let f = 0; f < 120; f++) scene.update(inputBits(0)); + const runners = scene.th08HudRunners; + assert.ok(Array.isArray(runners) && runners.length === 10, 'HUD runners arm without draw()'); + for (const runner of runners) { + assert.ok( + runner.frame >= 100, + `script ${runner.scriptId} ticked during update (frame ${runner.frame})` + ); + } +}); diff --git a/tests/th08-hud-layout.test.mjs b/tests/th08-hud-layout.test.mjs index 36a730f..1b19aba 100644 --- a/tests/th08-hud-layout.test.mjs +++ b/tests/th08-hud-layout.test.mjs @@ -17,7 +17,8 @@ const { formGaugeCursorX, formGaugePercentX, hudValuePosition, - gaugeQuad + gaugeQuad, + bossLifebarFillRatio } = await import('../tests/.build/th08-hud-layout.mjs'); test('TH08 playfield and front label positions match v1.00d', () => { @@ -98,3 +99,17 @@ test('the boss lifebar is the native 2px strip at y=19', () => { emptyColor: 0x00001cff }); }); + +test('the boss lifebar fill is the current phase segment, not whole-fight life', () => { + const disarmed = { threshold: -1, sub: -1 }; + const next = { threshold: 1000, sub: 40 }; + // Fresh spell/nonspell: ins_131 latched ceiling=2000, hp full, next + // ins_133 armed at 1000 → the strip reads full. + assert.equal(bossLifebarFillRatio(2000, 2000, [next, disarmed, disarmed, disarmed]), 1); + // Mid-phase: half of the current attack remains. + assert.equal(bossLifebarFillRatio(1500, 2000, [next, disarmed, disarmed, disarmed]), 0.5); + // Life-callback clamp: hp snaps to 1000, ceiling re-latches to 1000, + // the fired slot disarms → the next attack's strip reads full again. + assert.equal(bossLifebarFillRatio(1000, 1000, [disarmed, disarmed, disarmed, disarmed]), 1); + assert.equal(bossLifebarFillRatio(0, 1000, [disarmed, disarmed, disarmed, disarmed]), 0); +}); From 2eeb04cd40a045e42c7b04f6ebe7c806593c19eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 16:33:11 +0000 Subject: [PATCH 3/5] fix: TH08 et_ex 0x40 SETs heading (Wriggle midboss rain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TH07 mapped 0x40 as relative ADD. TH08 Sub16's 20-way FIRE0 plus f0=π/2 is authored firefly rain: every spoke must take heading π/2 after the pause, not rotate the 214° fan. Stage-1 ECL only writes cardinals into 0x40's f0. dirChangeBullet now takes the flag bit to clear so SET on 0x40 actually disarms (absolute previously only cleared 0x100). Fixture stage 1 earliest unexpected hit moves 3192 → 3206; the remaining contact is a vertical rain column (~1.4px from the player), a separate fan-placement residual. Stage 2 f677 native contact and f3367 advisory hit are unchanged. Co-authored-by: AgentMystia --- src/game/stage-scene.ts | 23 +++++++-- src/game/types.ts | 2 +- tests/th08-ex-dirchange.test.mjs | 81 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 tests/th08-ex-dirchange.test.mjs diff --git a/src/game/stage-scene.ts b/src/game/stage-scene.ts index 9be9f54..ef6abfe 100644 --- a/src/game/stage-scene.ts +++ b/src/game/stage-scene.ts @@ -4827,9 +4827,18 @@ export class StageScene implements GameHost { b.exAngleElapsed++; } } - if ((b.exFlags & 0x40) && b.exDir) this.dirChangeBullet(b, 'relative'); - if ((b.exFlags & 0x100) && b.exDir) this.dirChangeBullet(b, 'absolute'); - if ((b.exFlags & 0x80) && b.exDir) this.dirChangeBullet(b, 'aimed'); + // TH08 et_ex 0x40 is SET heading. TH07's BulletManager.cpp:763 mapped + // 0x40 as relative ADD (`angle += f0`); carrying that into TH08 rotated + // Wriggle midboss Sub16's 20-way fan (FIRE0, aimMode 0, angle2=π/16, + // flags 0x242) by +π/2 instead of dropping every spoke as rain. + // Stage-1 ECL only ever writes large cardinals into 0x40's f0 (±π/2, + // ±2π/3, π, ±1.8326) — SET targets, not nudges. 0x100 (also SET) is + // unused on stages 1–2; 0x80 stays aimed. Clear the live flag bit + // explicitly: inferring it from mode would leave 0x40 armed forever + // because absolute previously only cleared 0x100. + if ((b.exFlags & 0x40) && b.exDir) this.dirChangeBullet(b, 'absolute', 0x40); + if ((b.exFlags & 0x100) && b.exDir) this.dirChangeBullet(b, 'absolute', 0x100); + if ((b.exFlags & 0x80) && b.exDir) this.dirChangeBullet(b, 'aimed', 0x80); if ((b.exFlags & 0xc00) && b.exBounce) this.bounceBullet(b, (b.exFlags & 0x400) !== 0); // Default bullet integration is the same f32 store-back (`pos += vel`) // at all.c:16147-16149. Math.fround is therefore state semantics, not a @@ -4839,7 +4848,11 @@ export class StageScene implements GameHost { return { enteredNormalState: true, transitionX, transitionY }; } - private dirChangeBullet(b: EnemyBullet, mode: 'relative' | 'absolute' | 'aimed'): void { + private dirChangeBullet( + b: EnemyBullet, + mode: 'relative' | 'absolute' | 'aimed', + flagBit: number + ): void { const d = b.exDir!; const interval = Math.max(1, d.interval | 0); const maxTimes = Math.max(1, d.maxTimes | 0); @@ -4850,7 +4863,7 @@ export class StageScene implements GameHost { if (b.exDirElapsed >= interval) { b.dirTimes = times + 1; if (b.dirTimes >= maxTimes) { - b.exFlags &= mode === 'relative' ? ~0x40 : mode === 'absolute' ? ~0x100 : ~0x80; + b.exFlags &= ~flagBit; } // Native BulletManager.cpp:763 (relative) is a plain f32 `angle += d.angle` // with NO wrap — the heading accumulates unbounded (matching the f32 LHS diff --git a/src/game/types.ts b/src/game/types.ts index 0904223..5270850 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -133,7 +133,7 @@ export interface EnemyBullet { // FIRE. Fields mirror the exe's queue entry pfVar1[0..5] (audit-bullet-motion // §op79): opcode=arg1, cond=arg2, arg3=duration, arg4=maxTimes, f0/f1=floats. export interface BulletExSlot { - opcode: number; // 1 ramp / 0x10 accel / 0x20 angle / 0x40|0x80|0x100 dir / 0x400|0x800 bounce + opcode: number; // 1 ramp / 0x10 accel / 0x20 angle / 0x40|0x80|0x100 dir (TH08 0x40=SET) / 0x400|0x800 bounce cond: number; // arg2: cond gate — a cond==0 slot activates only before any other behavior arg3: number; // duration / interval / limit / bounce-maxTimes arg4: number; // dir-change maxTimes diff --git a/tests/th08-ex-dirchange.test.mjs b/tests/th08-ex-dirchange.test.mjs new file mode 100644 index 0000000..07cbe2c --- /dev/null +++ b/tests/th08-ex-dirchange.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { loadEngine, makeStubAssetsTh08, makeStubAudio } from '../scripts/lib/replay-harness.mjs'; + +// TH08 et_ex 0x40 is SET heading (TH07 0x40 was ADD). Wriggle midboss Sub16 +// arms a 20-way FIRE0 fan then et_ex 0x40 with f0=π/2 so every spoke falls +// as rain; the inherited relative mapping rotated the fan into the fixture +// player at f3192. +const mod = await loadEngine(); +const idle = () => ({ held: new Set(), pressed: new Set() }); +const replayInput = (word) => ({ + held: new Set([ + word & 0x1 ? 'shoot' : null, word & 0x2 ? 'bomb' : null, + word & 0x4 ? 'focus' : null, word & 0x10 ? 'up' : null, + word & 0x20 ? 'down' : null, word & 0x40 ? 'left' : null, + word & 0x80 ? 'right' : null, word & 0x100 ? 'skip' : null + ].filter(Boolean)), + pressed: new Set() +}); + +function makeScene() { + const scene = new mod.StageScene( + makeStubAssetsTh08(mod), makeStubAudio(), 3, 'reimuYukari', 1, null, 1 + ); + scene.mode = 'test'; + scene.playerObj.invulnFrames = 9999; + return scene; +} + +test('et_ex 0x40 SETs heading to f0 and clears its own flag', async () => { + const scene = makeScene(); + const shooter = scene.runtime.spawnEclEnemy(scene, { subId: 0, x: 192, y: 64, life: 1000 }); + shooter.ecl.shootOffset = { x: 0, y: 0 }; + const spawnHeading = 1.0; + scene.runtime.spawnBullets(scene, shooter, { + sprite: 2, offset: 2, count1: 1, count2: 1, + speed1: 2, speed2: 2, angle1: spawnHeading, angle2: 0, + flags: 0x40, sfx: 0, aimMode: 3, + exSlots: [{ + opcode: 0x40, cond: 1, arg3: 10, arg4: 1, + f0: Math.PI / 2, f1: 2.1 + }] + }); + const bullet = scene.enemyBullets.at(-1); + assert.ok(bullet, 'spawned a test bullet'); + const id = bullet.id; + for (let i = 0; i < 16; i++) scene.update(idle()); + const live = scene.enemyBullets.find((b) => b.id === id); + assert.ok(live, 'test bullet still live'); + assert.ok( + Math.abs(live.angle - Math.PI / 2) < 0.02, + `0x40 must SET heading to π/2, got ${live.angle} (relative would be ${spawnHeading + Math.PI / 2})` + ); + assert.equal(live.dirTimes, 1); + assert.equal(live.exFlags & 0x40, 0, '0x40 must clear its own bit after maxTimes'); + assert.ok(Math.abs(live.speed - 2.1) < 0.01, `new speed ${live.speed}`); +}); + +test('Wriggle Sub16 interval-40 layer falls as vertical rain, not a rotated fan', async () => { + const rpy = new mod.Rpy(readFileSync('tests/replays/th8_udLy01.rpy')); + const stage = rpy.stages.find((entry) => entry.stage === 1); + const scene = new mod.StageScene( + makeStubAssetsTh08(mod), makeStubAudio(), rpy.difficulty, rpy.team, + 1, null, stage.rngSeed + ); + scene.mode = 'test'; + scene.playerObj.invulnFrames = 9999; + for (let f = 0; f <= 3054; f++) scene.update(replayInput(stage.inputs[f])); + const rain = scene.enemyBullets.filter((b) => + !b.dead && b.spawnFrame === 2995 && b.sprite === 2 && b.dirTimes >= 1 + ); + assert.ok(rain.length >= 15, `expected turned Sub16 layer, got ${rain.length}`); + for (const b of rain) { + assert.ok( + Math.abs(b.angle - Math.PI / 2) < 0.05, + `spawn ${b.spawnFrame} id ${b.id} heading ${b.angle} should be π/2 rain, not spawn+π/2` + ); + } + assert.equal(scene.hitLog.length, 0, 'vertical rain must not contact the fixture player by f3054'); +}); From ee2c99681a31653596b221f07039b537e9bfb0b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 18:26:13 +0000 Subject: [PATCH 4/5] fix: TH08 graze counter steps +1/+2/+3; Stage-2 ECL opcode census FUN_0044a930 already decoded the human-tint/effects graze-counter tiers on Th08RunState; awardGraze still added 1 per event. Wire the helper so HUD/snapshot graze follows the v1.00d counter. Add implemented Stage-2 opcodes (136/142/145/146/152/168) to TH08_RAW_OPCODE_COVERAGE and census ecldata2 so newly used ops cannot silently miss the dispatcher set. Co-authored-by: AgentMystia --- src/game/eclvm.ts | 4 ++-- src/game/stage-scene.ts | 5 ++++- tests/th08-ecl-vm.test.mjs | 22 ++++++++++++---------- tests/th08-state.test.mjs | 12 ++++++++++++ 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/game/eclvm.ts b/src/game/eclvm.ts index 27b4217..334d525 100644 --- a/src/game/eclvm.ts +++ b/src/game/eclvm.ts @@ -197,8 +197,8 @@ export const TH08_RAW_OPCODE_COVERAGE = new Set([ 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 126, 127, 128, 129, 130, 131, 132, 133, - 134, 135, 139, 140, 144, 148, 153, 158, 160, 173, 174, 175, - 176, 184 + 134, 135, 136, 139, 140, 142, 144, 145, 146, 148, 152, 153, 158, 160, 168, + 173, 174, 175, 176, 184 ]); // Th08.exe .rdata table @ VA 0x4b4ad8 (file offset 0xb44d8): 21 bullet diff --git a/src/game/stage-scene.ts b/src/game/stage-scene.ts index ef6abfe..671440b 100644 --- a/src/game/stage-scene.ts +++ b/src/game/stage-scene.ts @@ -4113,7 +4113,10 @@ export class StageScene implements GameHost { // (item type 10 -> time/state-5) at the contact — the native orb // income stream (all.c:36844-36862). const extreme = this.runState.gaugeIsExtremelyHuman() || this.runState.gaugeIsExtremelyYoukai(); - if (!this.bombActiveThisFrame) this.graze++; + // FUN_0044a930 head: the displayed graze counter is +1, +2 past the + // human tint (−2000), +3 past the human effects threshold (−8000). + // Score/rank/time-orb awards stay per-event; only the counter steps. + if (!this.bombActiveThisFrame) this.graze += this.runState.grazeCounterIncrement(); this.traceReplayEvent?.({ kind: 'graze', frame: this.frame, data: { x: sourceX, y: sourceY, total: this.graze } diff --git a/tests/th08-ecl-vm.test.mjs b/tests/th08-ecl-vm.test.mjs index 9d9a851..a3ae3a6 100644 --- a/tests/th08-ecl-vm.test.mjs +++ b/tests/th08-ecl-vm.test.mjs @@ -516,17 +516,19 @@ test('TH08 Stage-1 opening mob waves retain the authored Lunatic spawn and FIRE ]); }); -test('every raw opcode in Stage 1 is covered by the TH08-native dispatcher', () => { - const runtime = new StageRuntime(TH08_DATA.stages[1], { - etama, enemy: enemyAnm, effect: effectAnm - }); - const used = new Set(); - for (let sub = 0; sub < runtime.ecl.subCount; sub++) { - for (const instr of runtime.ecl.sub(sub)) used.add(instr.id); +test('every raw opcode in Stage 1 and Stage 2 is covered by the TH08-native dispatcher', () => { + for (const stageId of [1, 2]) { + const runtime = new StageRuntime(TH08_DATA.stages[stageId], { + etama, enemy: enemyAnm, effect: effectAnm + }); + const used = new Set(); + for (let sub = 0; sub < runtime.ecl.subCount; sub++) { + for (const instr of runtime.ecl.sub(sub)) used.add(instr.id); + } + const missing = [...used].filter((opcode) => !TH08_RAW_OPCODE_COVERAGE.has(opcode)); + assert.deepEqual(missing, [], `unhandled Stage-${stageId} raw opcodes: ${missing.join(', ')}`); + assert.ok(used.size > 80, `unexpectedly small Stage-${stageId} opcode census: ${used.size}`); } - const missing = [...used].filter((opcode) => !TH08_RAW_OPCODE_COVERAGE.has(opcode)); - assert.deepEqual(missing, [], `unhandled Stage-1 raw opcodes: ${missing.join(', ')}`); - assert.ok(used.size > 80, `unexpectedly small opcode census: ${used.size}`); }); // ---- timeline v2 (FUN_0042a8a0) -------------------------------------------- diff --git a/tests/th08-state.test.mjs b/tests/th08-state.test.mjs index 587d0e6..e412420 100644 --- a/tests/th08-state.test.mjs +++ b/tests/th08-state.test.mjs @@ -89,6 +89,18 @@ test('point collection reproduces PoC, tens, human-gauge, and score scaling', () assert.equal(doubled.creditedScore, 60000); }); +test('graze counter steps +1/+2/+3 from the human-side gauge tiers', () => { + const state = new Th08RunState(3); + assert.equal(state.grazeCounterIncrement(), 1, 'neutral gauge is +1'); + state.addYoukaiGauge(-2000); + assert.equal(state.grazeCounterIncrement(), 2, 'human tint (−2000) is +2'); + state.addYoukaiGauge(-6000); + assert.equal(state.grazeCounterIncrement(), 3, 'human effects (−8000) is +3'); + const youkai = new Th08RunState(3); + youkai.addYoukaiGauge(8000); + assert.equal(youkai.grazeCounterIncrement(), 1, 'youkai extreme stays +1'); +}); + test('small point and time-orb awards use native score divisions', () => { const state = new Th08RunState(3); const point = state.collectPointSmall({ atOrAbovePoC: true, abovePoCRandom: 100 }); From 0c395d67675ae6aaeb50539eb05750c5c7bb38a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 19:33:27 +0000 Subject: [PATCH 5/5] fix: skip et_ex on TH08 spawn-state ending tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TH08 OnUpdate dispatches spawn through the jump table at 0x432156. Those cases still do the extra full-velocity add when the ANM ends (pacing tick-10 pin) but they do not run state-1 et_ex. Ticking 0x40 wait on that fallthrough SET-heading one frame early and put Wriggle Sub16's interval-70 rain on the fixture player at f3206. Stage-1 unexpected hit moves 3205 → 3214 (sibling rain spoke). Co-authored-by: AgentMystia --- src/game/stage-scene.ts | 27 +++++++++++++--- tests/th08-ex-dirchange.test.mjs | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/game/stage-scene.ts b/src/game/stage-scene.ts index 671440b..1541b40 100644 --- a/src/game/stage-scene.ts +++ b/src/game/stage-scene.ts @@ -4506,8 +4506,9 @@ export class StageScene implements GameHost { } const wasInSpawnState = (b.spawnAge ?? b.spawnDuration) < b.spawnDuration; // Native spawn states 2/3/4 skip behavior, cull, bomb and player - // collision until their authored ANM ends. On the ending tick control - // falls through into state 1 and performs the normal move as well. + // collision until their authored ANM ends. On the ending tick the + // extra full-velocity move still runs; et_ex stays until the first + // exclusive state-1 tick (TH08 jump table @ 0x432156). const motion = this.updateBulletMotion(b); const enteredNormalState = motion.enteredNormalState; // TH08 spawn-state quad probe (all.c:23589-23591/23615-23617/ @@ -4693,13 +4694,19 @@ export class StageScene implements GameHost { let transitionX: number | undefined; let transitionY: number | undefined; const spawnAge = b.spawnAge ?? b.spawnDuration; + // TH08 OnUpdate (0x431240) dispatches spawn through a jump table at + // 0x432156 (state 2→0x43176e, 3→0x431880, 4→0x431991). Those cases + // are isolated: they never run the et_ex handlers that live in state 1. + // Capture this before the ending tick promotes spawnAge, so the extra + // full-velocity add still happens without advancing dir-change clocks. + const startedInSpawn = spawnAge < b.spawnDuration; // FUN_004069f0 executes the prototype VM's t0 synchronously at arm. The // native slot trace resolves the old +2 fixture workaround: a time-10 // flash returns after nine creep-only manager ticks, then its tenth tick // performs the fractional move, reports remove, and falls through to the // full move (Stage-2 replay slots 14/15, native f655..664). At 1/3 rate a // time-24 flash likewise ends on wall tick 70, not 72. - if (spawnAge < b.spawnDuration) { + if (startedInSpawn) { // Enemy-bullet storage is float32. FUN_004241c0 performs its spawn- // state multiply/add on x87, then writes the result back to the slot's // f32 position fields every manager tick. Keeping JS doubles here @@ -4732,12 +4739,17 @@ export class StageScene implements GameHost { } // State 2/3/4 probes the player quad and settles a latched conversion // at this creep-only position before LAB_00431306 promotes the bullet - // and runs the ordinary behavior/full-speed move. + // and runs the extra full-speed move (et_ex stays on the next tick). transitionX = b.x; transitionY = b.y; // Th07.exe FUN_004241c0 @ 0x424843-0x424860: an ending spawn ANM // changes state to 1, resets the normal age counter, and falls through // to the ordinary behavior/full-velocity move on this same tick. + // TH08's jump-table spawn cases do not share that fallthrough: they + // still perform the extra full-velocity add (pacing tick-10 pin) but + // skip et_ex. Wriggle Sub16's 0x40 rain (flags 0x242, interval 70) + // otherwise burns one wait tick here, SET-heading 1 frame early, and + // the interval-70 spoke overlaps the fixture player at f3206. b.spawnAge = b.spawnDuration; b.spawnAgeFrac = 0; b.age = 0; @@ -4746,6 +4758,9 @@ export class StageScene implements GameHost { // manager tick performs exactly one further queue pass BEFORE executing // active behavior routines (FUN_004241c0 @ all.c:16120). TH08's 0x20000 // wait gate parks the queue here while the bullet's motion continues. + // Spawn-state ticks — including the ending extra-move tick — skip this + // block; the first exclusive state-1 tick is the first et_ex increment. + if (!startedInSpawn) { if ((b.exWaitFrames ?? 0) > 0) { b.exWaitFrames = (b.exWaitFrames ?? 0) - 1; } else { @@ -4843,9 +4858,11 @@ export class StageScene implements GameHost { if ((b.exFlags & 0x100) && b.exDir) this.dirChangeBullet(b, 'absolute', 0x100); if ((b.exFlags & 0x80) && b.exDir) this.dirChangeBullet(b, 'aimed', 0x80); if ((b.exFlags & 0xc00) && b.exBounce) this.bounceBullet(b, (b.exFlags & 0x400) !== 0); + } // Default bullet integration is the same f32 store-back (`pos += vel`) // at all.c:16147-16149. Math.fround is therefore state semantics, not a - // rendering approximation. + // rendering approximation. Spawn-end still takes this extra add; only + // the et_ex handlers above are skipped. b.x = Math.fround(b.x + b.vx); b.y = Math.fround(b.y + b.vy); return { enteredNormalState: true, transitionX, transitionY }; diff --git a/tests/th08-ex-dirchange.test.mjs b/tests/th08-ex-dirchange.test.mjs index 07cbe2c..50a99b8 100644 --- a/tests/th08-ex-dirchange.test.mjs +++ b/tests/th08-ex-dirchange.test.mjs @@ -57,6 +57,39 @@ test('et_ex 0x40 SETs heading to f0 and clears its own flag', async () => { assert.ok(Math.abs(live.speed - 2.1) < 0.01, `new speed ${live.speed}`); }); +test('state-2 spawn ending tick does not increment et_ex 0x40 wait', async () => { + const fire = (flags) => { + const scene = makeScene(); + const shooter = scene.runtime.spawnEclEnemy(scene, { subId: 0, x: 192, y: 64, life: 1000 }); + shooter.ecl.shootOffset = { x: 0, y: 0 }; + scene.runtime.spawnBullets(scene, shooter, { + sprite: 2, offset: 2, count1: 1, count2: 1, + speed1: 2, speed2: 2, angle1: 0, angle2: 0, + flags, sfx: 0, aimMode: 3, + exSlots: [{ + opcode: 0x40, cond: 1, arg3: 10, arg4: 1, + f0: Math.PI / 2, f1: 2.1 + }] + }); + return { scene, bullet: scene.enemyBullets.at(-1) }; + }; + + const spawned = fire(0x42); + assert.equal(spawned.bullet.spawnDuration, 10, 'sprite-2 flash is 10 ticks'); + assert.equal(spawned.bullet.exFlags & 0x40, 0x40, 'FIRE flags 0x40 must arm et_ex 0x40'); + for (let i = 0; i < 10; i++) spawned.scene.update(idle()); + assert.equal(spawned.bullet.exDirElapsed, 0, 'ending fallthrough must not tick 0x40'); + assert.equal(spawned.bullet.dirTimes, 0); + spawned.scene.update(idle()); + assert.equal(spawned.bullet.exDirElapsed, 1, 'first exclusive tick is the first wait increment'); + assert.equal(spawned.bullet.dirTimes, 0); + + const immediate = fire(0x40); + assert.equal(immediate.bullet.spawnDuration, 0); + for (let i = 0; i < 10; i++) immediate.scene.update(idle()); + assert.equal(immediate.bullet.exDirElapsed, 10); +}); + test('Wriggle Sub16 interval-40 layer falls as vertical rain, not a rotated fan', async () => { const rpy = new mod.Rpy(readFileSync('tests/replays/th8_udLy01.rpy')); const stage = rpy.stages.find((entry) => entry.stage === 1); @@ -79,3 +112,23 @@ test('Wriggle Sub16 interval-40 layer falls as vertical rain, not a rotated fan' } assert.equal(scene.hitLog.length, 0, 'vertical rain must not contact the fixture player by f3054'); }); + +test('Wriggle Sub16 rain does not contact the fixture player at f3206', async () => { + const rpy = new mod.Rpy(readFileSync('tests/replays/th8_udLy01.rpy')); + const stage = rpy.stages.find((entry) => entry.stage === 1); + const scene = new mod.StageScene( + makeStubAssetsTh08(mod), makeStubAudio(), rpy.difficulty, rpy.team, + 1, null, stage.rngSeed + ); + scene.mode = 'test'; + scene.rank = stage.rank; + scene.graze = stage.graze; + scene.playerObj.lives = stage.lives; + scene.playerObj.bombs = stage.bombs; + scene.playerObj.power = stage.power; + for (let f = 0; f <= 3206; f++) scene.update(replayInput(stage.inputs[f])); + assert.equal( + scene.hitLog.length, 0, + `spawn-end must not tick et_ex: ${JSON.stringify(scene.hitLog)}` + ); +});