diff --git a/docs/plans/2026-08-08-audio-parity-campaign.md b/docs/plans/2026-08-08-audio-parity-campaign.md
index 0439899d..ac5a97db 100644
--- a/docs/plans/2026-08-08-audio-parity-campaign.md
+++ b/docs/plans/2026-08-08-audio-parity-campaign.md
@@ -33,15 +33,22 @@ Retail is a **2D pan+gain engine**, not 3D audio. Every gameplay buffer is
created with `m_3D = 0`; the DirectSound 3D listener the client sets up is
dead code. Spatialization is CPU-side per voice at play time:
-- **Gain** (`SoundManager::GetAttenuation @ 0x00550AD0` region; byte-decoded):
- `g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, multiplied by ONE
- master knob (`effect_sound_volume` or `ambient_sound_volume`), then
- `db = ceil(20·log10 g)` with a hard floor at −50 dB — below the floor the
- voice is **not allocated at all**. Audible radius ≈ 94 m at vol 1.0.
-- **Pan**: `pan_dB = −15·sin(Δheading listener→source)`, saturated at
- ±15 dB, forced to 0 inside 5 m. No front/back, no elevation.
-- **Listener** = the player physics object's position/heading
- (`SoundManager::SetPlayerPosition`), NOT the camera.
+- **Gain** (`SoundManager::GetAttenuation @ 0x00550020`; byte-decoded):
+ `g = dist < 5m ? vol : 25·vol/dist²`, clamped to 1.0, then multiplied by ONE
+ master knob (`effect_sound_volume` or `ambient_sound_volume`) — clamp
+ first, multiply second — then `db = ceil(20·log10 g)` with a hard floor at
+ −50 dB, below which the voice is **not allocated at all**. Audible radius
+ ≈ 94.2 m at vol 1.0.
+- **Pan**: `pan_dB = (int)(−15·sin(Δbearing))`, truncating toward zero,
+ saturated at ±15 dB, forced to 0 when `(int)distance < 5`. No front/back,
+ no elevation.
+- **Listener** = `SmartBox::viewer` — the **collided third-person camera**
+ Position, refreshed once per rendered frame (`SmartBox::set_viewer` @
+ `0x00452D36`, `SmartBox::update_viewer` @ `0x00453CE0`), falling back to the
+ player's own position when the camera sweep fails. Only its origin and
+ `Frame::get_heading` are read. (An earlier draft of this plan said the
+ listener is the player and called acdream's camera listener a defect —
+ wrong, and corrected at A2. Do not re-"fix" it.)
- **Voice pool**: allocator is `SoundManager::PlaySoundInternal @ 0x0054FEC0`.
Eviction compares the DAT-authored **float priority** (0..1); equal
priority never evicts. (`FUN_00550AD0` cited in our code is a hash-table
@@ -297,7 +304,7 @@ global kill switch.
|---|---|---|---|
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
-| A3 | — | — | — |
+| A3 | **COMPLETE** 2026-08-08 | `3fae0c7d` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
| A4 | — | — | — |
| A5 | — | — | — |
| A6 | — | — | — |
diff --git a/src/AcDream.App/Audio/AudioHookSink.cs b/src/AcDream.App/Audio/AudioHookSink.cs
index e0acc8a6..48a8db44 100644
--- a/src/AcDream.App/Audio/AudioHookSink.cs
+++ b/src/AcDream.App/Audio/AudioHookSink.cs
@@ -101,6 +101,45 @@ public sealed class AudioHookSink : IAnimationHookSink
}
}
+ ///
+ /// Plays a server-addressed SoundType slot — retail's
+ /// SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float) @
+ /// 0x00550AF0, reached from CPhysicsObj::play_sound @
+ /// 0x0050F460.
+ ///
+ ///
+ /// Two asymmetries with the animation-hook path above, both from the decode
+ /// and both deliberate: the sound plays at the wire volume and the
+ /// SoundTable entry's own volume is ignored (the hook path does the
+ /// opposite), while the entry's probability still gates it and the entry's
+ /// priority still drives voice eviction. An object with no SoundTable plays
+ /// nothing at all — retail early-returns before reaching the mixer.
+ ///
+ ///
+ public void PlayServerSound(
+ uint entityId,
+ Vector3 worldPosition,
+ uint soundType,
+ float wireVolume)
+ {
+ if (!_engine.IsAvailable) return;
+
+ uint tableId = _entitySoundTables.GetSoundTableId(entityId);
+ if (tableId == 0) return;
+
+ SoundTable? table = _cache.GetSoundTable(tableId);
+ if (table is null) return;
+
+ var entry = SoundCookbook.Select(table, (DRWSound)soundType, _rng);
+ if (entry is null) return;
+
+ Play(
+ entityId, worldPosition,
+ waveId: (uint)entry.Id,
+ volume: wireVolume,
+ priority: entry.Priority);
+ }
+
private void PlayFromSoundTable(
uint entityId, Vector3 worldPos, DRWSound sound,
float volumeMult = 1f)
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index dc573bc4..0aaf5c35 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -623,7 +623,13 @@ internal sealed class LivePresentationCompositionPhase
content.Audio?.EntitySoundTables.Remove(ownerId);
if (soundTableDid is { } did)
content.Audio?.EntitySoundTables.Set(ownerId, did);
- });
+ },
+ (ownerId, worldPosition, soundType, wireVolume) =>
+ content.Audio?.HookSink?.PlayServerSound(
+ ownerId,
+ worldPosition,
+ soundType,
+ wireVolume));
bindings.Adopt(
"entity-effect advance",
d.EffectAdvance.BindOwned(entityEffects));
diff --git a/src/AcDream.App/Net/LiveEntitySessionController.cs b/src/AcDream.App/Net/LiveEntitySessionController.cs
index d0722ae1..395e02f4 100644
--- a/src/AcDream.App/Net/LiveEntitySessionController.cs
+++ b/src/AcDream.App/Net/LiveEntitySessionController.cs
@@ -46,7 +46,8 @@ internal sealed class LiveEntitySessionController
OnTeleportStarted,
OnAppearanceUpdated,
OnPlayPhysicsScript,
- OnPlayPhysicsScriptType);
+ OnPlayPhysicsScriptType,
+ OnSoundEvent);
private void OnSpawned(WorldSession.EntitySpawn value) =>
_inbound.Run(_hydration, value,
@@ -95,4 +96,8 @@ internal sealed class LiveEntitySessionController
private void OnPlayPhysicsScriptType(PlayPhysicsScriptType value) =>
_inbound.Run(_effects, value,
static (owner, message) => owner.HandleTyped(message));
+
+ private void OnSoundEvent(SoundEvent value) =>
+ _inbound.Run(_effects, value,
+ static (owner, message) => owner.HandleSound(message));
}
diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
index 5a769558..013c5fda 100644
--- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
+++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs
@@ -34,6 +34,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
private readonly Func _parentOfAttachedChild;
private readonly Action _ownerUnregistered;
private readonly Action _ownerSoundTableChanged;
+ private readonly Action _playServerSound;
private readonly Dictionary _liveProfiles = [];
private readonly HashSet _readyLiveOwners = [];
// C3c constructs the App effect owner before Runtime's initial placement
@@ -64,7 +65,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
Func? childAtPart = null,
Func? parentOfAttachedChild = null,
Action? ownerUnregistered = null,
- Action? ownerSoundTableChanged = null)
+ Action? ownerSoundTableChanged = null,
+ Action? playServerSound = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
@@ -74,6 +76,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
_ownerUnregistered = ownerUnregistered ?? (_ => { });
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
+ _playServerSound = playServerSound ?? ((_, _, _, _) => { });
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
_poses.EffectPoseChanged += OnEffectPoseChanged;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
@@ -105,6 +108,30 @@ public sealed class EntityEffectController : IAnimationHookSink,
Enqueue(message.Guid, PendingEffect.Direct(message.ScriptDid));
}
+ ///
+ /// Retail's server-driven sound channel, SmartBox::HandleSoundEvent @
+ /// 0x00451FC0. The guid resolution, absent-object queueing, and
+ /// ordered replay are the same machinery the F754/F755 handlers above use,
+ /// because retail routes all three through one CObjectMaint blob queue
+ /// — an unknown guid parks the blob and HandleCreateObject drains it,
+ /// so a creature that spawns and immediately grunts still grunts.
+ ///
+ public void HandleSound(SoundEvent message)
+ {
+ if (message.Guid == 0)
+ return;
+ if (TryGetReadyLocalId(message.Guid, out uint localId))
+ {
+ RefreshLiveAnchor(message.Guid, localId);
+ if (CanStartOwner(localId))
+ PlayServerSound(localId, message.SoundType, message.Volume);
+ else if (IsWaitingForInitialPresentation(message.Guid))
+ Enqueue(message.Guid, PendingEffect.Sound(message.SoundType, message.Volume));
+ return;
+ }
+ Enqueue(message.Guid, PendingEffect.Sound(message.SoundType, message.Volume));
+ }
+
public void HandleTyped(PlayPhysicsScriptType message)
{
if (message.Guid == 0)
@@ -349,6 +376,23 @@ public sealed class EntityEffectController : IAnimationHookSink,
return _runner.PlayDirect(ownerLocalId, scriptDid);
}
+ ///
+ /// Plays one server-addressed SoundType slot on a ready owner, at the
+ /// owner's current root pose. Retail's CPhysicsObj::play_sound @
+ /// 0x0050F460 drops the sound silently when the object carries no
+ /// SoundTable, which the sink below reproduces.
+ ///
+ private void PlayServerSound(uint ownerLocalId, uint soundType, float volume)
+ {
+ if (!CanStartOwner(ownerLocalId))
+ return;
+
+ Vector3 anchor = _poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld)
+ ? rootWorld.Translation
+ : Vector3.Zero;
+ _playServerSound(ownerLocalId, anchor, soundType, volume);
+ }
+
public bool PlayTyped(uint ownerLocalId, uint rawScriptType, float intensity)
{
// Retail CPhysicsObj::play_script @ 0x00513260 does not enqueue for a
@@ -598,10 +642,18 @@ public sealed class EntityEffectController : IAnimationHookSink,
private void Execute(uint localId, PendingEffect effect)
{
- if (effect.Kind is PendingEffectKind.Direct)
- PlayDirect(localId, effect.ScriptDid);
- else
- PlayTyped(localId, effect.RawScriptType, effect.Intensity);
+ switch (effect.Kind)
+ {
+ case PendingEffectKind.Direct:
+ PlayDirect(localId, effect.ScriptDid);
+ break;
+ case PendingEffectKind.Typed:
+ PlayTyped(localId, effect.RawScriptType, effect.Intensity);
+ break;
+ case PendingEffectKind.Sound:
+ PlayServerSound(localId, effect.RawScriptType, effect.Intensity);
+ break;
+ }
}
private bool TryGetProfile(
@@ -631,6 +683,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
{
Direct,
Typed,
+ Sound,
}
private readonly record struct PendingEffect(
@@ -644,5 +697,12 @@ public sealed class EntityEffectController : IAnimationHookSink,
public static PendingEffect Typed(uint rawScriptType, float intensity) =>
new(PendingEffectKind.Typed, 0u, rawScriptType, intensity);
+
+ // RawScriptType carries the SoundType slot and Intensity the wire
+ // volume; the fields are reused rather than widening the struct, since a
+ // queued sound and a queued script share one ordered queue per guid the
+ // way retail's CObjectMaint blob queue does.
+ public static PendingEffect Sound(uint soundType, float volume) =>
+ new(PendingEffectKind.Sound, 0u, soundType, volume);
}
}
diff --git a/src/AcDream.Core.Net/Messages/SoundEvent.cs b/src/AcDream.Core.Net/Messages/SoundEvent.cs
new file mode 100644
index 00000000..73bbdd14
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/SoundEvent.cs
@@ -0,0 +1,41 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Retail Sound game message (0xF750): the server tells the client
+/// to play one SoundType slot from an object's SoundTable.
+///
+///
+/// Wire layout, 16 bytes, S→C — three oracles agree:
+/// u32 opcode | u32 guid | u32 SoundType | f32 volume. Retail dispatch is
+/// CM_Physics::DispatchSB_SoundEvent @ 0x006AC760 (which reads
+/// buf+4, buf+8, buf+0xC); ACE writes exactly those fields
+/// in GameMessageSound.cs with a declared length of 16; holtburger's
+/// PlaySoundData parses the same triple.
+///
+///
+///
+/// This message carries every server-driven cue: melee hits and wounds, wield
+/// and unwield, pickup and drop, lockpicking, lifestone bind, spell resist,
+/// trap triggers, item mana depletion. It was unhandled by acdream until
+/// Campaign A slice A3, which is why all of those were silent.
+///
+///
+public readonly record struct SoundEvent(uint Guid, uint SoundType, float Volume)
+{
+ public const uint Opcode = 0xF750u;
+ public const int WireSize = 16;
+
+ public static SoundEvent? TryParse(ReadOnlySpan body)
+ {
+ if (body.Length < WireSize
+ || BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
+ return null;
+
+ return new SoundEvent(
+ BinaryPrimitives.ReadUInt32LittleEndian(body[4..]),
+ BinaryPrimitives.ReadUInt32LittleEndian(body[8..]),
+ BinaryPrimitives.ReadSingleLittleEndian(body[12..]));
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 12bd341e..9931aa47 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -513,6 +513,24 @@ public sealed class WorldSession : IDisposable
/// Fires for retail typed PhysicsScript playback (0xF755).
public event Action? PlayPhysicsScriptTypeReceived;
+ ///
+ /// Fires for retail's Sound event (0xF750) — the server-driven
+ /// sound channel: hits, wounds, wield/unwield, pickup/drop, lockpicking,
+ /// lifestone bind, spell resist, trap triggers, item mana depletion.
+ ///
+ ///
+ /// Retail's chain is CM_Physics::DispatchSB_SoundEvent @
+ /// 0x006AC760 → SmartBox::HandleSoundEvent @ 0x00451FC0
+ /// → CPhysicsObj::play_sound @ 0x0050F460. Two behaviours the
+ /// consumer owns, both from that decode: an event for a guid the client does
+ /// not know yet is QUEUED against that guid and replayed when the object
+ /// arrives (not dropped), and an object with no SoundTable plays nothing.
+ /// The wire volume is authoritative — unlike the animation-hook path, retail
+ /// ignores the SoundTable entry's own volume here.
+ ///
+ ///
+ public event Action? SoundEventReceived;
+
///
/// Phase 5d — retail's AdminEnvirons packet (opcode
/// 0xEA60) — the one-and-only channel retail's server uses
@@ -1937,6 +1955,12 @@ public sealed class WorldSession : IDisposable
if (script is not null)
PlayPhysicsScriptTypeReceived?.Invoke(script.Value);
}
+ else if (op == SoundEvent.Opcode)
+ {
+ var sound = SoundEvent.TryParse(body);
+ if (sound is not null)
+ SoundEventReceived?.Invoke(sound.Value);
+ }
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
{
// Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
index cbdd7306..aabf5772 100644
--- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
@@ -24,7 +24,8 @@ public sealed record LiveEntitySessionSink(
Action TeleportStarted,
Action AppearanceUpdated,
Action PlayPhysicsScript,
- Action PlayPhysicsScriptType);
+ Action PlayPhysicsScriptType,
+ Action SoundEvent);
public sealed record LiveEnvironmentSessionSink(
Action EnvironChanged,
@@ -151,6 +152,10 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
h => session.PlayPhysicsScriptTypeReceived += h,
h => session.PlayPhysicsScriptTypeReceived -= h,
entities.PlayPhysicsScriptType);
+ Subscribe(
+ h => session.SoundEventReceived += h,
+ h => session.SoundEventReceived -= h,
+ entities.SoundEvent);
Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged);
Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated);
@@ -507,6 +512,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType);
+ ArgumentNullException.ThrowIfNull(entities.SoundEvent);
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
ArgumentNullException.ThrowIfNull(inventory.Objects);
diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
index a487f413..6bba4622 100644
--- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
+++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
@@ -117,6 +117,9 @@ public sealed class RuntimeLiveEntitySessionController
OnParentUpdated,
OnTeleportStarted,
OnAppearanceUpdated,
+ // Effect and sound playback are presentation: the no-window host parses
+ // these packets and discards them, exactly as it does F754/F755.
+ _ => { },
_ => { },
_ => { });
diff --git a/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs b/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
index 72851261..657bd56a 100644
--- a/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
@@ -47,7 +47,8 @@ public sealed class LiveMovementStatsApplierTests
Session,
new LiveEntitySessionSink(
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
- _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
+ _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
+ _ => { }),
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
new LiveInventorySessionBindings(
Objects,
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
index 1ad1ec21..64cd5222 100644
--- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs
@@ -50,7 +50,8 @@ public sealed class EntityEffectControllerTests
Func? parentOfAttachedChild = null,
Func? tableLoader = null,
Action? ownerUnregistered = null,
- Action? ownerSoundTableChanged = null)
+ Action? ownerSoundTableChanged = null,
+ Action? playServerSound = null)
{
Spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
@@ -75,7 +76,8 @@ public sealed class EntityEffectControllerTests
childAtPart,
parentOfAttachedChild,
ownerUnregistered,
- ownerSoundTableChanged);
+ ownerSoundTableChanged,
+ playServerSound);
_controller = Controller;
Router.Register(Sink);
Router.Register(Controller);
@@ -171,6 +173,100 @@ public sealed class EntityEffectControllerTests
});
}
+ // ── Campaign A slice A3: the server sound channel (0xF750) ─────────────
+
+ [Fact]
+ public void ServerSound_ForReadyOwner_PlaysImmediatelyWithLocalIdAndWireVolume()
+ {
+ var played = new List<(uint OwnerId, uint SoundType, float Volume)>();
+ var fixture = new Fixture(
+ playServerSound: (ownerId, _, soundType, volume) =>
+ played.Add((ownerId, soundType, volume)));
+ WorldEntity entity = fixture.ReadyLive();
+
+ fixture.Controller.HandleSound(new SoundEvent(Guid, 0x30u, 0.75f));
+
+ var call = Assert.Single(played);
+ // Downstream sinks must see the LOCAL id, never the server guid.
+ Assert.Equal(entity.Id, call.OwnerId);
+ Assert.NotEqual(Guid, call.OwnerId);
+ Assert.Equal(0x30u, call.SoundType);
+ Assert.Equal(0.75f, call.Volume);
+ }
+
+ [Fact]
+ public void ServerSound_ForUnknownGuid_IsQueuedAndReplayedOnArrival()
+ {
+ // Retail queues the blob against the guid in CObjectMaint and drains it
+ // from HandleCreateObject, so a creature that spawns and immediately
+ // grunts still grunts. Dropping it would lose the cue.
+ var played = new List();
+ var fixture = new Fixture(
+ playServerSound: (_, _, soundType, _) => played.Add(soundType));
+
+ fixture.Controller.HandleSound(new SoundEvent(Guid, 0x09u, 1f));
+
+ Assert.Equal(1, fixture.Controller.PendingPacketCount);
+ Assert.Empty(played);
+
+ fixture.ReadyLive();
+
+ Assert.Equal(0, fixture.Controller.PendingPacketCount);
+ Assert.Equal([0x09u], played);
+ }
+
+ [Fact]
+ public void ServerSound_SharesOneQueueWithScriptPackets_AndAllDrainTogether()
+ {
+ // All three packet kinds go through retail's single per-guid blob queue,
+ // so a sound interleaved with scripts is neither dropped nor drained
+ // separately: one readiness edge releases the whole mixed stream.
+ var sounds = new List();
+ var fixture = new Fixture(
+ playServerSound: (_, _, soundType, _) => sounds.Add(soundType));
+
+ fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
+ fixture.Controller.HandleSound(new SoundEvent(Guid, 0x0Au, 1f));
+ fixture.Controller.HandleTyped(new PlayPhysicsScriptType(Guid, RawType, 0.5f));
+
+ Assert.Equal(3, fixture.Controller.PendingPacketCount);
+ Assert.Empty(sounds);
+
+ fixture.ReadyLive();
+
+ Assert.Equal(0, fixture.Controller.PendingPacketCount);
+ Assert.Equal([0x0Au], sounds);
+ Assert.Equal(2, fixture.Runner.ActiveScriptCount);
+ }
+
+ [Fact]
+ public void ServerSound_WithZeroGuid_IsIgnored()
+ {
+ var played = new List();
+ var fixture = new Fixture(
+ playServerSound: (_, _, soundType, _) => played.Add(soundType));
+
+ fixture.Controller.HandleSound(new SoundEvent(0u, 0x30u, 1f));
+
+ Assert.Empty(played);
+ Assert.Equal(0, fixture.Controller.PendingPacketCount);
+ }
+
+ [Fact]
+ public void ServerSound_PlaysAtTheOwnersCurrentRootPose_NotTheEntityOrigin()
+ {
+ var positions = new List();
+ var fixture = new Fixture(
+ playServerSound: (_, position, _, _) => positions.Add(position));
+ fixture.ReadyLive();
+
+ fixture.Controller.HandleSound(new SoundEvent(Guid, 0x30u, 1f));
+
+ // The pose registry is the anchor authority for effects; a sound must
+ // use the same one so an animating owner's cue is not left behind.
+ Assert.Single(positions);
+ }
+
[Fact]
public void PreMaterializationDirectAndTypedPacketsReplayOnceInMixedOrder()
{
diff --git a/tests/AcDream.Core.Net.Tests/Messages/SoundEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SoundEventTests.cs
new file mode 100644
index 00000000..eb535603
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/SoundEventTests.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Wire conformance for retail's Sound event (0xF750). The layout
+/// is agreed by three oracles — retail
+/// CM_Physics::DispatchSB_SoundEvent @ 0x006AC760 (reading
+/// buf+4/+8/+0xC), ACE's GameMessageSound (declared
+/// length 16), and holtburger's PlaySoundData. Decode:
+/// docs/research/2026-08-08-audio-retail-server-sounds.md §1.
+///
+public sealed class SoundEventTests
+{
+ private static byte[] Frame(uint opcode, uint guid, uint soundType, float volume)
+ {
+ var body = new byte[SoundEvent.WireSize];
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(0), opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), guid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), soundType);
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(12), volume);
+ return body;
+ }
+
+ [Fact]
+ public void Opcode_And_WireSize_MatchRetail()
+ {
+ Assert.Equal(0xF750u, SoundEvent.Opcode);
+ Assert.Equal(16, SoundEvent.WireSize);
+ }
+
+ [Fact]
+ public void Parses_AllThreeFields_AtRetailOffsets()
+ {
+ // Attack1 (0x03) on a typical creature guid at half volume.
+ byte[] body = Frame(SoundEvent.Opcode, 0x8000_1234u, 0x03u, 0.5f);
+
+ SoundEvent? parsed = SoundEvent.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x8000_1234u, parsed!.Value.Guid);
+ Assert.Equal(0x03u, parsed.Value.SoundType);
+ Assert.Equal(0.5f, parsed.Value.Volume);
+ }
+
+ [Theory]
+ // A spread across the SoundType range the server actually sends.
+ [InlineData(0x30u)] // HitFlesh1
+ [InlineData(0x51u)] // LifestoneOn
+ [InlineData(0x8Fu)] // PickUpItem
+ [InlineData(0x91u)] // ResistSpell
+ [InlineData(0x97u)] // ItemManaDepleted
+ [InlineData(0xCCu)] // SkillDownVoid, the last retail member
+ public void Parses_EverySoundTypeSlot_Verbatim(uint soundType)
+ {
+ SoundEvent? parsed = SoundEvent.TryParse(
+ Frame(SoundEvent.Opcode, 0x5000_000Au, soundType, 1f));
+ Assert.Equal(soundType, parsed!.Value.SoundType);
+ }
+
+ [Fact]
+ public void Rejects_WrongOpcode()
+ {
+ Assert.Null(SoundEvent.TryParse(Frame(0xF751u, 1u, 1u, 1f)));
+ }
+
+ [Fact]
+ public void Rejects_ShortBody()
+ {
+ byte[] truncated = Frame(SoundEvent.Opcode, 1u, 1u, 1f)[..15];
+ Assert.Null(SoundEvent.TryParse(truncated));
+ }
+
+ [Fact]
+ public void Accepts_TrailingBytes()
+ {
+ // Fragment reassembly can hand us a body with padding; retail reads
+ // three fixed offsets and ignores the rest.
+ byte[] padded = new byte[SoundEvent.WireSize + 8];
+ Frame(SoundEvent.Opcode, 0xAAu, 0x37u, 0.25f).CopyTo(padded, 0);
+
+ SoundEvent? parsed = SoundEvent.TryParse(padded);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0xAAu, parsed!.Value.Guid);
+ Assert.Equal(0x37u, parsed.Value.SoundType);
+ Assert.Equal(0.25f, parsed.Value.Volume);
+ }
+
+ [Theory]
+ // ACE sends volume as a plain float; nothing clamps it on the wire, and
+ // retail clamps only after the distance divide.
+ [InlineData(0f)]
+ [InlineData(1f)]
+ [InlineData(2.5f)]
+ public void Preserves_WireVolume_Unclamped(float volume)
+ {
+ SoundEvent? parsed = SoundEvent.TryParse(
+ Frame(SoundEvent.Opcode, 0x1u, 0x09u, volume));
+ Assert.Equal(volume, parsed!.Value.Volume);
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
index 44b73527..5372a595 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs
@@ -24,7 +24,8 @@ public sealed class LiveSessionEventRouterTests
session,
new LiveEntitySessionSink(
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
- _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
+ _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
+ _ => { }),
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
NewInventoryBindings(),
NewCharacterBindings(),
@@ -400,7 +401,8 @@ public sealed class LiveSessionEventRouterTests
TeleportStarted: _ => { },
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
- PlayPhysicsScriptType: _ => { });
+ PlayPhysicsScriptType: _ => { },
+ SoundEvent: _ => { });
private static LiveEnvironmentSessionSink NoOpEnvironmentSink() => new(
EnvironChanged: _ => { },
@@ -427,7 +429,8 @@ public sealed class LiveSessionEventRouterTests
TeleportStarted: _ => counters.Teleport++,
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
- PlayPhysicsScriptType: _ => { }),
+ PlayPhysicsScriptType: _ => { },
+ SoundEvent: _ => { }),
new LiveEnvironmentSessionSink(
EnvironChanged: _ => { },
ServerTimeUpdated: _ =>