acdream/docs/research/deepdives/r05-audio-sound.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

44 KiB
Raw Permalink Blame History

R5 — Audio System Deep Dive

Ground-truth port plan for acdream's audio subsystem, derived from decompiled acclient.exe, cross-referenced against ACE (server), ACViewer (dat loader), holtburger (client protocol), AC2D (C++ client), and DatReaderWriter (dat schema). Backend for the port is Silk.NET.OpenAL.


0. Executive summary

Retail AC audio is a thin wrapper over three Microsoft APIs:

Retail component Windows API Dat source Notes
PCM / MP3 playback DirectSound 8 Wave (0x0A) Per-sound secondary buffer, primary buffer for mixing
MIDI music winmm midiStream Loose *.mid files on disk 16 channels, streaming with 6-buffer rotation
Compressed decode winmm ACM Wave data (MP3 body) acmStreamOpen/Convert wraps decompression

Everything on top — SoundTable, ambient rolls, priority eviction, distance falloff, the volume slider stack, footstep selection — is retail-authored C++ logic on top of those three APIs. The retail 3D model is a custom inverse-square falloff computed in software that lands at a final IDirectSoundBuffer::SetVolume(dB) call; retail does not use IDirectSound3DBuffer at all. This is critical to faithful port — OpenAL's native AL_INVERSE_DISTANCE model doesn't match retail; we do the math ourselves and only use OpenAL's raw gain.

Only 16 concurrent sound slots (3D SFX). New sounds with higher computed volume evict the quietest currently-playing slot. Master slider multiplies all SFX. Dedicated ambient, interface, and music channels exist separately.

Well-known SoundId range: 0x00 (Invalid) to 0xCC (SkillDownVoid). Enumerated in full in section 3. Each creature Setup carries a DefaultSoundTable (DID ref to 0x20xxxxxx), and AnimationHooks trigger sounds by SoundId through that table.


1. SoundTable dat layout

Dat range: 0x20000000 0x2000FFFF in client_portal.dat (first-byte tag 0x20).

1.1 On-disk format

From references/ACE/Source/ACE.DatLoader/FileTypes/SoundTable.cs and DatReaderWriter/DBObjs/SoundTable.generated.cs:

SoundTable (Id = 0x20xxxxxx)
  uint32  Id              // = this dat file id
  int32   HashKey         // (aka Unknown in ACE). Same across files; salt for hash fn?
  int32   numHashes
  repeat numHashes times:
    uint32 soundId                  // Sound enum key
    SoundHashData {
      float Priority
      float Probability
      float Volume                   // 0..1 (relative to base)
    }
  int32   numSounds
  repeat numSounds times:
    uint32 soundId                   // Sound enum key (matches numHashes entries 1:1 practically)
    SoundData {
      int32 numEntries
      repeat numEntries times:
        SoundEntry {
          QualifiedDataId<Wave> Id   // 0x0A000... dat ref
          float Priority
          float Probability
          float Volume
        }
      int32 Unknown                  // always 0 in observed data
    }

1.2 Semantic model

A SoundTable maps Sound (enum) → list of candidate Wave DIDs. When you "play the Attack1 sound on this creature," you:

  1. Look up Sound.Attack1 in Sounds[].
  2. From the candidate list, select one randomly, weighted by Probability.
  3. Effective play volume = baseVolume * entry.Volume * masterSlider.
  4. Priority is used for eviction when the 16-slot 3D voice pool is full (lower priority = evict first). See section 11.

1.3 The decompiled pick function

From docs/research/decompiled/chunk_00550000.c at FUN_00551290 — the random-sound picker:

// __thiscall SoundTable::GetRandomSoundData(this, soundEnum, outSlotPtr)
if (soundEnum && FindHashEntry(this, soundEnum, &local)) {
    uint count = *(uint*)(local + 0x7C);   // numEntries
    if (count != 0) {
        uint idx = rand() % count;         // uniform pick; weighting via probability roll later
        SoundEntry* e = (SoundEntry*)(*(int*)(local+0x80) + idx * 0x10);
        out->waveId   = e->Id;
        out->priority = e->Priority;
        out->prob     = e->Probability;
        out->volume   = e->Volume;
        if (out->waveId != 0) return LoadWave(out);
    }
}

The stride is 0x10 = 16 bytes = exactly (uint waveId + 3 × float) = one SoundEntry. The per-sound SoundHashData acts as the default values when you call a sound type directly with no override (e.g. ambient roll uses those three floats, not the SoundEntry ones).

Random pick is uniform over the array; the per-entry Probability is checked after picking (FUN_00550cf0): rand()/RAND_MAX < entry.Probability. If that coin flip fails, the sound is silently dropped. This is how retail avoids ALL spatter variants firing on every hit.

1.4 Where it lives

Each creature Setup (dat 0x02xxxxxx) has a DefaultSoundTable DID (references/ACE/Source/ACE.DatLoader/FileTypes/SetupModel.cs:41). The engine resolves that at PhysicsObj construction (PhysicsObj.cs:670, SoundTable = (SoundTable)DBObj.Get(qdid)).

Common tables (from dat inspection and conventions): drudges, golems, humans, elementals, tuskers, each have their own SoundTable with appropriate Attack1/Wound/Death samples.


2. Wave dat layout

Dat range: 0x0A000000 0x0A00FFFF in client_portal.dat.

2.1 On-disk format

From ACE.DatLoader/FileTypes/Wave.cs and the generated Wave.generated.cs:

Wave (Id = 0x0Axxxxxx)
  uint32  Id
  int32   headerSize
  int32   dataSize
  byte[headerSize]  Header   // raw WAVEFORMATEX (+ extra for compressed formats)
  byte[dataSize]    Data     // raw sample bytes

There is no RIFF "RIFF/WAVE/fmt " wrapping in the dat. To play the blob via DirectSound, retail:

  1. Allocates a DSBUFFERDESC with lpwfxFormat pointing at Header.
  2. For AC-compressed variants, runs Data through acmStreamOpenacmStreamConvert to get raw PCM, then feeds PCM to the secondary buffer.

2.2 Header format detection

  • Header[0] == 0x55 → MP3 (MPEGLAYER3, WAVE_FORMAT_MPEGLAYER3). Retail funnels these through ACM (acmStreamOpen(...,piVar3[1],local_18,...)).
  • Header[0] == 0x01 → linear PCM (WAVE_FORMAT_PCM). Feeds straight into a DirectSound buffer.
  • Header[0] == 0x02 → ADPCM (observed rarely; handled like MP3 via ACM).

ACViewer's export code (Wave.cs:32-72) just prepends a stock RIFF/WAVE header at export time using the first 16 bytes of Header as the fmt body, which is standard — the AC dat header is a standard WAVEFORMATEX, just without the file-format wrapper.

2.3 Observed format conventions

From the DirectSound primary-buffer initializer (chunk_00550000.c:4119-4124, FUN_00554930), the primary mixer is configured as:

  • cbSize = 0
  • wFormatTag = 1 (PCM)
  • nChannels = 2 (stereo mix)
  • nSamplesPerSec = 0x2b11 = 44100 Hz
  • nAvgBytesPerSec = 0xac44 = 176400 (44100 × 2 × 2)
  • nBlockAlign = 4
  • wBitsPerSample = 16

Individual Wave samples may be 11025 Hz or 22050 Hz mono; DirectSound resamples into the 44.1kHz stereo primary buffer transparently.

2.4 Playback object

Retail allocates a 0x20 / 0x24-byte structure per playing sound (see FUN_005df0f5(0x20) + FUN_005df0f5(0x24) in chunk_00550000.c:577,3274):

SoundPlayInstance (~0x24 bytes)
  +0x00  vtable                     // &PTR_FUN_007cbd18 etc
  +0x04  IDirectSoundBuffer*        // the secondary buffer
  +0x08  IDirectSoundNotify*        // completion notify
  +0x0C  byte[] pcm                 // decoded PCM (freed after buffer built)
  +0x10  size of pcm
  +0x14  ?
  +0x18  flags
  +0x1C  sourceWaveId               // 0x0A000xxx, for dedup cache
  ...

3. Well-known SoundIds

From references/ACE/Source/ACE.Entity/Enum/Sound.cs (exact hex values match the dat enum). Grouped for the port's SoundId C# enum:

3.1 Voice / creature lifecycle (0x01 0x1D)

Speak1=0x01  Random=0x02
Attack1=0x03 Attack2=0x04 Attack3=0x05
SpecialAttack1=0x06 SpecialAttack2=0x07 SpecialAttack3=0x08
Damage1=0x09 Damage2=0x0A Damage3=0x0B
Wound1=0x0C  Wound2=0x0D  Wound3=0x0E
Death1=0x0F  Death2=0x10  Death3=0x11
Grunt1=0x12  Grunt2=0x13  Grunt3=0x14
Oh1=0x15     Oh2=0x16     Oh3=0x17
Heave1=0x18  Heave2=0x19  Heave3=0x1A
Knockdown1=0x1B Knockdown2=0x1C Knockdown3=0x1D

3.2 Weapon / combat (0x1E 0x36)

Swoosh1=0x1E Swoosh2=0x1F Swoosh3=0x20
Thump1=0x21  Smash1=0x22  Scratch1=0x23
Spear=0x24   Sling=0x25   Dagger=0x26
ArrowWhiz1=0x27 ArrowWhiz2=0x28
CrossbowPull=0x29 CrossbowRelease=0x2A
BowPull=0x2B      BowRelease=0x2C
ThrownWeaponRelease1=0x2D
ArrowLand=0x2E
Collision=0x2F
HitFlesh1=0x30    HitLeather1=0x31
HitChain1=0x32    HitPlate1=0x33
HitMissile1=0x34  HitMissile2=0x35  HitMissile3=0x36

Hit sounds are selected by target armor type, not weapon. Retail plays exactly one of HitFlesh/Leather/Chain/Plate on damage based on the struck body part's armor.

3.3 Movement (0x37 0x3C)

Footstep1=0x37  // soft/running
Footstep2=0x38  // heavy/walking
Walk1=0x39      // unused in the late client (was first-person walking)
Dance1=0x3A Dance2=0x3B Dance3=0x3C

Footstep selection: see section 4. There is no per-surface SoundId variant — the SoundTable's Footstep1/Footstep2 slots are expected to hold the correct per-creature foot sample, and surface type picks which slot to trigger, not which sample.

3.4 Interaction (0x3D 0x45)

Hidden1=0x3D Hidden2=0x3E Hidden3=0x3F
Eat1=0x40    Drink1=0x41
Open=0x42    Close=0x43
OpenSlam=0x44 CloseSlam=0x45

3.5 Ambient (0x46 0x4E)

Ambient1=0x46 Ambient2=0x47 Ambient3=0x48 Ambient4=0x49
Ambient5=0x4A Ambient6=0x4B Ambient7=0x4C Ambient8=0x4D
Waterfall=0x4E

These are played from the region-level AmbientSTBDesc, not from a creature SoundTable — see section 7.

3.6 Character lifecycle (0x4F 0x5D, 0xCA 0xCC)

LogOut=0x4F   LogIn=0x50
LifestoneOn=0x51
AttribUp=0x52 AttribDown=0x53
SkillUp=0x54  SkillDown=0x55
HealthUp=0x56 HealthDown=0x57
ShieldUp=0x58 ShieldDown=0x59
EnchantUp=0x5A EnchantDown=0x5B
VisionUp=0x5C  VisionDown=0x5D
// "Void" variants for Shadow buffs
HealthDownVoid=0xCA RegenDownVoid=0xCB SkillDownVoid=0xCC

3.7 Magic (0x5E 0x68)

Fizzle=0x5E    Launch=0x5F    Explode=0x60
TransUp=0x61   TransDown=0x62
BreatheFlaem=0x63  BreatheAcid=0x64
BreatheFrost=0x65  BreatheLightning=0x66
Create=0x67    Destroy=0x68

