Fix native retained presentation batching
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
138
tools/frida/capture_presentation_trace.py
Normal file
138
tools/frida/capture_presentation_trace.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user