From 4db7e412bdae6b0eb295951ae99f2a70e2a1aeb1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 24 Jul 2026 20:28:59 -0400 Subject: [PATCH] Match native surface persistence policy --- docs/engine-re.md | 31 +++++- docs/opcode-reference.md | 10 +- docs/phase-a-slice-plan.md | 31 ++++++ docs/remake-architecture-and-roadmap.md | 5 +- docs/sys4-format-notes.md | 26 ++++- .../NativeNumberedSaveCodecTests.cs | 10 ++ .../Age.Engine.Tests/NumberedSaveVmTests.cs | 96 +++++++++++++++++++ engine/Age.Engine/Model/GfxState.cs | 53 ++++++++-- .../Persistence/NativeGfxPersistenceCodec.cs | 31 +++--- .../Persistence/NativeNumberedSaveCodec.cs | 10 +- engine/Age.Engine/Vm/VirtualMachine.cs | 22 ++++- engine/Age.Engine/Vm/VmOptions.cs | 7 +- tools/age_opcodes_himegari.py | 1 - vm-map/opcodes.toml | 14 +-- 14 files changed, 299 insertions(+), 48 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index 883e803..f5f7136 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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, diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index d8062c5..c33a9cd 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 8a2c7f9..ceb97eb 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index eb44c30..7bd21b8 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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. diff --git a/docs/sys4-format-notes.md b/docs/sys4-format-notes.md index c90cc90..f22b5c1 100644 --- a/docs/sys4-format-notes.md +++ b/docs/sys4-format-notes.md @@ -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 diff --git a/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs b/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs index 3a6bdee..d8aa7ad 100644 --- a/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs +++ b/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs @@ -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 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)))); } } diff --git a/engine/Age.Engine.Tests/NumberedSaveVmTests.cs b/engine/Age.Engine.Tests/NumberedSaveVmTests.cs index d794a93..a2458f8 100644 --- a/engine/Age.Engine.Tests/NumberedSaveVmTests.cs +++ b/engine/Age.Engine.Tests/NumberedSaveVmTests.cs @@ -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()), + ], []); + 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()), + (0x2, Array.Empty()), + ], []); + 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]; diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index ca2e8b2..b2a59a8 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -56,8 +56,11 @@ public sealed record GfxDiagnosticSnapshot( BlockingGfxObjectDiagnostic? BlockingRangeTransform, IReadOnlyList 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 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 _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 _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 _movieStopTimesMs = new(); @@ -496,6 +513,24 @@ public sealed class GfxState } } + /// 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. + public void SetSurfaceReloadOnRestore(int slot, bool reload) + { + lock (_lock) + { + if (reload) _reloadableSurfaces.Add(slot); + else _reloadableSurfaces.Remove(slot); + } + } + + /// 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. + public void ClearSurfaceReloadPolicies() + { + lock (_lock) _reloadableSurfaces.Clear(); + } + /// 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. public void SetMovieStopTime(int slot, long? stopTimeMs) diff --git a/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs b/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs index 0c3c6e8..bb8c1b8 100644 --- a/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs +++ b/engine/Age.Engine/Persistence/NativeGfxPersistenceCodec.cs @@ -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(); 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))) diff --git a/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs b/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs index 0ca77d0..67e674f 100644 --- a/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs +++ b/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs @@ -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(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), 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 payload, int offset, NativeSavedScriptFrame frame, bool terminal) { if (frame.ReturnIndices.Count > FrameReturnCapacity) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index c842560..42cbf4a 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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( @@ -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; diff --git a/engine/Age.Engine/Vm/VmOptions.cs b/engine/Age.Engine/Vm/VmOptions.cs index a9a8d19..90233b2 100644 --- a/engine/Age.Engine/Vm/VmOptions.cs +++ b/engine/Age.Engine/Vm/VmOptions.cs @@ -10,6 +10,11 @@ namespace Age.Engine.Vm; /// post-exit bytecode can be explored. Leave false for native-faithful execution. /// 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. +/// Native set:CreateObject profile setting. Together with +/// , controls the optional all-surface release before numbered load. +/// Native set:AutoFreeTex profile setting. Himegari defaults this off, +/// so initialized system textures survive a numbered load unless an explicit reload record replaces them. 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); diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 32280b3..c74477c 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -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."), } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index b29a2c8..8c7d65a 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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