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:
parent
e42b99482e
commit
8bc458fb88
13 changed files with 420 additions and 24 deletions
41
src/AcDream.Core.Net/Messages/SoundEvent.cs
Normal file
41
src/AcDream.Core.Net/Messages/SoundEvent.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Sound</c> game message (<c>0xF750</c>): the server tells the client
|
||||
/// to play one <c>SoundType</c> slot from an object's SoundTable.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout, 16 bytes, S→C — three oracles agree:
|
||||
/// <c>u32 opcode | u32 guid | u32 SoundType | f32 volume</c>. Retail dispatch is
|
||||
/// <c>CM_Physics::DispatchSB_SoundEvent</c> @ <c>0x006AC760</c> (which reads
|
||||
/// <c>buf+4</c>, <c>buf+8</c>, <c>buf+0xC</c>); ACE writes exactly those fields
|
||||
/// in <c>GameMessageSound.cs</c> with a declared length of 16; holtburger's
|
||||
/// <c>PlaySoundData</c> parses the same triple.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<byte> 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..]));
|
||||
}
|
||||
}
|
||||
|
|
@ -513,6 +513,24 @@ public sealed class WorldSession : IDisposable
|
|||
/// <summary>Fires for retail typed PhysicsScript playback (0xF755).</summary>
|
||||
public event Action<PlayPhysicsScriptType>? PlayPhysicsScriptTypeReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Fires for retail's <c>Sound</c> event (<c>0xF750</c>) — the server-driven
|
||||
/// sound channel: hits, wounds, wield/unwield, pickup/drop, lockpicking,
|
||||
/// lifestone bind, spell resist, trap triggers, item mana depletion.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's chain is <c>CM_Physics::DispatchSB_SoundEvent</c> @
|
||||
/// <c>0x006AC760</c> → <c>SmartBox::HandleSoundEvent</c> @ <c>0x00451FC0</c>
|
||||
/// → <c>CPhysicsObj::play_sound</c> @ <c>0x0050F460</c>. 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public event Action<SoundEvent>? SoundEventReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5d — retail's <c>AdminEnvirons</c> packet (opcode
|
||||
/// <c>0xEA60</c>) — 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue