Implement ADV History voice replay

This commit is contained in:
gamer147
2026-07-19 14:11:49 -04:00
parent eee9022cfb
commit d3698a0612
10 changed files with 134 additions and 21 deletions

View File

@@ -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
snapshot/commit work remains a second responsibility.
- 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.
It was previously misclassified as a safe statement marker; 17,323 corpus uses make this a foundational
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
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.
`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
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
callback scheduler. None changes backlog ownership or requires choosing a save/profile backend.
Stored voice replay now follows the native split. Ordinary op `0xc4` and History op `0x1bd` share the
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
Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is

View File

@@ -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.
### 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
- **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)
- **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
- **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

View File

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

View File

@@ -1,3 +1,4 @@
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
@@ -10,6 +11,18 @@ public class HistoryDataOpsTests
private static Operand G(int address) => new(T_GINT, 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]
public void CopyInlineIntArrayWritesConsecutiveSignedDwords()
{
@@ -161,4 +174,49 @@ public class HistoryDataOpsTests
Assert.False(history.TryFindVoicePair(0, out long voiceId, out long 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);
}
}

View File

@@ -26,6 +26,8 @@ internal class RecordingHost : IHost
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
public readonly List<long> SleptDurations = 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> SfxReleases = 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 (int Width, int Height) GetTextureSize(int slot) => (0, 0);
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 StartSoundEffect(int channel) => SfxStarts.Add(channel);
public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel);

View File

@@ -72,6 +72,9 @@ public interface IHost
(int Width, int Height) GetTextureSize(int slot);
void PlayBgm(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 StartSoundEffect(int channel) { }
void ReleaseSoundEffect(int channel) { }

View File

@@ -882,7 +882,12 @@ public sealed class VirtualMachine
case "play-voice":
_autoVoicePending = true;
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
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;
case "u0041D050": // 0xb5 / semantics: sfx-start

View File

@@ -40,7 +40,7 @@ public sealed class GodotAdvHost : IHost
private bool _advTextForceComplete;
private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new();
private volatile bool _messageSkipActive;
private AudioPayload? _queuedSkippedVoice;
private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice;
private int _activeWaitLayout;
private long _waitIndicatorStartedMs;
private volatile bool _advPagePresentationSuspended;
@@ -312,7 +312,7 @@ public sealed class GodotAdvHost : IHost
var queued = _queuedSkippedVoice;
_queuedSkippedVoice = null;
if (queued != null) DispatchVoice(queued);
if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant);
}
public void WakeInputCallbackService() => _inputCallbackSignal.Set();
@@ -601,24 +601,30 @@ public sealed class GodotAdvHost : IHost
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 audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
["playback_variant"] = playbackVariant });
if (audio == null) return;
if (_messageSkipActive)
{
bool firstQueued = _queuedSkippedVoice == null;
_queuedSkippedVoice = audio;
_queuedSkippedVoice = (audio, playbackVariant);
if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip");
return;
}
DispatchVoice(audio);
DispatchVoice(audio, playbackVariant);
}
private void DispatchVoice(AudioPayload audio)
private void DispatchVoice(AudioPayload audio, int playbackVariant)
{
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);
}

View File

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

View File

@@ -1946,12 +1946,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "play-voice"
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
source = "investigation"
confidence = "high"
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]]
i = 1
@@ -4057,12 +4057,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "play-history-voice"
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
source = "investigation"
confidence = "high"
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]]
i = 1