Implement forced BGM opcodes

This commit is contained in:
gamer147
2026-07-29 12:38:54 -04:00
parent cbd1521872
commit e24fcfff1f
14 changed files with 259 additions and 45 deletions

View File

@@ -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 | | `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 | | `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 | | `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) | | `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) | | `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) | | `0xa0cc8` | `screen_bpp` | `int` | screen bpp (8) |

View File

@@ -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 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. 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, `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; 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. durations at least 1000 ms take 100 steps, shorter durations take 10, and target zero releases the source.

View File

@@ -201,6 +201,27 @@ The requested CP932 face is copied into the primary LOGFONT lfFaceName and AGE a
- **grounding:** source=investigation, confidence=high - **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. - **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) ### 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. - **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 - **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 - **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. - **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) ### 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. - **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 - **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 - **grounding:** source=kelebek, confidence=low
- **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. - **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) ### 0xbb `u0041D250` (u0041D250, argc 1)
- **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. - **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it.
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -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. **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 ## Later Phase B breadth
**INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static **INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static

View File

@@ -255,7 +255,7 @@ root/call-script parsing). Generated asset/callscript JSON remains a tooling and
| `run <file.BIN>` | 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**. | | `run <file.BIN>` | 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 <out.json>` | 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 <out.json>` | 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 <SCENE.BIN> [--boot] [--state <f>] [0xADDR=VAL…] --trace-json <out>` | ★ 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 <f>` 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. | | `trace <SCENE.BIN> [--boot] [--state <f>] [0xADDR=VAL…] --trace-json <out>` | ★ 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 <f>` 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 <SCENE.BIN> [0xADDR=VAL…]` | Dump executed `play-bgm`/`play-voice` in order + resolved catalog record. | optional seeds. provider-less (stub) for now. | | `audio <SCENE.BIN> [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] <SCENE.BIN> [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`. | | `gfx [--boot] <SCENE.BIN> [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 <f>] [--save-state <f>] <SCENE.BIN…> [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**. | | `play [--boot] [--state <f>] [--save-state <f>] <SCENE.BIN…> [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). | | | `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). | |

View File

@@ -352,6 +352,13 @@ sealed class AudioTraceHost : IHost
var entry = _res.ResolveBgm(id); var entry = _res.ResolveBgm(id);
Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG <missing>")); Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG <missing>"));
} }
public void RestartBgm(long id, int startMode)
{
var entry = _res.ResolveBgm(id);
string resolved = entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG <missing>";
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 public void PlayVoice(long id) // voice: universal packed catalog id
{ {
var e = _res.ResolveVoice(id); var e = _res.ResolveVoice(id);

View File

@@ -30,6 +30,41 @@ public class SfxOpsTests
Assert.Equal("exit", vm.HaltReason); Assert.Equal("exit", vm.HaltReason);
Assert.Equal([24L, 25L, 25L], host.BgmTracks); Assert.Equal([24L, 25L, 25L], host.BgmTracks);
Assert.Equal((0, 0L), Assert.Single(host.BgmFades)); 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<Operand>()),
(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<Operand>()),
}, Array.Empty<string>());
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] [Fact]

View File

@@ -43,6 +43,8 @@ internal class RecordingHost : IHost
public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new(); public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new();
public readonly List<int> SfxReleases = new(); public readonly List<int> SfxReleases = new();
public readonly List<long> BgmTracks = new(); public readonly List<long> 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 Target, long Duration)> BgmFades = new();
public readonly List<(int Category, int BasisPoints)> AudioVolumeChanges = new(); public readonly List<(int Category, int BasisPoints)> AudioVolumeChanges = new();
public readonly List<(int Category, bool Enabled)> AudioRouteChanges = 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)); => TextureDraws.Add((slot, sx, sy, w, h, dx, dy));
public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) => BgmTracks.Add(id); 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) => Voices.Add(id);
public void PlayVoice(long id, int playbackVariant) public void PlayVoice(long id, int playbackVariant)
{ {

View File

@@ -137,6 +137,11 @@ public interface IHost
void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY);
(int Width, int Height) GetTextureSize(int slot); (int Width, int Height) GetTextureSize(int slot);
void PlayBgm(long id); 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); void PlayVoice(long id);
// Native voice playback retains a second start argument: ordinary dialogue passes 0, // 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. // while History replay (0x1bd) passes 1. Existing non-audio hosts may ignore it.

View File

@@ -786,6 +786,17 @@ public sealed class VirtualMachine
_host.ResetSceneContext(); _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) private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0)
{ {
ExecFrame? previousInteractiveFrame; ExecFrame? previousInteractiveFrame;
@@ -2421,6 +2432,16 @@ public sealed class VirtualMachine
} }
return pc + 1; 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": case "get-current-bgm-track":
Write(a[0], _currentBgmTrackId); Write(a[0], _currentBgmTrackId);
return pc + 1; return pc + 1;
@@ -2464,7 +2485,11 @@ public sealed class VirtualMachine
{ {
int targetPercent = (int)Read(a[0]); int targetPercent = (int)Read(a[0]);
_host.FadeBgm(targetPercent, Read(a[1])); _host.FadeBgm(targetPercent, Read(a[1]));
if (targetPercent == 0) _currentBgmTrackId = 0; if (targetPercent == 0)
{
_currentBgmTrackId = 0;
_host.StopBgm();
}
return pc + 1; return pc + 1;
} }
case "get-audio-volume": // 0xc5 (category)(out basis points) case "get-audio-volume": // 0xc5 (category)(out basis points)

View File

@@ -1584,11 +1584,33 @@ public sealed class GodotAdvHost : IHost
// ---- audio ops (OGG plays natively in Godot) ---- // ---- audio ops (OGG plays natively in Godot) ----
// BGM is addressed by direct name (BGM{id:D3}.OGG); voice uses the universal packed catalog. // BGM is addressed by direct name (BGM{id:D3}.OGG); voice uses the universal packed catalog.
public void PlayBgm(long id) 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 asset = _res.ResolveBgm(id);
var audio = asset != null ? LoadAudio(asset) : null; var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("bgm", new() { ["id"] = id, ["file"] = audio?.Name }); _timeline?.Event("bgm", new()
if (audio != null) _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); {
["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); public void PlayVoice(long id) => PlayVoice(id, 0);

View File

@@ -1829,12 +1829,19 @@ public partial class Main : Godot.Control
px[i + 3] = 0; 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) 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); var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; } 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 // 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 // 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 // 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(); _bgm.Play();
} }
public void StopBgm()
{
RestoreVoiceBgmDuck();
CancelBgmFade();
_bgm.Stop();
_bgm.Stream = null;
_bgm.VolumeDb = 0;
}
private void PersistAudioMixerSettings(AudioMixerSettingsSnapshot snapshot) private void PersistAudioMixerSettings(AudioMixerSettingsSnapshot snapshot)
{ {
try try
@@ -2268,8 +2284,15 @@ public partial class Main : Godot.Control
bool bgmReplacementCancelsFade = bgmFadeStarted bool bgmReplacementCancelsFade = bgmFadeStarted
&& _bgmFadeTween == null && _bgmFadeTween == null
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f; && System.Math.Abs(_bgm.VolumeDb) < 0.001f;
_bgm.Stop(); RestartBgm(bgm.Bytes, bgm.Name, 0);
_bgm.Stream = null; 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 AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
{ {
RenderMode = 1, RenderMode = 1,
@@ -2337,18 +2360,22 @@ public partial class Main : Godot.Control
new Sys4LogicalCanvas(_screenWidth, _screenHeight)); new Sys4LogicalCanvas(_screenWidth, _screenHeight));
textEffectSmoke.QueueFree(); textEffectSmoke.QueueFree();
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
&& bgmReplacementCancelsFade && textEffectModesOk && fontCalibrationOk && bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk
&& bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk
&& logicalCanvasOk; && logicalCanvasOk;
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " + $"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
$"sleep-min=1ms; native-key-translation=ok; cp932-wav-info=ok; " + $"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}; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}"); $"window-request={_windowOptions.Width}x{_windowOptions.Height}");
else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " + else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " +
$"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}; " + $"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}; " +
$"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " + $"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " +
$"bgm-fade-replacement={bgmReplacementCancelsFade}; " + $"bgm-fade-replacement={bgmReplacementCancelsFade}; " +
$"bgm-one-shot={bgmOneShotModeOk}; bgm-loop={bgmLoopModeOk}; " +
$"bgm-stop-release={bgmStopReleaseOk}; " +
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}"); $"window-request={_windowOptions.Width}x{_windowOptions.Height}");

View File

@@ -563,6 +563,11 @@ name = "current_bgm_track_id"
type = "int" 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" 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]] [[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 offset = 0xa0cc0
name = "screen_w" name = "screen_w"
type = "int" type = "int"

View File

@@ -1804,60 +1804,63 @@ observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0xb7 op = 0xb7
label = "u0041D0E0" label = "restart-bgm-loop"
argc = 1 argc = 1
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u0041D0E0" name = "restart-bgm-loop"
category = "unknown" category = "audio"
summary = "" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "track_or_zero"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]
op = 0xb8 op = 0xb8
label = "u00415520" label = "stop-bgm"
argc = 0 argc = 0
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00415520" name = "stop-bgm"
category = "unknown" category = "audio"
summary = "" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode]]
op = 0xb9 op = 0xb9
label = "u0041D140" label = "restart-bgm-once"
argc = 1 argc = 1
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u0041D140" name = "restart-bgm-once"
category = "unknown" category = "audio"
summary = "" 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 noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "track_or_zero"
observed_types = ["imm"] observed_types = ["imm"]
[[opcode]] [[opcode]]
@@ -1938,6 +1941,7 @@ source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] 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." 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]] [[opcode.semantics.args]]
i = 1 i = 1