Implement native layout-3 save restoration

This commit is contained in:
gamer147
2026-07-24 16:23:01 -04:00
parent 3b63c42826
commit 3ace5371e6
21 changed files with 1659 additions and 81 deletions

View File

@@ -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, RootReload }
public enum FrameCause { TopScene, CallScript, RootReload, SaveRestore }
/// <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

View File

@@ -8,6 +8,9 @@ public interface IScriptProvider
/// <summary>The script for this id, or null if the id maps to no known script.</summary>
Script? GetById(long id);
/// <summary>Resolve a fixed engine callback filename such as CALLBACK_LOAD.BIN.</summary>
Script? GetByName(string name) => null;
/// <summary>Selectors of currently mounted append catalogs. Opcode 0x143 scans these in
/// ascending order and executes record zero from each catalog.</summary>
IReadOnlyList<int> MountedAppendSelectors => Array.Empty<int>();

View File

@@ -71,8 +71,8 @@ public sealed record AdvTextHistoryRenderBatch(
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.
/// 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
{
@@ -185,6 +185,35 @@ public sealed class AdvTextHistory
_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();
_layouts.Clear();
foreach (AdvTextHistoryRecord record in records)
{
var layout = GetOrCreateLayout(record.Layout.Slot);
layout.Width = record.Layout.Width;
layout.Height = record.Layout.Height;
layout.OriginX = record.Layout.OriginX;
layout.OriginY = record.Layout.OriginY;
layout.CursorX = record.Layout.CursorX;
layout.CursorY = record.Layout.CursorY;
layout.Right = record.Layout.Right;
layout.Bottom = record.Layout.Bottom;
}
CurrentLayoutSlot = entries.Count > 0 ? entries[^1].LayoutSlot : 0;
_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.

View File

@@ -56,6 +56,13 @@ public sealed record GfxDiagnosticSnapshot(
BlockingGfxObjectDiagnostic? BlockingRangeTransform,
IReadOnlyList<BlockingGfxObjectDiagnostic> BlockingObjects);
public sealed record GfxPersistenceSnapshot(
IReadOnlyList<(int Slot, long ResourceId, long ColorKey, bool Created)> Surfaces,
IReadOnlyList<(long Handle, GfxState.GfxObject Object)> Objects,
long RangeFirst,
long RangeCount,
GfxState.GfxObject RangeTransform);
/// <summary>A renderable view of one visible gfx object — the host composites these in ascending-handle order
/// (= the engine's z-order) each frame. Built by <see cref="GfxState.SnapshotVisibleObjects"/>; the surface
/// resId/colorkey are resolved from the object's live source slot at snapshot time (see docs/engine-re.md,
@@ -293,31 +300,7 @@ public sealed class GfxState
{
if (!_objects.TryGetValue(sourceHandle, out var s)) return false;
bool destinationIsNew = !_objects.ContainsKey(destinationHandle);
_objects[destinationHandle] = new GfxObject
{
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
Field64 = s.Field64, Field68 = s.Field68, Field6c = s.Field6c,
Color = s.Color, HasColor = s.HasColor, StaticColorMode = s.StaticColorMode,
OneShotColorTarget = s.OneShotColorTarget, ColorDelayMs = s.ColorDelayMs,
ColorDurationMs = s.ColorDurationMs, OneShotColorEnabled = s.OneShotColorEnabled,
OneShotColorBlend = s.OneShotColorBlend,
SrcFrameCount = s.SrcFrameCount, SrcColumns = s.SrcColumns, SrcCell = s.SrcCell,
SrcPeriod = s.SrcPeriod, SrcStart = s.SrcStart, SrcAnim = s.SrcAnim,
ColorPeriod = s.ColorPeriod, ColorStart = s.ColorStart, ColorTarget = s.ColorTarget,
ColorAnim = s.ColorAnim, SourceSlot = s.SourceSlot, SrcRect = s.SrcRect, Visible = s.Visible,
ScaleCurrent = s.ScaleCurrent, ScaleTarget = s.ScaleTarget,
ScaleDelayMs = s.ScaleDelayMs, ScaleDurationMs = s.ScaleDurationMs, ScaleEnabled = s.ScaleEnabled,
TranslationCurrent = s.TranslationCurrent, TranslationTarget = s.TranslationTarget,
TranslationDelayMs = s.TranslationDelayMs, TranslationDurationMs = s.TranslationDurationMs,
TranslationEnabled = s.TranslationEnabled,
RotationCurrent = s.RotationCurrent, RotationTarget = s.RotationTarget,
RotationDelayMs = s.RotationDelayMs, RotationDurationMs = s.RotationDurationMs,
RotationChannelEnabled = s.RotationChannelEnabled,
OneShotAnimationControlFlags = s.OneShotAnimationControlFlags,
OneShotStartMs = s.OneShotStartMs,
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
};
_objects[destinationHandle] = CloneState(s);
if (destinationIsNew) InsertOrderedHandle(destinationHandle);
CurrentObject = destinationHandle;
MarkRetainedMutation();
@@ -418,6 +401,79 @@ public sealed class GfxState
}
}
public GfxPersistenceSnapshot CapturePersistenceSnapshot()
{
lock (_lock)
{
var surfaces = _surfaces
.Select(pair => (
pair.Key, pair.Value.ResId, pair.Value.ColorKey,
_createdSurfaces.Contains(pair.Key)))
.OrderBy(item => item.Key)
.ToArray();
var objects = _orderedObjectHandles
.Select(handle => (handle, CloneState(_objects[handle])))
.ToArray();
return new GfxPersistenceSnapshot(
surfaces, objects, _rangeTransformFirst, _rangeTransformCount,
CloneState(_rangeTransform));
}
}
public void RestorePersistenceSnapshot(GfxPersistenceSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
lock (_lock)
{
_surfaces.Clear();
_createdSurfaces.Clear();
foreach (var (slot, resourceId, colorKey, created) in snapshot.Surfaces)
{
_surfaces[slot] = (resourceId, colorKey);
if (created) _createdSurfaces.Add(slot);
}
_objects.Clear();
_orderedObjectHandles.Clear();
foreach (var (handle, state) in snapshot.Objects)
{
_objects[handle] = CloneState(state);
_orderedObjectHandles.Add(handle);
}
_orderedObjectHandles.Sort();
_rangeTransformFirst = snapshot.RangeFirst;
_rangeTransformCount = snapshot.RangeCount;
_rangeTransform = CloneState(snapshot.RangeTransform);
MarkRetainedMutation();
}
}
private static GfxObject CloneState(GfxObject s)
=> new()
{
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
Field64 = s.Field64, Field68 = s.Field68, Field6c = s.Field6c,
Color = s.Color, HasColor = s.HasColor, StaticColorMode = s.StaticColorMode,
OneShotColorTarget = s.OneShotColorTarget, ColorDelayMs = s.ColorDelayMs,
ColorDurationMs = s.ColorDurationMs, OneShotColorEnabled = s.OneShotColorEnabled,
OneShotColorBlend = s.OneShotColorBlend,
SrcFrameCount = s.SrcFrameCount, SrcColumns = s.SrcColumns, SrcCell = s.SrcCell,
SrcPeriod = s.SrcPeriod, SrcStart = s.SrcStart, SrcAnim = s.SrcAnim,
ColorPeriod = s.ColorPeriod, ColorStart = s.ColorStart, ColorTarget = s.ColorTarget,
ColorAnim = s.ColorAnim, SourceSlot = s.SourceSlot, SrcRect = s.SrcRect, Visible = s.Visible,
ScaleCurrent = s.ScaleCurrent, ScaleTarget = s.ScaleTarget,
ScaleDelayMs = s.ScaleDelayMs, ScaleDurationMs = s.ScaleDurationMs, ScaleEnabled = s.ScaleEnabled,
TranslationCurrent = s.TranslationCurrent, TranslationTarget = s.TranslationTarget,
TranslationDelayMs = s.TranslationDelayMs, TranslationDurationMs = s.TranslationDurationMs,
TranslationEnabled = s.TranslationEnabled,
RotationCurrent = s.RotationCurrent, RotationTarget = s.RotationTarget,
RotationDelayMs = s.RotationDelayMs, RotationDurationMs = s.RotationDurationMs,
RotationChannelEnabled = s.RotationChannelEnabled,
OneShotAnimationControlFlags = s.OneShotAnimationControlFlags,
OneShotStartMs = s.OneShotStartMs,
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
};
private readonly object _lock = new();
// ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ----

View File

@@ -12,5 +12,9 @@ public sealed class Script
public IReadOnlyList<uint> BodyDwords { get; init; } = Array.Empty<uint>();
/// <summary>T1 entries: code DWORD offsets of op-0x71 message boundaries.</summary>
public IReadOnlyList<int> ReadMessageOffsets { get; init; } = Array.Empty<int>();
/// <summary>T2 entries: code DWORD offsets of resumable call-script sites.</summary>
public IReadOnlyList<int> ScriptCallOffsets { get; init; } = Array.Empty<int>();
/// <summary>T3 entries: code DWORD offsets used to reconstruct the intra-script call stack.</summary>
public IReadOnlyList<int> LocalCallOffsets { get; init; } = Array.Empty<int>();
public string GetString(int offset) => Strings.TryGetValue(offset, out var s) ? s : "";
}

