Skip to content

# Sounds

`assets/sfx/<name>.wav` replaces a sound effect. The substitution happens when the emulated SPU voice's PCM is handed to the mixer. The game's sound tables, the sample bank in SPU RAM, the voice's start address and pitch, and the tick on which the game is told the sound has ended are untouched, so a sound mod never moves gameplay state.

## Dump, edit, drop in

    openpete-spyro1 --disc SCUS-94228.cue --dump-sfx-pack dump/

reads every sample from the disc's WAD (every level, the cutscenes, the intro, the title screen, dragon speech) and writes each distinct one as a 16-bit mono WAV plus an `index.txt` census listing each sample's stock duration. Add `--dump-sfx-wads <path>` for a custom level's WAD.

Files are named `<label>-<hash>-p<pitch>.wav`:

- `label` is the sound-table slot name (`gemPickup`, `spyroCharge`, `sound_0x3d` for slots the decompilation has not named), `def<n>` for samples no slot points at, or `speech<n>` for dragon speech. The matcher ignores it.
- `hash` is the content hash of the original sample. It is the identity: a sample shipped in several levels hashes the same, so one file replaces it everywhere.
- `p<pitch>` is the base pitch the game plays the sample at, in SPU units (`0x1000` = 44.1 kHz). The WAV is written at the matching rate.

Edit the file, keep the name, and place it in `<mod>/assets/sfx/`. Any sample rate and bit depth (16, 24, float) is accepted. Stereo is downmixed to mono with a warning: the SPU voice is mono and the game pans it.

## Name forms

Form | Matches | The WAV is
--- | --- | ---
`<label>-<hash>-p<pitch>.wav` | That sample, at that base pitch | The sound at base pitch `pitch`, at the file's own rate
`<label>-<hash>.wav` | That sample under every definition | The raw sample at the decoder's 44.1 kHz; the game pitches it
`<slot>.wav` | Whatever the current level maps to that slot | The sound at that definition's base pitch, at the file's own rate

The tagged form stays correct for other definitions that share the sample: the engine folds the tag into the playback rate, so a definition that plays the sample transposed plays the file transposed the same way.

The symbolic form replaces a role: `gemPickup.wav` is the gem sound in every level, including a custom level that shipped its own recording. The hash forms replace a recording: a re-recorded sound in a custom level has a new hash and is left alone until a file names it.

Precedence when several files match: exact tag, then untagged hash, then a tag for another definition, then symbolic name. A code registration through `api->sfx_replace` outranks a symbolic file. Between mods the one listed later in `enabled.txt` wins. Each collision is logged as `<winner> overrides "<file>" (was <loser>)`.

## What the game still decides

- Duration. The game is told the sound ended when the original sample would have ended. A longer replacement keeps ringing on a separate channel after the game frees the voice (silenced by a deliberate stop, a level load, mute, and pause). A shorter file leaves silence.
- Pitch and variance. Per-play pitch, random variance, and pitch cycling apply to the replacement as to the original. A `-flat` suffix before `.wav` (`gemPickup-flat.wav`, `<label>-<hash>-p0400-flat.wav`) pins the file to its own rate for the whole play. Audible only; the game's pitch bookkeeping is untouched.
- Volume and pan.
- Looping. A sample the game loops keeps looping. A replacement with a `smpl` loop chunk loops that window; without one the whole file loops.

## Iteration

Sources are rescanned at every level load: edit, then re-enter the level. `OPENPETE_SFXPACK_LOG=1` logs every sample keyed that no file covers (hash, address, base pitch, size). `--sfxpack-dir DIR` points the base pack at a directory outside any mod; mod roots always join it.

The `[sfxpack]` line at exit counts `keyed`, `resolved`, `replaced`, and `rings_out` (replacements that outlast their stock sample). `OPENPETE_SFXPACK_VERIFY=1` compares each keyed file's PCM against the original decoded from SPU RAM and reports `verify_ok` and `verify_bad` counts; with an unmodified dump loaded, `verify_bad` is 0.

## A mod's own sounds

`assets/sfx/own/<name>.wav` holds sounds played host-side with `api->sfx_play(self, "<name>", gain)`. That directory is not scanned for replacements.

Example: `declarative-example` (one symbolic file among its assets, no code).