Implement native ADV retained text
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.)*
|
||||
|
||||
|
||||
33
engine/Age.Engine.Tests/AdvTextOpsTests.cs
Normal file
33
engine/Age.Engine.Tests/AdvTextOpsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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<long> 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() { }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,4 +5,5 @@ public sealed class Frame
|
||||
public Dictionary<int, long> F = new(); // local-float (raw)
|
||||
public Dictionary<int, string> S = new(); // local-string
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<int, SurfaceTextDraw> _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,8 +44,55 @@ 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<long, string>? 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 —";
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
133
tools/frida/capture_adv_text_trace.py
Normal file
133
tools/frida/capture_adv_text_trace.py
Normal 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())
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user