View File

@@ -43,6 +43,8 @@ public sealed record NativeSaveIdentity(
}
}
public sealed record NativeNumberedSaveFile(NativeSaveDocument Document, byte[] HistoryTail);
public interface INativeDatStore
{
NativeSaveIdentity Identity { get; }
@@ -53,6 +55,15 @@ public interface INativeDatStore
NativeSaveMetadata? QueryNumberedMetadata(int slot);
NativeSaveDocument? LoadNumbered(int slot);
void SaveNumbered(int slot, ReadOnlySpan<byte> payload, NativeSystemTime timestamp, uint accumulatedPlaySeconds);
NativeNumberedSaveFile? LoadNumberedFile(int slot)
{
NativeSaveDocument? document = LoadNumbered(slot);
return document == null ? null : new NativeNumberedSaveFile(document, []);
}
void SaveNumberedFile(
int slot, ReadOnlySpan<byte> payload, ReadOnlySpan<byte> historyTail,
NativeSystemTime timestamp, uint accumulatedPlaySeconds)
=> SaveNumbered(slot, payload, timestamp, accumulatedPlaySeconds);
int DeleteNumberedPair(int slot);
int CopyNumberedPair(int sourceSlot, int destinationSlot);
byte[]? LoadNumberedThumbnail(int slot);
@@ -160,6 +171,17 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
return File.Exists(path) ? LoadAndValidate(path, numbered: true) : null;
}
public NativeNumberedSaveFile? LoadNumberedFile(int slot)
{
string path = Path.Combine(_root, NumberedFileName(slot));
if (!File.Exists(path)) return null;
byte[] source = File.ReadAllBytes(path);
NativeSaveDocument document = NativeSaveContainerCodec.Decode(source);
_identity.Validate(document.Metadata, numbered: true);
return new NativeNumberedSaveFile(
document, source.AsSpan(document.BytesConsumed).ToArray());
}
public NativeSaveMetadata? QueryNumberedMetadata(int slot)
{
string path = Path.Combine(_root, NumberedFileName(slot));
@@ -186,6 +208,22 @@ public sealed class DirectoryNativeDatStore : INativeDatStore
WriteThrough(Path.Combine(_root, NumberedFileName(slot)), encoded);
}
public void SaveNumberedFile(
int slot,
ReadOnlySpan<byte> payload,
ReadOnlySpan<byte> historyTail,
NativeSystemTime timestamp,
uint accumulatedPlaySeconds)
{
byte[] container = NativeSaveContainerCodec.Encode(
payload, _identity.CreateMetadata(timestamp, accumulatedPlaySeconds, numbered: true));
byte[] encoded = new byte[checked(container.Length + historyTail.Length)];
container.CopyTo(encoded, 0);
historyTail.CopyTo(encoded.AsSpan(container.Length));
Directory.CreateDirectory(_root);
WriteThrough(Path.Combine(_root, NumberedFileName(slot)), encoded);
}
public int DeleteNumberedPair(int slot)
{
bool dataDeleted = TryDelete(Path.Combine(_root, NumberedFileName(slot)));

View File

@@ -0,0 +1,200 @@
using System.Buffers.Binary;
using Age.Engine.Model;
namespace Age.Engine.Persistence;
internal static class NativeGfxPersistenceCodec
{
private const int SurfaceCount = 1000;
private const int SurfaceRecordSize = 20;
public static (
byte[] SurfaceRecords,
IReadOnlyList<NativeSavedGfxObject> Objects,
long RangeFirst,
int RangeCount,
byte[] RangeRecord) Capture(GfxState gfx)
{
GfxPersistenceSnapshot snapshot = gfx.CapturePersistenceSnapshot();
byte[] surfaces = new byte[NativeNumberedSaveState.SurfaceRecordsSize];
foreach (var (slot, resourceId, colorKey, created) in snapshot.Surfaces)
{
if ((uint)slot >= SurfaceCount || created || resourceId < 0) continue;
int at = slot * SurfaceRecordSize;
WriteInt(surfaces, at, unchecked((int)resourceId));
WriteInt(surfaces, at + 4, PackNativeColorKey(colorKey));
WriteInt(surfaces, at + 8, 1);
}
NativeSavedGfxObject[] objects = snapshot.Objects
.Select(item => new NativeSavedGfxObject(item.Handle, EncodeObject(item.Object)))
.ToArray();
return (
surfaces, objects, snapshot.RangeFirst, unchecked((int)snapshot.RangeCount),
EncodeObject(snapshot.RangeTransform));
}
public static GfxPersistenceSnapshot Decode(NativeNumberedSaveState state)
{
var surfaces = new List<(int Slot, long ResourceId, long ColorKey, bool Created)>();
for (int slot = 0; slot < SurfaceCount; slot++)
{
int at = slot * SurfaceRecordSize;
int resourceId = ReadInt(state.SurfaceRecords, at);
int present = ReadInt(state.SurfaceRecords, at + 8);
if (present == 1 && resourceId >= 0)
surfaces.Add((slot, unchecked((uint)resourceId),
UnpackNativeColorKey(ReadInt(state.SurfaceRecords, at + 4)), false));
}
var objects = state.GfxObjects
.Select(item => (item.Handle, DecodeObject(item.Record)))
.ToArray();
return new GfxPersistenceSnapshot(
surfaces, objects, state.RangeTransformFirst, state.RangeTransformCount,
DecodeObject(state.RangeTransformRecord));
}
private static byte[] EncodeObject(GfxState.GfxObject value)
{
byte[] raw = new byte[NativeNumberedSaveState.GfxRecordSize];
int flags = value.Visible ? 1 : 0;
if (value.RotationEnabled || value.SrcAnim || value.ColorAnim) flags |= 4;
WriteInt(raw, 0, flags);
WriteInt(raw, 4, value.SourceSlot);
WriteInt(raw, 8, value.SrcRect.X);
WriteInt(raw, 0x0c, value.SrcRect.Y);
WriteInt(raw, 0x10, checked(value.SrcRect.X + value.SrcRect.W));
WriteInt(raw, 0x14, checked(value.SrcRect.Y + value.SrcRect.H));
WriteVector(raw, 0x18, value.V18);
WriteVector(raw, 0x24, value.V24);
WriteInt(raw, 0x30, unchecked((int)value.StaticColorMode));
WriteInt(raw, 0x34, unchecked((int)value.OneShotStartMs));
WriteInt(raw, 0x38, unchecked((int)value.ColorDelayMs));
WriteInt(raw, 0x3c, unchecked((int)value.ScaleDelayMs));
WriteInt(raw, 0x40, unchecked((int)value.RotationDelayMs));
WriteInt(raw, 0x44, unchecked((int)value.TranslationDelayMs));
WriteInt(raw, 0x4c, unchecked((int)value.ColorDurationMs));
WriteInt(raw, 0x50, unchecked((int)value.ScaleDurationMs));
WriteInt(raw, 0x54, unchecked((int)value.RotationDurationMs));
WriteInt(raw, 0x58, unchecked((int)value.TranslationDurationMs));
WriteInt(raw, 0x60, unchecked((int)value.Color));
WriteInt(raw, 0x64, unchecked((int)value.OneShotColorTarget));
WriteScaleMatrix(raw, 0x6c, value.ScaleCurrent);
WriteScaleMatrix(raw, 0xac, value.ScaleTarget);
WriteFloat(raw, 0x16c, value.TranslationCurrent.X);
WriteFloat(raw, 0x170, value.TranslationCurrent.Y);
WriteFloat(raw, 0x174, value.TranslationCurrent.Z);
WriteFloat(raw, 0x1ac, value.TranslationTarget.X);
WriteFloat(raw, 0x1b0, value.TranslationTarget.Y);
WriteFloat(raw, 0x1b4, value.TranslationTarget.Z);
WriteInt(raw, 0x20c, unchecked((int)value.ColorStart));
WriteInt(raw, 0x214, unchecked((int)value.RotationStartMs));
WriteInt(raw, 0x220, unchecked((int)value.ColorPeriod));
WriteInt(raw, 0x228, unchecked((int)value.RotationPeriodMs));
WriteInt(raw, 0x230, unchecked((int)value.SrcPeriod));
WriteInt(raw, 0x234, unchecked((int)value.SrcCell));
WriteInt(raw, 0x238, unchecked((int)value.SrcFrameCount));
WriteInt(raw, 0x23c, unchecked((int)value.SrcColumns));
WriteInt(raw, 0x240, unchecked((int)value.ColorTarget));
WriteVector(raw, 0x244, value.RotationAxis);
WriteInt(raw, 0x2d0, unchecked((int)value.OneShotAnimationControlFlags));
return raw;
}
private static GfxState.GfxObject DecodeObject(ReadOnlySpan<byte> raw)
{
if (raw.Length != NativeNumberedSaveState.GfxRecordSize)
throw new InvalidDataException("Native retained-gfx record has the wrong size.");
int left = ReadInt(raw, 8), top = ReadInt(raw, 0x0c);
int right = ReadInt(raw, 0x10), bottom = ReadInt(raw, 0x14);
int flags = ReadInt(raw, 0);
return new GfxState.GfxObject
{
Visible = (flags & 1) != 0,
SourceSlot = ReadInt(raw, 4),
SrcRect = (left, top, right - left, bottom - top),
V18 = ReadLongVector(raw, 0x18),
V24 = ReadLongVector(raw, 0x24),
StaticColorMode = ReadInt(raw, 0x30),
OneShotStartMs = ReadInt(raw, 0x34),
ColorDelayMs = ReadInt(raw, 0x38),
ScaleDelayMs = ReadInt(raw, 0x3c),
RotationDelayMs = ReadInt(raw, 0x40),
TranslationDelayMs = ReadInt(raw, 0x44),
ColorDurationMs = ReadInt(raw, 0x4c),
ScaleDurationMs = ReadInt(raw, 0x50),
RotationDurationMs = ReadInt(raw, 0x54),
TranslationDurationMs = ReadInt(raw, 0x58),
Color = unchecked((uint)ReadInt(raw, 0x60)),
HasColor = ReadInt(raw, 0x60) != -1,
OneShotColorTarget = unchecked((uint)ReadInt(raw, 0x64)),
ScaleCurrent = ReadScale(raw, 0x6c),
ScaleTarget = ReadScale(raw, 0xac),
TranslationCurrent = ReadDoubleVector(raw, 0x16c),
V16c = ReadLongFloatVector(raw, 0x16c),
TranslationTarget = ReadDoubleVector(raw, 0x1ac),
ColorStart = ReadInt(raw, 0x20c),
RotationStartMs = ReadInt(raw, 0x214),
ColorPeriod = ReadInt(raw, 0x220),
RotationPeriodMs = ReadInt(raw, 0x228),
SrcPeriod = ReadInt(raw, 0x230),
SrcCell = ReadInt(raw, 0x234),
SrcFrameCount = Math.Max(1, ReadInt(raw, 0x238)),
SrcColumns = Math.Max(1, ReadInt(raw, 0x23c)),
ColorTarget = unchecked((uint)ReadInt(raw, 0x240)),
RotationAxis = ReadLongVector(raw, 0x244),
OneShotAnimationControlFlags = unchecked((uint)ReadInt(raw, 0x2d0)),
RotationEnabled = (flags & 4) != 0 && ReadInt(raw, 0x228) > 0,
SrcAnim = (flags & 4) != 0 && ReadInt(raw, 0x238) > 1,
ColorAnim = (flags & 4) != 0 && ReadInt(raw, 0x220) > 0,
OneShotColorEnabled = ReadInt(raw, 0x4c) > 0,
ScaleEnabled = ReadInt(raw, 0x50) > 0,
RotationChannelEnabled = ReadInt(raw, 0x54) > 0,
TranslationEnabled = ReadInt(raw, 0x58) > 0,
};
}
private static int PackNativeColorKey(long colorKey)
=> colorKey < 0 ? 0 : unchecked((int)(0xff000000 | (uint)colorKey & 0xffffff));
private static long UnpackNativeColorKey(int colorKey)
=> colorKey == 0 ? -1 : unchecked((uint)colorKey) & 0xffffff;
private static void WriteScaleMatrix(Span<byte> raw, int offset, (double X, double Y, double Z) scale)
{
WriteFloat(raw, offset, scale.X);
WriteFloat(raw, offset + 0x14, scale.Y);
WriteFloat(raw, offset + 0x28, scale.Z);
WriteFloat(raw, offset + 0x3c, 1);
}
private static (double X, double Y, double Z) ReadScale(ReadOnlySpan<byte> raw, int offset)
=> (ReadFloat(raw, offset), ReadFloat(raw, offset + 0x14), ReadFloat(raw, offset + 0x28));
private static void WriteVector(Span<byte> raw, int offset, (long X, long Y, long Z) vector)
{
WriteInt(raw, offset, unchecked((int)vector.X));
WriteInt(raw, offset + 4, unchecked((int)vector.Y));
WriteInt(raw, offset + 8, unchecked((int)vector.Z));
}
private static (long X, long Y, long Z) ReadLongVector(ReadOnlySpan<byte> raw, int offset)
=> (ReadInt(raw, offset), ReadInt(raw, offset + 4), ReadInt(raw, offset + 8));
private static (long X, long Y, long Z) ReadLongFloatVector(ReadOnlySpan<byte> raw, int offset)
=> ((long)ReadFloat(raw, offset), (long)ReadFloat(raw, offset + 4), (long)ReadFloat(raw, offset + 8));
private static (double X, double Y, double Z) ReadDoubleVector(ReadOnlySpan<byte> raw, int offset)
=> (ReadFloat(raw, offset), ReadFloat(raw, offset + 4), ReadFloat(raw, offset + 8));
private static void WriteFloat(Span<byte> raw, int offset, double value)
=> WriteInt(raw, offset, BitConverter.SingleToInt32Bits((float)value));
private static float ReadFloat(ReadOnlySpan<byte> raw, int offset)
=> BitConverter.Int32BitsToSingle(ReadInt(raw, offset));
private static int ReadInt(ReadOnlySpan<byte> raw, int offset)
=> BinaryPrimitives.ReadInt32LittleEndian(raw.Slice(offset, 4));
private static void WriteInt(Span<byte> raw, int offset, int value)
=> BinaryPrimitives.WriteInt32LittleEndian(raw.Slice(offset, 4), value);
}

View File

@@ -0,0 +1,294 @@
using System.Buffers.Binary;
using System.Text;
namespace Age.Engine.Persistence;
public sealed record NativeSavedScriptFrame(
int ParentContext,
uint ScriptId,
IReadOnlyList<int> ReturnIndices,
int ResumeIndex,
int CallTargetIndex);
public sealed record NativeSavedGfxObject(long Handle, byte[] Record);
public sealed record NativeNumberedSaveState(
int SavedFrameOwner,
int EngineState,
IReadOnlyList<int> StateWords,
byte[] ResourceRecords,
byte[] SurfaceRecords,
IReadOnlyList<NativeSavedScriptFrame> Frames,
IReadOnlyList<int> IntegerGlobals,
IReadOnlyList<int> FloatGlobals,
IReadOnlyList<string> StringGlobals,
IReadOnlyList<int> PointerGlobals,
IReadOnlyList<int> PointerStrings,
IReadOnlyList<int> LocalPointerScratch,
IReadOnlyList<NativeSavedGfxObject> GfxObjects,
long RangeTransformFirst,
int RangeTransformCount,
byte[] RangeTransformRecord)
{
public const int StateWordCount = 10;
public const int ResourceRecordsSize = 300 * 4;
public const int SurfaceRecordsSize = 20_000;
public const int GfxRecordSize = 0x2d4;
}
/// <summary>AGE SaveVersion1=3 numbered-save logical payload.</summary>
public static class NativeNumberedSaveCodec
{
private const int FixedPrefixSize = 0x5304;
private const int FrameSize = 0x414;
private const int FixedSuffixSize = 0x414;
private const int FrameReturnCapacity = 256;
private const int GfxAllocationDwords = 0x2d8;
private const int GfxConstantDwords = 0x2e1;
private static readonly Encoding NativeEncoding = CreateNativeEncoding();
public static byte[] Encode(NativeNumberedSaveState state)
{
ArgumentNullException.ThrowIfNull(state);
ValidateState(state);
int cutoff = state.Frames.Count - 1;
byte[] strings = EncodeStrings(state.StringGlobals);
int stringDwords = strings.Length / 4;
int bankDwords = checked(
state.IntegerGlobals.Count + state.FloatGlobals.Count + state.StringGlobals.Count
+ state.PointerGlobals.Count + state.PointerStrings.Count + state.LocalPointerScratch.Count);
int totalDwords = checked(
cutoff * 0x105 + 0x53ea + bankDwords + stringDwords
+ GfxConstantDwords + state.GfxObjects.Count * GfxAllocationDwords);
byte[] payload = new byte[checked((totalDwords - 2) * 4)];
WriteInt(payload, 0, cutoff);
WriteInt(payload, 4, state.SavedFrameOwner);
WriteInt(payload, 8, state.EngineState);
WriteIntList(payload, 0x0c, state.StateWords);
state.ResourceRecords.CopyTo(payload, 0x34);
state.SurfaceRecords.CopyTo(payload, 0x4e4);
for (int i = 0; i < state.Frames.Count; i++)
WriteFrame(payload, FixedPrefixSize + i * FrameSize, state.Frames[i], i == cutoff);
int at = checked(0x5718 + cutoff * FrameSize);
int[] counts =
[
state.IntegerGlobals.Count, state.FloatGlobals.Count, state.StringGlobals.Count,
state.PointerGlobals.Count, state.PointerStrings.Count, state.LocalPointerScratch.Count,
];
WriteIntList(payload, at, counts);
at += 24;
WriteIntList(payload, at, state.IntegerGlobals);
at += state.IntegerGlobals.Count * 4;
WriteIntList(payload, at, state.FloatGlobals);
at += state.FloatGlobals.Count * 4;
WriteInt(payload, at, stringDwords);
at += 4;
strings.CopyTo(payload, at);
at += strings.Length;
WriteIntList(payload, at, state.PointerGlobals);
at += state.PointerGlobals.Count * 4;
WriteIntList(payload, at, state.PointerStrings);
at += state.PointerStrings.Count * 4;
WriteIntList(payload, at, state.LocalPointerScratch);
at += state.LocalPointerScratch.Count * 4;
WriteInt(payload, at, NativeNumberedSaveState.GfxRecordSize);
WriteInt(payload, at + 4, state.GfxObjects.Count);
at += 8;
foreach (NativeSavedGfxObject gfx in state.GfxObjects)
{
WriteInt(payload, at, unchecked((int)gfx.Handle));
gfx.Record.CopyTo(payload, at + 4);
at += 4 + NativeNumberedSaveState.GfxRecordSize;
}
WriteInt(payload, at, unchecked((int)state.RangeTransformFirst));
WriteInt(payload, at + 4, state.RangeTransformCount);
state.RangeTransformRecord.CopyTo(payload, at + 8);
return payload;
}
public static NativeNumberedSaveState Decode(ReadOnlySpan<byte> payload)
{
if (payload.Length < 0x5718)
throw new InvalidDataException("Numbered-save layout 3 payload is truncated.");
int cutoff = ReadNonNegative(payload, 0, "saved frame cutoff");
int fixedBytes = checked(0x5718 + cutoff * FrameSize);
Require(payload, 0, fixedBytes, "numbered-save fixed state");
int[] stateWords = ReadInts(payload, 0x0c, NativeNumberedSaveState.StateWordCount);
byte[] resources = payload.Slice(0x34, NativeNumberedSaveState.ResourceRecordsSize).ToArray();
byte[] surfaces = payload.Slice(0x4e4, NativeNumberedSaveState.SurfaceRecordsSize).ToArray();
var frames = new NativeSavedScriptFrame[cutoff + 1];
for (int i = 0; i < frames.Length; i++)
frames[i] = ReadFrame(payload, FixedPrefixSize + i * FrameSize);
int at = fixedBytes;
int[] counts = ReadInts(payload, at, 6);
if (counts.Any(count => count < 0))
throw new InvalidDataException("Numbered-save layout 3 contains a negative bank count.");
at += 24;
int[] integers = ReadInts(payload, at, counts[0]);
at = checked(at + counts[0] * 4);
int[] floats = ReadInts(payload, at, counts[1]);
at = checked(at + counts[1] * 4);
int stringDwords = ReadNonNegative(payload, at, "string blob length");
at += 4;
int stringBytes = checked(stringDwords * 4);
Require(payload, at, stringBytes, "numbered-save string blob");
string[] strings = DecodeStrings(payload.Slice(at, stringBytes), counts[2]);
at += stringBytes;
int[] pointers = ReadInts(payload, at, counts[3]);
at = checked(at + counts[3] * 4);
int[] pointerStrings = ReadInts(payload, at, counts[4]);
at = checked(at + counts[4] * 4);
int[] localPointerScratch = ReadInts(payload, at, counts[5]);
at = checked(at + counts[5] * 4);
int gfxRecordSize = ReadNonNegative(payload, at, "gfx record size");
int gfxCount = ReadNonNegative(payload, at + 4, "gfx object count");
if (gfxRecordSize != NativeNumberedSaveState.GfxRecordSize)
throw new InvalidDataException($"Unsupported native gfx record size 0x{gfxRecordSize:x}.");
at += 8;
var objects = new NativeSavedGfxObject[gfxCount];
for (int i = 0; i < objects.Length; i++)
{
Require(payload, at, 4 + gfxRecordSize, "numbered-save gfx object");
long handle = ReadInt(payload, at);
objects[i] = new NativeSavedGfxObject(handle, payload.Slice(at + 4, gfxRecordSize).ToArray());
at += 4 + gfxRecordSize;
}
Require(payload, at, 8 + gfxRecordSize, "numbered-save range transform");
long rangeFirst = ReadInt(payload, at);
int rangeCount = ReadInt(payload, at + 4);
byte[] rangeRecord = payload.Slice(at + 8, gfxRecordSize).ToArray();
return new NativeNumberedSaveState(
ReadInt(payload, 4), ReadInt(payload, 8), stateWords, resources, surfaces, frames,
integers, floats, strings, pointers, pointerStrings, localPointerScratch, objects,
rangeFirst, rangeCount, rangeRecord);
}
public static NativeNumberedSaveState Empty(IReadOnlyList<NativeSavedScriptFrame> frames)
=> new(
0, 0, new int[NativeNumberedSaveState.StateWordCount],
new byte[NativeNumberedSaveState.ResourceRecordsSize],
new byte[NativeNumberedSaveState.SurfaceRecordsSize],
frames, Array.Empty<int>(), Array.Empty<int>(), Array.Empty<string>(),
Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>(),
Array.Empty<NativeSavedGfxObject>(), 0, 0,
new byte[NativeNumberedSaveState.GfxRecordSize]);
private static void WriteFrame(Span<byte> payload, int offset, NativeSavedScriptFrame frame, bool terminal)
{
if (frame.ReturnIndices.Count > FrameReturnCapacity)
throw new InvalidDataException("A native saved frame cannot hold more than 256 return entries.");
WriteInt(payload, offset, frame.ParentContext);
WriteInt(payload, offset + 4, unchecked((int)frame.ScriptId));
WriteInt(payload, offset + 8, frame.ReturnIndices.Count);
for (int i = 0; i < frame.ReturnIndices.Count; i++)
WriteInt(payload, offset + 12 + i * 4, frame.ReturnIndices[i]);
WriteInt(payload, offset + 0x40c, frame.ResumeIndex);
WriteInt(payload, offset + 0x410, terminal ? -1 : frame.CallTargetIndex);
}
private static NativeSavedScriptFrame ReadFrame(ReadOnlySpan<byte> payload, int offset)
{
int count = ReadNonNegative(payload, offset + 8, "frame return count");
if (count > FrameReturnCapacity)
throw new InvalidDataException("Numbered-save frame return count exceeds 256.");
return new NativeSavedScriptFrame(
ReadInt(payload, offset), unchecked((uint)ReadInt(payload, offset + 4)),
ReadInts(payload, offset + 12, count),
ReadInt(payload, offset + 0x40c), ReadInt(payload, offset + 0x410));
}
private static byte[] EncodeStrings(IReadOnlyList<string> strings)
{
using var stream = new MemoryStream();
foreach (string text in strings)
{
byte[] encoded = NativeEncoding.GetBytes(text ?? "");
stream.Write(encoded);
stream.WriteByte(0);
}
while ((stream.Length & 3) != 0) stream.WriteByte(0);
return stream.ToArray();
}
private static string[] DecodeStrings(ReadOnlySpan<byte> blob, int count)
{
var result = new string[count];
int at = 0;
for (int i = 0; i < count; i++)
{
if (at >= blob.Length)
throw new InvalidDataException("Numbered-save string blob ended before its declared string count.");
int end = blob[at..].IndexOf((byte)0);
if (end < 0)
throw new InvalidDataException("Numbered-save string blob ended before its declared string count.");
result[i] = NativeEncoding.GetString(blob.Slice(at, end));
at += end + 1;
}
return result;
}
private static void ValidateState(NativeNumberedSaveState state)
{
if (state.Frames.Count == 0) throw new InvalidDataException("A numbered save requires at least one frame.");
if (state.StateWords.Count != NativeNumberedSaveState.StateWordCount)
throw new InvalidDataException("Numbered-save state word count must be 10.");
if (state.ResourceRecords.Length != NativeNumberedSaveState.ResourceRecordsSize)
throw new InvalidDataException("Numbered-save resource table must be 1,200 bytes.");
if (state.SurfaceRecords.Length != NativeNumberedSaveState.SurfaceRecordsSize)
throw new InvalidDataException("Numbered-save surface table must be 20,000 bytes.");
if (state.RangeTransformRecord.Length != NativeNumberedSaveState.GfxRecordSize
|| state.GfxObjects.Any(item => item.Record.Length != NativeNumberedSaveState.GfxRecordSize))
throw new InvalidDataException("Numbered-save gfx records must be 0x2d4 bytes.");
}
private static int[] ReadInts(ReadOnlySpan<byte> source, int offset, int count)
{
int bytes = checked(count * 4);
Require(source, offset, bytes, "numbered-save integer array");
var values = new int[count];
for (int i = 0; i < count; i++) values[i] = ReadInt(source, offset + i * 4);
return values;
}
private static void WriteIntList(Span<byte> destination, int offset, IReadOnlyList<int> values)
{
for (int i = 0; i < values.Count; i++) WriteInt(destination, offset + i * 4, values[i]);
}
private static int ReadNonNegative(ReadOnlySpan<byte> source, int offset, string name)
{
int value = ReadInt(source, offset);
if (value < 0) throw new InvalidDataException($"Numbered-save {name} is negative.");
return value;
}
private static int ReadInt(ReadOnlySpan<byte> source, int offset)
{
Require(source, offset, 4, "numbered-save dword");
return BinaryPrimitives.ReadInt32LittleEndian(source.Slice(offset, 4));
}
private static void WriteInt(Span<byte> destination, int offset, int value)
=> BinaryPrimitives.WriteInt32LittleEndian(destination.Slice(offset, 4), value);
private static void Require(ReadOnlySpan<byte> source, int offset, int count, string name)
{
if (offset < 0 || count < 0 || offset > source.Length - count)
throw new InvalidDataException($"{name} is truncated.");
}
private static Encoding CreateNativeEncoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(932);
}
}

View File

@@ -0,0 +1,179 @@
using System.Buffers.Binary;
using System.Text;
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Persistence;
/// <summary>The non-container LZSS tail appended to a layout-3 numbered save.</summary>
public static class NativeTextHistoryCodec
{
private const int HeaderSize = 12;
private const int RecordDwords = 11;
private static readonly Encoding NativeEncoding = CreateNativeEncoding();
public static byte[] Encode(AdvTextHistory history)
{
ArgumentNullException.ThrowIfNull(history);
byte[] logical = EncodeLogical(history);
byte[] stored = LzssEncoder.EncodeOrVerbatim(logical);
byte[] result = new byte[HeaderSize + stored.Length];
WriteInt(result, 0, logical.Length);
WriteInt(result, 4, logical.Length);
WriteInt(result, 8, stored.Length);
stored.CopyTo(result, HeaderSize);
return result;
}
public static void DecodeInto(ReadOnlySpan<byte> source, AdvTextHistory history)
{
ArgumentNullException.ThrowIfNull(history);
if (source.Length < HeaderSize) throw new InvalidDataException("Native text-history tail is truncated.");
int logicalLength = ReadNonNegative(source, 0, "logical length");
int duplicateLength = ReadNonNegative(source, 4, "duplicate logical length");
int storedLength = ReadNonNegative(source, 8, "stored length");
if (duplicateLength != logicalLength)
throw new InvalidDataException("Native text-history length fields disagree.");
if (source.Length < HeaderSize + storedLength)
throw new InvalidDataException("Native text-history compressed stream is truncated.");
ReadOnlySpan<byte> stored = source.Slice(HeaderSize, storedLength);
byte[] logical = storedLength == logicalLength
? stored.ToArray()
: LzssDecoder.Decode(stored, logicalLength, "native text history");
DecodeLogical(logical, history);
}
private static byte[] EncodeLogical(AdvTextHistory history)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
writer.Write(history.Entries.Count);
foreach (AdvTextHistoryEntry entry in history.Entries)
{
writer.Write(entry.LayoutSlot);
writer.Write(entry.FirstRecordIndex);
}
writer.Write(history.Records.Count);
foreach (AdvTextHistoryRecord record in history.Records)
{
writer.Write(record.Layout.Slot);
writer.Write(record.Layout.OriginX);
writer.Write(record.Layout.OriginY);
writer.Write(record.Layout.Width);
writer.Write(record.Layout.Height);
writer.Write(unchecked((int)record.Value));
writer.Write(unchecked((int)record.AuxValue));
writer.Write(record.Style.PrimaryFontSize);
writer.Write(unchecked((int)record.Style.TextColor));
writer.Write(record.Layout.CursorY);
writer.Write(unchecked((int)record.Flags));
}
using var strings = new MemoryStream();
foreach (AdvTextHistoryRecord record in history.Records)
{
strings.Write(NativeEncoding.GetBytes(record.Text ?? ""));
strings.WriteByte(0);
}
while ((strings.Length & 3) != 0) strings.WriteByte(0);
byte[] blob = strings.ToArray();
writer.Write(blob.Length / 4);
for (int i = 0; i < blob.Length; i += 4)
writer.Write(~BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(i, 4)));
return stream.ToArray();
}
private static void DecodeLogical(ReadOnlySpan<byte> source, AdvTextHistory history)
{
int at = 0;
int entryCount = ReadCount(source, ref at, "entry");
var entries = new AdvTextHistoryEntry[entryCount];
for (int i = 0; i < entryCount; i++)
entries[i] = new AdvTextHistoryEntry(ReadNext(source, ref at), ReadNext(source, ref at));
int recordCount = ReadCount(source, ref at, "record");
var raw = new int[recordCount, RecordDwords];
for (int i = 0; i < recordCount; i++)
for (int dword = 0; dword < RecordDwords; dword++)
raw[i, dword] = ReadNext(source, ref at);
int stringDwords = ReadCount(source, ref at, "string blob dword");
int stringBytes = checked(stringDwords * 4);
if (at > source.Length - stringBytes)
throw new InvalidDataException("Native text-history string blob is truncated.");
byte[] blob = source.Slice(at, stringBytes).ToArray();
for (int i = 0; i < blob.Length; i += 4)
{
uint value = ~BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(i, 4));
BinaryPrimitives.WriteUInt32LittleEndian(blob.AsSpan(i, 4), value);
}
string[] texts = DecodeStrings(blob, recordCount);
var records = new AdvTextHistoryRecord[recordCount];
for (int i = 0; i < recordCount; i++)
{
var flags = unchecked((AdvTextHistoryRecordFlags)(uint)raw[i, 10]);
AdvTextHistoryRecordKind kind =
flags.HasFlag(AdvTextHistoryRecordFlags.VoicePair) ? AdvTextHistoryRecordKind.Voice :
flags.HasFlag(AdvTextHistoryRecordFlags.TypedMetadata) ? AdvTextHistoryRecordKind.Metadata :
AdvTextHistoryRecordKind.Text;
var layout = new AdvTextLayoutSnapshot(
raw[i, 0], raw[i, 3], raw[i, 4], raw[i, 1], raw[i, 2],
0, raw[i, 9], raw[i, 3], raw[i, 4]);
var style = AdvTextStyle.Default with
{
PrimaryFontSize = raw[i, 7],
TextColor = unchecked((uint)raw[i, 8]),
};
records[i] = new AdvTextHistoryRecord(
kind, flags, layout, style, texts[i], raw[i, 5], raw[i, 6], -1);
}
history.RestorePersistenceSnapshot(entries, records);
}
private static string[] DecodeStrings(ReadOnlySpan<byte> blob, int count)
{
var result = new string[count];
int at = 0;
for (int i = 0; i < count; i++)
{
if (at >= blob.Length)
throw new InvalidDataException("Native text-history string blob ended early.");
int end = blob[at..].IndexOf((byte)0);
if (end < 0) throw new InvalidDataException("Native text-history string blob ended early.");
result[i] = NativeEncoding.GetString(blob.Slice(at, end));
at += end + 1;
}
return result;
}
private static int ReadCount(ReadOnlySpan<byte> source, ref int at, string name)
{
int value = ReadNext(source, ref at);
if (value < 0) throw new InvalidDataException($"Native text-history {name} count is negative.");
return value;
}
private static int ReadNext(ReadOnlySpan<byte> source, ref int at)
{
if (at > source.Length - 4) throw new InvalidDataException("Native text-history logical data is truncated.");
int value = BinaryPrimitives.ReadInt32LittleEndian(source.Slice(at, 4));
at += 4;
return value;
}
private static int ReadNonNegative(ReadOnlySpan<byte> source, int offset, string name)
{
int value = BinaryPrimitives.ReadInt32LittleEndian(source.Slice(offset, 4));
if (value < 0) throw new InvalidDataException($"Native text-history {name} is negative.");
return value;
}
private static void WriteInt(Span<byte> destination, int offset, int value)
=> BinaryPrimitives.WriteInt32LittleEndian(destination.Slice(offset, 4), value);
private static Encoding CreateNativeEncoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(932);
}
}

