From 7a11a3853d978ffc6cede22bbeb37bcb8133ea8f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 24 Jul 2026 18:43:21 -0400 Subject: [PATCH] Restore complete numbered save state --- docs/engine-ctx-reference.md | 2 + docs/engine-re.md | 30 ++++++- docs/opcode-reference.md | 13 +-- docs/phase-a-slice-plan.md | 51 +++++++++++ docs/remake-architecture-and-roadmap.md | 10 ++- docs/sys4-format-notes.md | 14 ++- .../NativeNumberedSaveCodecTests.cs | 8 +- .../Age.Engine.Tests/NumberedSaveVmTests.cs | 38 +++++++- .../SaveUiIntegrationTests.cs | 89 ++++++++++++++++++- engine/Age.Engine.Tests/TestSupport.cs | 3 +- .../Persistence/NativeNumberedSaveCodec.cs | 21 ++--- engine/Age.Engine/Vm/VirtualMachine.cs | 80 +++++++++++++---- tools/age_opcodes_himegari.py | 1 - vm-map/engine-ctx.toml | 10 +++ vm-map/opcodes.toml | 20 ++--- 15 files changed, 329 insertions(+), 61 deletions(-) diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index daf71ef..4d354ff 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -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) | diff --git a/docs/engine-re.md b/docs/engine-re.md index bde0bb4..883e803 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index dbf0c08..d8062c5 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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 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 diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index b53ecf1..8a2c7f9 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index a641a21..eb44c30 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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 diff --git a/docs/sys4-format-notes.md b/docs/sys4-format-notes.md index c78e5b1..c90cc90 100644 --- a/docs/sys4-format-notes.md +++ b/docs/sys4-format-notes.md @@ -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 diff --git a/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs b/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs index cc1a4f2..3a6bdee 100644 --- a/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs +++ b/engine/Age.Engine.Tests/NativeNumberedSaveCodecTests.cs @@ -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); diff --git a/engine/Age.Engine.Tests/NumberedSaveVmTests.cs b/engine/Age.Engine.Tests/NumberedSaveVmTests.cs index 4836b43..d794a93 100644 --- a/engine/Age.Engine.Tests/NumberedSaveVmTests.cs +++ b/engine/Age.Engine.Tests/NumberedSaveVmTests.cs @@ -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()), (0x19e, [new Operand(GlobalInt, 0x20), new Operand(Immediate, 2)]), (0x2, Array.Empty()), @@ -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(), -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 diff --git a/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs b/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs index daaf34e..bf41767 100644 --- a/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs +++ b/engine/Age.Engine.Tests/SaveUiIntegrationTests.cs @@ -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 Frames = new(); + } + + private sealed class ContinueInstalledLoadHost(InstalledContinuationState state) : ClickFirstSlotHost + { + public override void WaitForInput( + int layoutSlot, Func serviceInputCallback, Func 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(() => 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 ?? ""}:{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() { diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 25448bd..4d1522a 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -41,6 +41,7 @@ internal class RecordingHost : IHost public readonly List SfxStarts = new(); public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new(); public readonly List SfxReleases = new(); + public readonly List 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) { diff --git a/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs b/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs index 4f0ea2a..0ca77d0 100644 --- a/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs +++ b/engine/Age.Engine/Persistence/NativeNumberedSaveCodec.cs @@ -14,8 +14,8 @@ public sealed record NativeSavedGfxObject(long Handle, byte[] Record); public sealed record NativeNumberedSaveState( int SavedFrameOwner, - int EngineState, - IReadOnlyList StateWords, + int BgmTrackId, + IReadOnlyList SoundEffectResourceIds, byte[] ResourceRecords, byte[] SurfaceRecords, IReadOnlyList 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 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(), Array.Empty(), Array.Empty(), @@ -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) diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 54dc0d2..c842560 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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( + Dictionary bank, IReadOnlyList values, Func 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) diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 2b12e3b..32280b3 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -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.'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 3b95404..c81808b 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -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" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 10eca33..b29a2c8 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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]]