acdream/docs/research/2026-08-08-audio-retail-music-absence.md
Erik ffa5087527 docs: Campaign A (audio parity) — six-lane retail decode + campaign plan
Full review of the audio subsystem against the named 2013 retail decomp,
with byte-verification of every load-bearing float compare (five BN
polarity/constant elisions caught). Headlines: retail is a CPU-side 2D
pan+gain engine (no 3D listener in use); the SoundTable probability field
is a Bernoulli SILENCE gate our SoundCookbook never applies (4,183/4,184
entries are single-entry and we short-circuit them); 0xF750 server sounds
are entirely unhandled; ambients are region-authored weighted one-shots
(indoors silent by design); and retail EoR has NO music system at all.

Plan proposes slices A1-A6; awaiting user go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:55:33 +02:00

29 KiB
Raw Permalink Blame History

Lane 6 — Retail MUSIC system (MediaMachine / MD_Data_Sound / winmm MIDI)

Read-only research note. Sources: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR build, PDB-named), acclient.h (verbatim retail structs), symbols.json, plus references/DatReaderWriter/, references/ACViewer/, the retail install at C:\Turbine\Asheron's Call\, and the live UserPreferences.ini.


0. Headline finding — retail EoR HAS NO MUSIC SYSTEM

This is the load-bearing result and it contradicts the standing assumption in docs/research/deepdives/r05-audio-sound.md §6.

Three independent pieces of evidence, all from the exact PDB-paired 2013 build:

  1. midiPlay has zero callers. The only three occurrences of address 0x00553390 in the whole 65 MB pseudo-C are the function's own definition/open/close lines (acclient_2013_pseudo_c.txt:350072,350074,350133). The single internal call is midiPlayNextmidiPlay (:350147), and midiPlayNext is itself only reachable from the MidiProc buffer-done callback (:350223) — i.e. it only ever advances a queue that nothing ever fills.
  2. Both MIDI callbacks are permanently null. midiEventCallback and midiStartCallback are statically initialised to 0 (:1185597, :1185598) and there is no assignment site anywhere in the image. MidiProc null-checks them on every event and no-ops.
  3. No music preference and no music files. SoundManager::InitPrefs / ShutDown register exactly eight sound preferences — SoundDisabled, SoundVolume, AmbientSoundDisabled, AmbientSoundVolume, InterfaceSoundDisabled, InterfaceSoundVolume, SoundFeatures, PlaySoundOnlyWhenActive (:346764:346810 region, Unregister list at :00550367:005503ad). The live %USERPROFILE%\Documents\Asheron's Call\UserPreferences.ini [Sound] section contains exactly those keys — no music volume, no music toggle. And C:\Turbine\Asheron's Call\ contains zero .mid / .rmi / .mp3 / .wav files; the only media file on disk is turbine_logo_ac.avi.
  4. The word "music" does not appear anywhere in the 65 MB pseudo-C (case-insensitive grep: 0 hits), and SoundType (the 0x000xCC enum, acclient.h:4569) has no music member.

So: the client links a complete Microsoft-sample-derived SMF streaming player, initialises it at startup (SoundManager::InitmidiSetup(), :346764), tears it down at shutdown (SoundManager::ShutDownmidiCleanup(), :346655), and never hands it a file. It is dead infrastructure — a vestige of a 1999 design decision that was cut.

What players actually hear as "music" in retail EoR is one of three things, all of them ordinary DAT Wave (0x0A) PCM played through the normal DirectSound path:

Perceived as Actually is Retail mechanism
Login / splash score audio track of turbine_logo_ac.avi MD_Data_Movie → DirectShow IGraphBuilder
Dungeon "chanting/drums/whispers" atmosphere UI SoundTable stingers CPlayerSystem::Handle_Admin__Environs codes 0x650x7C
Outdoor/dungeon soundscape region ambient sound rolls Ambient priority queue + AmbientSTBDesc

1. The MIDI subsystem (documented for completeness / correction of r05)

Free functions, all at 0x00552f600x00553840. It is a near-verbatim port of Microsoft's MIDIPLYR SDK sample, including the literal event name "Wait For Buffer Return" (:350321).

