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