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 @ + /// 0x006AC760SmartBox::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: _ =>