Implement native ADV retained text

This commit is contained in:
gamer147
2026-07-10 22:46:27 -04:00
parent f94bca4ba5
commit 921c91474f
14 changed files with 401 additions and 38 deletions

View File

@@ -923,6 +923,45 @@ NOT the predicted coroutine yield (`0x140`/`~0x50f`); the oracle reports whateve
surfaced an earlier state hole. **This is now the repeatable way to localize a mis-modeled op/state.** surfaced an earlier state hole. **This is now the repeatable way to localize a mis-modeled op/state.**
Phase-2 extension (deferred): effects-diff (global-bank / gfx-registry writes) for branchy scenes. Phase-2 extension (deferred): effects-diff (global-bank / gfx-registry writes) for branchy scenes.
### ADV retained text — ops `0x7a` / `0x204` and show-text publication (2026-07-10)
The SC0000 textbox uses two related native paths under the text manager at `ctx+0x14940`:
- **Op `0x7a` (`op_0x7a_handler@0x41eba0`) is `set-adv-text-cursor(layoutSlot,x,y)`.**
`adv_text_set_cursor@0x4530f0` treats slot 0 as the current slot at manager `+0x4c8`, resolves
`manager+0x414[slot]`, and `text_layout_set_cursor@0x452530` writes x/y to `+4/+8` of that
layout's last 20-byte record. SC0000 `0x9d3` and `0xbbf` set slot 1 to `(75,47)` for voiced
pages. The reset narration record is `(100,47,720,147,0)`.
- **Op `0x204` (`op_0x204_handler@0x422a60`) is immediate `draw-string(surface,x,y,string)`.**
`draw_string_to_surface@0x450150` locks the numbered D3D surface, selects the uncached or cached/effect
raster worker, consumes CP932 characters through GDI `GetGlyphOutlineA`, blends the bitmap in
`text_blit_glyph_bitmap@0x458c80`, and unlocks. Font/color/effect state is retained around manager
`+0x450/+0x458/+0x4d0/+0x544/+0x558`; the observed ADV glyph is white with a one-pixel `0x606060`
outline and advances 25 pixels. At SC0000 `0x9b2`, surface 13 is 400x30 and receives `"魔王"` at
`(1,1)`; the following `0x1fb` binds it to retained object `0xe678` at `(74,444)`, so the name begins
at screen `(75,445)`.
Show-text (`0x6e`) is the timed companion, not an immediate Label write. `adv_text_build_glyph_records`
`@0x4576c0` measures and rasterizes the complete CP932 line into one 20-byte record per glyph. Layout slot
1 has origin `(0,430)`, bounds `(720,147)`, reveal handle base `55000` (`0xd6d8`), and source surface
`slot+0x14 = 21`. `adv_text_publish_next_glyph@0x451220` advances the reveal index and publishes each
record with `gfx_object_bind_draw`. Native SC0000 records `ctx+0x14e9c = 50 ms`: page 1 builds 13 records
at `0x834`, then publishes them one at a time before reaching wait `0x83c`. A click during reveal completes
the remaining records and is consumed; the next click releases the stable wait.
The port retains the SC0000-visible contract without exposing thousands of individual host glyph objects:
surface strings remain associated with blank surface slots for later retained-object binding, while ADV
lines retain cursor/origin, start time, visible-glyph count, and completion state. The compositor renders
the surface-13 name at the bound `0xe678` transform and the dialogue at origin+cursor. `ShowText` parks the
VM until the 50 ms/glyph service completes or a click forces completion, so the completion click cannot
pre-arm the following `wait-for-input`. Local/global string-pointer operand tags (8/14) are now resolved by
the VM, which is required for SC0000's `lookup-array local-string-ptr -> draw-string` name path.
Matching evidence: `build/native-adv-text-trace.jsonl` and Godot timeline captures at `0x834`, `0x9b2`,
`0x9d3`, and `0xa0d`. Windowed page-1 pixels place glyphs at x=100 and native y=477; voiced page 7 has
non-overlapping name/dialogue bands at y=447468, 478500, and 507530. A manual run progressed 14 pages:
11 clicks completed active reveals and 14 later clicks released 14 distinct waits through `0xe0c`.
### Scene-entry state snapshot — auto-seeding single-scene runs (2026-07-09) ### Scene-entry state snapshot — auto-seeding single-scene runs (2026-07-09)
**Problem the oracle surfaced:** single-scene VM runs diverge from the engine because they lack the **Problem the oracle surfaced:** single-scene VM runs diverge from the engine because they lack the

View File

