feat: add runtime ADV page locator
This commit is contained in:
@@ -104,6 +104,9 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
|||||||
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine)
|
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The disposable `build/page-map-<SCENE>.jsonl` files are produced by normal Godot runs and map runtime ADV
|
||||||
|
page ordinals to their authoritative script offsets for `tools/locate_page.py`.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Three-way separation.** `姫狩りダンジョンマイスター/` = untouched originals; `extracted/` =
|
- **Three-way separation.** `姫狩りダンジョンマイスター/` = untouched originals; `extracted/` =
|
||||||
|
|||||||
@@ -1433,3 +1433,15 @@ samples packed ARGB, and applies it through normal mode-specific blending rather
|
|||||||
convention. Focused tests cover exact AE001H invariance, mode-0 RGB modulation, and mode-1 alpha opacity.
|
convention. Focused tests cover exact AE001H invariance, mode-0 RGB modulation, and mode-1 alpha opacity.
|
||||||
Validation: engine **152/152**, zero-warning Godot build, and threaded `SELFTEST OK`; manual confirmation is
|
Validation: engine **152/152**, zero-warning Godot build, and threaded `SELFTEST OK`; manual confirmation is
|
||||||
the remaining visual gate.
|
the remaining visual gate.
|
||||||
|
|
||||||
|
### ADV page-to-script locator implemented (2026-07-11)
|
||||||
|
|
||||||
|
Godot now turns each stable `wait-for-input` into a shared human/tool coordinate. A normal run recreates
|
||||||
|
`build/page-map-<SCENE>.jsonl` and records the run-relative page number, page-start location, canonical wait
|
||||||
|
script/offset, last show-text instruction and inline-string offsets, dialogue text, and nested call stack.
|
||||||
|
The optional HUD uses the compact form `SC0000 P014 · wait SC0000@0x… · text SC0000@0x…`; F2 toggles it
|
||||||
|
and F3 copies it. `tools/locate_page.py SC0000 14` resolves that record and prints authoritative disassembly
|
||||||
|
around the wait. Page number is deliberately only the friendly coordinate because state and branches can
|
||||||
|
shift ordinals; the script/offset remains authoritative. A live SC0000 run verified page 1 as
|
||||||
|
`show-text@0x834`, string `0x14963`, and `wait-for-input@0x83c`. Validation: focused Python tests,
|
||||||
|
engine **152/152**, zero-warning Godot build, threaded `SELFTEST OK`, and the live lookup all pass.
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited *
|
|||||||
| `correlate_scope.py` | Align the VM's `set-texture(resId)` trace with the game's Frida load order → tag each load's DATA2 package, flag package transitions, dump the significant ops in each transition span (the **scope selector** hunt). | `correlate_scope.py <SCENE>` | `build/settex-<SCENE>.json` + `build/frida-load-order-result.json` + index → stdout |
|
| `correlate_scope.py` | Align the VM's `set-texture(resId)` trace with the game's Frida load order → tag each load's DATA2 package, flag package transitions, dump the significant ops in each transition span (the **scope selector** hunt). | `correlate_scope.py <SCENE>` | `build/settex-<SCENE>.json` + `build/frida-load-order-result.json` + index → stdout |
|
||||||
| `diff_optrace.py` | **Differential offset-path oracle** (`docs/engine-re.md`): diff the engine's executed offset path (`trace_engine_ops.py`) against the VM's (`Age.Cli trace --trace-json`) → first divergence = the mis-modeled branch/op/state, with opcode + ±3 ops of context. Identifies the scene's codebase by longest-common-prefix; filters the VM trace to argc≥1 (operand-capture parity). Pure core unit-tested (`test_diff_optrace.py`). | `py -3.11 -X utf8 tools/diff_optrace.py SC0000 [--full]` | `build/engine-optrace.jsonl` + `build/vm-optrace.json` + disasm → stdout |
|
| `diff_optrace.py` | **Differential offset-path oracle** (`docs/engine-re.md`): diff the engine's executed offset path (`trace_engine_ops.py`) against the VM's (`Age.Cli trace --trace-json`) → first divergence = the mis-modeled branch/op/state, with opcode + ±3 ops of context. Identifies the scene's codebase by longest-common-prefix; filters the VM trace to argc≥1 (operand-capture parity). Pure core unit-tested (`test_diff_optrace.py`). | `py -3.11 -X utf8 tools/diff_optrace.py SC0000 [--full]` | `build/engine-optrace.jsonl` + `build/vm-optrace.json` + disasm → stdout |
|
||||||
|
|
||||||
|
### Runtime page locator
|
||||||
|
|
||||||
|
| Tool | Purpose | Run | Reads → Writes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `locate_page.py` | Resolve a run-relative ADV page number to its canonical wait script/offset, last show-text instruction, call stack, and nearby disassembly. Pure selection/window logic is tested by `test_locate_page.py`. | `py -3.11 -X utf8 tools/locate_page.py SC0000 14 [--map <jsonl>] [--context N]` | `build/page-map-<SCENE>.jsonl` + script corpus → stdout |
|
||||||
|
|
||||||
## Engine (C#) — VM core, CLI, Godot frontend
|
## Engine (C#) — VM core, CLI, Godot frontend
|
||||||
|
|
||||||
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not
|
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not
|
||||||
@@ -152,6 +158,14 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
|
|||||||
`--boot --shot-sequence ... --gfx-log ...` to distinguish control-flow stalls from retained-object/compositor
|
`--boot --shot-sequence ... --gfx-log ...` to distinguish control-flow stalls from retained-object/compositor
|
||||||
failures at an exact bytecode boundary. Relative output paths are project-relative (`godot/`).
|
failures at an exact bytecode boundary. Relative output paths are project-relative (`godot/`).
|
||||||
|
|
||||||
|
**Godot page locator:** every normal run recreates `build/page-map-<SCENE>.jsonl`, adding one record per
|
||||||
|
`wait-for-input` with the run-relative page, page-start location, canonical wait script/offset, last
|
||||||
|
show-text instruction and string offsets, text, and nested call stack. Use `--page-map <jsonl>` to override
|
||||||
|
the output. `--locator-hud` shows `SC0000 P014 · wait SC0000@0x… · text SC0000@0x…` at launch; **F2**
|
||||||
|
toggles it and **F3** copies the current locator to the clipboard. The offset remains authoritative because
|
||||||
|
branching/state can shift page ordinals between runs. Resolve a reported page with
|
||||||
|
`py -3.11 -X utf8 tools/locate_page.py SC0000 14`.
|
||||||
|
|
||||||
## Asset resolution / graphics
|
## Asset resolution / graphics
|
||||||
|
|
||||||
| Tool | Purpose | Run | Reads → Writes |
|
| Tool | Purpose | Run | Reads → Writes |
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||||
private readonly Age.Engine.Hosting.FrameClock _clock;
|
private readonly Age.Engine.Hosting.FrameClock _clock;
|
||||||
private readonly GodotTimelineLog? _timeline;
|
private readonly GodotTimelineLog? _timeline;
|
||||||
|
private readonly PageLocatorState _locator;
|
||||||
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 object _textLock = new();
|
||||||
@@ -45,15 +46,16 @@ public sealed class GodotAdvHost : IHost
|
|||||||
public readonly List<(int Offset, string Text)> Captured = new();
|
public readonly List<(int Offset, string Text)> Captured = new();
|
||||||
|
|
||||||
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
|
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
|
||||||
GodotTimelineLog? timeline = null)
|
PageLocatorState locator, GodotTimelineLog? timeline = null)
|
||||||
{
|
{
|
||||||
_main = main; _res = res; _scene = scene; _clock = clock;
|
_main = main; _res = res; _scene = scene; _clock = clock;
|
||||||
_timeline = timeline;
|
_locator = locator; _timeline = timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowText(int offset, string text)
|
public void ShowText(int offset, string text)
|
||||||
{
|
{
|
||||||
Captured.Add((offset, text));
|
Captured.Add((offset, text));
|
||||||
|
_locator.Text(offset, text);
|
||||||
lock (_textLock)
|
lock (_textLock)
|
||||||
{
|
{
|
||||||
_advText = text;
|
_advText = text;
|
||||||
@@ -147,6 +149,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
public void WaitForInput(int layoutSlot)
|
public void WaitForInput(int layoutSlot)
|
||||||
{
|
{
|
||||||
Pages++;
|
Pages++;
|
||||||
|
_locator.Wait(Pages);
|
||||||
_main.CallDeferred("PageBreak");
|
_main.CallDeferred("PageBreak");
|
||||||
// Publish retained mutations accumulated before the wait once. A static input wait is not itself a
|
// Publish retained mutations accumulated before the wait once. A static input wait is not itself a
|
||||||
// reason to rebuild the 800x600 background every frame; ambient channels are queried separately.
|
// reason to rebuild the 800x600 background every frame; ambient channels are queried separately.
|
||||||
|
|||||||
@@ -9,19 +9,40 @@ using Age.Engine.Diagnostics;
|
|||||||
public sealed class GodotTraceSink : ITraceSink
|
public sealed class GodotTraceSink : ITraceSink
|
||||||
{
|
{
|
||||||
private readonly GodotTimelineLog? _timeline;
|
private readonly GodotTimelineLog? _timeline;
|
||||||
|
private readonly PageLocatorState _locator;
|
||||||
private readonly Stack<string> _scripts = new();
|
private readonly Stack<string> _scripts = new();
|
||||||
public GodotTraceSink(GodotTimelineLog? timeline = null) => _timeline = timeline;
|
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
|
||||||
public bool TracingSteps => _timeline != null;
|
{ _locator = locator; _timeline = timeline; }
|
||||||
|
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
|
||||||
|
public bool TracingSteps => true;
|
||||||
public readonly ConcurrentQueue<long> CallScripts = new();
|
public readonly ConcurrentQueue<long> CallScripts = new();
|
||||||
public void Emit(in TraceEvent e)
|
public void Emit(in TraceEvent e)
|
||||||
{
|
{
|
||||||
if (e.Kind == TraceEventKind.CallScript) CallScripts.Enqueue(e.Id);
|
if (e.Kind == TraceEventKind.CallScript) CallScripts.Enqueue(e.Id);
|
||||||
if (_timeline == null) return;
|
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
|
||||||
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null) _scripts.Push(e.Name);
|
{
|
||||||
else if (e.Kind == TraceEventKind.FrameExit && _scripts.Count > 0) _scripts.Pop();
|
_scripts.Push(e.Name);
|
||||||
|
PublishCallStack();
|
||||||
|
}
|
||||||
|
else if (e.Kind == TraceEventKind.FrameExit && _scripts.Count > 0)
|
||||||
|
{
|
||||||
|
_scripts.Pop();
|
||||||
|
PublishCallStack();
|
||||||
|
}
|
||||||
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
|
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
|
||||||
_timeline.Step(_scripts.Count > 0 ? _scripts.Peek() : "<unknown>", e.Ins.Offset, e.Opcode, e.Depth);
|
{
|
||||||
|
string script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
|
||||||
|
_locator.Step(script, e.Ins.Offset);
|
||||||
|
_timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth);
|
||||||
|
}
|
||||||
else if (e.Kind == TraceEventKind.Halt)
|
else if (e.Kind == TraceEventKind.Halt)
|
||||||
_timeline.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
|
_timeline?.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishCallStack()
|
||||||
|
{
|
||||||
|
var stack = _scripts.ToArray();
|
||||||
|
System.Array.Reverse(stack);
|
||||||
|
_locator.CallStack(stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public partial class Main : Godot.Control
|
|||||||
private Label _text = null!;
|
private Label _text = null!;
|
||||||
private Label _speaker = null!;
|
private Label _speaker = null!;
|
||||||
private Label _status = null!;
|
private Label _status = null!;
|
||||||
|
private Label _locatorHud = 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
|
||||||
private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9
|
private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9
|
||||||
@@ -36,6 +37,8 @@ public partial class Main : Godot.Control
|
|||||||
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
|
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
|
||||||
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
|
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
|
||||||
private GodotTraceSink _trace = null!;
|
private GodotTraceSink _trace = null!;
|
||||||
|
private PageLocatorState _locator = null!;
|
||||||
|
private bool _locatorHudVisible;
|
||||||
private Age.Engine.Diagnostics.HistogramTraceSink? _hist; // --trace-histogram: profile the real run
|
private Age.Engine.Diagnostics.HistogramTraceSink? _hist; // --trace-histogram: profile the real run
|
||||||
private string? _histFile;
|
private string? _histFile;
|
||||||
private Age.Engine.Model.OpcodeTable? _table;
|
private Age.Engine.Model.OpcodeTable? _table;
|
||||||
@@ -97,6 +100,9 @@ public partial class Main : Godot.Control
|
|||||||
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
|
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
|
||||||
_status.OffsetLeft = 40; _status.OffsetTop = -60;
|
_status.OffsetLeft = 40; _status.OffsetTop = -60;
|
||||||
AddChild(_status);
|
AddChild(_status);
|
||||||
|
_locatorHud = new Label { Visible = false, MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
_locatorHud.Position = new Vector2(8, 8);
|
||||||
|
AddChild(_locatorHud);
|
||||||
|
|
||||||
// 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/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
|
foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
|
||||||
@@ -109,6 +115,7 @@ public partial class Main : Godot.Control
|
|||||||
_text.AddThemeFontOverride("font", ff);
|
_text.AddThemeFontOverride("font", ff);
|
||||||
_speaker.AddThemeFontOverride("font", ff);
|
_speaker.AddThemeFontOverride("font", ff);
|
||||||
_status.AddThemeFontOverride("font", ff);
|
_status.AddThemeFontOverride("font", ff);
|
||||||
|
_locatorHud.AddThemeFontOverride("font", ff);
|
||||||
_text.AddThemeFontSizeOverride("font_size", 25);
|
_text.AddThemeFontSizeOverride("font_size", 25);
|
||||||
_speaker.AddThemeFontSizeOverride("font_size", 25);
|
_speaker.AddThemeFontSizeOverride("font_size", 25);
|
||||||
_text.AddThemeConstantOverride("outline_size", 1);
|
_text.AddThemeConstantOverride("outline_size", 1);
|
||||||
@@ -140,6 +147,7 @@ public partial class Main : Godot.Control
|
|||||||
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
|
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
|
||||||
long transitionClickMs = -1; // --transition-click-ms <n>: force active transitions after n virtual ms
|
long transitionClickMs = -1; // --transition-click-ms <n>: force active transitions after n virtual ms
|
||||||
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
|
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
|
||||||
|
string? pageMapPath = null; // --page-map <jsonl>: override default build/page-map-SCxxxx.jsonl
|
||||||
for (int i = 0; i < userArgs.Length; i++)
|
for (int i = 0; i < userArgs.Length; i++)
|
||||||
{
|
{
|
||||||
if (userArgs[i] == "--scene" && i + 1 < userArgs.Length) scene = userArgs[i + 1];
|
if (userArgs[i] == "--scene" && i + 1 < userArgs.Length) scene = userArgs[i + 1];
|
||||||
@@ -154,6 +162,8 @@ public partial class Main : Godot.Control
|
|||||||
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
||||||
if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs);
|
if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs);
|
||||||
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
|
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
|
||||||
|
if (userArgs[i] == "--page-map" && i + 1 < userArgs.Length) pageMapPath = userArgs[i + 1];
|
||||||
|
if (userArgs[i] == "--locator-hud") _locatorHudVisible = true;
|
||||||
if (userArgs[i] == "--seed" && i + 1 < userArgs.Length)
|
if (userArgs[i] == "--seed" && i + 1 < userArgs.Length)
|
||||||
{
|
{
|
||||||
var kv = userArgs[i + 1].Split('=');
|
var kv = userArgs[i + 1].Split('=');
|
||||||
@@ -178,9 +188,13 @@ public partial class Main : Godot.Control
|
|||||||
if (_selftest) (script, provider) = BuildSelfTestScene(table);
|
if (_selftest) (script, provider) = BuildSelfTestScene(table);
|
||||||
else { scripts = Sys4ScriptProvider.Load(table); script = scripts.RequireByName(scene + ".BIN"); provider = scripts; }
|
else { scripts = Sys4ScriptProvider.Load(table); script = scripts.RequireByName(scene + ".BIN"); provider = scripts; }
|
||||||
if (_timelineLogPath != null) _timeline = new GodotTimelineLog(_timelineLogPath);
|
if (_timelineLogPath != null) _timeline = new GodotTimelineLog(_timelineLogPath);
|
||||||
|
if (!_selftest && pageMapPath == null)
|
||||||
|
pageMapPath = System.IO.Path.Combine(Paths.Build, $"page-map-{scene.ToUpperInvariant()}.jsonl");
|
||||||
|
_locator = new PageLocatorState(scene, _selftest ? null : pageMapPath);
|
||||||
|
_locatorHud.Visible = _locatorHudVisible;
|
||||||
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
|
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
|
||||||
_host = new GodotAdvHost(this, resources, scene, _clock, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
|
_host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
|
||||||
_trace = new GodotTraceSink(_timeline);
|
_trace = new GodotTraceSink(_locator, _timeline);
|
||||||
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
|
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
|
||||||
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
|
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
|
||||||
_table = table;
|
_table = table;
|
||||||
@@ -298,6 +312,19 @@ public partial class Main : Godot.Control
|
|||||||
public override void _Input(InputEvent e)
|
public override void _Input(InputEvent e)
|
||||||
{
|
{
|
||||||
if (_selftest) return;
|
if (_selftest) return;
|
||||||
|
if (e is InputEventKey key && key.Pressed && !key.Echo && key.Keycode == Key.F2)
|
||||||
|
{
|
||||||
|
_locatorHudVisible = !_locatorHudVisible;
|
||||||
|
_locatorHud.Visible = _locatorHudVisible;
|
||||||
|
if (_locatorHudVisible) _locatorHud.Text = _locator.CurrentDisplay;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e is InputEventKey copy && copy.Pressed && !copy.Echo && copy.Keycode == Key.F3)
|
||||||
|
{
|
||||||
|
DisplayServer.ClipboardSet(_locator.CurrentDisplay);
|
||||||
|
_locatorHud.Text = _locator.CurrentDisplay + " · copied";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.IsActionPressed("ui_accept") ||
|
if (e.IsActionPressed("ui_accept") ||
|
||||||
(e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left))
|
(e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left))
|
||||||
_host.SignalInput();
|
_host.SignalInput();
|
||||||
@@ -305,7 +332,7 @@ public partial class Main : Godot.Control
|
|||||||
|
|
||||||
public override void _ExitTree()
|
public override void _ExitTree()
|
||||||
{
|
{
|
||||||
DumpHistogram(); _host?.Stop(); _timeline?.Dispose();
|
DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose();
|
||||||
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
|
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
|
||||||
_movies.Clear();
|
_movies.Clear();
|
||||||
}
|
}
|
||||||
@@ -702,7 +729,12 @@ public partial class Main : Godot.Control
|
|||||||
private sealed record MovieRuntime(string Name, int RawIndex, DirectShowMovieDecoder Decoder);
|
private sealed record MovieRuntime(string Name, int RawIndex, DirectShowMovieDecoder Decoder);
|
||||||
|
|
||||||
public void AppendLine(string text) => _text.Text += text + "\n";
|
public void AppendLine(string text) => _text.Text += text + "\n";
|
||||||
public void PageBreak() { _pageCount++; _status.Text = ""; }
|
public void PageBreak()
|
||||||
|
{
|
||||||
|
_pageCount++;
|
||||||
|
_status.Text = "";
|
||||||
|
if (_locatorHudVisible) _locatorHud.Text = _locator.CurrentDisplay;
|
||||||
|
}
|
||||||
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 —";
|
||||||
|
|
||||||
|
|||||||
135
godot/PageLocatorState.cs
Normal file
135
godot/PageLocatorState.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe bridge between the VM trace, the ADV host's page waits, and the Godot UI.
|
||||||
|
/// A page number is run-relative; the script + wait offset is its canonical locator.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PageLocatorState : System.IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly string _rootScene;
|
||||||
|
private readonly StreamWriter? _writer;
|
||||||
|
private string _script = "<startup>";
|
||||||
|
private int _offset = -1;
|
||||||
|
private string[] _callStack = System.Array.Empty<string>();
|
||||||
|
private string? _pageStartScript;
|
||||||
|
private int? _pageStartOffset;
|
||||||
|
private string? _textScript;
|
||||||
|
private int? _textOffset;
|
||||||
|
private int? _textStringOffset;
|
||||||
|
private string? _text;
|
||||||
|
private string _currentDisplay;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public PageLocatorState(string rootScene, string? mapPath)
|
||||||
|
{
|
||||||
|
_rootScene = rootScene.ToUpperInvariant();
|
||||||
|
_currentDisplay = _rootScene;
|
||||||
|
if (mapPath == null) return;
|
||||||
|
var dir = Path.GetDirectoryName(mapPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
_writer = new StreamWriter(mapPath, append: false) { AutoFlush = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentDisplay { get { lock (_lock) return _currentDisplay; } }
|
||||||
|
|
||||||
|
public void Step(string script, int offset)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_script = NormalizeScript(script);
|
||||||
|
_offset = offset;
|
||||||
|
if (_pageStartOffset == null)
|
||||||
|
{
|
||||||
|
_pageStartScript = _script;
|
||||||
|
_pageStartOffset = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CallStack(string[] callStack)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_callStack = new string[callStack.Length];
|
||||||
|
for (int i = 0; i < callStack.Length; i++) _callStack[i] = NormalizeScript(callStack[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Text(int stringOffset, string text)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_textScript = _script;
|
||||||
|
_textOffset = _offset;
|
||||||
|
_textStringOffset = stringOffset;
|
||||||
|
_text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Wait(int page)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
string preview = Preview(_text);
|
||||||
|
string textLocation = _textOffset is int textOffset
|
||||||
|
? $"{_textScript}@0x{textOffset:x}"
|
||||||
|
: "none";
|
||||||
|
_currentDisplay = $"{_rootScene} P{page:000} · wait {_script}@0x{_offset:x} · text {textLocation}";
|
||||||
|
if (preview.Length > 0) _currentDisplay += $" · {preview}";
|
||||||
|
|
||||||
|
if (_writer != null && !_disposed)
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["root_scene"] = _rootScene,
|
||||||
|
["page"] = page,
|
||||||
|
["page_start_script"] = _pageStartScript,
|
||||||
|
["page_start_offset"] = Hex(_pageStartOffset),
|
||||||
|
["wait_script"] = _script,
|
||||||
|
["wait_offset"] = Hex(_offset),
|
||||||
|
["text_script"] = _textScript,
|
||||||
|
["text_offset"] = Hex(_textOffset),
|
||||||
|
["text_string_offset"] = Hex(_textStringOffset),
|
||||||
|
["text"] = _text,
|
||||||
|
["call_stack"] = _callStack,
|
||||||
|
};
|
||||||
|
_writer.WriteLine(JsonSerializer.Serialize(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The display remains parked on this page, but text/start observations for the next page must
|
||||||
|
// not inherit stale values if a page contains no show-text operation of its own.
|
||||||
|
_pageStartScript = null;
|
||||||
|
_pageStartOffset = null;
|
||||||
|
_textScript = null;
|
||||||
|
_textOffset = null;
|
||||||
|
_textStringOffset = null;
|
||||||
|
_text = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeScript(string script)
|
||||||
|
=> Path.GetFileNameWithoutExtension(script).ToUpperInvariant();
|
||||||
|
|
||||||
|
private static string? Hex(int? value) => value is int v ? $"0x{v:x}" : null;
|
||||||
|
|
||||||
|
private static string Preview(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return "";
|
||||||
|
string oneLine = text.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||||
|
if (oneLine.Length > 36) oneLine = oneLine[..35] + "…";
|
||||||
|
return $"「{oneLine}」";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_writer?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
tools/locate_page.py
Normal file
118
tools/locate_page.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Resolve a runtime ADV page number to authoritative script offsets and disassembly.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
py -3.11 -X utf8 tools/locate_page.py SC0000 14
|
||||||
|
py -3.11 -X utf8 tools/locate_page.py SC0000 14 --map path/to/page-map.jsonl --context 10
|
||||||
|
|
||||||
|
The Godot runtime writes build/page-map-<SCENE>.jsonl at each wait-for-input. Page numbers are
|
||||||
|
run-relative conveniences; wait_script + wait_offset is the stable bytecode coordinate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paths
|
||||||
|
import sys4load
|
||||||
|
|
||||||
|
|
||||||
|
def load_records(path: Path) -> list[dict]:
|
||||||
|
records = []
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
for number, line in enumerate(f, 1):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
records.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"{path}:{number}: invalid JSON: {exc}") from exc
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def find_page(records: list[dict], scene: str, page: int) -> dict | None:
|
||||||
|
scene = scene.upper().removesuffix(".BIN")
|
||||||
|
return next((r for r in records
|
||||||
|
if str(r.get("root_scene", "")).upper().removesuffix(".BIN") == scene
|
||||||
|
and r.get("page") == page), None)
|
||||||
|
|
||||||
|
|
||||||
|
def context_window(offsets: list[int], target: int, radius: int) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
index = offsets.index(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"offset 0x{target:x} is not an instruction boundary") from exc
|
||||||
|
return max(0, index - radius), min(len(offsets), index + radius + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def disasm_context(script: str, target: int, radius: int) -> list[tuple[int, str, bool]]:
|
||||||
|
key = script.upper()
|
||||||
|
if not key.endswith(".BIN"):
|
||||||
|
key += ".BIN"
|
||||||
|
scripts = paths.scripts()
|
||||||
|
if key not in scripts:
|
||||||
|
raise ValueError(f"script {key} is not present in the override-aware corpus")
|
||||||
|
scr = sys4load.load(scripts[key])
|
||||||
|
sys4load.decode_code(scr)
|
||||||
|
line_by_offset = {}
|
||||||
|
for line in sys4load.render_listing(scr).splitlines():
|
||||||
|
match = re.match(r"\s*0x([0-9a-fA-F]+):\s*(.*)", line)
|
||||||
|
if match:
|
||||||
|
line_by_offset[int(match.group(1), 16)] = match.group(2).rstrip()
|
||||||
|
offsets = [ins.offset for ins in scr.instructions]
|
||||||
|
begin, end = context_window(offsets, target, radius)
|
||||||
|
return [(off, line_by_offset.get(off, "?"), off == target) for off in offsets[begin:end]]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hex(value: object, field: str) -> int:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"page record has no {field}")
|
||||||
|
return int(value, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def report(record: dict, radius: int) -> str:
|
||||||
|
scene, page = record["root_scene"], record["page"]
|
||||||
|
wait_script = record["wait_script"]
|
||||||
|
wait_offset = parse_hex(record.get("wait_offset"), "wait_offset")
|
||||||
|
lines = [f"{scene} P{page:03d}",
|
||||||
|
f"page start: {record.get('page_start_script') or '?'}@{record.get('page_start_offset') or '?'}",
|
||||||
|
f"last text: {record.get('text_script') or '?'}@{record.get('text_offset') or '?'}",
|
||||||
|
f"wait: {wait_script}@0x{wait_offset:x}"]
|
||||||
|
stack = record.get("call_stack") or []
|
||||||
|
if stack:
|
||||||
|
lines.append("call stack: " + " > ".join(stack))
|
||||||
|
if record.get("text"):
|
||||||
|
lines.append("text: " + str(record["text"]).replace("\r", " ").replace("\n", " "))
|
||||||
|
lines += ["", f"disassembly around {wait_script}@0x{wait_offset:x}:"]
|
||||||
|
for offset, instruction, selected in disasm_context(wait_script, wait_offset, radius):
|
||||||
|
lines.append(f"{'>>>' if selected else ' '} 0x{offset:05x}: {instruction}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("scene", help="root scene, e.g. SC0000")
|
||||||
|
parser.add_argument("page", type=int, help="one-based runtime page number")
|
||||||
|
parser.add_argument("--map", type=Path, dest="map_path", help="page-map JSONL (default: build/page-map-SCENE.jsonl)")
|
||||||
|
parser.add_argument("--context", type=int, default=8, help="instructions on either side of the wait (default: 8)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
scene = args.scene.upper().removesuffix(".BIN")
|
||||||
|
map_path = args.map_path or paths.BUILD / f"page-map-{scene}.jsonl"
|
||||||
|
if not map_path.is_file():
|
||||||
|
parser.error(f"page map not found: {map_path} (run the Godot scene to the desired page first)")
|
||||||
|
if args.context < 0:
|
||||||
|
parser.error("--context must be non-negative")
|
||||||
|
try:
|
||||||
|
record = find_page(load_records(map_path), scene, args.page)
|
||||||
|
if record is None:
|
||||||
|
raise ValueError(f"{scene} page {args.page} is not present in {map_path}")
|
||||||
|
print(report(record, args.context))
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
51
tools/test_locate_page.py
Normal file
51
tools/test_locate_page.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Plain tests for locate_page.py."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from locate_page import context_window, find_page, load_records
|
||||||
|
|
||||||
|
FAILS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(condition, message):
|
||||||
|
(FAILS.append(message) or print("FAIL:", message)) if not condition else print("ok:", message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_and_find():
|
||||||
|
rows = [
|
||||||
|
{"root_scene": "SC0000", "page": 1, "wait_offset": "0x83c"},
|
||||||
|
{"root_scene": "SC0000", "page": 2, "wait_offset": "0x871"},
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "pages.jsonl"
|
||||||
|
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
||||||
|
loaded = load_records(path)
|
||||||
|
check(len(loaded) == 2, "JSONL page records load")
|
||||||
|
check(find_page(loaded, "sc0000.bin", 2)["wait_offset"] == "0x871",
|
||||||
|
"scene matching is case-insensitive and accepts .BIN")
|
||||||
|
check(find_page(loaded, "SC0000", 3) is None, "missing page returns None")
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_window():
|
||||||
|
check(context_window([0, 5, 10, 15, 20], 10, 1) == (1, 4),
|
||||||
|
"context window includes target and requested neighbors")
|
||||||
|
check(context_window([0, 5, 10], 0, 8) == (0, 3), "context window clamps at script bounds")
|
||||||
|
try:
|
||||||
|
context_window([0, 5], 3, 1)
|
||||||
|
raised = False
|
||||||
|
except ValueError:
|
||||||
|
raised = True
|
||||||
|
check(raised, "non-instruction target is rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
test_load_and_find()
|
||||||
|
test_context_window()
|
||||||
|
print("FAILURES:", len(FAILS))
|
||||||
|
return 1 if FAILS else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user