Honor ADV skip for BGM fades

This commit is contained in:
gamer147
2026-08-17 13:54:20 -04:00
parent 4d9b786ffb
commit 9ed954fa1b
7 changed files with 77 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
}, Array.Empty<string>());
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));
}
}

View File

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

View File

@@ -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."),

View File

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