Match native surface persistence policy

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

View File

@@ -1750,9 +1750,9 @@ diagnostic/extended-mode surface.
#### Layout-3 restore mechanics and port correspondence (2026-07-24)
The port tracks the native global banks separately at runtime, captures the marked frame chain, serializes
live surfaces and retained objects, and reloads host textures from the 20-byte surface records. Full load
first replaces each serialized mutable bank prefix while preserving initialization-authored cells beyond
its count, restores history/gfx and retained audio, unwinds the obsolete managed call chain, runs
live surfaces and retained objects, and applies the 20-byte surface records' explicit reload flags. Full
load first replaces each serialized mutable bank prefix while preserving initialization-authored cells
beyond its count, restores history/gfx and retained audio, unwinds the obsolete managed call chain, runs
`CALLBACK_LOAD.BIN` when
the mounted script provider resolves it, starts the saved root at its `0xae` rendezvous, recursively
reconstructs child frames, resumes parents after their saved T2 call sites,
@@ -1775,6 +1775,31 @@ tracks both lifecycles through `0xbf`/`0xc2` and `0xb4`/`0xb6`, serializes them,
track plus saved SFX resources. The new EngineCtx fields and function annotations are applied to the
saved `/v2` image.
The surface records have a related preservation boundary. Resource id is at `+0x00`, packed color key at
`+0x04`, `+0x08` is a reload flag rather than a general presence bit, `+0x0c` remains unknown, and
`+0x10` marks a blank/mutable created surface. `gfx_surface_record_tables_init@0x472700` zeroes both
1,000-record tables and initializes every resource id to `-1`.
`gfx_surface_create_blank@0x477370` writes resource `-1` and created flag one;
`gfx_surface_load_asset@0x477c40` writes resource/color and clears the created flag. Neither operation
changes the reload flag. The layout-3 serializer copies all 20,000 live record bytes verbatim, rather
than deriving reloadability from the presence of an asset.
The restore loop calls `gfx_surface_load_asset` only when the saved reload flag is one and resource id is
nonnegative. Otherwise the already-initialized surface survives. The optional all-surface release is
separately gated by both `set:CreateObject` and `set:AutoFreeTex`; the registered defaults at
`engine_settings_register_defaults@0x46be30` are one and zero respectively, matching Himegari's active
path. Opcode `0x259` is also a real lifecycle operation:
`op_0x259_script_entry_clear_surface_persistence_flags@0x417660` clears record `+0x08` and `+0x0c`
across both native tables at every script entry.
Installed slot 15 records resource `0x3383` with reload flag zero. SYSTEM4 loads that atlas before the
save UI, and BUNKI cuts its reusable choice-box corners, borders, and winged top ornament from it. The
port formerly released all 1,000 host surfaces unconditionally, so post-load BUNKI retained its black
backing but lost the atlas decoration. Restoration now preserves live surfaces and overlays only flagged
reload records by default, while `VmOptions.CreateObject && VmOptions.AutoFreeTextures` reproduces the
native all-release branch. Port-authored saves emit the exact resource/reload/created fields, including
`-1` for unused records, and opcode `0x259` clears the modeled reload policy without releasing textures.
The corrected native functions are renamed/commented in the saved `/v2` image.
### Opcode `0xae` continues numbered-save stack restoration (2026-07-20)
Opcode `0xae` is the load-side rendezvous paired with serialized script-frame state. Its handler,

View File

@@ -924,6 +924,11 @@ The setter get-or-creates the object and writes the complete operand. During ret
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x24e_handler@0x425070 writes operand 1 directly to EngineCtx.gfx_animation_service_flags at +0x51b80. The mapped field is also read by op 0x243: bit 1 suppresses its force-complete/clock-reset request.
### 0x259 `script-entry` (script-entry, argc 0)
- **summary:** zero-arg script/prologue entry; clears surface-record persistence fields +0x08 and +0x0c across both 1,000-record native tables before the declaration chain continues
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0x259_script_entry_clear_surface_persistence_flags@0x417660 loops over both 20,000-byte surface-record tables (1,000 records x 20 bytes), writing zero to record +0x08 and +0x0c. SC0000 offset 0x0 and every observed script entry begins with this opcode; 0x258 continues the declaration chain. Port clears its modeled +0x08 reload policy; +0x0c remains opaque/unmodeled.
## input
### 0x86 `set-cursor-resource` (u0041B210, argc 1)
@@ -1149,11 +1154,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting
- **summary:** 2 imm; runs in a chain right after script-entry 0x259, enumerating ids — prologue declaration/registration?
- **grounding:** source=harness, confidence=low, noop_headless=True
### 0x259 `script-entry` (u00416410, argc 0)
- **summary:** zero-arg; the first instruction of a script (offset 0), opens the decl chain that 0x258 continues — script/prologue entry marker, structural
- **grounding:** source=harness, confidence=low, noop_headless=True
- **evidence:** SC0000 offset 0x0 = op 0x259 (argc 0); 0x258's summary names it 'script-entry 0x259'; VM treats it as no-op (default stub) across all 279 CLEAN A0 scenes
## unknown
### 0x2 `exit` (exit, argc 0)

