70 KiB
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 1–9 → 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 A⇒G[A], default 0). Per-call local frame with typed banks sized by header F0–F5 (local_int[F0],local_float[F1],local_string[F2], …). - PC / control flow: build
offset→instruction-indexmap fromsys4loadinstructions (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, where0xffffffff= 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
-ptroperands are lvalues:lookup-array(dst_ptr, base, idx)yields a reference toG[base+idx];movthrough 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 shl→p1 = 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).
- arithmetic/bit
- Markers → no-op (this TESTS the classification):
0x1f4 0x1f5 0x1d5 0x1bc 0x1bfskip; tentative0x21b 0x1d2 0x258skip — 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-textline must be a real decoded string from the script's pool, and the sequence must be an in-order subsequence of that script'sdialogue.jsonllines (≈ 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-textcount, lowjccdensity, fewcall-script. Selection step: rankSC####/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
tools/vm0.py: load a script viasys4load, build offset→index map, frame + global bank.- Implement operand resolution + the arithmetic/compare/mov/lookup/control handlers; unit-test on
RECOVER.BIN(array copy + both loops must produce correct global writes). - Add ADV capture + markers-as-noop + call/effectful stubs; add opcode-coverage logging.
- Run on the chosen linear scene; diff emitted
show-textvsdialogue.jsonl(subsequence check); eyeball the first ~15 lines for coherence. - 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-textis a coherent in-order subsequence of theirdialogue.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
jccall execute correctly. The core execution model is proven. - Full SC/SP oracle sweep (
vm0.py --sweep): 282 / 294 scenes DIALOGUE-VALID = 95.9%. Every emittedshow-textline is checked (by string offset) as an in-order subsequence of the script's staticdialogue.jsonllines. 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 + naturalexit); 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), plusload_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 --import → dotnet build godot/Himegari.csproj →
godot --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):
- 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 stub —0x208(get-texture-size) + the sprite position/registration/animation chain — so theirdst/sizeare garbage (backgrounds land off-center, e.g.BG030A dst=(300,300); sizes come out0x0). Only the immediate full-screen draws ((0,0) 800×600) render right. - 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). - Slot model is an approximation. We use one
TextureRectper 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-texturebinds 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".) - 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 manifestfiles[base+id], offset 0 (same asset-texture).play-bgm→ DIRECT LITERAL NAMEid → 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 0x5and BGM008 for0x8; we mis-played BGM006/009 because we resolved BGM via the manifest (files[5]=BGM006). BGM is actually addressed by direct nameBGM{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), soid-1would 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 linejmps 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):
- Native gfx ops (b): all 14 command-buffer ops (
0x1a2,0x1f7,0x1fa,0x1ff,0x202,0x203,0x212,0x213,0x215–0x21a) reversed + implemented against a host-sideGfxState(VM execution state;engine/Age.Engine/Model/GfxState.cs).0x215now returns distinct per-object slots. - Missing system-boot state (a): the CG handle array
G[0x62455..]is set by boot script INIT2 (via entrypointSYSTEM4.BIN), which a cold single-scene run skips → all CGs collapsed onto object 0. Supplied viaAge.Cli gfx --bootand Godot--boot(runINITCONFIG/INIT2/INITthroughGameSessionfirst). 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 text —
draw-string(0x204)×205 +0x7atext-param×205 (1:1 paired). Dialogue text is currently surfaced viaIHost.ShowText→ GodotLabel; the engine's native glyph/window draw path is unmodeled (cosmetic for now, but it owns text layout/speed). - Unmodeled gfx-range cluster —
0x21c–0x243+0x2bd/0x2bf(e.g. 0x220×66, 0x22f×34, 0x228×33, 0x21e×25): siblings of the0x212–0x21acommand-buffer family we implemented, not yet reversed → the biggest single rendering unknown (likely sprite/effect/blend geometry). RE these next before more compositor work. - Timing —
sleep(0xc8)×20: animation pacing; fades/effects can't animate (only snap) until this exists. - Audio/SFX —
play-sound-effect(0xb4)×24 +0xb5/0xb6/0xc2/0xd9(channel/volume/stop control) — stubbed. - Scene coroutine —
0x7b×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, …), 1–2 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
("0x21c–0x243 sprite transform / ANIMATION cluster"). First slice of the 0x21c–0x243 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
sleepop 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-backset-texture→draw-textureswaps run with nosleep/wait/present/coroutine between them; what actually paces them is still unknown. The--shot-sequenceframes 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: thesleepseam, the one-shot pauses, theGfxStaterace 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.
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 0x3958–0x3973 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.jsonlis 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 soVmnever referencesSys4.ExecFramerefactor ofVirtualMachine: per-script state (script, pc, locals, intra-call stack, per-frame emit-guard) moved intoExecFrame, run by a recursiveRunFrame. Globals/Emitted/Steps stay VM-level (shared). Emitted lines now carry their source script name.call-scriptexecutes: loads the child, runs it as a nested frame sharing globals, returns to the caller atpc+1.exit/exit-scriptand empty-stackretreturn 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+ theaudio/gfxdiagnostics 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 gracefully — trace/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).SyntheticSceneTestsruns an assembled scene (show-text- wait-for-input + real nested call-script + shared-global return) with full handling and asserts its
exact output.
WaitForInputTestsreworked onto a synthesized two-page scene.RecoverTestskeeps its ISA-litmus role with call-script handling ON via a no-op subroutine double (isolates the ISA from the real subroutines' state deps).TraceDiffTestsretired (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).
- wait-for-input + real nested call-script + shared-global return) with full handling and asserts its
exact output.
- Godot
--selftestrewritten: 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).
- The "rapid burst" seen in plow traces is a headless fiction (input-plow). The real back-to-back
set-texture→draw-textureswaps are the per-page compositorlabel_1235a(instruction indices0x3958–0x3973= dword0x123e8–0x12497): a loop over ≤8 gfx object slots that per slot queries state (0x215), erases/releases (0x1f7/0x1fa), and draws with alpha (0x203). - Op
0xcd get-input-typeis unmodeled in the VM (nocase; only0x72 wait-for-inputis handled — two input mechanisms, one modeled).INPUTNAME.BIN:0x1c1name-entry is aget-input-type→jcc→sleep 1→jmppoll 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; 0x34→0x35 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)
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):
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).IHost.FrameYield()— the VM (VirtualMachine.RunFrame) calls it once per executed opcode (right afterStep). No-op in all 9 headless hosts (CaptureHost, the CLI trace hosts, every test host) → headless output byte-identical. This is the parity guarantee.godot/GodotAdvHost.cs+godot/Main.cs— the throttle.Mainowns aFrameClock; each_ProcessitAdvance(delta)s the clock andPulseFrame()s.GodotAdvHost.FrameYield()counts ops and, onceEffectiveBudgetis reached, blocks the VM background thread on anAutoResetEventuntil the next_Processadvances the clock — throttling the interpreter to ≈OpsPerFrameops per rendered frame (≈30 → ≈1800 ops/sec, the native rate-limited cadence).Sleepnow waits on the same clock (notThread.Sleep), and the anim tween reads_lastDelta * _clock.Speed, so the singleSpeedfactor scales throttle + sleep + tween coherently.Speedis 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, else0xRRGGBBexact-match,0= key black; baked at surface-load, see engine-re.md §Blend).RenderObjectgainsAlpha/Tint/Blend(BlendKindOpaque|Alpha|Additive);GfxObject.HasColor;GfxState.SetObjectColor;SnapshotVisibleObjectsresolves them.0x202/0x203route throughSetObjectColor(the stale "alpha deferred" trace stub is gone — alpha is now consumed).- Godot compositor: colorkey-baked image cache (keyed by
(path, colorKey)),BlitLayerapplies 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 0x21c–0x243 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):
GfxObjectgains src-rect (spritesheet) + animated-color channels;GfxState.SetSrcRect/SetColorAnim;BlendMath.PingPong;SnapshotVisibleObjects(long nowMs)= a port ofgfx_object_anim_interpolate(ping-pong the spritesheet cell + color/glow), driven by theFrameClock(the "mach 5" pacing fix).- Wired 7 ops:
0x22f/0x229position (direct V24 set),0x239/0x231spritesheet (static cell / animate),0x232color glow (ping-pong),0x228/0x23fqueries (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:
- 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. - On a top-level entry at offset zero, synthesize native scheduler state
G[0xaba5c]=1for 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. - 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, outsequence. This handles a stale prior-sceneoutvalue and avoids hardcoding SC0000's0x45e; all 138 corpus ADV sites share the same shape. - Record op
0x7b's two saved handler PCs as frame metadata. Consume op0x7cas the host-scheduler resume marker: the port's existingIHost.FrameYield/FrameClockpath supplies per-frame pacing, 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)
The remaining race was a unit mismatch at the scheduler boundary. Native
adv_interpreter_tick advances one opcode, while the live cadence probe counted calls to
vm_operand_fetch (about 1,788 operand reads/s). The port's FrameYield runs once per completed
opcode, so using 1,800 there overclocked script teardown by roughly ninefold. FrameClock now supplies a
refresh-independent 200 completed-opcode/s allowance; sleep and input waits discard parked-time credit, and
input is ignored unless the VM is actually at wait-for-input. --speed scales the unified VM,
sleep, and animation clock for inspection without --shot-sequence auto-advance.
Live native capture recorded the complete 0xcbc0 scale ramp (1→5 over 1,890 ms), including the exact
composed matrices. The port previously deleted the object at scale 1.44 after 226 ms. At 215/s calibration it
survived 1,798 ms to scale 4.72; the final 200/s replay retained it for 2,014 virtual ms, past the endpoint.
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 ⏳ OPEN (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
Labelshortcut stays visible; it is deliberately out of scope for this slice because nativedraw-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
-
Make the failure boundary deterministic before changing semantics. Reproduce from
--bootwith 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. Useplay-bgm 0x8 @ 0x1728as the reachability sentinel. Do not judge progress from the white pixels alone. -
Classify before fixing. If the VM reaches/passes
0x1728while 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 reaches0x1728, 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 withtrace_engine_ops.pyand usediff_optrace.pyto find the first engine/port offset divergence. -
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. -
Recover the foreground ADV transition contract. Reverse/capture the producer behind
get-adv-service-state(0x1cc), implement the already-knownget-message-skipoutput (0x1c7), and observe what a click changes during the initial fade and the pre-0x1728burst. Establish an explicit host-level foreground transition withstart → 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. -
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), and0x21f(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. -
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.