@@ -5,10 +5,15 @@
## adv ## adv
### 0x7a `text-param?` (u0041AD70, argc 3) ### 0x7a `set-adv-text-cursor` (set-adv-text-cursor, argc 3)
- **summary:** 3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param - **summary:** (layout_slot)(x)(y) - set the cursor in the selected ADV text layout's last 20-byte record. Slot 0 selects the current layout.
- **grounding:** source=inference, confidence=med - **grounding:** source=investigation, confidence=high
- **evidence:** confirm via frida - **evidence:** Ghidra /v2: op_0x7a_handler@0x41eba0 fetches operands 3,2,1 and calls adv_text_set_cursor@0x4530f0 on text manager ctx+0x14940. Slot 0 resolves manager+0x4c8; manager+0x414[slot] selects the layout; text_layout_set_cursor@0x452530 writes x/y to +4/+8 of its last 0x14-byte record. SC0000 0x9d3 computes slot 1, x=75, y=47 before voiced show-text.
### 0x204 `draw-string` (draw-string, argc 4)
- **summary:** (surface_slot)(x)(y)(string) - rasterize a CP932 string immediately into a numbered graphics surface using current font/color/effect state.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x204_handler@0x422a60 resolves operand 4 as a string, fetches surface/x/y, then calls draw_string_to_surface@0x450150 on text manager ctx+0x14940. The worker validates and locks gfx-manager surface table +0xa590[slot], chooses text_raster_string_uncached@0x459d90 or cached/effect path @0x45b600, rasterizes GDI GetGlyphOutlineA bitmaps through text_blit_glyph_bitmap@0x458c80 using font/color state +0x4d0/+0x458, then unlocks. SC0000 0x9b2 draws the speaker name into 400x30 surface 0xd at (1,1); following 0x1fb binds it to retained object 0xe678 at (74,444).
## audio ## audio
@@ -981,10 +986,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x204 `draw-string` (draw-string, argc 4)
- **summary:** —
- **grounding:** source=kelebek, confidence=med
### 0x205 `u00420A60` (u00420A60, argc 6) ### 0x205 `u00420A60` (u00420A60, argc 6)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -1016,3 +1016,36 @@ threaded `SELFTEST OK`; all seven Python suites, opcode/ctx lint, 481-script dec
Normal-speed windowed capture wrote 220 PNGs and progressed through the complete AE sequence; at the target 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. 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. This slice was committed as `85fc07d`. Ghidra `/v2` comments were updated and saved. This slice was committed as `85fc07d`.
### A2b — native ADV retained text (`0x7a` / `0x204`) ✅ DONE (2026-07-10)
The prerequisite input check passed before text work: the unmodified port reached page-1
`wait-for-input@0x83c` in about 2.0 seconds and a manual run released 14 consecutive waits through page 15.
Clicks were independently healthy, so no scheduler/input workaround was folded into this slice.
Native `/v2` dispatch resolves `0x7a` to `op_0x7a_handler@0x41eba0` and `0x204` to
`op_0x204_handler@0x422a60`. Op `0x7a` selects layout slot 1 and writes the last 20-byte record's cursor;
SC0000 voiced pages compute `(75,47)`, while narration resets to `(100,47)`. Layout origin is `(0,430)`,
bounds are `(720,147)`, retained glyph handles begin at `0xd6d8`, and glyph source surface is 21.
Op `0x204` locks a numbered D3D surface and GDI-rasterizes its CP932 string with retained font/color/effect
state. SC0000 `0x9b2` draws `"魔王"` to 400x30 surface 13 at `(1,1)`; `0x1fb@0x9bb` binds that surface to
object `0xe678` at `(74,444)`. Show-text builds all glyph records, then publishes one every 50 ms; a click
completes and consumes an active reveal before the VM can reach the following stable wait.
The port now handles both opcodes and string-pointer types 8/14. Blank-surface text is retained by slot and
drawn through the bound object's transform (the SC0000 speaker-name path), while ADV text retains cursor,
origin, 50 ms/glyph progress, and forced completion. The prior top-left dev Label/status presentation is
gone. Page-1 window pixels show the black 800x227 textbox with text at native `(100,477)`; voiced page 7
has separate name/dialogue bands at y=447468, 478500, and 507530. Final manual validation progressed
14 pages, with 11 completion clicks consumed during reveal and 14 later clicks releasing 14 waits.
Ghidra `/v2` was renamed/commented throughout the handler, layout, raster, surface-lock, and publication
chain and saved. The new low-frequency `capture_adv_text_trace.py` records matching native offsets/state;
an initial experimental version combining a D3D device scan with hot per-glyph/render hooks crashed in
`frida-agent.dll` at teardown, so those hooks were removed and the safe narrowed rerun left AGE alive.
**Validation:** engine **110/110**; corpus sweep unchanged at **284 exit / 13 STEP-LIMIT**; Godot build
clean apart from the pre-existing nullable warning and threaded `SELFTEST OK`; all seven Python suites,
opcode/ctx lint, 481-script decode, RECOVER, and `git diff --check` clean. SC0000 coverage rises from
**85/129 to 87/129 handled (67.4%)**, with 42 GAP ops / 188 GAP instructions. Movie `0x236` and SFX remain
separate slices. No implementation commit was made.

View File