Symbol Addr Role
midiSetup() 0x00553770 midiOutGetNumDevs, fill dwVolCache[16]/dwVolPctCache[16] with 100, CreateEventA("Wait For Buffer Return"), midiStreamOpen(&hStream, &uMIDIDeviceID, 1, MidiProc, 0, 0x30000). Sets MidiIsSetup.
StreamBufferSetup(char* path) 0x00553030 allocates 6 buffers × 0x400 bytes (LocalAlloc(LMEM_ZEROINIT, 0x423) then 32-byte-aligned), ConverterInit(path), midiStreamProperty(…, 0x80000001) = set time division, primes all 6 via ConvertToBuffer + midiOutPrepareHeader + midiStreamOut.
midiPlay(path, loop, immediate, tempoMul) 0x00553390 if already playing and immediate==0 → stash into pending/pending_loop/dwQueuedTempoMultiplier, set is_pending, return (that is the "queue next track" path). Otherwise midiStop(), StreamBufferSetup, then per-channel `midiOutShortMsg(0xB0
midiPlayNext() 0x005534c0 pops pendingmidiPlay(pending, pending_loop, 1, …).
MidiProc 0x00553500 MOM_DONE (0x3C9) refills/rotates buffers mod 6; on end-of-data (uCallbackStatus == 0x12C) waits for all 6 buffers back, then either midiPlayNext() if a track is queued or midiStop(). MOM_POSITIONCB (0x3CA) sniffs the stream: caches controller-7 volume per channel and forwards note-on/off + volume on channels 14/15 only to midiEventCallback (a game-sync hook — never installed).
midiStop() 0x00553240 midiStreamStop, midiOutReset, WaitForSingleObject(hBufferReturnEvent, 0x7D0 = 2000 ms), ConverterCleanup, FreeBuffers, close+reopen the stream, reset is_pending/pending_loop/dwQueuedTempoMultiplier = 100.
SetChannelVolume(ch, pct) 0x00552f60 `midiOutShortMsg(0xB0
ConverterInit(path) 0x00554530 CreateFileA(path, GENERIC_READ, …) → reads 'MThd' (0x6468544D), byte-swaps header, dwFormat/dwTrackCount/dwTimeDivision, then per track reads 'MTrk' (0x6B72544D) into a 0x400 window. Standard MIDI File from a loose disk path — never from a DAT.

Corrections to r05-audio-sound.md §6

r05 claim Verdict
"Music is MIDI, streamed through midiStreamOpen" Correct as to mechanism, wrong as to it being used.
6 × 1024-byte buffers, "Wait For Buffer Return" event, 16-channel volume arrays Confirmed.
"MThd/MTrk parsing at FUN_00555150" Right idea, wrong address in the named build: ConverterInit @ 0x00554530.
"pan (0x0A)" via midiOutShortMsg Not found. Only controller 7 (volume) is written (SetChannelVolume, midiPlay). No pan CC.
"Track selection is by the game code calling PlayMusic(path, loop) — driven by region/area rules" REFUTED. No such caller and no region→track table exists. This sentence is the source of the PlayMusic(string resourceName, bool loop) shape in IAudioEngine — that signature is an invention, not a retail port.
"Recommended: convert MIDI to OGG offline" Moot — there is no MIDI content to convert.

Consequence for the port: a MIDI synth is not needed. Neither is an OGG music bus. There is nothing to be faithful to.


2. MediaMachine — what it actually is (a per-UI-element media bytecode VM)

MediaMachine is not the music system. It is the interpreter for the media script attached to every UI element state in the LayoutDesc DAT. Playing a sound is one of its eleven instructions.

Ownership chain

LayoutDesc (DBObj)                 acclient.h:33881
 └─ ElementDesc : StateDesc        acclient.h:33693
     └─ StateDesc                  acclient.h:33640
         └─ SmartArray<MediaDesc*> m_media     ← the script
UIElement
 └─ MediaMachine m_mediaMachine    acclient.h:33786
     ├─ UIElement* m_owner
     ├─ SmartArray<MediaDesc*> m_array   ← deep copy of the active state's m_media
     └─ unsigned m_curIndex              ← the program counter

MediaMachine : UIListener (acclient.h:33873).

State machine (this is the whole thing)

  • MediaMachine::Reset(const SmartArray<MediaDesc*>&) @ 0x00465d90 (:112681): Cleanup(), then deep-copy every MediaDesc via MediaDesc::CreateMediaType(const MediaDesc*), set m_curIndex = 0, and immediately Update(). Called from UIElement::SetStateMediaMachine::Reset(&m_mediaMachine, &m_desc.m_media) (:108863), and on element copy (:111688, :111745).
  • MediaMachine::Update() @ 0x00465ba0 (:112526) — the interpreter loop:
    1. UIListener::UnRegisterForGlobalMessage(this, 3).
    2. While m_curIndex < m_array.m_num: dispatch on m_type - 1 through an 11-entry jump table (jump_table_465cc0, :112621) to the matching Update_X(desc).
    3. The return value is "may I advance?" — non-zero ⇒ m_curIndex++ and continue in the same call; zero ⇒ break (the instruction is still blocking).
    4. On break, UIListener::RegisterForGlobalMessage(this, 3) — i.e. subscribe to the per-tick global message so the machine resumes next frame.
  • MediaMachine::ListenToGlobalMessage(msg, _) @ 0x00465cf0: if (msg == 3) Update(). Global message 3 is the machine's clock. There is no dedicated music/media tick.
  • Cleanup() @ 0x00465af0: virtual-deletes every owned MediaDesc, zeroes the array. Called by dtor and by Reset.

Termination: the machine runs off the end of the array and stops (no re-registration). A Jump instruction is what makes a script loop forever.

Instruction set (MediaDesc::m_type, 1-based)

Verified from MediaDesc::CreateMediaType(uint32_t) @ 0x0069d420 (:675786) and each ctor's MediaDesc::MediaDesc(this, N):

# Type Struct (acclient.h) Blocking? Semantics
1 Movie MD_Data_Movie :34160 — PStringBase m_strFileName, bool m_StretchToFullScreen, MovieTheatre* yes, until done MD_Data_Movie::Update(owner) @ 0x0069d489; DirectShow (ATL::CComPtr<IGraphBuilder> :34191). Requires owner visible-bit (m_owner+0x554) >> 0x11 & 1.
2 Alpha MD_Data_Alpha :34118 — DID m_file no alpha mask image
3 Anim MD_Data_Anim :34101 — float m_duration, m_drawMode, SmartArray<DID> m_frames, double m_StartTime, int m_displayedFrameNum yes, for m_duration flipbook; latches m_StartTime on first visit (sentinel -1.0)
4 Cursor MD_Data_Cursor :34168 — DID m_file, int m_xHotspot, m_yHotspot no UIElement::SetCursor
5 Image MD_Data_Image :34111 — DID m_file, m_drawMode no set the element's picture
6 Jump MD_Data_Jump :34132 — uint m_jumpItemIndex, float m_probability no RollDice(0,1) vs probability; on pass m_curIndex = m_jumpItemIndex - 1 (then the loop's ++ lands exactly on m_jumpItemIndex). This is the loop primitive.
7 Message MD_Data_Message :34139 — uint m_messageID, float m_probability no UIElement::BroadcastElementMessage(owner, m_messageID, 0, 0)
8 Pause MD_Data_Pause :34124 — float m_minDuration, m_maxDuration, double m_endTime yes first visit: m_endTime = Timer::compute_time() + RollDice(min,max); blocks until now ≥ endTime, then resets m_endTime = -1.0
9 Sound MD_Data_Sound :34146 — DID m_file, SoundType m_stype no see §3
10 State MD_Data_State :34153 — uint m_stateID, float m_probability terminal probabilistic owner->SetState(m_stateID); always returns 0 (:112068) so the machine stops — the new state's Reset takes over
11 Fade MD_Data_Fade :34176 — float m_startAlpha, m_endAlpha, m_duration, double m_startTime yes see §4

Sentinel convention: m_StartTime / m_endTime / m_startTime use the double -1.0 (0xBFF00000 in the high word) as "not yet started"; each blocking instruction resets it to -1.0 when it completes, so a Jump back over it re-arms it.

Text/serialised form

MediaDesc::ToFileNode / CreateFromFileNode (0x0069d740 / 0x0069d7e0) read a MediaType node (keyword "MediaType", KW_MEDIATYPE @ :821851) under a "Media" node (KW_MEDIA @ :821841), enum-name table 14. Per-type keywords seen in the init block around :821780:821960: MinDuration, MaxDuration, Probability, SoundName, SoundTable, StartAlpha, EndAlpha, Duration, StateID, StretchToFullScreen, NoDBFile, PassToChildren. StateDesc::LoadMedia @ 0x0069c950 (:674969) appends each parsed desc; StateDesc::ConcatenateMedia @ 0x0069c9b0 merges a parent state's script into a child's.

DatReaderWriter already models all of this: references/DatReaderWriter/DatReaderWriter/Generated/Types/MediaDesc.generated.cs (abstract + MediaType dispatch) and MediaDescSound.generated.cs (uint File, Sound Sound). The binary layout is int32 mediaType, int32 type, uint32 file, uint32 sound — note the doubled type field, which the generated reader reproduces.


3. MediaMachine::Update_Sound — the authored-sound instruction

0x004658b0 (:112264). Full decode:

Update_Sound(MD_Data_Sound* d):
    if (d == null || m_owner == null) return 0;          // 0 = block (dead-end)

    if (d->m_stype == Sound_Invalid)                     // 0
        # m_file is a direct Wave DID
        if (d->m_file.id != 0)
            SoundManager::PlaySoundFromCenter(d->m_file, 1.0f)   # volume literal 0x3F800000
        return 1
    else
        # m_file is a SoundTable DID; look it up as DBO type 0x22
        CSoundTable* st = DBObj::Get(QualifiedDataID(d->m_file.id, 0x22))
        if (st != null)
            SoundManager::PlaySoundFromCenter(d->m_stype, st)
            return 1
        return 1                                          # falls through, still advances

Two shapes, discriminated by m_stype:

  • m_stype == Sound_Invalidm_file is a Wave DID (0x0A……), played at literal volume 1.0 via the PlaySoundFromCenter(DID, float) overload (0x005509e0, :346951) which looks the wave up in SoundManager::sound_hash_.
  • m_stype != Sound_Invalidm_file is a SoundTable DID (0x20……) and m_stype selects the row; PlaySoundFromCenter(SoundType, CSoundTable*) (0x00550950, :346927) rolls SoundManager::GetSound (weighted pick over SoundTableData::data_[] with priority/probability/volume) and plays it.

DBO types confirmed: CSoundTable::GetDBOType() == 0x22 (:349100), DBWave::Get() uses QualifiedDataID(id, 0xF) (:349327).

Both go through the interface channel, not a music channel: PlaySoundFromCenter gates on SoundManager::interface_sounds_enabled and s_bPlaySoundOnlyWhenActive && Device::m_bIsActiveApp, then GetAttenuation(0f, vol, &out, /*isAmbient=*/0) and PlaySoundInternal(buf, /*position=*/0, vol) — position 0 ⇒ non-positional, "from center". So the UI/authored-media bus == the interface-sound bus, and the retail Interface Sound Volume slider is its only volume control.

This is the closest thing retail has to "the client plays a piece of authored audio because a UI state was entered" — and it is the mechanism the login screen and any title-card audio would use.


4. Fades — the only fade math in the media system (and it is alpha, not audio)

MediaMachine::Update_Fade @ 0x00465930 (:112307):

now = Timer::compute_time()
target = owner->m_object ?? owner->m_parent->GetObjectA()   # the drawable
if (d->m_startTime == -1.0) d->m_startTime = now            # latch on first visit

dur = d->GetDuration()                                      # == m_duration
if (fabs(dur) >= 0.000199999995f)      # EPSILON = 2e-4 s
    t = (now - d->m_startTime) / dur
else
    t = 1.0f
t = clamp(t, 0.0f, 1.0f)                                    # two-sided clamp

alpha = d->m_startAlpha + (d->m_endAlpha - d->m_startAlpha) * t      # plain lerp
target->vtable[0x48](alpha)                                  # set element alpha

if (t >= 1.0f) { d->m_startTime = -1.0; return 1 }           # done → advance
return 0                                                     # still fading → block

Notable: linear interpolation, no easing; duration epsilon 2e-4 s; a zero/short duration snaps to endAlpha in one tick. There is no audio fade, no crossfade, and no volume ramp anywhere in MediaMachine or in the MIDI code. SetChannelVolume is an instantaneous CC-7 write. Any crossfade in an acdream music feature would be new design, not a port.


5. Trigger map — every code site that starts "music-adjacent" audio

Event Site What plays
UI element enters a state UIElement::SetStateMediaMachine::Reset (:108863) → Update_Sound (:112589) authored MD_Data_Sound (Wave DID or SoundTable+SoundType)
Per-tick continuation of a blocked script MediaMachine::ListenToGlobalMessage(3)Update (:112642) next instruction in the script
UI element cleanup / hide MediaMachine::Cleanup (:109993), Update (:110007) — (stops the script, does not stop already-playing sounds)
Splash / intro MD_Data_Movie::Update (:675489) → DirectShow on m_strFileName turbine_logo_ac.avi (its audio track is the theme)
Teleport start (portal in) gmSmartBoxUI teleport anim, :218903 Sound_UI_EnterPortal (0x6A) from ClientUISystem::GetUISoundTable()
Teleport end (portal out, TAS_WORLD_FADE_IN) :219745 Sound_UI_ExitPortal (0x6B)
Server-pushed atmosphere cue CPlayerSystem::Handle_Admin__Environs(uint) @ 0x0055de20 (:362360+) codes below
Landblock / cell change CellManager::ChangePosition @ 0x004559b0Ambient::InitSounds (:94714), LScape::add_ambient_sounds (:94718), Ambient::UpdatePlayQueue (:94722), Ambient::ReleaseSoundTables (:94726) region ambient rolls (§6)
Per-frame ambient pump Ambient::UseTime @ 0x00551880, called from :94200 due entries from the ambient PQueue

Handle_Admin__Environs — the retail "set the mood" opcode

AdminEnvirons (acdream: 0xEA60, WorldSession.cs:1915). Two disjoint ranges:

  • 1..6 → lighting/fog overrides (LScape::m_override_*: ambient level, ambient colour, fog colour, fog min/max; case 6 also sets m_bRadarBlank = 1). acdream already ports these.

  • 0x65..0x7Cone-shot UI-SoundTable stinger, each PlaySoundFromCenter(Sound_UI_*, GetUISoundTable()), gated on the player physics object existing:

    code SoundType code SoundType
    0x65 Sound_UI_Roar (0x76) 0x6E Sound_UI_Drums (0x7F)
    0x66 Sound_UI_Bell (0x77) 0x6F Sound_UI_GhostSpeak (0x80)
    0x67 Sound_UI_Chant1 (0x78) 0x70 Sound_UI_Breathing (0x81)
    0x68 Sound_UI_Chant2 (0x79) 0x71 Sound_UI_Howl (0x82)
    0x69 Sound_UI_DarkWhispers1 (0x7A) 0x72 Sound_UI_LostSouls (0x83)
    0x6A Sound_UI_DarkWhispers2 (0x7B) 0x75 Sound_UI_Squeal (0x84)
    0x6B Sound_UI_DarkLaugh (0x7C) 0x760x7A Sound_UI_Thunder1..5 (0x850x89)
    0x6C Sound_UI_DarkWind (0x7D) (0x7B/0x7C) tail of the Thunder run
    0x6D Sound_UI_DarkSpeech (0x7E)

    Codes 0x73/0x74 have no case (fall through, nothing plays). Note the environ code and the SoundType are offset by 0x11 but not uniformly — the switch is explicit, so port it as an explicit table, never as arithmetic.

This is the trigger for what players remember as dungeon "music".


6. Region ambient soundscape — retail's real "area audio"

Not music, but it is the system a "music by region" feature would have to sit next to, and it is the only region→audio authored data in the DATs.

CRegionDesc (acclient.h:53230)
 └─ CSoundDesc* sound_info                 :53200 — SmartArray<AmbientSTBDesc*>
CSceneType  :53240 — { name, scenes[], AmbientSTBDesc* sound_table_desc }
AmbientSTBDesc :35486 — { DID stb_id, bool stb_not_found,
                          SmartArray<AmbientSoundDesc*> ambient_sounds,
                          CSoundTable* sound_table, uint play_count }
AmbientSoundDesc :35496 — { SoundType stype, bool is_continuous, float volume,
                            float base_chance, float min_rate, float max_rate }
  • Selection is per land cell, from the terrain word: in CLandBlock::add_ambient_sounds (:314270+) each cell reads terrainType = (t >> 2) & 0x1F and sceneIdx = t >> 11, then CRegionDesc::GetSTBDesc(region, terrainType, sceneIdx)Ambient::AddSound(ambient, stbDesc, cellVertexPos) (:314293).
  • AmbientSound is polymorphic: ConstantSound (continuous, tracks current_volume) and IntermitSound (per-LandDefs::Direction min_dist[8] / max_dist[8] arrays + play_chance) — acclient.h:52830, :52856.
  • Scheduling is an absolute-deadline priority queue: Ambient::Play @ 0x005517a0 (:347826) → CanHear(), PlayNow(), GetSoundPos(); positional ⇒ PlayAmbientSound, non-positional ⇒ PlayAmbientSoundFromCenter; then PQueueArray<double>::Insert(sound_queue, Timer::cur_time + GetPlayInterval(), snd) and on_queue = 1. Ambient::UseTime pops due entries.
  • Ambient volume path: PlayAmbientSoundFromCenter @ 0x005508b0 multiplies by SoundManager::ambient_sound_volume, rolls rand() * 3.05185094e-05f (= 1/32768) against the entry probability, then GetAttenuation(0, vol, &out, /*isAmbient=*/1).

7. Cross-reference results

  • references/ACViewer/ — grep for midi|music over all *.cs: only three hits, all unrelated (WeenieClassName.cs, SoulEmote.cs). ACViewer implements no music and no MIDI. It is not an oracle here, and its silence is itself corroboration that there is nothing to load.
  • references/DatReaderWriter/ — no music/MIDI DBObj type exists. Audio surface is exactly two DBObjs:
    • WaveDBObjType.Wave, range 0x0A0000000x0A00FFFF, layout int32 headerSize, int32 dataSize, byte[] header, byte[] data (DBObjs/Wave.generated.cs). The header is a WAVEFORMATEX blob; the body can be PCM or MP3 (retail decodes via winmm ACM).
    • SoundTableDBObjType.SoundTable, range 0x200000000x2000FFFF, int32 HashKey, Dictionary<uint, SoundHashData>, Dictionary<Sound, SoundData> with SoundEntry { QualifiedDataId<Wave> Id, float Priority, Probability, Volume }.
    • MediaDesc* types are all present (§2), including MediaDescSound.
    • There is no 0x25…… music table and no MIDI DAT type. (0x25…… is the RegionDesc/Region family, not music.)

8. Answers to the five questions

1. Formats and where the bytes live. Retail's only music-capable path is winmm midiStream* fed a Standard MIDI File read from a loose disk path via CreateFileA — never from a DAT. No DirectMusic, no MP3-as-music, no 0x25…… music table. That path is never invoked and no .mid ships with the client. All audio the client actually plays is DAT Wave (0x0A0000000x0A00FFFF, PCM or MP3-in-WAVEFORMATEX) selected either directly by DID or through SoundTable (0x200000000x2000FFFF). The one long-form musical asset that ships is the audio track of turbine_logo_ac.avi, played by DirectShow.

2. The MediaMachine state machine. It is not a music machine — it is an 11-opcode media bytecode VM per UI element state, with m_curIndex as PC, "may I advance?" booleans as the blocking protocol, global message 3 as the clock, Jump as the loop primitive, State as the terminal instruction, and -1.0 double sentinels for "not yet armed". Full opcode table and per-opcode decode in §2; Update_Sound in §3; Update_Fade (linear alpha lerp, 2e-4 s duration epsilon) in §4.

3. Trigger map. §5. Region/landblock entry drives ambient, not music. Portal in/out and the 24 AdminEnvirons codes 0x650x7C drive the atmosphere stingers. UI state entry drives authored MD_Data_Sound. Login/ intro music is a movie file. Nothing anywhere starts a music track.

4. Does a modern port need a MIDI synth? No. There is no MIDI content and no code that would play it; EoR-era audio is 100% sampled. ACViewer implements nothing here either. Building a synth (or an OGG music bus) would be new feature design with no retail referent — and per CLAUDE.md that is a "different feature", not a port, so it needs explicit approval.

5. Rough port scope.

  • Delete the false surface (smallest, highest value). IAudioEngine.PlayMusic(string resourceName, bool loop) / StopMusic() in src/AcDream.Core/Audio/AudioModel.cs:102-103, their no-op bodies in src/AcDream.App/Audio/OpenAlAudioEngine.cs:386-387, and MusicVolume at AudioModel.cs:84 / OpenAlAudioEngine.cs are modelled on a retail feature that does not exist. resourceName as a string path is itself a tell — every other audio entry point in the engine is DID-keyed. A retail-faithful engine has three buses (SFX / Ambient / Interface), not four. Removing them retires a divergence rather than creating one; if they stay, they need a retail-divergence-register.md row explaining that they model dead retail code. Also worth a look: src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs:260 comments the music path as "stubbed for R5 MIDI", and retail's Settings has no music slider at all.
  • Correct the record. docs/research/deepdives/r05-audio-sound.md §6 and its executive-summary table row ("MIDI music | winmm midiStream | Loose *.mid files on disk") should carry the "infrastructure present, never invoked, no content ships" finding — see the correction table in §1. That table row is what produced the phantom API.
  • The genuinely missing retail behavior, in cost order:
    1. AdminEnvirons sound cues (24 codes → Sound_UI_* via the UI SoundTable). acdream already parses these and already has RuntimeEnvironmentSoundCue, with src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:214-239 logging audio binding pending. This is a table + one PlayUi call — the cheapest real retail-fidelity win in the whole lane.
    2. MediaDescSound in the LayoutDesc importer. src/AcDream.App/UI/Layout/LayoutImporter.cs:464-489 reads MediaDescImage and MediaDescCursor from each state's media list and ignores everything else. Adding the Sound opcode (two shapes per §3, both routed to the interface bus) gives retail's authored UI sounds for free — DatReaderWriter already parses the type.
    3. Region ambient system (§6): AmbientSTBDesc selection from the terrain word on landblock change, ConstantSound/IntermitSound, the absolute-deadline PQueue, ambient_sound_volume. This is the real "area audio" and it is what StartAmbient/StopAmbient (OpenAlAudioEngine.cs:367-385, currently handle-reservation only) exist to serve. Multi-commit; belongs in a roadmap phase, not an issue.
    4. MediaMachine proper (Pause/Jump/Anim/Fade/State scripting) — only if animated UI states become a goal. Not required for audio.
  • Explicitly out of scope for a faithful port: MIDI playback, a soundfont synth, crossfades, a music bus, region→track tables. None exist in retail.

9. Anchors (for citation in code comments)

Symbol Address pseudo-C line
SoundManager::Init (calls midiSetup) 0x00550640 346753
SoundManager::ShutDown (calls midiCleanup) 0x005502b0 ~346580
midiSetup 0x00553770 350282
midiPlay (no callers) 0x00553390 350072
midiPlayNext 0x005534c0 350138
midiStop 0x00553240 349978
midiCleanup 0x00553350 350047
MidiProc 0x00553500 350152
StreamBufferSetup 0x00553030 349843
ConverterInit (MThd/MTrk) 0x00554530 351254
SetChannelVolume 0x00552f60 349787
midiEventCallback / midiStartCallback (= 0) 0x0086fa70 / 0x0086fa74 11855978
MediaMachine::Update 0x00465ba0 112526
MediaMachine::Reset 0x00465d90 112681
MediaMachine::Cleanup 0x00465af0 112475
MediaMachine::ListenToGlobalMessage 0x00465cf0 112638
MediaMachine::Update_Sound 0x004658b0 112264
MediaMachine::Update_Fade 0x00465930 112307
MediaMachine::Update_Pause 0x00465520 111937
MediaMachine::Update_Jump 0x004655b0 111983
MediaDesc::CreateMediaType(uint) 0x0069d420 675786
MD_Data_Sound::MD_Data_Sound (type 9) 0x0069e5f0 677163
MD_Data_Sound::Serialize 0x0069e670 677192
StateDesc::LoadMedia 0x0069c950 674969
SoundManager::PlaySoundFromCenter(SoundType, CSoundTable*) 0x00550950 346927
SoundManager::PlaySoundFromCenter(DID, float) 0x005509e0 346951
SoundManager::PlayAmbientSoundFromCenter 0x005508b0 346893
CPlayerSystem::Handle_Admin__Environs 0x0055de20 ~362360
Ambient::Play 0x005517a0 347826
Ambient::UseTime 0x00551880 347957
CellManager::ChangePosition (ambient re-init) 0x004559b0 94601
CSoundTable::GetDBOType (= 0x22) 0x00552560 349100
DBWave::Get (QDID type 0xF) 0x00552880 349327
enum SoundType (0x000xCC) acclient.h:4569
MediaMachine / MediaDesc / MD_Data_* structs acclient.h:33873, 33907, 3410134182