From d3698a0612eb11ca8a71cd1fcde54828d91a850b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 19 Jul 2026 14:11:49 -0400 Subject: [PATCH] Implement ADV History voice replay --- docs/engine-re.md | 18 ++++-- docs/opcode-reference.md | 8 +-- docs/phase-a-slice-plan.md | 24 ++++++++ .../Age.Engine.Tests/HistoryDataOpsTests.cs | 58 +++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 9 ++- engine/Age.Engine/Hosting/IHost.cs | 3 + engine/Age.Engine/Vm/VirtualMachine.cs | 7 ++- godot/GodotAdvHost.cs | 18 ++++-- tools/age_opcodes_himegari.py | 2 +- vm-map/opcodes.toml | 8 +-- 10 files changed, 134 insertions(+), 21 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index e96f711..ae0b6df 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index a560e89..b36b673 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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 diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 4310d32..f65efeb 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -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. diff --git a/engine/Age.Engine.Tests/HistoryDataOpsTests.cs b/engine/Age.Engine.Tests/HistoryDataOpsTests.cs index cf26443..fb3b6c7 100644 --- a/engine/Age.Engine.Tests/HistoryDataOpsTests.cs +++ b/engine/Age.Engine.Tests/HistoryDataOpsTests.cs @@ -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 serviceInputCallback, + Func 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()), + }, Array.Empty()); + 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()), + }, Array.Empty()); + 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); + } } diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index c7e02e9..bf39825 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -26,6 +26,8 @@ internal class RecordingHost : IHost public readonly List WaitIndicators = new(); public readonly List SleptDurations = new(); public readonly List<(long Resource, int Channel)> SfxLoads = new(); + public readonly List Voices = new(); + public readonly List<(long Id, int PlaybackVariant)> VoiceRequests = new(); public readonly List SfxStarts = new(); public readonly List 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); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 7ca6699..801f7cb 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -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) { } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index f6d38b9..2a698fe 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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 diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 68b291f..b605fb7 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -40,7 +40,7 @@ public sealed class GodotAdvHost : IHost private bool _advTextForceComplete; private readonly Dictionary _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); } diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 2deacb6..48af3d0 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -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`.'), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 03ea818..1345cee 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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