@@ -178,6 +178,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
| `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_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` | | `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` |
| `tools/frida/capture_adv_text_trace.py` | **ADV text trace:** correlates SC offsets with op `0x6e`/`0x7a`/`0x204`, layout cursor/origin and 20-byte record counts, CP932 strings, surface draw/bind, and timed glyph-record publication. Read-only and deliberately limited to low-frequency known handlers; the first experimental version's D3D scan plus hot per-glyph/render hooks crashed in `frida-agent.dll` during teardown and was removed. | `py -3.11 -u -X utf8 tools/frida/capture_adv_text_trace.py [secs] [pid\|AGE.EXE]` | native game → `build/native-adv-text-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`.)* *(Static disassembly of `build/engine-dump/range_00400000.bin` uses **capstone** — `py -3.11 -m pip install capstone`; VA `X` → file offset `X0x400000`.)*

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class AdvTextOpsTests
{
[Fact]
public void TextCursorAndDrawStringReachHostWithLocalStringPointer()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
int lookup = table.ByLabel("lookup-array")!.Value;
var script = ScriptAssembler.Assemble(table, "ADVTEXT",
new List<(int, Operand[])>
{
(lookup, new[] { new Operand(14, 0), new Operand(5, 0x315), new Operand(0, 2) }),
(0x204, new[] { new Operand(0, 13), new Operand(0, 1), new Operand(0, 1), new Operand(14, 0) }),
(0x7a, new[] { new Operand(0, 1), new Operand(0, 75), new Operand(0, 47) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(script, table, host);
vm.GlobalStrings[0x317] = "speaker";
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal((1, 75, 47), Assert.Single(host.TextCursors));
Assert.Equal((13, 1, 1, "speaker"), Assert.Single(host.SurfaceStrings));
}
}

View File

@@ -14,8 +14,13 @@ internal sealed class RecordingHost : IHost
public bool MessageSkip; public bool MessageSkip;
public bool AdvReadSkip; public bool AdvReadSkip;
public readonly List<(int Offset, string Text)> Lines = new(); public readonly List<(int Offset, string Text)> Lines = new();
public readonly List<(int Slot, int X, int Y)> TextCursors = new();
public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new();
public readonly List<long> SleptDurations = new(); public readonly List<long> SleptDurations = new();
public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void ShowText(int offset, string text) => Lines.Add((offset, text));
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
=> SurfaceStrings.Add((surfaceSlot, x, y, text));
public void WaitForInput() => Waits++; public void WaitForInput() => Waits++;
public void Sleep(long duration) => SleptDurations.Add(duration); public void Sleep(long duration) => SleptDurations.Add(duration);
public void FrameYield() { } public void FrameYield() { }

View File

@@ -4,6 +4,10 @@ namespace Age.Engine.Hosting;
public interface IHost public interface IHost
{ {
void ShowText(int offset, string text); void ShowText(int offset, string text);
// Native ADV text subsystem: op 0x7a updates the selected layout's last 20-byte cursor record;
// op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface.
void SetAdvTextCursor(int layoutSlot, int x, int y) { }
void DrawStringToSurface(int surfaceSlot, int x, int y, string text) { }
void WaitForInput(); void WaitForInput();
void Sleep(long duration); void Sleep(long duration);
void FrameYield(); void FrameYield();

View File

@@ -5,4 +5,5 @@ public sealed class Frame
public Dictionary<int, long> F = new(); // local-float (raw) public Dictionary<int, long> F = new(); // local-float (raw)
public Dictionary<int, string> S = new(); // local-string public Dictionary<int, string> S = new(); // local-string
public Dictionary<int, long> P = new(); // local-ptr (holds a global address) public Dictionary<int, long> P = new(); // local-ptr (holds a global address)
public Dictionary<int, long> SP = new(); // local-string-ptr (holds a global-string address)
} }

View File

@@ -10,7 +10,8 @@ public sealed class VirtualMachine
private const int FRAME_RETURN = int.MinValue + 1; private const int FRAME_RETURN = int.MinValue + 1;
private const int SceneEntryCoroutineGate = 0xaba5c; private const int SceneEntryCoroutineGate = 0xaba5c;
private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6, private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6,
T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12; T_GSTRPTR = 8, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12,
T_LSTRPTR = 14;
private readonly Script _s; private readonly Script _s;
private readonly OpcodeTable _t; private readonly OpcodeTable _t;
@@ -40,7 +41,7 @@ public sealed class VirtualMachine
private static long PyDiv(long a, long b) { if (b == 0) return 0; long q = a / b, r = a % b; if (r != 0 && (r < 0) != (b < 0)) q--; return q; } private static long PyDiv(long a, long b) { if (b == 0) return 0; long q = a / b, r = a % b; if (r != 0 && (r < 0) != (b < 0)) q--; return q; }
private static long PyMod(long a, long b) { if (b == 0) return 0; long r = a % b; if (r != 0 && (r < 0) != (b < 0)) r += b; return r; } private static long PyMod(long a, long b) { if (b == 0) return 0; long r = a % b; if (r != 0 && (r < 0) != (b < 0)) r += b; return r; }
private static bool IsStr(Operand o) => o.Type == T_STR || o.Type == T_GSTR || o.Type == T_LSTR; private static bool IsStr(Operand o) => o.Type is T_STR or T_GSTR or T_GSTRPTR or T_LSTR or T_LSTRPTR;
private static bool SameOperand(Operand a, Operand b) => a.Type == b.Type && a.Value == b.Value; private static bool SameOperand(Operand a, Operand b) => a.Type == b.Type && a.Value == b.Value;
private static bool IsAdvLabeledYield(Script script, Instruction ins) private static bool IsAdvLabeledYield(Script script, Instruction ins)
@@ -96,7 +97,9 @@ public sealed class VirtualMachine
{ {
T_STR => _cur.Script.GetString((int)op.Value), T_STR => _cur.Script.GetString((int)op.Value),
T_GSTR => Gs(GlobalStrings, (int)op.Value), T_GSTR => Gs(GlobalStrings, (int)op.Value),
T_GSTRPTR => Gs(GlobalStrings, (int)Gi(Globals, (int)op.Value)),
T_LSTR => Gs(_cur.Locals.S, (int)op.Value), T_LSTR => Gs(_cur.Locals.S, (int)op.Value),
T_LSTRPTR => Gs(GlobalStrings, (int)Gi(_cur.Locals.SP, (int)op.Value)),
_ => "", _ => "",
}; };
@@ -105,13 +108,15 @@ public sealed class VirtualMachine
switch (op.Type) switch (op.Type)
{ {
case T_GSTR: GlobalStrings[(int)op.Value] = val; break; case T_GSTR: GlobalStrings[(int)op.Value] = val; break;
case T_GSTRPTR: GlobalStrings[(int)Gi(Globals, (int)op.Value)] = val; break;
case T_LSTR: _cur.Locals.S[(int)op.Value] = val; break; case T_LSTR: _cur.Locals.S[(int)op.Value] = val; break;
case T_LSTRPTR: GlobalStrings[(int)Gi(_cur.Locals.SP, (int)op.Value)] = val; break;
} }
} }
private long BaseAddr(Operand op) => op.Type switch private long BaseAddr(Operand op) => op.Type switch
{ {
T_IMM or T_GINT or T_GFLOAT or T_GSTR or T_GPTR => op.Value, T_IMM or T_GINT or T_GFLOAT or T_GSTR or T_GPTR or T_GSTRPTR => op.Value,
T_LINT => Gi(_cur.Locals.I, (int)op.Value), T_LINT => Gi(_cur.Locals.I, (int)op.Value),
T_LPTR => Gi(_cur.Locals.P, (int)op.Value), T_LPTR => Gi(_cur.Locals.P, (int)op.Value),
_ => op.Value, _ => op.Value,
@@ -122,7 +127,9 @@ public sealed class VirtualMachine
switch (dst.Type) switch (dst.Type)
{ {
case T_LPTR: _cur.Locals.P[(int)dst.Value] = addr; break; case T_LPTR: _cur.Locals.P[(int)dst.Value] = addr; break;
case T_LSTRPTR: _cur.Locals.SP[(int)dst.Value] = addr; break;
case T_GPTR: Globals[(int)dst.Value] = addr; break; case T_GPTR: Globals[(int)dst.Value] = addr; break;
case T_GSTRPTR: Globals[(int)dst.Value] = addr; break;
default: Write(dst, Gi(Globals, (int)addr)); break; default: Write(dst, Gi(Globals, (int)addr)); break;
} }
} }
@@ -271,6 +278,11 @@ public sealed class VirtualMachine
_host.ShowText(off, text); _host.ShowText(off, text);
} }
return pc + 1; return pc + 1;
case "set-adv-text-cursor": // 0x7a (layout slot, x, y); slot 0 means current natively
_host.SetAdvTextCursor((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1;
case "draw-string": // 0x204 (surface slot, x, y, string)
_host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]));
return pc + 1;
case "wait-for-input": case "wait-for-input":
// Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions).
if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; }

View File

@@ -18,10 +18,17 @@ public sealed class GodotAdvHost : IHost
private readonly GodotTimelineLog? _timeline; private readonly GodotTimelineLog? _timeline;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false); private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private volatile bool _stopping; private volatile bool _stopping;
private readonly object _textLock = new();
private readonly Dictionary<int, SurfaceTextDraw> _surfaceText = new();
private string _advText = "";
private int _advTextX = 100, _advTextY = 47;
private long _advTextStartedMs;
private bool _advTextForceComplete;
private GfxState? _foregroundGfx; private GfxState? _foregroundGfx;
public volatile bool IsWaiting; public volatile bool IsWaiting;
public volatile bool IsTransitionWaiting; public volatile bool IsTransitionWaiting;
public volatile bool IsSleeping; public volatile bool IsSleeping;
public volatile bool IsTextRevealing;
private int _presentRequested = 1; private int _presentRequested = 1;
private long _transitionStartedAtMs = -1; private long _transitionStartedAtMs = -1;
public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs); public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs);
@@ -37,9 +44,56 @@ public sealed class GodotAdvHost : IHost
public void ShowText(int offset, string text) public void ShowText(int offset, string text)
{ {
Captured.Add((offset, text)); Captured.Add((offset, text));
_main.CallDeferred("AppendLine", text); lock (_textLock)
{
_advText = text;
_advTextStartedMs = _clock.NowMs;
_advTextForceComplete = false;
IsTextRevealing = text.Length > 0;
}
_timeline?.State("text-reveal", new()
{
["offset"] = $"0x{offset:x}", ["x"] = _advTextX, ["y"] = _advTextY,
["glyphs"] = text.Length, ["delay_ms"] = 50,
});
while (IsTextRevealing && !_stopping)
{
lock (_textLock)
{
if (_advTextForceComplete || _clock.NowMs - _advTextStartedMs >= text.Length * 50L)
IsTextRevealing = false;
}
if (IsTextRevealing) _frameSignal.WaitOne(50);
}
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
} }
public void SetAdvTextCursor(int layoutSlot, int x, int y)
{
lock (_textLock) { _advTextX = x; _advTextY = y; }
_timeline?.Event("text-cursor", new() { ["slot"] = layoutSlot, ["x"] = x, ["y"] = y });
}
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
{
lock (_textLock) _surfaceText[surfaceSlot] = new SurfaceTextDraw(x, y, text);
_timeline?.Event("draw-string", new() { ["surface"] = surfaceSlot, ["x"] = x, ["y"] = y, ["text"] = text });
}
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
{
lock (_textLock)
{
int visible = _advTextForceComplete || !IsTextRevealing
? _advText.Length
: (int)System.Math.Clamp((_clock.NowMs - _advTextStartedMs) / 50L + 1, 0, _advText.Length);
return (_advText, _advTextX, _advTextY, visible, IsTextRevealing);
}
}
public bool TryGetSurfaceText(int surfaceSlot, out SurfaceTextDraw draw)
{ lock (_textLock) return _surfaceText.TryGetValue(surfaceSlot, out draw); }
public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race) public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
public void WaitForInput() public void WaitForInput()
@@ -51,6 +105,12 @@ public sealed class GodotAdvHost : IHost
_gate.Wait(); _gate.Wait();
IsWaiting = false; IsWaiting = false;
_timeline?.State("running", new() { ["input"] = "auto-or-user" }); _timeline?.State("running", new() { ["input"] = "auto-or-user" });
lock (_textLock)
{
_advText = "";
_advTextX = 100;
_advTextY = 47;
}
_main.CallDeferred("ClearPage"); _main.CallDeferred("ClearPage");
} }
@@ -58,6 +118,13 @@ public sealed class GodotAdvHost : IHost
// the foreground lifecycle; it never pre-arms or advances the following stable input wait. // the foreground lifecycle; it never pre-arms or advances the following stable input wait.
public void SignalInput() public void SignalInput()
{ {
if (IsTextRevealing)
{
lock (_textLock) _advTextForceComplete = true;
_timeline?.State("text-reveal-forced-complete", new());
_frameSignal.Set();
return;
}
if (IsTransitionWaiting && _foregroundGfx != null) if (IsTransitionWaiting && _foregroundGfx != null)
{ {
int completed = _foregroundGfx.CompleteForegroundTransitions(_clock.NowMs); int completed = _foregroundGfx.CompleteForegroundTransitions(_clock.NowMs);
@@ -116,12 +183,13 @@ public sealed class GodotAdvHost : IHost
// Native retained-object writes are not front-buffer writes. The renderer publishes them only at an // 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. // explicit present or while the interpreter is parked in a presentation-capable service boundary.
public bool ShouldRecomposite() public bool ShouldRecomposite()
=> IsWaiting || IsTransitionWaiting || IsSleeping || => IsWaiting || IsTransitionWaiting || IsSleeping || IsTextRevealing ||
System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0; System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0;
public void Stop() public void Stop()
{ {
_stopping = true; _stopping = true;
lock (_textLock) _advTextForceComplete = true;
if (_gate.CurrentCount == 0) _gate.Release(); if (_gate.CurrentCount == 0) _gate.Release();
_frameSignal.Set(); _frameSignal.Set();
} }
@@ -160,12 +228,14 @@ public sealed class GodotAdvHost : IHost
public void CreateTexture(int slot, int width, int height) public void CreateTexture(int slot, int width, int height)
{ {
lock (_textLock) _surfaceText.Remove(slot);
_slotBmp[slot] = null; _slotDims[slot] = (width, height); _slotBmp[slot] = null; _slotDims[slot] = (width, height);
if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}"); if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}");
} }
public void SetTexture(long resourceId, int slot) public void SetTexture(long resourceId, int slot)
{ {
lock (_textLock) _surfaceText.Remove(slot);
var asset = _res.Resolve(_scene, resourceId); var asset = _res.Resolve(_scene, resourceId);
var bmp = asset != null ? ResourceMap.TexturePath(asset) : null; var bmp = asset != null ? ResourceMap.TexturePath(asset) : null;
_slotBmp[slot] = bmp; _slotBmp[slot] = bmp;
@@ -206,3 +276,5 @@ public sealed class GodotAdvHost : IHost
if (path != null) _main.CallDeferred("PlayVoice", path); if (path != null) _main.CallDeferred("PlayVoice", path);
} }
} }
public readonly record struct SurfaceTextDraw(int X, int Y, string Text);

