Implement root reload and title debug launcher
This commit is contained in:
183
engine/Age.Engine.Tests/DebugSceneLaunchTests.cs
Normal file
183
engine/Age.Engine.Tests/DebugSceneLaunchTests.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class DebugSceneLaunchTests
|
||||
{
|
||||
private static Operand I(long value) => new(0, value);
|
||||
private static Operand G(long address) => new(3, address);
|
||||
private static (int, Operand[]) Call(Operand id) => (0x3, new[] { id });
|
||||
private static (int, Operand[]) Mov(int address, long value) => (0x55, new[] { G(address), I(value) });
|
||||
private static (int, Operand[]) Wait() => (0x72, new[] { I(0) });
|
||||
private static (int, Operand[]) Sleep() => (0xc8, new[] { I(1) });
|
||||
private static (int, Operand[]) Exit() => (0x2, Array.Empty<Operand>());
|
||||
|
||||
[Fact]
|
||||
public async Task ParkedTitleFrameReturnsToCoordinatorWhichDispatchesSelectedScript()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Call(I(1)), Call(G(0x699)), Mov(0x7102, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Wait(), Mov(0x7100, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x7101, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var host = new BlockingWaitHost();
|
||||
var trace = new RecordingTraceSink();
|
||||
var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new()
|
||||
{
|
||||
[1] = title,
|
||||
[2] = selected,
|
||||
}), sink: trace);
|
||||
|
||||
Assert.False(vm.TryRequestDebugFrameReturn(0, new Dictionary<int, long>()));
|
||||
Task run = Task.Run(() => vm.Run());
|
||||
await host.WaitEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var parked = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
|
||||
Assert.Equal("TITLE.BIN", parked.CurrentScript);
|
||||
Assert.Equal(new[] { "SYSTEM4.BIN", "TITLE.BIN" }, parked.CallStack);
|
||||
Assert.True(vm.TryRequestDebugFrameReturn(parked.FrameId, new Dictionary<int, long>
|
||||
{
|
||||
[0] = 1,
|
||||
[0xaba5c] = -1,
|
||||
[0x62ccf] = 0,
|
||||
[0x699] = 2,
|
||||
}));
|
||||
Assert.False(vm.TryRequestDebugFrameReturn(parked.FrameId, new Dictionary<int, long>()));
|
||||
host.SignalInput();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7100));
|
||||
Assert.Equal(1, vm.Globals[0x7101]);
|
||||
Assert.Equal(1, vm.Globals[0x7102]);
|
||||
Assert.Null(vm.DebugFrame);
|
||||
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameExit
|
||||
&& e.Name == "TITLE.BIN" && e.Text == "DebugReturned");
|
||||
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameEnter
|
||||
&& e.Name == "DEBUG.BIN" && e.Cause == FrameCause.CallScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedScriptExitScriptStillPerformsWholeStackRootReload()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var initialRoot = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Call(I(1)), Call(G(0x699)), Mov(0x7200, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Wait(), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
(0x9, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x7201, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var host = new BlockingWaitHost();
|
||||
var trace = new RecordingTraceSink();
|
||||
var vm = new VirtualMachine(initialRoot, table, host, provider: new MapProvider(new()
|
||||
{
|
||||
[0] = reloadedRoot,
|
||||
[1] = title,
|
||||
[2] = selected,
|
||||
}), sink: trace);
|
||||
|
||||
Task run = Task.Run(() => vm.Run());
|
||||
await host.WaitEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var frame = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
|
||||
Assert.True(vm.TryRequestDebugFrameReturn(frame.FrameId,
|
||||
new Dictionary<int, long> { [0x699] = 2 }));
|
||||
host.SignalInput();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7200));
|
||||
Assert.Equal(1, vm.Globals[0x7201]);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameEnter
|
||||
&& e.Name == "SYSTEM4.BIN" && e.Cause == FrameCause.RootReload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PollingTitleReturnsAtOpcodeBoundaryWithoutAdvInputWait()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Call(I(1)), Call(G(0x699)), Mov(0x7302, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Sleep(), Mov(0x7300, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x7301, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var host = new BlockingSleepHost();
|
||||
var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new()
|
||||
{
|
||||
[1] = title,
|
||||
[2] = selected,
|
||||
}));
|
||||
|
||||
Task run = Task.Run(() => vm.Run());
|
||||
await host.SleepEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var frame = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
|
||||
Assert.Equal(new[] { "SYSTEM4.BIN", "TITLE.BIN" }, frame.CallStack);
|
||||
Assert.True(vm.TryRequestDebugFrameReturn(frame.FrameId,
|
||||
new Dictionary<int, long> { [0x699] = 2 }));
|
||||
host.CompleteSleep();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7300));
|
||||
Assert.Equal(1, vm.Globals[0x7301]);
|
||||
Assert.Equal(1, vm.Globals[0x7302]);
|
||||
}
|
||||
|
||||
private sealed class BlockingWaitHost : RecordingHost
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||
public TaskCompletionSource WaitEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
|
||||
Func<AdvAutoWaitState> autoWaitState)
|
||||
{
|
||||
Waits++;
|
||||
WaitEntered.TrySetResult();
|
||||
if (!_gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("test input wait timed out");
|
||||
}
|
||||
|
||||
public void SignalInput() => _gate.Release();
|
||||
}
|
||||
|
||||
private sealed class BlockingSleepHost : RecordingHost
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||
public TaskCompletionSource SleepEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override void Sleep(long duration)
|
||||
{
|
||||
SleptDurations.Add(duration);
|
||||
SleepEntered.TrySetResult();
|
||||
if (!_gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("test sleep timed out");
|
||||
}
|
||||
|
||||
public void CompleteSleep() => _gate.Release();
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,19 @@ public class MovieOpcodeTests
|
||||
(0x130, new[] { new Operand(3, 0x100) }),
|
||||
(0x9, System.Array.Empty<Operand>()),
|
||||
}, System.Array.Empty<string>());
|
||||
var vm = new VirtualMachine(root, table, new RecordingHost());
|
||||
var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])>
|
||||
{
|
||||
(0x130, new[] { new Operand(3, 0x101) }),
|
||||
(0x2, System.Array.Empty<Operand>()),
|
||||
}, System.Array.Empty<string>());
|
||||
var host = new RecordingHost();
|
||||
var vm = new VirtualMachine(root, table, host,
|
||||
provider: new MapProvider(new Dictionary<long, Script> { [0] = reloadedRoot }));
|
||||
|
||||
vm.Run();
|
||||
Assert.Equal(1, vm.Globals[0x100]);
|
||||
|
||||
vm.Globals[0x100] = -1;
|
||||
vm.Run();
|
||||
Assert.Equal(0, vm.Globals[0x100]);
|
||||
Assert.Equal(0, vm.Globals[0x101]);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
115
engine/Age.Engine.Tests/RootReloadTests.cs
Normal file
115
engine/Age.Engine.Tests/RootReloadTests.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class RootReloadTests
|
||||
{
|
||||
private static Operand I(long value) => new(0, value);
|
||||
private static Operand G(long address) => new(3, address);
|
||||
|
||||
[Fact]
|
||||
public void ExitScriptDiscardsEveryCallerAndReloadsRawScriptZero()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var system4 = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])>
|
||||
{
|
||||
(0x1b6, new[] { G(0x220) }),
|
||||
(0x19a, new[] { G(0x221) }),
|
||||
(0x55, new[] { G(0x202), I(0x99) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var deepest = ScriptAssembler.Assemble(table, "DEEPEST", new List<(int, Operand[])>
|
||||
{
|
||||
(0x55, new[] { G(0x203), I(3) }),
|
||||
(0x9, Array.Empty<Operand>()),
|
||||
(0x55, new[] { G(0x204), I(4) }),
|
||||
}, Array.Empty<string>());
|
||||
var child = ScriptAssembler.Assemble(table, "CHILD", new List<(int, Operand[])>
|
||||
{
|
||||
(0x3, new[] { I(2) }),
|
||||
(0x55, new[] { G(0x205), I(5) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var initial = ScriptAssembler.Assemble(table, "INITIAL", new List<(int, Operand[])>
|
||||
{
|
||||
(0x1b7, new[] { I(1) }),
|
||||
(0x88, new[] { I(1) }),
|
||||
(0x55, new[] { G(0x200), I(1) }),
|
||||
(0x3, new[] { I(1) }),
|
||||
(0x55, new[] { G(0x201), I(2) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var provider = new MapProvider(new Dictionary<long, Script>
|
||||
{
|
||||
[0] = system4,
|
||||
[1] = child,
|
||||
[2] = deepest,
|
||||
});
|
||||
var host = new RecordingHost();
|
||||
var trace = new RecordingTraceSink();
|
||||
var vm = new VirtualMachine(initial, table, host, provider: provider, sink: trace);
|
||||
vm.Globals[0x2ff] = 0x1234;
|
||||
vm.ExternalGlobals[7] = 0x5678;
|
||||
vm.Gfx.SetSurface(5, 0x33, 0);
|
||||
vm.Gfx.BindDraw(0x100, 5, 0, 0, 1, 1, 0, 0);
|
||||
vm.TextHistory.DefineLayout(1, 10, 10, 0, 0);
|
||||
vm.TextHistory.AppendText(1, 0, "preserved", AdvTextStyle.Default);
|
||||
vm.TextHistory.SetRecordingEnabled(false);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal("exit", vm.HaltReason);
|
||||
Assert.Equal(1, vm.Globals[0x200]);
|
||||
Assert.Equal(3, vm.Globals[0x203]);
|
||||
Assert.Equal(0x99, vm.Globals[0x202]);
|
||||
Assert.False(vm.Globals.ContainsKey(0x201));
|
||||
Assert.False(vm.Globals.ContainsKey(0x204));
|
||||
Assert.False(vm.Globals.ContainsKey(0x205));
|
||||
Assert.Equal(0x1234, vm.Globals[0x2ff]);
|
||||
Assert.Equal(0x5678, vm.ExternalGlobals[7]);
|
||||
Assert.Equal(0, vm.Globals[0x220]);
|
||||
Assert.Equal(0, vm.Globals[0x221]);
|
||||
Assert.Empty(vm.Gfx.SnapshotVisibleObjects());
|
||||
Assert.Single(vm.TextHistory.Records, r => r.Text == "preserved");
|
||||
Assert.False(vm.TextHistory.RecordingSuppressed);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
Assert.False(host.MessageSkip);
|
||||
|
||||
var rootEnter = Assert.Single(trace.Events,
|
||||
e => e.Kind == TraceEventKind.FrameEnter && e.Name == "SYSTEM4");
|
||||
Assert.Equal(FrameCause.RootReload, rootEnter.Cause);
|
||||
Assert.Equal(3, trace.Events.Count(e =>
|
||||
e.Kind == TraceEventKind.FrameExit && e.Text == "RootReload"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HimegariRawScriptZeroIsSystem4()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var scripts = Sys4ScriptProvider.Load(table);
|
||||
|
||||
Assert.Equal("SYSTEM4.BIN", scripts.GetById(0)?.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitScriptResetsSceneBeforeAnUnresolvedRootLoadFails()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var script = ScriptAssembler.Assemble(table, "NO_ROOT", new List<(int, Operand[])>
|
||||
{
|
||||
(0x9, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var host = new RecordingHost();
|
||||
var vm = new VirtualMachine(script, table, host);
|
||||
vm.Gfx.SetSurface(5, 0x33, 0);
|
||||
vm.Gfx.BindDraw(0x100, 5, 0, 0, 1, 1, 0, 0);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal("root-reload-unresolved:0x0", vm.HaltReason);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
Assert.Empty(vm.Gfx.SnapshotVisibleObjects());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
public class Sys4ScriptProviderTests
|
||||
@@ -46,4 +47,44 @@ public class Sys4ScriptProviderTests
|
||||
Assert.Null(provider.GetByName("../FIELD.BIN"));
|
||||
Assert.Equal(481, provider.ScriptNames.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugCatalogPreservesPackedIdentityAcrossBaseAndAppendPacks()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var entries = DebugSceneCatalog.Build(catalog);
|
||||
|
||||
Assert.Equal(catalog.EnumerateScripts().Count, entries.Count);
|
||||
Assert.Equal(481, entries.Count(entry => entry.PackId == 0));
|
||||
Assert.Contains(entries, entry => entry.Name == "$1$SC1260.BIN"
|
||||
&& entry.PackedId == (0x01000000L | (uint)entry.RawIndex)
|
||||
&& entry.Kind == DebugScriptKind.Scenario);
|
||||
Assert.All(entries, entry => Assert.Same(
|
||||
catalog.ResolvePacked(entry.PackedId),
|
||||
catalog.AppendPacks.GetValueOrDefault(entry.PackId, catalog).ResolveRaw(entry.RawIndex)));
|
||||
Assert.Equal(entries.Count, entries.Select(entry => entry.PackedId).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugCatalogFiltersByProfileCategoryNameAndPackedId()
|
||||
{
|
||||
var entries = DebugSceneCatalog.Build(Sys4AssetCatalog.Load(Paths.Sys4Ini));
|
||||
|
||||
var scenarios = DebugSceneCatalog.Filter(entries, DebugScriptFilter.Scenario, "");
|
||||
Assert.NotEmpty(scenarios);
|
||||
Assert.All(scenarios, entry => Assert.Equal(DebugScriptKind.Scenario, entry.Kind));
|
||||
Assert.Contains(scenarios, entry => entry.Name.Equals("SC0000.BIN", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var debug = DebugSceneCatalog.Filter(entries, DebugScriptFilter.Debug, "DEBUG.BIN");
|
||||
var exact = Assert.Single(debug);
|
||||
Assert.Equal("DEBUG.BIN", exact.Name);
|
||||
Assert.True(exact.Launchable);
|
||||
Assert.Equal(exact, Assert.Single(DebugSceneCatalog.Filter(entries, DebugScriptFilter.All,
|
||||
$"0x{exact.PackedId:x}")));
|
||||
Assert.Equal(exact, Assert.Single(DebugSceneCatalog.Filter(entries, DebugScriptFilter.All,
|
||||
exact.PackedId.ToString())));
|
||||
|
||||
Assert.False(entries.Single(entry => entry.Name == "SYSTEM4.BIN").Launchable);
|
||||
Assert.False(entries.Single(entry => entry.Name == "TITLE.BIN").Launchable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ internal class RecordingHost : IHost
|
||||
public readonly List<long> CursorResources = new();
|
||||
public readonly List<bool> AdvPagePresentationSuspended = new();
|
||||
public int CursorClearCount;
|
||||
public int SceneContextResets;
|
||||
public void ShowText(int offset, string text) => Lines.Add((offset, text));
|
||||
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
|
||||
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
|
||||
@@ -88,6 +89,7 @@ internal class RecordingHost : IHost
|
||||
public void ClearCursorResource() => CursorClearCount++;
|
||||
public virtual void Sleep(long duration) => SleptDurations.Add(duration);
|
||||
public virtual void FrameYield() { }
|
||||
public void ResetSceneContext() => SceneContextResets++;
|
||||
public bool IsMessageSkipActive => MessageSkip;
|
||||
public void SetMessageSkipActive(bool active)
|
||||
{
|
||||
|
||||
135
engine/Age.Engine/Diagnostics/DebugSceneCatalog.cs
Normal file
135
engine/Age.Engine/Diagnostics/DebugSceneCatalog.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user