From fb3a7206ce259448fe833b7bcead6c9baccce995 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 11 Jul 2026 22:52:26 -0400 Subject: [PATCH] feat: add runtime ADV page locator --- docs/PROJECT-STRUCTURE.md | 3 + docs/phase-a-slice-plan.md | 12 ++++ docs/tools-reference.md | 14 ++++ godot/GodotAdvHost.cs | 7 +- godot/GodotTraceSink.cs | 35 ++++++++-- godot/Main.cs | 40 +++++++++-- godot/PageLocatorState.cs | 135 +++++++++++++++++++++++++++++++++++++ tools/locate_page.py | 118 ++++++++++++++++++++++++++++++++ tools/test_locate_page.py | 51 ++++++++++++++ 9 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 godot/PageLocatorState.cs create mode 100644 tools/locate_page.py create mode 100644 tools/test_locate_page.py diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index a9615a1..6505437 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -104,6 +104,9 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) └── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine) ``` +The disposable `build/page-map-.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 - **Three-way separation.** `姫狩りダンジョンマイスター/` = untouched originals; `extracted/` = diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 19dfd41..1a44ee5 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. Validation: engine **152/152**, zero-warning Godot build, and threaded `SELFTEST OK`; manual confirmation is 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-.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. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 94b50d9..75a97f7 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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 ` | `build/settex-.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 | +### 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 ] [--context N]` | `build/page-map-.jsonl` + script corpus → stdout | + ## Engine (C#) — VM core, CLI, Godot frontend 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 failures at an exact bytecode boundary. Relative output paths are project-relative (`godot/`). +**Godot page locator:** every normal run recreates `build/page-map-.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 ` 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 | Tool | Purpose | Run | Reads → Writes | diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index df24b45..52fb01f 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -22,6 +22,7 @@ public sealed class GodotAdvHost : IHost private readonly SemaphoreSlim _gate = new(0, 1); private readonly Age.Engine.Hosting.FrameClock _clock; private readonly GodotTimelineLog? _timeline; + private readonly PageLocatorState _locator; private readonly System.Threading.AutoResetEvent _frameSignal = new(false); private volatile bool _stopping; private readonly object _textLock = new(); @@ -45,15 +46,16 @@ public sealed class GodotAdvHost : IHost public readonly List<(int Offset, string Text)> Captured = new(); 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; - _timeline = timeline; + _locator = locator; _timeline = timeline; } public void ShowText(int offset, string text) { Captured.Add((offset, text)); + _locator.Text(offset, text); lock (_textLock) { _advText = text; @@ -147,6 +149,7 @@ public sealed class GodotAdvHost : IHost public void WaitForInput(int layoutSlot) { Pages++; + _locator.Wait(Pages); _main.CallDeferred("PageBreak"); // 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. diff --git a/godot/GodotTraceSink.cs b/godot/GodotTraceSink.cs index ad4c6df..e674eaf 100644 --- a/godot/GodotTraceSink.cs +++ b/godot/GodotTraceSink.cs @@ -9,19 +9,40 @@ using Age.Engine.Diagnostics; public sealed class GodotTraceSink : ITraceSink { private readonly GodotTimelineLog? _timeline; + private readonly PageLocatorState _locator; private readonly Stack _scripts = new(); - public GodotTraceSink(GodotTimelineLog? timeline = null) => _timeline = timeline; - public bool TracingSteps => _timeline != null; + public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? 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 CallScripts = new(); public void Emit(in TraceEvent e) { if (e.Kind == TraceEventKind.CallScript) CallScripts.Enqueue(e.Id); - if (_timeline == null) return; - if (e.Kind == TraceEventKind.FrameEnter && e.Name != null) _scripts.Push(e.Name); - else if (e.Kind == TraceEventKind.FrameExit && _scripts.Count > 0) _scripts.Pop(); + if (e.Kind == TraceEventKind.FrameEnter && e.Name != null) + { + _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) - _timeline.Step(_scripts.Count > 0 ? _scripts.Peek() : "", e.Ins.Offset, e.Opcode, e.Depth); + { + string script = _scripts.Count > 0 ? _scripts.Peek() : ""; + _locator.Step(script, e.Ins.Offset); + _timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth); + } 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); } } diff --git a/godot/Main.cs b/godot/Main.cs index a3cd142..ad84f71 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -27,6 +27,7 @@ public partial class Main : Godot.Control private Label _text = null!; private Label _speaker = null!; private Label _status = null!; + private Label _locatorHud = null!; private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _voice = null!; // interrupt-on-new voice 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 _movies = new(); private readonly System.Collections.Generic.HashSet _movieFrameSeen = new(); private GodotTraceSink _trace = null!; + private PageLocatorState _locator = null!; + private bool _locatorHudVisible; private Age.Engine.Diagnostics.HistogramTraceSink? _hist; // --trace-histogram: profile the real run private string? _histFile; private Age.Engine.Model.OpcodeTable? _table; @@ -97,6 +100,9 @@ public partial class Main : Godot.Control _status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide); _status.OffsetLeft = 40; _status.OffsetTop = -60; 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). 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); _speaker.AddThemeFontOverride("font", ff); _status.AddThemeFontOverride("font", ff); + _locatorHud.AddThemeFontOverride("font", ff); _text.AddThemeFontSizeOverride("font_size", 25); _speaker.AddThemeFontSizeOverride("font_size", 25); _text.AddThemeConstantOverride("outline_size", 1); @@ -140,6 +147,7 @@ public partial class Main : Godot.Control double speed = 1.0; // --speed : sleeps + retained presentation clocks long transitionClickMs = -1; // --transition-click-ms : force active transitions after n virtual ms string? histFile = null; // --trace-histogram : op/call-site execution counts of the REAL run + string? pageMapPath = null; // --page-map : override default build/page-map-SCxxxx.jsonl for (int i = 0; i < userArgs.Length; i++) { 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] == "--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] == "--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) { var kv = userArgs[i + 1].Split('='); @@ -178,9 +188,13 @@ public partial class Main : Godot.Control if (_selftest) (script, provider) = BuildSelfTestScene(table); else { scripts = Sys4ScriptProvider.Load(table); script = scripts.RequireByName(scene + ".BIN"); provider = scripts; } 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(); - _host = new GodotAdvHost(this, resources, scene, _clock, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null }; - _trace = new GodotTraceSink(_timeline); + _host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null }; + _trace = new GodotTraceSink(_locator, _timeline); // --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). _table = table; @@ -298,6 +312,19 @@ public partial class Main : Godot.Control public override void _Input(InputEvent e) { 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") || (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)) _host.SignalInput(); @@ -305,7 +332,7 @@ public partial class Main : Godot.Control 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(); _movies.Clear(); } @@ -702,7 +729,12 @@ public partial class Main : Godot.Control private sealed record MovieRuntime(string Name, int RawIndex, DirectShowMovieDecoder Decoder); 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 ShowEnd() => _status.Text = "— end —"; diff --git a/godot/PageLocatorState.cs b/godot/PageLocatorState.cs new file mode 100644 index 0000000..eec0ac8 --- /dev/null +++ b/godot/PageLocatorState.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +/// +/// 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. +/// +public sealed class PageLocatorState : System.IDisposable +{ + private readonly object _lock = new(); + private readonly string _rootScene; + private readonly StreamWriter? _writer; + private string _script = ""; + private int _offset = -1; + private string[] _callStack = System.Array.Empty(); + 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 + { + ["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(); + } + } +} diff --git a/tools/locate_page.py b/tools/locate_page.py new file mode 100644 index 0000000..e721b7b --- /dev/null +++ b/tools/locate_page.py @@ -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-.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()) diff --git a/tools/test_locate_page.py b/tools/test_locate_page.py new file mode 100644 index 0000000..19d7ed1 --- /dev/null +++ b/tools/test_locate_page.py @@ -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())