View File

@@ -3597,6 +3597,37 @@ then confirmed that CHMENU, unit management, SELSTAGE, and FORT audio populate c
visible load-specific discrepancy is the reusable choice-box frame losing its top winged flourish after
restoration; that is the next independent investigation slice.
### Persistence implementation step 9 — preserve flag-zero system surfaces (2026-07-24)
The post-load choice-box discrepancy came from the graphics half of the same ownership rule. BUNKI builds
the common modal frame from SYSTEM4's texture slot 15: resource `0x3383` supplies its corners, borders,
selection bar, and winged top ornament. The installed layout-3 record retains that resource id but stores
zero at record `+0x08`.
Native RE confirms `+0x08` is a reload flag, not general surface presence.
`save_data_deserialize_and_begin_restore@0x40fd10` calls `gfx_surface_load_asset` only for flag-one
records. Flag-zero system surfaces remain live unless a separate all-1,000 release path is enabled by
both `set:CreateObject` and `set:AutoFreeTex`. The port instead released every host surface and cleared
the complete managed surface map before applying only flag-one records. That removed slot 15, leaving
BUNKI's black backing but none of its atlas decoration.
Full load now preserves the current surface registry, overlays explicitly reloadable saved records, and
still replaces retained objects and the range transform. Regressions retain an initialized flag-zero
slot across a synthetic full load, prove that it is not redundantly reopened, and pin the installed
slot-15 resource/reload-flag oracle. The `/v2` deserializer annotation records the corrected ownership
boundary and the program is saved. Manual slot-000 acceptance confirms the stage-entry and later BUNKI
choices retain the complete winged frame.
The follow-up fidelity pass closes the two deferred policy gaps. `VmOptions` now carries the native
`CreateObject`/`AutoFreeTex` settings with Himegari's registered defaults (`true`/`false`), and enables
the all-1,000 host/model release only when both are true. Surface snapshots carry a named per-slot reload
bit; ordinary texture loads no longer manufacture it. Native encoding initializes unused resource ids to
`-1`, preserves resource/color/reload/created fields, and decoding distinguishes stored records from the
subset that must actually be reopened. Opcode `0x259`, formerly treated as a structural marker, is now
mapped and implemented as the script-entry clear of record `+0x08`/`+0x0c` (the port models `+0x08`).
Regressions cover native-default preservation, opt-in all-release, flag-for-flag port-authored output,
script-entry clearing, and the installed all-zero reload-bit oracle.
## Data-semantics sidebar: focused append EBINIT inspection (2026-07-24)
The static INIT surface now accepts a universal packed script id for focused append inspection.

View File

@@ -477,7 +477,10 @@ the `0xae` rendezvous. The real `SAVE.BIN` script is covered end to end for list
read-only installed-save gate now continues through `CALLBACK_LOAD`, reconstructs
`SYSTEM4.BIN → FORT.BIN`, and reaches FORT's `CHMENU` gameplay poll. Full restoration replaces only the
serialized mutable bank prefixes (preserving initialized unit/stage/string definitions), restores the
retained BGM/SFX state, and rebuilds graphics/history/frame state. JSON inspection/export,
retained BGM/SFX state, preserves initialized flag-zero system surfaces while overlaying explicit saved
reload records, reproduces the native configuration-gated all-surface release when explicitly enabled,
and writes native per-slot reload/created metadata rather than treating every texture as reloadable.
Opcode `0x259` supplies its real script-entry reload-policy clear. JSON inspection/export,
namespaced mod data, and migrations remain additive extended-mode work rather than 1.0 compatibility
requirements.

View File

