Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

WAV (Waveform Audio)

While standard RIFF WAV files are supported, KOTOR utilizes a multi-tiered routing structure to evaluate audio buffers dynamically based on whether the file encapsulates voice-overs (VO), ambient sound effects (SFX), or unmodified bytes.

At a Glance

PropertyValue
Extension(s).wav
Magic SignatureRIFF
TypeStreamed / Buffered Audio
Rust ReferenceView rakata_formats::Wav in Rustdocs

File Layout

There is no single layout here. A .wav in KotOR is one of three different things wearing the same extension, and which one you have is decided entirely by what the first few bytes look like.

VariantDiscriminatorPayload starts at
Standard RIFF/WAVEBegins with RIFF, and the declared RIFF size accounts for the whole file0x00; the file is the payload
SFX wrapperNo RIFF at 0x00, and RIFF present at 0x1D60x1D6 (470), where a normal RIFF/WAVE begins
MP3-in-WAV wrapperBegins with RIFF, but riff_size + 8 falls short of the file lengthriff_size + 8, where raw MP3 data begins

Telling the two wrapped forms apart

They are discriminated differently, and it is worth being precise about which is which. The SFX form is caught by the absence of RIFF at the start, not by any magic of its own. The MP3 form does start with RIFF and passes a naive check. What gives it away is arithmetic, not a signature: riff_size + 8 lands short of the file’s end, and the payload starts exactly there. A reader that trusts the RIFF tag and stops will hand the wrapper to a decoder and call the actual audio padding.

Every shipping example declares a riff_size of 50, putting the payload at byte 58, and it is tempting to treat 58 as the rule. It is not: the engine computes the offset rather than assuming it, so a wrapper of any other size resolves correctly for the engine and wrongly for a reader that hardcoded the observed value.

What vanilla writes, if you want to match it. Those 58 bytes are the same in every MP3-in-WAV file the game ships, voice and music alike, so a writer can copy them outright. They’re an empty WAV that describes nothing about the MP3 behind it:

OffsetBytesHolds
0x00RIFF, then 50The RIFF chunk, declaring 50 bytes
0x08WAVEThe form type
0x0Cfmt , then 18A format chunk for 8-bit mono PCM at 22,050 Hz, with no extra format bytes
0x26fact, then 4A fact chunk holding 0
0x32data, then 0An empty data chunk
0x3AThe MP3 frames, outside the RIFF chunk entirely

On an MP3 request the engine only checks for RIFF and the size before handing everything from byte 58 to the decoder, so none of those fields has to be true.

What the engine actually tests

Important

The engine never checks FF F3 60 C4, and its test is negative Those four bytes are not a KotOR signature. They are an ordinary MPEG frame header (FF F3 = MPEG-2 Layer III, no CRC; 60 = 48 kbps, 22.05 kHz, unpadded; C4 = mono), the first header of the MP3 stub the wrapper opens with. They are constant across shipped files only because every stub was encoded the same way.

A program-wide search for the constant, in both byte orders, returns zero hits anywhere in swkotor.exe. It is a prefix observed in shipped files, not a magic value the engine validates.

What both load paths actually do is test for the absence of something. CExoStreamingSoundSourceInternal::InitializeSource (0x005dbd30), the streaming path, reads four bytes and compares them against RIFF; if that fails it seeks straight to 0x1D6 and requires RIFF there, rejecting the file if that also fails. CResWave::OnResourceService (0x005df230), the resident path, does the same after first checking the first eight bytes against a separate BMU V1.0 sub-format signature, then sets a flag that CResWave::GetData (0x005df370) consumes as a plain data + 0x1D6.

So the discriminator is “not RIFF at the start, and RIFF at 470”. Any file meeting that shape is treated as wrapped regardless of what its first four bytes hold.

The intervening bytes are genuinely untouched. Across both paths the only bytes read inside the 470-byte span are 0–3, plus 0–7 on the resident path for the unrelated BMU check. Bytes 8 through 0x1D5 are never dereferenced, compared, or used as a length or checksum by either function: the skip is a real fseek or pointer add, not a disguised read.

Keying on the four-byte prefix instead is stricter than the engine and refuses wrapped files it accepts. Rakata’s reader applies the engine’s own test: no RIFF at the start, RIFF at 0x1D6. Its writer still emits the prefix, deliberately, because every file that takes this form carries exactly the same block and other tools do key on it even though the game does not.

The 470-byte prefix

Note

The 470-byte prefix is three MP3 frames, which is why it is 470 bytes The engine skips the block, but a writer still has to produce it, so it is worth saying what it is rather than leaving it as an opaque run. It is fixed: byte for byte identical in every wrapped file in a retail install, with no variation at all.

It decodes as three MPEG-2 Layer III frames at 48 kbps and 22.05 kHz. Frame headers sit at 0, 156 and 313; the lengths those headers declare are 156, 157 and 157; and they total exactly 470. So the payload does not begin at an offset some tool picked, it begins where the third frame ends. Offset 13 holds the ASCII LAME3.93, and most of the remaining space is LAME’s 0x55 padding byte.

The later two headers read FF F3 62 C4, differing from the first only in the padding flag (62 rather than 60), which is exactly why they are 157 bytes against the first frame’s 156. Frame length for MPEG-2 Layer III is 72 * bitrate / sample_rate plus the padding byte, and 72 x 48000 / 22050 truncates to 156. That arithmetic is the whole of why the prefix is 470.

The wrapper is therefore an MP3 stub with a RIFF/WAVE file glued on behind it. What it was for is not established, and one specific explanation has been checked and did not hold: neither LAME nor Xing appears anywhere in swkotor.exe, so the stub is not boilerplate the game itself emits or recognises by name. That does not rule out an encoder having produced it upstream in BioWare’s tooling; it rules out the game being the thing that put it there. What it means for a writer is that the block gets reproduced verbatim, and that the frame arithmetic is an independent check that a copy is intact: decode the three headers and the lengths have to land on 0x1D6.

Where the form occurs. Every file under streamsounds/, and some but not all of streammusic/. No .wav resource inside any archive takes this form, and nothing under streamwaves/ does either: every voice file there is the MP3-in-WAV form, and so is the rest of streammusic/. So “SFX” here is a shape a few directories use, not a property of the extension.

BMU V1.0, and where the Rust implementation stops

Note

BMU V1.0 is a third sub-format, and this manual does not document it The resident load path checks the first eight bytes against a BMU V1.0 signature before it tests for RIFF at all. Nothing else in these pages mentions it and Rakata does not handle it. It is recorded here because a reader tracing the engine’s audio dispatch will meet the branch and find nothing about it anywhere else.

Note

Scope of the Rust implementation rakata-formats handles the container tier only: identifying which of the three wrappers a file uses, and getting to the payload inside it. Sample decoding is not its job, which mirrors the engine, where the executable routes to Miles Sound System and performs almost no chunk parsing of its own.

Streamed audio

Voice, ambient sound and music are opened by path, not looked up as resources. CExoStreamingSoundSourceInternal::Play (0x005dca30) builds a path under the HD0 alias, which is the install directory unless swKotor.ini says otherwise, and opens the file there. Override/, the module archives and chitin.key are never consulted, a same-named file in Override/ is ignored, and a missing file silently plays nothing. The one wrinkle is load-screen music, which also asks the resource manager to cache the file before it plays, to an effect nobody has traced.

Two flags pick the folder, one for voice and one for music:

StreamPath
Musicstreammusic\<name>
Sound, neither flagstreamsounds\<name>
Voice, when the name passes the shard teststreamwaves\<a>\<b>\<name>
Voice, otherwisestreamwaves\<name>

The shard test. A voice name is sharded when it is exactly sixteen characters, starts with n or N, and has a second character other than _. Then <a> is characters 1 to 5 and <b> is characters 6 to 11, counting from zero. nglobebant00102_ passes, so <a> is globe and <b> is bant00, and the file is streamwaves\globe\bant00\nglobebant00102_.wav. A shorter name, one not starting with n, or an n_ name stays flat. The dialogue server’s voice check applies the same test.

Case. Paths are built in the name’s own case, with backslashes. The Windows filesystem the game runs on ignores case, so the vanilla voice file above sits on disk as NGLOBEBANT00102_.wav. A case-sensitive filesystem would care, directory names included.

