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 |
| `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) |

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
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.

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
- **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

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.
**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

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**. |
| `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. |
| `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`. |
| `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). | |

View File

@@ -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 <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
{
var e = _res.ResolveVoice(id);

View File

@@ -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<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]

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> SfxReleases = 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 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)
{

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);
(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.

View File

@@ -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)

View File

@@ -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);

View File

@@ -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}");

View File

@@ -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"

View File

@@ -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