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>
29 KiB
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:
midiPlayhas zero callers. The only three occurrences of address0x00553390in 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 ismidiPlayNext→midiPlay(:350147), andmidiPlayNextis itself only reachable from theMidiProcbuffer-done callback (:350223) — i.e. it only ever advances a queue that nothing ever fills.- Both MIDI callbacks are permanently null.
midiEventCallbackandmidiStartCallbackare statically initialised to 0 (:1185597,:1185598) and there is no assignment site anywhere in the image.MidiProcnull-checks them on every event and no-ops. - No music preference and no music files.
SoundManager::InitPrefs/ShutDownregister exactly eight sound preferences —SoundDisabled,SoundVolume,AmbientSoundDisabled,AmbientSoundVolume,InterfaceSoundDisabled,InterfaceSoundVolume,SoundFeatures,PlaySoundOnlyWhenActive(:346764–:346810region, 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. AndC:\Turbine\Asheron's Call\contains zero.mid/.rmi/.mp3/.wavfiles; the only media file on disk isturbine_logo_ac.avi. - The word "music" does not appear anywhere in the 65 MB pseudo-C
(case-insensitive grep: 0 hits), and
SoundType(the 0x00–0xCC 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::Init → midiSetup(),
:346764), tears it down at shutdown (SoundManager::ShutDown →
midiCleanup(), :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 0x65–0x7C |
| 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 0x00552f60–0x00553840. 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 pending → midiPlay(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 everyMediaDescviaMediaDesc::CreateMediaType(const MediaDesc*), setm_curIndex = 0, and immediatelyUpdate(). Called fromUIElement::SetState→MediaMachine::Reset(&m_mediaMachine, &m_desc.m_media)(:108863), and on element copy (:111688,:111745).MediaMachine::Update()@0x00465ba0(:112526) — the interpreter loop:UIListener::UnRegisterForGlobalMessage(this, 3).- While
m_curIndex < m_array.m_num: dispatch onm_type - 1through an 11-entry jump table (jump_table_465cc0,:112621) to the matchingUpdate_X(desc). - The return value is "may I advance?" — non-zero ⇒
m_curIndex++and continue in the same call; zero ⇒ break (the instruction is still blocking). - 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 ownedMediaDesc, zeroes the array. Called by dtor and byReset.
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_Invalid⇒m_fileis aWaveDID (0x0A……), played at literal volume 1.0 via thePlaySoundFromCenter(DID, float)overload (0x005509e0,:346951) which looks the wave up inSoundManager::sound_hash_.m_stype != Sound_Invalid⇒m_fileis aSoundTableDID (0x20……) andm_stypeselects the row;PlaySoundFromCenter(SoundType, CSoundTable*)(0x00550950,:346927) rollsSoundManager::GetSound(weighted pick overSoundTableData::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::SetState → MediaMachine::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 @ 0x004559b0 → Ambient::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 setsm_bRadarBlank = 1). acdream already ports these. -
0x65..0x7C→ one-shot UI-SoundTable stinger, eachPlaySoundFromCenter(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)0x76–0x7A Sound_UI_Thunder1..5(0x85–0x89)0x6C Sound_UI_DarkWind(0x7D)(0x7B/0x7C) tail of the Thunder run 0x6D Sound_UI_DarkSpeech(0x7E)Codes
0x73/0x74have no case (fall through, nothing plays). Note the environ code and theSoundTypeare 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 readsterrainType = (t >> 2) & 0x1FandsceneIdx = t >> 11, thenCRegionDesc::GetSTBDesc(region, terrainType, sceneIdx)→Ambient::AddSound(ambient, stbDesc, cellVertexPos)(:314293). AmbientSoundis polymorphic:ConstantSound(continuous, trackscurrent_volume) andIntermitSound(per-LandDefs::Directionmin_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; thenPQueueArray<double>::Insert(sound_queue, Timer::cur_time + GetPlayInterval(), snd)andon_queue = 1.Ambient::UseTimepops due entries. - Ambient volume path:
PlayAmbientSoundFromCenter@0x005508b0multiplies bySoundManager::ambient_sound_volume, rollsrand() * 3.05185094e-05f(=1/32768) against the entry probability, thenGetAttenuation(0, vol, &out, /*isAmbient=*/1).
7. Cross-reference results
references/ACViewer/— grep formidi|musicover 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:Wave—DBObjType.Wave, range0x0A000000–0x0A00FFFF, layoutint32 headerSize, int32 dataSize, byte[] header, byte[] data(DBObjs/Wave.generated.cs). Theheaderis aWAVEFORMATEXblob; the body can be PCM or MP3 (retail decodes via winmm ACM).SoundTable—DBObjType.SoundTable, range0x20000000–0x2000FFFF,int32 HashKey,Dictionary<uint, SoundHashData>,Dictionary<Sound, SoundData>withSoundEntry { QualifiedDataId<Wave> Id, float Priority, Probability, Volume }.MediaDesc*types are all present (§2), includingMediaDescSound.- There is no
0x25……music table and no MIDI DAT type. (0x25……is theRegionDesc/Regionfamily, 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 (0x0A000000–0x0A00FFFF, PCM or MP3-in-WAVEFORMATEX)
selected either directly by DID or through SoundTable
(0x20000000–0x2000FFFF). 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 0x65–0x7C 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()insrc/AcDream.Core/Audio/AudioModel.cs:102-103, their no-op bodies insrc/AcDream.App/Audio/OpenAlAudioEngine.cs:386-387, andMusicVolumeatAudioModel.cs:84/OpenAlAudioEngine.csare modelled on a retail feature that does not exist.resourceNameas 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 aretail-divergence-register.mdrow explaining that they model dead retail code. Also worth a look:src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs:260comments 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*.midfiles 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:
AdminEnvironssound cues (24 codes →Sound_UI_*via the UI SoundTable). acdream already parses these and already hasRuntimeEnvironmentSoundCue, withsrc/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs:214-239loggingaudio binding pending. This is a table + onePlayUicall — the cheapest real retail-fidelity win in the whole lane.MediaDescSoundin the LayoutDesc importer.src/AcDream.App/UI/Layout/LayoutImporter.cs:464-489readsMediaDescImageandMediaDescCursorfrom 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 —DatReaderWriteralready parses the type.- Region ambient system (§6):
AmbientSTBDescselection 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 whatStartAmbient/StopAmbient(OpenAlAudioEngine.cs:367-385, currently handle-reservation only) exist to serve. Multi-commit; belongs in a roadmap phase, not an issue. MediaMachineproper (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 |
1185597–8 |
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 (0x00–0xCC) |
— | acclient.h:4569 |
MediaMachine / MediaDesc / MD_Data_* structs |
— | acclient.h:33873, 33907, 34101–34182 |