View File

@@ -29,6 +29,8 @@ public static class Sys4Loader
throw new InvalidDataException($"{name}: invalid T1 read-message table");
int[] messageOffsets = dw.AsSpan(messageTableOffset, messageCount)
.ToArray().Select(value => checked((int)value)).ToArray();
int[] scriptCallOffsets = ReadOffsetTable(dw, fields[9], fields[10], nbody, name, "T2 script-call");
int[] localCallOffsets = ReadOffsetTable(dw, fields[11], fields[12], nbody, name, "T3 local-call");
return new Script
{
Name = name,
@@ -39,9 +41,20 @@ public static class Sys4Loader
Strings = strings,
BodyDwords = dw,
ReadMessageOffsets = messageOffsets,
ScriptCallOffsets = scriptCallOffsets,
LocalCallOffsets = localCallOffsets,
};
}
private static int[] ReadOffsetTable(
uint[] body, int count, int offset, int bodyLength, string name, string tableName)
{
if (count < 0 || offset < 0 || offset > bodyLength || count > bodyLength - offset)
throw new InvalidDataException($"{name}: invalid {tableName} table");
return body.AsSpan(offset, count).ToArray()
.Select(value => checked((int)value)).ToArray();
}
private static (List<Instruction>, Dictionary<int, int>, Dictionary<int, string>)
DecodeCode(uint[] dw, int[] fields, int nbody, OpcodeTable table)
{

View File

@@ -7,8 +7,8 @@ using Age.Engine.Persistence;
namespace Age.Engine.Vm;
/// <summary>
/// A persistent global store carried across scenes. Every scene the game runs shares one flat global
/// bank (the engine's model); running scenes in isolation with empty state is why our headless VM
/// A persistent store carried across scenes. AGE keeps separate integer, float, string, and pointer
/// banks; running scenes in isolation with empty state is why our headless VM
/// diverges from the real game (the bg/sprite geometry drift, the state-gated EMPTY scenes, Lily's
/// form-gated voices are all state divergence — see docs/phase-a-slice-plan.md A2b-Geometry).
///
@@ -20,7 +20,10 @@ namespace Age.Engine.Vm;
public sealed class GameSession
{
public Dictionary<int, long> Globals { get; } = new();
public Dictionary<int, long> GlobalFloats { get; } = new();
public Dictionary<int, string> GlobalStrings { get; } = new();
public Dictionary<int, int> GlobalPointers { get; } = new();
public Dictionary<int, int> GlobalStringPointers { get; } = new();
/// <summary>AGE's selected profile-wide cells plus native shared SAVE.DAT/RT.DAT lifecycle.</summary>
public SharedProfile SharedProfile { get; }
/// <summary>Native shared/numbered save directory service used by persistence opcodes.</summary>
@@ -45,13 +48,19 @@ public sealed class GameSession
var vm = new VirtualMachine(
script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore);
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
foreach (var kv in GlobalFloats) vm.GlobalFloats[kv.Key] = kv.Value;
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
foreach (var kv in GlobalPointers) vm.GlobalPointers[kv.Key] = kv.Value;
foreach (var kv in GlobalStringPointers) vm.GlobalStringPointers[kv.Key] = kv.Value;
vm.Run();
// Globals are one flat space; last write wins — the engine's single global bank.
// Each native bank is shared by the session; last write wins within that bank.
foreach (var kv in vm.Globals) Globals[kv.Key] = kv.Value;
foreach (var kv in vm.GlobalFloats) GlobalFloats[kv.Key] = kv.Value;
foreach (var kv in vm.GlobalStrings) GlobalStrings[kv.Key] = kv.Value;
foreach (var kv in vm.GlobalPointers) GlobalPointers[kv.Key] = kv.Value;
foreach (var kv in vm.GlobalStringPointers) GlobalStringPointers[kv.Key] = kv.Value;
return new SceneResult(vm.Emitted.ToList(), vm.HaltReason, vm.Steps);
}

View File

@@ -37,6 +37,11 @@ public sealed class VirtualMachine
private readonly List<string> _activeFrameNames = new();
private readonly List<ExecFrame> _activeExecutionFrames = new();
private ExecFrame? _saveResumeFrame;
private NativeNumberedSaveState? _loadedNumberedState;
private NativeNumberedSaveState? _retainedNativeNumberedState;
private int _restoreFrameIndex = -1;
private uint _accumulatedPlaySeconds;
private readonly long _sessionStartTimestamp;
private ExecFrame? _debugActiveFrame;
private long _debugActiveFrameId;
private long _debugNextFrameId;
@@ -68,9 +73,12 @@ public sealed class VirtualMachine
public long CallScriptDispatches { get; private set; }
public Dictionary<int, long> Globals { get; } = new();
public Dictionary<int, long> GlobalFloats { get; } = new();
/// <summary>Native/profile-owned values read by scripts but maintained outside script-visible writes.</summary>
public Dictionary<int, long> ExternalGlobals { get; } = new();
public Dictionary<int, string> GlobalStrings { get; } = new();
public Dictionary<int, int> GlobalPointers { get; } = new();
public Dictionary<int, int> GlobalStringPointers { get; } = new();
public GfxState Gfx { get; } = new();
public InputBindings InputBindings { get; } = new();
public List<(int Offset, string Text, string Script)> Emitted { get; } = new();
@@ -124,6 +132,7 @@ public sealed class VirtualMachine
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory();
_sharedProfile = sharedProfile ?? new SharedProfile();
_nativeDatStore = nativeDatStore;
_sessionStartTimestamp = System.Diagnostics.Stopwatch.GetTimestamp();
}
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary.
@@ -291,6 +300,10 @@ public sealed class VirtualMachine
}
private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0;
private int GlobalPointer(int index)
=> GlobalPointers.TryGetValue(index, out int value) ? value : unchecked((int)Gi(Globals, index));
private int GlobalStringPointer(int index)
=> GlobalStringPointers.TryGetValue(index, out int value) ? value : unchecked((int)Gi(Globals, index));
private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k);
private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : "";
private static long PyDiv(long a, long b) { if (b == 0) return 0; long q = a / b, r = a % b; if (r != 0 && (r < 0) != (b < 0)) q--; return q; }
@@ -328,8 +341,9 @@ public sealed class VirtualMachine
private long Read(Operand op) => op.Type switch
{
T_IMM => op.Value,
T_GINT or T_GFLOAT => ReadGlobal((int)op.Value),
T_GPTR => Gi(Globals, (int)Gi(Globals, (int)op.Value)),
T_GINT => ReadGlobal((int)op.Value),
T_GFLOAT => Gi(GlobalFloats, (int)op.Value),
T_GPTR => Gi(Globals, GlobalPointer((int)op.Value)),
T_LINT => Gi(_cur.Locals.I, (int)op.Value),
T_LFLOAT => Gi(_cur.Locals.F, (int)op.Value),
T_LPTR => ReadIntCell(Ga(_cur.Locals.P, (int)op.Value)),
@@ -340,8 +354,9 @@ public sealed class VirtualMachine
{
switch (op.Type)
{
case T_GINT: case T_GFLOAT: Globals[(int)op.Value] = val; break;
case T_GPTR: Globals[(int)Gi(Globals, (int)op.Value)] = val; break;
case T_GINT: Globals[(int)op.Value] = val; break;
case T_GFLOAT: GlobalFloats[(int)op.Value] = val; break;
case T_GPTR: Globals[GlobalPointer((int)op.Value)] = val; break;
case T_LINT: _cur.Locals.I[(int)op.Value] = val; break;
case T_LFLOAT: _cur.Locals.F[(int)op.Value] = val; break;
case T_LPTR: WriteIntCell(Ga(_cur.Locals.P, (int)op.Value), val); break;
@@ -352,7 +367,7 @@ public sealed class VirtualMachine
{
T_STR => _cur.Script.GetString((int)op.Value),
T_GSTR => Gs(GlobalStrings, (int)op.Value),
T_GSTRPTR => Gs(GlobalStrings, (int)Gi(Globals, (int)op.Value)),
T_GSTRPTR => Gs(GlobalStrings, GlobalStringPointer((int)op.Value)),
T_LSTR => Gs(_cur.Locals.S, (int)op.Value),
T_LSTRPTR => ReadStringCell(Ga(_cur.Locals.SP, (int)op.Value)),
_ => "",
@@ -363,7 +378,7 @@ public sealed class VirtualMachine
switch (op.Type)
{
case T_GSTR: GlobalStrings[(int)op.Value] = val; break;
case T_GSTRPTR: GlobalStrings[(int)Gi(Globals, (int)op.Value)] = val; break;
case T_GSTRPTR: GlobalStrings[GlobalStringPointer((int)op.Value)] = val; break;
case T_LSTR: _cur.Locals.S[(int)op.Value] = val; break;
case T_LSTRPTR: WriteStringCell(Ga(_cur.Locals.SP, (int)op.Value), val); break;
}
@@ -465,7 +480,8 @@ public sealed class VirtualMachine
T_LINT => VmAddress.LocalInteger((int)op.Value),
T_LFLOAT => VmAddress.LocalFloat((int)op.Value),
T_LSTR => VmAddress.LocalString((int)op.Value),
T_GPTR or T_GSTRPTR => VmAddress.Global((int)Gi(Globals, (int)op.Value)),
T_GPTR => VmAddress.Global(GlobalPointer((int)op.Value)),
T_GSTRPTR => VmAddress.Global(GlobalStringPointer((int)op.Value)),
T_LPTR => Ga(_cur.Locals.P, (int)op.Value),
T_LSTRPTR => Ga(_cur.Locals.SP, (int)op.Value),
_ => VmAddress.Global((int)op.Value),
@@ -492,7 +508,8 @@ public sealed class VirtualMachine
{
case T_LPTR: _cur.Locals.P[(int)destination.Value] = address; return true;
case T_LSTRPTR: _cur.Locals.SP[(int)destination.Value] = address; return true;
case T_GPTR: case T_GSTRPTR: Globals[(int)destination.Value] = address.Address; return true;
case T_GPTR: GlobalPointers[(int)destination.Value] = address.Address; return true;
case T_GSTRPTR: GlobalStringPointers[(int)destination.Value] = address.Address; return true;
default: return false;
}
}
@@ -514,10 +531,13 @@ public sealed class VirtualMachine
int address = checked((int)destination.Value + index);
switch (destination.Type)
{
case T_GINT: case T_GFLOAT: Globals[address] = value; break;
case T_GINT: Globals[address] = value; break;
case T_GFLOAT: GlobalFloats[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_GPTR:
Globals[checked(GlobalPointer((int)destination.Value) + index)] = value;
break;
case T_LPTR: WriteIntCell(Ga(_cur.Locals.P, (int)destination.Value).Offset(index), value); break;
}
}
@@ -528,9 +548,10 @@ public sealed class VirtualMachine
{
T_LINT => Gi(_cur.Locals.I, checked((int)operand.Value + offset)),
T_LFLOAT => Gi(_cur.Locals.F, checked((int)operand.Value + offset)),
T_GINT or T_GFLOAT => ReadGlobal(checked((int)operand.Value + offset)),
T_GINT => ReadGlobal(checked((int)operand.Value + offset)),
T_GFLOAT => Gi(GlobalFloats, checked((int)operand.Value + offset)),
T_LPTR => ReadIntCell(Ga(_cur.Locals.P, (int)operand.Value).Offset(offset)),
T_GPTR => Gi(Globals, checked((int)Gi(Globals, (int)operand.Value) + offset)),
T_GPTR => Gi(Globals, checked(GlobalPointer((int)operand.Value) + offset)),
_ => Gi(Globals, checked((int)operand.Value + offset)),
};
}
@@ -541,7 +562,8 @@ public sealed class VirtualMachine
T_LINT => (VmAddressSpace.LocalInteger, checked((int)operand.Value + offset)),
T_LFLOAT => (VmAddressSpace.LocalFloat, checked((int)operand.Value + offset)),
T_LPTR => PointerIdentity(Ga(_cur.Locals.P, (int)operand.Value).Offset(offset)),
T_GPTR => (VmAddressSpace.Global, checked((int)Gi(Globals, (int)operand.Value) + offset)),
T_GPTR => (VmAddressSpace.Global,
checked(GlobalPointer((int)operand.Value) + offset)),
_ => (VmAddressSpace.Global, checked((int)operand.Value + offset)),
};
@@ -554,6 +576,7 @@ public sealed class VirtualMachine
: unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture);
private sealed class RootReloadRequestedException : Exception { }
private sealed class NumberedRestoreRequestedException : Exception { }
private sealed class ProcessExitRequestedException : Exception { }
private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary<int, long> GlobalWrites);
private sealed record PreloadedScriptSlot(long ScriptId, ExecFrame Frame);
@@ -571,7 +594,32 @@ public sealed class VirtualMachine
FrameCause cause = FrameCause.TopScene;
while (true)
{
var outcome = RunFrame(new ExecFrame(root, rootEntry), cause);
FrameOutcome outcome;
try
{
outcome = RunFrame(new ExecFrame(root, rootEntry), cause);
}
catch (NumberedRestoreRequestedException)
{
if (_loadedNumberedState == null) throw;
Script? callback = _provider?.GetByName("CALLBACK_LOAD.BIN");
if (callback != null)
{
int callbackEntry = callback.IndexByOffset.TryGetValue(0, out int ci) ? ci : 0;
FrameOutcome callbackOutcome = RunFrame(
new ExecFrame(callback, callbackEntry), FrameCause.SaveRestore, callback.PackedId);
if (callbackOutcome is FrameOutcome.Halted or FrameOutcome.ExitRequested)
{
outcome = callbackOutcome;
break;
}
}
root = ResolveSavedScript(_loadedNumberedState.Frames[0]);
rootEntry = FindRestoreRendezvous(root);
_restoreFrameIndex = 0;
cause = FrameCause.SaveRestore;
continue;
}
if (outcome == FrameOutcome.RootReload)
{
// Native 0x9 performs the scene reset before attempting the resource-0 load. Keep
@@ -666,6 +714,7 @@ public sealed class VirtualMachine
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
frame.Pc = pc;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
@@ -727,6 +776,192 @@ public sealed class VirtualMachine
}
}
private NativeNumberedSaveState CaptureNumberedState()
{
const int himegariIntegerCount = 0x6241b;
const int himegariFloatCount = 1;
const int himegariStringCount = 0x315;
const int himegariPointerCount = 1;
ExecFrame[] active;
int cutoff;
lock (_debugControlLock)
{
active = _activeExecutionFrames.ToArray();
cutoff = _saveResumeFrame == null ? active.Length - 1 : Array.IndexOf(active, _saveResumeFrame);
}
if (cutoff < 0)
throw new InvalidDataException("No active script frame is available for a numbered save.");
var frames = new NativeSavedScriptFrame[cutoff + 1];
for (int i = 0; i <= cutoff; i++)
{
ExecFrame frame = active[i];
int[] returns = frame.CallStack
.Where(returnPc => (uint)returnPc < (uint)frame.Script.Instructions.Count)
.Select(returnPc =>
{
int returnOffset = frame.Script.Instructions[returnPc].Offset;
return FindTableIndex(frame.Script.LocalCallOffsets, returnOffset - 3);
})
.Where(index => index >= 0)
.ToArray();
int resumeIndex = CurrentReadMessageIndex(frame);
int callTargetIndex = i == cutoff || (uint)frame.Pc >= (uint)frame.Script.Instructions.Count
? -1
: FindTableIndex(frame.Script.ScriptCallOffsets, frame.Script.Instructions[frame.Pc].Offset);
frames[i] = new NativeSavedScriptFrame(
i - 1, frame.Script.PackedId, returns, resumeIndex, callTargetIndex);
}
NativeNumberedSaveState basis = _retainedNativeNumberedState
?? NativeNumberedSaveCodec.Empty(frames);
var gfx = NativeGfxPersistenceCodec.Capture(Gfx);
return basis with
{
Frames = frames,
IntegerGlobals = DenseValues(Globals, himegariIntegerCount),
FloatGlobals = DenseValues(GlobalFloats, himegariFloatCount),
StringGlobals = DenseStrings(GlobalStrings, himegariStringCount),
PointerGlobals = DensePointerValues(GlobalPointers, himegariPointerCount),
PointerStrings = DensePointerValues(GlobalStringPointers, himegariPointerCount),
LocalPointerScratch = new int[himegariPointerCount],
SurfaceRecords = gfx.SurfaceRecords,
GfxObjects = gfx.Objects,
RangeTransformFirst = gfx.RangeFirst,
RangeTransformCount = gfx.RangeCount,
RangeTransformRecord = gfx.RangeRecord,
};
}
private bool TryLoadNumberedState(int slot, bool restoreHistory)
{
if (_nativeDatStore == null) return false;
try
{
NativeNumberedSaveFile? file = _nativeDatStore.LoadNumberedFile(slot);
if (file == null) return false;
NativeNumberedSaveState state = NativeNumberedSaveCodec.Decode(file.Document.Payload);
ApplyNumberedState(state);
_retainedNativeNumberedState = state;
_accumulatedPlaySeconds = file.Document.Metadata.AccumulatedPlaySeconds;
if (restoreHistory)
{
if (file.HistoryTail.Length == 0) TextHistory.Clear();
else NativeTextHistoryCodec.DecodeInto(file.HistoryTail, TextHistory);
_loadedNumberedState = state;
}
else
{
_loadedNumberedState = null;
_restoreFrameIndex = -1;
}
return true;
}
catch (Exception error) when (
error is IOException or UnauthorizedAccessException or InvalidDataException
or ArgumentOutOfRangeException or OverflowException)
{
_loadedNumberedState = null;
_restoreFrameIndex = -1;
return false;
}
}
private void ApplyNumberedState(NativeNumberedSaveState state)
{
Globals.Clear();
for (int i = 0; i < state.IntegerGlobals.Count; i++)
if (state.IntegerGlobals[i] != 0) Globals[i] = state.IntegerGlobals[i];
GlobalFloats.Clear();
for (int i = 0; i < state.FloatGlobals.Count; i++)
if (state.FloatGlobals[i] != 0) GlobalFloats[i] = state.FloatGlobals[i];
GlobalStrings.Clear();
for (int i = 0; i < state.StringGlobals.Count; i++)
if (state.StringGlobals[i].Length != 0) GlobalStrings[i] = state.StringGlobals[i];
GlobalPointers.Clear();
for (int i = 0; i < state.PointerGlobals.Count; i++)
if (state.PointerGlobals[i] != 0) GlobalPointers[i] = state.PointerGlobals[i];
GlobalStringPointers.Clear();
for (int i = 0; i < state.PointerStrings.Count; i++)
if (state.PointerStrings[i] != 0) GlobalStringPointers[i] = state.PointerStrings[i];
GfxPersistenceSnapshot gfxSnapshot = NativeGfxPersistenceCodec.Decode(state);
for (int slot = 0; slot < 1000; slot++) _host.ReleaseSurface(slot);
Gfx.RestorePersistenceSnapshot(gfxSnapshot);
foreach (var (slot, resourceId, colorKey, created) in gfxSnapshot.Surfaces)
{
if (!created) _host.SetTexture(resourceId, slot, colorKey);
}
}
private Script ResolveSavedScript(NativeSavedScriptFrame frame)
{
if (_s.PackedId == frame.ScriptId) return _s;
Script? script = _provider?.GetById(frame.ScriptId);
return script ?? throw new InvalidDataException(
$"Numbered save references unresolved script 0x{frame.ScriptId:x}.");
}
private static int FindRestoreRendezvous(Script script)
{
for (int i = 0; i < script.Instructions.Count; i++)
if (script.Instructions[i].Opcode == 0xae) return i;
throw new InvalidDataException(
$"Saved script {script.Name} has no opcode 0xae restore rendezvous.");
}
private static int ResolveTableOffset(
Script script, IReadOnlyList<int> table, int index, int fallback)
{
if ((uint)index >= (uint)table.Count) return fallback;
return script.IndexByOffset.TryGetValue(table[index], out int pc) ? pc : fallback;
}
private static int CurrentReadMessageIndex(ExecFrame frame)
{
for (int i = 0; i < frame.Script.ReadMessageOffsets.Count; i++)
if (frame.Script.ReadMessageOffsets[i] == frame.ReadMessageOffset) return i;
return -1;
}
private static int FindTableIndex(IReadOnlyList<int> table, int offset)
{
for (int i = 0; i < table.Count; i++)
if (table[i] == offset) return i;
return -1;
}
private static int[] DenseValues(IReadOnlyDictionary<int, long> source, int fixedCount)
{
var result = new int[fixedCount];
foreach ((int index, long value) in source)
if ((uint)index < (uint)result.Length) result[index] = unchecked((int)value);
return result;
}
private static int[] DensePointerValues(IReadOnlyDictionary<int, int> source, int fixedCount)
{
var result = new int[fixedCount];
foreach ((int index, int value) in source)
if ((uint)index < (uint)result.Length) result[index] = value;
return result;
}
private static string[] DenseStrings(IReadOnlyDictionary<int, string> source, int fixedCount)
{
var result = Enumerable.Repeat(string.Empty, fixedCount).ToArray();
foreach ((int index, string value) in source)
if ((uint)index < (uint)result.Length) result[index] = value;
return result;
}
private uint AccumulatedPlaySeconds()
{
double elapsedSeconds = System.Diagnostics.Stopwatch.GetElapsedTime(_sessionStartTimestamp).TotalSeconds;
return unchecked(_accumulatedPlaySeconds + (uint)Math.Min(uint.MaxValue, elapsedSeconds));
}
private bool ServiceHotspotCallback()
{
int target;
@@ -741,6 +976,7 @@ public sealed class VirtualMachine
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; break; }
Steps++;
_cur.Pc = pc;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, _cur.Script.Instructions[pc], _depth));
int next = Step(_cur.Script.Instructions[pc], pc);
_host.FrameYield();
@@ -819,6 +1055,76 @@ public sealed class VirtualMachine
case "halve-strlen": // 0x1a6: strlen(native encoded bytes) >> 1
Write(a[0], NativeStringByteLength(ReadStr(a[1])) >> 1);
return pc + 1;
case "save-numbered-slot": // 0x19e
{
if (_nativeDatStore == null)
{
Write(a[0], 1);
return pc + 1;
}
try
{
int slot = unchecked((int)Read(a[1]));
NativeNumberedSaveState state = CaptureNumberedState();
byte[] payload = NativeNumberedSaveCodec.Encode(state);
byte[] history = NativeTextHistoryCodec.Encode(TextHistory);
NativeSystemTime timestamp = NativeSystemTime.FromLocalDateTime(DateTime.Now);
uint playSeconds = AccumulatedPlaySeconds();
_nativeDatStore.SaveNumberedFile(slot, payload, history, timestamp, playSeconds);
_sharedProfile.Save(_nativeDatStore, timestamp, playSeconds);
Write(a[0], 0);
}
catch (Exception error) when (
error is IOException or UnauthorizedAccessException or InvalidDataException
or ArgumentOutOfRangeException or OverflowException)
{
Write(a[0], 1);
}
return pc + 1;
}
case "load-numbered-slot-data-only": // 0x19f
{
if (!TryLoadNumberedState(unchecked((int)Read(a[1])), restoreHistory: false))
Write(a[0], 1);
else
Write(a[0], 0);
return pc + 1;
}
case "load-numbered-slot-and-resume": // 0x1a1
{
if (!TryLoadNumberedState(unchecked((int)Read(a[1])), restoreHistory: true))
{
Write(a[0], 1);
return pc + 1;
}
throw new NumberedRestoreRequestedException();
}
case "continue-save-load-stack-restore": // 0xae
{
if (_loadedNumberedState == null || _restoreFrameIndex < 0)
return pc + 1;
NativeSavedScriptFrame saved = _loadedNumberedState.Frames[_restoreFrameIndex];
bool terminal = _restoreFrameIndex == _loadedNumberedState.Frames.Count - 1;
if (terminal)
{
_loadedNumberedState = null;
_restoreFrameIndex = -1;
return ResolveTableOffset(_cur.Script, _cur.Script.ReadMessageOffsets, saved.ResumeIndex, pc + 1);
}
int parentIndex = _restoreFrameIndex;
NativeSavedScriptFrame childSaved = _loadedNumberedState.Frames[parentIndex + 1];
Script child = ResolveSavedScript(childSaved);
_restoreFrameIndex = parentIndex + 1;
FrameOutcome childOutcome = RunFrame(
new ExecFrame(child, FindRestoreRendezvous(child)), FrameCause.SaveRestore,
childSaved.ScriptId);
_restoreFrameIndex = parentIndex;
if (childOutcome is FrameOutcome.Halted or FrameOutcome.ExitRequested)
return HALT;
return ResolveTableOffset(
_cur.Script, _cur.Script.ScriptCallOffsets, saved.CallTargetIndex, pc) + 1;
}
case "query-numbered-save-metadata": // 0x1a0
{
if (_nativeDatStore == null)
@@ -1354,6 +1660,8 @@ public sealed class VirtualMachine
// each invocation an independent diagnostic activation.
loaded.Frame.CallStack.Clear();
loaded.Frame.EmitSeen.Clear();
loaded.Frame.Pc = loaded.Frame.Script.IndexByOffset.TryGetValue(0, out int loadedEntry)
? loadedEntry : 0;
var outcome = RunFrame(loaded.Frame, FrameCause.CallScript, loaded.ScriptId);
if (outcome == FrameOutcome.Halted) return HALT;
if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD;