Files
OpenMaidEngine/docs/phase-a-slice-plan.md
2026-07-18 20:28:36 -04:00

125 KiB
Raw Blame History

Phase A — Vertical Slice Plan (the first build step)

Concrete execution plan for Phase A of remake-architecture-and-roadmap.md. Decided over the alternative (fully decoding SCJUMP.BIN) after recon showed SCJUMP is not the gating unknown.

Why the slice, and why headless-first

SCJUMP recon (2026-07-06): SCJUMP.BIN is a 29,796-instruction progression state machine, not the call-script registry. Top level switches on global 0x3234 (mode 19 → big blocks); each block is nested eq/ne/and/jcc on flags, ending in movs to output globals. Almost no call-script. So it decides what scene/branch comes next via state, and does not resolve call-script id → code. Consequence: the id→code registry stays engine-level (deferred), but the slice can stub call-script — it is not gating for running one scene's dialogue.

Correctness bootstrap (roadmap §5) drives the ordering: the VM must be validated-correct before it is trustworthy. Our strongest oracle is build/text/dialogue.jsonl (the show-text lines per script). So the very first slice is headless and text-only, validated by that oracle — no Godot, no AGF, no audio, no dispatch registry. Only once the VM reproduces dialogue do we add rendering.

Phase A therefore splits:

  • A0 — headless VM, dialogue-validated (Python prototype). ← immediate, executable now.
  • A1 — port the validated model to C# (the runtime's VM core).
  • A2 — Godot ADV backend (render one scene with visuals + voice).

A0 — Headless VM validated by the dialogue oracle

Goal: a Python interpreter that executes one ADV scene's bytecode and emits its show-text sequence; that sequence is a coherent, in-order subsequence of the script's static dialogue.jsonl lines. This proves the execution model — control flow, operand/pointer semantics, string handling, and the no-op-marker assumptions — before any C#/Godot investment. Reuses tools/sys4load.py for all parsing/decoding (no new parser).

