From e24fcfff1f73137a4e3e241ae017b977d1e9398e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 29 Jul 2026 12:38:54 -0400 Subject: [PATCH] Implement forced BGM opcodes --- docs/engine-ctx-reference.md | 1 + docs/engine-re.md | 33 +++++++++++++++++ docs/opcode-reference.md | 35 +++++++++++------- docs/phase-b-framework.md | 35 ++++++++++++++++++ docs/tools-reference.md | 2 +- engine/Age.Cli/Program.cs | 7 ++++ engine/Age.Engine.Tests/SfxOpsTests.cs | 35 ++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 4 +++ engine/Age.Engine/Hosting/IHost.cs | 5 +++ engine/Age.Engine/Vm/VirtualMachine.cs | 27 +++++++++++++- godot/GodotAdvHost.cs | 26 ++++++++++++-- godot/Main.cs | 39 ++++++++++++++++---- vm-map/engine-ctx.toml | 5 +++ vm-map/opcodes.toml | 50 ++++++++++++++------------ 14 files changed, 259 insertions(+), 45 deletions(-) diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 0867ea7..c9c359e 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -117,6 +117,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `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 | +| `0xa0b8c` | `current_bgm_start_mode` | `int` | logical playback mode retained by the music facade and forwarded to its backend: zero stops at decoder EOF, nonzero rewinds; ordinary op 0xbf and forced op 0xb7 use one, forced op 0xb9 uses zero | | `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 1a3b2da..4387633 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2826,6 +2826,39 @@ move and repeatedly throughout enemy turns. The VM now treats a current-track re A different id still starts a new track, and `0xc2` target zero clears the retained id so a later request can start that track normally. +**Forced BGM lifecycle siblings (mapped 2026-07-29).** The remaining `0xb7`/`0xb8`/`0xb9` gap is one +coherent BGM tranche: + +- `op_0xb7_force_play_bgm_loop@0x4202d0` calls `bgm_force_play_track@0x464a90` with start mode one. + A nonzero operand replaces and starts that track; zero restarts the retained current track. Unlike + ordinary `0xbf`, the force worker has no same-track early return. CALLBACK_LOAD uses zero after a + numbered-load callback, and CONFIG uses BGM029 as its music-preview sample. +- `op_0xb8_stop_bgm@0x416ba0` calls `bgm_stop_release@0x464690`, clearing the retained track and stopping + the active backend. CONFIG uses it when leaving the preview path; MMODE uses it before constructing the + music-room screen. +- `op_0xb9_force_play_bgm_once@0x420330` calls the same force worker with start mode zero. Its sole + Himegari site starts BGM025 as STAGECLEAR's one-shot fanfare. + +All three first complete and cancel an active `0xc2` fade by clearing run-state bit `0x200` and calling +`bgm_fade_tick(...,100)`. Both start variants store their mode at +`EngineCtx.current_bgm_start_mode` (`ctx+0xa0b8c`) before invoking the backend. Himegari's OGG backend +forwards that value through `FUN_00482f00` to `sound_buffer_start`; the already-decoded +`sound_stream_fill_quarter@0x483b70` rewinds at EOF only when the mode is nonzero. Thus the distinction is +logical loop versus one-shot, not a mixer flag. A compatible implementation must extend the existing BGM +host seam with explicit restart and loop ownership, preserve `0xbf`'s current idempotence, treat operand +zero through the VM's retained track, and make `0xb8` clear that retained state. + +The port now implements that split directly. `IHost.RestartBgm(track,startMode)` is distinct from ordinary +`PlayBgm`, so `0xbf` remains same-track-idempotent while `0xb7`/`0xb9` always replace the stream. The VM +resolves operand zero through `_currentBgmTrackId`; zero with no retained track calls the same idempotent +stop path as native. `StopBgm` clears the VM track and makes Godot cancel voice ducking and any deferred +fade, stop the player, release its stream, and restore neutral gain. Godot sets `AudioStreamOggVorbis.Loop` +from the native start mode. The already-mapped `0xc2` target-zero endpoint now also calls this seam after +its blocking fade, replacing the former silent-but-still-bound Godot stream with native release behavior. +A focused regression covers same-track restart, zero aliasing, loop/one-shot selection, track replacement, +stop/clear, fade-to-zero release, and restart after stop; the threaded self-test exercises both loop modes +and real stop/release. + `0xc2` is BGM rather than SFX: `op_0xc2_bgm_fade@0x4204c0` sets run-state `0x200`, arms the service timer, and calls `bgm_fade_arm@0x464830`. `bgm_fade_tick@0x464960` linearly interpolates current to target percent; durations at least 1000 ms take 100 steps, shorter durations take 10, and target zero releases the source. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index cfc1420..74c41cf 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -201,6 +201,27 @@ The requested CP932 face is copied into the primary LOGFONT lfFaceName and AGE a - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra op 0xb6 handler 0x420250 -> sfx_channel_release@0x482600 -> sound_buffer_destroy@0x4831a0, which releases the per-channel object under its critical section and clears the slot. Native trace captured SC0000's channels 0..9 release sweep in consecutive calls. +### 0xb7 `restart-bgm-loop` (restart-bgm-loop, argc 1) +- **summary:** (track_or_zero) — force-start looping BGM even when the requested nonzero track already matches the retained current track. A zero operand restarts the retained track; when no track is retained it resolves to an idempotent stop. Any active BGM fade is completed/cancelled first. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xb7_force_play_bgm_loop@0x4202d0 first clears run-state bit 0x200 and forces bgm_fade_tick(...,100) when a fade is active, then calls bgm_force_play_track@0x464a90 with operand 1 and logical start mode 1. Unlike ordinary bgm_play_track@0x464750 (op 0xbf), the force worker has no same-track early return. A nonzero operand replaces EngineCtx.current_bgm_track_id; zero reuses it, or releases when it is also zero. The OGG backend forwards mode 1 through sound_buffer_start; sound_stream_fill_quarter rewinds at EOF only for nonzero mode. Corpus: CALLBACK_LOAD uses zero to restart restored music; CONFIG uses BGM029 for its music preview. + +Implemented: the VM resolves zero through its retained current track and calls the explicit forced-restart host seam with mode 1. Godot replaces the OGG stream with Loop=true and cancels any deferred fade; ordinary op 0xbf keeps its same-track idempotence. + +### 0xb8 `stop-bgm` (stop-bgm, argc 0) +- **summary:** Stop and release the active BGM source and clear the retained current track. Any active BGM fade is completed/cancelled first. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xb8_stop_bgm@0x416ba0 clears run-state bit 0x200 and forces bgm_fade_tick(...,100) when a fade is active, then calls bgm_stop_release@0x464690. The worker clears music-facade current track/status fields and invokes the active backend's stop/release method. Corpus: CONFIG stops the BGM029 preview while closing; MMODE stops current music before building the music-room screen. + +Implemented: the VM clears its retained track and calls StopBgm. Godot cancels voice ducking and any fade, stops playback, releases the stream, and restores neutral gain. + +### 0xb9 `restart-bgm-once` (restart-bgm-once, argc 1) +- **summary:** (track_or_zero) — force-start one-shot BGM even when the requested nonzero track already matches the retained current track. A zero operand restarts the retained track once; when no track is retained it resolves to an idempotent stop. Any active BGM fade is completed/cancelled first. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0xb9_force_play_bgm_once@0x420330 shares op 0xb7's fade-finalization and bgm_force_play_track@0x464a90 path but supplies logical start mode 0. The OGG backend forwards that mode through sound_buffer_start; sound_stream_fill_quarter pads/stops at decoder EOF instead of rewinding. Corpus: STAGECLEAR's sole site force-starts BGM025 as its one-shot clear fanfare. + +Implemented: the VM shares op 0xb7's retained-track/zero handling but supplies mode 0. Godot replaces the OGG stream with Loop=false, so BGM025 naturally stops at EOF. + ### 0xba `sfx-start-loop` (sfx-start-loop, argc 1) - **summary:** (channel) — start the already-loaded sound-effect channel with logical loop mode 1; the streaming decoder rewinds at EOF until the channel is replaced or released. - **grounding:** source=investigation, confidence=high @@ -220,6 +241,8 @@ The requested CP932 face is copied into the primary LOGFONT lfFaceName and AGE a - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra op 0xc2 handler 0x4204c0 sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830; bgm_fade_tick@0x464960 interpolates current/target percent and applies volume, releasing at target 0. Native SC0000 trace at 0x7c1/0x126c shows target 0, duration 3000, 1% ticks at about 30 ms. +Implemented through the blocking host fade clock. Target zero clears the VM's retained track after the wait and invokes the shared BGM stop/release seam, so Godot does not retain a silent bound stream. + ### 0xc4 `play-voice` (play-voice, argc 1) - **summary:** Play a voice clip by universal packed SYS4INI/AAI id with native playback/history variant 0. While all-message Skip is active, retain/replace the queued voice request instead of starting it; playback resumes from the latest queued request after Skip clears. Uses the same packed-id opener as textures/scripts/movies, unlike play-bgm's direct BGM{id:03d} naming. - **grounding:** source=investigation, confidence=high @@ -1729,18 +1752,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **grounding:** source=kelebek, confidence=low - **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. -### 0xb7 `u0041D0E0` (u0041D0E0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0xb8 `u00415520` (u00415520, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - -### 0xb9 `u0041D140` (u0041D140, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0xbb `u0041D250` (u0041D250, argc 1) - **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 3d50a6f..3079ccf 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -874,6 +874,41 @@ effectful inventory from 15 distinct opcodes / 22 instructions to 12 / 16. **NEXT:** rerank the remaining 12 effectful opcode gaps and investigate the widest coherent slice. +**Remaining-gap rerank and forced-BGM investigation (2026-07-29):** a fresh scan of all 481 scripts +confirms 12 effectful gaps totaling 16 instructions. `0xb7`, `0xb8`, `0x142`, and `0x24d` occur twice +each; `0xb9`, `0x137`, `0x144`, `0x149`, `0x241`, `0x248`, `0x2c6`, and `0x2c8` are singletons. Raw +frequency no longer identifies a subsystem, but native RE does: `0xb7`/`0xb8`/`0xb9` are the complete +forced-BGM lifecycle around the already-implemented `0xbf`/`0xc2` path. + +`0xb7(track_or_zero)` force-restarts looping playback, including the same retained track and the zero +operand's "restart current" form; `0xb9(track_or_zero)` is the corresponding one-shot start; and `0xb8` +stops/releases and clears the retained track. Each first completes/cancels a pending BGM fade. CONFIG's +BGM029 preview needs the force-loop/stop pair, CALLBACK_LOAD uses zero to restart restored music, MMODE +stops current music on entry, and STAGECLEAR uses the one-shot form for BGM025. The existing Godot music +player and retained VM track provide the whole implementation seam; it needs an explicit loop/restart +request while ordinary `0xbf` remains same-track-idempotent. + +**NEXT:** implement and test `0xb7`/`0xb8`/`0xb9` together. The likely follow-up is INPUTNAME's CP932 +string/UI cluster (`0x144`, `0x2c6`, `0x2c8`), but its modal host contract should be finished only after +the bounded BGM tranche lands. + +**Forced-BGM lifecycle implemented (2026-07-29):** `0xb7` and `0xb9` now force-replace the active BGM +stream with logical loop and one-shot mode respectively, including zero's native alias to the retained +track; `0xb8` stops/releases and clears that track. Ordinary `0xbf` remains idempotent for same-track +FIELD cleanup. Godot cancels deferred fades on replacement, applies the start mode through the OGG loop +flag, and makes stop release the stream rather than leaving a silent source. The same stop seam now closes +`0xc2` target zero after its blocking fade, matching native source release instead of retaining a muted +Godot stream. + +All five sites now dispatch, reducing the effectful-gap inventory from 12 opcodes / 16 instructions to +9 / 11. A focused VM regression covers forced same-track restart, zero reuse, both modes, replacement, +stop/clear, and restart after stop. All 483 engine tests, opcode/EngineCtx lint, the zero-warning Godot +build, and the Himegari-targeted threaded self-test pass; the self-test now also validates OGG loop mode +and true stop/release. + +**NEXT:** investigate INPUTNAME's `0x144`/`0x2c6`/`0x2c8` cluster as one modal name-entry and CP932 +string slice. + ## Later Phase B breadth **INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 45b977a..e3020ec 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -255,7 +255,7 @@ root/call-script parsing). Generated asset/callscript JSON remains a tooling and | `run ` | Execute a script; print steps, show-text count, **call-script dispatch count**, the first 30 lines (each tagged with its source script), and the distinct source scripts. | `CaptureHost` (headless); **executes call-script**. | | `trace ` | Trace every SC/SP scene → offsets + halt + steps. **Provider-less** (call-script stubbed) = a base-ISA offset dump. | writes JSON. (Was the vm0 differential oracle; vm0 is retired from oracle duty — `TraceDiffTests` removed.) | | `trace [--boot] [--state ] [0xADDR=VAL…] --trace-json ` | ★ Emit the **full per-op executed-offset path** of one scene (not just show-text), filtered to the scene's own frame — the VM side of the differential offset-path oracle (`diff_optrace.py`). `--boot` runs the SYSTEM4 state prefix; **`--state ` loads a captured scene-entry snapshot** (`capture_global_writes.py`) = the engine's real pre-scene state; `0xADDR=VAL` hand-seeds. | `JsonOffsetTraceSink` (observe-only, parity held) → `{scene, offsets:[…]}` JSON. | -| `audio [0xADDR=VAL…]` | Dump executed `play-bgm`/`play-voice` in order + resolved catalog record. | optional seeds. provider-less (stub) for now. | +| `audio [0xADDR=VAL…]` | Dump executed ordinary/forced/stopped BGM and `play-voice` in order + resolved catalog record; forced BGM events distinguish loop and one-shot mode. | optional seeds. provider-less (stub) for now. | | `gfx [--boot] [0xADDR=VAL…]` | Dump executed `set-texture`/`get-texture-size`/`draw-texture` (resolved file + computed geometry) **plus the per-object gfx slots** — the headless geometry oracle. **`--boot`** runs SYSTEM4's state prefix (`INITCONFIG/INIT2/INIT`) via `GameSession` first (so INIT2's gfx handle array is present) and runs the target with call-script on; without it, seeds-only + provider-less. | gfx ops now execute against `GfxState`. | | `play [--boot] [--state ] [--save-state ] [0xADDR=VAL…]` | ★ Cross-scene **state runner**: run a scene sequence carrying persistent globals. `--boot` first runs the 9 `*INIT` data scripts (real skill/item/unit/map/stage state). `--state`/`--save-state` load/persist a JSON snapshot. | `GameSession`; **executes call-script**. | | `sweep [--boot] [0xADDR=VAL…]` | Corpus-scale run. **With call-script execution on: 284/297 exit, 13 STEP-LIMIT** (input/state-gated ADV scenes spin headless once subroutine global-writes drive their loops — state divergence, not a bug; 0 depth-cap/unresolved). **With seeds = a story-state explorer**: reports which scenes' dialogue changes ±seed (e.g. form flag `0xa57=1` → 34/297 scenes). | | diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index eb83020..07145e2 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -352,6 +352,13 @@ sealed class AudioTraceHost : IHost var entry = _res.ResolveBgm(id); Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG ")); } + public void RestartBgm(long id, int startMode) + { + var entry = _res.ResolveBgm(id); + string resolved = entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG "; + Events.Add((startMode == 0 ? "restart-bgm-once" : "restart-bgm-loop", id, resolved)); + } + public void StopBgm() => Events.Add(("stop-bgm", 0, "")); public void PlayVoice(long id) // voice: universal packed catalog id { var e = _res.ResolveVoice(id); diff --git a/engine/Age.Engine.Tests/SfxOpsTests.cs b/engine/Age.Engine.Tests/SfxOpsTests.cs index f4fb57c..d71f99c 100644 --- a/engine/Age.Engine.Tests/SfxOpsTests.cs +++ b/engine/Age.Engine.Tests/SfxOpsTests.cs @@ -30,6 +30,41 @@ public class SfxOpsTests Assert.Equal("exit", vm.HaltReason); Assert.Equal([24L, 25L, 25L], host.BgmTracks); Assert.Equal((0, 0L), Assert.Single(host.BgmFades)); + Assert.Equal(1, host.BgmStops); + } + + [Fact] + public void ForcedBgmTrioRestartsWithNativeModesReusesCurrentAndStops() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + const int currentAfterStop = 0x100; + const int currentAfterRestart = 0x101; + var script = ScriptAssembler.Assemble(table, "BGM-FORCE", + new List<(int, Operand[])> + { + (0xbf, new[] { new Operand(0, 24) }), + (0xb7, new[] { new Operand(0, 24) }), // same track still restarts + (0xb7, new[] { new Operand(0, 0) }), // zero aliases retained track + (0xb9, new[] { new Operand(0, 0) }), // retained track, one-shot mode + (0xb9, new[] { new Operand(0, 25) }), // nonzero replaces retained track + (0xb8, Array.Empty()), + (0xc0, new[] { new Operand(3, currentAfterStop) }), + (0xb7, new[] { new Operand(0, 0) }), // no retained track: idempotent stop + (0xbf, new[] { new Operand(0, 25) }), + (0xc0, new[] { new Operand(3, currentAfterRestart) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal([24L, 25L], host.BgmTracks); + Assert.Equal([(24L, 1), (24L, 1), (24L, 0), (25L, 0)], host.BgmRestartRequests); + Assert.Equal(2, host.BgmStops); + Assert.Equal(0, vm.Globals[currentAfterStop]); + Assert.Equal(25, vm.Globals[currentAfterRestart]); } [Fact] diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index bf92f19..bb29681 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -43,6 +43,8 @@ internal class RecordingHost : IHost public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new(); public readonly List SfxReleases = new(); public readonly List BgmTracks = new(); + public readonly List<(long Track, int StartMode)> BgmRestartRequests = new(); + public int BgmStops; public readonly List<(int Target, long Duration)> BgmFades = new(); public readonly List<(int Category, int BasisPoints)> AudioVolumeChanges = new(); public readonly List<(int Category, bool Enabled)> AudioRouteChanges = new(); @@ -175,6 +177,8 @@ internal class RecordingHost : IHost => TextureDraws.Add((slot, sx, sy, w, h, dx, dy)); public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public void PlayBgm(long id) => BgmTracks.Add(id); + public void RestartBgm(long id, int startMode) => BgmRestartRequests.Add((id, startMode)); + public void StopBgm() => BgmStops++; public void PlayVoice(long id) => Voices.Add(id); public void PlayVoice(long id, int playbackVariant) { diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 8c9cc84..95eea15 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -137,6 +137,11 @@ public interface IHost void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); (int Width, int Height) GetTextureSize(int slot); void PlayBgm(long id); + // Ordinary op 0xbf is VM-filtered so reasserting the current track is idempotent. Ops + // 0xb7/0xb9 deliberately bypass that guard and restart with logical loop/one-shot mode. + void RestartBgm(long id, int startMode) => PlayBgm(id); + // Op 0xb8 releases the current source rather than merely applying a zero-volume envelope. + void StopBgm() => FadeBgm(0, 0); void PlayVoice(long id); // Native voice playback retains a second start argument: ordinary dialogue passes 0, // while History replay (0x1bd) passes 1. Existing non-audio hosts may ignore it. diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index dcd7b1f..f7da7be 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -786,6 +786,17 @@ public sealed class VirtualMachine _host.ResetSceneContext(); } + private void RestartBgm(long requestedTrackId, int startMode) + { + if (requestedTrackId != 0) + _currentBgmTrackId = requestedTrackId; + + if (_currentBgmTrackId == 0) + _host.StopBgm(); + else + _host.RestartBgm(_currentBgmTrackId, startMode); + } + private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0) { ExecFrame? previousInteractiveFrame; @@ -2421,6 +2432,16 @@ public sealed class VirtualMachine } return pc + 1; } + case "restart-bgm-loop": // 0xb7: force start, including current-track/zero alias + RestartBgm(Read(a[0]), 1); + return pc + 1; + case "stop-bgm": // 0xb8: release source and clear retained track + _currentBgmTrackId = 0; + _host.StopBgm(); + return pc + 1; + case "restart-bgm-once": // 0xb9: force start without decoder rewind at EOF + RestartBgm(Read(a[0]), 0); + return pc + 1; case "get-current-bgm-track": Write(a[0], _currentBgmTrackId); return pc + 1; @@ -2464,7 +2485,11 @@ public sealed class VirtualMachine { int targetPercent = (int)Read(a[0]); _host.FadeBgm(targetPercent, Read(a[1])); - if (targetPercent == 0) _currentBgmTrackId = 0; + if (targetPercent == 0) + { + _currentBgmTrackId = 0; + _host.StopBgm(); + } return pc + 1; } case "get-audio-volume": // 0xc5 (category)(out basis points) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 12bcb9e..5f62710 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1584,11 +1584,33 @@ public sealed class GodotAdvHost : IHost // ---- audio ops (OGG plays natively in Godot) ---- // BGM is addressed by direct name (BGM{id:D3}.OGG); voice uses the universal packed catalog. public void PlayBgm(long id) + => DispatchBgm(id, 1, false); + + public void RestartBgm(long id, int startMode) + => DispatchBgm(id, startMode, true); + + private void DispatchBgm(long id, int startMode, bool forceRestart) { var asset = _res.ResolveBgm(id); var audio = asset != null ? LoadAudio(asset) : null; - _timeline?.Event("bgm", new() { ["id"] = id, ["file"] = audio?.Name }); - if (audio != null) _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); + _timeline?.Event("bgm", new() + { + ["id"] = id, + ["file"] = audio?.Name, + ["start_mode"] = startMode, + ["force_restart"] = forceRestart, + }); + if (audio == null) return; + if (forceRestart) + _main.CallDeferred("RestartBgm", audio.Bytes, audio.Name, startMode); + else + _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); + } + + public void StopBgm() + { + _timeline?.Event("bgm-stop", new()); + _main.CallDeferred("StopBgm"); } public void PlayVoice(long id) => PlayVoice(id, 0); diff --git a/godot/Main.cs b/godot/Main.cs index cd9c475..c9604ee 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -1829,12 +1829,19 @@ public partial class Main : Godot.Control px[i + 3] = 0; } - // Decode VFS-owned bytes in Godot. BGM loops; voice plays once, cutting off any prior line. + // Decode VFS-owned bytes in Godot. Ordinary BGM loops; forced starts retain AGE's + // loop/one-shot mode. Voice plays once, cutting off any prior line. public void PlayBgm(byte[] oggBytes, string assetName) + => StartBgm(oggBytes, assetName, 1); + + public void RestartBgm(byte[] oggBytes, string assetName, int startMode) + => StartBgm(oggBytes, assetName, startMode); + + private void StartBgm(byte[] oggBytes, string assetName, int startMode) { var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes); if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; } - stream.Loop = true; + stream.Loop = startMode != 0; // FadeBgm is marshalled from the VM thread and starts on the next Godot frame, while the VM's // blocking deadline begins immediately. Scene startup can consequently request the replacement // track just before the old fade tween reaches zero. Do not let that orphaned tween mute the new @@ -1845,6 +1852,15 @@ public partial class Main : Godot.Control _bgm.Play(); } + public void StopBgm() + { + RestoreVoiceBgmDuck(); + CancelBgmFade(); + _bgm.Stop(); + _bgm.Stream = null; + _bgm.VolumeDb = 0; + } + private void PersistAudioMixerSettings(AudioMixerSettingsSnapshot snapshot) { try @@ -2268,8 +2284,15 @@ public partial class Main : Godot.Control bool bgmReplacementCancelsFade = bgmFadeStarted && _bgmFadeTween == null && System.Math.Abs(_bgm.VolumeDb) < 0.001f; - _bgm.Stop(); - _bgm.Stream = null; + RestartBgm(bgm.Bytes, bgm.Name, 0); + bool bgmOneShotModeOk = (_bgm.Stream as AudioStreamOggVorbis)?.Loop == false; + RestartBgm(bgm.Bytes, bgm.Name, 1); + bool bgmLoopModeOk = (_bgm.Stream as AudioStreamOggVorbis)?.Loop == true; + StopBgm(); + bool bgmStopReleaseOk = _bgm.Stream == null + && !_bgm.Playing + && _bgmFadeTween == null + && System.Math.Abs(_bgm.VolumeDb) < 0.001f; AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with { RenderMode = 1, @@ -2337,18 +2360,22 @@ public partial class Main : Godot.Control new Sys4LogicalCanvas(_screenWidth, _screenHeight)); textEffectSmoke.QueueFree(); ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk - && bgmReplacementCancelsFade && textEffectModesOk && fontCalibrationOk + && bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk + && bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk && logicalCanvasOk; if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + $"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " + $"sleep-min=1ms; native-key-translation=ok; cp932-wav-info=ok; " + - $"bgm-fade-replacement=ok; text-effect-modes=ok; font-calibration=ok; " + + $"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " + + $"text-effect-modes=ok; font-calibration=ok; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}"); else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " + $"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}; " + $"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " + $"bgm-fade-replacement={bgmReplacementCancelsFade}; " + + $"bgm-one-shot={bgmOneShotModeOk}; bgm-loop={bgmLoopModeOk}; " + + $"bgm-stop-release={bgmStopReleaseOk}; " + $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}"); diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 07916d9..26664f2 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -563,6 +563,11 @@ 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 = 0xa0b8c +name = "current_bgm_start_mode" +type = "int" +note = "logical playback mode retained by the music facade and forwarded to its backend: zero stops at decoder EOF, nonzero rewinds; ordinary op 0xbf and forced op 0xb7 use one, forced op 0xb9 uses zero" +[[field]] offset = 0xa0cc0 name = "screen_w" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index c265a27..7f9ebc6 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1804,60 +1804,63 @@ observed_types = ["imm"] [[opcode]] op = 0xb7 -label = "u0041D0E0" +label = "restart-bgm-loop" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041D0E0" -category = "unknown" -summary = "" +name = "restart-bgm-loop" +category = "audio" +summary = "(track_or_zero) — force-start looping BGM even when the requested nonzero track already matches the retained current track. A zero operand restarts the retained track; when no track is retained it resolves to an idempotent stop. Any active BGM fade is completed/cancelled first." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xb7_force_play_bgm_loop@0x4202d0 first clears run-state bit 0x200 and forces bgm_fade_tick(...,100) when a fade is active, then calls bgm_force_play_track@0x464a90 with operand 1 and logical start mode 1. Unlike ordinary bgm_play_track@0x464750 (op 0xbf), the force worker has no same-track early return. A nonzero operand replaces EngineCtx.current_bgm_track_id; zero reuses it, or releases when it is also zero. The OGG backend forwards mode 1 through sound_buffer_start; sound_stream_fill_quarter rewinds at EOF only for nonzero mode. Corpus: CALLBACK_LOAD uses zero to restart restored music; CONFIG uses BGM029 for its music preview." +details = "Implemented: the VM resolves zero through its retained current track and calls the explicit forced-restart host seam with mode 1. Godot replaces the OGG stream with Loop=true and cancels any deferred fade; ordinary op 0xbf keeps its same-track idempotence." [[opcode.semantics.args]] i = 1 -role = "" +role = "track_or_zero" observed_types = ["imm"] [[opcode]] op = 0xb8 -label = "u00415520" +label = "stop-bgm" argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415520" -category = "unknown" -summary = "" +name = "stop-bgm" +category = "audio" +summary = "Stop and release the active BGM source and clear the retained current track. Any active BGM fade is completed/cancelled first." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xb8_stop_bgm@0x416ba0 clears run-state bit 0x200 and forces bgm_fade_tick(...,100) when a fade is active, then calls bgm_stop_release@0x464690. The worker clears music-facade current track/status fields and invokes the active backend's stop/release method. Corpus: CONFIG stops the BGM029 preview while closing; MMODE stops current music before building the music-room screen." +details = "Implemented: the VM clears its retained track and calls StopBgm. Godot cancels voice ducking and any fade, stops playback, releases the stream, and restores neutral gain." [[opcode]] op = 0xb9 -label = "u0041D140" +label = "restart-bgm-once" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041D140" -category = "unknown" -summary = "" +name = "restart-bgm-once" +category = "audio" +summary = "(track_or_zero) — force-start one-shot BGM even when the requested nonzero track already matches the retained current track. A zero operand restarts the retained track once; when no track is retained it resolves to an idempotent stop. Any active BGM fade is completed/cancelled first." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xb9_force_play_bgm_once@0x420330 shares op 0xb7's fade-finalization and bgm_force_play_track@0x464a90 path but supplies logical start mode 0. The OGG backend forwards that mode through sound_buffer_start; sound_stream_fill_quarter pads/stops at decoder EOF instead of rewinding. Corpus: STAGECLEAR's sole site force-starts BGM025 as its one-shot clear fanfare." +details = "Implemented: the VM shares op 0xb7's retained-track/zero handling but supplies mode 0. Godot replaces the OGG stream with Loop=false, so BGM025 naturally stops at EOF." [[opcode.semantics.args]] i = 1 -role = "" +role = "track_or_zero" observed_types = ["imm"] [[opcode]] @@ -1938,6 +1941,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra op 0xc2 handler 0x4204c0 sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830; bgm_fade_tick@0x464960 interpolates current/target percent and applies volume, releasing at target 0. Native SC0000 trace at 0x7c1/0x126c shows target 0, duration 3000, 1% ticks at about 30 ms." +details = "Implemented through the blocking host fade clock. Target zero clears the VM's retained track after the wait and invokes the shared BGM stop/release seam, so Godot does not retain a silent bound stream." [[opcode.semantics.args]] i = 1