Two kinds of request, and they don’t take the same files. Every stream asks for either an MP3 or a WAV, and that decides which of the three forms will play.

  • Asking for MP3, the engine looks at the .wav-named file first and takes it only if it’s the MP3-in-WAV wrapper: RIFF at the start, with an MP3 body after the chunk. If it isn’t, the engine tries a real .mp3 of the same name instead. That’s why a .wav on disk can answer an MP3 request at all.
  • Asking for WAV, it opens <name>.wav and takes a plain RIFF or the prefixed form, RIFF at 0 or at 0x1D6. That check always runs. There’s a branch in the code that would skip it and open the file straight by name, but nothing ever switches it on.

So the forms aren’t interchangeable. A prefixed file fails an MP3 request, and so does a plain RIFF with nothing after the chunk. An MP3-in-WAV file passes a WAV request and plays silence: the sound library reads the empty WAVE at the front, finds a data chunk of size 0, and never looks at the MP3 behind it. So anything that asks for WAV only, which means a looping sound object, a room’s part sounds, sound sets and stingers, can’t use the MP3-in-WAV form, and nothing tells you why.

SourceFolderAsks for
An NPC line’s voice: the dialogue entry’s Sound, or VO_ResRef when that’s empty. A player reply’s never plays. See dlg.mdstreamwaves\MP3, then WAV
A barkstreamwaves\MP3 only
A dialogue’s AmbientTrack, see dlg.mdstreammusic\MP3 only
Area and battle music, load-screen, menu, credits and mini-game music, and the evil endingstreammusic\MP3 only
An area’s ambient loopstreammusic\WAV, or MP3 then WAV when PlayAmbientSound starts it
A sound object’s Sounds, when it’s Looping. See uts.mdstreamsounds\WAV
A room’s PartSounds, see are.mdstreamsounds\WAV
Sound sets and stingersstreamsounds\WAV
One-shot effects whose name starts c_, p_ or n_, in either casestreamsounds\WAV

A non-looping sound object doesn’t stream at all: each of its Sounds resolves as an ordinary WAV resource. A one-shot effect with any other name loads as a resource too.

Music is where the forms bite. Nearly everything in streammusic\ asks for MP3 only, so a track has to be the MP3-in-WAV form or a real .mp3. Drop a prefixed loop in its place and it plays nothing, with nothing to say why. Vanilla’s streammusic\ holds both forms and no .mp3 files, and every track a vanilla dialogue names is MP3-in-WAV.

Some stream folders are resource directories too. The top level of streamwaves\ and streammusic\ is also registered with the resource manager, so a file sitting directly there is a resource keyed by its name and extension. The sharded voice folders underneath are not indexed. streamsounds\ is not registered, so its files are reachable only by streaming. See stream and movie directories.

Redirecting the streams. Every stream path is built from HD0, so an HD0= entry under [Alias] in swKotor.ini moves all three folders at once. The value is joined to the rest of the path with no separator added, so it has to end in a backslash, as the default .\ does. The STREAMWAVES and STREAMMUSIC aliases exist, but playback never builds a path from them, and there is no STREAMSOUNDS alias at all.

Engine Audits & Decompilation

Read from CExoSoundInternal::LoadSoundProvider at 0x005d9140 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.

Pipeline EventGhidra Provenance & Engine Behavior
Standard Audio (WAV)If the payload begins with the exact "RIFF" 4-byte signature and evaluates dynamically as a non-MP3 track, the parser initiates at offset 0 and transmits the contiguous buffer to the Miles Sound System without execution modification.
Ambient Audio (SFX)Reached by failing the "RIFF" test at offset 0, not by matching anything. The engine then requires "RIFF" at +0x01d6 and rejects the file if it is absent. It does not interpret the 470-byte prefix in any way: it seeks or pointer-adds past it, reading none of bytes 8–0x1d5. The payload is the remainder, size = file_size - 0x1d6.
Voice Audio (VO)For streaming voice-over tracks, the .wav wrapper successfully begins with a "RIFF" tag. However, structural logic asserting riff_size + 8 < file_size effectively succeeds. The memory engine immediately seeks to byte offset riff_size + 8 and subsequently pipes the remaining data exclusively as a literal .mp3 stream.
Delegation Hand-offThe main executable natively acts as a dispatch router, executing almost zero internal chunk structural parsing routines. Total specialization for deep RIFF chunk deserialization is deferred unconditionally to the external Miles Sound System layer.