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

@ -101,6 +101,45 @@ public sealed class AudioHookSink : IAnimationHookSink
}
}
/// <summary>
/// Plays a server-addressed <c>SoundType</c> slot — retail's
/// <c>SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float)</c> @
/// <c>0x00550AF0</c>, reached from <c>CPhysicsObj::play_sound</c> @
/// <c>0x0050F460</c>.
///
/// <para>
/// Two asymmetries with the animation-hook path above, both from the decode
/// and both deliberate: the sound plays at the <b>wire</b> 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.
/// </para>
/// </summary>
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)

View file

@ -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));

View file

@ -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));
}

View file

@ -34,6 +34,7 @@ public sealed class EntityEffectController : IAnimationHookSink,
private readonly Func<uint, uint?> _parentOfAttachedChild;
private readonly Action<uint> _ownerUnregistered;
private readonly Action<uint, uint?> _ownerSoundTableChanged;
private readonly Action<uint, Vector3, uint, float> _playServerSound;
private readonly Dictionary<RuntimeEntityKey, EntityEffectProfile> _liveProfiles = [];
private readonly HashSet<RuntimeEntityKey> _readyLiveOwners = [];
// C3c constructs the App effect owner before Runtime's initial placement
@ -64,7 +65,8 @@ public sealed class EntityEffectController : IAnimationHookSink,
Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null,
Action<uint>? ownerUnregistered = null,
Action<uint, uint?>? ownerSoundTableChanged = null)
Action<uint, uint?>? ownerSoundTableChanged = null,
Action<uint, Vector3, uint, float>? 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));
}
/// <summary>
/// Retail's server-driven sound channel, <c>SmartBox::HandleSoundEvent</c> @
/// <c>0x00451FC0</c>. 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 <c>CObjectMaint</c> blob queue
/// — an unknown guid parks the blob and <c>HandleCreateObject</c> drains it,
/// so a creature that spawns and immediately grunts still grunts.
/// </summary>
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);
}
/// <summary>
/// Plays one server-addressed <c>SoundType</c> slot on a ready owner, at the
/// owner's current root pose. Retail's <c>CPhysicsObj::play_sound</c> @
/// <c>0x0050F460</c> drops the sound silently when the object carries no
/// SoundTable, which the sink below reproduces.
/// </summary>
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);
}
}