diff --git a/docs/engine-re.md b/docs/engine-re.md index 92e7c4a..926cf79 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -3356,17 +3356,31 @@ accept/cancel `IHost` exchange, so cancellation cannot overwrite operand 1. Godo focus and show AGERC's exact Japanese errors. The helper operates on the C-string prefix and encodes through the configured native code page, avoiding both UTF-16 indexing and accidental splitting of CP932 pairs. -`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. -The VM is parked for the requested duration while the native main loop continues rendering. The Godot host -mirrors that ownership split: its VM worker waits on the fade clock only after publishing the completed +`0xc2` is BGM rather than SFX. During ordinary execution, `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. The VM is parked for the requested duration while the native main loop continues +rendering. Active ADV fast-forward (`run_state_flags & 0x08000000`) takes a separate opcode-local branch: it +arms the requested target, applies the terminal fade tick immediately, and never sets blocking state `0x200`. +A normal advance click does not take that branch. `adv_input_service_poll@0x411230` uses action bit `0x10` to +release the preceding text wait, while only fast-forward bit `0x40` establishes `0x08000000`; once `0x200` is +active, `engine_main_tick_with_exception_policy@0x411e8a` services only elapsed timer steps and does not poll +click input for the BGM fade. The Godot host mirrors that ownership split: its VM worker waits on the fade +clock only after publishing the completed pre-fade graphics burst and releasing the script presentation barrier. This is required at `SC0000@0x7c1`, where the three-second title-BGM fade begins after the New Game scene has already cleared to black; retaining presentation ownership during the wait incorrectly froze the last menu frame until the opening CG transition began. `0xd9` is adjacent startup control, not audio data: it clears run/service bit `0x1000` in the primary and, when active, secondary context and has no VM-visible result. +**Skip endpoint correction (2026-08-16).** The port initially sent every `0xc2` request through that blocking +host clock, so an already-active all-message or held-Ctrl Skip appeared to stop on SC0000 pages immediately +followed by a BGM fade. `SYSTEM4 P046` (`wait SC0000@0x4180`) is followed by a 3000 ms target-zero fade at +`SC0000@0x418d`; the captured run also contains the same pattern at P015 (3000 ms) and P090 (1500 ms). VM audio +dispatch now sends zero duration only for the same combined active ADV-skip predicate used by native endpoint +branches. Non-skip dispatch retains the authored duration, preserving native's deliberately unskippable-click +behavior. A focused regression covers both outcomes. + Opcode `0x1cf` belongs to the voice/BGM envelope rather than SFX. Handler `op_0x1cf_set_voice_bgm_duck_control@0x4209f0` replaces the transient mask at `EngineCtx.voice_bgm_duck_control_flags` (`ctx+0x6dbf0`). Before ordinary or History voice playback, diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 8434c4e..5da8a77 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -241,11 +241,11 @@ Implemented: the VM shares op 0xb7's retained-track/zero handling but supplies m - **evidence:** Ghidra /v2 op_0xc0_get_current_bgm_track@0x428440 writes EngineCtx.current_bgm_track_id (+0xa0b84) to operand 1. op 0xbf's bgm_play_track retains the selected direct-name id at the same music-facade field; layout-3 serialization copies it to payload +0x008 and restoration copies it back. Installed SAVE00 stores 0x18, matching FORT's BGM024. ### 0xc2 `fade-bgm` (u0041D2B0, argc 2) -- **summary:** (target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion. +- **summary:** (target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Active ADV fast-forward applies the endpoint immediately; an ordinary advance click does not. Target 0 releases the current BGM source at completion. - **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_bgm_fade@0x4204c0 checks run-state 0x08000000 first: active ADV fast-forward arms the target and calls bgm_fade_tick@0x464960 with a terminal delta without setting blocking state. Otherwise it sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830. engine_main_tick_with_exception_policy@0x411e8a services 0x200 only from elapsed timer steps and does not poll action-4 clicks; adv_input_service_poll@0x411230 uses click bit 0x10 only to release the preceding text wait, while fast-forward bit 0x40 establishes 0x08000000. 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. +Implemented through the blocking host fade clock. VM dispatch substitutes zero duration only when persistent/read/physical ADV fast-forward is already active, matching the native direct-endpoint branch; ordinary execution, including a click that merely released the preceding text wait, retains the authored duration. 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. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 708d07a..0ddf73c 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -112,6 +112,21 @@ in the reported SC0000 line with its active 24-pixel bold Mincho style. The real backend, and held-Ctrl input then recorded the reported P009 coordinate and continued through P166 without a worker failure; the probe stopped at the later interactive name-entry boundary. +### SC0000 BGM fades now honor active ADV Skip (2026-08-16) + +The runtime locator captured `SYSTEM4 P046` at `wait SC0000@0x4180`, after which all-message Skip appeared to +stall and clicks did not advance anything until execution eventually continued. The next effectful instruction +is `fade-bgm SC0000@0x418d`, targeting zero over 3000 ms. The Godot audio host always entered its blocking fade +clock, and neither its click path nor a skip wake changed the deadline. The same captured run exposes equivalent +post-page fades at P015 (3000 ms) and P090 (1500 ms). + +Native `/v2` separates those inputs. `op_0xc2_bgm_fade@0x4204c0` immediately applies the endpoint without +entering run-state `0x200` when ADV fast-forward state `0x08000000` is already active. A normal action-4 click +only releases the preceding text wait; the native `0x200` service itself does not poll click input, so an +ordinary click intentionally does not skip a BGM fade. VM audio dispatch now substitutes a zero-duration host +fade only for active persistent/read/physical fast-forward and preserves the authored duration otherwise. The +focused regression locks both outcomes. + ## Stage B0 — Ground-truth reconnaissance Before changing runtime architecture, record the original game's path from process start through the first diff --git a/engine/Age.Engine.Tests/SfxOpsTests.cs b/engine/Age.Engine.Tests/SfxOpsTests.cs index d71f99c..55a045c 100644 --- a/engine/Age.Engine.Tests/SfxOpsTests.cs +++ b/engine/Age.Engine.Tests/SfxOpsTests.cs @@ -99,4 +99,34 @@ public class SfxOpsTests Assert.Equal(0, Assert.Single(host.SfxReleases)); Assert.Equal((25, 3000L), Assert.Single(host.BgmFades)); } + + [Fact] + public void ActiveAdvSkipForcesBgmFadeEndpointButOrdinaryDispatchKeepsAuthoredDelay() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "BGM-FADE-SKIP", + new List<(int, Operand[])> + { + (0xc2, new[] { new Operand(0, 0), new Operand(0, 3000) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var persistentSkipScript = ScriptAssembler.Assemble(table, "BGM-FADE-PERSISTENT-SKIP", + new List<(int, Operand[])> + { + (0x88, new[] { new Operand(0, 1) }), + (0xc2, new[] { new Operand(0, 0), new Operand(0, 3000) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var ordinaryHost = new RecordingHost(); + var physicalSkipHost = new RecordingHost { MessageSkip = true }; + var persistentSkipHost = new RecordingHost(); + + new VirtualMachine(script, table, ordinaryHost).Run(); + new VirtualMachine(script, table, physicalSkipHost).Run(); + new VirtualMachine(persistentSkipScript, table, persistentSkipHost).Run(); + + Assert.Equal((0, 3000L), Assert.Single(ordinaryHost.BgmFades)); + Assert.Equal((0, 0L), Assert.Single(physicalSkipHost.BgmFades)); + Assert.Equal((0, 0L), Assert.Single(persistentSkipHost.BgmFades)); + } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.Audio.cs b/engine/Age.Engine/Vm/VirtualMachine.Audio.cs index d5dd410..7a93cb5 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.Audio.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.Audio.cs @@ -90,7 +90,12 @@ public sealed partial class VirtualMachine case "u0041D2B0": // 0xc2 / semantics: fade-bgm { int targetPercent = (int)Read(a[0]); - _host.FadeBgm(targetPercent, Read(a[1])); + long durationMs = Read(a[1]); + // Native op 0xc2 enters its timed run-state only during ordinary playback. An + // already-active ADV fast-forward applies the fade endpoint immediately; a normal + // advance click does not set that skip state and therefore retains the authored delay. + bool forceEndpoint = _messageSkipServiceActive || _host.IsMessageSkipActive; + _host.FadeBgm(targetPercent, forceEndpoint ? 0 : durationMs); if (targetPercent == 0) { _currentBgmTrackId = 0; diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 98bd0ee..9def9a9 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -26,7 +26,7 @@ INFERRED: dict[int, dict] = { 0xb4: dict(name='sfx-load', category='audio', noop=False, confidence='high', source='investigation', summary="(packed_raw_resource_id)(channel) — synchronously open the universal SYS4INI/AAI catalog entry and replace the channel's decoded sound buffer without starting playback. A zero high byte is a raw SYS4INI index; a nonzero high byte selects an append catalog and uses the low 24-bit index. Native manager supports channels 0..12; SC0000 uses 0..9."), 0xb5: dict(name='sfx-start', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) — start the already-loaded channel once (logical loop=false). DirectSound publishes synchronously through Play(0,0,DSBPLAY_LOOPING); the low-level flag loops only the streaming ring, while decoder EOF stops logical playback.'), 0xb6: dict(name='sfx-release', category='audio', noop=False, confidence='high', source='investigation', summary='(channel) - stop/destroy the channel decoder and DirectSound buffer, clear its retained resource id, and leave the slot empty. Idempotent for an unused channel.'), - 0xc2: dict(name='fade-bgm', category='audio', noop=False, confidence='high', source='investigation', summary='(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion.'), + 0xc2: dict(name='fade-bgm', category='audio', noop=False, confidence='high', source='investigation', summary='(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Active ADV fast-forward applies the endpoint immediately; an ordinary advance click does not. Target 0 releases the current BGM source at completion.'), 0xcc: dict(name='register-mouse-callback', category='input', noop=False, confidence='high', source='investigation', summary='(poll_interval_ms)(target_pc) - register a timed per-frame mouse callback in the current script.'), 0xcd: dict(name='dispatch-mouse-callback', category='input', noop=False, confidence='high', source='investigation', summary='Dispatch the registered mouse callback when its polling interval elapses.'), 0xd3: dict(name='begin-timed-callback-sequence', category='control', noop=False, confidence='high', source='investigation', summary="Clear and initialize the current script frame's timed local-callback sequence."), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index ed21965..8ed95a1 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1946,13 +1946,13 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "fade-bgm" category = "audio" -summary = "(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Target 0 releases the current BGM source at completion." +summary = "(target_percent)(duration_ms) — block script service while linearly fading current BGM volume to 0..100%. Durations >=1000 ms use 100 steps; shorter fades use 10. Active ADV fast-forward applies the endpoint immediately; an ordinary advance click does not. Target 0 releases the current BGM source at completion." noop_headless = false 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." +evidence = "Ghidra op_0xc2_bgm_fade@0x4204c0 checks run-state 0x08000000 first: active ADV fast-forward arms the target and calls bgm_fade_tick@0x464960 with a terminal delta without setting blocking state. Otherwise it sets run-state 0x200, arms the service timer, and calls bgm_fade_arm@0x464830. engine_main_tick_with_exception_policy@0x411e8a services 0x200 only from elapsed timer steps and does not poll action-4 clicks; adv_input_service_poll@0x411230 uses click bit 0x10 only to release the preceding text wait, while fast-forward bit 0x40 establishes 0x08000000. 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. VM dispatch substitutes zero duration only when persistent/read/physical ADV fast-forward is already active, matching the native direct-endpoint branch; ordinary execution, including a click that merely released the preceding text wait, retains the authored duration. 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