Implement ADV History data navigation

This commit is contained in:
gamer147
2026-07-18 23:41:23 -04:00
parent b7ea16b54c
commit ac5437c5cf
11 changed files with 406 additions and 23 deletions

View File

@@ -0,0 +1,164 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class HistoryDataOpsTests
{
private const int T_IMM = 0, T_GINT = 3, T_LINT = 9;
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static Operand I(long value) => new(T_IMM, value);
private static Operand G(int address) => new(T_GINT, address);
private static Operand L(int address) => new(T_LINT, address);
[Fact]
public void CopyInlineIntArrayWritesConsecutiveSignedDwords()
{
const int arrayOffset = 20;
var body = new uint[arrayOffset + 5];
body[arrayOffset] = 4;
body[arrayOffset + 1] = 0;
body[arrayOffset + 2] = 5;
body[arrayOffset + 3] = 0xffff_ff74;
body[arrayOffset + 4] = 538;
var instructions = new[]
{
new Instruction(0, 0x64, new[] { L(4), I(arrayOffset) }),
new Instruction(5, 0x55, new[] { G(0x100), L(4) }),
new Instruction(10, 0x55, new[] { G(0x101), L(5) }),
new Instruction(15, 0x55, new[] { G(0x102), L(6) }),
new Instruction(20, 0x55, new[] { G(0x103), L(7) }),
new Instruction(25, 0x2, Array.Empty<Operand>()),
};
var script = new Script
{
Name = "INLINE_ARRAY",
Header = new ScriptHeader(0, 0, 0, 0, 0, 0),
Instructions = instructions,
IndexByOffset = instructions.Select((ins, index) => (ins.Offset, index))
.ToDictionary(pair => pair.Offset, pair => pair.index),
Strings = new Dictionary<int, string>(),
BodyDwords = body,
};
var vm = new VirtualMachine(script, Table, new RecordingHost());
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(new long[] { 0, 5, -140, 538 },
new[] { vm.Globals[0x100], vm.Globals[0x101], vm.Globals[0x102], vm.Globals[0x103] });
}
[Fact]
public void LoaderRetainsRealHistoryFooterArraysForRuntimeCopy()
{
var history = Sys4Loader.Load(Paths.Scripts()["HISTORY.BIN"], Table);
Assert.Equal(4u, history.BodyDwords[0x13d4]);
Assert.Equal(new uint[] { 0, 5, 0, 538 }, history.BodyDwords.Skip(0x13d5).Take(4));
Assert.Equal(14u, history.BodyDwords[0x1424]);
Assert.Equal(0xffff_ff74u, history.BodyDwords[0x1424 + 10]);
}
[Theory]
[InlineData(3, 30)]
[InlineData(5, 50)]
[InlineData(9, -1)]
public void ValueSwitchJumpsToMatchingCaseOrDefault(int selector, int expected)
{
var script = ScriptAssembler.Assemble(Table, "VALUE_SWITCH",
new List<(int, Operand[])>
{
(0xa1, Array.Empty<Operand>()), // 0
(0xa2, new[] { I(3), I(16) }), // 1
(0xa2, new[] { I(5), I(24) }), // 6
(0xa3, new[] { G(0x100), I(32) }), // 11
(0x55, new[] { G(0x101), I(30) }), // 16
(0x8c, new[] { I(37) }), // 21
(0x55, new[] { G(0x101), I(50) }), // 24
(0x8c, new[] { I(37) }), // 29
(0x55, new[] { G(0x101), I(-1) }), // 32
(0x2, Array.Empty<Operand>()), // 37
}, Array.Empty<string>());
var vm = new VirtualMachine(script, Table, new RecordingHost());
vm.Globals[0x100] = selector;
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(expected, unchecked((int)vm.Globals[0x101]));
}
[Fact]
public void HistoryOpcodesStepFromLatestBoundaryAndQueryOneGroup()
{
var history = new AdvTextHistory();
history.ResetLayout(1);
history.AppendMetadata(111, 1, AdvTextStyle.Default);
history.AppendText(0, 10, "older", AdvTextStyle.Default);
history.ResetLayout(2);
history.AppendMetadata(222, 2, AdvTextStyle.Default);
history.AppendVoice(77, 9, AdvTextStyle.Default);
history.AppendText(0, 11, "target", AdvTextStyle.Default);
history.ResetLayout(3);
history.AppendText(0, 12, "latest", AdvTextStyle.Default);
var script = ScriptAssembler.Assemble(Table, "HISTORY_QUERY",
new List<(int, Operand[])>
{
(0x51, new[] { L(10), I(0), I(1) }), // delta = -1
(0x1d0, new[] { L(0), L(1), L(10) }),
(0x55, new[] { G(0x100), L(0) }),
(0x55, new[] { G(0x101), L(1) }),
(0x1d3, new[] { L(2), L(3), I(1), L(1), I(2) }),
(0x55, new[] { G(0x102), L(2) }),
(0x55, new[] { G(0x103), L(3) }),
(0x1d4, new[] { L(4), L(5), I(1), L(1) }),
(0x55, new[] { G(0x104), L(4) }),
(0x55, new[] { G(0x105), L(5) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, Table, new RecordingHost(), textHistory: history);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(2, vm.Globals[0x100]);
Assert.Equal(2, vm.Globals[0x101]);
Assert.Equal(1, vm.Globals[0x102]);
Assert.Equal(222, vm.Globals[0x103]);
Assert.Equal(77, vm.Globals[0x104]);
Assert.Equal(9, vm.Globals[0x105]);
}
[Fact]
public void StepSkipsFilteredAndDuplicateLogicalEntries()
{
var history = new AdvTextHistory();
history.ResetLayout(1);
history.AppendText(0, 1, "visible", AdvTextStyle.Default);
history.ResetLayout(2);
history.ResetLayout(2); // duplicate first-record index, as native structural calls may produce
history.AppendText(0, 2, "filtered", AdvTextStyle.Default,
AdvTextHistoryRecordFlags.NavigationFiltered);
history.ResetLayout(3);
history.AppendText(0, 3, "latest", AdvTextStyle.Default);
Assert.True(history.TryStepGroup(-1, out var prior));
Assert.Equal(new AdvTextHistoryEntry(1, 0), prior);
Assert.False(history.TryStepGroup(-2, out var boundary));
Assert.Equal(new AdvTextHistoryEntry(-1, -1), boundary);
}
[Fact]
public void MissingHistoryQueriesUseNativeOutputDefaults()
{
var history = new AdvTextHistory();
history.ResetLayout(1);
history.AppendText(0, 1, "plain", AdvTextStyle.Default);
Assert.False(history.TryFindMetadata(0, 7, out long metadata));
Assert.Equal(0, metadata);
Assert.False(history.TryFindVoicePair(0, out long voiceId, out long voiceArgument));
Assert.Equal((-1L, -1L), (voiceId, voiceArgument));
}
}

View File

@@ -78,6 +78,7 @@ public sealed class AdvTextHistory
private readonly List<AdvTextHistoryEntry> _entries = new();
private readonly Dictionary<int, LayoutState> _layouts = new();
private readonly HashSet<int> _pendingGroupStarts = new();
private int _navigationAnchorIndex = -1;
public IReadOnlyList<AdvTextHistoryRecord> Records => _records;
public IReadOnlyList<AdvTextHistoryEntry> Entries => _entries;
@@ -114,10 +115,11 @@ public sealed class AdvTextHistory
layout.CursorY = y;
}
public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style)
public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style,
AdvTextHistoryRecordFlags flags = AdvTextHistoryRecordFlags.None)
{
int slot = ResolveLayout(requestedSlot);
AppendRecord(slot, AdvTextHistoryRecordKind.Text, AdvTextHistoryRecordFlags.None,
AppendRecord(slot, AdvTextHistoryRecordKind.Text, flags,
style, text, 0, 0, sourceOffset);
}
@@ -135,6 +137,81 @@ public sealed class AdvTextHistory
_records.Clear();
_entries.Clear();
_pendingGroupStarts.Clear();
_navigationAnchorIndex = -1;
}
/// <summary>
/// Resolve a logical entry relative to AGE's latest-boundary navigation anchor. Repeated calls do not
/// mutate the anchor; HISTORY.BIN supplies cumulative deltas while counting and paging backward.
/// </summary>
public bool TryStepGroup(int delta, out AdvTextHistoryEntry entry)
{
entry = new AdvTextHistoryEntry(-1, -1);
if ((uint)_navigationAnchorIndex >= (uint)_entries.Count) return false;
int index = _navigationAnchorIndex;
if (delta < 0)
{
for (int remaining = -delta; remaining > 0; remaining--)
{
int firstRecord = _entries[index].FirstRecordIndex;
do
{
if (firstRecord == 0 || index < 1) return false;
index--;
}
while (IsNavigationFiltered(_entries[index])
|| _entries[index].FirstRecordIndex == firstRecord);
}
}
else
{
for (int remaining = delta; remaining > 0; remaining--)
{
int firstRecord = _entries[index].FirstRecordIndex;
do
{
index++;
if (index >= _entries.Count) return false;
// Native treats the last entry's record offset as the forward sentinel.
if (_entries[index].FirstRecordIndex == _entries[^1].FirstRecordIndex) return false;
}
while (IsNavigationFiltered(_entries[index])
|| _entries[index].FirstRecordIndex == firstRecord);
}
}
entry = _entries[index];
return true;
}
public bool TryFindMetadata(int firstRecordIndex, long metadataType, out long value)
{
value = 0;
bool found = false;
foreach (var record in EnumerateGroup(firstRecordIndex))
{
if (!record.Flags.HasFlag(AdvTextHistoryRecordFlags.TypedMetadata)
|| record.AuxValue != metadataType) continue;
value = record.Value;
found = true;
}
return found;
}
public bool TryFindVoicePair(int firstRecordIndex, out long voiceId, out long voiceArgument)
{
voiceId = -1;
voiceArgument = -1;
bool found = false;
foreach (var record in EnumerateGroup(firstRecordIndex))
{
if (!record.Flags.HasFlag(AdvTextHistoryRecordFlags.VoicePair)) continue;
voiceId = record.Value;
voiceArgument = record.AuxValue;
found = true;
}
return found;
}
private int SelectLayout(int requestedSlot)
@@ -161,6 +238,22 @@ public sealed class AdvTextHistory
if (RecordingSuppressed) return;
_entries.Add(new AdvTextHistoryEntry(slot, _records.Count));
_pendingGroupStarts.Add(slot);
_navigationAnchorIndex = _entries.Count - 1;
}
private bool IsNavigationFiltered(AdvTextHistoryEntry entry)
=> (uint)entry.FirstRecordIndex < (uint)_records.Count
&& _records[entry.FirstRecordIndex].Flags.HasFlag(AdvTextHistoryRecordFlags.NavigationFiltered);
private IEnumerable<AdvTextHistoryRecord> EnumerateGroup(int firstRecordIndex)
{
if ((uint)firstRecordIndex >= (uint)_records.Count) yield break;
for (int i = firstRecordIndex; i < _records.Count; i++)
{
if (i > firstRecordIndex
&& _records[i].Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart)) yield break;
yield return _records[i];
}
}
private void AppendRecord(int slot, AdvTextHistoryRecordKind kind, AdvTextHistoryRecordFlags flags,

View File

@@ -6,5 +6,7 @@ public sealed class Script
public required IReadOnlyList<Instruction> Instructions { get; init; }
public required IReadOnlyDictionary<int, int> IndexByOffset { get; init; }
public required IReadOnlyDictionary<int, string> Strings { get; init; }
/// <summary>Unmodified SYS4 body dwords, retained for inline data operands such as opcode 0x64.</summary>
public IReadOnlyList<uint> BodyDwords { get; init; } = Array.Empty<uint>();
public string GetString(int offset) => Strings.TryGetValue(offset, out var s) ? s : "";
}

