Fix native retained presentation batching

This commit is contained in:
gamer147
2026-07-10 21:37:42 -04:00
parent 992215cc48
commit 82afcf5506
10 changed files with 236 additions and 36 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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=(pccodebase)/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 `X0x400000`.)*
## Native engine RE (Ghidra)

View File

@@ -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();

View File

@@ -324,6 +324,17 @@ public sealed class GfxState
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0);
}
/// <summary>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.</summary>
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));
}
/// <summary>Click completion affects only type-0 foreground transitions, never ambient object channels.</summary>
public int CompleteForegroundTransitions(long nowMs)
{

View File

@@ -15,13 +15,14 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, (int W, int H)> _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 });
}

View File

@@ -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)

View File

@@ -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())

View File

@@ -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