feat(audio): Campaign A slice A3 — the server sound channel (0xF750)

acdream never parsed retail's Sound event, so every server-driven cue was
silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking,
lifestone bind, spell resist, trap triggers, item mana depletion.

SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose
layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent
@0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared
length 16, and holtburger's PlaySoundData.

Playback reuses EntityEffectController's existing per-guid queue rather
than adding a second one, because retail routes sounds through the SAME
CObjectMaint blob queue as F754/F755: an event for a guid the client does
not know yet is parked and drained by HandleCreateObject, so a creature
that spawns and immediately grunts still grunts. Dropping it — the
obvious alternative — would silently lose the cue. Sound joins Direct and
Typed as a third PendingEffect kind so one readiness edge releases the
whole mixed stream in order.

AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the
animation-hook path: the sound plays at the WIRE volume and the
SoundTable entry's volume is ignored (the hook path does the opposite),
while the entry's probability still gates it and its priority still
drives eviction. An object with no SoundTable plays nothing, matching
CPhysicsObj::play_sound @0x0050F460's early return.

The no-window host parses and discards, exactly as it does for F754/F755
— sound is presentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 22:07:23 +02:00
parent e42b99482e
commit 8bc458fb88
13 changed files with 420 additions and 24 deletions

View file

@ -47,7 +47,8 @@ public sealed class LiveMovementStatsApplierTests
Session,
new LiveEntitySessionSink(
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
_ => { }),
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
new LiveInventorySessionBindings(
Objects,

View file

@ -50,7 +50,8 @@ public sealed class EntityEffectControllerTests
Func<uint, uint?>? parentOfAttachedChild = null,
Func<uint, PhysicsScriptTable?>? tableLoader = null,
Action<uint>? ownerUnregistered = null,
Action<uint, uint?>? ownerSoundTableChanged = null)
Action<uint, uint?>? ownerSoundTableChanged = null,
Action<uint, Vector3, uint, float>? 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<uint>();
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<uint>();
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<uint>();
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<Vector3>();
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()
{

View file

@ -0,0 +1,105 @@
using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Wire conformance for retail's <c>Sound</c> event (<c>0xF750</c>). The layout
/// is agreed by three oracles — retail
/// <c>CM_Physics::DispatchSB_SoundEvent</c> @ <c>0x006AC760</c> (reading
/// <c>buf+4</c>/<c>+8</c>/<c>+0xC</c>), ACE's <c>GameMessageSound</c> (declared
/// length 16), and holtburger's <c>PlaySoundData</c>. Decode:
/// <c>docs/research/2026-08-08-audio-retail-server-sounds.md</c> §1.
/// </summary>
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);
}
}

View file

@ -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: _ =>