3.8 UI and chimes (0x6A 0x8A)

UI_EnterPortal=0x6A         UI_ExitPortal=0x6B
UI_GeneralQuery=0x6C        UI_GeneralError=0x6D
UI_TransientMessage=0x6E    UI_IconPickUp=0x6F
UI_IconSuccessfulDrop=0x70  UI_IconInvalid_Drop=0x71
UI_ButtonPress=0x72         UI_GrabSlider=0x73  UI_ReleaseSlider=0x74
UI_NewTargetSelected=0x75
// Ambient chimes played via UI channel
UI_Roar=0x76  UI_Bell=0x77
UI_Chant1=0x78  UI_Chant2=0x79
UI_DarkWhispers1=0x7A  UI_DarkWhispers2=0x7B
UI_DarkLaugh=0x7C      UI_DarkWind=0x7D  UI_DarkSpeech=0x7E
UI_Drums=0x7F          UI_GhostSpeak=0x80
UI_Breathing=0x81      UI_Howl=0x82
UI_LostSouls=0x83      UI_Squeal=0x84
UI_Thunder1..6 = 0x85..0x8A

3.9 Inventory / containers (0x69, 0x8B 0x97)

Lockpicking=0x69
RaiseTrait=0x8B
WieldObject=0x8C   UnwieldObject=0x8D
ReceiveItem=0x8E   PickUpItem=0x8F   DropItem=0x90
ResistSpell=0x91
PicklockFail=0x92  LockSuccess=0x93
OpenFailDueToLock=0x94
TriggerActivated=0x95
SpellExpire=0x96   ItemManaDepleted=0x97

3.10 Generic triggers (0x98 0xC9)

TriggerActivated1 … TriggerActivated50 = 0x98 … 0xC9 — 50 generic trigger slots that level designers wire into specific effects per-dungeon (pressure plates, door opens, lever pulls).


4. Surface-material footstep selection

Retail does NOT have a surface-material → SoundId table. Instead:

  1. Every creature's SoundTable contains entries for Footstep1 and Footstep2. Different creatures have different samples (drudge footstep ≠ human footstep ≠ tusker footstep).
  2. The animation's frame hooks declare which footstep to play at each step (left-heavy vs right-heavy, run vs walk).
  3. The surface byte in terrain affects which alternative sample is picked from the Footstep1/Footstep2 SoundEntry list. Each human SoundTable has 24 entries per footstep slot — "grass step", "stone step", "dirt step". Selection is by rand() % count, but the per-entry Volume and Probability are used to keep the most appropriate sample dominant.

Retail's actual "which surface am I on" lookup lives in the terrain subsystem: LandBlock::GetSurfaceType(x,y) returns the dominant terrain type byte for the cell (documented in r04 terrain deep-dive as the per-vertex terrain-type encoding that also drives texture atlas choice). The audio subsystem does not consume surface type directly — it's embedded in the SoundTable design. We can faithfully port this by:

  • Keeping the SoundTable's full per-footstep entry list.
  • Letting the animation hooks pass the current surface hint into SoundTable.Play(SoundId.Footstep1, surfaceHint).
  • Falling back to uniform random when the SoundTable has only one entry.

The 2-slot Footstep1/Footstep2 pair is left-foot vs right-foot, not soft vs loud — alternating based on the animation frame that fires the hook.


5. 3D positional audio — the retail falloff

Retail does NOT use IDirectSound3DBuffer. The entire 3D effect is computed in software and applied via IDirectSoundBuffer::SetVolume(dB) + SetFrequency(pitch) on a plain stereo secondary buffer. Verified from decompiled code:

  • Grep for IDirectSound3D* in the decompiled chunks: zero hits.
  • The only DirectSound API surface used is DirectSoundCreate, CreateSoundBuffer, SetFormat, Play, Stop, SetVolume, SetFrequency, SetPan, QueryInterface(IDirectSoundNotify) (all seen in chunk_005D0000.c imports).

5.1 The falloff function — FUN_00550c30

Decompiled, annotated:

// returns 1 if audible (volume above floor), 0 otherwise.
// out: param_3 = final volume in ad-hoc units fed to SetVolume conversion
// param_1 = distance to listener
// param_2 = base volume (0..1)
// param_4 = channel type: 0 = SFX/ambient, 1 = interface
int FUN_00550c30(float distance, float baseVolume, int* outVol, int channelType)
{
  const float MIN_DISTANCE = _DAT_007cbc64;   // ~1.0f in world units
  const float DISTANCE_K   = _DAT_00870414;   // reference-distance falloff
  const float MAX_VOLUME   = _DAT_007938c0;   // cap (1.0f)
  const float VOLUME_FLOOR = _DAT_00795610;   // ~0.0001f  threshold for "inaudible"
  const float DB_SCALE     = _DAT_00870418;   // dB scaling constant
  const float DB_UNIT      = _DAT_007cbd00;   // convert to SetVolume hundredths

  float v = baseVolume;

  // 1. INVERSE-SQUARE distance attenuation (only beyond MIN_DISTANCE)
  if (distance >= MIN_DISTANCE)
      v = (DISTANCE_K * baseVolume) / (distance * distance);

  // 2. Cap at MAX_VOLUME
  if (v > MAX_VOLUME) v = MAX_VOLUME;

  // 3. Apply channel master volume slider
  float slider = (channelType == 0) ? g_SfxMasterVolume   // 0..1
                                    : g_InterfaceVolume;  // 0..1
  v *= slider;

  // 4. Check audibility
  if (v <= VOLUME_FLOOR) {
      *outVol = DS_VOLUME_FLOOR;     // -10000 hundredths of dB == mute
      return 0;
  }

  // 5. Convert linear 0..1 to DirectSound hundredths-of-dB
  //    0.6931472 = ln(2)  →  log2(v) = ln(v) / ln(2)
  //    final = ceil( log2(v) * DB_SCALE * DB_UNIT )
  int finalHdB = (int)ceilf(log2f(v) * DB_SCALE * DB_UNIT);
  *outVol = finalHdB;
  return 1;
}

Key properties of the retail model:

  • Pure inverse-square between 0 and some "max audible distance"; no rolloff-factor parameter like OpenAL's AL_ROLLOFF_FACTOR.
  • No doppler. Retail does not touch SetFrequency based on relative velocity. Pitch is static per-sound (there's a param_2 * 100 pitch shift in the hundredths, but it's fed by the SoundEntry's base, not runtime doppler).
  • No direction cone. All sounds are omnidirectional. There is a SetPan call in the secondary-buffer path to handle stereo positioning, but only for 2D/L-R based on relative bearing, not HRTF or cone.
  • Stereo / mono toggle in the options panel (ID_Sound_Stereo / ID_Sound_Mono) disables the pan computation for mono output.

5.2 Stereo panning

FUN_00553970(soundInst, pan, volume) (chunk_00550000.c:3140) clamps pan to -0xF..+0xF then calls IDirectSoundBuffer::SetPan(piVar1, pan*100) (offset 0x40 in the DirectSound vtable). Retail multiplies the final float by 100 to convert to the hundredths-of-dB unit DirectSound expects.

The pan value itself is computed by taking the vector from listener to source, projecting onto the listener's right axis, normalizing by some reference distance, and clamping. This is visible in the call sites for FUN_00550d80(inst, listenerRelativeVec, volume, attrFlags) — the second arg param_2 + 0x48 is a WorldObject's position frame, and there's a FUN_005364a0() (likely Vector3::Dot(up_vec) or similar) in the call chain that produces fVar4FUN_00550c30 as distance.

5.3 Priority-based eviction (the 16-slot table)

Retail has a fixed 16-slot voice pool for 3D positional sounds (chunk_00550000.c:527, FUN_00550ad0):

// pool of up to 16 active sounds
static SoundPlayInstance* g_pool[16];     // @ DAT_00870520
static float              g_poolVols[16]; // @ DAT_00870524
static uint32_t           g_poolNext;     // @ DAT_008703b8 (round-robin cursor)

void PlaySound3D(SoundInstance* inst, pan, volume) {
    // First pass: find a free or stopped slot.
    for (int i = 0; i < 16; ++i) {
        uint idx = (g_poolNext + i) & 0xF;
        if (g_pool[idx] == NULL) goto use_slot;
        if (!IsStillPlaying(g_pool[idx])) goto free_and_use;
    }
    // Second pass: evict slot whose current volume < our volume.
    for (int i = 0; i < 16; ++i) {
        uint idx = (g_poolNext + i) & 0xF;
        if (g_poolVols[idx] < inst->volume) goto free_and_use;
    }
    return; // nothing quieter than us → drop

free_and_use:
    StopBuffer(g_pool[idx]);
    DeleteInstance(g_pool[idx]);
use_slot:
    g_pool[idx]     = CreateBufferFromInst(inst);
    g_poolVols[idx] = inst->volume;
    g_poolNext      = (idx + 1) & 0xF;
    ApplyPanVolume(pan, volume);  // → FUN_00553970
}

This is the classic "sound hardware had 16 voices" DirectSound-era hardware-mixing constraint, but the AC client kept it even for pure software mixing. Port target: 16 simultaneous positional sources. Plus the music (MIDI) and UI channels, which don't share this pool.


6. Music system — WinMM MIDI, not PCM

Music is MIDI, streamed through midiStreamOpen (not DirectMusic, not PCM). Evidence: chunk_00550000.c contains:

  • midiStreamOpen(&DAT_00870a70, (LPUINT)&DAT_00820294, 1, 0x554120, 0, 0x30000)
  • midiStreamProperty, midiStreamRestart, midiStreamStop, midiStreamClose
  • midiOutShortMsg(hmo, ...) for per-channel volume (controller 0x07) and pan (0x0A).
  • midiOutPrepareHeader, midiOutUnprepareHeader.
  • MThd/MTrk chunk parsing at FUN_00555150 (0x6468544d = "MThd", 0x6b72544d = "MTrk").

6.1 Architecture

+-----------------------+         +----------------+
| *.mid on disk         | ──────▶ | MIDI parser    |
| (loose files, not dat)|         | FUN_00555150   |
+-----------------------+         +----------------+
                                          │
                                          ▼  6 × 1024-byte buffers
                              +----------------------+
                              | midiStreamOut rotate |  ← "Wait For Buffer
                              | DAT_008707c0[6]      |     Return" event
                              +----------------------+
                                          │
                                          ▼
                              Windows default MIDI device
  • 6 buffers, each 0x400 (1024) bytes, rotated via an event named "Wait For Buffer Return" (chunk_00550000.c:3743). Classic double- (or N-) buffered streaming; when a buffer finishes, Windows sets the event, the worker thread refills it.
  • 16-channel GM pan / volume arrays: DAT_008709b8[16] (current volume per channel), DAT_008709f8[16] (default volume per channel, initialized to 100). Tempo / pitch-bend goes through midiOutShortMsg.
  • No MusicTable. Tracks are identified by file path; retail has lstrcpyA(&DAT_00870770, param_1) storing the current track filename. Track selection is by the game code calling PlayMusic("path/foo.mid", loopFlag) — driven by region/area rules, not by a dat table.

6.2 What this means for the port

  • ACDream.Audio.MusicPlayer is distinct from the SFX engine.
  • Since midiStreamOpen is Windows-only, we replace it with a .NET MIDI library (NAudio.Midi or a minimal SMF parser + our own synth) or skip MIDI entirely and use the available .mid tracks converted to .ogg. Retail's decision to use MIDI was because 1999 CD bandwidth made PCM soundtracks infeasible; acdream has no such constraint. Recommended: convert MIDI to OGG offline, use a single OGG streaming source. This departs from retail but is indistinguishable to the ear and removes a whole OS dependency.
  • Fallback: if users want exactly-retail music, we can implement a tiny SMF player driving a soundfont.

7. Ambient sounds — region-level, not cell-level

7.1 Source

The RegionDesc dat (0x13000000 singleton "Dereth") contains a SoundDesc field (when PartsMask & 0x01):

SoundDesc
  List<AmbientSTBDesc> STBDesc
    AmbientSTBDesc {
      uint STBId                            // identifier/index
      List<AmbientSoundDesc> AmbientSounds
        AmbientSoundDesc {
          Sound SType                        // e.g. Ambient1..8, Waterfall
          float Volume
          float BaseChance                   // if 0, loops continuously
          float MinRate, MaxRate             // seconds between rolls
        }
    }
}

