feat: add runtime ADV page locator
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -9,19 +9,40 @@ using Age.Engine.Diagnostics;
|
||||
public sealed class GodotTraceSink : ITraceSink
|
||||
{
|
||||
private readonly GodotTimelineLog? _timeline;
|
||||
private readonly PageLocatorState _locator;
|
||||
private readonly Stack<string> _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<long> 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() : "<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)
|
||||
_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 _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<long, MovieRuntime> _movies = new();
|
||||
private readonly System.Collections.Generic.HashSet<long> _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 <f>: sleeps + retained presentation clocks
|
||||
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? pageMapPath = null; // --page-map <jsonl>: 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 —";
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user