Match native surface persistence policy

This commit is contained in:
gamer147
2026-07-24 20:28:59 -04:00
parent 7a11a3853d
commit 4db7e412bd
14 changed files with 299 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
using Age.Engine.Model;
using Age.Engine.Persistence;
using System.Buffers.Binary;
public class NativeNumberedSaveCodecTests
{
@@ -142,5 +143,14 @@ public class NativeNumberedSaveCodecTests
Assert.Equal(402459, state.IntegerGlobals.Count);
Assert.Equal(789, state.StringGlobals.Count);
Assert.Equal(NativeNumberedSaveState.GfxRecordSize, state.RangeTransformRecord.Length);
ReadOnlySpan<byte> systemChoiceAtlas = state.SurfaceRecords.AsSpan(15 * 20, 20);
Assert.Equal(0x3383, BinaryPrimitives.ReadInt32LittleEndian(systemChoiceAtlas));
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(systemChoiceAtlas[8..]));
Assert.All(
Enumerable.Range(0, 1000),
slot => Assert.Equal(
0,
BinaryPrimitives.ReadInt32LittleEndian(
state.SurfaceRecords.AsSpan(slot * 20 + 8))));
}
}

View File

@@ -38,6 +38,12 @@ public class NumberedSaveVmTests
vm.GlobalFloats[0] = BitConverter.SingleToInt32Bits(2.5f);
vm.GlobalStrings[4] = "姫狩り";
vm.Gfx.SetSurface(3, 0x1234, 0xff00ff);
vm.Gfx.SetSurface(5, 0x5678, 0);
vm.Gfx.SetSurfaceReloadOnRestore(5, true);
vm.Gfx.CreateSurface(4);
vm.Gfx.SetSurface(7, 0x7777, 0);
vm.Gfx.SetSurfaceReloadOnRestore(7, true);
vm.Gfx.ReleaseSurfaceRange(7, 1);
vm.Gfx.BindDraw(100, 3, 1, 2, 30, 40, 50, 60);
vm.Run();
@@ -53,6 +59,15 @@ public class NumberedSaveVmTests
Assert.Equal("姫狩り", state.StringGlobals[4]);
Assert.Equal(0x77u, state.Frames.Single().ScriptId);
Assert.Contains(state.GfxObjects, item => item.Handle == 100);
Assert.Equal(0x1234, ReadSurfaceField(state, 3, 0));
Assert.Equal(0, ReadSurfaceField(state, 3, 8));
Assert.Equal(-1, ReadSurfaceField(state, 4, 0));
Assert.Equal(1, ReadSurfaceField(state, 4, 0x10));
Assert.Equal(0x5678, ReadSurfaceField(state, 5, 0));
Assert.Equal(1, ReadSurfaceField(state, 5, 8));
Assert.Equal(-1, ReadSurfaceField(state, 6, 0));
Assert.Equal(-1, ReadSurfaceField(state, 7, 0));
Assert.Equal(1, ReadSurfaceField(state, 7, 8));
var restoredHistory = new AdvTextHistory();
NativeTextHistoryCodec.DecodeInto(file.HistoryTail, restoredHistory);
Assert.Equal("保存", restoredHistory.Records.Single().Text);
@@ -144,6 +159,7 @@ public class NumberedSaveVmTests
vm.Globals[0x124] = 777;
vm.GlobalStrings[0] = "stale";
vm.GlobalStrings[1] = "static-unit-name";
vm.Gfx.SetSurface(15, 0x3383, 0);
vm.Run();
@@ -163,6 +179,12 @@ public class NumberedSaveVmTests
Assert.Equal(Enumerable.Range(0, 10), host.SfxReleases);
Assert.Equal("履歴復帰", liveHistory.Records.Single().Text);
Assert.Equal(3, vm.Gfx.QuerySlot(100));
Assert.Contains(
vm.Gfx.CapturePersistenceSnapshot().Surfaces,
item => item.Slot == 15 && item.ResourceId == 0x3383);
Assert.Contains(host.Textures, item => item == (0x1234, 3));
Assert.DoesNotContain(host.Textures, item => item.Slot == 15);
Assert.Empty(host.ReleasedSurfaceRanges);
RenderObject restoredObject = Assert.Single(vm.Gfx.SnapshotVisibleObjects());
Assert.Equal(0x1234, restoredObject.SurfaceResId);
Assert.Equal((50, 60), (restoredObject.DstX, restoredObject.DstY));
@@ -182,6 +204,74 @@ public class NumberedSaveVmTests
}
}
[Fact]
public void DataOnlyLoadReleasesAllSurfacesWhenBothNativeSettingsEnableIt()
{
string root = NewTemporaryDirectory();
try
{
var store = new DirectoryNativeDatStore(root, Identity);
Script loader = ScriptAssembler.Assemble(Table, "LOAD_DATA_ONLY.BIN",
[
(0x19f, [new Operand(LocalInt, 0), new Operand(Immediate, 1)]),
(0x2, Array.Empty<Operand>()),
], []);
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(
[new NativeSavedScriptFrame(-1, 0, [], -1, -1)]) with
{
SurfaceRecords = NativeSurfaceRecords(3, 0x1234, 0xff00ff),
};
store.SaveNumberedFile(
1, NativeNumberedSaveCodec.Encode(state), [],
NativeSystemTime.FromLocalDateTime(DateTime.Now), 0);
var host = new RecordingHost();
var vm = new VirtualMachine(
loader, Table, host,
new VmOptions(CreateObject: true, AutoFreeTextures: true),
nativeDatStore: store);
vm.Gfx.SetSurface(15, 0x3383, 0);
vm.Run();
Assert.Contains((0, 1000), host.ReleasedSurfaceRanges);
Assert.DoesNotContain(
vm.Gfx.CapturePersistenceSnapshot().Surfaces,
item => item.Slot == 15);
Assert.Contains(
vm.Gfx.CapturePersistenceSnapshot().Surfaces,
item => item.Slot == 3
&& item.ResourceId == 0x1234
&& item.ReloadOnRestore);
Assert.Contains(host.Textures, item => item == (0x1234, 3));
Assert.Equal("exit", vm.HaltReason);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void ScriptEntryClearsReloadPolicyWithoutReleasingTheSurface()
{
Script script = ScriptAssembler.Assemble(Table, "ENTRY.BIN",
[
(0x259, Array.Empty<Operand>()),
(0x2, Array.Empty<Operand>()),
], []);
var vm = new VirtualMachine(script, Table, new RecordingHost());
vm.Gfx.SetSurface(15, 0x3383, 0);
vm.Gfx.SetSurfaceReloadOnRestore(15, true);
vm.Run();
GfxSurfacePersistenceState surface = Assert.Single(
vm.Gfx.CapturePersistenceSnapshot().Surfaces);
Assert.Equal(15, surface.Slot);
Assert.Equal(0x3383, surface.ResourceId);
Assert.False(surface.ReloadOnRestore);
}
private static Script WithPackedId(Script source, uint packedId)
=> new()
{
@@ -225,6 +315,8 @@ public class NumberedSaveVmTests
private static byte[] NativeSurfaceRecords(int slot, int resourceId, int colorKey)
{
byte[] result = new byte[NativeNumberedSaveState.SurfaceRecordsSize];
for (int index = 0; index < 1000; index++)
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(index * 20), -1);
int at = slot * 20;
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(at), resourceId);
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(at + 4), unchecked((int)(0xff000000u | (uint)colorKey)));
@@ -232,6 +324,10 @@ public class NumberedSaveVmTests
return result;
}
private static int ReadSurfaceField(NativeNumberedSaveState state, int slot, int offset)
=> BinaryPrimitives.ReadInt32LittleEndian(
state.SurfaceRecords.AsSpan(slot * 20 + offset));
private static byte[] NativeGfxRecord(int sourceSlot)
{
byte[] result = new byte[NativeNumberedSaveState.GfxRecordSize];

View File

@@ -56,8 +56,11 @@ public sealed record GfxDiagnosticSnapshot(
BlockingGfxObjectDiagnostic? BlockingRangeTransform,
IReadOnlyList<BlockingGfxObjectDiagnostic> BlockingObjects);
public readonly record struct GfxSurfacePersistenceState(
int Slot, long ResourceId, long ColorKey, bool Created, bool ReloadOnRestore);
public sealed record GfxPersistenceSnapshot(
IReadOnlyList<(int Slot, long ResourceId, long ColorKey, bool Created)> Surfaces,
IReadOnlyList<GfxSurfacePersistenceState> Surfaces,
IReadOnlyList<(long Handle, GfxState.GfxObject Object)> Objects,
long RangeFirst,
long RangeCount,
@@ -383,6 +386,7 @@ public sealed class GfxState
_fieldTable.Clear();
_surfaces.Clear();
_createdSurfaces.Clear();
_reloadableSurfaces.Clear();
_movieStopTimesMs.Clear();
_surfaceTransitions.Clear();
CurrentObject = 0;
@@ -405,11 +409,18 @@ public sealed class GfxState
{
lock (_lock)
{
var surfaces = _surfaces
.Select(pair => (
pair.Key, pair.Value.ResId, pair.Value.ColorKey,
_createdSurfaces.Contains(pair.Key)))
.OrderBy(item => item.Key)
var surfaces = _surfaces.Keys
.Concat(_reloadableSurfaces)
.Distinct()
.Select(slot =>
{
bool active = _surfaces.TryGetValue(slot, out var value);
return new GfxSurfacePersistenceState(
slot, active ? value.ResId : -1, active ? value.ColorKey : 0,
active && _createdSurfaces.Contains(slot),
_reloadableSurfaces.Contains(slot));
})
.OrderBy(item => item.Slot)
.ToArray();
var objects = _orderedObjectHandles
.Select(handle => (handle, CloneState(_objects[handle])))
@@ -427,10 +438,13 @@ public sealed class GfxState
{
_surfaces.Clear();
_createdSurfaces.Clear();
foreach (var (slot, resourceId, colorKey, created) in snapshot.Surfaces)
_reloadableSurfaces.Clear();
foreach (GfxSurfacePersistenceState surface in snapshot.Surfaces)
{
_surfaces[slot] = (resourceId, colorKey);
if (created) _createdSurfaces.Add(slot);
if (surface.ResourceId >= 0 || surface.Created)
_surfaces[surface.Slot] = (surface.ResourceId, surface.ColorKey);
if (surface.Created) _createdSurfaces.Add(surface.Slot);
if (surface.ReloadOnRestore) _reloadableSurfaces.Add(surface.Slot);
}
_objects.Clear();
_orderedObjectHandles.Clear();
@@ -481,6 +495,9 @@ public sealed class GfxState
// Created surfaces have real pixels but no asset resource id. Keep their class separate from both
// loaded textures and truly surfaceless objects because native mode-0 consumes packed alpha differently.
private readonly HashSet<int> _createdSurfaces = new();
// Native surface record +0x08. Ordinary create/load/release workers leave this bit unchanged;
// the numbered-save restore loop consults it to decide which asset-backed surfaces to reopen.
private readonly HashSet<int> _reloadableSurfaces = new();
// A separate entry models the native CMovieToTexture object attached to a surface. A null value means
// the movie object exists but its host decoder supplied no usable IMediaPosition stop time.
private readonly Dictionary<int, long?> _movieStopTimesMs = new();
@@ -496,6 +513,24 @@ public sealed class GfxState
}
}
/// <summary>Set the native surface-record +0x08 reload policy. This is separate from loading a
/// texture because AGE's ordinary create/load/release workers preserve the existing bit.</summary>
public void SetSurfaceReloadOnRestore(int slot, bool reload)
{
lock (_lock)
{
if (reload) _reloadableSurfaces.Add(slot);
else _reloadableSurfaces.Remove(slot);
}
}
/// <summary>Opcode 0x259 script-entry lifecycle: clear record +0x08 for every surface.
/// Native also clears the adjacent unknown +0x0c field, which the port does not otherwise model.</summary>
public void ClearSurfaceReloadPolicies()
{
lock (_lock) _reloadableSurfaces.Clear();
}
/// <summary>Op 0x236 handoff: retain the initialized movie graph's IMediaPosition stop time. Null
/// deliberately distinguishes a movie surface with unavailable metadata from an empty movie slot.</summary>
public void SetMovieStopTime(int slot, long? stopTimeMs)

View File

@@ -17,13 +17,17 @@ internal static class NativeGfxPersistenceCodec
{
GfxPersistenceSnapshot snapshot = gfx.CapturePersistenceSnapshot();
byte[] surfaces = new byte[NativeNumberedSaveState.SurfaceRecordsSize];
foreach (var (slot, resourceId, colorKey, created) in snapshot.Surfaces)
for (int slot = 0; slot < SurfaceCount; slot++)
WriteInt(surfaces, slot * SurfaceRecordSize, -1);
foreach (GfxSurfacePersistenceState surface 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);
if ((uint)surface.Slot >= SurfaceCount) continue;
int at = surface.Slot * SurfaceRecordSize;
WriteInt(surfaces, at,
surface.Created ? -1 : unchecked((int)surface.ResourceId));
WriteInt(surfaces, at + 4, PackNativeColorKey(surface.ColorKey));
WriteInt(surfaces, at + 8, surface.ReloadOnRestore ? 1 : 0);
WriteInt(surfaces, at + 0x10, surface.Created ? 1 : 0);
}
NativeSavedGfxObject[] objects = snapshot.Objects
.Select(item => new NativeSavedGfxObject(item.Handle, EncodeObject(item.Object)))
@@ -35,15 +39,20 @@ internal static class NativeGfxPersistenceCodec
public static GfxPersistenceSnapshot Decode(NativeNumberedSaveState state)
{
var surfaces = new List<(int Slot, long ResourceId, long ColorKey, bool Created)>();
var surfaces = new List<GfxSurfacePersistenceState>();
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));
bool reload = ReadInt(state.SurfaceRecords, at + 8) == 1;
bool created = ReadInt(state.SurfaceRecords, at + 0x10) == 1;
if (resourceId >= 0 || reload || created)
surfaces.Add(new GfxSurfacePersistenceState(
slot,
resourceId < 0 ? resourceId : unchecked((uint)resourceId),
UnpackNativeColorKey(ReadInt(state.SurfaceRecords, at + 4)),
created,
reload));
}
var objects = state.GfxObjects
.Select(item => (item.Handle, DecodeObject(item.Record)))

View File

@@ -177,12 +177,20 @@ public static class NativeNumberedSaveCodec
=> new(
0, 0, new int[NativeNumberedSaveState.SoundEffectChannelCount],
new byte[NativeNumberedSaveState.ResourceRecordsSize],
new byte[NativeNumberedSaveState.SurfaceRecordsSize],
EmptySurfaceRecords(),
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 byte[] EmptySurfaceRecords()
{
byte[] result = new byte[NativeNumberedSaveState.SurfaceRecordsSize];
for (int slot = 0; slot < 1000; slot++)
WriteInt(result, slot * 20, -1);
return result;
}
private static void WriteFrame(Span<byte> payload, int offset, NativeSavedScriptFrame frame, bool terminal)
{
if (frame.ReturnIndices.Count > FrameReturnCapacity)

View File

@@ -896,12 +896,24 @@ public sealed class VirtualMachine
}
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)
bool releaseAllSurfaces = _o.CreateObject && _o.AutoFreeTextures;
if (releaseAllSurfaces)
{
if (!created) _host.SetTexture(resourceId, slot, colorKey);
Gfx.ReleaseSurfaceRange(0, 1000);
_host.ReleaseSurfaceRange(0, 1000);
}
var restoredSurfaces = Gfx.CapturePersistenceSnapshot().Surfaces
.ToDictionary(item => item.Slot);
foreach (GfxSurfacePersistenceState surface in gfxSnapshot.Surfaces
.Where(item => item.ReloadOnRestore && item.ResourceId >= 0))
restoredSurfaces[surface.Slot] = surface;
Gfx.RestorePersistenceSnapshot(gfxSnapshot with
{
Surfaces = restoredSurfaces.Values.OrderBy(item => item.Slot).ToArray(),
});
foreach (GfxSurfacePersistenceState surface in gfxSnapshot.Surfaces
.Where(item => item.ReloadOnRestore && item.ResourceId >= 0))
_host.SetTexture(surface.ResourceId, surface.Slot, surface.ColorKey);
}
private static void ReplaceDensePrefix<T>(
@@ -1025,6 +1037,8 @@ public sealed class VirtualMachine
var a = ins.Args;
switch (_t.Label(op))
{
case "script-entry":
Gfx.ClearSurfaceReloadPolicies(); return pc + 1;
case "add": Write(a[0], Read(a[1]) + Read(a[2])); return pc + 1;
case "sub": Write(a[0], Read(a[1]) - Read(a[2])); return pc + 1;
case "mul": Write(a[0], Read(a[1]) * Read(a[2])); return pc + 1;

View File

@@ -10,6 +10,11 @@ namespace Age.Engine.Vm;
/// post-exit bytecode can be explored. Leave false for native-faithful execution.</param>
/// <param name="NativeStringCodePage">Encoding used when an opcode measures the engine's byte-string
/// representation. SYS4 defaults to CP932; another container frontend can select its own code page.</param>
/// <param name="CreateObject">Native set:CreateObject profile setting. Together with
/// <paramref name="AutoFreeTextures"/>, controls the optional all-surface release before numbered load.</param>
/// <param name="AutoFreeTextures">Native set:AutoFreeTex profile setting. Himegari defaults this off,
/// so initialized system textures survive a numbered load unless an explicit reload record replaces them.</param>
public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64,
bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false,
int NativeStringCodePage = 932);
int NativeStringCodePage = 932, bool CreateObject = true,
bool AutoFreeTextures = false);