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
|
|
@ -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 | — | — | — |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ public sealed record LiveEntitySessionSink(
|
|||
Action<uint> TeleportStarted,
|
||||
Action<ObjDescEvent.Parsed> AppearanceUpdated,
|
||||
Action<PlayPhysicsScript> PlayPhysicsScript,
|
||||
Action<PlayPhysicsScriptType> PlayPhysicsScriptType);
|
||||
Action<PlayPhysicsScriptType> PlayPhysicsScriptType,
|
||||
Action<SoundEvent> SoundEvent);
|
||||
|
||||
public sealed record LiveEnvironmentSessionSink(
|
||||
Action<uint> 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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { });
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ public sealed class LiveMovementStatsApplierTests
|
|||
Session,
|
||||
new LiveEntitySessionSink(
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
||||
_ => { }),
|
||||
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
|
||||
new LiveInventorySessionBindings(
|
||||
Objects,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
105
tests/AcDream.Core.Net.Tests/Messages/SoundEventTests.cs
Normal file
105
tests/AcDream.Core.Net.Tests/Messages/SoundEventTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: _ =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue