From 43eb6daa824ae8d66902ae15b667f11b21077ddb Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 28 Jul 2026 13:29:49 -0400 Subject: [PATCH] Fix BGM fade ownership across scene startup --- docs/engine-re.md | 10 +++++++-- docs/phase-b-framework.md | 10 +++++++++ godot/Main.cs | 46 +++++++++++++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index b5a915b..f99c33f 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2567,8 +2567,14 @@ enabled=`1` and target=`50` percent; SC0000 writes only control masks `0` and `1 The port therefore retains `0x1cf` as script-owned runtime state, applies the native registered default attenuation when an unsuppressed voice begins, and restores the saved BGM level when that voice completes -or is stopped by message Skip. This introduces no profile record, boot seed, or persistence backend. A -future unified settings backend can replace the registered defaults without changing the opcode/host seam. +or is stopped by message Skip. Godot also gives an explicit `0xc2` fade exclusive ownership of the BGM +envelope: voice start does not duck during that fade, a new fade cancels its predecessor, and starting a +replacement BGM cancels any still-live prior fade before restoring normal gain. The last rule closes a +main-thread scheduling race at the TITLE-to-SC0000 boundary. The VM's blocking fade deadline begins before +the deferred Godot tween is created, so `BGM005` could start during the tween's final frame and then be +overwritten to `-80 dB`; a later voice temporarily wrote the audible 50-percent duck level and restored +the erroneous silent level afterward. This introduces no profile record, boot seed, or persistence backend. +A future unified settings backend can replace the registered defaults without changing the opcode/host seam. ### Scene-entry state snapshot — auto-seeding single-scene runs (2026-07-09) diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 33326bd..81d947d 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -118,6 +118,16 @@ while Godot treated them as active-script manifest ids. The packed resolver maps through the existing channel players. A synchronized TITLE→GAMESTART→TITLE trace records every load/start with its filename, and manual validation confirms they are audible; BGM remains unaffected. +**TITLE-to-SC0000 BGM fade ownership corrected (2026-07-28).** SC0000's `0xc2@0x7c1` blocks the VM for +the three-second title-music fade, then starts `BGM005` at `0x7fa`. Godot created the actual gain tween +through a deferred main-thread call, however, so its start could lag the already-running virtual deadline +by one frame. On the unlucky schedule, `BGM005` restored unity gain just before that stale title tween +finished and wrote `-80 dB`. Voice ducking exposed the diagnosis: a voice wrote the configured 50-percent +level, making the live BGM audible, then restored the captured silent level. The Godot backend now owns +exactly one explicit BGM fade, cancels it before replacing the track or starting another fade, and suppresses +voice ducking while the fade owns the envelope as native AGE does. The threaded selftest starts a live +fade and proves a replacement BGM cancels it and retains normal gain. + **Pre-title video sequence implemented (2026-07-20).** SYSTEM4 already owns the native sequence; the port did not lose an executable-side launcher. Its sole op `0x130` call returns an engine initial-root flag that is one at context construction and cleared only when op `0x9` resets/reloads root script id zero. diff --git a/godot/Main.cs b/godot/Main.cs index 27e011e..4dc5415 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -39,6 +39,7 @@ public partial class Main : Godot.Control private Label _status = null!; private Label _locatorHud = null!; private AudioStreamPlayer _bgm = null!; // looping background music + private Tween? _bgmFadeTween; private AudioStreamPlayer _voice = null!; // interrupt-on-new voice private int _voiceQueuedGeneration; private int _voiceStartedGeneration; @@ -1631,6 +1632,11 @@ public partial class Main : Godot.Control var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes); if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; } stream.Loop = true; + // 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 + // stream after this method restores its normal gain. + CancelBgmFade(); _bgm.VolumeDb = 0; _bgm.Stream = stream; _bgm.Play(); @@ -1691,6 +1697,8 @@ public partial class Main : Godot.Control private void BeginVoiceBgmDuck(int generation, int targetPercent) { + // Native voice ducking is suppressed while an explicit 0xc2 BGM fade owns the envelope. + if (_bgmFadeTween != null) return; if (!_voiceBgmDuckActive) _voiceBgmDuckRestoreDb = _bgm.VolumeDb; _voiceBgmDuckActive = true; @@ -1770,8 +1778,26 @@ public partial class Main : Godot.Control { float linear = System.Math.Clamp(targetPercent / 100.0f, 0.0f, 1.0f); float targetDb = linear <= 0 ? -80.0f : Mathf.LinearToDb(linear); + RestoreVoiceBgmDuck(); + CancelBgmFade(); if (realDurationSeconds <= 0) { _bgm.VolumeDb = targetDb; return; } - CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds); + + var tween = CreateTween(); + _bgmFadeTween = tween; + tween.Finished += () => + { + if (ReferenceEquals(_bgmFadeTween, tween)) + _bgmFadeTween = null; + }; + tween.TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds); + } + + private void CancelBgmFade() + { + var tween = _bgmFadeTween; + _bgmFadeTween = null; + if (tween?.IsValid() == true) + tween.Kill(); } public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId, @@ -1947,13 +1973,25 @@ public partial class Main : Godot.Control byte[] glowGodotWav = RiffWaveSanitizer.RemoveInfoMetadata(glowSfx.Bytes); bool cp932WavMetadataOk = glowGodotWav.Length == 688_336 && AudioStreamWav.LoadFromBuffer(glowGodotWav) != null; - ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk; + AudioPayload bgm = selftestResources.ReadAudio(selftestResources.ResolveBgm(5)!); + FadeBgm(0, 10.0); + bool bgmFadeStarted = _bgmFadeTween?.IsValid() == true; + PlayBgm(bgm.Bytes, bgm.Name); + bool bgmReplacementCancelsFade = bgmFadeStarted + && _bgmFadeTween == null + && System.Math.Abs(_bgm.VolumeDb) < 0.001f; + _bgm.Stop(); + _bgm.Stream = null; + ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk + && bgmReplacementCancelsFade; 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"); + $"sleep-min=1ms; native-key-translation=ok; cp932-wav-info=ok; " + + $"bgm-fade-replacement=ok"); 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}"); + $"native-key-translation={inputTranslationOk}; cp932-wav-info={cp932WavMetadataOk}; " + + $"bgm-fade-replacement={bgmReplacementCancelsFade}"); GetTree().Quit(ok ? 0 : 1); }