Implement ADV History voice replay
This commit is contained in:
@@ -1290,7 +1290,8 @@ The write path is part of ordinary ADV execution:
|
|||||||
layout slot—not a T1 anchor id—although T1 entries structurally target these reset sites. Its ReadTextDB
|
layout slot—not a T1 anchor id—although T1 entries structurally target these reset sites. Its ReadTextDB
|
||||||
snapshot/commit work remains a second responsibility.
|
snapshot/commit work remains a second responsibility.
|
||||||
- op `0x6e`'s glyph builder appends normal text chunks with the active geometry/font/color state.
|
- op `0x6e`'s glyph builder appends normal text chunks with the active geometry/font/color state.
|
||||||
- voice op `0xc4` appends a `0x40000000` record containing its stored pair.
|
- voice op `0xc4` appends a `0x40000000` record containing `{voice_id,0}`; History replay op `0x1bd`
|
||||||
|
uses the same writer with `{voice_id,1}` when recording is enabled.
|
||||||
- op `0x1d2` appends a `0x20000000` record with operand 1 as the value and operand 2 as the metadata type.
|
- op `0x1d2` appends a `0x20000000` record with operand 1 as the value and operand 2 as the metadata type.
|
||||||
It was previously misclassified as a safe statement marker; 17,323 corpus uses make this a foundational
|
It was previously misclassified as a safe statement marker; 17,323 corpus uses make this a foundational
|
||||||
correction.
|
correction.
|
||||||
@@ -1301,7 +1302,8 @@ The write path is part of ordinary ADV execution:
|
|||||||
|
|
||||||
The read side is the previously identified op-`0x1d0..0x1d4` family. Op `0x1d0` returns a logical entry's
|
The read side is the previously identified op-`0x1d0..0x1d4` family. Op `0x1d0` returns a logical entry's
|
||||||
layout slot and first record index; op `0x1d1` renders records until the next group boundary; op `0x1d3`
|
layout slot and first record index; op `0x1d1` renders records until the next group boundary; op `0x1d3`
|
||||||
finds typed metadata; op `0x1d4` finds the voice pair; op `0x1bd` replays its voice id.
|
finds typed metadata; op `0x1d4` finds the voice pair. `HISTORY.BIN` dispatches pair variant 1 through op
|
||||||
|
`0x1bd` and variant 0 through ordinary op `0xc4`.
|
||||||
|
|
||||||
History is independent of `RT.DAT`, but native full save fidelity does serialize the live backlog.
|
History is independent of `RT.DAT`, but native full save fidelity does serialize the live backlog.
|
||||||
`text_history_serialize@0x451d00` writes the index and packed records/strings after context/numbered-save
|
`text_history_serialize@0x451d00` writes the index and packed records/strings after context/numbered-save
|
||||||
@@ -1399,8 +1401,16 @@ surfaces `0xc0/0xc1`; that existing exit boundary now tells the host to discard
|
|||||||
The semantic text/index records remain untouched, so reopening History rebuilds fresh rows while exiting
|
The semantic text/index records remain untouched, so reopening History rebuilds fresh rows while exiting
|
||||||
cannot leave the old labels above the resumed ADV page.
|
cannot leave the old labels above the resumed ADV page.
|
||||||
|
|
||||||
History's remaining work is stored voice replay through `0x1bd` and the `0xd3/0xd4/0xd5` smooth-scroll
|
Stored voice replay now follows the native split. Ordinary op `0xc4` and History op `0x1bd` share the
|
||||||
callback scheduler. None changes backlog ownership or requires choosing a save/profile backend.
|
section-manifest resolver, Skip replacement queue, and Auto voice-pending state, but carry variants 0 and 1
|
||||||
|
respectively through `IHost.PlayVoice`. Both append their pair to the backlog when recording is enabled;
|
||||||
|
History's surrounding `0x1bb(0)` suppression prevents the replay from recording itself. Native
|
||||||
|
`voice_play_indexed_asset@0x488330` stores that variant in the channel-12 sound-buffer state before starting
|
||||||
|
playback. Its precise audible meaning remains unproven, so the Godot host retains it through its queue and
|
||||||
|
timeline rather than inventing different playback behavior.
|
||||||
|
|
||||||
|
History's remaining work is the `0xd3/0xd4/0xd5` smooth-scroll callback scheduler and its small supporting
|
||||||
|
gaps. None changes backlog ownership or requires choosing a save/profile backend.
|
||||||
|
|
||||||
The original dependency order was **Hide Window first** to establish reusable callback/coroutine input, then
|
The original dependency order was **Hide Window first** to establish reusable callback/coroutine input, then
|
||||||
Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is
|
Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is
|
||||||
|
|||||||
@@ -138,14 +138,14 @@
|
|||||||
- **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.
|
||||||
|
|
||||||
### 0xc4 `play-voice` (play-voice, argc 1)
|
### 0xc4 `play-voice` (play-voice, argc 1)
|
||||||
- **summary:** Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). While all-message Skip is active, retain/replace the queued voice id instead of starting it; playback resumes from the latest queued id after Skip clears. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}).
|
- **summary:** Play a voice clip by id with native playback/history variant 0; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). 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. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d}).
|
||||||
- **grounding:** source=investigation, confidence=high
|
- **grounding:** source=investigation, confidence=high
|
||||||
- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it starts the voice immediately; while set it stores the latest id/zero arg at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active.
|
- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it calls voice_play_indexed_asset@0x488330 with variant 0 and records pair {id,0}; while Skip is active it stores the latest id/zero variant at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active.
|
||||||
|
|
||||||
### 0x1bd `play-history-voice` (u0041D910, argc 1)
|
### 0x1bd `play-history-voice` (u0041D910, argc 1)
|
||||||
- **summary:** (voice_id) - replay a voice id selected from the retained ADV text history, preserving normal Skip and Auto-voice state behavior.
|
- **summary:** Replay a voice id selected from retained ADV text history using native playback/history variant 1, preserving normal Skip and Auto-voice state behavior.
|
||||||
- **grounding:** source=investigation, confidence=high
|
- **grounding:** source=investigation, confidence=high
|
||||||
- **evidence:** Ghidra /v2: op_0x1bd_play_history_voice@0x420920 stops/replaces the active voice, starts operand 1 through the native voice service when Skip is inactive (or queues it while Skip is active), records the replay in the message voice state when enabled, and sets adv_auto_voice_pending when playback exists. HISTORY.BIN obtains the id from retained text-record metadata before invoking this opcode.
|
- **evidence:** Ghidra /v2: op_0x1bd_play_history_voice@0x420920 stops/replaces the active voice and calls voice_play_indexed_asset@0x488330 with operand 1 plus playback variant 1 when Skip is inactive (or queues that pair while Skip is active). When text-history recording is enabled it appends voice pair {id,1}; HISTORY.BIN suppresses recording with op 0x1bb while its UI is active, preventing replay clicks from recursively entering the backlog. The handler also sets adv_auto_voice_pending when playback exists. voice_play_indexed_asset retains the variant in sound-buffer state before starting channel 12; its exact audible meaning remains unproven.
|
||||||
|
|
||||||
## compute
|
## compute
|
||||||
|
|
||||||
|
|||||||
@@ -1811,3 +1811,27 @@ Validation: engine 198/198, zero-warning Godot build, threaded `SELFTEST OK`, an
|
|||||||
|
|
||||||
**Next:** manually recheck the five History rows and overlay cleanup against the original. If they match, proceed
|
**Next:** manually recheck the five History rows and overlay cleanup against the original. If they match, proceed
|
||||||
with stored History voice replay (`0x1bd`), followed by `0xd3/0xd4/0xd5` smooth-scroll fidelity.
|
with stored History voice replay (`0x1bd`), followed by `0xd3/0xd4/0xd5` smooth-scroll fidelity.
|
||||||
|
|
||||||
|
### ADV History stored voice replay implemented (2026-07-19)
|
||||||
|
|
||||||
|
The manual row/cleanup recheck passed, and stored History voice replay now uses the existing voice resolver,
|
||||||
|
message-Skip replacement queue, and Auto voice-pending service. Native RE exposed a meaningful second value
|
||||||
|
that the earlier one-id host API erased: ordinary `0xc4` calls the indexed voice service and records
|
||||||
|
`{voice_id,0}`, while `0x1bd` uses and records `{voice_id,1}`. `IHost.PlayVoice` now carries this playback
|
||||||
|
variant explicitly; Godot preserves it in queued requests and diagnostics while continuing to play the same
|
||||||
|
resolved OGG until the native variant's audible interpretation is proven.
|
||||||
|
|
||||||
|
The generic opcode still appends `{voice_id,1}` when history capture is enabled, matching the native handler.
|
||||||
|
During the real History menu, the existing `0x1bb(0)` suppression boundary prevents that replay from becoming
|
||||||
|
a new backlog record. Focused tests cover the variant, Auto state, normal recording, and suppressed replay.
|
||||||
|
HISTORY is now 70/78 distinct opcodes and 842/854 instructions handled or safe-noop; its eight remaining
|
||||||
|
effectful gaps total 12 instructions.
|
||||||
|
|
||||||
|
Validation: 199 non-DirectShow engine cases pass, the isolated DirectShow movie case passes, opcode tests and
|
||||||
|
lint are clean, vm0 RECOVER passes, the Godot build has zero warnings, and threaded `SELFTEST OK`. The full
|
||||||
|
suite's DirectShow case still times out only when run among the complete suite; this pre-existing multimedia
|
||||||
|
test interaction is unrelated to the History voice path. The `/v2` Ghidra image names/comments the shared
|
||||||
|
indexed voice service and is saved.
|
||||||
|
|
||||||
|
**Next:** investigate and implement the `0xd3/0xd4/0xd5` smooth-scroll callback/interpolation family as a
|
||||||
|
separate fidelity slice, including the remaining scheduler support it depends upon.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Age.Engine.Hosting;
|
||||||
using Age.Engine.Model;
|
using Age.Engine.Model;
|
||||||
using Age.Engine.Sys4;
|
using Age.Engine.Sys4;
|
||||||
using Age.Engine.Vm;
|
using Age.Engine.Vm;
|
||||||
@@ -10,6 +11,18 @@ public class HistoryDataOpsTests
|
|||||||
private static Operand G(int address) => new(T_GINT, address);
|
private static Operand G(int address) => new(T_GINT, address);
|
||||||
private static Operand L(int address) => new(T_LINT, address);
|
private static Operand L(int address) => new(T_LINT, address);
|
||||||
|
|
||||||
|
private sealed class HistoryVoiceHost : RecordingHost
|
||||||
|
{
|
||||||
|
public AdvAutoWaitState AutoState;
|
||||||
|
|
||||||
|
public override void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
|
||||||
|
Func<AdvAutoWaitState> autoWaitState)
|
||||||
|
{
|
||||||
|
AutoState = autoWaitState();
|
||||||
|
Waits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CopyInlineIntArrayWritesConsecutiveSignedDwords()
|
public void CopyInlineIntArrayWritesConsecutiveSignedDwords()
|
||||||
{
|
{
|
||||||
@@ -161,4 +174,49 @@ public class HistoryDataOpsTests
|
|||||||
Assert.False(history.TryFindVoicePair(0, out long voiceId, out long voiceArgument));
|
Assert.False(history.TryFindVoicePair(0, out long voiceId, out long voiceArgument));
|
||||||
Assert.Equal((-1L, -1L), (voiceId, voiceArgument));
|
Assert.Equal((-1L, -1L), (voiceId, voiceArgument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayHistoryVoiceUsesNativeVariantAndAutoServices()
|
||||||
|
{
|
||||||
|
var history = new AdvTextHistory();
|
||||||
|
var script = ScriptAssembler.Assemble(Table, "HISTORY_VOICE_REPLAY",
|
||||||
|
new List<(int, Operand[])>
|
||||||
|
{
|
||||||
|
(0x1bd, new[] { I(77) }),
|
||||||
|
(0x72, new[] { I(1) }),
|
||||||
|
(0x2, Array.Empty<Operand>()),
|
||||||
|
}, Array.Empty<string>());
|
||||||
|
var host = new HistoryVoiceHost();
|
||||||
|
var vm = new VirtualMachine(script, Table, host, textHistory: history);
|
||||||
|
|
||||||
|
vm.Run();
|
||||||
|
|
||||||
|
Assert.Equal("exit", vm.HaltReason);
|
||||||
|
Assert.Equal(new long[] { 77 }, host.Voices);
|
||||||
|
Assert.Equal(new[] { (77L, 1) }, host.VoiceRequests);
|
||||||
|
Assert.True(host.AutoState.VoicePending);
|
||||||
|
var voice = Assert.Single(history.Records);
|
||||||
|
Assert.Equal(AdvTextHistoryRecordKind.Voice, voice.Kind);
|
||||||
|
Assert.Equal((77L, 1L), (voice.Value, voice.AuxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuppressedHistoryReplayDoesNotRecordItself()
|
||||||
|
{
|
||||||
|
var history = new AdvTextHistory();
|
||||||
|
history.SetRecordingEnabled(false);
|
||||||
|
var script = ScriptAssembler.Assemble(Table, "HISTORY_VOICE_REPLAY_SUPPRESSED",
|
||||||
|
new List<(int, Operand[])>
|
||||||
|
{
|
||||||
|
(0x1bd, new[] { I(88) }),
|
||||||
|
(0x2, Array.Empty<Operand>()),
|
||||||
|
}, Array.Empty<string>());
|
||||||
|
var host = new RecordingHost();
|
||||||
|
var vm = new VirtualMachine(script, Table, host, textHistory: history);
|
||||||
|
|
||||||
|
vm.Run();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (88L, 1) }, host.VoiceRequests);
|
||||||
|
Assert.Empty(history.Records);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ internal class RecordingHost : IHost
|
|||||||
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
|
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
|
||||||
public readonly List<long> SleptDurations = new();
|
public readonly List<long> SleptDurations = new();
|
||||||
public readonly List<(long Resource, int Channel)> SfxLoads = new();
|
public readonly List<(long Resource, int Channel)> SfxLoads = new();
|
||||||
|
public readonly List<long> Voices = new();
|
||||||
|
public readonly List<(long Id, int PlaybackVariant)> VoiceRequests = new();
|
||||||
public readonly List<int> SfxStarts = new();
|
public readonly List<int> SfxStarts = new();
|
||||||
public readonly List<int> SfxReleases = new();
|
public readonly List<int> SfxReleases = new();
|
||||||
public readonly List<(int Target, long Duration)> BgmFades = new();
|
public readonly List<(int Target, long Duration)> BgmFades = new();
|
||||||
@@ -101,7 +103,12 @@ internal class RecordingHost : IHost
|
|||||||
public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { }
|
public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int 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) { }
|
public void PlayBgm(long id) { }
|
||||||
public void PlayVoice(long id) { }
|
public void PlayVoice(long id) => Voices.Add(id);
|
||||||
|
public void PlayVoice(long id, int playbackVariant)
|
||||||
|
{
|
||||||
|
Voices.Add(id);
|
||||||
|
VoiceRequests.Add((id, playbackVariant));
|
||||||
|
}
|
||||||
public void LoadSoundEffect(long resourceId, int channel) => SfxLoads.Add((resourceId, channel));
|
public void LoadSoundEffect(long resourceId, int channel) => SfxLoads.Add((resourceId, channel));
|
||||||
public void StartSoundEffect(int channel) => SfxStarts.Add(channel);
|
public void StartSoundEffect(int channel) => SfxStarts.Add(channel);
|
||||||
public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel);
|
public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel);
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ public interface IHost
|
|||||||
(int Width, int Height) GetTextureSize(int slot);
|
(int Width, int Height) GetTextureSize(int slot);
|
||||||
void PlayBgm(long id);
|
void PlayBgm(long id);
|
||||||
void PlayVoice(long id);
|
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.
|
||||||
|
void PlayVoice(long id, int playbackVariant) => PlayVoice(id);
|
||||||
void LoadSoundEffect(long resourceId, int channel) { }
|
void LoadSoundEffect(long resourceId, int channel) { }
|
||||||
void StartSoundEffect(int channel) { }
|
void StartSoundEffect(int channel) { }
|
||||||
void ReleaseSoundEffect(int channel) { }
|
void ReleaseSoundEffect(int channel) { }
|
||||||
|
|||||||
@@ -882,7 +882,12 @@ public sealed class VirtualMachine
|
|||||||
case "play-voice":
|
case "play-voice":
|
||||||
_autoVoicePending = true;
|
_autoVoicePending = true;
|
||||||
TextHistory.AppendVoice(Read(a[0]), 0, _advTextStyle);
|
TextHistory.AppendVoice(Read(a[0]), 0, _advTextStyle);
|
||||||
_host.PlayVoice(Read(a[0])); return pc + 1;
|
_host.PlayVoice(Read(a[0]), 0); return pc + 1;
|
||||||
|
case "play-history-voice": // 0x1bd: native voice start/history argument is one
|
||||||
|
case "u0041D910":
|
||||||
|
_autoVoicePending = true;
|
||||||
|
TextHistory.AppendVoice(Read(a[0]), 1, _advTextStyle);
|
||||||
|
_host.PlayVoice(Read(a[0]), 1); return pc + 1;
|
||||||
case "play-sound-effect": // 0xb4 / semantics: sfx-load
|
case "play-sound-effect": // 0xb4 / semantics: sfx-load
|
||||||
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;
|
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;
|
||||||
case "u0041D050": // 0xb5 / semantics: sfx-start
|
case "u0041D050": // 0xb5 / semantics: sfx-start
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
private bool _advTextForceComplete;
|
private bool _advTextForceComplete;
|
||||||
private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new();
|
private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new();
|
||||||
private volatile bool _messageSkipActive;
|
private volatile bool _messageSkipActive;
|
||||||
private AudioPayload? _queuedSkippedVoice;
|
private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice;
|
||||||
private int _activeWaitLayout;
|
private int _activeWaitLayout;
|
||||||
private long _waitIndicatorStartedMs;
|
private long _waitIndicatorStartedMs;
|
||||||
private volatile bool _advPagePresentationSuspended;
|
private volatile bool _advPagePresentationSuspended;
|
||||||
@@ -312,7 +312,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
var queued = _queuedSkippedVoice;
|
var queued = _queuedSkippedVoice;
|
||||||
_queuedSkippedVoice = null;
|
_queuedSkippedVoice = null;
|
||||||
if (queued != null) DispatchVoice(queued);
|
if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WakeInputCallbackService() => _inputCallbackSignal.Set();
|
public void WakeInputCallbackService() => _inputCallbackSignal.Set();
|
||||||
@@ -601,24 +601,30 @@ public sealed class GodotAdvHost : IHost
|
|||||||
if (audio != null) _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name);
|
if (audio != null) _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void PlayVoice(long id)
|
public void PlayVoice(long id) => PlayVoice(id, 0);
|
||||||
|
|
||||||
|
public void PlayVoice(long id, int playbackVariant)
|
||||||
{
|
{
|
||||||
var asset = _res.Resolve(_scene, id);
|
var asset = _res.Resolve(_scene, id);
|
||||||
var audio = asset != null ? LoadAudio(asset) : null;
|
var audio = asset != null ? LoadAudio(asset) : null;
|
||||||
|
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
|
||||||
|
["playback_variant"] = playbackVariant });
|
||||||
if (audio == null) return;
|
if (audio == null) return;
|
||||||
if (_messageSkipActive)
|
if (_messageSkipActive)
|
||||||
{
|
{
|
||||||
bool firstQueued = _queuedSkippedVoice == null;
|
bool firstQueued = _queuedSkippedVoice == null;
|
||||||
_queuedSkippedVoice = audio;
|
_queuedSkippedVoice = (audio, playbackVariant);
|
||||||
if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip");
|
if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DispatchVoice(audio);
|
DispatchVoice(audio, playbackVariant);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DispatchVoice(AudioPayload audio)
|
private void DispatchVoice(AudioPayload audio, int playbackVariant)
|
||||||
{
|
{
|
||||||
int generation = _main.QueueVoicePlayback();
|
int generation = _main.QueueVoicePlayback();
|
||||||
|
// Godot's stream player has no matching AGE start-mode control. Retain the native
|
||||||
|
// variant through dispatch/timeline so that distinction is not erased at the VM seam.
|
||||||
_main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation);
|
_main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ INFERRED: dict[int, dict] = {
|
|||||||
0x1b8: dict(name='get-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'),
|
0x1b8: dict(name='get-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'),
|
||||||
0x1b9: dict(name='set-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(milliseconds) - write an Auto-message delay to engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'),
|
0x1b9: dict(name='set-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(milliseconds) - write an Auto-message delay to engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'),
|
||||||
0x1bc: dict(name='reset-message-voice-state', category='input', noop=False, confidence='high', source='investigation', summary='Reset the per-message queued-voice flag used by ADV Auto timing.'),
|
0x1bc: dict(name='reset-message-voice-state', category='input', noop=False, confidence='high', source='investigation', summary='Reset the per-message queued-voice flag used by ADV Auto timing.'),
|
||||||
0x1bd: dict(name='play-history-voice', category='audio', noop=False, confidence='high', source='investigation', summary='(voice_id) - replay a voice id selected from the retained ADV text history, preserving normal Skip and Auto-voice state behavior.'),
|
0x1bd: dict(name='play-history-voice', category='audio', noop=False, confidence='high', source='investigation', summary='Replay a voice id selected from retained ADV text history using native playback/history variant 1, preserving normal Skip and Auto-voice state behavior.'),
|
||||||
0x1bf: dict(name='call-end', category='marker', noop=True, confidence='med', source='inference', summary='zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker'),
|
0x1bf: dict(name='call-end', category='marker', noop=True, confidence='med', source='inference', summary='zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker'),
|
||||||
0x1ca: dict(name='set-read-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text.'),
|
0x1ca: dict(name='set-read-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text.'),
|
||||||
0x1cb: dict(name='get-read-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - read the engine setting `message:ReadTextSkip`.'),
|
0x1cb: dict(name='get-read-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - read the engine setting `message:ReadTextSkip`.'),
|
||||||
|
|||||||
@@ -1946,12 +1946,12 @@ abi_source = "kelebek+decode-validated"
|
|||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "play-voice"
|
name = "play-voice"
|
||||||
category = "audio"
|
category = "audio"
|
||||||
summary = "Play a voice clip by id; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). While all-message Skip is active, retain/replace the queued voice id instead of starting it; playback resumes from the latest queued id after Skip clears. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})."
|
summary = "Play a voice clip by id with native playback/history variant 0; id resolves via the SYS4INI section manifest -> files[section_base(scene)+id] (voice OGG in DATA1/DATA4). 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. Same resolver rule as set-texture (NOT play-bgm, which is direct-name BGM{id:03d})."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "investigation"
|
source = "investigation"
|
||||||
confidence = "high"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it starts the voice immediately; while set it stores the latest id/zero arg at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active."
|
evidence = "By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. Ghidra /v2 op_0xc4_handler@0x420610: when run_state_flags bit 0x08000000 is clear it calls voice_play_indexed_asset@0x488330 with variant 0 and records pair {id,0}; while Skip is active it stores the latest id/zero variant at ctx+0x6dbf4/+0x6dbf8. adv_interpreter_tick starts and clears that deferred voice when Skip/read-skip input is no longer active."
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
@@ -4057,12 +4057,12 @@ abi_source = "kelebek+decode-validated"
|
|||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "play-history-voice"
|
name = "play-history-voice"
|
||||||
category = "audio"
|
category = "audio"
|
||||||
summary = "(voice_id) - replay a voice id selected from the retained ADV text history, preserving normal Skip and Auto-voice state behavior."
|
summary = "Replay a voice id selected from retained ADV text history using native playback/history variant 1, preserving normal Skip and Auto-voice state behavior."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "investigation"
|
source = "investigation"
|
||||||
confidence = "high"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = "Ghidra /v2: op_0x1bd_play_history_voice@0x420920 stops/replaces the active voice, starts operand 1 through the native voice service when Skip is inactive (or queues it while Skip is active), records the replay in the message voice state when enabled, and sets adv_auto_voice_pending when playback exists. HISTORY.BIN obtains the id from retained text-record metadata before invoking this opcode."
|
evidence = "Ghidra /v2: op_0x1bd_play_history_voice@0x420920 stops/replaces the active voice and calls voice_play_indexed_asset@0x488330 with operand 1 plus playback variant 1 when Skip is inactive (or queues that pair while Skip is active). When text-history recording is enabled it appends voice pair {id,1}; HISTORY.BIN suppresses recording with op 0x1bb while its UI is active, preventing replay clicks from recursively entering the backlog. The handler also sets adv_auto_voice_pending when playback exists. voice_play_indexed_asset retains the variant in sound-buffer state before starting channel 12; its exact audible meaning remains unproven."
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
|
|||||||
Reference in New Issue
Block a user