Execution model to implement

  • Memory: one flat global bank = dict[int,int] (globals are raw offsets into one space; global-int AG[A], default 0). Per-call local frame with typed banks sized by header F0F5 (local_int[F0], local_float[F1], local_string[F2], …).
  • PC / control flow: build offset→instruction-index map from sys4load instructions (each has .offset = dword index; jump targets are dword indices). jmp t → pc = map[t]. jcc(cond, A, B) → cond truthy ? goto A : goto B, where 0xffffffff = fall through (confirmed model from RECOVER).
  • Operand resolution by type: imm→value; global-int→G[value]; local-int→frame.int[value]; string(2)→decoded string at dword offset; float/global-string/etc. analogous.
  • ⚠ Pointer/lvalue semantics — the key modeling task. RECOVER proves -ptr operands are lvalues: lookup-array(dst_ptr, base, idx) yields a reference to G[base+idx]; mov through a ptr writes to the referenced cell; reading a ptr rvalue dereferences it. Model a ptr slot as holding an address into the global bank; nail this so the RECOVER array-copy produces correct results (unit test it directly).
  • Opcode handlers (~52 named ops):
    • arithmetic/bit add sub mul div mod and or sar shlp1 = p2 ⊙ p3.
    • compares eq ne lt lte gr gre → 0/1.
    • mov (incl. through ptr), lookup-array (p1=mem[base+idx]), lookup-array-2d (p1=mem[base + i*stride + col]), copy-to-global, set-array-to, bit-set/reset, check-bit.
    • control jmp call jcc ret exit exit-script.
    • string set-string concat strlen toString.
    • ADV capture: show-text → append (arg text) to the emitted list; end-text-line, wait-for-input, set-font, comment → capture/skip (no visible state).
  • Markers → no-op (this TESTS the classification): 0x1f4 0x1f5 0x1d5 0x1bc 0x1bf skip; tentative 0x21b 0x1d2 0x258 skip — if dialogue stays correct, the no-op assumption is validated.
  • call-script → STUB: log (id), return immediately. (Its dialogue belongs to other scripts; stubbing keeps the emitted set = this script's own lines.)
  • Effectful (draw/texture/audio/ui/input) → STUB: log and ignore.
  • Unknown/other opcodes → log + no-op, so a rare op doesn't halt the run (record coverage).

Oracle & scene choice

  • Oracle: with calls stubbed and default state, every emitted show-text line must be a real decoded string from the script's pool, and the sequence must be an in-order subsequence of that script's dialogue.jsonl lines (≈ equality for a linear scene). Catches: garbage strings (bad operand/ptr handling), impossible ordering (bad control flow), missing/extra lines.
  • Scene pick: choose a short, mostly-linear ADV scene — high show-text count, low jcc density, few call-script. Selection step: rank SC####/SP#### by (show-text count) / (jcc + call-script count), small size. Known-good fallback: SC0030.BIN (dialogue verified). Also run a RECOVER unit test to validate pointer/array semantics independent of dialogue.

Steps

  1. tools/vm0.py: load a script via sys4load, build offset→index map, frame + global bank.
  2. Implement operand resolution + the arithmetic/compare/mov/lookup/control handlers; unit-test on RECOVER.BIN (array copy + both loops must produce correct global writes).
  3. Add ADV capture + markers-as-noop + call/effectful stubs; add opcode-coverage logging.
  4. Run on the chosen linear scene; diff emitted show-text vs dialogue.jsonl (subsequence check); eyeball the first ~15 lines for coherence.
  5. Iterate until several scenes pass; record which ops/markers were exercised and any surprises (esp. whether the tentative-no-op markers hold).

Success criteria (A0 done)

  • RECOVER unit test passes (pointer/array model correct).
  • ≥3 ADV scenes: emitted show-text is a coherent in-order subsequence of their dialogue.jsonl, no garbage strings.
  • Coverage report of which opcodes actually executed (drives A1/A2 priorities).
  • The no-op-marker assumption is confirmed or corrected with evidence.

A0 result (2026-07-06) — execution model VALIDATED

tools/vm0.py built (reuses sys4load; ~250 lines). Results:

  • RECOVER unit test PASSES — all 7 checks (block-1 3-field copy, block-2 restore + flag, both skip-guards). The pointer/lvalue model, 2D stride indexing, both loops, and two-way jcc all execute correctly. The core execution model is proven.
  • Full SC/SP oracle sweep (vm0.py --sweep): 282 / 294 scenes DIALOGUE-VALID = 95.9%. Every emitted show-text line is checked (by string offset) as an in-order subsequence of the script's static dialogue.jsonl lines. Zero STRAY and zero ORDER violations across all 294 scenes — the model never emits a garbage string and never emits dialogue out of order. 279 CLEAN (valid + natural exit); 3 OK/LOOP (valid subsequence, halted by the loop-guard); 12 EMPTY; 3 skipped (no static show-text). SC0000 = 326 static / clean; SP0062 = 220/220 CLEAN.

A0-remainder work done (2026-07-06, session 2):

  • Loop-guard added (EMIT_CAP=2): halt a run once any single line is re-emitted a 3rd time — a semantic guard tied to the oracle (vs. a blind step limit), and it classifies the scene LOOPED instead of spewing garbage. The 3 zero-state spinners (SC0010/SC0600/SC0200) now terminate cleanly in <12k steps and their emitted lines are all valid.
  • SP0062 "stray" was a measurement artifact, not a bug — the precise offset-based oracle shows it CLEAN (220/220, natural exit). Offset-match ⟹ text-match (VM decodes each string at the same offset the extractor did), so CLEAN is trustworthy.
  • 0x71 (label-def) folded into the no-op marker set — structural, no runtime effect.
  • Sweep + single-scene diff harness added to vm0.py: --sweep [N] (coverage table over all SC/SP), --scene NAME (detailed diff for one script), plus load_oracle/subsequence_status.

op 0x90 investigated in depth — it is input chrome, NOT a correctness hole (full evidence: vm-map/opcodes.toml op 0x90 details). Kelebek left it "ukn"; corpus analysis resolves it: 0x90 x y w h tgt_a tgt_b tgt_c (argc 7) is a cursor/input hotspot hit-test that branches per interaction outcome and falls through to pc+1 when nothing matches (design-confirmed: enc.len 15 lands the next instr on the fall-through statement). It occurs ONLY in a shared ADV-chrome subroutine that is byte-identical in all 301 ADV scripts — exactly 8 sites each (5 immediate-rect buttons at (684..772, 572) toggling G[0x6c9..0x6cd] + 3 local-operand keyed forms), zero scene-specific use. Headless (no cursor/input) ⇒ fall through ⇒ vm0's stub is already correct, proven safe by all 279 CLEAN scenes (which contain these same 8 sites). op 0x97 (argc 5, no targets) is its companion register-hotspot call. So 0x90 stays as fall-through in A1 with confidence; it is modelled as a live hotspot test only in A2 (Godot input backend), confirming target→state mapping via Frida.

The 12 EMPTY scenes — state-gated interactive screens, not a model failure. Traced SC0830: it exits early because G[0xaba5c]==1 gates the content; past that gate the dialogue sits behind the ADV input-wait loop (the hotspot-polling chrome above), so with no seeded state and no input the scene exits or spins before reaching text. Unlocking them = seed per-scene state + supply input → Phase A2/B, not an A0 model fix.

⚠ Honest scope of the 95.9%: the subsequence oracle proves no-garbage / in-order, not a complete path — inherent to a subsequence oracle run headlessly (interactive/state-gated branches take the no-input path by design). That anti-garbage guarantee is exactly what A0 set out to prove.

Confirmed by this run: the classified no-op markers (0x1f4/0x1f5/0x1d5/0x1bc/0x1bf + tentative 0x21b/0x1d2/0x258, now + 0x71) are safe as no-ops for ADV flow; call-script is stubbable; effectful ops (draw-texture/create-texture/play-voice/0x1f7/0x202/0x203/…) stub cleanly.

A0 COMPLETE. Success criteria met: RECOVER unit test green (pointer/array/control-flow model proven); 282 ADV scenes emit clean in-order subsequences with zero garbage; coverage number recorded; no-op-marker assumption confirmed at scale; op 0x90 (the last big control-flow unknown) resolved as input chrome whose fall-through stub is correct headless. Next = A1 — port the model to the C# VM core, differential-test against vm0.py. 0x90/0x97 stay stubbed (correct headless); the interactive input path + per-scene state seeding land in A2 (Godot backend) alongside the real hotspot model.

A1 — Port the validated model to C# DONE (2026-07-06)

Reimplement the A0 execution model as the runtime VM core in C# (the language decision from the roadmap; GDScript is too slow for the loop). A0 is the reference: differential-test C# against the Python prototype's traces on the same scenes. Port the container parser too (or load via a shared spec). Deliverable: headless C# VM reproducing A0's results.

Result: engine/ .NET 8 solution (Age.Engine classlib w/ Model/Vm/Sys4/Hosting seams + Age.Cli + xUnit tests). RECOVER passes; the C# trace is byte-identical to vm0.py --trace across all 297 SC/SP scenes (offsets+halt+steps). (Historical: this parity held while call-script was stubbed; once call-script execution landed [2026-07-07], vm0.py was retired from oracle duty and TraceDiffTests removed — see the call-script EXECUTION section.) Version-neutral Script contract enforced (VM core never references Sys4). Spec/plan: docs/superpowers/{specs,plans}/2026-07-06-a1-csharp-vm*.md.

A2 — Godot ADV backend (one scene, with visuals)

Wire the C# VM's effectful ops to Godot: show-text/message window (+ furigana via display-furigana), set-font, wait-for-input, choices, play-voice/play-bgm, and create-texture/set-texture/ draw-texture/draw-string for the background + sprites. Convert the scene's AGF art with the on-disk AGF2BMP2AGF.exe. Resolve just-enough call-script/state so the scene's setup runs (or hand-set the preconditions). Deliverable: the chosen scene playable in Godot — bg + dialogue + a choice + voice — matching A0's text.

A2a — Interactive dialogue loop DONE (2026-07-06)

Godot 4.7 (.NET, S:/Godot/Godot_v4.7-stable_mono_win64) project in godot/ referencing Age.Engine in-process. VM gained one hook (IHost.WaitForInput, opcode 0x72); suspend/resume via a worker thread + blocking SemaphoreSlim in GodotAdvHost, UI marshalled with CallDeferred. Plays SC0000 page-by-page, pauses at wait-for-input, resumes on click/Enter. Headless self-test (--headless -- --selftest) asserts the emitted 186-line offset sequence == build/vm0-trace.json; A1 engine tests stay 7/7. (Historical: the selftest was later rewritten [2026-07-07] to run a SYNTHESIZED scene through the plumbing and match a live headless run — full call-script handling, no vm0/frozen golden — see the call-script EXECUTION section.) Toolchain: godot --headless --path godot --importdotnet build godot/Himegari.csprojgodot --headless --path godot [-- --selftest]. Spec/plan: docs/superpowers/{specs,plans}/2026-07-06-a2a-godot-dialogue*.md. Next = A2b: background via AGF2BMP2AGF.exe, play-voice/play-bgm, choices → VM globals, just-enough call-script/state (unlocks richer scenes).

A2b-Background — FIRST-PASS RENDER LANDED (2026-07-06)

Resolution solved (docs/asset-resolution-re.md: resId → files[section_base(scene)+resId]) and wired into a live render. Shipped: Age.Engine/Sys4/ResourceMap.cs (loads build/asset-index.json + build/asset-sections.json; Resolve(scene,resId) → AssetEntry; TexturePath → pre-converted BMP); GodotAdvHost implements create/set/draw-texture (slot → TextureRect composited behind the dialogue in a _stage layer); IHost.DrawTexture + VM dispatch extended to pass the destination x/y (draw-texture args 7/8); project.godot window = 800×600; convert_agf.py searches all archives + --scene batch. Engine 8/8, C# --selftest still byte-matches the vm0 trace (VM behaviour unchanged). Works end-to-end: the VM executes set-texture(resId) → ResourceMap resolves across archives → BMP loads → composite; the full-screen event-CG layer (EV052*) renders correctly as the opening plays.

Known first-pass limitations (all one subsystem = graphics geometry/blend, the next chunk):

  1. Only the full-screen layer is correct. Sprites/effects and BG* backgrounds routed through the CG-load subroutine (label_12649) derive width/height/position from native ops we still stub0x208 (get-texture-size) + the sprite position/registration/animation chain — so their dst/size are garbage (backgrounds land off-center, e.g. BG030A dst=(300,300); sizes come out 0x0). Only the immediate full-screen draws ((0,0) 800×600) render right.
  2. No alpha/blend. AE* full-screen fade/flash effects draw opaque and instant (a static grey/white sheet over the CG) instead of alpha-animating. No chromakey either (sprites would show green boxes — moot until they position).
  3. Slot model is an approximation. We use one TextureRect per slot, replace-on-draw. (⚠ Earlier this line claimed "the game blits onto slot 0 as an immediate-mode canvas" — DISPROVEN 2026-07-08: the engine is RETAINED — draw-texture binds a retained object by handle (gfx_object_bind_draw), objects have distinct handles + per-object source slots, composited each frame. See engine-re.md "opening render path is RETAINED".)
  4. AGF is pre-converted to BMP offline (convert_agf.py --scene); a runtime C# AGF decoder is deferred.

Next chunk — graphics-geometry/blend subsystem: implement 0x208 (host returns the slot's real image dims) + the sprite position/registration ops so geometry is correct; add alpha/additive blend for fades + green chromakey; likely move to a proper canvas/blit compositor. Fixes sprites, background placement, and fades together. (Superseded: the id-specific plan in docs/superpowers/plans/2026-07-06-a2b-background.md.)

A2b-Audio — WIRED, plays end-to-end (2026-07-06)

Audio wired, OGG plays natively in Godot (no Frida, no decode/geometry work). KEY FINDING — the two audio ops use DIFFERENT addressing (the initial "unified manifest" assumption was WRONG for BGM):

  • play-voice → per-scene manifest files[base+id], offset 0 (same as set-texture).
  • play-bgmDIRECT LITERAL NAME id → BGM{id:03d}.OGG (DATA3), NOT the manifest.

Shipped: IHost.PlayBgm/PlayVoice; VM dispatch routes play-bgm(0xbf)/play-voice(0xc4) (both argc 1); the three non-Godot hosts (CaptureHost, test RecHost/CountHost) no-op them so --selftest + engine 8/8 stay byte-identical (audio ops still pc+1, step count unchanged); ResourceMap.BgmPathById(id) (direct name) for BGM + ResourceMap.AudioPath(AssetEntry) (manifest Resolve) for voice; Main loads via AudioStreamOggVorbis.LoadFromBuffer into two AudioStreamPlayer nodes (BGM Loop=true; voice Loop=false, interrupt-on-new). Headless run: 0 OGG-load failures, selftest byte-parity OK.

BY-EAR VALIDATED (2026-07-06, systematic-debugging). User confirmed voices play on their lines (play-voice med→HIGH). Two reports root-caused:

  • BGM off-by-one → FIXED (real root cause, resolver changed for BGM only). Real game plays BGM005 for play-bgm 0x5 and BGM008 for 0x8; we mis-played BGM006/009 because we resolved BGM via the manifest (files[5]=BGM006). BGM is actually addressed by direct name BGM{id:03d}.OGG. Proof: play-bgm 0x23 → BGM035.OGG, a real standalone track (the BGM set skips 030-034) that the manifest mis-resolved to a graphics entry (files[35]=EV049AA.AGF). Voices are NOT off-by-one — the manifest interleaves graphics/ voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so id-1 would land voices on .AGF (silent) but they play ⇒ voice offset is exactly 0. So the fix is BGM-specific; voices/textures unchanged. Corrects the earlier "Frida-confirmed play-bgm 5→BGM006" record (a mis-attribution).
  • Lily silent = correct, form-gated (NOT a bug). Her lines use a 3-way dispatch on form flags G[0xa57](A)/G[0xa58](B)/G[0xa59](C): exactly one is 1 in the real game (her current form), else the line jmps past with no voice. Our harness seeds no globals → all zero → every Lily line skipped. Proven by seeding: audio SC0000.BIN 0xa57=1 → 35 LILA clips fire in order (form B→LILB, C→LILC). Left unseeded by user choice (no dummy state); Lily stays silent until real cross-scene state flow (Phase B) exists.

Diagnostic tool added: Age.Cli audio <SCENE.BIN> [0xADDR=VAL ...] — runs a scene and dumps executed play-bgm/play-voice ops in order (BGM direct-name, voice manifest), optional global seeding. Used for all of the above. Watch-items: BGM looping is whole-file for now (Eushully OGGs may carry LOOPSTART/ LOOPLENGTH Vorbis comments — refine later); play-sound-effect(0xb4, argc 2) left stubbed (arg roles unconfirmed).

A2b-Geometry — 0x208 keystone + blit compositor (2026-07-06)

Spec/plan: docs/superpowers/{specs,plans}/2026-07-06-a2b-graphics-geometry*.md. Shipped & verified: the CG-load subroutine (SC0000.asm label_12649) computes all sprite/background geometry in bytecode (add/sub/div/lookup-array); the only missing native primitive was 0x208 = get-texture-size(slot) → (out_w, out_h). Implemented as a real VM op (IHost.GetTextureSize, writes the two output globals); non-Godot hosts return (0,0) so trace/selftest parity holds (engine 11/11, --selftest byte-identical). Replaced the TextureRect-per-slot approximation with a faithful 800×600 immediate-mode blit compositor (Main.BlitSlot: _screen.BlitRect(src rect → dst) in execution order, one displayed TextureRect; source dims read from the pre-converted BMP header on the VM thread via BmpHeader.ReadDims, so the bytecode's geometry math sees real sizes synchronously). New diagnostics: Age.Cli gfx <SCENE> (headless numeric oracle — dumps per-draw resolved file + computed geometry) and godot … -- --shot <png> [--shot-page N] (page-gated screenshot capture). The opening event-CG sequence renders correctly — full-screen CG at (0,0) with dialogue over it (verified by screenshot, SC0000 pages 1/3).

Slot-0 seed (bug found & fixed via the gfx oracle + user eyeball): slot 0 is the primary/screen surface (800×600), normally created by engine-boot init the single-scene harness skips. Cold, 0x208 measured 0×0, and the anchor-preserve math (base' = center (w_new/2, h_new)) then wrote a corrupted (400,600) into the persistent base globals — so the first CG was grey and CG2 inherited the corruption. Fix: seed _slotDims[0] = (800,600) (and record create-texture(w,h) dims) so the first CG's anchor stays an identity. This is the faithful stand-in for the skipped boot-time primary-surface creation.

Post-opening bg/sprite drift — RESOLVED (2026-07-07): native gfx ops + missing INIT2 boot state. ⚠ Corrects an earlier wrong "state-divergence-only" verdict here. Symptom (screenshot Screenshot 2026-07-06 211353.png): everything blits through slot 0 as an immediate-mode canvas; the anchor-preserve base globals accumulate drift across differently-sized textures (BG030A→(300,500), next→(450,100), →(800,350)… marching bottom-right; the background ends up pinned off-centre / bottom-right with the rest of the screen grey). Root cause = the stubbed native op 0x215 collapsing every draw onto slot 0 (its return drives label_12649's slot-select).

The canonical decode + verdict now lives in docs/engine-re.md (op 0x215 section) — don't duplicate it here. In brief: 0x215's real handler FUN_0042a0b0 (Ghidra) writes cmd-type 5 into the current gfx-object record and returns a std::map::find over an engine-internal command-buffer registry (populated by sibling gfx ops like 0x1a2). That return is native command-buffer state, not the VM global bank → seeding story-state cannot fix it. So this is (b) a genuine native op, not (a) the Phase-B state-divergence problem. The prior conclusion in this doc — grounded in a 2/s capture_gfx_objects.py poll of the object-record array — was wrong: it observed the wrong structure (not the lookup map) and can't rule out transient records.

Resolution had TWO halves (canonical decode in docs/engine-re.md, op 0x215 + "The render drift's SECOND half"; don't duplicate here):

  1. Native gfx ops (b): all 14 command-buffer ops (0x1a2,0x1f7,0x1fa,0x1ff,0x202,0x203, 0x212,0x213,0x2150x21a) reversed + implemented against a host-side GfxState (VM execution state; engine/Age.Engine/Model/GfxState.cs). 0x215 now returns distinct per-object slots.
  2. Missing system-boot state (a): the CG handle array G[0x62455..] is set by boot script INIT2 (via entrypoint SYSTEM4.BIN), which a cold single-scene run skips → all CGs collapsed onto object 0. Supplied via Age.Cli gfx --boot and Godot --boot (run INITCONFIG/INIT2/INIT through GameSession first). So the drift needed BOTH — not story flags, and not native-ops-alone.

Result: with --boot, the opening event CGs render correctly — screenshot-verified live in Godot (--path godot -- --boot; the CGs that were entirely missing now fill the frame). Residual (deferred, not regressions): AE* fade/flash effects draw opaque (alpha/blend deferred — a white "explosion" glow that should fade stays); some object-slot CGs start with a zero anchor (cold gfx objects vs the real game's warm ones — default object geometry is confirmed (0,0) in gfx_object_init_default, so not a missing default). Next visual chunk = alpha/blend + effect fading (0x202/0x203 already store the packed color) + per-frame compositing. NOTE the two-boot gap: our Phase-B --boot runs data *INIT scripts; this added the system boot — a "full boot" should run both.

A2b — Scene completeness gauge (opcode coverage tracker, 2026-07-07)

To stop guessing how "done" a rendered scene is, tools/scene_opcode_coverage.py histograms a scene's static opcodes and classifies each against the C# VM: impl (VM has a handler arm — real or a deliberate no-op like set-font), safe-noop (no arm, but opcodes.toml marks it noop_headless — a statement / block marker, correct to skip), or GAP (no arm and effectful → the VM silently pc+1s past it). The implemented set is parsed from VirtualMachine.cs's case arms (single source of truth, no drift); output is build/scene-opcode-coverage/<SCENE>.md. This makes a half-rendered scene legible: "N ops still stubbed", not "something's wrong and we thought everything ran."

SC0000 baseline: 129 distinct opcodes / 16257 instrs. Instruction-weighted the VM already covers ~94.8% (impl 12368 + safe-noop 3053); the holes are 68 GAP opcodes / 836 instrs (5.1%). The GAP list clusters into concrete backlog buckets (drives the rendering roadmap below):

  • ADV on-screen textdraw-string(0x204)×205 + 0x7a text-param×205 (1:1 paired). Dialogue text is currently surfaced via IHost.ShowText → Godot Label; the engine's native glyph/window draw path is unmodeled (cosmetic for now, but it owns text layout/speed).
  • Unmodeled gfx-range cluster0x21c0x243 + 0x2bd/0x2bf (e.g. 0x220×66, 0x22f×34, 0x228×33, 0x21e×25): siblings of the 0x2120x21a command-buffer family we implemented, not yet reversed → the biggest single rendering unknown (likely sprite/effect/blend geometry). RE these next before more compositor work.
  • Timingsleep(0xc8)×20: animation pacing; fades/effects can't animate (only snap) until this exists.
  • Audio/SFXplay-sound-effect(0xb4)×24 + 0xb5/0xb6/0xc2/0xd9 (channel/volume/stop control) — stubbed.
  • Scene coroutine0x7b×6 / 0x7c×2 / 0x140×1: the scene-coroutine framework backlog (multi-object scene setup routes through it; see the "SECOND latent gap" note in the status memory).
  • Misc VM-support ops — a long tail (0x75-0x77, 0x85/0x88/0x8b, 0x93/0x94, 0x197-0x1a4, 0x1c7-0x1cf, 0x1fd, 0x20a/0x20c/0x20e, …), 12 sites each; mixed markers vs effectful — triage per-op as the VM reaches them.

Re-run per scene (scene_opcode_coverage.py SC0240 …) to gauge any target. The tracker also cross-checks opcodes.toml metadata against VM behavior — it already surfaced 0x259 (script-entry marker) missing its noop_headless flag (now reconciled).

A2b — Sprite transform/animation subsystem, opening slice (2026-07-07)

Spec docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md; plan docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md; RE in docs/engine-re.md ("0x21c0x243 sprite transform / ANIMATION cluster"). First slice of the 0x21c0x243 cluster the completeness gauge flagged.

Implemented (VM records, engine 50/50, full parity — sweep 284 exit/13 STEP-LIMIT unchanged): the four opening-path anim ops — 0x21e, 0x220, 0x234, and 0x238 — were first retained in the port here. Superseded by the 2026-07-10 matrix slice below: the original shared-target interpretation was wrong; 0x21e is scale, 0x220 is translation, and 0x234 is cyclic rotation.

Historical compositor: this slice temporarily used transform Z as alpha. The 2026-07-10 follow-up removes that approximation and applies the native separate scale/translation channels instead.

Tracker delta (scene_opcode_coverage.py SC0000): GAP 68→64 ops (836→741 instrs), impl 49→53, correctly-handled 60→65/129 (50.4%).

⚠ Honest scope — the opening AE* explosion does NOT visibly animate from this chunk. The opening's AE* effect (AE001D → AE002B → AE003B) is a sleep-paced sequence of RETAINED-object loads/draws — verified 2026-07-08 from native code + the raw bytecode (NOT our gfx oracle, which mis-reported these as "slot 0"; see engine-re.md "opening render path is RETAINED"). draw-texture binds a retained object by handle (gfx_object_bind_draw@0x47e870); the SC0000 CG loader supplies handle = CG_array[G[0x62450]] (the INIT2 handle array G[0x62455..]) and a per-object working slot G[0x62452], with sleep (0x64/0x3e8/0x2ee) between steps. Our VM executes the whole load/draw/sleep burst instantly (no timing, no per-frame present), so we only ever see the final retained state; the intermediate AE* frames never get a frame to display. This retained-object channel was the correct architectural seam, but the opening explosion needs frame-pacing — modeling the scene-coroutine / sleep 0xc8 timing (0x7b/0x140/0xc8, still GAP) so the burst is not collapsed. That is the clearly-scoped next chunk for the visible opening animation, and a genuinely different subsystem than the transform/alpha channel landed here. (Correction: an earlier draft of this note called it "immediate-mode slot-0 blits" — wrong; the engine is retained, per the native draw-texture → gfx_object_bind_draw bind.)

A2b — Frame-paced sleep (2026-07-08) — ⚠ did NOT make the opening animate (corrected)

The chunk the animation-subsystem note above flagged as "the clearly-scoped next chunk." Spec/plan docs/superpowers/{specs,plans}/2026-07-08-frame-paced-sleep{-design,}.md; RE docs/engine-re.md ("sleep (op 0xc8)").

⚠ CORRECTION (2026-07-08). The original heading here ("the opening animates ") and the "Verified visually" claim below were WRONG — a misread. The sleep op is correctly decoded + implemented and the one-shot dramatic pauses now work, but the rapid opening CG/AE* burst is NOT sleep-paced and did not start animating. Execution trace (via the new --trace-histogram) shows the back-to-back set-texture→draw-texture swaps run with no sleep/wait/present/coroutine between them; what actually paces them is still unknown. The --shot-sequence frames I read (arcane → Lily → maid → sky) were the game holding on key CGs via the sparse one-shot sleeps (slowed further by per-frame PNG-IO), which I mistook for the burst stepping. How the mistake happened: I inherited "the opening is sleep-paced" from this repo's own docs and treated it as verified instead of tracing execution first. What IS solid: the sleep seam, the one-shot pauses, the GfxState race fix, and the tooling. Related: the "493k sleeps" that confused me were a headless artifact (the name-entry poll loop), since fixed — see the "Headless divergence" note below.

Later resolution (2026-07-10): the matching native presentation trace below established that ordinary mutations run burst-fast and first become visible at 0x20c, 0x21c, sleep, or stable input boundaries.

What sleep actually is (RE-confirmed, correct): the Godot compositor (Main.Recomposite in _Process) presents live GfxState every frame; sleep (0xc8) was a GAP so the VM ran the whole burst in microseconds. Implementing it makes the explicit one-shot sleeps (1000/750/200 ms) pause correctly — but those are the dramatic holds, not the rapid burst's pacer.

RE (Ghidra): sleep_op_0xc8@0x420ec0 is non-blocking — it arms a main-loop-polled timer (sleep_timer_arm@0x44cff0; start = ms tick, duration = operand). Operand unit = milliseconds. (Also carries anti-tamper + a gfx cmd-type-3 write, neither needed host-side.) 0x20c = gfx_op_0x20c_present_frame → host-implicit (our compositor presents continuously) → noop_headless.

Implemented: IHost.Sleep(long) + VM case "sleep" forwarding the raw operand; the 9 non-Godot hosts no-op it → headless/CLI parity held (sweep unchanged 284 exit/13 STEP-LIMIT, emitted offsets/steps identical, selftest OK). GodotAdvHost.Sleep blocks the VM background thread duration ms (capped 10s) — the WaitForInput suspend pattern, time-based — so the main-thread compositor presents each intermediate frame. Behaviorally equivalent to the native non-blocking timer given our threading model.

Race closed: once the VM runs concurrently for seconds, the main-thread SnapshotVisibleObjects truly overlaps VM-thread _objects/_registry writes. GetOrCreate/Register/Release were unlocked → serialized them on the existing (re-entrant) _lock. New GfxStateConcurrencyTests (deterministic repro of the "Destination array is not long enough" crash) + SleepDispatchTests; engine 52/52.

New tool --shot-sequence <dir> [--frames N] (one PNG per frame, auto-advancing past input waits — a time-based effect can't be captured by a single --shot). The frames it produced showed the game holding on distinct CGs (arcane AE* → Lily → maid → sky) — but per the correction above, those holds are the sparse one-shot sleeps, not the rapid burst stepping. (Residual, unrelated: intermediate frames show the cold-object anchor doubling — a geometry issue independent of timing.)

Tracker delta (scene_opcode_coverage.py SC0000): GAP 64→62 ops (741→714 instrs), impl 53→54 (sleep), safe-noop 12→13 (present-frame), correctly-handled 65→67/129 (51.9%).

Open (the real burst pacer): what advances the rapid opening CG/AE* burst frame-to-frame is unknown — not sleep, not present-frame (only 2× in the whole scene), not the coroutine ops (absent from the burst). Next: profile the real Godot run (--trace-histogram) of SC0000's 0x39580x3973 loop + gfx-op sequence. The scene-coroutine framework was deferred here; the bounded host model is completed below (2026-07-09).

Diagnostics framework extended (2026-07-08)

Motivated by the misread above (a --trace-steps dump was 2.5M lines → grep/awk). Added, all observe-only (parity preserved): HistogramTraceSink (op + call-site script:pc execution counts + sample operand), TraceSinkBase (per-script step attribution across call-script frames), TextTraceSink op-filter (--trace-ops), CompositeTraceSink, OpcodeTable.ByLabel; CLI --trace-histogram/--trace-ops; Godot --trace-histogram <file> (profiles the REAL run) + --sleep-scale. See docs/tools-reference.md.

Headless divergence FIXED — faithful halt-at-wait (2026-07-08)

The histogram pinned the goose-chase root cause: op 0x72 wait-for-input was a no-op headless, so a run plowed past all 166 of a scene's prompts into the name-entry poll loop (INPUTNAME.BIN) and spun sleep 1 493,182× to STEP-LIMIT — a path no real playthrough reaches. Fix = VmOptions.HaltAtWaitForInput: the VM halts (reason wait-for-input) at 0x72. run/play faithful by default (SC0000 → ~402 steps / 0 sleeps, matching the real path to the first prompt; --plow = old walk-every-page); sweep plow by default (dialogue oracle, 284/13 unchanged) with --halt-at-wait → all 297 scenes halt cleanly (0 STEP-LIMIT). Godot unaffected (really blocks on input). The corpus's 13 STEP-LIMIT scenes were all this artifact, not VM bugs. HaltAtWaitTests.


Risks / open questions for A0

  • Pointer/lvalue semantics — the main modeling risk; RECOVER is the litmus test.
  • Initial global state — a scene may assume preconditions from earlier flow (SCJUMP/prior scenes). Mitigation: default-zero globals + set the few a scene reads early; the subsequence oracle tolerates a shortened path.
  • Runtime vs static dialogue order — static dialogue.jsonl is file-order (all lines); runtime is execution-order (branch taken). Hence subsequence, not equality; pick linear scenes to tighten it.
  • Hidden effect in a "stub" — a stubbed effectful op that actually gates control flow could skew output. Watch for divergence; promote a stub to a real handler if a scene needs it.

Immediate next action

Build tools/vm0.py and get the RECOVER unit test green (pointer/array/control-flow correctness), then run the first linear ADV scene against the dialogue oracle. That single result tells us whether the whole VM approach executes correctly — the load-bearing question behind option 3.


call-script EXECUTION (2026-07-07) — subroutines now run in the C# VM

Spec docs/superpowers/specs/2026-07-07-callscript-vm-execution-design.md; plan docs/superpowers/plans/2026-07-07-callscript-vm-execution.md. Enabled by the native-RE finding that call-script <id> is a raw SYS4INI file index (docs/engine-re.md).

Shipped (engine 25/25 green):

  • IScriptProvider (Hosting) + Sys4ScriptProvider (Sys4): id → build/callscript-names.json → name → Paths.Scripts() → Sys4Loader.Load, cached. Injected into the VM so Vm never references Sys4.
  • ExecFrame refactor of VirtualMachine: per-script state (script, pc, locals, intra-call stack, per-frame emit-guard) moved into ExecFrame, run by a recursive RunFrame. Globals/Emitted/Steps stay VM-level (shared). Emitted lines now carry their source script name.
  • call-script executes: loads the child, runs it as a nested frame sharing globals, returns to the caller at pc+1. exit/exit-script and empty-stack ret return from the frame (top frame → HALT). Depth-capped (VmOptions.CallDepthCap=64; native limit 38). Shared globals are the return channel; per-call locals are discarded on return.
  • Product paths (Age.Cli run/play/sweep, GameSession.RunScene) inject the provider. trace + the audio/gfx diagnostics stay provider-less (base-ISA oracle / built against stub behavior).

Design decision (refines the spec): a VM with no provider falls back to the prior stub (host.CallScript(id); pc+1), not a halt. This keeps every existing base-ISA test byte-identical (they construct provider-less VMs) and needs no golden-fixture regeneration; vm0.py retires from oracle duty gracefullytrace/TraceDiffTests/WaitForInputTests stay on the stub path as the base-ISA guard, with zero lockstep maintenance.

Validation: 6 new tests (fake-provider unit tests: return-to-caller, callee exit returns not halts, shared-global visibility, per-frame local isolation, unresolved-id halt, no-provider stub; integration: ADDILL executes ADDILLSUB+CALCREVISE and reaches its own exit; BUNKI's top-level ret returns cleanly). Corpus sweep (execution on): 284/297 exit clean, 13 STEP-LIMIT, 0 depth-cap, 0 unresolved, 0 crashes. The 13 STEP-LIMITs are input/state-gated ADV scenes (SC0000 etc.): executing subroutines makes their global-writes drive caller loops that headless can't break (no input; WaitForInput is a no-op) — the known state-divergence, not a call-script bug (loops hit STEP-LIMIT, not the depth cap → recursion is bounded correctly).

Godot wired + testing approach corrected (2026-07-07, follow-up). The provider is now injected everywhere — no path runs with call-script disabled:

  • Godot play path gets Sys4ScriptProvider, so subroutines execute live on screen (and the headless input-wait loops break on real player input, which is why headless-only scenes STEP-LIMIT).
  • Testing principle (user-directed): synthesize test data; never disable a feature to keep a real scene matching a frozen number. New Age.Engine/Sys4/ScriptAssembler (code+strings → Script; also Phase-D modding-assembler groundwork). SyntheticSceneTests runs an assembled scene (show-text
    • wait-for-input + real nested call-script + shared-global return) with full handling and asserts its exact output. WaitForInputTests reworked onto a synthesized two-page scene. RecoverTests keeps its ISA-litmus role with call-script handling ON via a no-op subroutine double (isolates the ISA from the real subroutines' state deps). TraceDiffTests retired (it matched the C# VM to vm0.py's stubbed trace; vm0 is off oracle duty and we don't gate handling to keep it matching).
  • Godot --selftest rewritten: was "run SC0000 stubbed, match vm0-trace(186)"; now runs a synthesized scene through the Godot thread/semaphore/CallDeferred plumbing and asserts it matches a live headless run of the same scene — full handling, no frozen golden, no vm0 dependency. Verified: godot --headless -- --selftest → "threaded host matches headless (3 lines, full handling)".

Verified live in Godot (2026-07-07): added --scene <NAME> to the frontend and a scene-end report of the call-scripts executed as nested frames (collected thread-safely — Godot drops GD.Print from the VM background thread). SC0240 executes 29 call-scripts (RESETLAND, SETEN, ADDEN, RENDERMAP, SETOBJ, DRAWOBJ, CALCREVISE, LOOK) live in the real runtime; SC0000 renders the opening event CG (windowed).

Remaining follow-ups: optionally give the audio/gfx CLI diagnostics a provider (they still run provider-less); engine-level diagnostics (the next pivot — the engine, not the frontend, should surface script/scene execution + call-script dispatch); decision→scene (scene chaining) rides this same loader once the SCJUMP decision→scene-id native hop is reversed.

A2b — opening speed-through: the pacing lead is ENGINE CADENCE (2026-07-08)

Correction. An earlier draft of this section claimed "there is no missing pacer — all opening pacing primitives are already shipped." That was retracted: it was inferred from headless op-counts (which cannot render) and it contradicts the direct eyes-on observation that the opening visibly speeds through. Ground truth = it speeds through; the pacer is real and the cause is still open. What survives below is only the mechanically-verified part plus the corrected hypothesis.

Verified (mechanical).

  1. The "rapid burst" seen in plow traces is a headless fiction (input-plow). The real back-to-back set-texture→draw-texture swaps are the per-page compositor label_1235a (instruction indices 0x39580x3973 = dword 0x123e80x12497): a loop over ≤8 gfx object slots that per slot queries state (0x215), erases/releases (0x1f7/0x1fa), and draws with alpha (0x203).
  2. Op 0xcd get-input-type is unmodeled in the VM (no case; only 0x72 wait-for-input is handled — two input mechanisms, one modeled). INPUTNAME.BIN:0x1c1 name-entry is a get-input-type→jcc→sleep 1→jmp poll that spins unfed. This is a real but separate gap (interactive blocker), not the speed-through.

The corrected hypothesis (engine cadence, not bytecode). Many opening draws are back-to-back with no sleep between them in the bytecode (e.g. resId 0x29 twice at 0xc45/0xc52; 0x340x35 at 0x1467/0x1479), yet the real game paces them. So the pace comes from the engine's execution cadence, not a script primitive. Corroboration: holding Ctrl fast-forwards ADV in the real game (faster, not instant) — a global engine speed governor over interpreter advancement. Suspected cause in our port: the Godot VM runs on a free-running background thread that is not synced to the 60fps compositor, so it blasts an entire page's draws in microseconds and the main-thread Recomposite only ever samples the final gfx-state → the intermediate CGs collapse. The native engine is a cooperatively-scheduled main loop where the interpreter yields per frame under vsync (consistent with sleep already being RE'd as a non-blocking, main-loop-polled timer — which only makes sense if the interpreter yields back to that loop).

Next (open). (1) Determine what actually landed re 60fps/vsync and the VM threading model in godot/. (2) RE the native engine main loop in Ghidra: how it ticks the interpreter per frame, the frame cap/vsync, and the Ctrl speed governor. (3) Frame-lock our VM to the render loop (a per-frame step budget or a per-frame yield/sync point) instead of the free-running thread. Confirm any fix against pixels (windowed --shot-sequence → real PNGs), not op-counts.

A2b — Frame-stepped VM DONE & MERGED (2026-07-08)

Historical implementation, superseded on 2026-07-10 by the native retained-presentation trace below. The validation recorded here was accurate for that slice, but the inferred native per-op cadence was not: service waits had been folded into an operand-fetch average. Commit 85fc07d removed the production opcode throttle; ordinary work now runs burst-fast to proven publication/pacing boundaries.

Executed the "Next (open)" list above (option 3: a per-frame yield/sync point). Spec docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md, plan docs/superpowers/plans/2026-07-08-frame-stepped-vm.md; merged to main (74a4221).

What landed (3 TDD tasks):

  1. engine/Age.Engine/Hosting/FrameClock.cs — pure, threadless virtual clock: NowMs, Speed (field, 1.0), OpsPerFrame (30), Advance(realΔseconds), EffectiveBudget (= OpsPerFrame*Speed, min 1).
  2. IHost.FrameYield() — the VM (VirtualMachine.RunFrame) calls it once per executed opcode (right after Step). No-op in all 9 headless hosts (CaptureHost, the CLI trace hosts, every test host) → headless output byte-identical. This is the parity guarantee.
  3. godot/GodotAdvHost.cs + godot/Main.cs — the throttle. Main owns a FrameClock; each _Process it Advance(delta)s the clock and PulseFrame()s. GodotAdvHost.FrameYield() counts ops and, once EffectiveBudget is reached, blocks the VM background thread on an AutoResetEvent until the next _Process advances the clock — throttling the interpreter to ≈OpsPerFrame ops per rendered frame (≈30 → ≈1800 ops/sec, the native rate-limited cadence). Sleep now waits on the same clock (not Thread.Sleep), and the anim tween reads _lastDelta * _clock.Speed, so the single Speed factor scales throttle + sleep + tween coherently. Speed is the future Ctrl fast-forward hook, left unwired at 1.0 (wiring it needs ADV-mode-scope RE; the seam is ready).

Verified. Engine 62/62; sweep exit=284/STEP-LIMIT=13 (parity); Godot SELFTEST OK; windowed --boot --shot-sequence = 16 distinct paced visual states across 120 frames (event-CG → fade/transition → onward) instead of an instant jump to the final frame — the throttle demonstrably steps the opening over real time.

⚠ Residual (NOT this slice — the graphics geometry/blend subsystem). By-eye the opening is still hard to judge because the graphics are still wrong (AE* fades draw opaque with no alpha/blend; cold-object anchors double; multi-surface compositing approximate). Pacing is fixed and mechanically confirmed against pixels, but visual correctness is a separate open chunk the user has deferred. Do not conflate "pacing fixed" with "opening looks right."

Next candidates (deferred). (a) Graphics geometry/blend fidelity — AE* alpha/blend + per-frame compositing + cold-object anchors (the thing that makes the paced opening actually look right). (b) Wire the Ctrl Speed multiplier (ADV-mode-scope RE). (c) Full scene-coroutine framework (0x7b/0x7c/0x140) for interactive multi-object scenes (completed below, 2026-07-09). (d) Model 0xcd get-input-type (name-entry interactivity, the separate input gap noted above).

A2b — Blend & transparency (slice A) DONE (2026-07-08)

First of three graphics-fidelity slices (A blend/transparency, B geometry/anchors, C render-targets). Spec docs/superpowers/specs/2026-07-08-blend-transparency-design.md, plan docs/superpowers/plans/2026-07-08-blend-transparency.md, branch feat/blend-transparency (engine 69/69, sweep exit=284/STEP-LIMIT=13 parity, SELFTEST OK).

Landed (hybrid: engine resolves, host blits):

  • Age.Engine/Model/BlendMath.cs — pure colorkey match + ARGB unpack (colorkey format reversed: op arg 3 <0 = none, else 0xRRGGBB exact-match, 0 = key black; baked at surface-load, see engine-re.md §Blend).
  • RenderObject gains Alpha/Tint/Blend (BlendKind Opaque|Alpha|Additive); GfxObject.HasColor; GfxState.SetObjectColor; SnapshotVisibleObjects resolves them. 0x202/0x203 route through SetObjectColor (the stale "alpha deferred" trace stub is gone — alpha is now consumed).
  • Godot compositor: colorkey-baked image cache (keyed by (path, colorKey)), BlitLayer applies colorkey + object alpha + RGB-tint modulate, and surfaceless colored objects fill a tint×alpha quad (the fades) instead of being skipped.

Result (pixels): page-1 event-CG composites cleanly (dialogue + prompt, no opaque boxes); the opening's mid-fade frames now alpha-blend (dark bg + light-ray burst, then a blended dark transition) instead of the pre-change full-screen opaque grey wall that ate the CG. Verified via --shot/--shot-sequence on SC0000 --boot.

Deferred (documented, NOT built — confirmed by a stalled interp RE pass, engine-re.md §Blend): smooth color-animation interpolation (the fade ramps snap to the correct end-state rather than gliding — the 0x202 color channel's blit consumer + clock coupling is a dedicated dig) and additive/glow blend (local_2c mode 2/3; its object field isn't pinned). BlendKind.Additive is an unused seam. Next graphics slices unchanged: B (geometry/anchors — sprite placement) and C (render-targets).

A2b — SC0000 anim/transform/spritesheet cluster (partial) DONE (2026-07-08)

The 0x21c0x243 gfx cluster, scoped to a tractable subset after Task-1 RE revealed it's heterogeneous (setters + queries + matrix/scale + a movie op). Spec docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md, plan .../plans/2026-07-08-sc0000-anim-transform-cluster.md, branch feat/anim-transform-cluster (engine 79/79, sweep exit=284/STEP-LIMIT=13 parity, SELFTEST OK, coverage SC0000 67→74/129 handled).

Key RE unlock: the dispatch table handler(op)=ctx[0x26c93+op] recovered statically from FUN_00413860 (the opcodes.toml u004xxxx labels are Kelebek drift). Full op→field map in engine-re.md §SC0000 anim cluster.

Built (hybrid: engine resolves, host blits):

  • GfxObject gains src-rect (spritesheet) + animated-color channels; GfxState.SetSrcRect / SetColorAnim; BlendMath.PingPong; SnapshotVisibleObjects(long nowMs) = a port of gfx_object_anim_interpolate (looping row-major spritesheet cells + ping-pong color/glow), driven by the FrameClock (the "mach 5" pacing fix).
  • Wired 7 ops: 0x22f/0x229 position (direct V24 set), 0x239/0x231 spritesheet (static cell / animate), 0x232 color glow (ping-pong), 0x228/0x23f queries (geometry back to script vars).

Deferred (own follow-ups, per scope decision): 0x21f/0x223 matrix/scale (need affine rendering), 0x236 timed/movie op, and the rare 0x21c/0x21d/0x224/0x242/0x243/0x23d/0x20a/0x20e tail. Adjacent non-cluster gaps remain: draw-string 0x204×205 (on-screen text) and play-sound-effect. Whole-scene visual validation is the user's call (they deferred confirmation until the scene is coherent).

A2b -- Scene-coroutine host model (2026-07-09) -- DONE

The native mechanism is fully reversed in docs/engine-re.md under Scene-coroutine framework. The port deliberately models its observable ADV lifecycle instead of emulating the runtime-resolved video service behind op 0x140.

Bounded model for this slice:

  1. Detect only the corpus-wide ADV form 0x140 out "LABEL" "J" in. TITLE.BIN's unrelated "BIN" "SC????.BIN" use remains unmodeled and must not acquire ADV scene-entry behavior.
  2. On a top-level entry at offset zero, synthesize native scheduler state G[0xaba5c]=1 for a script containing that ADV form. A captured global-write snapshot cannot supply it because the native scene loader does not set it through the script operand-write helper.
  3. At each ADV labeled-yield site, force exactly one setup-body iteration, then return the terminal value encoded by the site's following mov terminal, immediate + eq terminal, out sequence. This handles a stale prior-scene out value and avoids hardcoding SC0000's 0x45e; all 138 corpus ADV sites share the same shape.
  4. Record op 0x7b's two saved handler PCs as frame metadata. Consume op 0x7c as the host-scheduler resume marker: the host supplies service-boundary suspension and retained presentation, so it does not recursively execute the native render/poll/yield bytecode handlers.

Acceptance gates: a synthetic stale-terminal scene runs its setup body once and reaches content; a non-ADV 0x140 remains unchanged/stub-reported; real SC0000 executes op 0x140 twice, clears G[0xaba5c], and fills the slot-table columns (G[0x3239..0x324e] = 4..11) before content. Then run the engine suite, corpus sweep, Godot self-test, and live/pixel validation of the previously grey multi-layer page.

Result. The bounded model landed in VirtualMachine/ExecFrame with four focused tests. SC0000 now executes 0x140 twice, runs label_125bd once, clears the native entry gate, and loads resource 0x23 into assigned slot 5 rather than the broken slot 0. Static corpus validation found 138 ADV sites and zero shape mismatches; the one non-ADV TITLE.BIN site remains stub-reported.

Verified: engine 85/85; full sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot threaded self-test SELFTEST OK (3 lines, full handling). The native transition timing remains host-approximated. SC0000 coverage is now 77/129 handled (59.7%), with 52 GAP ops / 613 GAP instructions.

Follow-up — magic-circle teardown fixed (2026-07-09). Ghidra caller analysis corrected op 0x215: it queries the retained gfx-object map and returns obj+4, the source surface slot written by draw-texture; it does not query op 0x1a2's descriptor hash. The old host model returned -1 for CG handles, so SC0000 skipped its explicit 0x1f7(handle,10) + 0x1fa(slot) cleanup and left AE001H.AGF (resource 0x37) visible. GfxState.QuerySlot now returns the bound source slot, 0x1f7 erases retained objects, and 0x1fa clears the surface. A booted SC0000 integration regression asserts no visible resource 0x37 remains; engine suite 86/86. Live clicked-path validation confirmed the fix on 2026-07-10: the magic circle now disappears at the intended transition.

A2b — native scale/translation channel split IMPLEMENTED; exact visual math provisional (2026-07-10)

Replaced the legacy shared AnimTarget / transform-Z-as-opacity approximation with the native channel split proven in Ghidra. Op 0x21e now owns normalized scale (100 = identity; current obj+0x6c, target obj+0xac, delay/duration obj+0x3c/+0x50). Op 0x220 owns absolute translation (current obj+0x16c, target obj+0x1ac, delay/duration obj+0x44/+0x58). Consumer gfx_object_apply_transform_channels (0x472f00) establishes a shared first-frame start timestamp, independent delayed linear interpolation, and target commit on completion.

GfxState samples both channels from FrameClock; Godot applies scale around V18 plus independent translation, including nearest-neighbor scaling/flips in the software blitter. Op 0x234 no longer overwrites either matrix: the completed consumer RE corrected it to a separate cyclic rotation period+axis channel (retained now; affine rendering deferred). No transform Z value contributes to opacity.

Focused validation: six matrix/rotation tests cover channel independence, VM dispatch, shared-start delay/duration sampling, target commit, and non-opacity Z values. Engine 86/86 and Godot build pass. Booted SC0000 with --shot-sequence + --gfx-log: 180/180 PNGs, 152 log lines, and zero unresolved/error/NaN/Infinity outcomes. The visible circle expands around its anchor through sampled scales 1.00 → 1.22 → 1.44 → 1.66 while remaining op=1.00, then the retained object is logged GONE. This validates removal of the transform-Z opacity shortcut and shows the scale channel affecting geometry in the capture. It does not prove the port's exact matrix calculation, anchor interpretation, multiplication order, or 2D projection: normal playback still races past these sections too quickly for a reliable visual judgment. Treat that math as provisional until the pacing slice enables slow normal playback and a native-versus-port frame comparison.

A2b — animation pacing + matrix validation (2026-07-10; pacing model superseded)

This slice initially interpreted roughly 1,788 vm_operand_fetch calls/s as evidence for a refresh-independent 200 completed-opcode/s allowance. That scheduler interpretation is superseded: the probe counted operand reads and mixed burst execution with time parked in native services. The matching presentation trace below proves ordinary work is burst-fast; --speed now scales sleeps and retained presentation clocks, not opcode throughput. Input remains ignored unless the VM is actually at wait-for-input.

Live native capture recorded the complete 0xcbc0 scale ramp (1→5 over 1,890 ms), including the exact composed matrices. The 215/s and 200/s port replays were useful historical diagnostics showing that object lifetime had been coupled incorrectly to script progress, but those rates are not native scheduler constants. Native matrix terms and the port's focused tests agree on row-vector anchor + (point-anchor)*scale + translation; for base (0,600), anchor (400,1000), scale 5, both project to (-1600,-1000). Exact axis-aligned anchor/order/projection is validated; cyclic-rotation rasterization remains the next affine-rendering slice.

Verification: engine 92/92 after the opcode-clock reset test, corpus sweep unchanged at 284 exit / 13 STEP-LIMIT, Godot build and threaded SELFTEST OK. The transform capture tool and transform-aware --gfx-log are documented in docs/tools-reference.md.

A2b — ADV transition/lifecycle diagnosis plan COMPLETED BELOW (2026-07-10)

This is the next SC0000 correctness slice. It is driven by live A/B observations, not by static opcode coverage alone. The direct SC0000 histogram is currently 80/129 distinct ops handled (62.0%) and 96.2% instruction-weighted, but a rare query, scheduler op, or render worker can still control an entire visible section. Coverage also excludes called scripts; this slice follows only callees actually entered on the failing path rather than expanding into blanket subscript completion.

Observed native ADV contract (client behavior): foreground presentation changes are transitions even when they are only in-place fades. SC0000 begins black and fades into the first CG; the message window then fades in before text reveals. During a CG swap, the window transitions out, one or more CG transitions run, then the window transitions back in. A click during an active foreground transition completes it immediately and lets the next presentation step start; a click at a stable wait-for-input advances the script. Ambient retained animation is a separate class and must not all be completed by that click.

Current port failures (2026-07-10):

  • The initial black state holds and then the first CG pops in instead of fading. This is consistent with the port applying op 0x202's endpoint as a static tint while its native animated-color consumer remains unmodeled.
  • The retained textbox artwork appears to begin fading in, then disappears. The Godot Label shortcut stays visible; it is deliberately out of scope for this slice because native draw-string/text presentation will replace it before SC0000 is called complete. Diagnose the box object's lifetime, not the shortcut text.
  • The late animation burst before the first music change is badly wrong, then the screen becomes white and interactive play does not proceed to the music change.

Concrete failing boundary. BGM005 begins at SC0000 offset 0x7fa; the expected first change to BGM008 is play-bgm 0x8 at 0x1728. The immediately preceding block 0x133f..0x1725 exercises repeated 0x202/0x203 color operations and the implemented geometry/scale family, but also directly executes three still-stubbed gfx ops: 0x236 at 0x13c8, 0x1fd at 0x14f3, and 0x21f at 0x159a. It calls the shared animation finalizer label_1235a several times, including at 0x1725; that finalizer sets op 0x238's duration, then reads still-unimplemented 0x1c7 get-message-skip and 0x1cc get-adv-service-state to choose its present/yield path. Therefore the white stall could be an object/compositor error, an unmodeled foreground-transition gate, or wrong control flow caused by a stubbed output—not safely assumed to be “just interpolation.”

Investigation order

  1. Make the failure boundary deterministic before changing semantics. Reproduce from --boot with auto-input and a long enough --shot-sequence, plus --gfx-log. Add a single synchronized diagnostic timeline if the existing logs cannot answer the boundary: frame/virtual time; active script + PC/opcode; VM state (running, sleep, transition, input wait, halt); BGM event; and every changed visible object's handle, surface slot/resId, tint/alpha, transform, and lifecycle event. Use play-bgm 0x8 @ 0x1728 as the reachability sentinel. Do not judge progress from the white pixels alone.

  2. Classify before fixing. If the VM reaches/passes 0x1728 while the frame stays white, identify the topmost white/fill object and whether its handle remains visible, loses/rebinds its live surface, or has a stuck color endpoint. If the VM never reaches 0x1728, record the last PC and whether it is sleeping, input-waiting, transition-waiting, polling, halted, or still executing. If the executed path itself is suspect, capture the same native passage with trace_engine_ops.py and use diff_optrace.py to find the first engine/port offset divergence.

  3. Track the textbox artwork as an AGE object. From its first visible frame, identify its retained handle and follow bind, color/animation, present, erase, release, and surface-rebind events through the first CG swap. The key result is one of: GONE (premature erase), still present but covered (z/lifecycle input), still present but transparent/tinted (color channel), or bound to a replaced slot (surface lifetime). Compare only those corresponding native object events; do not spend this slice synchronizing the Godot text overlay.

  4. Recover the foreground ADV transition contract. Reverse/capture the producer behind get-adv-service-state (0x1cc), implement the already-known get-message-skip output (0x1c7), and observe what a click changes during the initial fade and the pre-0x1728 burst. Establish an explicit host-level foreground transition with start → per-frame progress → natural/forced completion → resume. Click completes and consumes the active foreground transition; only a stable input wait advances content. The wall clock remains the progress source and opcode pacing remains a guard within runnable bursts, not the mechanism that decides how long a presentation state lives.

  5. Reverse only the executed missing gfx dependency that remains causal. Triage the three direct gaps in failing-order: 0x236 (timed/animated-surface worker), 0x1fd (scaled vector/animation setter), and 0x21f (affine/matrix channel). For each, capture native inputs, retained fields, and sampled output at the exact SC0000 site; implement it with a focused VM/state/compositor test. Do not declare a stub harmless merely because it is rare, and do not implement the whole remaining opcode list without evidence.

  6. Validate as presentation checkpoints. Native/manual observation remains the final visual oracle, but each check should first have machine evidence (PC reached, object identity/lifetime, transition progress, and final state). Required checkpoints: black visibly ramps into the first CG; the textbox artwork survives until its intended transition-out; a CG swap orders window-out → CG transition(s) → window-in; clicking an active transition snaps to its endpoint without also advancing a stable page; the late burst has no stuck white owner; and execution reaches BGM008 @ 0x1728. Re-run engine tests, corpus sweep, Godot threaded self-test, and the SC0000 coverage report after each landed opcode or scheduler change.

Stop conditions / scope guard: this slice is complete when the port reaches 0x1728 interactively and the above foreground transitions have correct lifecycle/click behavior. Native glyph rendering, configurable text reveal speed, and unrelated subscript opcode completeness remain separate work. Any called script proven to own the first divergence becomes an explicit dependency of this slice; otherwise it stays out of scope.

Investigation 1 result — deterministic boundary classification (2026-07-10)

Added the observe-only Godot --timeline-log <jsonl> diagnostic so VM steps (real byte offsets), virtual time/frame, host state, BGM events, and changed visible-object outcomes share one ordered stream. The reproduction was SC0000 --boot, stable-wait auto-input via a long --shot-sequence, --gfx-log, and a uniform diagnostic --speed 8; speed scales VM, sleeps, and animation clocks together and does not inject input outside wait-for-input.

Classification: not a VM/control-flow stall on the deterministic path. The run executed all three direct gaps (0x236 @ 0x13c8, 0x1fd @ 0x14f3, 0x21f @ 0x159a), called the finalizer at 0x1725, then executed play-bgm 0x8 @ 0x1728 in running state at frame 818 / virtual 70,262 ms. The BGM event resolved to BGM008.OGG in the same synchronized event and execution continued through 0x172b and beyond; the full 1,800-frame run reached page 80 and five BGM events. Therefore a native offset-path diff is not warranted for this boundary unless a separately reproducible manual-input path fails to reach the sentinel.

The full-screen fill owner is retained handle 0xcf08, but it is not stuck at this boundary. It was a transparent white 800x600 fill (a=0.00) when 0x1728 executed. Later, 0x203 @ 0x1337f made it solid white for one sampled diagnostic frame (frame 911); the following label_1235a path executed 0x1c7, 0x1cc, and 0x21c, and the compositor sampled the same handle back at a=0.00 on frame 912. This is evidence of a likely incorrect flash/color presentation contract, not evidence for the reported pre-BGM infinite stall. No opcode, scheduler, or compositor semantic fix was made in this investigation step.

Validation after adding the diagnostic: engine 92/92, Godot build clean, threaded SELFTEST OK, and git diff --check clean. The headless --shot-sequence PNG capture path emits dummy-renderer GetImage errors, but the CPU compositor/timeline completed and the same path already had this limitation; use a windowed sequence when pixel files rather than object-state evidence are required.

A2b — cyclic rotation and affine rasterization DONE (2026-07-10)

This bounded slice followed the non-reproduced white-stall classification above; it did not resume that investigation and does not claim the interactive symptom is fixed.

Native contracts. Op 0x21f is a delayed one-shot axis-angle rotation, not a generic matrix row: (handle,delay,duration,axisX,axisY,axisZ,angleDegrees). It shares obj+0x34's start with scale/translation, uses delay/duration +0x40/+0x54, and linearly samples current axis/angle +0x1ec/+0x204 to target +0x1f8/+0x208. Op 0x234 is separately anchored cyclic rotation with integer-degree phase floor(((now-start)%period)*360/period). The native call order reduces to T(-anchor)*scale*oneShotRotation*translation*cyclicRotation*T(anchor), so the cycle rotates translation.

Op 0x223 was also closed out but deliberately not implemented here: it inserts a type-0 timed-alpha record in the surface command map, containing a target surface slot and two object ranges. SC0000's shared site 0x129e7 passes (handle+2, slot, handle+1,1,handle,1,delay,duration). This belongs to render-target/ foreground-transition presentation, not affine object state, and remains a visible GAP rather than receiving an uncertain approximation.

Native matrix oracle. The retained trace's handle 0xcb8e sample (anchor (700,600), scale from 0.9, axis (0,0,1), 30° target, sampled 11 ms into a 390 ms ramp after 500 ms delay) is [0.9055,0.0134;-0.0134,0.9055], translation (74.1449,47.3127); the focused port test matches it. The two executed SC0000 cycle sites use periods 9000/13000 ms and axes +Z/-Z. A windowed port capture advanced 563 ms from their first sample to phase angles 22°/15°, exactly the native integer formula.

Port result. GfxState now retains/samples the one-shot rotation and cyclic start/phase. Transform2DMath composes a row-vector 4×4 matrix and projects it to an invertible 2D affine transform. The Godot compositor uses a pure inverse-mapped nearest-neighbour RGBA8 rasterizer for both textures and solid fills, preserving the existing colorkey, tint-strength, opacity, clipping, flipping, and z-order paths. Native D3D9 filtering can still differ at subpixels; the matrix/order is oracle-backed rather than approximated.

The windowed --shot-sequence run wrote 454 PNGs with 102 pixel-state transitions; the first cyclic passage produced distinct affine frames as 0xcb8e/0xcb98 advanced. There is no corresponding native PNG sequence in the workspace, so validation is matrix/phase exact plus port-pixel coverage—not a false claim of pixel-perfect native frame equality.

Ghidra. Renamed/commented gfx_object_set_rotation_channel (0x47eb70), gfx_queue_surface_alpha_transition (0x47f440), the surface-command map helpers, and matrix4_make_axis_angle (0x48b215); corrected comments on the one-shot consumer, cyclic interpolator, and composite call order; named useful parameters; saved /v2.

Validation: engine 97/97; full sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot build clean apart from the pre-existing nullable warning and threaded SELFTEST OK; opcode tooling and focused Python tests clean; transform tool compiles; SC0000 coverage 81/129 handled (62.8%), 48 GAP ops / 602 GAP instructions; windowed affine capture clean. Final whitespace/diff validation is recorded with the handoff.

A2b — ADV foreground transition/click lifecycle DONE (2026-07-10)

Native recovery and control-flow correction. 0x1c7 is the message-skip run-state query. 0x1cc reads ctx+0x6dbd4, now adv_read_skip_state; adv_refresh_read_skip_state@0x406cd0 plus the text/label/wait handlers maintain it from message_ReadTextSkip and the current-PC read-history lookup. It is not surface progress. The earlier reading of label_1235a's branch was reversed: zero OR-state (normal playback) reaches 0x21c and sets scheduler bit 0x400; nonzero skip/read state reaches 0x243 + 0x20c to present the endpoint. Synchronized execution caught and corrected this before validation.

The executed missing producer for 0x223 range A was 0x21d: gfx_object_clone@0x47e4f0 copies the full 0x2d4-byte retained-object record from the current CG handle to handle+1. SC0000 then rebinds the source handle to the new CG and queues 0x223 with old clone range A, new source range B, and target presenter handle+2. Op 0x203 mode 2 is the companion transition-source alpha/identity path; its 0xffffffff is opaque identity, not generic solid-white tint, and negative alpha/RGB preserve current static bytes.

Port lifecycle. GfxState owns explicit queue/start/wall-clock progress/natural-or-forced completion. Normal 0x21c parks only the VM thread until the foreground command completes while Godot continues per-frame compositing. A click during that wait forces only the foreground transition to progress 1, is consumed, and resumes the VM; stable wait-for-input remains a separate gate. Tests verify that forcing a transition leaves an independent cyclic retained rotation active. The 0x20c skip/read path starts and snaps a pending foreground transition to its endpoint without the normal wait.

Synchronized window evidence. At normal speed, the second-to-third CG transition produced seven successive distinct PNG hashes while progress advanced 0.133, 0.297, 0.442, 0.592, 0.742, 0.891, 1.000; the user independently observed that fade working in the live window. A timed click landed at progress 0.157, the next frame was the endpoint, and the stable page count did not advance. The accelerated windowed run reached BGM008 @ 0x1728 on frame 137 with non-white changing pixels (2,469 sampled colors at the sentinel) and continued; its earlier white interval was finite and released before that boundary. This is windowed pixel/object/timeline evidence, not the earlier headless reachability inference.

The retained dialogue-text surface 0xe678 still has no pixels because native draw-string 0x204 remains a separate GAP; the Godot Label shortcut remains outside this slice. Its create/erase events are explicit in the object timeline and are not being mistaken for foreground CG transition correctness.

Ghidra. Renamed/commented adv_refresh_read_skip_state (0x406cd0), op_0x1cc_get_adv_read_skip_state (0x427330), op_0x21d_clone_gfx_object (0x423310), and gfx_object_clone (0x47e4f0); added EngineCtx.adv_read_skip_state; saved /v2.

Validation: engine 102/102; full sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot build clean apart from the pre-existing nullable warning and threaded SELFTEST OK; all six Python test scripts, opcode/ctx lint, 481-script decode validation, RECOVER, and Windows CR-aware git diff --check clean. The plain check reports only the generated reference's CRLF on its newly added row. SC0000 coverage is 85/129 handled (65.9%), 44 GAP ops / 598 GAP instructions. This slice was later committed as c9be9c5.

A2b — op 0x202 one-shot color/presentation DONE (2026-07-10)

Native contract. gfx_op_0x202_worker_set_color_anim@0x47ea00 writes target packed ARGB at obj+0x64, delay/duration at +0x38/+0x4c, and resets the one-shot family's shared start +0x34. The missing consumer is the bit-1 path in gfx_object_apply_transform_channels@0x472f00: it seeds start from frame time ctx+0xb550, performs an integer bytewise current-+0x60 to target-+0x64 LERP, then commits target, clears timing, writes target -1, and clears the active bit once all sibling one-shot channels finish. This is ordinary presentation-clock coupling, not op 0x238's separate service clock. ctx+0xb55c == 1 forces completion unless the object-local +0x2d0 override bit is set. /v2 comments were corrected and saved.

Port and endpoint diagnosis. GfxState now retains current/target/delay/duration separately, shares the start timestamp with scale/rotation/translation, and exposes synchronized color=current->target plus progress in the compositor/timeline log. This fixes the initial black-to-EV049AA cut: windowed frames sample the full-screen 0xcf08 black owner through 11 monotonically decreasing states from 1.00 to 0.00 instead of installing the transparent endpoint immediately. The ADV textbox backing 0xd2f0 is a real 800×227 black fill at (0,373); repeated show/hide passages now produce intermediate samples (for example 0.00, 0.26, 0.54, 0.82, 1.00 and the reverse), synchronized to the executing 0x202 sites around 0x12283..0x12342.

Predecessor diagnosis, resolved by the next subsection. The remaining full-white runs were not a stuck color clock or d2f0 endpoint: pixel windows place them after d2f0 is transparent, while object timelines show AE* handles first becoming visible in mode 0 with default 0xffffffff before their later mode-1 initializer. Native call-site disassembly proves op 0x203's obj+0x30 is passed directly as the D3D blend selector and mode 1 uses SRCALPHA/INVSRCALPHA; the port now uses ARGB alpha as opacity and RGB as multiplicative modulation for that mode. The long white intervals did not disappear, which localizes the residual to when intermediate retained state is presented/batched, not to 0x202 interpolation. Altering that scheduler boundary without a matching native present trace would exceed this bounded slice. draw-string 0x204/0x7a was not pulled in.

Windowed evidence: 340 PNGs, 107 distinct pixel states; black-to-first-CG and d2f0 ramps agree with synchronized object progress. Focused tests cover exact integer midpoint math, delay, natural completion field cleanup, negative sentinel resolution, the 0x202-then-0x203 current/target pattern, shared color/matrix clock start, mode-1 opacity/modulation, and the software rasterizer.

Validation: engine 108/108; full sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot build clean apart from the pre-existing nullable warning and threaded SELFTEST OK; all seven Python test scripts, opcode/ctx lint, 481-script decode validation, and RECOVER clean. SC0000 coverage remains 85/129 handled (65.9%), 44 GAP ops / 598 GAP instructions. The differential oracle retains its prior branch-state divergence after 0x12031; it agrees through the executed 0x202/0x203 sequence and does not implicate this slice. Windows CR-aware whitespace validation is clean.

A2b — retained presentation batching / native scheduler boundary DONE (2026-07-10)

The synchronized native trace resolves the white-hold residual. At the AE001D passage, the native engine binds 0xcb8e/0xcb98, applies mode 1 at 0xd5a/0xd63, and arms 0x202 at 0xd73/0xd8a within one roughly 5 ms opcode burst. No gfx_render_frame occurs between those mutations; the first composition is the following 0x21c service loop. The preceding explicit 0x20c/0x125a6 mode-0 white frame lasts only about 10 ms. The port's 200-completed-op/s throttle had stretched that between-present burst across multiple window frames, making retained intermediate state look like a long white stall.

Godot now leaves ordinary opcode FrameYield unthrottled and publishes retained state only at proven presentation-capable boundaries: 0x20c, 0x21c, sleep, and input wait. 0x21c renders while visible finite one-shot channels or 0x223 commands remain active; hidden stale records and ambient cyclic channels cannot hold it open. The first implementation incorrectly included hidden records and parked at the first CG; the visible-only correction was re-run through 14 pages and the full AE burst.

Windowed before/after evidence at the same SC0000 sites: the old capture exposed AE001D in mode 0 for six compositor frames and held one identical white PNG state for 25 frames. The corrected capture executes bind, mode 1, and color targets in frame 64 and first publishes AE001D already in mode 1 at 0x21c; the old mode-0 AE object state is absent from the object/pixel timeline. Remaining short white flashes are explicit native present/color effects, not the prior between-op hold. Draw-string 0x204/0x7a, movie 0x236, and SFX remain separate slices.

Validation: engine 109/109; full sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot build and threaded SELFTEST OK; all seven Python suites, opcode/ctx lint, 481-script decode, and RECOVER clean. Normal-speed windowed capture wrote 220 PNGs and progressed through the complete AE sequence; at the target frame, bind + mode-1 + 0x202 setup share one VM frame and the first published object state is mode 1. Ghidra /v2 comments were updated and saved. This slice was committed as 85fc07d.

A2b — native ADV retained text (0x7a / 0x204) DONE (2026-07-10)

The prerequisite input check passed before text work: the unmodified port reached page-1 wait-for-input@0x83c in about 2.0 seconds and a manual run released 14 consecutive waits through page 15. Clicks were independently healthy, so no scheduler/input workaround was folded into this slice.

Native /v2 dispatch resolves 0x7a to op_0x7a_handler@0x41eba0 and 0x204 to op_0x204_handler@0x422a60. Op 0x7a selects layout slot 1 and writes the last 20-byte record's cursor; SC0000 voiced pages compute (75,47), while narration resets to (100,47). Layout origin is (0,430), bounds are (720,147), retained glyph handles begin at 0xd6d8, and glyph source surface is 21. Op 0x204 locks a numbered D3D surface and GDI-rasterizes its CP932 string with retained font/color/effect state. SC0000 0x9b2 draws "魔王" to 400x30 surface 13 at (1,1); 0x1fb@0x9bb binds that surface to object 0xe678 at (74,444). Show-text builds all glyph records, then publishes one every 50 ms; a click completes and consumes an active reveal before the VM can reach the following stable wait.

The port now handles both opcodes and string-pointer types 8/14. Blank-surface text is retained by slot and drawn through the bound object's transform (the SC0000 speaker-name path), while ADV text retains cursor, origin, 50 ms/glyph progress, and forced completion. The prior top-left dev Label/status presentation is gone. Page-1 window pixels show the black 800x227 textbox with text at native (100,477); voiced page 7 has separate name/dialogue bands at y=447468, 478500, and 507530. Final manual validation progressed 14 pages, with 11 completion clicks consumed during reveal and 14 later clicks releasing 14 waits.

Ghidra /v2 was renamed/commented throughout the handler, layout, raster, surface-lock, and publication chain and saved. The new low-frequency capture_adv_text_trace.py records matching native offsets/state; an initial experimental version combining a D3D device scan with hot per-glyph/render hooks crashed in frida-agent.dll at teardown, so those hooks were removed and the safe narrowed rerun left AGE alive.

Validation: engine 110/110; corpus sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot build clean apart from the pre-existing nullable warning and threaded SELFTEST OK; all seven Python suites, opcode/ctx lint, 481-script decode, RECOVER, and git diff --check clean. SC0000 coverage rises from 85/129 to 87/129 handled (67.4%), with 42 GAP ops / 188 GAP instructions. Movie 0x236 and SFX remain separate slices. No implementation commit was made.

Foundation track — native asset VFS + ALF/AAI/AGF readers (VFS-A/B/C DONE)

This is an optional high-leverage detour before movie 0x236 or SFX. It replaces the Phase-A pre-extracted/pre-converted asset bootstrap with the native loose-override/archive-fallback model and removes the runtime dependency on extracted/ plus build/textures/. Canonical format/architecture detail and source references live in docs/asset-resolution-re.md §“Runtime asset-VFS track”; this section defines slice boundaries only.

Land it as three bounded slices, not one archive/codec rewrite:

VFS-A is the common prerequisite. VFS-B and VFS-C may be swapped afterward: choose A→C→B for the fastest SC0000 textbox/chrome payoff, or A→B→C when complete base+append archive mounting is the priority.

  1. VFS-A — base SYS4 catalog + ALF byte reads. Runtime-parse SYS4INI into raw-id, scene-local, and name lookup views; preserve placeholders; expose bounded asset streams. Apply native precedence loose root/<record.name> → indexed ALF range. Convert Sys4Loader/Sys4ScriptProvider to consume bytes from this seam first, proving the existing 52 patched root BINs still shadow archive copies. Gate: sampled and corpus-size/range validation against extracted/, plus synthetic loose-override tests.
  2. VFS-B — APPEND01 AAI mount. Parse S4AC422 and read APPEND01.ALF. Use Ghidra/native observation only as needed to settle pack selection/mount precedence; compare the entire parsed directory and sampled payloads with BinExtractALF.exe. Gate: no guessed name override or high-byte-id behavior reaches runtime.
  3. VFS-C — in-process AGF → RGBA8. Port the MIT GARbro decode algorithm into platform-neutral engine code with a focused LZSS primitive, palette/truecolor expansion, stride/orientation handling, and optional ACIF alpha. Replace BMP-path texture loading with decoded pixel surfaces. Gate: codec sample matrix plus exact SO001 dimensions/alpha, then windowed SC0000 textbox/button pixels with extracted/ and build/textures/ unavailable to the runtime.

After those gates, move OGG/WAV consumers onto the same byte store as a small follow-up. That prepares SFX without conflating its channel/timing semantics with archive access. Movie 0x236 may likewise consume raw AGF/MPEG bytes later, but video decoding remains explicitly outside this track.

Expected authored seams (names provisional): Sys4AssetCatalog, IAssetStore/Sys4AssetStore, bounded archive stream/reader, LzssDecoder, and AgfDecoder; focused tests belong in Age.Engine.Tests. Runtime caches should key decoded assets by catalog identity plus loose-file timestamp, while tests use synthetic fixtures and installed-game integration checks rather than committing proprietary assets.

Decision point: this track is worthwhile before broadening beyond SC0000 because it establishes the modding contract and benefits scripts, UI chrome, SFX, and movies. It is not required to continue opcode coverage immediately, so choosing movie/SFX next remains valid.

VFS-A — base SYS4 catalog + ALF byte reads DONE (2026-07-11)

Sys4AssetCatalog now runtime-parses SYS4INI.BIN into all 13208 raw slots (including two @ placeholders), 13206 real/name records, bounded scene sections, and universal raw-id lookup. Sys4AssetStore applies native loose exact-basename -> ALF offset/size precedence with traversal rejection, per-open file handles, and a seek/read boundary. Sys4ScriptProvider and all CLI/Godot root-script paths use store bytes; ResourceMap consumes the live catalog instead of generated asset JSON.

Validation: 116 engine tests; every field against build/asset-index.json; all 136 generated scene views; all 13206 archive ranges; representative bytes across DATA1..5 against extracted/; synthetic precedence, range, traversal, and concurrency tests; CLI SC0000 run; Godot build/selftest. The installed root has 49 archive-backed script overrides and two root-only BIN engine files, not the historically reported 52 shadowing scripts; all 49 were byte-proven to win and differ from their archive payload. VFS-B APPEND01/AAI, VFS-C AGF, audio migration, and movie 0x236 remain separate.

VFS-C — in-process AGF to RGBA8 + SC0000 system chrome DONE (2026-07-11)

AgfDecoder now consumes IAssetStore bytes and emits a platform-neutral, tightly packed top-down RGBA8 surface. Its shared 4 KiB-ring LzssDecoder covers raw/compressed information, pixel, and optional ACIF alpha sections; expansion covers 4/8-bit palettes, 24/32-bit truecolor, DIB row padding, and bottom-up orientation. Synthetic fixtures exercise the whole format matrix. Installed AE000A, AE001A, BG030A, EV052CA, and SO001 match the existing converter's pixels exactly; SO001 is 800×300 with non-binary per-pixel alpha.

ResourceMap now resolves texture ids through the scene manifest with a universal raw-id fallback, then decodes through the loose-first VFS. Godot and the CLI no longer use BmpHeader, TexturePath, or pre-converted BMPs. SYSTEM4's inherited SO001 surface is seeded into slot 0x11 before SC0000, allowing the existing callback-window crops to render the textbox and control icons. Windowed page-1 validation passed with build/textures/ temporarily unavailable; source alpha produced the translucent upper edge and the bottom-right control row was visible.

Validation: 124/124 engine tests, CLI/Godot builds, Godot threaded selftest, pixel parity for five installed assets, and the windowed no-BMP capture. VFS-B APPEND01/AAI, OGG/WAV migration, movie 0x236, and unrelated opcode work remain separate.

VFS-B — APPEND01 AAI mount DONE (2026-07-11)

Sys4AssetCatalog.Load now discovers installed *.AAI files beside SYS4INI.BIN, parses the S4AC directory at its native offsets, and mounts each catalog by the selector stored at header offset 0x108. For Himegari, APPEND01.AAI mounts selector 1, names one APPEND01.ALF, and contains 81 records whose literal names all begin $1$. AssetEntry retains the pack id, ResolvePacked applies the evidenced high byte -> mounted catalog; low 24 bits -> record rule, and Sys4ScriptProvider routes append call ids through the existing IAssetStore. Base direct-name lookup remains base-only: append records are not guessed name replacements. The selected catalog still probes the record's exact loose basename before its ALF range.

Native /v2 now names/comments the AAI constructor/load/table/open/get chain and asset_mount_append_catalogs@0x44f120; asset_open_indexed_entry@0x44f390 documents the packed-id split. The native scan stores each successful catalog directly into its selector slot, so a later enumerated AAI with the same selector replaces the earlier pointer. Its SAR 24 selector extraction makes sign-bit high bytes negative table indexes, so the port rejects them rather than inventing 0x80..0xff behavior. Only selector 1 is installed and exercised here.

Validation: 127/127 engine tests; stable complete-directory digest; all 81 ranges fit; a disposable BinExtractALF.exe APPEND01.AAI extraction matches all 81 names, sizes, and payload bytes; real $1$SC1260.BIN loads through its packed id; CLI/Godot builds and threaded SELFTEST OK. Audio migration, movie 0x236, AGF work, and unrelated opcodes were untouched.

Phase A — native SC0000 SFX family (0xb4/0xb5/0xb6/0xc2/0xd9) DONE (2026-07-11)

Native RE and the matching trace resolve the bounded family. 0xb4(resource,channel) synchronously loads and retains a scene-manifest sound; 0xb5(channel) starts that loaded sound once; 0xb6(channel) destroys and clears it. The manager supports 13 native slots, while SC0000 uses a fixed 0..9 pool. Native playback is a notification-fed DirectSound ring: Play(...,DSBPLAY_LOOPING) keeps the ring alive, but logical mode 0 stops at decoder EOF. Mode 1 belongs to adjacent op 0xba and remains outside this slice. These five ops do not set pan or SFX volume; SC0000 inherits centered pan and configured SFX volume. The captured loads both apply DirectSound attenuation -2377; because native audio-preference import is outside this slice, the bounded extracted-WAV port uses unity gain and centered pan instead of treating that user setting as opcode semantics.

At the requested first site, 0xb4@0xc29 resolves 0x28 to E0808.WAV and loads channel 0; 0xb5@0xc2e publishes playback in the same native millisecond. 0xb4@0xc31 preloads the same WAV into engine-owned secondary channel 4. G[0x6242d] is native/profile-owned rather than script-written, so the port exposes value 4 through the new external-global seam for SC0000. The matching port timeline now records the same (load ch0, start ch0, preload ch4) sequence in one frame. Startup 0x62b..0x646 and the later 0x120d..0x1228 both release channels 0..9 in order.

0xc2(target,duration) is the adjacent blocking BGM fade: 100 linear steps for durations at least 1000 ms, 10 steps below that, with target zero releasing the source. 0xd9 only clears native service bit 0x1000; the isolated VM recognizes it with no host-visible effect. The Godot backend retains ten AudioStreamPlayer channels, loads catalog-resolved WAV bytes, separates load from start, releases buffers deterministically, and parks BGM fades on the unified virtual clock. ALF/AAI/AGF VFS work and movie 0x236 remain separate.

Ghidra /v2 now names/comments all five handlers plus the asset-open, decode, DirectSound start/refill/stop/ volume/release, and BGM fade workers; the program is saved. Evidence is build/native-sfx-trace.jsonl and godot/build/sfx-port-timeline4.jsonl (the latter is the final rebuilt channel-4 trace; earlier diagnostic reruns captured the missing external-state mismatch).

Automated validation: engine 111/111; corpus sweep unchanged at 284 exit / 13 STEP-LIMIT; Godot build and threaded SELFTEST OK; all seven Python suites, opcode/ctx lint, 481-script decode, RECOVER, and tracer bytecode compilation clean. SC0000 coverage rises from 87/129 to 92/129 handled (71.3%), leaving 37 GAP ops / 101 GAP instructions. Headless sequence capture still emits the known dummy-renderer GetImage diagnostics while completing successfully; it is not an audio failure.

Manual validation: the normal-speed windowed port advanced through wait-for-input@0x1a58 at 45.6 s, well past the first effects and dialogue pages, with no audio-related stall. The user confirmed the effects were audible and sounded good.

Audio-byte migration onto the asset VFS DONE (2026-07-11)

ResourceMap.BgmPathById and ResourceMap.AudioPath are removed. BGM direct-name resolution now returns a catalog entry, while voice and SFX retain their scene-local resolution; all three flow through ResourceMap.ReadAudio and the injected IAssetStore. GodotAdvHost passes owned byte arrays across the existing deferred main-thread boundary, and Main decodes them with AudioStreamOggVorbis.LoadFromBuffer / AudioStreamWav.LoadFromBuffer. The existing BGM loop and fade, voice interruption, ten-channel SFX load/start/release, clock pacing, and gain behavior are unchanged.

Validation: engine 128/128 and both .NET/Godot builds pass. An archive-only SC0000 regression verifies the Ogg signatures for BGM005.OGG and MAN999.OGG plus the RIFF/WAVE signature for E0808.WAV. With extracted/ physically moved aside, the CLI completed SC0000's 127-op audio trace and a windowed Godot run crossed voice at 0xa0a/0xbf6, BGM005 at 0x7fa, and the established SFX load/start/preload sequence at 0xc29/0xc2e/0xc31 on channels 0/0/4. Godot reported no OGG/WAV read, decode, or deferred-call errors; extracted/ was restored afterward. Movie 0x236, AGF work, opcode semantics, and unrelated VM behavior were untouched.

Phase A — SC0000 movie opcode 0x236 DONE (2026-07-11)

Reversed and implemented only SC0000's existing 0x236@0x13c8. Native ABI is (resource_id, surface_slot, movie_flags, sync_mask); the native site evaluates (0x33, 0, 2, 0). Native graph construction and asset open are synchronous, playback is asynchronous, and the opcode itself does not park the interpreter: execution resumes at bytecode 0x13d1. SC0000's later 0x21c presentation boundary sets run-state bit 0x400 and services the retained movie until EOF; only the following cleanup stops and detaches playback.

Resource 0x33 resolves to the 8,194,052-byte DATA1.ALF:CHAPTER.AGF MPEG-1 program stream through the catalog and IAssetStore. ResourceMap.ReadMovie owns that VFS read. The Godot Windows backend uses the system DirectShow MPEG splitter/decoder, converts its bottom-up RGB32 samples to retained RGBA frames, and presents them on the native slot-0 movie layer. The payload contains an audio stream, but its pin remains unrendered; audio, AGF still-image work, generalized video APIs, other opcodes, and unrelated VM behavior remain out of scope.

Ghidra /v2 names/comments the 0x423ee0 handler and the movie ctor, graph-interface/open/play, sound-route/volume, texture-renderer sample/media-type, stop/detach/release/destructor chain; the program is saved. Validation covers the exact real-SC0000 operands and 0x13c8 -> 0x13d1 boundary, archive-only VFS payload identity, actual 800x600 RGBA DirectShow decode, .NET/Godot builds, and the real Godot site opening the same VFS byte count. Manual testing exposed and corrected an initial same-frame teardown: pre-yield static loads no longer destroy the movie, 0x21c waits for DirectShow completion, and a real-render run records first frame 98 / stop frame 181. A follow-up compositor-only investigation found that the movie object was present at the correct retained z-position, but the static AGF image cache keyed by (assetId,colorKey) froze its first sample. A duplicate unconditional backbuffer copy of the current sample was then covered by that same retained object. Dynamic movie surfaces now bypass the static-image cache and publish only through their retained object. A windowed auto-play run recorded first frame 101 / stop frame 190, and the user manually confirmed that the video visibly played. The known lower white/textbox panel is an unrelated retained-object issue. Movie audio remains deliberately unrendered and out of scope. The decoder test asserts two delivered MPEG frames differ. The two archive/decode tests also pass with all of extracted/ physically moved aside. The real-scene trace is intentionally separate: the existing test bootstrap still finds root script fixtures through Paths.Scripts() under extracted/, and changing that unrelated bootstrap was outside this movie slice. Final validation: engine 132/132, Godot build with zero warnings, threaded SELFTEST OK, and opcode-map lint clean.

Phase A — SC0000 textbox/control-strip one-shot blend correction DONE (2026-07-11)

The lower white panel noted after movie publication and the white-outline controls had one shared cause, not a movie or AGF-alpha fault. SYSTEM4's inherited SO001 surface is cropped by ADV backing object 0xd2f0 and control-strip object 0xd2f1; both use a mode-0 0x202 one-shot color transition. The port discarded that one-shot provenance after sampling and interpreted the packed alpha as static tint strength. As a result, full-strength white replaced the controls' yellow pixels, while zero strength exposed the raw mostly-white backing crop instead of hiding it.

GfxState now distinguishes static mode-0 0x203 state from mode-0 color state which has passed through 0x202. The latter retains ARGB-opacity plus multiplicative-RGB semantics after target commit. This keeps the established 0x00ffffff static CG initializer opaque, preserves yellow under white identity modulation, and makes a committed alpha-zero ADV crop transparent. Focused model/raster tests cover the committed endpoint, identity modulation, and zero-opacity output. Validation: engine 134/134, Godot build with zero warnings, matching windowed capture, and user manual confirmation that the box disappears fully and the controls retain their normal color while visible.

Godot window jitter / compositor performance investigation (2026-07-11)

The visible window jitter is main-thread compositor pressure, not FrameClock.Speed, coroutine pacing, or GPU throughput. A bounded windowed run on the Vulkan backend (RTX 4080 SUPER, --max-fps 60 --print-fps) sustained only 2325 FPS / 4043 ms per frame while SC0000 approached its first ADV input wait. The retained frontend currently makes ShouldRecomposite() true throughout input waits, explicit sleeps, foreground waits, and text reveal. Each recomposite clears the 800x600 canvas, then for every visible layer calls Image.GetData(), runs the C# per-pixel inverse-affine rasterizer, calls Image.SetData(), and finally uploads the full canvas with ImageTexture.Update(). An unchanged input-wait screen therefore consumes the same expensive path every rendered frame; dragging the OS window stutters because this work runs on Godot's main thread.

Continuous presentation itself is partly load-bearing: one-shot transitions, movies, and ambient cyclic channels must continue sampling FrameClock while active. The unconditional redraw implied by IsWaiting/IsTextRevealing is not. The existing gfx-change log showed real one-shot color changes through render frame 121, then no retained-object changes, while the separate steady-state FPS probe remained near 23 FPS. A safe correction should preserve time-based presentation but distinguish dirty/static waits from active visual channels, and should avoid copying the full Godot Image out and back once per layer. Likely implementation boundaries are: (1) explicit compositor dirty generation plus an active-animation/movie query, and (2) one CPU backbuffer acquisition/update per recomposite or migration of ordinary layers to Godot/GPU-native drawing. Merely changing --speed, sleep/coroutine behavior, or the 60 FPS cap will not remove the underlying frame cost.

Quick win 1 implemented (2026-07-11). GfxState.HasActiveVisualPresentation(nowMs) now reports only retained pixels that can change without another VM mutation: finite surface/one-shot channels plus visible spritesheet, color, and cyclic-rotation channels with positive periods. Godot consumes explicit presentation dirtiness once and otherwise recomposites only while that query is true. Entering input wait or sleep requests one publish so preceding retained writes remain visible; the wait/sleep state itself and ADV text reveal no longer rebuild the background. Movie frames retain their existing per-sample dirty publication. This preserves the clock, VM suspension model, active transitions, ambient animation, and movie lifecycle.

Validation: the focused static-vs-ambient query regression brings the engine suite to 135/135; Godot builds with zero warnings and threaded SELFTEST OK. A matching hidden-window Vulkan run capped at 60 FPS, using --speed 8 only to reach the static state quickly, held 60 FPS / 16.66 ms per frame for all eight reported samples, versus the pre-change 23-25 FPS. Buffer batching/source-pixel caching and rasterizer fast paths remain independent follow-ups.

Quick win 2 implemented (2026-07-11). The Godot compositor now owns one reusable 800x600 RGBA8 managed backbuffer. Recomposite() clears it once, every image/fill/transition layer mutates that same array in z-order through the unchanged SoftwareAffineRasterizer, and only the completed frame crosses the Godot boundary via one Image.SetData() plus one ImageTexture.Update(). The former per-layer screen Image.GetData() / Image.SetData() round trip is gone. Static source images still call GetData() per draw; source-pixel caching remains the next independent optimization.

Visual validation used the same SC0000 page-1 command before and after the refactor (--speed 8 --shot-page 1 --shot-settle 3). The two PNG files are byte-identical, both SHA-256 E669355772D4D9118BE80AC93595F78BB467B088659DEA4CC2D2B7D0F05B62BE. A 12-second normal-speed windowed Vulkan probe now reported 53, 26, and 39 FPS through the initial heavier active intervals, then 59-60 FPS through lighter/static intervals; the old compositor had remained around 23-25 FPS. Validation also holds at engine 135/135, zero-warning Godot build, and threaded SELFTEST OK. The general per-pixel affine loop is now the clearest remaining active-frame cost.

The byte-identical page-1 capture is a differential compositor oracle only, not a claim that the automated --shot path has the correct interactive presentation state. Both the pre- and post-refactor captures show fallback SO013A rather than the expected first event CG. The matching gfx log proves this is not resource-load failure: EV049AA.AGF resolves and loads into slot 3, but at capture its handle 0x0 is positioned at (-400,-600) and the full-screen 0xcf08 copy has completed its fade to alpha zero. The user confirmed that ordinary manual play displays the correct CGs. Keep this shot-path state discrepancy open and do not use page 1 as an absolute scene-fidelity oracle; it predates and is pixel-identical across quick win 2.

Quick win 3 implemented (2026-07-11). Static compositor sources no longer round-trip through Godot Image.CreateFromData() / Image.GetData(). The cache now retains (width,height,RGBA) directly under (assetId,colorKey): unkeyed assets reuse their decoded RgbaImage.Pixels, while keyed variants clone and bake transparency once so the canonical decoded asset remains reusable under other keys. Dynamic movie samples never enter this cache; they use the decoder-owned newest-frame bytes directly, cloning only if a color key must be applied.

The differential page-1 PNG remains byte-identical at SHA-256 E669355772D4D9118BE80AC93595F78BB467B088659DEA4CC2D2B7D0F05B62BE (subject to the shot-path oracle caveat above). A bounded 220-frame sequence reached VFS movie publication at render frame 141 and captured multiple distinct movie-frame hashes, confirming dynamic samples still change rather than freezing in the static cache. Engine 135/135, zero-warning Godot build, and threaded SELFTEST OK remain clean.

The normal-speed FPS samples were effectively unchanged from quick win 2 (54/26/38, then 58-60, versus 53/26/39, then 59-60). Source copying was therefore no longer a material bottleneck after the shared backbuffer landed. The cleanup removes needless Godot objects/copies and helps future layer-heavy scenes, but the measured next target is the general inverse-affine per-pixel path—especially identity-transform, opaque-copy, and axis-aligned fill fast paths.

Quick win 4 initial raster fast path implemented (2026-07-11). SoftwareAffineRasterizer now detects exact identity matrices with integral translation and bypasses inverse-matrix construction, floating-point coordinate application, Floor, and per-pixel geometric rejection. Both BlitRgba and FillRgba use direct clipped integer indexing for that case while retaining the exact existing tint, multiplicative tint, source alpha, object opacity, destination blend, and alpha-accumulation arithmetic. Scale, rotation, fractional translation, singular transforms, and all other geometry retain the original affine fallback. Zero-opacity draws also return before scanning pixels.

Six new differential cases compare the fast paths byte-for-byte against a retained copy of the pre-fast-path algorithm across positive/negative clipping, existing destination pixels, ordinary and multiplicative tint, partial source/object alpha, and solid fills. The full engine suite is 141/141; Godot builds with zero warnings and threaded SELFTEST OK. The differential SC0000 page image retains SHA-256 E669355772D4D9118BE80AC93595F78BB467B088659DEA4CC2D2B7D0F05B62BE, subject to the automated-shot presentation-state caveat above.

Because the console SC0000 state does not match the user's ordinary interactive CG presentation, no whole-scene FPS claim is made for this slice. An isolated Release-mode 800x600 tinted/alpha identity-blit kernel comparison measured 2.737 ms for the new path versus 4.217 ms for the retained general loop, or 1.54x for that layer type. Manual-path fade responsiveness is validated below. A later bounded step may add a fully opaque/unmodulated row-copy path as optional incremental work.

Manual validation: the user tested the ordinary interactive path—the path whose CG state and fade cadence differ from the console capture—and reported a massive improvement. This confirms the identity blit/fill fast path materially improves real fade responsiveness, not merely the isolated kernel benchmark.

SC0000 third-CG white screen and AE001D glow fixed (2026-07-11)

The page-14 screenshot path reproduced the user's all-white EV052DA page exactly. Synchronized compositor and opcode evidence excluded the animated glow assets as the white owner: both AE001D layers load successfully, retain their AGF alpha, rotate, and render in mode 1 at 6-8% opacity. Base handle 0xcb2a was correct through the 0x223 EV052CA→EV052DA transition (mode=2, identity), then 0x203@0x12478 restores it to textured mode 0 with preserved 0xffffffff. The old port resolved that endpoint as tintStr=1.00, whitening every EV052DA pixel. Textured mode 0 now uses opaque multiplicative RGB modulation, while surfaceless mode-0 fills retain their existing strength behavior. The transition cleanup regression covers mode 2 → mode 0 (-1,-1).

The resulting non-white capture then proved the glow itself still contributed no visible pixels. Native RE closed the causal gap: 0x1fd(handle,x%,y%,z%) immediately writes the current scale matrix at obj+0x6c, distinct from 0x21e's one-shot target. SC0000 scales the two AE001D circles to 210% and 240%; the stub left them at 100%, almost entirely below the viewport. GfxState.SetCurrentScale and VM dispatch now reproduce the native matrices, with focused geometry/dispatch tests. Validation: engine 145/145, opcode build/lint, Godot build/capture, Ghidra annotations saved, and user confirmation that the CG and animated glow are both visible. This AE001D path is a retained scaled/rotating texture; the later 0x231 spritesheet-cell issue was separately resolved by preserving draw-texture's 200x200 cell and interpreting (8,4) as total frames/columns rather than a grid divisor.

SC0000 ADV wait indicator implemented (2026-07-11)

The missing post-reveal bat is SO000.AGF, a 390x27 strip of thirteen 30x27 frames. SYSTEM4 loads raw asset 0x337c into surface slot 12 and configures layout 1 through op 0x73 as a marker at layout-relative (385,140), screen (385,570), with terminal frame 12 and a 48 ms period. Native op 0x72 activates that layout descriptor after reveal completion. The Phase-A bootstrap now injects SO000 into surface 12 and forwards the exact op-0x73 configuration through IHost; WaitForInput carries the selected layout and starts the marker clock. Godot presents it as a separate 30x27 atlas overlay, advancing inclusive frames 0..12 every 48 ms only during the stable wait and hiding on release. This avoids recompositing the 800x600 software backbuffer during otherwise static waits. Validation: engine 147/147, zero-warning Godot build, threaded SELFTEST OK, and a real page-1 capture showing the transparent bat at (385,570).

A2b -- SC0000 post-movie AE001H spritesheet correction DONE (2026-07-11)

The reported missing cave spirit was the 0x231 path, independent of the earlier AE001D retained glow. Native interpolation proved (period, 8, 4) means milliseconds per frame, total frames, and columns; the existing 200x200 draw-texture rectangle is one cell and must not be divided again. The retained model now cycles the eight AE001H cells row-major and wraps. Focused tests cover the exact 800x400/4x2 layout. Validation: engine 146/146, opcode sources regenerated, Godot zero-warning build and threaded selftest, real-scene compositor trace across all eight cells, Ghidra /v2 annotations saved, and user confirmation on the normal client path. The automated-shot wrong-CG state remains the pre-existing separate gotcha.

A2b -- SC0000 post-movie AE001H travel-path correction DONE (2026-07-11)

The remaining off-screen motion is a separate 0x228 query-model bug, not part of the spritesheet crop. Native 0x228 decomposes and returns the retained object's target translation (obj+0x1ac..+0x1b4), while the C# VM currently returns its static draw/base position V24. For AE001H this changes the first 0x220 target from (40,-20) to (400,0); because the compositor also retains the (360,20) draw base, the sprite was displaced to approximately x=760 before cyclic rotation. GfxState.TryQueryTranslationTarget now exposes the retained target independently of V24; VM dispatch matches the native 0/1 status convention and preserves output operands on a miss. Bytecode-level tests cover the exact three targets (40,-20), (50,-80), (130,-100) plus missing-object behavior. Validation: engine 149/149, zero-warning Godot build, and threaded SELFTEST OK. Current static SC0000 coverage is 94/129 distinct ops handled (72.9%): 82 implemented + 12 safe no-ops, leaving 35 GAP ops / 88 GAP instructions. By instruction frequency, 16,169/16,257 are handled (99.5%); this gauge does not replace manual end-to-end fidelity.

A2b -- SC0000 AE001H white-pulse correction DONE (2026-07-11)

Manual movement confirmation exposed a separate color artifact. The sheet itself has no white frames. SC0000 later calls 0x232(handle,1200,224,-1); native treats RGB -1 as "preserve current static RGB," whereas the port masks it to white and drives mode-0 tint strength from the animated alpha. The compositor log correspondingly cycles tintStr from 0 to about 0.82 and back. This identifies the white wash as a port bug, not intended artwork. Native dataflow confirms fresh static color is 0xffffffff; 0x232 samples 0xffffffff ↔ 0xe0ffffff and passes it through unchanged blend mode 0, whose alpha is inert and whose white RGB is identity modulation. The port now resolves native default/static color and negative sentinels, samples packed ARGB, and applies it through normal mode-specific blending rather than a separate tint-strength convention. Focused tests cover exact AE001H invariance, mode-0 RGB modulation, and mode-1 alpha opacity. Validation: engine 152/152, zero-warning Godot build, and threaded SELFTEST OK; manual confirmation is the remaining visual gate.

ADV page-to-script locator implemented (2026-07-11)

Godot now turns each stable wait-for-input into a shared human/tool coordinate. A normal run recreates build/page-map-<SCENE>.jsonl and records the run-relative page number, page-start location, canonical wait script/offset, last show-text instruction and inline-string offsets, dialogue text, and nested call stack. The optional HUD uses the compact form SC0000 P014 · wait SC0000@0x… · text SC0000@0x…; F2 toggles it and F3 copies it. tools/locate_page.py SC0000 14 resolves that record and prints authoritative disassembly around the wait. Page number is deliberately only the friendly coordinate because state and branches can shift ordinals; the script/offset remains authoritative. A live SC0000 run verified page 1 as show-text@0x834, string 0x14963, and wait-for-input@0x83c. Validation: focused Python tests, engine 152/152, zero-warning Godot build, threaded SELFTEST OK, and the live lookup all pass.

SC0000 page-58 EV050EA background restored (2026-07-11)

The first user-supplied locator, SC0000 P058 / wait@0x6545 / text@0x653d, led directly to the missing full-screen CG. Resource 0x7a resolves to EV050EA.AGF and was loaded into slot 5, but retained handle 0xcb2a inherited an older (-100,0) translation, -90 degree rotation, and alpha-zero endpoint. Native RE showed why cleanup diverged: gfx_object_init_default zeroes source slot obj+4, so op 0x215 returns 0 for a created-but-unbound object; only a missing handle returns -1. The port's SourceSlot=-1 default made the sign-tested cleanup skip that object. The model now uses native slot 0, allowing erase/recreate to restore identity state. A lifecycle regression reproduces the transform-create, query-cleanup, and CG-reuse sequence. Validation: engine 153/153, zero-warning Godot build, and a synchronized real-scene compositor trace at page 58 showing EV050EA.AGF at (0,0), scale 1, translation 0, rotation 0, and opacity 1.

SC0000 page-89 BG004D background restored (2026-07-11)

Locator SC0000 P089 / wait@0x89e9 / text@0x89e1 identified BG004D.AGF as loaded but offscreen at (400,650). The prior BG001A animation intentionally leaves translation y=550; the erroneous extra (400,600) came from GodotAdvHost retaining slot 0's seeded 800x600 dimensions after op 0x1fa released the surface. Native release frees and nulls the slot. Static releases now remove the host resource and size state (while the existing in-flight movie retention remains unchanged), and the CLI graphics trace mirrors that lifecycle. The loader consequently builds base (0,-500), so the retained translation places BG004D at (0,50). Op 0x1ff was also completed as the native immediate current-translation setter at obj+0x16c. Validation: engine 155/155, zero-warning Godot and CLI builds, and a full synchronized run reaching the exact page-89 wait with BG004D.AGF drawn at (0,50) alongside the character sprite.

ADV control-strip button investigation complete (2026-07-18)

Native RE and the shared SC0000 routine now establish the implementation contract for the five visible lower-right controls: op 0x90 registers enter/leave/activation callbacks, SO001 supplies the base icons, tooltips, generic alpha-0x80 hover overlay, and persistent state overlays, and the activation callbacks map left-to-right to History, Auto message, all-message Skip, read-message Skip, and Hide window. Manual correction confirms the controls are silent on hover, matching the absence of any audio call in the path. Relevant input/state opcodes and G[0x6c9..0x6cd] are named in their canonical registries; the /v2 Ghidra image is annotated and saved. Full native evidence is in engine-re.md.

ADV control-strip pointer callbacks + Auto bridge implemented (2026-07-18)

The first bounded runtime slice is now live. ExecFrame owns the native-style hotspot registry populated by ops 0x90, 0x94, and 0x97; Godot routes 800x600 virtual pointer motion into it and consumes an armed hotspot click before the ordinary ADV page-advance path. Enter, leave, and activation targets execute as temporary local callbacks on the VM thread while the enclosing wait-for-input remains parked. A dedicated host wake channel services those callbacks without releasing the page semaphore. Inclusive bounds, direct enter/leave dispatch, activation/re-arm, nested local calls, click consumption, and registry lifetime have focused regressions. Callback completion requests one retained-frame publication, so static waits show the new tooltip/glow immediately without continuously recompositing the 800x600 canvas.

This does not introduce duplicate Godot widgets or hover assets: the callbacks already present in every ADV script redraw the retained SO001 tooltip/glow/state layers. Ops 0x1b6/0x1b7 now retain Auto-message state, so the x=706 action toggles and redraws its active overlay. Auto's timed page advance is not part of this slice; nor are keyboard/pad activation and the remaining four action services. Validation: engine 157/157, zero-warning Godot build, threaded SELFTEST OK, and SC0000 static coverage 100/129 handled (77.5%; 88 implemented + 12 safe no-ops, 29 GAP ops / 70 instructions).

Next: manually validate hover and repeated Auto clicks in a normal SC0000 run, then add Auto timing and the History/all-skip/read-skip/Hide service behavior one bounded action at a time. No hover-audio work is required.

Manual-validation correction (2026-07-18). The first build displayed the SO001 strip but did not hover. Its cold profile state took jcc@0x8d with G[0x6c1]==0, skipping the five visible registrations at 0x94..0xd0; later op 0x93@0x622 also canceled the one early registration pass before the first dialogue wait. Native SYSTEM4 supplies G[0x6c1]=1, and its ADV coroutine republishes the shared definitions at the stable wait. The single-scene bootstrap now supplies that exact inherited flag, retains canceled definitions as inactive coroutine templates, and re-arms them at wait-for-input. Called-script frame exit also restores the parent's armed registry. A real-SC0000 regression reaches the first wait, moves to History (684,572), executes label_1a0, observes G[0x6c9]=1, and verifies callback-frame publication. Validation is now engine 159/159, zero-warning Godot build, and threaded SELFTEST OK; manual recheck remains the visual gate.

ADV Auto timing implemented (2026-07-18)

The follow-up manual check confirms hover and the script-owned toggles work; Auto previously changed only its SO001 active overlay because timed release had not yet been implemented. Native RE closes that gap without a scene-specific shortcut. Auto now uses the engine's two configuration channels: unvoiced pages wait AutoMessageTime1 (CONFIG default 2000 ms), while voiced pages wait for real voice completion and then apply AutoMessageTime0 (default 500 ms). Ops 0x1b8/0x1b9 read/write those delays, op 0xc4 marks a queued voice, and op 0x1bc resets the per-message voice state. A configured zero retains the native 100-ms fallback.

The implementation keeps VM-owned settings separate from host-owned clock/audio state. The Godot wait loop polls the existing monotonic frame clock and an interrupt-safe voice generation counter; it does not enqueue a fake click or sleep the VM thread for a hard-coded duration. Focused regressions cover unvoiced and voiced deadlines, live Auto disable/re-enable, zero fallback, the time-setting ABI, and voice-state reset. Validation: engine 165/165, opcode and EngineCtx generators/tests/lints clean, Godot build has no compiler/analyzer warnings, and threaded SELFTEST OK.

Next: manually validate Auto advancing both an unvoiced page and a voiced page, then take the remaining History/all-message-skip/read-message-skip/Hide actions as separate bounded services.

ADV all-message Skip implemented (2026-07-18)

The x=728 action now enables the native persistent Skip service through op 0x88. The VM distinguishes persistent button/display state (op 0x19a) from the live skip query (op 0x1c7, persistent state OR the existing host fast-forward channel), and op 0x101 resets only transient input state. Godot completes text reveal and stable waits while Skip is active; the existing skip-aware transition branches publish their final endpoints. Voice calls follow the native latest-only queue: requests replace one deferred voice while Skip is active, and the most recent request starts when op 0x88(0) clears the service.

Native set:CancelMesSkipOnClick defaults to zero, so this slice deliberately does not turn arbitrary clicks into a port-only cancel gesture. A future settings-profile implementation can expose the native nonzero press/release policy without changing the Skip state seam. Regressions exercise the opcode lifecycle and the real SC0000 x=728 callback. Validation: engine 167/167, zero-warning Godot build, opcode and EngineCtx generators/tests/lints clean, and threaded SELFTEST OK.

Next: manually validate that x=728 fast-forwards text and transitions and that a later 0x88(0) boundary returns to normal pacing. Then implement Read-message Skip on the same service seam, adding the required per-script-offset read-history model rather than treating it as another global all-skip toggle.

Manual-validation correction (2026-07-18). The initial Skip build collapsed each formerly blocking message span into a free-running VM burst, so playback teleported to explicit sleeps and then teleported again. Native still executes one opcode per interpreter tick during Skip. Godot now waits for one rendered frame pulse at each FrameYield only while persistent message Skip is enabled, retaining normal free-running bursts outside Skip. Validation: engine 168/168, zero-warning Godot build, threaded SELFTEST OK; repeat the x=728 pacing check before starting Read-message Skip.

ADV Read-message Skip persistence investigation (2026-07-18)

Native RE confirms that this service cannot be implemented as a second all-message toggle. AGE resolves the current script code offset through its per-frame message-offset table, then tests a dword flag in an engine-owned ReadTextDB keyed by the raw packed script resource id and per-script message index. Click, Auto, and active-Skip completion queue {script_resource_id,index,count}; opcode 0x71, while still the structural T1 label/anchor, commits those pending records and therefore is not a pure runtime no-op.

The database is profile-wide rather than slot-local. AGE loads and writes RT.DAT beside shared SAVE.DAT, using $$RT.DAT and RT.BAK for replacement/backup; numbered game slots remain SAVE%2.2d.DAT. Successful slot/context saves and normal shutdown persist the shared database. The full native chain and file layout are recorded in engine-re.md, and /v2 is renamed/commented/saved.

Next: implement a profile-level read-history service behind the VM/host boundary, make click/Auto/Skip completion queue the native tuple, make op 0x71 commit it, and use the same resolver for op 0x1cc. Persist it in a port-owned shared format first; raw RT.DAT import/export can remain a separate compatibility slice.

ADV remaining-button difficulty inventory (2026-07-18)

The remaining action buttons beyond Read-message Skip are History (x=684) and Hide Window (x=772). They are not additional toggle services. HIDEWIN.BIN is 29/37 distinct opcodes covered (251/286 instructions) and mainly needs the real op-0x199 coroutine transition plus AGE's per-frame mouse/joy callback tables, button-state polling, and cursor-resource operations. Existing retained-object translation and presentation support covers its scene-view/pan behavior, making this a medium-sized infrastructure slice.

HISTORY.BIN is 41/78 distinct opcodes covered (761/854 instructions) and needs a separate session-level retained text-record model, navigation/render/metadata ops 0x1d0/0x1d1/0x1d3/0x1d4, stored-voice replay, the same generic input-callback layer, literal local-array initialization, and additional menu/text-surface operations. The shared ReadTextDB is not a backlog: it stores only per-message read flags. History is therefore the largest remaining control-strip action.

Recommended next slice: implement Hide Window first as the smallest clean remaining action and use it to land the generic coroutine/input-callback service that History will later reuse. Read-message Skip remains the next bounded persistence slice; History should follow after both foundations exist. Full native evidence and the difficulty table live in engine-re.md.