Implement visible ADV History presentation

This commit is contained in:
gamer147
2026-07-18 23:59:26 -04:00
parent ac5437c5cf
commit 32a67b88de
11 changed files with 420 additions and 20 deletions

View File

@@ -0,0 +1,102 @@
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class HistoryPresentationOpsTests
{
private const int T_IMM = 0, T_GINT = 3;
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);
[Fact]
public void RenderHistoryUsesTheSelectedTargetLayoutAndOneRetainedGroup()
{
var history = new AdvTextHistory();
history.DefineLayout(1, 640, 160, 80, 430);
history.AppendMetadata(9, 2, AdvTextStyle.Default);
history.AppendText(0, 10, "retained dialogue", new AdvTextStyle(24, 8, false,
0xffffff, 0x606060, 3, 1, 1));
history.ResetLayout(1);
history.AppendText(0, 11, "next group", AdvTextStyle.Default);
history.SetRecordingEnabled(false);
history.DefineLayout(4, 600, 150, 0, 0);
history.SetRecordingEnabled(true);
var script = ScriptAssembler.Assemble(Table, "HISTORY_RENDER",
new List<(int, Operand[])>
{
(0x198, new[] { I(4), I(65), I(150) }),
(0x7a, new[] { I(4), I(45), I(42) }),
(0x1d1, new[] { I(4), I(0), I(0), I(0), I(0) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(script, Table, host, textHistory: history);
vm.Run();
var render = Assert.Single(host.HistoryRenders);
Assert.Equal((4, 0, "retained dialogue"),
(render.LayoutSlot, render.FirstRecordIndex, render.Text));
Assert.Equal(new AdvTextLayoutSnapshot(4, 600, 150, 65, 150, 45, 42), render.Layout);
Assert.Equal((24, 0xffffffL, 0x606060L),
(render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor));
}
[Fact]
public void HistorySupportingPresentationOpsReachTheHostWithNativeShaping()
{
var script = ScriptAssembler.Assemble(Table, "HISTORY_PRESENT_SUPPORT",
new List<(int, Operand[])>
{
(0x131, new[] { G(0x100) }),
(0x20b, new[] { I(0xc1), I(0), I(30), I(600), I(30), I(999), I(0x123456) }),
(0x222, new[] { I(0), I(60000) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost { MessageWindowAlphaSetting = 6 };
var vm = new VirtualMachine(script, Table, host);
vm.Run();
Assert.Equal(6, vm.Globals[0x100]);
Assert.Equal(new SurfaceRectFill(0xc1, 0, 30, 600, 30, 255, 0x123456),
Assert.Single(host.SurfaceFills));
Assert.Equal((0L, 60000L), Assert.Single(host.PresentedRanges));
}
[Fact]
public void ResetLayoutClearsItsPreviouslyBoundHostPresentation()
{
var script = ScriptAssembler.Assemble(Table, "HISTORY_CLEAR_RENDER",
new List<(int, Operand[])>
{
(0x71, new[] { I(5) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
new VirtualMachine(script, Table, host).Run();
Assert.Equal(5, Assert.Single(host.ClearedTextLayouts));
}
[Fact]
public void RealHistoryScriptBuildsVisibleRowsFromARealSc0000Page()
{
var scripts = Sys4ScriptProvider.Load(Table);
var session = new GameSession();
session.RunScene(scripts.RequireByName("SC0000.BIN"), Table, new RecordingHost(),
new VmOptions(MaxSteps: 1_000_000, HaltAtWaitForInput: true), scripts);
var host = new RecordingHost();
var result = session.RunScene(scripts.RequireByName("HISTORY.BIN"), Table, host,
new VmOptions(MaxSteps: 2_000_000, HaltAtWaitForInput: true), scripts);
Assert.NotEqual("step-cap", result.Halt);
Assert.Contains(host.HistoryRenders, render => render.Text.Length > 0);
Assert.Contains(host.PresentedRanges, range => range == (0L, 60000L));
}
}

View File

@@ -17,6 +17,10 @@ internal class RecordingHost : IHost
public readonly List<(int Offset, string Text)> Lines = new();
public readonly List<(int Slot, int X, int Y)> TextCursors = new();
public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new();
public readonly List<AdvTextHistoryRenderBatch> HistoryRenders = new();
public readonly List<int> ClearedTextLayouts = new();
public readonly List<SurfaceRectFill> SurfaceFills = new();
public readonly List<(long First, long Count)> PresentedRanges = new();
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
public readonly List<long> SleptDurations = new();
public readonly List<(long Resource, int Channel)> SfxLoads = new();
@@ -32,6 +36,14 @@ internal class RecordingHost : IHost
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)
=> SurfaceStrings.Add((surfaceSlot, x, y, text));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
=> SurfaceStrings.Add((surfaceSlot, x, y, text));
public void ClearRenderedAdvTextLayout(int layoutSlot) => ClearedTextLayouts.Add(layoutSlot);
public void RenderTextHistory(AdvTextHistoryRenderBatch batch) => HistoryRenders.Add(batch);
public int MessageWindowAlphaSetting { get; set; }
public void FillSurfaceRect(SurfaceRectFill fill) => SurfaceFills.Add(fill);
public void PresentObjectRange(GfxState gfx, long firstHandle, long count)
=> PresentedRanges.Add((firstHandle, count));
public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config);
public void SetAdvPagePresentationSuspended(bool suspended)
=> AdvPagePresentationSuspended.Add(suspended);

View File

@@ -10,6 +10,9 @@ public readonly record struct AdvWaitIndicatorConfig(
public readonly record struct AdvAutoWaitState(
bool Enabled, bool VoicePending, long PostVoiceDelayMs, long UnvoicedDelayMs);
public readonly record struct SurfaceRectFill(
int SurfaceSlot, int X, int Y, int Width, int Height, int Alpha, long Rgb);
public interface IHost
{
void ShowText(int offset, string text);
@@ -17,6 +20,13 @@ public interface IHost
// op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface.
void SetAdvTextCursor(int layoutSlot, int x, int y) { }
void DrawStringToSurface(int surfaceSlot, int x, int y, string text) { }
void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
=> DrawStringToSurface(surfaceSlot, x, y, text);
void ClearRenderedAdvTextLayout(int layoutSlot) { }
void RenderTextHistory(AdvTextHistoryRenderBatch batch) { }
int MessageWindowAlphaSetting => 0;
void FillSurfaceRect(SurfaceRectFill fill) { }
void PresentObjectRange(GfxState gfx, long firstHandle, long count) { }
void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { }
// Op 0x199 temporarily yields the active ADV page into its registered hide-window coroutine.
// The retained scene continues to render, but the text layout and its wait marker are suspended

View File

@@ -58,6 +58,15 @@ public sealed record AdvTextHistoryRecord(
/// <summary>AGE's logical 8-byte history index entry.</summary>
public readonly record struct AdvTextHistoryEntry(int LayoutSlot, int FirstRecordIndex);
/// <summary>The host-facing result of rendering one retained history group into a target ADV layout.</summary>
public sealed record AdvTextHistoryRenderBatch(
int LayoutSlot,
int FirstRecordIndex,
int Flags,
AdvTextLayoutSnapshot Layout,
string Text,
AdvTextStyle Style);
/// <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.
@@ -115,6 +124,14 @@ public sealed class AdvTextHistory
layout.CursorY = y;
}
public void SetLayoutOrigin(int requestedSlot, int x, int y)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.OriginX = x;
layout.OriginY = y;
}
public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style,
AdvTextHistoryRecordFlags flags = AdvTextHistoryRecordFlags.None)
{
@@ -214,6 +231,42 @@ public sealed class AdvTextHistory
return found;
}
/// <summary>
/// Build the ordinary bound-text result of native op 0x1d1. The game-facing HISTORY path passes flags
/// and colors as zero: metadata and voice records are skipped, while adjacent text records in the same
/// group are emitted continuously into the selected target layout.
/// </summary>
public bool TryBuildRenderBatch(int requestedLayoutSlot, int firstRecordIndex, int flags,
long overrideTextColor, long overrideEffectColor,
out AdvTextHistoryRenderBatch batch)
{
batch = null!;
if ((uint)firstRecordIndex >= (uint)_records.Count) return false;
int slot = ResolveLayout(requestedLayoutSlot);
var target = SnapshotLayout(slot);
var text = new System.Text.StringBuilder();
AdvTextStyle style = AdvTextStyle.Default;
bool haveStyle = false;
foreach (var record in EnumerateGroup(firstRecordIndex))
{
if (record.Flags.HasFlag(AdvTextHistoryRecordFlags.NavigationFiltered) && (flags & 1) == 0)
break;
if (record.Kind != AdvTextHistoryRecordKind.Text) continue;
if (!haveStyle)
{
style = record.Style;
haveStyle = true;
}
text.Append(record.Text);
}
if ((flags & 2) != 0)
style = style with { TextColor = overrideTextColor, EffectColor = overrideEffectColor };
batch = new AdvTextHistoryRenderBatch(slot, firstRecordIndex, flags, target, text.ToString(), style);
return true;
}
private int SelectLayout(int requestedSlot)
{
int slot = requestedSlot == 0 ? CurrentLayoutSlot : requestedSlot;
@@ -233,6 +286,13 @@ public sealed class AdvTextHistory
return layout;
}
private AdvTextLayoutSnapshot SnapshotLayout(int slot)
{
var layout = GetOrCreateLayout(slot);
return new AdvTextLayoutSnapshot(slot, layout.Width, layout.Height,
layout.OriginX, layout.OriginY, layout.CursorX, layout.CursorY);
}
private void AppendBoundary(int slot)
{
if (RecordingSuppressed) return;

View File

@@ -499,6 +499,7 @@ public sealed class VirtualMachine
return pc + 1;
case "reset-adv-text-layout": // 0x71: reset layout and begin the next logical retained group
TextHistory.ResetLayout((int)Read(a[0]));
_host.ClearRenderedAdvTextLayout((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]));
@@ -510,7 +511,8 @@ public sealed class VirtualMachine
(int)Read(a[8]), Read(a[9])));
return pc + 1;
case "draw-string": // 0x204 (surface slot, x, y, string)
_host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]));
_host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]),
_advTextStyle);
return pc + 1;
case "wait-for-input":
// Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions).
@@ -680,6 +682,15 @@ public sealed class VirtualMachine
Write(a[1], -1);
}
return pc + 1;
case "render-text-history": // 0x1d1: rasterize/bind one retained group to a target layout
case "u0041BAE0":
{
int flags = (int)Read(a[2]);
if ((flags & 4) == 0 && TextHistory.TryBuildRenderBatch(
(int)Read(a[0]), (int)Read(a[1]), flags, Read(a[3]), Read(a[4]), out var batch))
_host.RenderTextHistory(batch);
return pc + 1;
}
case "u0041BB90":
case "find-text-history-value": // 0x1d3: operand 3 is accepted but ignored natively
{
@@ -715,6 +726,13 @@ public sealed class VirtualMachine
EffectOffsetY = (int)Read(a[1])
};
return pc + 1;
case "set-adv-text-layout-origin": // 0x198; slot 0 selects the current layout
case "u0041B540":
TextHistory.SetLayoutOrigin((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]));
return pc + 1;
case "get-message-window-alpha": // 0x131: host profile/config seam; default host currently supplies 0
case "u00415F70":
Write(a[0], _host.MessageWindowAlphaSetting); return pc + 1;
case "u00415BF0":
case "reset-message-skip-input": // 0x101 clears transient input/run bits, not op 0x88 state
return pc + 1;
@@ -743,6 +761,12 @@ public sealed class VirtualMachine
Write(a[1], gw); Write(a[2], gh);
return pc + 1;
}
case "fill-surface-rect": // 0x20b: clipped alpha/RGB fill of a mutable surface
case "u00420D50":
_host.FillSurfaceRect(new SurfaceRectFill(
(int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]),
(int)System.Math.Min(Read(a[5]), 255), Read(a[6]) & 0x00ff_ffff));
return pc + 1;
case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1;
case "play-voice":
_autoVoicePending = true;
@@ -879,6 +903,9 @@ public sealed class VirtualMachine
_host.WaitForForegroundTransition(Gfx); return pc + 1;
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
return pc + 1;
case "present-gfx-object-range": // 0x222: publish pending retained changes in the selected range
case "u004216C0":
_host.PresentObjectRange(Gfx, Read(a[0]), Read(a[1])); return pc + 1;
default:
// Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g.
// 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods.