Restore complete numbered save state

This commit is contained in:
gamer147
2026-07-24 18:43:21 -04:00
parent db41f77eb5
commit 7a11a3853d
15 changed files with 329 additions and 61 deletions

View File

@@ -16,6 +16,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch-
| `0x1c34` | `mouse_wheel_delta` | `int` | signed WM_MOUSEWHEEL delta accumulated by age_main_window_proc; op 0x10d returns and clears it |
| `0x4d7c` | `shared_profile_state` | `void*` | embedded shared SAVE.DAT state object; owns profile integer/settings tables and container timing metadata |
| `0x5190` | `shared_profile_int_table` | `int` | open-addressing 12-byte string-key to 32-bit value table; op 0x1a2 stores, 0x1a3 loads, shared SAVE.DAT serializes it |
| `0x144e0` | `sfx_channel_resource_ids` | `int` | base of ten packed resource ids retained by the sound-effect facade; op 0xb4 loads a channel, op 0xb6 clears it, and numbered-save layouts restore then reopen every positive id |
| `0x14d54` | `gfx_obj_ptr_table` | `void*` | per-object pointer table (ops 0x212/0x213 write obj+0x64/0x68/0x6c) |
| `0x14e08` | `gfx_default_object_slot` | `int` | op 0x80 selected slot; op 0x1d9 substitutes it when its explicit object-slot operand is zero |
| `0x14ea0` | `text_line_spacing` | `int` | extra pixel leading between text lines; defaults to 6, op 0x8b writes it |
@@ -99,6 +100,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch-
| `0x9c660` | `sys4ini_records` | `void*` | SYS4INI 80-byte record base at embedded FileDB+0x414; record = base + id*0x50 |
| `0x9f274` | `mounted_aai_catalogs` | `void*` | base of 256-entry selector-keyed AAI catalog-pointer table inside the embedded FileDB; op 0x143 scans slots 1..255 from +0x9f278 |
| `0x9f278` | `mounted_aai_catalog_selector_1` | `void*` | selector-one cell and op 0x143 scan start; subsequent dwords are selector 2..255 |
| `0xa0b84` | `current_bgm_track_id` | `int` | direct-name BGM track id retained by the music facade; op 0xbf starts/replaces it, ops 0xc0/0xc3 get/set it, and numbered-save layouts restore it |
| `0xa0cc0` | `screen_w` | `int` | logical screen width; constructor/default registry uses 640, then the SYS4INI SCREENX setting overrides it (Himegari 800) |
| `0xa0cc4` | `screen_h` | `int` | logical screen height; constructor/default registry uses 480, then the SYS4INI SCREENY setting overrides it (Himegari 600) |
| `0xa0cc8` | `screen_bpp` | `int` | screen bpp (8) |

View File

@@ -1751,12 +1751,30 @@ diagnostic/extended-mode surface.
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 banks/history/gfx, unwinds the obsolete managed call chain, runs `CALLBACK_LOAD.BIN` when
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,
and finally resumes the terminal frame at its T1 boundary. Successful `0x19e` also flushes shared
`SAVE.DAT`/`RT.DAT`, matching `context_state_serialize`.
The prefix boundary is observable in the installed file: integer count `0x6241b` stops before the
initialization-authored unit/stage definition tables, and string count `0x315` stops exactly before
`unit_story_display_names`. Native `save_data_deserialize_and_begin_restore@0x40fd10` zeroes only those
counted regions; the port's former whole-dictionary clear erased the definitions needed by CHMENU,
unit-management, and SELSTAGE after load.
The fixed audio fields are also resolved. Payload `+0x008` is
`EngineCtx.current_bgm_track_id` (`ctx+0xa0b84`), maintained by
`bgm_play_track@0x464750` and queried by op `0xc0`; the installed FORT save contains `0x18`, matching
FORT's BGM024 command immediately before its T1 resume boundary. Payload `+0x00c..+0x033` copies
`EngineCtx.sfx_channel_resource_ids[10]` (`ctx+0x144e0`), and
`sfx_reload_saved_channels@0x482640` reopens each positive packed id after deserialization. The port now
tracks both lifecycles through `0xbf`/`0xc2` and `0xb4`/`0xb6`, serializes them, and restores the active
track plus saved SFX resources. The new EngineCtx fields and function annotations are applied to 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,
@@ -1776,6 +1794,16 @@ coroutine-resume or call boundaries, including SC0000's main-loop resume sequenc
The port now implements both branches: an ordinary one-instruction no-op outside restoration, and the
T1/T2/T3-driven managed-frame reconstruction described above while a full numbered load is active.
The first interactive installed-save continuation exposed a dispatch-label integration error rather than
a native semantic error. Although `0xae` had this mapped semantic record and a VM case, its opcode-table
label still used upstream placeholder `u00415130`; the VM switches on the label, so the active case was
unreachable. The saved root entered `SYSTEM4.BIN` with restore state intact, but `0xae` fell through and
SYSTEM4 ran its ordinary `LOGO.BIN → OP.BIN → INIT.BIN → TITLE.BIN` boot path. The source label is now
`continue-save-load-stack-restore`. A real installed-save gate executes beyond the rendezvous and proves
`SYSTEM4.BIN → FORT.BIN → CHMENU.BIN` restoration to the gameplay poll. The synthetic two-frame gate
also asserts that its child enters with `FrameCause.SaveRestore`, preventing its ordinary call site from
masking another inactive-branch regression.
### ADV read-message Skip and shared `RT.DAT` history (2026-07-18)
Read-message Skip is backed by an engine-owned `ReadTextDB`, not a VM-global flag and not ordinary numbered

View File

@@ -167,6 +167,11 @@ Port status (2026-07-24): after the blocking host releases this wait, the VM que
- **grounding:** source=investigation, confidence=high
- **evidence:** By-ear confirmed (2026-07-06): SC0000 real game plays BGM005 for play-bgm 0x5 and BGM008 for play-bgm 0x8 (we initially mis-played BGM006/BGM009 via the disproven scene-section model). Direct-name proven by play-bgm 0x23 -> BGM035.OGG, a real standalone track (the BGM set skips 030-034). Ghidra /v2 op_0xbf_handler@0x420390 forwards the numeric track to the BGM facade rather than asset_open_indexed_entry. Diagnostic: `Age.Cli audio SC0000.BIN`.
### 0xc0 `get-current-bgm-track` (get-current-bgm-track, argc 1)
- **summary:** (track_out) - return the direct-name BGM track id retained by the music facade; the same value is restored from numbered saves.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2 op_0xc0_get_current_bgm_track@0x428440 writes EngineCtx.current_bgm_track_id (+0xa0b84) to operand 1. op 0xbf's bgm_play_track retains the selected direct-name id at the same music-facade field; layout-3 serialization copies it to payload +0x008 and restoration copies it back. Installed SAVE00 stores 0x18, matching FORT's BGM024.
### 0xc2 `fade-bgm` (u0041D2B0, argc 2)
- **summary:** (target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion.
- **grounding:** source=investigation, confidence=high
@@ -397,12 +402,12 @@ Implemented as a whole-stack root-reload boundary in the persistent VM. A reques
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0xa3_handler@0x420060 formats operand 1, queries value_dispatch_lookup@0x419290, writes the matched or operand-2 default PC into the current frame, and clears the command type. Corpus pairs it with 0xa1/0xa2 in 12 generic switch sequences.
### 0xae `continue-save-load-stack-restore` (u00415130, argc 0)
### 0xae `continue-save-load-stack-restore` (continue-save-load-stack-restore, argc 0)
- **summary:** () - during serialized save restoration, replace the current frame PC with its saved resume/call target and advance through the saved script-context stack; otherwise a no-op.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: op_0xae_continue_save_load_stack_restore@0x416790 first tests ctx+0x53d24 (set by save_data_deserialize_and_begin_restore@0x40fd10). When clear it returns. When set, it selects the serialized frame layout through set:SaveVersion1/2, restores the current PC from that layout's saved return/call target, advances through contexts with script_frame_restore_saved_layout@0x40f2d0, and clears the restore flag on reaching the saved terminal context. Its 305 corpus sites overwhelmingly follow coroutine-resume/call boundaries, which provide the rendezvous points used while reconstructing the stack.
Layout 3 frame d259 indexes SYS4 T1 read-message reset sites, d260 indexes T2 call-script sites, and the saved local return stack indexes T3 local-call sites. Port status (2026-07-24): the active path reconstructs the saved recursive frame chain and resumes the terminal frame at its T1 boundary.
Layout 3 frame d259 indexes SYS4 T1 read-message reset sites, d260 indexes T2 call-script sites, and the saved local return stack indexes T3 local-call sites. Port status (2026-07-24): the active path reconstructs the saved recursive frame chain and resumes the terminal frame at its T1 boundary. The installed SAVE00 continuation gate proves SYSTEM4 -> FORT restoration reaches FORT's CHMENU gameplay poll; the synthetic gate asserts the child frame enters with SaveRestore rather than ordinary CallScript cause.
### 0xc8 `sleep` (sleep, argc 1)
- **summary:** Pause the current script for <duration> milliseconds while retained presentation continues.
@@ -1267,10 +1272,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0xc0 `u00415620` (u00415620, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0xc5 `u0041D4A0` (u0041D4A0, argc 2)
- **summary:** —
- **grounding:** source=kelebek, confidence=low

View File

@@ -3546,6 +3546,57 @@ and unnamed retained-gfx fields outside the confirmed Himegari compatibility cla
This documentation-only closeout does not change runtime persistence. The next functional check remains
continuing from the installed slot through the restored gameplay frame.
### Persistence implementation step 7 — installed gameplay continuation correction (2026-07-24)
Interactive loading of slot 000 reached `SYSTEM4.BIN` as a restore but then played `LOGO.BIN` and
`OP.BIN` before returning to a broken TITLE state. The installed-save regression had stopped on frame
entry, before executing the saved root's `0xae`, while the synthetic two-frame test accidentally made
the same child call through its ordinary script path.
The restore state and decoded frames were correct (`SYSTEM4.BIN` root plus terminal `FORT.BIN`). The
actual integration error was the opcode registry: `0xae` had the implemented semantic case but retained
placeholder label `u00415130`, and VM dispatch selects cases by label. It therefore behaved as an
unhandled stub. The source label is now `continue-save-load-stack-restore` and generated opcode
references were rebuilt.
The synthetic regression now requires the child to enter with `FrameCause.SaveRestore` and rejects an
ordinary `CallScript` entry. A new read-only installed-save continuation gate traverses the real
`SAVE.BIN → CALLBACK_LOAD.BIN → SYSTEM4.BIN → FORT.BIN → CHMENU.BIN` path and stops at FORT's stable
gameplay poll; the original AppData files remain untouched.
Validation: all 387 engine tests pass, opcode lint reports zero errors/warnings, the Godot C# build has
zero warnings, and the threaded headless run reports `SELFTEST OK`. The next acceptance action is a
visual retry of slot 000 in the rebuilt port.
### Persistence implementation step 8 — preserve initialized banks and restore retained audio (2026-07-24)
The first successful FORT continuation exposed a second load-state boundary. CHMENU retained party-slot
shells but lacked names/definition data, SELSTAGE listed no stages, unit-management was blank, and the
TITLE BGM continued playing. Reaching FORT through the developer launcher populated the same consumers,
localizing the issue to restoration rather than their rendering opcodes.
Layout 3 stores mutable prefixes, not the complete initialized typed banks. The installed integer count
is `0x6241b`, before unit/stage definitions including `0x66716` and `0xe8275`; the installed string count
is `0x315`, exactly where `unit_story_display_names` begins. Native deserialization zeroes only the
counted prefixes. The port instead cleared whole dictionaries, erasing the initialization-authored tail.
Full load now removes and replaces only keys below each saved count, including saved zeroes, and preserves
every initialized key at or above the boundary.
Native RE also resolves the fixed audio words. Payload `+0x008` is the current direct-name BGM track;
the installed value `0x18` matches FORT's skipped BGM024 instruction. The following ten DWORDs are packed
SFX resource ids; the installed save retains SE015 and SE020 in channels 1 and 2. VM audio operations now
track these values, numbered saves emit them, and full restoration replaces the BGM plus reloads retained
SFX channels. Opcode `0xc0` is mapped as the current-BGM query. Ghidra `/v2` names/comments the BGM
getter/setter/player and saved-SFX reload worker, includes the two new EngineCtx fields, and is saved.
Regressions cover saved-zero replacement, preservation at integer/string prefix boundaries, BGM/SFX
round trips, the `0xc0` query, and installed SAVE00 continuation with its matching append-install mask.
Validation: all 387 engine tests pass, opcode and EngineCtx generation/lints are clean, the Godot C#
build has zero warnings, and the threaded headless run reports `SELFTEST OK`. Manual slot-000 acceptance
then confirmed that CHMENU, unit management, SELSTAGE, and FORT audio populate correctly. The remaining
visible load-specific discrepancy is the reusable choice-box frame losing its top winged flourish after
restoration; that is the next independent investigation slice.
## 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

@@ -473,9 +473,13 @@ import/export and the packed-script/T1 ReadTextDB queue/commit/query lifecycle a
including `message:ReadTextSkip` ops `0x1ca`/`0x1cb` and state query `0x1cc`. Numbered active-frame state
is now implemented in native layout 3: metadata query, paired `.DAT`/`.STH` lifecycle, exact native BMP
thumbnail I/O, six global banks, retained surface/gfx state, history, and nested frame restoration through
the `0xae` rendezvous. The real `SAVE.BIN` script is covered end to end for listing and loading, including
a read-only installed-save compatibility gate. JSON inspection/export, namespaced mod data, and migrations
remain additive extended-mode work rather than 1.0 compatibility requirements.
the `0xae` rendezvous. The real `SAVE.BIN` script is covered end to end for listing and loading; the
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,
namespaced mod data, and migrations remain additive extended-mode work rather than 1.0 compatibility
requirements.
### Phase C — Externalize & modding foundation
- Add **editable named data overlays** mapped explicitly onto the VM's `*INIT`-produced state; external

View File

@@ -198,8 +198,8 @@ The decoded layout-3 body begins:
|---:|---:|---|
| `0x000` | 4 | terminal saved-frame index, called `cutoff`; frame count is `cutoff + 1` |
| `0x004` | 4 | saved frame-owner/context word |
| `0x008` | 4 | engine-state word |
| `0x00c` | `0x28` | ten engine/context state DWORDs |
| `0x008` | 4 | current direct-name BGM track id |
| `0x00c` | `0x28` | ten packed SFX resource ids, one per retained channel |
| `0x034` | `0x4b0` | 100 resource-reload records of three DWORDs |
| `0x4e4` | `0x4e20` | 1,000 surface-reload records of 20 bytes |
| `0x5304` | `(cutoff + 1) * 0x414` | saved script-frame records |
@@ -233,6 +233,13 @@ The integer and float arrays follow their counts. String count comes from bank c
begins with an additional `string_blob_dwords` followed by that many bytes of concatenated NUL-terminated
CP932 strings and DWORD padding. The three pointer-family DWORD arrays follow.
These counts are serialized mutable-prefix lengths, not declarations that every typed runtime bank ends
there. Native deserialization zeroes and replaces only each counted prefix. Initialization-authored
definitions after the prefix remain live: in Himegari the saved integer prefix ends at `0x6241b`, before
unit/stage definition tables such as `0x66716` and `0xe8275`, while the saved string prefix ends at
`0x315`, exactly where `unit_story_display_names` begins. Clearing the whole runtime dictionary during
import therefore destroys data that is intentionally absent from the numbered file.
Retained graphics then uses:
```text
@@ -252,7 +259,8 @@ records actually written (`0x2e1 + object_count * 0x2d8` DWORDs in the graphics
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]`, and 211 retained graphics objects.
`[402459,1,789,1,1,1]`, current BGM id `0x18`, retained SFX ids `0x3321` (channel 1) and
`0x2aea` (channel 2), and 211 retained graphics objects.
#### Appended text-history tail

View File

@@ -14,8 +14,8 @@ public class NativeNumberedSaveCodecTests
NativeNumberedSaveState state = NativeNumberedSaveCodec.Empty(frames) with
{
SavedFrameOwner = 7,
EngineState = 9,
StateWords = Enumerable.Range(10, 10).ToArray(),
BgmTrackId = 9,
SoundEffectResourceIds = Enumerable.Range(10, 10).ToArray(),
IntegerGlobals = new[] { 12, -3, 0x12345678 },
FloatGlobals = new[] { BitConverter.SingleToInt32Bits(1.25f) },
StringGlobals = new[] { "姫狩り", "", "save" },
@@ -38,8 +38,8 @@ public class NativeNumberedSaveCodecTests
NativeNumberedSaveState decoded = NativeNumberedSaveCodec.Decode(encoded);
Assert.Equal(state.SavedFrameOwner, decoded.SavedFrameOwner);
Assert.Equal(state.EngineState, decoded.EngineState);
Assert.Equal(state.StateWords, decoded.StateWords);
Assert.Equal(state.BgmTrackId, decoded.BgmTrackId);
Assert.Equal(state.SoundEffectResourceIds, decoded.SoundEffectResourceIds);
Assert.Equal(state.Frames[0].ParentContext, decoded.Frames[0].ParentContext);
Assert.Equal(state.Frames[0].ScriptId, decoded.Frames[0].ScriptId);
Assert.Equal(state.Frames[0].ReturnIndices, decoded.Frames[0].ReturnIndices);

View File

@@ -22,6 +22,9 @@ public class NumberedSaveVmTests
var store = new DirectoryNativeDatStore(root, Identity);
Script script = WithPackedId(ScriptAssembler.Assemble(Table, "SAVE_TEST.BIN",
[
(0xbf, [new Operand(Immediate, 24)]),
(0xc0, [new Operand(GlobalInt, 0x21)]),
(0xb4, [new Operand(Immediate, 0x3321), new Operand(Immediate, 1)]),
(0x1ad, Array.Empty<Operand>()),
(0x19e, [new Operand(GlobalInt, 0x20), new Operand(Immediate, 2)]),
(0x2, Array.Empty<Operand>()),
@@ -40,10 +43,13 @@ public class NumberedSaveVmTests
vm.Run();
Assert.Equal(0, vm.Globals[0x20]);
Assert.Equal(24, vm.Globals[0x21]);
NativeNumberedSaveFile file = store.LoadNumberedFile(2)!;
NativeNumberedSaveState state = NativeNumberedSaveCodec.Decode(file.Document.Payload);
Assert.Equal(0x6241b, state.IntegerGlobals.Count);
Assert.Equal(456, state.IntegerGlobals[0x123]);
Assert.Equal(24, state.BgmTrackId);
Assert.Equal(0x3321, state.SoundEffectResourceIds[1]);
Assert.Equal("姫狩り", state.StringGlobals[4]);
Assert.Equal(0x77u, state.Frames.Single().ScriptId);
Assert.Contains(state.GfxObjects, item => item.Handle == 100);
@@ -97,6 +103,12 @@ public class NumberedSaveVmTests
new NativeSavedScriptFrame(0, 0x89, Array.Empty<int>(), -1, -1),
]) with
{
BgmTrackId = 24,
SoundEffectResourceIds =
[
0, 0x3321, 0x2aea, 0, 0,
0, 0, 0, 0, 0,
],
IntegerGlobals = DenseIntBank(0x124, (0x123, 456)),
FloatGlobals = [BitConverter.SingleToInt32Bits(3.5f)],
StringGlobals = ["復帰"],
@@ -113,8 +125,10 @@ public class NumberedSaveVmTests
1, NativeNumberedSaveCodec.Encode(state), NativeTextHistoryCodec.Encode(history),
NativeSystemTime.FromLocalDateTime(DateTime.Now), 123);
var liveHistory = new AdvTextHistory();
var trace = new RecordingTraceSink();
var host = new RecordingHost();
var vm = new VirtualMachine(
loader, Table, new RecordingHost(), provider: new MapProvider(
loader, Table, host, provider: new MapProvider(
new()
{
[0x88] = resumed,
@@ -124,22 +138,42 @@ public class NumberedSaveVmTests
{
["CALLBACK_LOAD.BIN"] = callback,
}),
textHistory: liveHistory, nativeDatStore: store);
sink: trace, textHistory: liveHistory, nativeDatStore: store);
vm.Globals[0x10] = 999;
vm.Globals[0x123] = 999;
vm.Globals[0x124] = 777;
vm.GlobalStrings[0] = "stale";
vm.GlobalStrings[1] = "static-unit-name";
vm.Run();
Assert.False(vm.Globals.ContainsKey(0x10));
Assert.Equal(456, vm.Globals[0x123]);
Assert.Equal(777, vm.Globals[0x124]);
Assert.Equal(456, vm.Globals[0x500]);
Assert.Equal(456, vm.Globals[0x501]);
Assert.Equal(1, vm.Globals[0x502]);
Assert.Equal("復帰", vm.GlobalStrings[0]);
Assert.Equal(0x123, vm.GlobalPointers[0]);
Assert.Equal("static-unit-name", vm.GlobalStrings[1]);
Assert.Equal([24L], host.BgmTracks);
Assert.Equal(
[(0x3321L, 1), (0x2aeaL, 2)],
host.SfxLoads);
Assert.Equal(Enumerable.Range(0, 10), host.SfxReleases);
Assert.Equal("履歴復帰", liveHistory.Records.Single().Text);
Assert.Equal(3, vm.Gfx.QuerySlot(100));
RenderObject restoredObject = Assert.Single(vm.Gfx.SnapshotVisibleObjects());
Assert.Equal(0x1234, restoredObject.SurfaceResId);
Assert.Equal((50, 60), (restoredObject.DstX, restoredObject.DstY));
Assert.Contains(trace.Events, item =>
item.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter
&& item.Name == "CHILD.BIN"
&& item.Cause == Age.Engine.Diagnostics.FrameCause.SaveRestore);
Assert.DoesNotContain(trace.Events, item =>
item.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter
&& item.Name == "CHILD.BIN"
&& item.Cause == Age.Engine.Diagnostics.FrameCause.CallScript);
Assert.Equal("exit", vm.HaltReason);
}
finally

View File

@@ -9,6 +9,8 @@ public class SaveUiIntegrationTests
{
private sealed class MenuReadyException : Exception;
private sealed class InstalledResumeReachedException : Exception;
private sealed class InstalledGameplayPollReachedException : Exception;
private sealed class InstalledRootReloadReachedException(string message) : Exception(message);
private sealed class StopAtFirstMenuPollHost : RecordingHost
{
@@ -19,7 +21,7 @@ public class SaveUiIntegrationTests
}
}
private sealed class ClickFirstSlotHost : RecordingHost
private class ClickFirstSlotHost : RecordingHost
{
private long _now;
private bool _pressed;
@@ -45,6 +47,53 @@ public class SaveUiIntegrationTests
}
}
private sealed class InstalledContinuationState(int expectedRestoreFrames)
{
public int RestoreFrameCount;
public bool TerminalRestoreEntered => RestoreFrameCount >= expectedRestoreFrames;
public readonly List<string> Frames = new();
}
private sealed class ContinueInstalledLoadHost(InstalledContinuationState state) : ClickFirstSlotHost
{
public override void WaitForInput(
int layoutSlot, Func<bool> serviceInputCallback, Func<AdvAutoWaitState> autoWaitState)
{
if (state.TerminalRestoreEntered) throw new InstalledGameplayPollReachedException();
base.WaitForInput(layoutSlot, serviceInputCallback, autoWaitState);
}
public override void Sleep(long duration)
{
base.Sleep(duration);
if (state.TerminalRestoreEntered) throw new InstalledGameplayPollReachedException();
}
}
private sealed class InstalledContinuationSink(InstalledContinuationState state) : ITraceSink
{
public bool TracingSteps => false;
public void Emit(in TraceEvent item)
{
if (item.Kind == TraceEventKind.FrameEnter)
{
state.Frames.Add($"+{item.Name}:{item.Cause}");
if (item.Cause == FrameCause.SaveRestore
&& !string.Equals(item.Name, "CALLBACK_LOAD.BIN",
StringComparison.OrdinalIgnoreCase))
state.RestoreFrameCount++;
if (item.Cause == FrameCause.RootReload)
throw new InstalledRootReloadReachedException(
string.Join(", ", state.Frames));
}
else if (item.Kind == TraceEventKind.FrameExit)
{
state.Frames.Add($"-{item.Name}:{item.Text}");
}
}
}
private sealed class SaveUiProvider(
Sys4ScriptProvider native,
Script? resumed,
@@ -224,12 +273,50 @@ public class SaveUiIntegrationTests
sharedProfile: sharedProfile,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x4] = unchecked((uint)sharedProfile.LoadInteger(0x5c3));
vm.Globals[0x6241b] = 1;
vm.Globals[0x696] = 0;
Assert.Throws<InstalledResumeReachedException>(() => vm.Run());
}
[Fact]
public void InstalledSave00ContinuesToRestoredGameplayPollWhenPresent()
{
string root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Eushully", "姫狩りダンジョンマイスター", "SAVE");
if (!File.Exists(Path.Combine(root, "SAVE00.DAT"))) return;
var scripts = Sys4ScriptProvider.Load(Table);
var store = new DirectoryNativeDatStore(root, Identity);
NativeNumberedSaveState numbered = NativeNumberedSaveCodec.Decode(
store.LoadNumberedFile(0)!.Document.Payload);
var state = new InstalledContinuationState(numbered.Frames.Count);
var host = new ContinueInstalledLoadHost(state);
var sharedProfile = new SharedProfile();
Assert.True(sharedProfile.Load(store));
var vm = new VirtualMachine(
scripts.RequireByName("SAVE.BIN"), Table, host,
new VmOptions(MaxSteps: 1_000_000), scripts,
new InstalledContinuationSink(state),
sharedProfile: sharedProfile,
nativeDatStore: store);
host.Vm = vm;
vm.Globals[0x4] = unchecked((uint)sharedProfile.LoadInteger(0x5c3));
vm.Globals[0x6241b] = 1;
vm.Globals[0x696] = 0;
Exception? outcome = Record.Exception(() => vm.Run());
Assert.True(outcome is InstalledGameplayPollReachedException,
$"outcome={outcome?.GetType().Name ?? "<none>"}:{outcome?.Message}; " +
$"halt={vm.HaltReason}; steps={vm.Steps}; waits={host.Waits}; " +
$"movies={string.Join(",", host.ModalMovies.Select(item => item.Resource))}; " +
$"saved={string.Join(", ", numbered.Frames.Select((frame, index) =>
$"{index}:0x{frame.ScriptId:x}/resume={frame.ResumeIndex}/call={frame.CallTargetIndex}"))}; " +
$"frames={string.Join(", ", state.Frames)}");
}
private static Script WithPackedId(Script source, uint packedId)
=> new()
{

View File

@@ -41,6 +41,7 @@ internal class RecordingHost : IHost
public readonly List<int> SfxStarts = new();
public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new();
public readonly List<int> SfxReleases = new();
public readonly List<long> BgmTracks = new();
public readonly List<(int Target, long Duration)> BgmFades = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
public System.Action? OnPlayMovie;
@@ -149,7 +150,7 @@ internal class RecordingHost : IHost
public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy)
=> TextureDraws.Add((slot, sx, sy, w, h, dx, dy));
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayBgm(long id) => BgmTracks.Add(id);
public void PlayVoice(long id) => Voices.Add(id);
public void PlayVoice(long id, int playbackVariant)
{

View File

@@ -14,8 +14,8 @@ public sealed record NativeSavedGfxObject(long Handle, byte[] Record);
public sealed record NativeNumberedSaveState(
int SavedFrameOwner,
int EngineState,
IReadOnlyList<int> StateWords,
int BgmTrackId,
IReadOnlyList<int> SoundEffectResourceIds,
byte[] ResourceRecords,
byte[] SurfaceRecords,
IReadOnlyList<NativeSavedScriptFrame> Frames,
@@ -30,7 +30,7 @@ public sealed record NativeNumberedSaveState(
int RangeTransformCount,
byte[] RangeTransformRecord)
{
public const int StateWordCount = 10;
public const int SoundEffectChannelCount = 10;
public const int ResourceRecordsSize = 300 * 4;
public const int SurfaceRecordsSize = 20_000;
public const int GfxRecordSize = 0x2d4;
@@ -65,8 +65,8 @@ public static class NativeNumberedSaveCodec
WriteInt(payload, 0, cutoff);
WriteInt(payload, 4, state.SavedFrameOwner);
WriteInt(payload, 8, state.EngineState);
WriteIntList(payload, 0x0c, state.StateWords);
WriteInt(payload, 8, state.BgmTrackId);
WriteIntList(payload, 0x0c, state.SoundEffectResourceIds);
state.ResourceRecords.CopyTo(payload, 0x34);
state.SurfaceRecords.CopyTo(payload, 0x4e4);
@@ -119,7 +119,8 @@ public static class NativeNumberedSaveCodec
int fixedBytes = checked(0x5718 + cutoff * FrameSize);
Require(payload, 0, fixedBytes, "numbered-save fixed state");
int[] stateWords = ReadInts(payload, 0x0c, NativeNumberedSaveState.StateWordCount);
int[] soundEffects = ReadInts(
payload, 0x0c, NativeNumberedSaveState.SoundEffectChannelCount);
byte[] resources = payload.Slice(0x34, NativeNumberedSaveState.ResourceRecordsSize).ToArray();
byte[] surfaces = payload.Slice(0x4e4, NativeNumberedSaveState.SurfaceRecordsSize).ToArray();
var frames = new NativeSavedScriptFrame[cutoff + 1];
@@ -167,14 +168,14 @@ public static class NativeNumberedSaveCodec
byte[] rangeRecord = payload.Slice(at + 8, gfxRecordSize).ToArray();
return new NativeNumberedSaveState(
ReadInt(payload, 4), ReadInt(payload, 8), stateWords, resources, surfaces, frames,
ReadInt(payload, 4), ReadInt(payload, 8), soundEffects, 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],
0, 0, new int[NativeNumberedSaveState.SoundEffectChannelCount],
new byte[NativeNumberedSaveState.ResourceRecordsSize],
new byte[NativeNumberedSaveState.SurfaceRecordsSize],
frames, Array.Empty<int>(), Array.Empty<int>(), Array.Empty<string>(),
@@ -239,8 +240,8 @@ public static class NativeNumberedSaveCodec
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.SoundEffectResourceIds.Count != NativeNumberedSaveState.SoundEffectChannelCount)
throw new InvalidDataException("Numbered-save sound-effect channel 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)

View File

@@ -40,6 +40,9 @@ public sealed class VirtualMachine
private NativeNumberedSaveState? _loadedNumberedState;
private NativeNumberedSaveState? _retainedNativeNumberedState;
private int _restoreFrameIndex = -1;
private long _currentBgmTrackId;
private readonly long[] _loadedSoundEffectResourceIds =
new long[NativeNumberedSaveState.SoundEffectChannelCount];
private uint _accumulatedPlaySeconds;
private readonly long _sessionStartTimestamp;
private ExecFrame? _debugActiveFrame;
@@ -819,6 +822,9 @@ public sealed class VirtualMachine
var gfx = NativeGfxPersistenceCodec.Capture(Gfx);
return basis with
{
BgmTrackId = unchecked((int)_currentBgmTrackId),
SoundEffectResourceIds = _loadedSoundEffectResourceIds
.Select(id => unchecked((int)id)).ToArray(),
Frames = frames,
IntegerGlobals = DenseValues(Globals, himegariIntegerCount),
FloatGlobals = DenseValues(GlobalFloats, himegariFloatCount),
@@ -870,21 +876,24 @@ public sealed class VirtualMachine
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];
ReplaceDensePrefix(
Globals, state.IntegerGlobals.Select(value => (long)value).ToArray(), value => value != 0);
ReplaceDensePrefix(
GlobalFloats, state.FloatGlobals.Select(value => (long)value).ToArray(), value => value != 0);
ReplaceDensePrefix(GlobalStrings, state.StringGlobals, value => value.Length != 0);
ReplaceDensePrefix(GlobalPointers, state.PointerGlobals, value => value != 0);
ReplaceDensePrefix(GlobalStringPointers, state.PointerStrings, value => value != 0);
_currentBgmTrackId = unchecked((uint)state.BgmTrackId);
if (_currentBgmTrackId == 0) _host.FadeBgm(0, 0);
else _host.PlayBgm(_currentBgmTrackId);
for (int channel = 0; channel < _loadedSoundEffectResourceIds.Length; channel++)
{
_host.ReleaseSoundEffect(channel);
long resourceId = unchecked((uint)state.SoundEffectResourceIds[channel]);
_loadedSoundEffectResourceIds[channel] = resourceId;
if (resourceId != 0) _host.LoadSoundEffect(resourceId, channel);
}
GfxPersistenceSnapshot gfxSnapshot = NativeGfxPersistenceCodec.Decode(state);
for (int slot = 0; slot < 1000; slot++) _host.ReleaseSurface(slot);
@@ -895,6 +904,15 @@ public sealed class VirtualMachine
}
}
private static void ReplaceDensePrefix<T>(
Dictionary<int, T> bank, IReadOnlyList<T> values, Func<T, bool> retain)
{
foreach (int key in bank.Keys.Where(key => (uint)key < (uint)values.Count).ToArray())
bank.Remove(key);
for (int i = 0; i < values.Count; i++)
if (retain(values[i])) bank[i] = values[i];
}
private Script ResolveSavedScript(NativeSavedScriptFrame frame)
{
if (_s.PackedId == frame.ScriptId) return _s;
@@ -2193,7 +2211,13 @@ public sealed class VirtualMachine
case "release-transient-surfaces": // 0x23d: native fixed range [42,1000)
Gfx.ReleaseSurfaceRange(42, 1000 - 42);
_host.ReleaseSurfaceRange(42, 1000 - 42); return pc + 1;
case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1;
case "play-bgm":
_currentBgmTrackId = Read(a[0]);
_host.PlayBgm(_currentBgmTrackId);
return pc + 1;
case "get-current-bgm-track":
Write(a[0], _currentBgmTrackId);
return pc + 1;
case "play-voice":
_autoVoicePending = true;
TextHistory.AppendVoice(Read(a[0]), 0, _advTextStyle);
@@ -2208,15 +2232,33 @@ public sealed class VirtualMachine
case "schedule-voice-playback": // 0x2c0: replace the pending delayed combat voice request
_host.ScheduleVoicePlayback(Read(a[0]), (int)Read(a[1]), Read(a[2])); return pc + 1;
case "play-sound-effect": // 0xb4 / semantics: sfx-load
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;
{
long resourceId = Read(a[0]);
int channel = (int)Read(a[1]);
if ((uint)channel < (uint)_loadedSoundEffectResourceIds.Length)
_loadedSoundEffectResourceIds[channel] = resourceId;
_host.LoadSoundEffect(resourceId, channel);
return pc + 1;
}
case "u0041D050": // 0xb5 / semantics: sfx-start
_host.StartSoundEffect((int)Read(a[0])); return pc + 1;
case "u0041D080": // 0xb6 / semantics: sfx-release
_host.ReleaseSoundEffect((int)Read(a[0])); return pc + 1;
{
int channel = (int)Read(a[0]);
if ((uint)channel < (uint)_loadedSoundEffectResourceIds.Length)
_loadedSoundEffectResourceIds[channel] = 0;
_host.ReleaseSoundEffect(channel);
return pc + 1;
}
case "schedule-sfx-start": // 0x2bf / native SetDelay(channel, start mode, delay ms)
_host.ScheduleSoundEffectStart((int)Read(a[0]), (int)Read(a[1]), Read(a[2])); return pc + 1;
case "u0041D2B0": // 0xc2 / semantics: fade-bgm
_host.FadeBgm((int)Read(a[0]), Read(a[1])); return pc + 1;
{
int targetPercent = (int)Read(a[0]);
_host.FadeBgm(targetPercent, Read(a[1]));
if (targetPercent == 0) _currentBgmTrackId = 0;
return pc + 1;
}
case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000
return pc + 1;
case "get-initial-root-run": // 0x130 (out)

View File

@@ -18,7 +18,6 @@ INFERRED: dict[int, dict] = {
0x93: dict(name='cancel-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary="Reset the current frame's hotspot registry/input wait and clear native run-state bit 0x00800000. Used before opening History, Menu, or HIDEWIN flows."),
0x94: dict(name='arm-hotspot-wait', category='input', noop=True, confidence='high', source='investigation', summary='Arm native hotspot input processing after the script has registered its rectangles.'),
0x97: dict(name='bind-hotspot-key', category='input', noop=True, confidence='high', source='investigation', summary='(x)(y)(w)(h)(logical_action) - find the already-registered rectangle with identical bounds and bind a logical input action to its activation callback.'),
0xae: dict(name='continue-save-load-stack-restore', category='control', noop=False, confidence='high', source='investigation', summary='() - during serialized save restoration, replace the current frame PC with its saved resume/call target and advance through the saved script-context stack; otherwise a no-op.'),
0xb4: dict(name='sfx-load', category='audio', noop=False, confidence='high', source='investigation', summary="(packed_raw_resource_id)(channel) — synchronously open the universal SYS4INI/AAI catalog entry and replace the channel's decoded sound buffer without starting playback. A zero high byte is a raw SYS4INI index; a nonzero high byte selects an append catalog and uses the low 24-bit index. Native manager supports channels 0..12; SC0000 uses 0..9."),
0xb5: dict(name='sfx-start', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback.'),
0xb6: dict(name='sfx-release', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel.'),

View File

@@ -48,6 +48,11 @@ name = "mouse_wheel_delta"
type = "int"
note = "signed WM_MOUSEWHEEL delta accumulated by age_main_window_proc; op 0x10d returns and clears it"
[[field]]
offset = 0x144e0
name = "sfx_channel_resource_ids"
type = "int"
note = "base of ten packed resource ids retained by the sound-effect facade; op 0xb4 loads a channel, op 0xb6 clears it, and numbered-save layouts restore then reopen every positive id"
[[field]]
offset = 0x14d54
name = "gfx_obj_ptr_table"
type = "void*"
@@ -473,6 +478,11 @@ name = "mounted_aai_catalog_selector_1"
type = "void*"
note = "selector-one cell and op 0x143 scan start; subsequent dwords are selector 2..255"
[[field]]
offset = 0xa0b84
name = "current_bgm_track_id"
type = "int"
note = "direct-name BGM track id retained by the music facade; op 0xbf starts/replaces it, ops 0xc0/0xc3 get/set it, and numbered-save layouts restore it"
[[field]]
offset = 0xa0cc0
name = "screen_w"
type = "int"

View File

@@ -1716,7 +1716,7 @@ observed_types = ["imm"]
[[opcode]]
op = 0xae
label = "u00415130"
label = "continue-save-load-stack-restore"
argc = 0
abi_source = "kelebek+decode-validated"
@@ -1724,7 +1724,7 @@ abi_source = "kelebek+decode-validated"
name = "continue-save-load-stack-restore"
category = "control"
summary = "() - during serialized save restoration, replace the current frame PC with its saved resume/call target and advance through the saved script-context stack; otherwise a no-op."
details = "Layout 3 frame d259 indexes SYS4 T1 read-message reset sites, d260 indexes T2 call-script sites, and the saved local return stack indexes T3 local-call sites. Port status (2026-07-24): the active path reconstructs the saved recursive frame chain and resumes the terminal frame at its T1 boundary."
details = "Layout 3 frame d259 indexes SYS4 T1 read-message reset sites, d260 indexes T2 call-script sites, and the saved local return stack indexes T3 local-call sites. Port status (2026-07-24): the active path reconstructs the saved recursive frame chain and resumes the terminal frame at its T1 boundary. The installed SAVE00 continuation gate proves SYSTEM4 -> FORT restoration reaches FORT's CHMENU gameplay poll; the synthetic gate asserts the child frame enters with SaveRestore rather than ordinary CallScript cause."
noop_headless = false
source = "investigation"
confidence = "high"
@@ -1901,23 +1901,23 @@ observed_types = ["imm", "g-int", "l-int", "l-ptr"]
[[opcode]]
op = 0xc0
label = "u00415620"
label = "get-current-bgm-track"
argc = 1
abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00415620"
category = "unknown"
summary = ""
name = "get-current-bgm-track"
category = "audio"
summary = "(track_out) - return the direct-name BGM track id retained by the music facade; the same value is restored from numbered saves."
noop_headless = false
source = "kelebek"
confidence = "low"
source = "investigation"
confidence = "high"
depends_on = []
evidence = ""
evidence = "Ghidra /v2 op_0xc0_get_current_bgm_track@0x428440 writes EngineCtx.current_bgm_track_id (+0xa0b84) to operand 1. op 0xbf's bgm_play_track retains the selected direct-name id at the same music-facade field; layout-3 serialization copies it to payload +0x008 and restoration copies it back. Installed SAVE00 stores 0x18, matching FORT's BGM024."
[[opcode.semantics.args]]
i = 1
role = ""
role = "current BGM track id output"
observed_types = ["l-int"]
[[opcode]]