Implement root reload and title debug launcher

This commit is contained in:
gamer147
2026-07-20 22:32:49 -04:00
parent df17747f63
commit f21a4c06bf
20 changed files with 1151 additions and 56 deletions

View File

@@ -0,0 +1,135 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Age.Engine.Sys4;
namespace Age.Engine.Diagnostics;
public enum DebugScriptKind { Scenario, SecondaryEvent, Debug, Other }
public enum DebugScriptFilter { All, Scenario, SecondaryEvent, Debug, Other }
/// <summary>One script shown by the developer scene launcher. PackedId, rather than Name, is its identity.</summary>
public sealed record DebugSceneEntry(
long PackedId,
string Name,
string Archive,
long Size,
int PackId,
int RawIndex,
DebugScriptKind Kind,
bool Launchable);
/// <summary>Future profile-owned extension for a proven launch state; catalog rows use no extra writes.</summary>
public sealed record DebugLaunchPreset(
string Label,
long PackedScriptId,
IReadOnlyDictionary<int, long> ExtraGlobalWrites,
string Note);
/// <summary>Pure catalog/filter model shared by the Godot developer UI and unit tests.</summary>
public static partial class DebugSceneCatalog
{
public static IReadOnlyList<DebugSceneEntry> Build(Sys4AssetCatalog catalog)
=> catalog.EnumerateScripts()
.Select(item =>
{
string logicalName = StripAppendPrefix(item.Asset.Name);
return new DebugSceneEntry(
item.PackedId,
item.Asset.Name,
item.Asset.Archive,
item.Asset.Size,
item.Asset.PackId,
item.Asset.RawIndex,
Classify(logicalName),
!logicalName.Equals("SYSTEM4.BIN", StringComparison.OrdinalIgnoreCase)
&& !logicalName.Equals("TITLE.BIN", StringComparison.OrdinalIgnoreCase));
})
.OrderBy(entry => KindRank(entry.Kind))
.ThenBy(entry => entry.Name, NaturalNameComparer.Instance)
.ThenBy(entry => entry.PackedId)
.ToArray();
public static IReadOnlyList<DebugSceneEntry> Filter(
IEnumerable<DebugSceneEntry> entries, DebugScriptFilter filter, string? query)
{
string needle = (query ?? "").Trim();
return entries.Where(entry => MatchesFilter(entry, filter) && MatchesQuery(entry, needle)).ToArray();
}
public static DebugScriptKind Classify(string name)
{
string logicalName = StripAppendPrefix(Path.GetFileName(name));
if (ScenarioName().IsMatch(logicalName)) return DebugScriptKind.Scenario;
if (logicalName.StartsWith("SP", StringComparison.OrdinalIgnoreCase))
return DebugScriptKind.SecondaryEvent;
if (logicalName.StartsWith("DEBUG", StringComparison.OrdinalIgnoreCase))
return DebugScriptKind.Debug;
return DebugScriptKind.Other;
}
private static bool MatchesFilter(DebugSceneEntry entry, DebugScriptFilter filter)
=> filter == DebugScriptFilter.All || (int)entry.Kind == (int)filter - 1;
private static bool MatchesQuery(DebugSceneEntry entry, string query)
{
if (query.Length == 0) return true;
if (entry.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
if (query.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
&& long.TryParse(query.AsSpan(2), NumberStyles.AllowHexSpecifier,
CultureInfo.InvariantCulture, out long hex))
return entry.PackedId == hex;
return long.TryParse(query, NumberStyles.Integer, CultureInfo.InvariantCulture, out long dec)
&& entry.PackedId == dec;
}
private static string StripAppendPrefix(string name) => AppendPrefix().Replace(name, "", 1);
private static int KindRank(DebugScriptKind kind) => kind switch
{
DebugScriptKind.Scenario => 0,
DebugScriptKind.SecondaryEvent => 1,
DebugScriptKind.Debug => 2,
_ => 3,
};
[GeneratedRegex(@"^SC\d{4}\.BIN$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ScenarioName();
[GeneratedRegex(@"^\$\d+\$", RegexOptions.CultureInvariant)]
private static partial Regex AppendPrefix();
private sealed class NaturalNameComparer : IComparer<string>
{
public static NaturalNameComparer Instance { get; } = new();
public int Compare(string? left, string? right)
{
left ??= "";
right ??= "";
int li = 0, ri = 0;
while (li < left.Length && ri < right.Length)
{
if (char.IsDigit(left[li]) && char.IsDigit(right[ri]))
{
int lstart = li, rstart = ri;
while (li < left.Length && char.IsDigit(left[li])) li++;
while (ri < right.Length && char.IsDigit(right[ri])) ri++;
ReadOnlySpan<char> ln = left.AsSpan(lstart, li - lstart).TrimStart('0');
ReadOnlySpan<char> rn = right.AsSpan(rstart, ri - rstart).TrimStart('0');
int length = ln.Length.CompareTo(rn.Length);
if (length != 0) return length;
int numeric = ln.CompareTo(rn, StringComparison.Ordinal);
if (numeric != 0) return numeric;
int padded = (li - lstart).CompareTo(ri - rstart);
if (padded != 0) return padded;
continue;
}
int character = char.ToUpperInvariant(left[li]).CompareTo(char.ToUpperInvariant(right[ri]));
if (character != 0) return character;
li++;
ri++;
}
return (left.Length - li).CompareTo(right.Length - ri);
}
}
}

View File

@@ -2,7 +2,7 @@ using Age.Engine.Model;
namespace Age.Engine.Diagnostics;
public enum TraceEventKind { Step, FrameEnter, FrameExit, CallScript, Stub, Halt }
public enum FrameCause { TopScene, CallScript }
public enum FrameCause { TopScene, CallScript, RootReload }
/// <summary>An engine diagnostic fact. A <c>readonly struct</c> with a Kind discriminator and a shared
/// field set — no per-event heap allocation. Only the fields relevant to a Kind are populated; the

View File

@@ -65,6 +65,9 @@ public interface IHost
void ClearCursorResource() { }
void Sleep(long duration);
void FrameYield();
// Native op 0x9 resets scene-owned host services before reloading root script resource 0.
// Global banks, engine configuration, decoded-asset caches, and persistent profile state survive.
void ResetSceneContext() { }
// Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive
// hosts default to normal playback; the Godot host supplies the live interactive values.
void SetMessageSkipActive(bool active) { }

View File

@@ -237,6 +237,24 @@ public sealed class GfxState
}
}
/// <summary>Native scene_context_init_reset ownership boundary used by opcode 0x9: discard
/// retained objects, command/query state, surfaces, transitions, render-target selection, and the
/// scene animation clock while leaving VM globals and decoded host assets outside this model.</summary>
public void ResetSceneContext()
{
lock (_lock)
{
_objects.Clear();
_fieldTable.Clear();
_surfaces.Clear();
_surfaceTransitions.Clear();
CurrentObject = 0;
CurrentRenderTargetSlot = -1;
AnimClockDurationTicks = 0;
AnimClockGeneration++;
}
}
private readonly object _lock = new();
// ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ----

View File

@@ -16,6 +16,9 @@ public sealed record AssetEntry(
bool IsPlaceholder = false,
int PackId = 0);
/// <summary>A real catalog entry paired with the packed resource id AGE uses at runtime.</summary>
public sealed record PackedAssetEntry(long PackedId, AssetEntry Asset);
/// <summary>Runtime parser and lookup views for a base S4IC SYS4INI catalog and its S4AC append mounts.</summary>
public sealed class Sys4AssetCatalog
{
@@ -172,6 +175,25 @@ public sealed class Sys4AssetCatalog
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
.Select(f => f.Name.ToUpperInvariant()).ToArray();
/// <summary>Enumerate every script in native packed-id order, including mounted append packs.
/// Placeholder slots and non-script assets are excluded without collapsing raw indices.</summary>
public IReadOnlyList<PackedAssetEntry> EnumerateScripts()
{
var scripts = new List<PackedAssetEntry>();
AddScripts(this, scripts);
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
AddScripts(append, scripts);
return scripts;
}
private static void AddScripts(Sys4AssetCatalog catalog, List<PackedAssetEntry> scripts)
{
long selector = (long)catalog.PackId << 24;
foreach (var entry in catalog.Files)
if (entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
scripts.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
}
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)
{
var ranges = new Dictionary<string, (int Start, int End)>(StringComparer.OrdinalIgnoreCase);

View File

@@ -3,12 +3,16 @@ using Age.Engine.Hosting;
using Age.Engine.Model;
namespace Age.Engine.Vm;
/// <summary>A stable identity/snapshot of the exact script frame currently executing.</summary>
public sealed record DebugFrameSnapshot(long FrameId, string CurrentScript, IReadOnlyList<string> CallStack);
public sealed class VirtualMachine
{
private const long NoJump = 0xFFFFFFFF;
private const int HALT = int.MinValue;
private const int FRAME_RETURN = int.MinValue + 1;
private const int HOTSPOT_RETURN = int.MinValue + 2;
private const int ROOT_RELOAD = int.MinValue + 3;
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_GSTRPTR = 8, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12,
@@ -24,6 +28,12 @@ public sealed class VirtualMachine
private int _depth;
private readonly ITraceSink _sink;
private readonly object _interactiveLock = new();
private readonly object _debugControlLock = new();
private readonly List<string> _activeFrameNames = new();
private ExecFrame? _debugActiveFrame;
private long _debugActiveFrameId;
private long _debugNextFrameId;
private DebugFrameReturnRequest? _debugFrameReturnRequest;
private ExecFrame? _interactiveFrame;
private ExecFrame? _rawInputFrame;
private int _pointerX = int.MinValue, _pointerY = int.MinValue;
@@ -59,12 +69,40 @@ public sealed class VirtualMachine
}
public AdvTextHistory TextHistory { get; }
/// <summary>The currently executing recursive script frame and stack, or null outside VM execution.</summary>
public DebugFrameSnapshot? DebugFrame
{
get
{
lock (_debugControlLock)
return _debugActiveFrame == null
? null
: new DebugFrameSnapshot(_debugActiveFrameId, _debugActiveFrame.Script.Name,
_activeFrameNames.ToArray());
}
}
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
IScriptProvider? provider = null, ITraceSink? sink = null,
AdvTextHistory? textHistory = null)
{ _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider;
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); }
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary.
/// Writes are copied here and applied by the VM thread before another opcode executes.</summary>
public bool TryRequestDebugFrameReturn(long frameId, IReadOnlyDictionary<int, long> globalWrites)
{
ArgumentNullException.ThrowIfNull(globalWrites);
lock (_debugControlLock)
{
if (_debugActiveFrame == null || _debugActiveFrameId != frameId
|| _debugFrameReturnRequest != null) return false;
_debugFrameReturnRequest = new DebugFrameReturnRequest(
_debugActiveFrame, new Dictionary<int, long>(globalWrites));
return true;
}
}
/// <summary>Update the native 800x600 cursor coordinate without advancing the current ADV page.</summary>
public void UpdatePointer(int x, int y)
{
@@ -315,7 +353,9 @@ public sealed class VirtualMachine
? ReadStr(operand)
: unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture);
private enum FrameOutcome { Returned, Halted, RanOff }
private sealed class RootReloadRequestedException : Exception { }
private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary<int, long> GlobalWrites);
private enum FrameOutcome { Returned, DebugReturned, RootReload, Halted, RanOff }
public void Run(int entryOffset = 0)
{
@@ -324,14 +364,65 @@ public sealed class VirtualMachine
if (entryOffset == 0 && _s.Instructions.Any(ins => IsAdvLabeledYield(_s, ins)))
Globals[SceneEntryCoroutineGate] = 1;
var top = new ExecFrame(_s, _s.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0);
var outcome = RunFrame(top, FrameCause.TopScene);
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
else if (outcome == FrameOutcome.Returned) HaltReason ??= "exit";
// Halted: HaltReason already set by the halting op.
Script root = _s;
int rootEntry = root.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0;
FrameCause cause = FrameCause.TopScene;
while (true)
{
var outcome = RunFrame(new ExecFrame(root, rootEntry), cause);
if (outcome == FrameOutcome.RootReload)
{
// Native 0x9 performs the scene reset before attempting the resource-0 load. Keep
// that ordering even when a diagnostic provider cannot resolve the root script.
ResetSceneContextForRootReload();
var reloaded = _provider?.GetById(0);
if (reloaded == null)
{
HaltReason ??= "root-reload-unresolved:0x0";
break;
}
root = reloaded;
rootEntry = root.IndexByOffset.TryGetValue(0, out int ri) ? ri : 0;
cause = FrameCause.RootReload;
continue;
}
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
else if (outcome is FrameOutcome.Returned or FrameOutcome.DebugReturned) HaltReason ??= "exit";
// Halted: HaltReason already set by the halting op.
break;
}
_sink.Emit(TraceEvent.Halt(HaltReason ?? "unknown", Steps));
}
private void ResetSceneContextForRootReload()
{
Gfx.ResetSceneContext();
_valueSwitchTargets.Clear();
lock (_interactiveLock)
{
_interactiveFrame = null;
_rawInputFrame = null;
_mouseButtonState = 0;
_mouseWheelDelta = 0;
_heldInputCallbackMask = 0;
_queuedInputCallbackMask = 0;
}
lock (_debugControlLock)
{
_debugActiveFrame = null;
_debugActiveFrameId = 0;
_debugFrameReturnRequest = null;
}
_autoMessageEnabled = false;
_autoVoicePending = false;
_messageSkipEnabled = false;
_messageSkipServiceActive = false;
_advTextStyle = AdvTextStyle.Default;
TextHistory.SetRecordingEnabled(true);
_host.SetMessageSkipActive(false);
_host.ResetSceneContext();
}
private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0)
{
ExecFrame? previousInteractiveFrame;
@@ -342,6 +433,16 @@ public sealed class VirtualMachine
previousRawInputFrame = _rawInputFrame;
}
var prev = _cur; _cur = frame; _depth++;
ExecFrame? previousDebugActiveFrame;
long previousDebugActiveFrameId;
lock (_debugControlLock)
{
previousDebugActiveFrame = _debugActiveFrame;
previousDebugActiveFrameId = _debugActiveFrameId;
_debugActiveFrame = frame;
_debugActiveFrameId = ++_debugNextFrameId;
_activeFrameNames.Add(frame.Script.Name);
}
bool hostContextEntered = false;
try
{
@@ -350,22 +451,35 @@ public sealed class VirtualMachine
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
try
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == ROOT_RELOAD) { outcome = FrameOutcome.RootReload; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
if (TryConsumeDebugFrameReturn(frame)) { outcome = FrameOutcome.DebugReturned; break; }
pc = next;
}
}
catch (RootReloadRequestedException) { outcome = FrameOutcome.RootReload; }
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
return outcome;
}
finally
{
lock (_debugControlLock)
{
if (ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) _debugFrameReturnRequest = null;
if (_activeFrameNames.Count > 0) _activeFrameNames.RemoveAt(_activeFrameNames.Count - 1);
_debugActiveFrame = previousDebugActiveFrame;
_debugActiveFrameId = previousDebugActiveFrameId;
}
lock (_interactiveLock)
{
if (cause == FrameCause.CallScript)
@@ -386,6 +500,20 @@ public sealed class VirtualMachine
}
}
private bool TryConsumeDebugFrameReturn(ExecFrame frame)
{
if (Volatile.Read(ref _debugFrameReturnRequest) is not { } pending
|| !ReferenceEquals(pending.Frame, frame)) return false;
lock (_debugControlLock)
{
if (!ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) return false;
foreach (var (address, value) in _debugFrameReturnRequest.GlobalWrites)
Globals[address] = value;
_debugFrameReturnRequest = null;
return true;
}
}
private bool ServiceHotspotCallback()
{
int target;
@@ -403,6 +531,7 @@ public sealed class VirtualMachine
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, _cur.Script.Instructions[pc], _depth));
int next = Step(_cur.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == ROOT_RELOAD) throw new RootReloadRequestedException();
if (next == HOTSPOT_RETURN || next == FRAME_RETURN) break;
if (next == HALT) break;
pc = next;
@@ -593,11 +722,10 @@ public sealed class VirtualMachine
}
case "exit": return FRAME_RETURN;
case "exit-script":
// Native op 0x9 clears the process-initial root flag before returning control to
// the root-script loader. Root reload itself remains represented by the port's
// existing frame/session boundary; retaining the flag here prevents LOGO/OP replay.
// Native op 0x9 clears the process-initial flag, disposes every active script frame,
// resets scene-owned services, and loads raw script resource 0 as the new root.
_initialRootRun = false;
return FRAME_RETURN;
return ROOT_RELOAD;
case "call-script":
{
long id = a.Count > 0 ? Read(a[0]) : 0;
@@ -614,6 +742,7 @@ public sealed class VirtualMachine
var entry = child.IndexByOffset.TryGetValue(0, out var ci) ? ci : 0;
var outcome = RunFrame(new ExecFrame(child, entry), FrameCause.CallScript, id);
if (outcome == FrameOutcome.Halted) return HALT; // propagate whole-VM halt up
if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD; // discard every caller frame
return pc + 1; // Returned / RanOff: resume caller
}
case "show-text":