View File

@@ -14,6 +14,7 @@ public partial class Main : Godot.Control
private Image _screen = null!; // 800x600 immediate-mode canvas private Image _screen = null!; // 800x600 immediate-mode canvas
private ImageTexture _screenTex = null!; private ImageTexture _screenTex = null!;
private Label _text = null!; private Label _text = null!;
private Label _speaker = null!;
private Label _status = null!; private Label _status = null!;
private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _bgm = null!; // looping background music
private AudioStreamPlayer _voice = null!; // interrupt-on-new voice private AudioStreamPlayer _voice = null!; // interrupt-on-new voice
@@ -61,26 +62,35 @@ public partial class Main : Godot.Control
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
AddChild(_screenView); // added first -> draws behind the text/status labels AddChild(_screenView); // added first -> draws behind the text/status labels
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart }; _text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_text.OffsetLeft = 40; _text.OffsetTop = 40; _text.OffsetRight = -40; _text.OffsetBottom = -80;
AddChild(_text); AddChild(_text);
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
AddChild(_speaker);
_status = new Label(); _status = new Label();
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide); _status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
_status.OffsetLeft = 40; _status.OffsetTop = -60; _status.OffsetLeft = 40; _status.OffsetTop = -60;
AddChild(_status); AddChild(_status);
// Best-effort CJK font so the visual isn't tofu (headless self-test doesn't depend on it). // Best-effort CJK font so the visual isn't tofu (headless self-test doesn't depend on it).
foreach (var fp in new[] { "C:/Windows/Fonts/YuGothM.ttc", "C:/Windows/Fonts/YuGothR.ttc", foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
"C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/meiryo.ttc" }) "C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" })
{ {
if (!System.IO.File.Exists(fp)) continue; if (!System.IO.File.Exists(fp)) continue;
try try
{ {
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) }; var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
_text.AddThemeFontOverride("font", ff); _text.AddThemeFontOverride("font", ff);
_speaker.AddThemeFontOverride("font", ff);
_status.AddThemeFontOverride("font", ff); _status.AddThemeFontOverride("font", ff);
_text.AddThemeFontSizeOverride("font_size", 22); _text.AddThemeFontSizeOverride("font_size", 25);
_speaker.AddThemeFontSizeOverride("font_size", 25);
_text.AddThemeConstantOverride("outline_size", 1);
_speaker.AddThemeConstantOverride("outline_size", 1);
var outline = new Color(0x60 / 255f, 0x60 / 255f, 0x60 / 255f, 1);
_text.AddThemeColorOverride("font_outline_color", outline);
_speaker.AddThemeColorOverride("font_outline_color", outline);
break; break;
} }
catch { /* fall back to the default font */ } catch { /* fall back to the default font */ }
@@ -189,6 +199,7 @@ public partial class Main : Godot.Control
_host?.PulseFrame(); _host?.PulseFrame();
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite()) if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite())
Recomposite(); // native publishes retained mutations only at present/service boundaries Recomposite(); // native publishes retained mutations only at present/service boundaries
if (!_selftest && _host != null) UpdateAdvTextPresentation();
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be // --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. // verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done) if (_seqDir != null && _seqIdx < _seqFrames && !_done)
@@ -265,6 +276,7 @@ public partial class Main : Godot.Control
private void Recomposite() private void Recomposite()
{ {
_screen.Fill(new Color(0, 0, 0, 0)); _screen.Fill(new Color(0, 0, 0, 0));
_speaker.Visible = false;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null; System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
int z = 0; int z = 0;
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // one synchronized sample for objects + ranges var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // one synchronized sample for objects + ranges
@@ -322,12 +334,30 @@ public partial class Main : Godot.Control
} }
} }
decisions?.Add(v.Handle, $"z{z} {outcome}"); decisions?.Add(v.Handle, $"z{z} {outcome}");
var rawObject = _vm.Gfx.TryGet(v.Handle);
if (rawObject != null && _host.TryGetSurfaceText(rawObject.SourceSlot, out var surfaceText))
{
var textPos = localToDest.Apply(surfaceText.X, surfaceText.Y);
_speaker.Position = new Vector2((float)textPos.X, (float)textPos.Y);
_speaker.Size = new Vector2(System.Math.Max(1, v.W - surfaceText.X), System.Math.Max(1, v.H - surfaceText.Y));
_speaker.Text = surfaceText.Text;
_speaker.Visible = true;
}
z++; z++;
} }
_screenTex.Update(_screen); _screenTex.Update(_screen);
if (decisions != null) LogGfxDecisionChanges(decisions); if (decisions != null) LogGfxDecisionChanges(decisions);
} }
private void UpdateAdvTextPresentation()
{
var t = _host.SnapshotAdvText();
_text.Position = new Vector2(t.X, 430 + t.Y);
_text.Size = new Vector2(System.Math.Max(1, 720 - t.X), System.Math.Max(1, 147 - t.Y));
int count = System.Math.Clamp(t.VisibleGlyphs, 0, t.Text.Length);
_text.Text = count == 0 ? "" : t.Text[..count];
}
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state) private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
=> state is { } c => state is { } c
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}" ? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
@@ -491,7 +521,7 @@ public partial class Main : Godot.Control
} }
public void AppendLine(string text) => _text.Text += text + "\n"; public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak() { _pageCount++; _status.Text = "▼ click / Enter"; } public void PageBreak() { _pageCount++; _status.Text = ""; }
public void ClearPage() { _text.Text = ""; _status.Text = ""; } public void ClearPage() { _text.Text = ""; _status.Text = ""; }
public void ShowEnd() => _status.Text = "— end —"; public void ShowEnd() => _status.Text = "— end —";

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
INFERRED: dict[int, dict] = { INFERRED: dict[int, dict] = {
0x71: dict(name='label-def', category='structural', noop=True, confidence='high', source='investigation', summary='1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id'), 0x71: dict(name='label-def', category='structural', noop=True, confidence='high', source='investigation', summary='1 imm; count == T1 table size -> the label/anchor T1 indexes. v1 no-op; revisit if menu/callback dispatch looks up by id'),
0x7a: dict(name='text-param?', category='adv', noop=False, confidence='med', source='inference', summary='3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param'),
0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'), 0x7b: dict(name='coroutine-save-yield-handlers', category='control', noop=False, confidence='high', source='investigation', summary='(handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index) + gfx cmd-type 5. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).'),
0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'), 0x7c: dict(name='coroutine-resume', category='control', noop=False, confidence='high', source='investigation', summary='() — scene-coroutine RESUME point. Native requires run-state bit 0x2000000 (ctx[0x6dbc8]) set — THROWS (__CxxThrowException) if unset, so it is only ever reached on a scheduler-driven re-entry, NEVER on a cold first pass (cold flow jmps over it). Restores PC=ctx[0x53d28]+ctx[0x6dbcc]*4, clears the run-bit (ctx+0xa0ce4 &= ~0x2000000), resets input/line state. SC0000 0x443 (falls into the main loop label_444). See engine-re.md §Scene-coroutine framework.'),
0x90: dict(name='hotspot-branch', category='input', noop=True, confidence='high', source='investigation', summary='cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1'), 0x90: dict(name='hotspot-branch', category='input', noop=True, confidence='high', source='investigation', summary='cursor/input hotspot hit-test: rect (x,y,w,h) -> 3-way branch on interaction, else fall through to pc+1'),

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Capture native ADV text layout, rasterization, reveal, and presentation boundaries.
Start the native game at the title screen, arm this probe, then choose New Game and click through
the first voiced page (SC0000 0x9b2/0x9d3). Output is written to
``build/native-adv-text-trace.jsonl``. The probe is read-only.
py -3.11 -u -X utf8 tools/frida/capture_adv_text_trace.py 45
"""
import json
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "build" / "native-adv-text-trace.jsonl"
JS = r"""
const mod = Process.getModuleByName('AGE.EXE');
const OFF = {
operand:0x1b940, op6e:0x1e330, op7a:0x1eba0, op204:0x22a60,
setCursor:0x530f0, buildText:0x576c0, buildTextBlocking:0x57dc0,
reveal:0x51220, drawString:0x50150, rasterSimple:0x59d90, rasterCached:0x5b600,
bind:0x7e870
};
const IDX=0x53d14, PC=0x53d2c, CB=0x53d28, STRIDE=0x78;
let ctx=null, current={codebase:0,offset:-1}, seq=0;
function i32(p,o=0){try{return p.add(o).readS32();}catch(e){return null;}}
function u32(p,o=0){try{return p.add(o).readU32();}catch(e){return null;}}
function stackP(reg,n){try{return reg.esp.add(4+n*4).readPointer();}catch(e){return ptr(0);}}
function stackI(reg,n){try{return reg.esp.add(4+n*4).readS32();}catch(e){return null;}}
function bytes(p,cap=256){
if(!p || p.isNull()) return [];
const out=[]; try{for(let i=0;i<cap;i++){const b=p.add(i).readU8();if(b===0)break;out.push(b);}}catch(e){}
return out;
}
function layout(mgr,slot){
try{
if(slot===0)slot=i32(mgr,0x4c8);
const p=mgr.add(0x414+slot*4).readPointer();
const begin=u32(p,0x30),end=u32(p,0x34),count=(begin&&end)?((end-begin)/0x14):0;
let last=null;if(count>0){const q=ptr(end-0x14);last=[i32(q,4),i32(q,8),i32(q,12),i32(q,16),i32(q,0)];}
return {slot:slot,layout:p.toString(),count:count,last:last,origin:[i32(p,0xc),i32(p,0x10)],handleBase:i32(p,0x68),
revealLimit:i32(p,0x6c),mode:i32(p,0x70),revealIndex:i32(mgr,0x570)};
}catch(e){return {slot:slot,error:String(e)};}
}
function state(extra={}){const c=ctx;return Object.assign({kind:'event',seq:++seq,t:Date.now(),
codebase:current.codebase,offset:current.offset,
frameTime:c?u32(c,0xb550):null,runFlags:c?u32(c,0xa0ce4):null,
charDelay:c?i32(c,0x14e9c):null},extra);}
function emit(name,extra={}){send(state(Object.assign({name:name},extra)));}
Interceptor.attach(mod.base.add(OFF.operand),{onEnter(){
const c=this.context.ecx;ctx=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){}
}});
for(const [name,off] of [['op-0x6e',OFF.op6e],['op-0x7a',OFF.op7a],['op-0x204',OFF.op204]])
Interceptor.attach(mod.base.add(off),{onEnter(){emit(name);}});
Interceptor.attach(mod.base.add(OFF.setCursor),{onEnter(){
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);
emit('cursor-set-enter',{slot:this.slot,x:stackI(this.context,1),y:stackI(this.context,2),layout:layout(this.mgr,this.slot)});
},onLeave(){emit('cursor-set-leave',{layout:layout(this.mgr,this.slot)});}});
function hookBuild(off,name){Interceptor.attach(mod.base.add(off),{onEnter(){
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);this.before=layout(this.mgr,this.slot);
emit(name+'-enter',{slot:this.slot,textBytes:bytes(stackP(this.context,1)),rubyBytes:bytes(stackP(this.context,2)),
flags:stackI(this.context,3),layout:this.before});
},onLeave(){emit(name+'-leave',{layout:layout(this.mgr,this.slot)});}});}
hookBuild(OFF.buildText,'build-text');hookBuild(OFF.buildTextBlocking,'build-text-blocking');
Interceptor.attach(mod.base.add(OFF.reveal),{onEnter(){
this.mgr=this.context.ecx;this.slot=stackI(this.context,0);emit('glyph-reveal-enter',{layout:layout(this.mgr,this.slot)});
},onLeave(ret){emit('glyph-reveal-leave',{done:ret.toInt32(),layout:layout(this.mgr,this.slot)});}});
Interceptor.attach(mod.base.add(OFF.drawString),{onEnter(){emit('draw-string-worker',{
surface:stackI(this.context,0),textBytes:bytes(stackP(this.context,1)),x:stackI(this.context,2),y:stackI(this.context,3)});}});
for(const [name,off] of [['raster-simple',OFF.rasterSimple],['raster-cached',OFF.rasterCached]])
Interceptor.attach(mod.base.add(off),{onEnter(){emit(name,{surface:stackI(this.context,0),x:stackI(this.context,1),y:stackI(this.context,2)});}});
Interceptor.attach(mod.base.add(OFF.bind),{onEnter(){const h=stackI(this.context,0);if(h===0xe678||h>=0xe678&&h<0xe800)
emit('bind-text-object',{handle:h,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)]});}});
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 45
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)
counts = {}
with OUT.open("w", encoding="utf-8") as f:
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] ADV text hooks live @ {row['base']}"); return
for key in ("textBytes", "rubyBytes"):
if key in row:
row[key[:-5]] = bytes(row.pop(key)).decode("cp932", errors="replace")
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 {"op-0x6e", "op-0x7a", "op-0x204", "cursor-set-enter",
"draw-string-worker", "build-text-enter", "glyph-reveal-enter",
"bind-text-object", "surface-lock", "surface-unlock"}:
print(f" #{row['seq']:05d} off=0x{row['offset']:05x} {name:22s} delay={row['charDelay']}")
try:
session = frida.attach(target)
except frida.ProcessNotFoundError:
print("[frida] AGE.EXE not found; leave the native game at the title screen first.")
return 2
script = session.create_script(JS); script.on("message", on_message); script.load()
print(f"[frida] capture armed for {seconds}s. Choose New Game and click through the first voiced page.")
try: time.sleep(seconds)
except KeyboardInterrupt: pass
try: session.detach()
except Exception: 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

@@ -1210,33 +1210,33 @@ observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0x7a op = 0x7a
label = "u0041AD70" label = "set-adv-text-cursor"
argc = 3 argc = 3
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "text-param?" name = "set-adv-text-cursor"
category = "adv" category = "adv"
summary = "3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param" summary = "(layout_slot)(x)(y) - set the cursor in the selected ADV text layout's last 20-byte record. Slot 0 selects the current layout."
noop_headless = false noop_headless = false
source = "inference" source = "investigation"
confidence = "med" confidence = "high"
depends_on = [] depends_on = []
evidence = "confirm via frida" evidence = "Ghidra /v2: op_0x7a_handler@0x41eba0 fetches operands 3,2,1 and calls adv_text_set_cursor@0x4530f0 on text manager ctx+0x14940. Slot 0 resolves manager+0x4c8; manager+0x414[slot] selects the layout; text_layout_set_cursor@0x452530 writes x/y to +4/+8 of its last 0x14-byte record. SC0000 0x9d3 computes slot 1, x=75, y=47 before voiced show-text."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "layout slot (0 = current)"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "cursor x"
observed_types = ["l-int"] observed_types = ["l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "cursor y"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode]] [[opcode]]
@@ -4867,32 +4867,32 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "draw-string" name = "draw-string"
category = "unknown" category = "adv"
summary = "" summary = "(surface_slot)(x)(y)(string) - rasterize a CP932 string immediately into a numbered graphics surface using current font/color/effect state."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "med" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "Ghidra /v2: op_0x204_handler@0x422a60 resolves operand 4 as a string, fetches surface/x/y, then calls draw_string_to_surface@0x450150 on text manager ctx+0x14940. The worker validates and locks gfx-manager surface table +0xa590[slot], chooses text_raster_string_uncached@0x459d90 or cached/effect path @0x45b600, rasterizes GDI GetGlyphOutlineA bitmaps through text_blit_glyph_bitmap@0x458c80 using font/color state +0x4d0/+0x458, then unlocks. SC0000 0x9b2 draws the speaker name into 400x30 surface 0xd at (1,1); following 0x1fb binds it to retained object 0xe678 at (74,444)."
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "destination surface slot"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "x"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "y"
observed_types = ["imm", "l-int"] observed_types = ["imm", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 4 i = 4
role = "" role = "CP932 string"
observed_types = ["string", "g-str", "l-str", "l-str-ptr"] observed_types = ["string", "g-str", "l-str", "l-str-ptr"]
[[opcode]] [[opcode]]