View File

@@ -22,7 +22,15 @@ public static class Sys4Loader
var header = new ScriptHeader(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]);
var (instrs, idxByOff, strings) = DecodeCode(dw, fields, nbody, table);
return new Script { Name = name, Header = header, Instructions = instrs, IndexByOffset = idxByOff, Strings = strings };
return new Script
{
Name = name,
Header = header,
Instructions = instrs,
IndexByOffset = idxByOff,
Strings = strings,
BodyDwords = dw,
};
}
private static (List<Instruction>, Dictionary<int, int>, Dictionary<int, string>)

View File

@@ -35,6 +35,7 @@ public sealed class VirtualMachine
private bool _autoVoicePending;
private volatile bool _messageSkipEnabled;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
private readonly Dictionary<string, int> _valueSwitchTargets = new(StringComparer.Ordinal);
public long CallScriptDispatches { get; private set; }
public Dictionary<int, long> Globals { get; } = new();
@@ -217,6 +218,24 @@ public sealed class VirtualMachine
}
}
private void WriteConsecutive(Operand destination, int index, long value)
{
int address = checked((int)destination.Value + index);
switch (destination.Type)
{
case T_GINT: case T_GFLOAT: Globals[address] = value; break;
case T_LINT: _cur.Locals.I[address] = value; break;
case T_LFLOAT: _cur.Locals.F[address] = value; break;
case T_GPTR: Globals[checked((int)Gi(Globals, (int)destination.Value) + index)] = value; break;
case T_LPTR: Globals[checked((int)Gi(_cur.Locals.P, (int)destination.Value) + index)] = value; break;
}
}
private string FormatSwitchValue(Operand operand)
=> IsStr(operand)
? ReadStr(operand)
: unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture);
private enum FrameOutcome { Returned, Halted, RanOff }
public void Run(int entryOffset = 0)
@@ -333,6 +352,26 @@ public sealed class VirtualMachine
LookupStore(a[0], BaseAddr(a[1]) + Read(a[2])); return pc + 1;
case "lookup-array-2d":
LookupStore(a[0], BaseAddr(a[1]) + Read(a[2]) * Read(a[3]) + Read(a[4])); return pc + 1;
case "copy-inline-int-array": // 0x64: count dword followed by plain file values
{
int offset = checked((int)Read(a[1]));
if ((uint)offset >= (uint)_cur.Script.BodyDwords.Count)
{
HaltReason ??= $"inline-array-offset@0x{offset:x}";
return HALT;
}
uint rawCount = _cur.Script.BodyDwords[offset];
int available = _cur.Script.BodyDwords.Count - offset - 1;
if (rawCount > (uint)available)
{
HaltReason ??= $"inline-array-length@0x{offset:x}:{rawCount}";
return HALT;
}
int count = (int)rawCount;
for (int i = 0; i < count; i++)
WriteConsecutive(a[0], i, unchecked((int)_cur.Script.BodyDwords[offset + 1 + i]));
return pc + 1;
}
case "bit-set":
{
long bit = Read(a[1]);
@@ -357,6 +396,16 @@ public sealed class VirtualMachine
long tgt = Read(a[0]) != 0 ? a[1].Value : a[2].Value;
return tgt == NoJump ? pc + 1 : _cur.Script.IndexByOffset.GetValueOrDefault((int)tgt, pc + 1);
}
case "begin-value-switch":
_valueSwitchTargets.Clear(); return pc + 1;
case "add-value-switch-case":
_valueSwitchTargets[FormatSwitchValue(a[0])] = checked((int)Read(a[1])); return pc + 1;
case "value-switch-jump":
{
int target = _valueSwitchTargets.TryGetValue(FormatSwitchValue(a[0]), out int matched)
? matched : checked((int)Read(a[1]));
return _cur.Script.IndexByOffset.GetValueOrDefault(target, pc + 1);
}
case "u0041ADB0":
case "coroutine-save-yield-handlers": // 0x7b: retain native handler metadata
_cur.CoroutineYieldHandlerA = (int)Read(a[0]);
@@ -619,6 +668,32 @@ public sealed class VirtualMachine
return pc + 1;
case "append-text-history-metadata": // 0x1d2: typed value attached to the current group
TextHistory.AppendMetadata(Read(a[0]), Read(a[1]), _advTextStyle); return pc + 1;
case "step-text-history": // 0x1d0: cumulative delta from the latest retained boundary
if (TextHistory.TryStepGroup((int)Read(a[2]), out var historyEntry))
{
Write(a[0], historyEntry.LayoutSlot);
Write(a[1], historyEntry.FirstRecordIndex);
}
else
{
Write(a[0], -1);
Write(a[1], -1);
}
return pc + 1;
case "u0041BB90":
case "find-text-history-value": // 0x1d3: operand 3 is accepted but ignored natively
{
bool found = TextHistory.TryFindMetadata((int)Read(a[3]), Read(a[4]), out long value);
Write(a[0], found ? 1 : 0);
Write(a[1], value);
return pc + 1;
}
case "u0041BC00":
case "find-text-history-pair": // 0x1d4: operand 3 is accepted but ignored natively
TextHistory.TryFindVoicePair((int)Read(a[3]), out long voiceId, out long voiceArgument);
Write(a[0], voiceId);
Write(a[1], voiceArgument);
return pc + 1;
case "clear-text-history": // 0x85: bound the backlog to the current ordinary ADV block
TextHistory.Clear(); return pc + 1;
case "set-font-size":