From 34db35903b33ad1a9cf8ebb826df849aec67e03f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 10 Jul 2026 22:46:27 -0400 Subject: [PATCH] Implement native ADV retained text --- docs/engine-re.md | 39 ++++++ docs/opcode-reference.md | 17 +-- docs/phase-a-slice-plan.md | 33 +++++ docs/tools-reference.md | 1 + engine/Age.Engine.Tests/AdvTextOpsTests.cs | 33 +++++ engine/Age.Engine.Tests/TestSupport.cs | 5 + engine/Age.Engine/Hosting/IHost.cs | 4 + engine/Age.Engine/Vm/Frame.cs | 1 + engine/Age.Engine/Vm/VirtualMachine.cs | 18 ++- godot/GodotAdvHost.cs | 76 +++++++++++- godot/Main.cs | 42 ++++++- tools/age_opcodes_himegari.py | 1 - tools/frida/capture_adv_text_trace.py | 133 +++++++++++++++++++++ vm-map/opcodes.toml | 36 +++--- 14 files changed, 401 insertions(+), 38 deletions(-) create mode 100644 engine/Age.Engine.Tests/AdvTextOpsTests.cs create mode 100644 tools/frida/capture_adv_text_trace.py diff --git a/docs/engine-re.md b/docs/engine-re.md index 6a85a1d..79dbb43 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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.** 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=447–468, 478–500, and 507–530. 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) **Problem the oracle surfaced:** single-scene VM runs diverge from the engine because they lack the diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index f82ee46..0de41ee 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -5,10 +5,15 @@ ## adv -### 0x7a `text-param?` (u0041AD70, argc 3) -- **summary:** 3 args (imm/computed/imm); sub computes a value then 0x7a then show-text — text speed/wait/window param -- **grounding:** source=inference, confidence=med -- **evidence:** confirm via frida +### 0x7a `set-adv-text-cursor` (set-adv-text-cursor, argc 3) +- **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=investigation, confidence=high +- **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 @@ -981,10 +986,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x204 `draw-string` (draw-string, argc 4) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - ### 0x205 `u00420A60` (u00420A60, argc 6) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 5f6e0a4..16db1bc 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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 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`. + +### 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=447–468, 478–500, and 507–530. 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. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 4b826e3..a3befc7 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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_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 `X−0x400000`.)* diff --git a/engine/Age.Engine.Tests/AdvTextOpsTests.cs b/engine/Age.Engine.Tests/AdvTextOpsTests.cs new file mode 100644 index 0000000..470e718 --- /dev/null +++ b/engine/Age.Engine.Tests/AdvTextOpsTests.cs @@ -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()), + }, Array.Empty()); + 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)); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index ab9fc1c..9366cd5 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -14,8 +14,13 @@ internal sealed class RecordingHost : IHost public bool MessageSkip; public bool AdvReadSkip; 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 SleptDurations = new(); 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 Sleep(long duration) => SleptDurations.Add(duration); public void FrameYield() { } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index cbe5cfc..3d9fb08 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -4,6 +4,10 @@ namespace Age.Engine.Hosting; public interface IHost { 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 Sleep(long duration); void FrameYield(); diff --git a/engine/Age.Engine/Vm/Frame.cs b/engine/Age.Engine/Vm/Frame.cs index d8e3612..1fa06a9 100644 --- a/engine/Age.Engine/Vm/Frame.cs +++ b/engine/Age.Engine/Vm/Frame.cs @@ -5,4 +5,5 @@ public sealed class Frame public Dictionary F = new(); // local-float (raw) public Dictionary S = new(); // local-string public Dictionary P = new(); // local-ptr (holds a global address) + public Dictionary SP = new(); // local-string-ptr (holds a global-string address) } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 247fc76..357dff2 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -10,7 +10,8 @@ public sealed class VirtualMachine private const int FRAME_RETURN = int.MinValue + 1; 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, - 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 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 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 IsAdvLabeledYield(Script script, Instruction ins) @@ -96,7 +97,9 @@ public sealed class VirtualMachine { T_STR => _cur.Script.GetString((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_LSTRPTR => Gs(GlobalStrings, (int)Gi(_cur.Locals.SP, (int)op.Value)), _ => "", }; @@ -105,13 +108,15 @@ public sealed class VirtualMachine switch (op.Type) { 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_LSTRPTR: GlobalStrings[(int)Gi(_cur.Locals.SP, (int)op.Value)] = val; break; } } 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_LPTR => Gi(_cur.Locals.P, (int)op.Value), _ => op.Value, @@ -122,7 +127,9 @@ public sealed class VirtualMachine switch (dst.Type) { 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_GSTRPTR: Globals[(int)dst.Value] = addr; break; default: Write(dst, Gi(Globals, (int)addr)); break; } } @@ -271,6 +278,11 @@ public sealed class VirtualMachine _host.ShowText(off, text); } 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": // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 893153e..e900f20 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -18,10 +18,17 @@ public sealed class GodotAdvHost : IHost private readonly GodotTimelineLog? _timeline; private readonly System.Threading.AutoResetEvent _frameSignal = new(false); private volatile bool _stopping; + private readonly object _textLock = new(); + private readonly Dictionary _surfaceText = new(); + private string _advText = ""; + private int _advTextX = 100, _advTextY = 47; + private long _advTextStartedMs; + private bool _advTextForceComplete; private GfxState? _foregroundGfx; public volatile bool IsWaiting; public volatile bool IsTransitionWaiting; public volatile bool IsSleeping; + public volatile bool IsTextRevealing; private int _presentRequested = 1; private long _transitionStartedAtMs = -1; 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) { 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 void WaitForInput() @@ -51,6 +105,12 @@ public sealed class GodotAdvHost : IHost _gate.Wait(); IsWaiting = false; _timeline?.State("running", new() { ["input"] = "auto-or-user" }); + lock (_textLock) + { + _advText = ""; + _advTextX = 100; + _advTextY = 47; + } _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. public void SignalInput() { + if (IsTextRevealing) + { + lock (_textLock) _advTextForceComplete = true; + _timeline?.State("text-reveal-forced-complete", new()); + _frameSignal.Set(); + return; + } if (IsTransitionWaiting && _foregroundGfx != null) { 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 // explicit present or while the interpreter is parked in a presentation-capable service boundary. public bool ShouldRecomposite() - => IsWaiting || IsTransitionWaiting || IsSleeping || + => IsWaiting || IsTransitionWaiting || IsSleeping || IsTextRevealing || System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0; public void Stop() { _stopping = true; + lock (_textLock) _advTextForceComplete = true; if (_gate.CurrentCount == 0) _gate.Release(); _frameSignal.Set(); } @@ -160,12 +228,14 @@ public sealed class GodotAdvHost : IHost public void CreateTexture(int slot, int width, int height) { + lock (_textLock) _surfaceText.Remove(slot); _slotBmp[slot] = null; _slotDims[slot] = (width, height); if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}"); } public void SetTexture(long resourceId, int slot) { + lock (_textLock) _surfaceText.Remove(slot); var asset = _res.Resolve(_scene, resourceId); var bmp = asset != null ? ResourceMap.TexturePath(asset) : null; _slotBmp[slot] = bmp; @@ -206,3 +276,5 @@ public sealed class GodotAdvHost : IHost if (path != null) _main.CallDeferred("PlayVoice", path); } } + +public readonly record struct SurfaceTextDraw(int X, int Y, string Text); diff --git a/godot/Main.cs b/godot/Main.cs index 28b28ba..f845f0d 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -14,6 +14,7 @@ public partial class Main : Godot.Control private Image _screen = null!; // 800x600 immediate-mode canvas private ImageTexture _screenTex = null!; private Label _text = null!; + private Label _speaker = null!; private Label _status = null!; private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _voice = null!; // interrupt-on-new voice @@ -61,26 +62,35 @@ public partial class Main : Godot.Control _screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); 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.OffsetLeft = 40; _text.OffsetTop = 40; _text.OffsetRight = -40; _text.OffsetBottom = -80; AddChild(_text); + _speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false }; + _speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + AddChild(_speaker); _status = new Label(); _status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide); _status.OffsetLeft = 40; _status.OffsetTop = -60; AddChild(_status); // 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", - "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/meiryo.ttc" }) + foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc", + "C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" }) { if (!System.IO.File.Exists(fp)) continue; try { var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) }; _text.AddThemeFontOverride("font", ff); + _speaker.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; } catch { /* fall back to the default font */ } @@ -189,6 +199,7 @@ public partial class Main : Godot.Control _host?.PulseFrame(); if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite()) 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 // verified as distinct frames, not just the final state. Captures after Recomposite; quits when full. if (_seqDir != null && _seqIdx < _seqFrames && !_done) @@ -265,6 +276,7 @@ public partial class Main : Godot.Control private void Recomposite() { _screen.Fill(new Color(0, 0, 0, 0)); + _speaker.Visible = false; System.Collections.Generic.Dictionary? decisions = _gfxLogPath != null || _timeline != null ? new() : null; int z = 0; 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}"); + 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++; } _screenTex.Update(_screen); 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) => state is { } c ? $" 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 PageBreak() { _pageCount++; _status.Text = "▼ click / Enter"; } + public void PageBreak() { _pageCount++; _status.Text = ""; } public void ClearPage() { _text.Text = ""; _status.Text = ""; } public void ShowEnd() => _status.Text = "— end —"; diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 5c60c5e..5a95078 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -5,7 +5,6 @@ from __future__ import annotations 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'), - 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).'), 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'), diff --git a/tools/frida/capture_adv_text_trace.py b/tools/frida/capture_adv_text_trace.py new file mode 100644 index 0000000..044bb89 --- /dev/null +++ b/tools/frida/capture_adv_text_trace.py @@ -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;i0){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()) diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 0fd0bdf..5021f20 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1210,33 +1210,33 @@ observed_types = ["imm"] [[opcode]] op = 0x7a -label = "u0041AD70" +label = "set-adv-text-cursor" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "text-param?" +name = "set-adv-text-cursor" 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 -source = "inference" -confidence = "med" +source = "investigation" +confidence = "high" 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]] i = 1 -role = "" +role = "layout slot (0 = current)" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "cursor x" observed_types = ["l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "cursor y" observed_types = ["imm", "l-int"] [[opcode]] @@ -4867,32 +4867,32 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "draw-string" -category = "unknown" -summary = "" +category = "adv" +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 -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" 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]] i = 1 -role = "" +role = "destination surface slot" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "x" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "y" observed_types = ["imm", "l-int"] [[opcode.semantics.args]] i = 4 -role = "" +role = "CP932 string" observed_types = ["string", "g-str", "l-str", "l-str-ptr"] [[opcode]]