@@ -252,11 +252,27 @@ Retained graphics then uses:
... native allocation slack
```
The 1,000 surface records preserve resource reload state; mapped fields include resource id at `+0x00`,
packed color key at `+0x04`, and presence at `+0x08`. A retained `0x2d4` record is structurally complete
but not every internal graphics field is semantically named. The native allocation is larger than the
records actually written (`0x2e1 + object_count * 0x2d8` DWORDs in the graphics sizing term), leaving
zero/slack bytes after the meaningful range record.
The 1,000 surface records preserve the native 20-byte metadata cells verbatim:
| Offset | Size | Meaning |
|---:|---:|---|
| `+0x00` | 4 | signed resource id; `-1` means no reloadable asset |
| `+0x04` | 4 | packed color key |
| `+0x08` | 4 | reload-on-restore flag |
| `+0x0c` | 4 | unknown lifecycle field; cleared with `+0x08` at script entry |
| `+0x10` | 4 | blank/mutable created-surface flag |
Initialization writes resource id `-1` to every otherwise-zero record. Ordinary asset load, blank-surface
creation, and release do not synthesize or clear the reload flag; opcode `0x259` clears `+0x08` and
`+0x0c` across both live/saved tables at script entry. During normal layout-3 restoration AGE reopens only
records whose reload flag is one and whose resource id is nonnegative. Flag-zero records describe
surfaces expected to remain live across the load. The installed file is decisive: all 1,000 reload flags
are zero, while SYSTEM4's reusable choice-frame atlas remains recorded as resource `0x3383` in slot 15.
A separate configuration-gated path can release all 1,000 surfaces before this loop, but Himegari's
registered `CreateObject=1`, `AutoFreeTex=0` defaults leave it inactive. A retained `0x2d4`
record is structurally complete but not every internal graphics field is semantically named. The native
allocation is larger than the records actually written (`0x2e1 + object_count * 0x2d8` DWORDs in the
graphics sizing term), leaving zero/slack bytes after the meaningful range record.
The installed `SAVE00.DAT` validates the complete layout-3 decode: cutoff 1, global-bank counts
`[402459,1,789,1,1,1]`, current BGM id `0x18`, retained SFX ids `0x3321` (channel 1) and

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);

View File

@@ -77,6 +77,5 @@ INFERRED: dict[int, dict] = {
0x23b: dict(name='draw-decimal-glyphs', category='draw', noop=False, confidence='high', source='investigation', summary='Draw an integer as decimal glyph objects from a style registered by opcode 0x13a.'),
0x23f: dict(name='query-surface-stop-time-ms', category='draw', noop=False, confidence='high', source='investigation', summary='(out_stop_time_ms)(surface_slot) — query the DirectShow stop position retained by a loaded movie surface, convert seconds to integer milliseconds by truncating toward zero, and write -1 when the movie slot is empty. Port-only host decoder failure is modeled as an explicitly completed, zero-duration movie.'),
0x258: dict(name='decl?', category='marker', noop=True, confidence='low', source='harness', summary='2 imm; runs in a chain right after script-entry 0x259, enumerating ids — prologue declaration/registration?'),
0x259: dict(name='script-entry', category='marker', noop=True, confidence='low', source='harness', summary='zero-arg; the first instruction of a script (offset 0), opens the decl chain that 0x258 continues — script/prologue entry marker, structural'),
0x2c5: dict(name='byte-string-length', category='compute', noop=False, confidence='high', source='investigation', summary="Write the resolved NUL-terminated engine string's raw byte length."),
}

View File

@@ -6735,19 +6735,19 @@ observed_types = ["imm"]
[[opcode]]
op = 0x259
label = "u00416410"
label = "script-entry"
argc = 0
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "script-entry"
category = "marker"
summary = "zero-arg; the first instruction of a script (offset 0), opens the decl chain that 0x258 continues — script/prologue entry marker, structural"
noop_headless = true
source = "harness"
confidence = "low"
category = "draw"
summary = "zero-arg script/prologue entry; clears surface-record persistence fields +0x08 and +0x0c across both 1,000-record native tables before the declaration chain continues"
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
evidence = "SC0000 offset 0x0 = op 0x259 (argc 0); 0x258's summary names it 'script-entry 0x259'; VM treats it as no-op (default stub) across all 279 CLEAN A0 scenes"
evidence = "Ghidra /v2: op_0x259_script_entry_clear_surface_persistence_flags@0x417660 loops over both 20,000-byte surface-record tables (1,000 records x 20 bytes), writing zero to record +0x08 and +0x0c. SC0000 offset 0x0 and every observed script entry begins with this opcode; 0x258 continues the declaration chain. Port clears its modeled +0x08 reload policy; +0x0c remains opaque/unmodeled."
[[opcode]]
op = 0x2bd