Files
OpenMaidEngine/engine/Age.Engine/Model/AdvTextHistory.cs
2026-07-30 19:04:22 -04:00

467 lines
17 KiB
C#

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,
int LineSpacing,
string FontFace)
{
public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0, 6, "");
}
/// <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,
int Right,
int Bottom);
/// <summary>
/// One live show-text run with the layout and raster state captured when the opcode executes.
/// Unlike <see cref="AdvTextHistoryRecord"/>, this presentation record exists even while History
/// backlog recording is suppressed.
/// </summary>
public sealed record AdvLiveTextRun(
int SourceOffset,
AdvTextLayoutSnapshot Layout,
AdvTextStyle Style,
string Text,
IReadOnlyList<string> ScriptStack)
{
public bool BelongsToScript(string scriptName)
{
foreach (string frame in ScriptStack)
if (string.Equals(frame, scriptName, StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
}
/// <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>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. Native numbered saves serialize this same model; live HISTORY.BIN
/// reads it directly, so restored history is immediately available to the script UI.
/// </summary>
public sealed class AdvTextHistory
{
private sealed class LayoutState
{
public int Width;
public int Height;
public int OriginX;
public int OriginY;
public int ResetCursorX;
public int ResetCursorY;
public int CursorX;
public int CursorY;
public int Right;
public int Bottom;
public long WaitIndicatorObjectHandle = -1;
public long TextObjectRangeFirst = -1;
public long TextObjectRangeCount;
}
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();
private int _navigationAnchorIndex = -1;
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;
layout.Right = width;
layout.Bottom = height;
AppendBoundary(slot);
}
public void ResetLayout(int requestedSlot)
{
int slot = SelectLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.CursorX = layout.ResetCursorX;
layout.CursorY = layout.ResetCursorY;
AppendBoundary(slot);
}
public void SetResetCursor(int requestedSlot, int x, int y)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.ResetCursorX = x;
layout.ResetCursorY = y;
}
public void SetBounds(int requestedSlot, int right, int bottom)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.Right = right;
layout.Bottom = bottom;
}
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 SetLayoutOrigin(int requestedSlot, int x, int y)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.OriginX = x;
layout.OriginY = y;
}
public void SetWaitIndicatorObjectHandle(int requestedSlot, long handle)
{
int slot = ResolveLayout(requestedSlot);
GetOrCreateLayout(slot).WaitIndicatorObjectHandle = handle;
}
public void SetTextObjectRange(int requestedSlot, long firstHandle, long count)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
layout.TextObjectRangeFirst = firstHandle;
layout.TextObjectRangeCount = count;
}
public AdvTextLayoutPresentationBinding GetPresentationBinding(int requestedSlot)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
return new AdvTextLayoutPresentationBinding(
slot, checked(slot + 0x14), layout.TextObjectRangeFirst,
layout.TextObjectRangeCount, layout.WaitIndicatorObjectHandle,
layout.ResetCursorX, layout.ResetCursorY);
}
/// <summary>
/// Resolve layouts whose complete native retained-glyph handle interval is covered by one op-0x1f7
/// erase. The Label backend collapses those glyph objects into live text runs, so full native range
/// teardown removes the corresponding detached presentation atomically.
/// </summary>
public IReadOnlyList<int> LayoutsCoveredByTextObjectErase(long firstHandle, long count)
{
long eraseCount = count > 1 ? count : 1;
var slots = new List<int>();
foreach (var pair in _layouts)
{
var layout = pair.Value;
if (RangeCovers(firstHandle, eraseCount,
layout.TextObjectRangeFirst, layout.TextObjectRangeCount))
slots.Add(pair.Key);
}
return slots;
}
private static bool RangeCovers(long first, long count, long targetFirst, long targetCount)
{
if (count <= 0 || targetCount <= 0 || targetFirst < first) return false;
ulong offset = (ulong)(targetFirst - first);
ulong extent = (ulong)count;
return offset <= extent && (ulong)targetCount <= extent - offset;
}
/// <summary>
/// Advance to the next horizontal text line. Native resets x to the layout's configured reset cursor
/// and adds the primary font's pixel height plus the current extra line spacing to y.
/// </summary>
public void EndLine(int requestedSlot, AdvTextStyle style)
{
int slot = ResolveLayout(requestedSlot);
var layout = GetOrCreateLayout(slot);
int fontHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
layout.CursorX = layout.ResetCursorX;
layout.CursorY += fontHeight + style.LineSpacing;
}
public AdvTextLayoutSnapshot GetLayoutSnapshot(int requestedSlot)
=> SnapshotLayout(ResolveLayout(requestedSlot));
public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style,
AdvTextHistoryRecordFlags flags = AdvTextHistoryRecordFlags.None)
{
int slot = ResolveLayout(requestedSlot);
AppendRecord(slot, AdvTextHistoryRecordKind.Text, flags,
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();
_navigationAnchorIndex = -1;
}
/// <summary>Replace retained history from AGE's numbered-save history tail.</summary>
public void RestorePersistenceSnapshot(
IReadOnlyList<AdvTextHistoryEntry> entries,
IReadOnlyList<AdvTextHistoryRecord> records)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(records);
_records.Clear();
_records.AddRange(records);
_entries.Clear();
_entries.AddRange(entries);
_pendingGroupStarts.Clear();
// Native text_history_deserialize clears and reconstructs only the history entry/record vectors.
// Live layout definitions, selected layout, surfaces, and retained-object bindings remain owned by
// the initialized text manager and are rebuilt by the script restore path as needed.
_navigationAnchorIndex = entries.Count - 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;
}
/// <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;
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 AdvTextLayoutSnapshot SnapshotLayout(int slot)
{
var layout = GetOrCreateLayout(slot);
return new AdvTextLayoutSnapshot(slot, layout.Width, layout.Height,
layout.OriginX, layout.OriginY, layout.CursorX, layout.CursorY,
layout.Right, layout.Bottom);
}
private void AppendBoundary(int slot)
{
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,
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,
layout.Right, layout.Bottom);
_records.Add(new AdvTextHistoryRecord(kind, flags, snapshot, style, text, value, auxValue, sourceOffset));
}
}