Implement retained ADV History writers

This commit is contained in:
gamer147
2026-07-18 23:28:26 -04:00
parent 9a6bc38f40
commit b7ea16b54c
6 changed files with 415 additions and 9 deletions

View File

@@ -0,0 +1,155 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class AdvTextHistoryTests
{
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static Operand I(long value) => new(0, value);
private static Operand S(int index) => new(2, index);
[Fact]
public void OrdinaryAdvOpsBuildGroupedStyledMetadataVoiceAndTextRecords()
{
var script = ScriptAssembler.Assemble(Table, "HISTORY_WRITE",
new List<(int, Operand[])>
{
(0x70, new[] { I(1), I(640), I(160), I(80), I(430) }),
(0x75, new[] { I(24) }),
(0x76, new[] { I(0xf0e0d0) }),
(0x7a, new[] { I(0), I(12), I(34) }),
(0x1d2, new[] { I(123), I(1) }),
(0xc4, new[] { I(77) }),
(0x6e, new[] { I(0), S(0) }),
(0x71, new[] { I(1) }),
(0x6e, new[] { I(0), S(1) }),
(0x2, Array.Empty<Operand>()),
}, new[] { "first", "second" });
var vm = new VirtualMachine(script, Table, new RecordingHost());
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(new[]
{
new AdvTextHistoryEntry(1, 0),
new AdvTextHistoryEntry(1, 3),
}, vm.TextHistory.Entries);
Assert.Collection(vm.TextHistory.Records,
metadata =>
{
Assert.Equal(AdvTextHistoryRecordKind.Metadata, metadata.Kind);
Assert.Equal(AdvTextHistoryRecordFlags.TypedMetadata | AdvTextHistoryRecordFlags.GroupStart,
metadata.Flags);
Assert.Equal(123, metadata.Value);
Assert.Equal(1, metadata.AuxValue);
Assert.Equal((24, 0xf0e0d0L), (metadata.Style.PrimaryFontSize, metadata.Style.TextColor));
Assert.Equal(new AdvTextLayoutSnapshot(1, 640, 160, 80, 430, 12, 34), metadata.Layout);
},
voice =>
{
Assert.Equal(AdvTextHistoryRecordKind.Voice, voice.Kind);
Assert.Equal(AdvTextHistoryRecordFlags.VoicePair, voice.Flags);
Assert.Equal((77L, 0L), (voice.Value, voice.AuxValue));
},
text =>
{
Assert.Equal(AdvTextHistoryRecordKind.Text, text.Kind);
Assert.Equal("first", text.Text);
Assert.Equal(AdvTextHistoryRecordFlags.None, text.Flags);
},
text =>
{
Assert.Equal(AdvTextHistoryRecordKind.Text, text.Kind);
Assert.Equal("second", text.Text);
Assert.Equal(AdvTextHistoryRecordFlags.GroupStart, text.Flags);
Assert.Equal((0, 0), (text.Layout.CursorX, text.Layout.CursorY));
});
}
[Fact]
public void RecordingSuppressionBlocksEveryWriterUntilReenabled()
{
var script = ScriptAssembler.Assemble(Table, "HISTORY_SUPPRESS",
new List<(int, Operand[])>
{
(0x1bb, new[] { I(0) }),
(0x70, new[] { I(2), I(500), I(100), I(10), I(20) }),
(0x1d2, new[] { I(9), I(2) }),
(0xc4, new[] { I(88) }),
(0x6e, new[] { I(0), S(0) }),
(0x1bb, new[] { I(1) }),
(0x71, new[] { I(2) }),
(0x6e, new[] { I(0), S(1) }),
(0x2, Array.Empty<Operand>()),
}, new[] { "hidden", "retained" });
var vm = new VirtualMachine(script, Table, new RecordingHost());
vm.Run();
Assert.False(vm.TextHistory.RecordingSuppressed);
Assert.Equal(new AdvTextHistoryEntry(2, 0), Assert.Single(vm.TextHistory.Entries));
var record = Assert.Single(vm.TextHistory.Records);
Assert.Equal("retained", record.Text);
Assert.True(record.Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart));
}
[Fact]
public void ClearDropsRecordsIndexAndPendingGroupStartButRetainsLayoutDefinition()
{
var history = new AdvTextHistory();
history.DefineLayout(3, 320, 90, 20, 400);
history.AppendText(0, 10, "old", AdvTextStyle.Default);
history.Clear();
history.AppendText(0, 11, "new", AdvTextStyle.Default);
Assert.Empty(history.Entries);
var record = Assert.Single(history.Records);
Assert.Equal("new", record.Text);
Assert.False(record.Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart));
Assert.Equal(new AdvTextLayoutSnapshot(3, 320, 90, 20, 400, 0, 0), record.Layout);
}
[Fact]
public void GameSessionSharesOneLiveHistoryAcrossSceneVms()
{
Script Scene(string name, string text) => ScriptAssembler.Assemble(Table, name,
new List<(int, Operand[])>
{
(0x71, new[] { I(1) }),
(0x6e, new[] { I(0), S(0) }),
(0x2, Array.Empty<Operand>()),
}, new[] { text });
var session = new GameSession();
session.RunScene(Scene("FIRST", "one"), Table, new RecordingHost());
session.RunScene(Scene("SECOND", "two"), Table, new RecordingHost());
Assert.Equal(new[] { "one", "two" }, session.TextHistory.Records.Select(r => r.Text));
Assert.Equal(new[]
{
new AdvTextHistoryEntry(1, 0),
new AdvTextHistoryEntry(1, 1),
}, session.TextHistory.Entries);
}
[Fact]
public void RealSc0000FirstPagePopulatesTheRetainedBacklogBeforeItsWait()
{
var scripts = Sys4ScriptProvider.Load(Table);
var scene = scripts.RequireByName("SC0000.BIN");
var vm = new VirtualMachine(scene, Table, new RecordingHost(),
new VmOptions(MaxSteps: 1_000_000, HaltAtWaitForInput: true), scripts);
vm.Run();
Assert.Equal("wait-for-input", vm.HaltReason);
Assert.NotEmpty(vm.TextHistory.Entries);
var text = Assert.Single(vm.TextHistory.Records.Where(r => r.Kind == AdvTextHistoryRecordKind.Text));
Assert.Equal(0x14963, text.SourceOffset);
Assert.NotEmpty(text.Text);
Assert.True(text.Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart));
}
}

View File

@@ -0,0 +1,177 @@
namespace Age.Engine.Model;
[Flags]
public enum AdvTextHistoryRecordFlags : uint
{
None = 0,
GroupStart = 0x00000001,
NavigationFiltered = 0x00000002,
TypedMetadata = 0x20000000,
VoicePair = 0x40000000,
}
public enum AdvTextHistoryRecordKind
{
Text,
Metadata,
Voice,
}
/// <summary>The text raster state copied into each native retained text record.</summary>
public readonly record struct AdvTextStyle(
int PrimaryFontSize,
int RubyFontSize,
bool Bold,
long TextColor,
long EffectColor,
int RenderMode,
int EffectOffsetX,
int EffectOffsetY)
{
public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0);
}
/// <summary>A stable snapshot of the layout state associated with a retained record.</summary>
public readonly record struct AdvTextLayoutSnapshot(
int Slot,
int Width,
int Height,
int OriginX,
int OriginY,
int CursorX,
int CursorY);
/// <summary>
/// One semantic counterpart of AGE's 0x48-byte retained text record. Metadata uses
/// <see cref="Value"/> plus <see cref="AuxValue"/> as value/type; voice uses them as id/argument.
/// </summary>
public sealed record AdvTextHistoryRecord(
AdvTextHistoryRecordKind Kind,
AdvTextHistoryRecordFlags Flags,
AdvTextLayoutSnapshot Layout,
AdvTextStyle Style,
string Text,
long Value,
long AuxValue,
int SourceOffset);
/// <summary>AGE's logical 8-byte history index entry.</summary>
public readonly record struct AdvTextHistoryEntry(int LayoutSlot, int FirstRecordIndex);
/// <summary>
/// Engine-owned retained ADV backlog. It deliberately has no persistence behavior: native numbered-save
/// restoration belongs to the future unified save architecture, while live HISTORY.BIN reads this model.
/// </summary>
public sealed class AdvTextHistory
{
private sealed class LayoutState
{
public int Width;
public int Height;
public int OriginX;
public int OriginY;
public int CursorX;
public int CursorY;
}
private readonly List<AdvTextHistoryRecord> _records = new();
private readonly List<AdvTextHistoryEntry> _entries = new();
private readonly Dictionary<int, LayoutState> _layouts = new();
private readonly HashSet<int> _pendingGroupStarts = new();
public IReadOnlyList<AdvTextHistoryRecord> Records => _records;
public IReadOnlyList<AdvTextHistoryEntry> Entries => _entries;
public bool RecordingSuppressed { get; private set; }
public int CurrentLayoutSlot { get; private set; }
public void SetRecordingEnabled(bool enabled) => RecordingSuppressed = !enabled;
public void DefineLayout(int requestedSlot, int width, int height, int originX, int originY)
{
int slot = SelectLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.Width = width;
layout.Height = height;
layout.OriginX = originX;
layout.OriginY = originY;
AppendBoundary(slot);
}
public void ResetLayout(int requestedSlot)
{
int slot = SelectLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.CursorX = 0;
layout.CursorY = 0;
AppendBoundary(slot);
}
public void SetCursor(int requestedSlot, int x, int y)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.CursorX = x;
layout.CursorY = y;
}
public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style)
{
int slot = ResolveLayout(requestedSlot);
AppendRecord(slot, AdvTextHistoryRecordKind.Text, AdvTextHistoryRecordFlags.None,
style, text, 0, 0, sourceOffset);
}
public void AppendMetadata(long value, long metadataType, AdvTextStyle style)
=> AppendRecord(CurrentLayoutSlot, AdvTextHistoryRecordKind.Metadata,
AdvTextHistoryRecordFlags.TypedMetadata, style, "", value, metadataType, -1);
public void AppendVoice(long voiceId, long voiceArgument, AdvTextStyle style)
=> AppendRecord(CurrentLayoutSlot, AdvTextHistoryRecordKind.Voice,
AdvTextHistoryRecordFlags.VoicePair, style, "", voiceId, voiceArgument, -1);
/// <summary>Clear the retained records and logical index while keeping reusable layout definitions.</summary>
public void Clear()
{
_records.Clear();
_entries.Clear();
_pendingGroupStarts.Clear();
}
private int SelectLayout(int requestedSlot)
{
int slot = requestedSlot == 0 ? CurrentLayoutSlot : requestedSlot;
CurrentLayoutSlot = slot;
return slot;
}
private int ResolveLayout(int requestedSlot) => requestedSlot == 0 ? CurrentLayoutSlot : requestedSlot;
private LayoutState GetOrCreateLayout(int slot)
{
if (!_layouts.TryGetValue(slot, out var layout))
{
layout = new LayoutState();
_layouts.Add(slot, layout);
}
return layout;
}
private void AppendBoundary(int slot)
{
if (RecordingSuppressed) return;
_entries.Add(new AdvTextHistoryEntry(slot, _records.Count));
_pendingGroupStarts.Add(slot);
}
private void AppendRecord(int slot, AdvTextHistoryRecordKind kind, AdvTextHistoryRecordFlags flags,
AdvTextStyle style, string text, long value, long auxValue, int sourceOffset)
{
if (RecordingSuppressed) return;
if (_pendingGroupStarts.Remove(slot)) flags |= AdvTextHistoryRecordFlags.GroupStart;
var layout = GetOrCreateLayout(slot);
var snapshot = new AdvTextLayoutSnapshot(slot, layout.Width, layout.Height,
layout.OriginX, layout.OriginY, layout.CursorX, layout.CursorY);
_records.Add(new AdvTextHistoryRecord(kind, flags, snapshot, style, text, value, auxValue, sourceOffset));
}
}

View File

@@ -20,6 +20,8 @@ public sealed class GameSession
{
public Dictionary<int, long> Globals { get; } = new();
public Dictionary<int, string> GlobalStrings { get; } = new();
/// <summary>The live retained ADV backlog shared by every VM run in this session.</summary>
public AdvTextHistory TextHistory { get; } = new();
public void Seed(int addr, long value) => Globals[addr] = value;
public void SeedString(int addr, string value) => GlobalStrings[addr] = value;
@@ -29,7 +31,7 @@ public sealed class GameSession
VmOptions? options = null, IScriptProvider? provider = null,
ITraceSink? sink = null)
{
var vm = new VirtualMachine(script, table, host, options, provider, sink);
var vm = new VirtualMachine(script, table, host, options, provider, sink, TextHistory);
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
@@ -42,8 +44,9 @@ public sealed class GameSession
return new SceneResult(vm.Emitted.ToList(), vm.HaltReason, vm.Steps);
}
/// <summary>Serialize the persistent state to JSON (globals + string globals, keyed by decimal address).
/// Lets an expensive booted state be snapshotted and reused; foundation for save-file work.</summary>
/// <summary>Serialize the persistent global banks to JSON, keyed by decimal address.
/// Lets an expensive booted state be snapshotted and reused. Live <see cref="TextHistory"/> is
/// intentionally excluded until the unified save/profile architecture defines its lifecycle.</summary>
public string ToJson()
{
var snap = new StateSnapshot(

View File

@@ -34,6 +34,7 @@ public sealed class VirtualMachine
private long _autoMessageTime1Ms = 2000;
private bool _autoVoicePending;
private volatile bool _messageSkipEnabled;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
public long CallScriptDispatches { get; private set; }
public Dictionary<int, long> Globals { get; } = new();
@@ -46,11 +47,13 @@ public sealed class VirtualMachine
public long Steps { get; private set; }
public bool AutoMessageEnabled => _autoMessageEnabled;
public bool MessageSkipEnabled => _messageSkipEnabled;
public AdvTextHistory TextHistory { get; }
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
IScriptProvider? provider = null, ITraceSink? sink = 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; }
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); }
/// <summary>Update the native 800x600 cursor coordinate without advancing the current ADV page.</summary>
public void UpdatePointer(int x, int y)
@@ -436,10 +439,20 @@ public sealed class VirtualMachine
if (c > _o.EmitCap) { HaltReason = $"LOOP:line@0x{off:x}×{c}"; return HALT; }
string text = _cur.Script.GetString(off);
Emitted.Add((off, text, _cur.Script.Name));
int layoutSlot = a.Count > 0 ? (int)Read(a[0]) : 0;
TextHistory.AppendText(layoutSlot, off, text, _advTextStyle);
_host.ShowText(off, text);
}
return pc + 1;
case "define-adv-text-layout": // 0x70: configure layout and begin a logical retained group
TextHistory.DefineLayout((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]),
(int)Read(a[3]), (int)Read(a[4]));
return pc + 1;
case "reset-adv-text-layout": // 0x71: reset layout and begin the next logical retained group
TextHistory.ResetLayout((int)Read(a[0]));
return pc + 1;
case "set-adv-text-cursor": // 0x7a (layout slot, x, y); slot 0 means current natively
TextHistory.SetCursor((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]));
_host.SetAdvTextCursor((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1;
case "configure-adv-wait-indicator": // 0x73: per-layout animated input-wait marker
_host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig(
@@ -601,6 +614,32 @@ public sealed class VirtualMachine
case "block-mark":
case "reset-message-voice-state": // 0x1bc resets native per-message voice/queued-voice state
_autoVoicePending = false; return pc + 1;
case "set-text-history-recording": // 0x1bb: HISTORY.BIN suppresses recording its own UI
if (Read(a[0]) is 0 or 1) TextHistory.SetRecordingEnabled(Read(a[0]) == 1);
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 "clear-text-history": // 0x85: bound the backlog to the current ordinary ADV block
TextHistory.Clear(); return pc + 1;
case "set-font-size":
_advTextStyle = _advTextStyle with { PrimaryFontSize = (int)Read(a[0]) }; return pc + 1;
case "set-ruby-font-size":
_advTextStyle = _advTextStyle with { RubyFontSize = (int)Read(a[0]) }; return pc + 1;
case "set-font-bold":
_advTextStyle = _advTextStyle with { Bold = Read(a[0]) != 0 }; return pc + 1;
case "set-text-color":
_advTextStyle = _advTextStyle with { TextColor = Read(a[0]) }; return pc + 1;
case "set-text-effect-color":
_advTextStyle = _advTextStyle with { EffectColor = Read(a[0]) }; return pc + 1;
case "set-text-render-mode":
_advTextStyle = _advTextStyle with { RenderMode = (int)Read(a[0]) }; return pc + 1;
case "set-text-effect-offset":
_advTextStyle = _advTextStyle with
{
EffectOffsetX = (int)Read(a[0]),
EffectOffsetY = (int)Read(a[1])
};
return pc + 1;
case "u00415BF0":
case "reset-message-skip-input": // 0x101 clears transient input/run bits, not op 0x88 state
return pc + 1;
@@ -632,6 +671,7 @@ public sealed class VirtualMachine
case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1;
case "play-voice":
_autoVoicePending = true;
TextHistory.AppendVoice(Read(a[0]), 0, _advTextStyle);
_host.PlayVoice(Read(a[0])); return pc + 1;
case "play-sound-effect": // 0xb4 / semantics: sfx-load
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;