diff --git a/docs/engine-re.md b/docs/engine-re.md
index 1f23516..c710ab6 100644
--- a/docs/engine-re.md
+++ b/docs/engine-re.md
@@ -522,18 +522,23 @@ Both branches finish with
`0x224`, which clears the native gfx command queue at `ctx+0x418`. These handlers are now named,
commented, and saved in the Ghidra image.
-None of `0x21c/0x224/0x243` waits for the per-object transform duration. The normal-path boundary is
-the engine's rate-limited one-op interpreter cadence plus continuous retained compositing. This matters because
-the earlier Frida probe hooked `vm_operand_fetch`: its ~1,788/s result counts **operand reads**, commonly
-several per completed opcode. Feeding that number to the port's per-completed-opcode `FrameYield` made
-the `0xcbc0` section reach only scale 1.44 in 226 ms before teardown.
+The matching native presentation trace corrects the earlier cadence model. Ordinary opcode execution is
+**burst-fast between presentation services**, while `0x21c` is the render/wait boundary: it parks the
+interpreter and `gfx_render_frame` repeatedly samples visible finite one-shot channels and queued surface
+commands until dirty presentation state clears; `0x224` then clears the command queue. `0x20c` is a single
+explicit publication on the skip branch.
-The corrected host limiter is refresh-independent and runs at 200 completed opcodes/s. It resets accumulated
-credit after sleep/input parking, and clicks are accepted only while actually waiting, so clicks during a
-visible animation cannot pre-arm the next wait. A normal-clock replay retained `0xcbc0` for 1,798 ms at
-the intermediate 215/s calibration; the final 200/s replay kept it alive for 2,014 virtual ms, beyond the native
-1,890 ms endpoint. `--speed` scales VM, sleep, and animation clocks together for comparison without
-changing these virtual-time relationships or auto-advancing waits.
+At the opening AE001D passage, native `0x125a6` rendered the preceding state, then both object binds plus
+mode-1 `0x203` and target `0x202` writes (`0xd5a/0xd63/0xd73/0xd8a`) completed in about 5 ms with **no render
+between them**. Their first composition was the following repeated `gfx_render_frame` loop at `0x21c`.
+Likewise, the explicitly presented mode-0 white CG at `0x125a6` survived only about 10 ms before that next
+boundary. The port's former 200-completed-op/s throttle stretched the same burst across many display frames;
+that average had folded service waits into execution time and was not an opcode scheduler rate.
+
+The Godot host therefore leaves ordinary `FrameYield` non-blocking and publishes retained mutations only at
+`0x20c`, `0x21c`, sleep, and stable input waits. `0x21c` waits for visible finite color/matrix channels plus
+`0x223`; ambient cyclic/spritesheet pulses do not block, and click forcing remains restricted to `0x223`.
+This is a native-evidenced scheduler correction, not a guessed duration sleep.
##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction)
diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md
index 1c4720c..38e0ba8 100644
--- a/docs/opcode-reference.md
+++ b/docs/opcode-reference.md
@@ -86,13 +86,13 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t
- **evidence:** Ghidra handler 0x427330 calls vm_operand_write(1, ctx+0x6dbd4). Producer recovered 2026-07-10: adv_update_read_text_skip_state@0x406cd0 and op 0x6e/0x71/0x72 maintain the field from message_ReadTextSkip plus current-PC read-history lookup; adv_interpreter_tick consumes it in click/read-skip control. It is not the 0x223 surface-transition progress flag.
### 0x21c `mark-frame-yield` (mark-frame-yield, argc 0)
-- **summary:** Set native run-state bit 0x400; in normal ADV playback this is the queued foreground-transition yield/resume boundary.
+- **summary:** Set native run-state bit 0x400; in normal ADV playback this is the retained-presentation render/wait/resume boundary.
- **grounding:** source=investigation, confidence=high
- **depends on:** 0x223, 0x1c7, 0x1cc
- **depended on by:** 0x223
-- **evidence:** Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a's jcc reaches it when the OR of op 0x1c7/0x1cc is zero; synchronized port trace confirmed this is the normal path after 0x223.
+- **evidence:** Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10.
-SC0000 label_1235a reaches this when the OR of 0x1c7 message-skip and 0x1cc read-skip state is zero (normal playback). Native run-state bit 0x400 yields the interpreter while the queued foreground presentation advances. The interactive port starts pending 0x223 commands here, parks only the VM thread while per-frame compositing continues, and resumes after natural or click-forced completion.
+SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. The port publishes and waits for visible finite one-shot channels or 0x223 commands; click forcing remains limited to the latter.
## draw
@@ -153,7 +153,7 @@ SC0000 label_1235a reaches this when the OR of 0x1c7 message-skip and 0x1cc read
- **depended on by:** 0x20d, 0x223
- **evidence:** Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. 2026-07-08.
-Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_render_frame @0x4820b0. Godot composites continuously. In label_1235a the OR of 0x1c7/0x1cc is nonzero on the read/message-skip branch, which resets the animation service then presents; the port starts and snaps any pending 0x223 transition to its endpoint here. Normal zero-state playback branches to 0x21c, which owns wait/resume. Headless hosts remain non-blocking. Kelebek label u00416200 was VA-drift.
+Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking.
### 0x212 `set-gfx-field64` (set-gfx-field64, argc 2)
- **summary:** 0x212 (obj_idx)(val) — gfx cmd-type 5. Handler gfx_op_0x212_set_field64 @0x4230c0: obj=[ctx+0x14d54 + obj_idx*4]; if obj: *(obj+0x64)=val. Sets one per-object field. See docs/engine-re.md gfx op-contract table.
diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md
index e1c1482..fcc9974 100644
--- a/docs/phase-a-slice-plan.md
+++ b/docs/phase-a-slice-plan.md
@@ -980,3 +980,31 @@ opcode/ctx lint, 481-script decode validation, and RECOVER clean. SC0000 coverag
(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. No commit was made.
diff --git a/docs/tools-reference.md b/docs/tools-reference.md
index 26c4850..6f4858c 100644
--- a/docs/tools-reference.md
+++ b/docs/tools-reference.md
@@ -177,6 +177,8 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
| `tools/frida/trace_engine_ops.py` | **Engine op-path tracer** for the differential oracle (`docs/engine-re.md` "Differential offset-path oracle"): per executed op, read `cur_ctx_index@0x53d14`/`frame_pc@0x53d2c`/`frame_codebase@0x53d28` → emit `(codebase, offset=(pc−codebase)/4)`. **Use `--hook operand` (0x41b940, proven-safe)** — `--hook tick` (0x410fb0) sees `ecx≠ctx` (0 entries). Writes `build/tracer-live.flag` when the hook is installed → launch in the background, gate the New-Game trigger on the flag (else the scene-entry burst is missed). | `py -3.11 -u -X utf8 tools/frida/trace_engine_ops.py [--hook operand\|tick] [secs]` | running game → `build/engine-optrace.jsonl` |
| `tools/frida/capture_global_writes.py` | **Scene-entry state capture** → auto-seed for single-scene runs (`docs/engine-re.md` "Scene-entry state snapshot"). Hooks `vm_operand_write@0x425fb0` and logs `(codebase, index, PLAINTEXT value)` for global-ints (the helper sees the value before the obfuscated store — no de-obfuscation needed). **`--spawn` captures from boot** (packer-aware: polls until `0x425fb0` unpacks, then attaches; kills the spawned pid on setup failure so no suspended orphan). `--attach` = partial (misses pre-attach writes). Validated: a real boot→New-Game→SC0000 capture seeds the VM to match the engine's whole opening. | `py -3.11 -u -X utf8 tools/frida/capture_global_writes.py --spawn [secs]` | running/spawned game → `build/global-writes.jsonl` (raw) + `build/scene-entry-state.json` (GameSession snapshot) |
+| `tools/frida/capture_presentation_trace.py` | **Retained-state presentation trace:** correlates the current script offset with native draw/color writes, object composition, surface-command consumption, `gfx_render_frame`, queue clear, and D3D9 Present count. Read-only; distinguishes live retained state from state actually published to the window. | `py -3.11 -u -X utf8 tools/frida/capture_presentation_trace.py [secs] [pid\|AGE.EXE]` | native game → `build/native-presentation-trace.jsonl` |
+
*(Static disassembly of `build/engine-dump/range_00400000.bin` uses **capstone** — `py -3.11 -m pip install capstone`; VA `X` → file offset `X−0x400000`.)*
## Native engine RE (Ghidra)
diff --git a/engine/Age.Engine.Tests/OneShotColorTests.cs b/engine/Age.Engine.Tests/OneShotColorTests.cs
index 0693391..4e0d68f 100644
--- a/engine/Age.Engine.Tests/OneShotColorTests.cs
+++ b/engine/Age.Engine.Tests/OneShotColorTests.cs
@@ -4,6 +4,21 @@ namespace Age.Engine.Tests;
public class OneShotColorTests
{
+ [Fact]
+ public void TimedPresentation_TracksOnlyVisibleFiniteChannelsUntilSampledComplete()
+ {
+ var visible = Visible(GfxState.PackColor(0, 0xffffff));
+ visible.SetAnimatedObjectColorResolved(0x100, 0, 100, 0xff, 0xffffff);
+ Assert.True(visible.HasActiveTimedPresentation(1000));
+ visible.SnapshotVisibleObjects(1000);
+ visible.SnapshotVisibleObjects(1100);
+ Assert.False(visible.HasActiveTimedPresentation(1100));
+
+ var unbound = new GfxState();
+ unbound.SetAnimatedObjectColorResolved(0x200, 0, 100, 0xff, 0xffffff);
+ Assert.False(unbound.HasActiveTimedPresentation(1000));
+ }
+
private static GfxState Visible(long current)
{
var gfx = new GfxState();
diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs
index f738968..db29300 100644
--- a/engine/Age.Engine/Model/GfxState.cs
+++ b/engine/Age.Engine/Model/GfxState.cs
@@ -324,6 +324,17 @@ public sealed class GfxState
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0);
}
+ /// Native op 0x21c keeps presenting until both queued surface commands and finite one-shot
+ /// object channels have completed. Ambient cyclic/spritesheet/color pulses are deliberately excluded.
+ public bool HasActiveTimedPresentation(long nowMs)
+ {
+ lock (_lock)
+ return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
+ _objects.Values.Any(o => o.Visible &&
+ (o.OneShotColorEnabled || o.ScaleEnabled ||
+ o.RotationChannelEnabled || o.TranslationEnabled));
+ }
+
/// Click completion affects only type-0 foreground transitions, never ambient object channels.
public int CompleteForegroundTransitions(long nowMs)
{
diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs
index a611615..026f90c 100644
--- a/godot/GodotAdvHost.cs
+++ b/godot/GodotAdvHost.cs
@@ -15,13 +15,14 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary _slotDims = new() { { 0, (800, 600) } };
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly Age.Engine.Hosting.FrameClock _clock;
- private readonly Age.Engine.Hosting.WallClockOpPacer _opPacer;
private readonly GodotTimelineLog? _timeline;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private volatile bool _stopping;
private GfxState? _foregroundGfx;
public volatile bool IsWaiting;
public volatile bool IsTransitionWaiting;
+ public volatile bool IsSleeping;
+ private int _presentRequested = 1;
private long _transitionStartedAtMs = -1;
public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs);
public readonly List<(int Offset, string Text)> Captured = new();
@@ -31,7 +32,6 @@ public sealed class GodotAdvHost : IHost
{
_main = main; _res = res; _scene = scene; _clock = clock;
_timeline = timeline;
- _opPacer = new Age.Engine.Hosting.WallClockOpPacer(clock);
}
public void ShowText(int offset, string text)
@@ -51,7 +51,6 @@ public sealed class GodotAdvHost : IHost
_gate.Wait();
IsWaiting = false;
_timeline?.State("running", new() { ["input"] = "auto-or-user" });
- _opPacer.Reset();
_main.CallDeferred("ClearPage");
}
@@ -75,13 +74,13 @@ public sealed class GodotAdvHost : IHost
public void WaitForForegroundTransition(GfxState gfx)
{
int started = gfx.StartForegroundTransitions(_clock.NowMs);
- if (started == 0 && !gfx.HasActiveForegroundTransitions(_clock.NowMs)) return;
+ if (started == 0 && !gfx.HasActiveTimedPresentation(_clock.NowMs)) return;
_foregroundGfx = gfx;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, _clock.NowMs);
IsTransitionWaiting = true;
_timeline?.State("transition-start", new() { ["count"] = started });
int lastBucket = -1;
- while (gfx.HasActiveForegroundTransitions(_clock.NowMs) && !_stopping)
+ while (gfx.HasActiveTimedPresentation(_clock.NowMs) && !_stopping)
{
var active = gfx.SnapshotForegroundTransitions(_clock.NowMs);
int bucket = active.Count == 0 ? 100 : (int)System.Math.Floor(active[0].Progress * 10);
@@ -99,7 +98,6 @@ public sealed class GodotAdvHost : IHost
IsTransitionWaiting = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
_foregroundGfx = null;
- _opPacer.Reset();
_timeline?.State("running", new() { ["transition_complete"] = true });
}
@@ -112,8 +110,15 @@ public sealed class GodotAdvHost : IHost
{
["started"] = started, ["completed"] = completed,
});
+ System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
+ // Native retained-object writes are not front-buffer writes. The renderer publishes them only at an
+ // explicit present or while the interpreter is parked in a presentation-capable service boundary.
+ public bool ShouldRecomposite()
+ => IsWaiting || IsTransitionWaiting || IsSleeping ||
+ System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0;
+
public void Stop()
{
_stopping = true;
@@ -124,15 +129,9 @@ public sealed class GodotAdvHost : IHost
// Main thread, once per rendered frame: releases a VM thread parked in FrameYield/Sleep.
public void PulseFrame() => _frameSignal.Set();
- // Called once per executed opcode (IHost.FrameYield). After a frame's worth of ops (the clock's
- // budget), block the VM background thread until Main._Process advances the clock — throttling the
- // interpreter to ~budget ops per rendered frame (the native engine's rate-limited cadence).
- public void FrameYield()
- {
- _opPacer.OpcodeCompleted();
- while (!_opPacer.CanRunNext && !_stopping)
- _frameSignal.WaitOne(50);
- }
+ // Native presentation trace: ordinary opcode bursts run to the next service boundary in a few
+ // milliseconds and are not frame-paced. Pacing belongs to 0x21c, sleep, and input waits below.
+ public void FrameYield() { }
// op 0xc8: block the VM background thread so the main-thread compositor (Main.Recomposite in _Process)
// presents the current retained GfxState — this is what makes the sleep-paced opening burst animate.
@@ -147,12 +146,13 @@ public sealed class GodotAdvHost : IHost
long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window
long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
+ IsSleeping = true;
while (_clock.NowMs < deadline)
{
if (_stopping) break;
_frameSignal.WaitOne(50);
}
- _opPacer.Reset();
+ IsSleeping = false;
_timeline?.State("running", new() { ["sleep_complete"] = true });
}
diff --git a/godot/Main.cs b/godot/Main.cs
index 520d76d..87160eb 100644
--- a/godot/Main.cs
+++ b/godot/Main.cs
@@ -187,7 +187,8 @@ public partial class Main : Godot.Control
_clock.Advance(delta);
_timeline?.SetFrame(++_timelineFrame, _clock.NowMs);
_host?.PulseFrame();
- if (!_selftest && _vm != null) Recomposite(); // retained per-frame compositor (surface+object model)
+ if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite())
+ Recomposite(); // native publishes retained mutations only at present/service boundaries
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
diff --git a/tools/frida/capture_presentation_trace.py b/tools/frida/capture_presentation_trace.py
new file mode 100644
index 0000000..034443f
--- /dev/null
+++ b/tools/frida/capture_presentation_trace.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Correlate SC0000 bytecode offsets with native retained-state presentation.
+
+This read-only Frida probe records the narrow boundary needed to distinguish object mutation from
+displayed output: draw/color workers, object composition, surface-command consumption, gfx frame render,
+command-queue clear, and the D3D9 Present count. Each event carries the most recent VM codebase/word
+offset plus the native frame clock and dirty/queue fields.
+
+Start the game at the title screen, start this probe, then choose New Game so the hook sees SC0000 from
+entry. Stop after the first dialogue page:
+
+ py -3.11 -u -X utf8 tools/frida/capture_presentation_trace.py 30
+
+Output: build/native-presentation-trace.jsonl. No values are patched and the game is never slowed.
+"""
+import json
+import sys
+import time
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[2]
+OUT = REPO / "build" / "native-presentation-trace.jsonl"
+LIVE = REPO / "build" / "presentation-tracer-live.flag"
+
+JS = r"""
+const mod = Process.getModuleByName('AGE.EXE');
+const OFF = {
+ operand: 0x1b940, bind: 0x7e870, colorAnim: 0x7ea00, colorStatic: 0x7e9b0,
+ render: 0x820b0, commands: 0x7fbc0, composite: 0x7f650, clear: 0x7cb10
+};
+const IDX=0x53d14, PC=0x53d2c, CB=0x53d28, STRIDE=0x78;
+let ctx=null, current={codebase:0,offset:-1}, seq=0, presentCount=0, d3dHooked=false;
+
+function i32(p,o){ try{return p.add(o).readS32();}catch(e){return null;} }
+function u32(p,o){ try{return p.add(o).readU32();}catch(e){return null;} }
+function state(extra={}) {
+ const c=ctx;
+ return Object.assign({kind:'event',seq:++seq,t:Date.now(),codebase:current.codebase,
+ offset:current.offset,presents:presentCount,frameTime:c?u32(c,0xb550):null,
+ dirty:c?i32(c,0xb558):null,commandDirty:c?i32(c,0xb560):null,
+ commandCount:c?i32(c,0x41c):null},extra);
+}
+function emit(name,extra={}){ send(state(Object.assign({name:name},extra))); }
+function stackI(reg,n){ try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;} }
+
+function hookD3D(c) {
+ if (d3dHooked) return;
+ d3dHooked=true;
+ let d3d; try{d3d=Process.getModuleByName('d3d9.dll');}catch(e){emit('d3d-missing');return;}
+ const lo=d3d.base, hi=d3d.base.add(d3d.size), inside=p=>p.compare(lo)>=0&&p.compare(hi)<0;
+ let best=null,bestN=0;
+ for(let off=0;off<0x200000;off+=4){
+ let obj,vt; try{obj=c.add(off).readPointer();vt=obj.readPointer();}catch(e){continue;}
+ if(obj.isNull()||!inside(vt))continue;
+ let n=0; for(let s=0;s<120;s++){let fn;try{fn=vt.add(s*4).readPointer();}catch(e){break;}
+ if(inside(fn))n++;else if(s>3)break;}
+ if(n>bestN){bestN=n;best=vt;}
+ }
+ if(!best||bestN<60){emit('d3d-device-not-found',{methodRun:bestN});return;}
+ const fn=best.add(17*4).readPointer();
+ Interceptor.attach(fn,{onEnter(){presentCount++;}});
+ emit('d3d-present-hooked',{address:fn.toString(),methodRun:bestN});
+}
+
+Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){
+ const c=this.context.ecx; ctx=c; hookD3D(c);
+ try{const idx=c.add(IDX).readS32(); if(idx<0||idx>=64)return;
+ const pc=c.add(PC+idx*STRIDE).readU32(), cb=c.add(CB+idx*STRIDE).readU32();
+ current={codebase:cb>>>0,offset:((pc-cb)>>>2)};
+ }catch(e){}
+}});
+
+Interceptor.attach(mod.base.add(OFF.bind),{onEnter(){emit('bind-draw',{
+ handle:stackI(this.context,0),slot:stackI(this.context,1),src:[stackI(this.context,2),stackI(this.context,3),stackI(this.context,4),stackI(this.context,5)],
+ dst:[stackI(this.context,6),stackI(this.context,7)]});}});
+Interceptor.attach(mod.base.add(OFF.colorAnim),{onEnter(){emit('color-anim',{
+ handle:stackI(this.context,0),delay:stackI(this.context,1),duration:stackI(this.context,2),argb:stackI(this.context,3)>>>0});}});
+Interceptor.attach(mod.base.add(OFF.colorStatic),{onEnter(){emit('color-static',{
+ handle:stackI(this.context,0),mode:stackI(this.context,1),argb:stackI(this.context,2)>>>0});}});
+Interceptor.attach(mod.base.add(OFF.commands),{onEnter(){emit('surface-commands-enter');},onLeave(){emit('surface-commands-leave');}});
+Interceptor.attach(mod.base.add(OFF.render),{onEnter(){ctx=this.context.ecx;emit('render-enter');},onLeave(){emit('render-leave');}});
+Interceptor.attach(mod.base.add(OFF.clear),{onEnter(){emit('queue-clear-enter');},onLeave(){emit('queue-clear-leave');}});
+Interceptor.attach(mod.base.add(OFF.composite),{onEnter(args){
+ const h=args[0].toUInt32();
+ if(h>=0xcb00 && h<=0xd400) emit('composite',{handle:h});
+}});
+send({kind:'ready',base:mod.base.toString()});
+"""
+
+
+def main():
+ import frida
+ seconds = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 30
+ proc = sys.argv[2] if len(sys.argv) > 2 else "AGE.EXE"
+ target = int(proc) if proc.isdigit() else proc
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ f = OUT.open("w", encoding="utf-8")
+ counts = {}
+
+ def on_message(msg, data):
+ if msg.get("type") == "error":
+ print("[frida-error]", msg.get("description")); return
+ if msg.get("type") != "send": return
+ row = msg["payload"]
+ if row.get("kind") == "ready":
+ print(f"[frida] presentation hooks live @ {row['base']}"); return
+ f.write(json.dumps(row, ensure_ascii=False) + "\n"); f.flush()
+ name = row.get("name", "?"); counts[name] = counts.get(name, 0) + 1
+ if name in {"bind-draw", "color-anim", "color-static", "render-enter", "render-leave",
+ "surface-commands-enter", "queue-clear-enter"}:
+ print(f" #{row['seq']:05d} off=0x{row['offset']:05x} {name:22s} "
+ f"h={('0x%x' % row['handle']) if row.get('handle') is not None else '-':>8s} "
+ f"present={row['presents']} q={row['commandCount']}")
+
+ try:
+ session = frida.attach(target)
+ except frida.ProcessNotFoundError:
+ print("[frida] AGE.EXE not found; leave the native game at the title screen first.")
+ f.close(); return 2
+ script = session.create_script(JS); script.on("message", on_message); script.load()
+ LIVE.write_text("live", encoding="utf-8")
+ print(f"[frida] capture armed for {seconds}s. Choose New Game now; stop after the first page.")
+ try:
+ time.sleep(seconds)
+ except KeyboardInterrupt:
+ pass
+ try: session.detach()
+ except Exception: pass
+ f.close()
+ try: LIVE.unlink()
+ except OSError: pass
+ print(f"[trace] wrote {sum(counts.values())} events -> {OUT}")
+ print("[trace] " + ", ".join(f"{k}={v}" for k,v in sorted(counts.items())))
+ return 0 if counts else 3
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml
index d8cad0f..3165c85 100644
--- a/vm-map/opcodes.toml
+++ b/vm-map/opcodes.toml
@@ -5110,7 +5110,7 @@ abi_source = "kelebek+decode-validated"
name = "present-frame"
category = "draw"
summary = "Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately."
-details = "Native handler gfx_op_0x20c_present_frame (dispatch ctx[0x26c93+0x20c]) -> gfx_render_frame @0x4820b0. Godot composites continuously. In label_1235a the OR of 0x1c7/0x1cc is nonzero on the read/message-skip branch, which resets the animation service then presents; the port starts and snaps any pending 0x223 transition to its endpoint here. Normal zero-state playback branches to 0x21c, which owns wait/resume. Headless hosts remain non-blocking. Kelebek label u00416200 was VA-drift."
+details = "Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking."
noop_headless = false
source = "investigation"
confidence = "high"
@@ -5468,13 +5468,13 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "mark-frame-yield"
category = "control"
-summary = "Set native run-state bit 0x400; in normal ADV playback this is the queued foreground-transition yield/resume boundary."
-details = "SC0000 label_1235a reaches this when the OR of 0x1c7 message-skip and 0x1cc read-skip state is zero (normal playback). Native run-state bit 0x400 yields the interpreter while the queued foreground presentation advances. The interactive port starts pending 0x223 commands here, parks only the VM thread while per-frame compositing continues, and resumes after natural or click-forced completion."
+summary = "Set native run-state bit 0x400; in normal ADV playback this is the retained-presentation render/wait/resume boundary."
+details = "SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. The port publishes and waits for visible finite one-shot channels or 0x223 commands; click forcing remains limited to the latter."
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = [0x223, 0x1c7, 0x1cc]
-evidence = "Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. SC0000 label_1235a's jcc reaches it when the OR of op 0x1c7/0x1cc is zero; synchronized port trace confirmed this is the normal path after 0x223."
+evidence = "Ghidra handler 0x417520 sets cmd-type 1 and ORs ctx+0xa0ce4 with 0x400. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10."
[[opcode]]
op = 0x21d