From ACE.DatLoader.Entity.AmbientSoundDesc.IsContinuous => BaseChance == 0.

7.2 Activation model

Ambient sounds are tied to landblock/terrain type, not to EnvCell instances. The retail pattern:

  1. On landblock change, the game queries terrainType for each corner of the current cell and picks the dominant AmbientSTBDesc by STBId (the STBId is indexed by terrain type or region-specific rule).
  2. Active AmbientSTBDesc spawns a per-sound background roll:
    • If BaseChance == 0 → continuous loop on a dedicated voice.
    • Else → every N seconds (where N = rand() in [MinRate, MaxRate]), roll rand() < BaseChance and if true, play the Sound.SType once at Volume, positioned near the listener (at a small random offset so it has subtle 3D movement).
  3. On landblock change or leaving the region, stop all ambient voices associated with the outgoing STBId and start new ones.

7.3 Ambient master volume gate

Separate slider (DAT_008375bc, default 0x3f800000 = 1.0f), separate disable toggle (DAT_008375b8). See section 10.


8. Motion-triggered sounds — AnimationHook dispatch

8.1 Two hook types

From ACE.Entity.Enum.AnimationHookType:

Sound         = 1    → SoundHook        { uint Id }              // play Wave DID directly
SoundTable    = 2    → SoundTableHook   { Sound SoundType }      // play via this obj's SoundTable
SoundTweaked  = 21   → SoundTweakedHook { uint SoundID,           // tweaked with custom params
                                           float Priority,
                                           float Probability,
                                           float Volume }

All three ship on frames of Animation entries (dat type 0x01000000). A walk-cycle animation has SoundTableHook{Footstep1} on frame 4 and SoundTableHook{Footstep2} on frame 12, for example.

8.2 Dispatch point

Per AnimHook.Execute in ACE's physics port (placeholder; the retail client does it in CAnimationSequencer::ProcessFrameHooks):

void ProcessFrame(AnimationHook hook, PhysicsObj obj) {
    switch (hook.HookType) {
        case AnimationHookType.Sound:
            // direct Wave DID, bypasses SoundTable
            var h = (SoundHook)hook;
            audioEngine.PlayWaveAtPosition(h.Id, obj.Position, obj.Velocity, defaultVolume: 1.0f);
            break;
        case AnimationHookType.SoundTable:
            // look up via object's SoundTable
            var st = (SoundTableHook)hook;
            obj.SoundTable?.Play(st.SoundType, obj.Position, obj.Velocity);
            break;
        case AnimationHookType.SoundTweaked:
            // one-off sound with overridden priority/probability/volume
            var t = (SoundTweakedHook)hook;
            audioEngine.PlayWaveTweaked(t.SoundID, obj.Position,
                                         t.Priority, t.Probability, t.Volume);
            break;
    }
}

The retail MotionInterpreter fires hooks in order within a frame, so if two SoundTable hooks coexist on the same frame they both play (subject to voice-pool eviction).

8.3 Our integration point

The acdream MotionInterpreter (src/AcDream.Core/Physics/MotionInterpreter.cs) and AnimationSequencer (src/AcDream.Core/Physics/AnimationSequencer.cs) need a hook-callback contract. Propose:

public interface IAnimationHookSink
{
    void OnSoundHook(uint waveId, Vector3 pos, Vector3 vel);
    void OnSoundTableHook(SoundId id, Vector3 pos, Vector3 vel);
    void OnSoundTweakedHook(uint waveId, Vector3 pos, Vector3 vel,
                             float priority, float probability, float volume);
}

The AudioEngine implements this interface, and AnimationSequencer receives an IAnimationHookSink in its constructor. This keeps animation code audio-agnostic while giving the audio engine full access to hook timing.


9. Server-sent sound — GameMessageSound (opcode 0xF750)

9.1 Wire format

From ACE.Server.Network.GameMessages.Messages.GameMessageSound and cross-verified against holtburger PlaySoundData:

GameMessage  (opcode = 0xF750)
  Guid    target      // 8 bytes (object this sound plays on)
  uint32  soundId     // Sound enum value
  float   volume      // 0..1

Total payload: 16 bytes after the opcode.

Opcode Name (ACE) Payload Purpose
0xF750 Sound / PlaySoundData {Guid, uint soundId, float volume} Per-object sound via the object's SoundTable
0xF754 PlayScriptId {Guid, uint scriptId} PlayScript (particle emitter; may have attached sound entries in PhysicsScript)
0xF755 PlayEffect / GameMessageScript {Guid, uint PlayScript, float speed} Canned visual-+-sound effect package (e.g. Fizzle, Launch, etc — full PlayScript enum in section 3 ref)

The PlayScript enum (see ACE.Entity.Enum.PlayScript) has 95+ entries like Fizzle=0x51, PortalEntry=0x52, BreatheFlame=0x54, Create=0x58, Destroy=0x59, etc — each is a bundle of a particle effect and a sound. Client resolves via a PhysicsScriptTable (not audio's concern, but the sound portion funnels into PlaySound).

9.3 Object-referenced vs anonymous

  • Retail always plays sounds attached to an object — the object's position is the 3D source, its SoundTable resolves the sample.
  • There is no "play at (x,y,z)" opcode. If the server wants a location-based sound, it attaches it to a hidden/ephemeral weenie at that location. This matches the observation that non-weenie ambient sounds (landblock ambient) are client-driven from the RegionDesc entries, not sent by the server.

10. Volume sliders — the stack

From chunk_00400000.c:1705-1728 (the settings UI init) and chunk_00550000.c:875-930 (the options-save path), retail exposes six volume controls plus a focus-gate:

UI label Internal var Default Semantic
Sound Disabled (checkbox) DAT_008375b0 off Master SFX mute
Effect Volume (slider 0..1) DAT_008375b4 1.0 SFX master multiplier
Ambient Sound Disabled (checkbox) DAT_008375b8 off Ambient mute
Ambient Volume (slider) DAT_008375bc 1.0 Ambient master multiplier
Interface Sound Disabled (checkbox) DAT_008375c0 off UI mute
Interface Volume (slider) DAT_008375c4 1.0 UI master multiplier
Play Sound Only When Active DAT_008375cc off mute when app not focused

Plus a stereo/mono picker (DAT_008375c8) with values ID_Sound_Stereo / ID_Sound_Mono.

There is no separate music slider in retail. Music uses its own WinMM midiOutSetVolume-driven master (one hex volume uint, default 0x7FFF per channel) and is controlled by the per-channel DAT_008709b8[16] pan+volume arrays. In practice users typically turn down music via the default mixer rather than in-game.

10.1 Application order

For a 3D SFX sound:

finalLinear = baseSampleVolume          // per SoundEntry.Volume
            * soundTableEntry.Volume     // sometimes 1.0, sometimes tweaked
            * falloff(distance)
            * masterEffectVolume         // slider 0..1
            * (masterMute ? 0 : 1)
            * (appFocused || !focusMute ? 1 : 0)

For ambient:

finalLinear = ambientSample.Volume
            * ambientMasterVolume
            * (ambientMute ? 0 : 1)
            * (appFocused || !focusMute ? 1 : 0)

For UI/interface:

finalLinear = UiSample.Volume           // usually 1.0 (no falloff, no 3D)
            * interfaceMasterVolume
            * (interfaceMute ? 0 : 1)
            * (appFocused || !focusMute ? 1 : 0)

Final per-voice volume is then converted linear→dB and IDirectSoundBuffer::SetVolume(hundredths_dB) is called.


11. DirectSound API surface

Direct API calls seen in decompiled code (chunk_00550000.c + chunk_005D0000.c):

Retail call Retail location Purpose
DirectSoundCreate(0, &pDS, 0) FUN_00554930 Create device
IDirectSound::SetCooperativeLevel(hwnd, DSSCL_PRIORITY=2) same Cooperative level = PRIORITY
IDirectSound::CreateSoundBuffer(&DSBUFFERDESC, &pBuf, 0) same Primary buffer
IDirectSoundBuffer::QueryInterface(IID_IDirectSoundNotify, ...) same For completion notify
IDirectSoundNotify::SetNotificationPositions(pos) implied vtable slot 0x3C 1 notify at end of play
IDirectSoundBuffer::SetFormat(&wfx) vtable slot 0x38 Set primary mix format 44.1kHz stereo 16-bit
IDirectSoundBuffer::Play(0, 0, DSBPLAY_LOOPING=1) vtable 0x30 Start primary buffer looping
IDirectSoundBuffer::SetVolume(hundredths_dB) vtable 0x34 Per-voice volume
IDirectSoundBuffer::SetFrequency(Hz) vtable 0x3C Pitch (per-sound random variance)
IDirectSoundBuffer::SetPan(hundredths_dB_LR) vtable 0x40 Stereo pan
IDirectSoundBuffer::Lock/Unlock vtable 0x4C Write PCM into secondary buffers
IDirectSoundBuffer::Stop vtable 0x48 Stop on eviction
acmStreamOpen/Convert/Close FUN_005532f0 Decompress MP3/ADPCM to PCM

IDirectSound3D* is not used. No SetListener, no SetRolloffFactor, no SetDopplerFactor, no SetDistanceFactor, no SetOrientation. All 3D math is in the CPU path.


12. Mixer thread

Retail uses DirectSound's internal mixer thread (running inside dsound.dll). Application code only fires Play() and relies on IDirectSoundNotify for stop/complete events.

MIDI is a separate story: midiStreamOut is async via midiStreamOpen's MidiCallback parameter (0x554120 in the call — offset to a retail callback function) which fires MOM_DONE events on a Windows-owned thread. The named event "Wait For Buffer Return" is signaled from that callback, then the MIDI parser thread wakes and refills the next MThd/MTrk chunk.

Port target: OpenAL runs its own mixer thread internally. We don't need to manage one. Our main-loop audio tick only needs to:

  • Update listener position/orientation from the camera.
  • Check for finished voices and recycle slots.
  • Poll for pending ambient rolls and schedule their next Play().

13. Port plan — C# class layout

Target namespace: AcDream.Audio. Backend: Silk.NET.OpenAL. All files under src/AcDream.Core/Audio/ (platform-free) and src/AcDream.App/Audio/ (platform Silk glue). Keep dat parsing under src/AcDream.Core/Dat/.

13.1 Classes

AcDream.Core.Audio
├── SoundId.cs                 // enum matching ACE.Entity.Enum.Sound 1:1
├── SoundDat                   // parses SoundTable dat via DatCollection
│   ├── SoundTable  record     // (uint Id, Dictionary<SoundId,SoundData> Sounds, …)
│   ├── SoundEntry  record     // (uint WaveId, float Priority, Prob, Volume)
│   └── WaveBlob    record     // (uint Id, byte[] Header, byte[] Data, AudioFormat Parsed)
├── WaveDecoder.cs             // WAVEFORMATEX parsing + PCM/MP3/ADPCM → Int16/Float32 PCM
├── SoundCache                 // LRU cache: WaveId → decoded PCM + OpenAL buffer ID
├── AudioFalloff.cs            // static: retail inverse-square + dB conversion
├── AudioSource3D              // one voice, wraps an OpenAL source
│   ├── WaveId, Priority, Volume, Position, Velocity
│   ├── bool UpdateAttenuation(listener) → updates AL_GAIN, AL_PITCH, AL_POSITION
│   └── PlayState { NotStarted, Playing, Finished, Evicted }
├── AudioEngine
│   ├── 16 × AudioSource3D  voicePool
│   ├── AudioListener3D     listener
│   ├── SfxMasterVolume, AmbientMasterVolume, UiMasterVolume, FocusGate  (properties)
│   ├── Play(Guid target, SoundId id, float volume, Vector3 pos, Vector3 vel)
│   ├── PlayWaveDirect(uint waveId, …)
│   ├── PlayWaveTweaked(uint waveId, float priority, prob, volume, …)
│   ├── PlayUI(SoundId id, float volume)       // 2D, no falloff, no pool competition
│   ├── UpdateListener(Vector3 pos, Quaternion rot)
│   └── Tick(float dt)    // advance ambient rolls, recycle finished voices
└── AmbientPlayer
    ├── RegionSoundDesc (parsed SoundDesc)
    ├── ActiveSTBId  (current terrain STBId)
    ├── per-AmbientSoundDesc schedule state: nextFireTime
    ├── Tick(dt, listenerPos)
    └── ApplyRegionChange(STBId newId)

AcDream.App.Audio
└── OpenAlBackend              // ALContext, buffer/source wrappers

13.2 Key method sketches

// AudioFalloff.cs - faithful port of FUN_00550c30
public static bool Compute(float distance, float baseVolume, AudioChannel ch,
                            out float gain, out float pitchMul)
{
    const float MIN_DIST   = 1.0f;
    const float DIST_K     = 1.0f;     // = DAT_00870414 calibrated at runtime
    const float MAX_VOL    = 1.0f;
    const float VOL_FLOOR  = 1e-4f;

    float v = baseVolume;
    if (distance >= MIN_DIST)
        v = (DIST_K * baseVolume) / (distance * distance);

    if (v > MAX_VOL) v = MAX_VOL;

    float slider = ch switch {
        AudioChannel.Sfx       => AudioEngine.SfxMasterVolume,
        AudioChannel.Ambient   => AudioEngine.AmbientMasterVolume,
        AudioChannel.Interface => AudioEngine.UiMasterVolume,
        _ => 1f
    };
    v *= slider;
    if (!AudioEngine.FocusActive) v = 0;

    if (v <= VOL_FLOOR) { gain = 0; pitchMul = 1f; return false; }
    gain = v;
    pitchMul = 1f;  // retail has optional per-sound pitch variance (random ±5%); apply here later
    return true;
}
// AudioEngine.Play - faithful port of the 16-slot eviction policy
public void Play(SoundId id, SoundTable table, Vector3 pos, Vector3 vel,
                  float baseVolume = 1.0f)
{
    if (!table.Sounds.TryGetValue(id, out var soundData))   return;
    if (soundData.Entries.Count == 0)                       return;

    // Uniform pick (retail: rand() % count), then probability check
    int idx = _rng.Next(soundData.Entries.Count);
    var entry = soundData.Entries[idx];
    if (_rng.NextSingle() >= entry.Probability) return; // sound rolled away

    float dist = Vector3.Distance(pos, Listener.Position);
    if (!AudioFalloff.Compute(dist, baseVolume * entry.Volume,
                               AudioChannel.Sfx, out float gain, out float pitch))
        return;   // inaudible

    // Voice pool: round-robin scan, evict quietest weaker voice
    int slot = AcquireVoiceSlot(gain, entry.Priority);
    if (slot < 0) return;    // nothing weaker → drop

    var source = _voicePool[slot];
    source.LoadWave(entry.WaveId, _soundCache);
    source.Position = pos;
    source.Velocity = vel;
    source.Gain     = gain;
    source.Pitch    = pitch;
    source.Priority = entry.Priority;
    source.Play();
}

int AcquireVoiceSlot(float incomingVolume, float incomingPriority)
{
    // Pass 1: free / finished
    for (int i = 0; i < 16; i++) {
        int idx = (_poolCursor + i) & 0xF;
        var v = _voicePool[idx];
        if (!v.IsPlaying) return AdvanceCursor(idx);
    }
    // Pass 2: evict quieter-than-us using weighted volume (priority tie-breaker)
    // Retail uses pure volume comparison; priority just affects computed volume pre-selection.
    for (int i = 0; i < 16; i++) {
        int idx = (_poolCursor + i) & 0xF;
        if (_voicePool[idx].Gain < incomingVolume) {
            _voicePool[idx].Stop();
            return AdvanceCursor(idx);
        }
    }
    return -1;
}
// AmbientPlayer.Tick - matches AmbientSoundDesc semantics
public void Tick(float dt, Vector3 listenerPos)
{
    if (_activeDesc == null) return;
    foreach (var s in _activeStates) {
        if (s.Desc.IsContinuous) {
            if (!s.ContinuousSource.IsPlaying)
                s.ContinuousSource.Play();    // loops forever, position tracks listener
            continue;
        }
        s.NextFireIn -= dt;
        if (s.NextFireIn <= 0) {
            // roll chance
            if (_rng.NextSingle() < s.Desc.BaseChance) {
                // play near listener (retail scatters within ~8m box)
                var pos = listenerPos + _rng.InUnitSphere() * 8f;
                _engine.PlayAmbient(s.Desc.SType, s.Desc.Volume, pos);
            }
            // schedule next
            s.NextFireIn = _rng.NextFloat(s.Desc.MinRate, s.Desc.MaxRate);
        }
    }
}

13.3 OpenAL parameter mapping

Because retail does all attenuation in software, we set OpenAL to linear gain passthrough:

// on engine init:
_al.DistanceModel(DistanceModel.None);   // disable OpenAL's rolloff
_al.DopplerFactor(0f);                    // retail has no doppler
// for each source:
alSource.Gain  = computedGain;            // 0..1 from our falloff
alSource.Pitch = pitchMul;
alSource.Position = worldPos;             // informational only (model=None)
alSource.Velocity = Vector3.Zero;         // no doppler, keep at 0

For stereo panning of 2D UI sounds, mono Wave files positioned with a source that has SourceRelative=true at (pan, 0, distFixed) will produce the expected L-R balance via OpenAL's minimal spatialization, or we can explicitly feed stereo PCM into two mono sources with gain-distributed panning. Simplest path: keep UI sounds mono, set Source.Position to (computedPan, 0, -1) relative, and leave DistanceModel=None — OpenAL still applies default HRTF/stereo-pan math for relative sources.


14. Integration points

14.1 Player footsteps

  • PlayerMovementController (src/AcDream.App/Input/PlayerMovementController.cs) drives motion; the MotionInterpreter processes AnimationHooks each frame.
  • Hook into AnimationSequencer's frame-advance callback. When a frame has a SoundTableHook with SoundType=Footstep1/2, call AudioEngine.Play(Footstep1, player.SoundTable, player.Position, player.Velocity, baseVolume: 1.0f).
  • Surface-type hint (from terrain subsystem) can be fed to a SoundTable.PlayWithSurfaceHint(Footstep1, surface) overload that biases the uniform random pick toward entries whose names hint at the material.

14.2 Combat hit sounds

  • Combat system ports to acdream later (Phase 6+). When GameMessageSound (opcode 0xF750) arrives from the server:
void OnNetSound(Guid target, SoundId id, float volume) {
    var obj = _gameState.FindObject(target);
    if (obj == null || obj.SoundTable == null) return;
    _engine.Play(id, obj.SoundTable, obj.Position, obj.Velocity, volume);
}
  • Attack swing / bowstring sounds are triggered by SoundTableHook on the attacker's animation — already covered by the 14.1 path.
  • Impact sounds (HitFlesh/HitLeather/…) require the server to decide armor type and fire 0xF750 with the right SoundId aimed at the struck object.

14.3 Spellcasting

  • Cast-start (windup) sounds: SoundTableHook{Launch} on the caster's casting animation.
  • In-flight whoosh: either the projectile weenie has a SoundTable with a looping Swoosh* played each frame, or the projectile PhysicsScript fires a continuous sound. We favor the former for simpler state.
  • Impact / resolution: server sends GameMessageScript{Fizzle} or {Explode} on opcode 0xF755 (PlayEffect) — PlayScript resolves to both a particle + sound bundle.

14.4 Ambient loop on landblock load

  • Streaming/GpuWorldState has a landblock-change event. Wire AmbientPlayer.ApplyRegionChange(newSTBId) there.
  • STBId selection: TERRAIN_TYPE byte of the current cell's dominant vertex → lookup in RegionDesc.SoundInfo.STBDesc (or use a simpler "by terrain-type-byte index" for phase 1; we can refine once we match retail behavior visually/aurally).

14.5 UI sounds

  • UI_ButtonPress, UI_IconPickUp, etc fire from UI input handling.
  • Dedicated AudioEngine.PlayUI(SoundId) path that skips the voice-pool eviction (UI sounds go to their own small pool or are fire-and-forget).

15. Research gaps / future work

  1. Exact DAT_00870414 and DAT_00870418 calibration constants — the decompiled code exposes the function signature but not the numeric defaults. Recommended: instrument retail (or a retail-server client test harness) to dump them at runtime, or derive from listening-distance tests (e.g. "Drudge footsteps audible out to ~30m → back-solve DIST_K").
  2. Surface-type-to-footstep-sample mapping — confirmed as being "implicit in the SoundTable's multi-entry lists," but the specific rule for which entry index maps to which surface type needs a sound-table dump + listening test.
  3. MIDI file inventory — where are the actual *.mid files stored? They're not in client_portal.dat. Likely <install>/sound/*.mid as loose files. Confirm with a retail-install inventory.
  4. PlayScript sound attachment — PlayScript effects (dat type 0x33xxxxxx?) each carry a list of frames with optional sound hooks. Depth analysis is part of R6 (particles); we link to it at that time.
  5. Pitch variance — retail has some random pitch variance on certain sounds (probably ±5% or so to avoid "identical twins" artifacts on rapid-fire Wound1 sounds). Per-SoundEntry flag or global? TBD from more decomp.
  6. Stereo pan specifics — precise projection formula for SetPan needs deeper grep of FUN_00550d80's caller chain to extract the pan scalar computation.

16. References

  • Decompiled retail: docs/research/decompiled/chunk_00550000.c (entire chunk is the sound subsystem + MIDI + ACM wrappers + options UI callbacks; also chunk_005D0000.c:12314 for DirectSoundCreate import).
  • ACE dat loader: references/ACE/Source/ACE.DatLoader/FileTypes/{SoundTable,Wave}.cs and Entity/{SoundData,SoundTableData,SoundDesc,AmbientSoundDesc,AmbientSTBDesc}.cs.
  • ACE enums: references/ACE/Source/ACE.Entity/Enum/{Sound,AnimationHookType,PlayScript}.cs.
  • ACE game messages: references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs (opcodes 0xF750/F754/F755) and Messages/GameMessageSound.cs, Messages/GameMessageScript.cs.
  • ACE physics: references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:3088 (play_sound) and Physics/Hooks/AnimHook.cs.
  • Holtburger client: references/holtburger/crates/holtburger-protocol/src/messages/effects/types.rs (PlaySoundData, 16-byte layout confirming ACE).
  • DatReaderWriter schema: references/DatReaderWriter/DatReaderWriter/dats.xml:3751 (Wave type, 0xA000000..0xA00FFFF) and :3918 (SoundTable type, 0x20000000..0x2000FFFF); generated types in DatReaderWriter/Generated/{DBObjs,Types}/.
  • ACViewer reader (same base as ACE, useful cross-check): references/ACViewer/ACViewer/FileTypes/{SoundTable,Sound}.cs.
  • AC2D client opcode reference: references/AC2D/cNetwork.cpp:2009-2029 (0xF750 is "sound effect", 0xF755 is "visual/sound effect", unimplemented placeholders).