N64 Decompilation
Project Setup
First, ask user if they want to initialize a git repository for this project.
Initialize with uv (not pip) for better version locking:
uv init --bare
uv add "splat64[mips]"
Copy ROM to project folder as baserom.<extension> (keep original extension):
cp "/path/to/Game Name.n64" baserom.n64
Generate config from ROM (splat handles byteswapping internally):
uv run -m splat create_config baserom.n64
After config generation, splat creates a byteswapped baserom.z64. From this point on, always use baserom.z64 (the build system expects z64 format).
After config is generated, enable undefined symbol paths in the yaml (uncomment these lines):
undefined_funcs_auto_path: undefined_funcs_auto.txt
undefined_syms_auto_path: undefined_syms_auto.txt
Do NOT enable hardware_regs or libultra_symbols - they cause symbol mismatches and are unnecessary work.
Split the ROM:
uv run -m splat split <game>.yaml
Splat generates include/macro.inc automatically - do not create it manually.
Create .gitignore:
# ROMs (copyright)
*.z64
*.n64
*.v64
# Generated by splat
asm/
assets/
*.ld
.splat/
.splache
# Build artifacts
build/
*.o
*.bin
.ninja_log
build.ninja
# Auto-generated symbol files
undefined_funcs_auto.txt
undefined_syms_auto.txt
# Python
__pycache__/
.venv/
venv/
# Decomp tools
ctx.c
ctx.c.m2c
permuter_settings.toml
expected/
IMPORTANT: The asm/ folder is generated by splat. Never edit asm files directly - changes won't persist. Fix issues in the YAML config or symbol files instead.
Splat suggests file splits in output - these are "highly likely" object boundaries detected via zero-padding between functions aligned at 0x10. Add these to the yaml LATER, after basic build works.
Note on splits: Splat detects splits when a function's last instruction is at offset like 0x1C and the next function starts at 0x20 (aligned). If a function ends at 0x1C and the next starts at 0x20 without padding, splat cannot detect the boundary - but one may still exist.
Splat YAML Subsegment Format
Subsegments use shorthand: [offset, type] or [offset, type, name]
subsegments:
- [0x1050, asm] # name defaults to "1050" (hex offset)
- [0xA0CD0, asm, libultra/A0CD0] # explicit name, creates libultra/ folder
To rename a file, add the third element. Names with slashes become folders.
IMPORTANT: After ANY changes to the YAML configuration, run python configure.py --clean before rebuilding.
Build System
CRITICAL: Use references/configure-simple.py for initial setup. It handles assembly-only builds. Do NOT use the complex Pokemon Snap example yet - that's for later when adding C compilation.
The configure script requires ninja_syntax:
uv add ninja_syntax
Update the BASENAME variable in configure.py to match your splat YAML filename (e.g., if splat generated capybara.yaml, set BASENAME = "capybara").
After creating configure.py, make it executable: chmod +x configure.py
Checksum File
Create checksum.sha1 to verify builds match the original ROM:
<sha1hash> build/<basename>.z64
Generate the hash from your baserom:
sha1sum baserom.z64
Example checksum.sha1:
edc7c49cc568c045fe48be0d18011c30f393cbaf build/pokemonsnap.z64
The build system uses this to validate the output matches the original.
Building
Run configure and build:
python configure.py
ninja
After YAML changes, always clean first: python configure.py --clean
The first build (before adding splits) should produce a matching checksum. If it doesn't match, use xxd or similar hex tools to compare build/<basename>.z64 against baserom.z64 and find where bytes differ - this indicates a toolchain or configuration issue that must be resolved before proceeding.
Once building succeeds:
- Add the suggested splits from splat output to the yaml
- Rebuild and verify still matches
- Commit your progress
- Proceed to libultra identification
Diffing Tools
Create tools/ folder for helper scripts.
make_expected.sh - Save a known-good build for comparison:
#!/bin/bash
mkdir -p expected && cp -r build expected/
Only run this after a build passes checksum verification - expected/ should only contain verified matching builds.
tools/first_diff.py - Find first difference between builds. Use references/first_diff.py as a starting point. Update BASENAME to match your project, then run with uv run tools/first_diff.py (dependencies are specified inline). The script:
- Compares built ROM against expected
- Decodes MIPS instructions for readable diff
- Resolves addresses to symbol names from map file
Identifying libultra
Find Version
Check asm/header.s for revision field:
.word 0x00001448 /* Revision */
The byte (0x48 = 'H') encodes libultra SDK version as ASCII (E=2.0E, F=2.0F, ... L=2.0L). See https://n64brew.dev/wiki/Libultra for version history.
Setup
git clone https://github.com/decompals/ultralib
rm -rf ultralib/.git
uv add git+https://github.com/matt-kempster/m2c
Download ultralib into the project and commit it (remove .git, not a submodule). Do NOT add ultralib to .gitignore.
After adding ultralib, update configure.py to include its headers and add BUILD_VERSION:
INCLUDES = "-I include -I ultralib/include -I ultralib/include/PR"
DEFINES = "-D_FINALROM -DNDEBUG -DBUILD_VERSION=VERSION_H" # Adjust letter to match SDK version
BUILD_VERSION must match the SDK version letter identified above (H for 2.0H, etc.).
Get n64sym Hints First
BEFORE doing any analysis, ask user to run n64sym and paste the output: https://shygoo.github.io/n64sym/web/
Wait for user to provide n64sym output. Use it to estimate:
- Where libultra might start (look for first os* function address)
- Where libultra might end
- Which functions might be present
WARNING: n64sym is UNRELIABLE - pattern-matches against known binaries. NEVER add symbols directly. Use ONLY as hints for where to look.
Find libultra Boundaries
NOTE: Do not read raw asm files - they are large and token-inefficient. Always use m2c first:
uv run m2c asm/<file>.s
-
Find start of libultra: Based on n64sym hints, check candidate files with m2c
- Compare m2c output against ultralib source
- When matched, rename in yaml:
[0xA0CD0, asm, libultra/A0CD0]
-
Find end of libultra: Same process for last libultra function
-
Rename ALL files in the libultra range in the yaml subsegments
-
Rebuild and verify still matches
Libultra is typically one continuous block of modules.
Identify Called Functions
Find ALL function calls INTO the libultra VRAM range:
# Find calls to libultra address range (adjust range for your ROM)
rg "jal.*0x800[a-f]" asm/
Continue until you have identified ALL unique libultra functions called from game code. These are the priority - game code needs to know their signatures.
Add Symbols Module-by-Module
For each called function:
- Run m2c on the file containing that address
- Match m2c output to ultralib source
- Add function symbol to symbol_addrs.txt (text symbols more important than data)
- Rebuild and verify
For symbol syntax, see https://github.com/ethteck/splat/wiki/Adding-Symbols
Do not stop until all calls into libultra are identified. Verify build matches and commit before proceeding.
Identifying Compiler Version
The compiler type is in the yaml under options.compiler (detected by splat).
Using decomp.me
- Find a small, linear function outside libultra
- Run through m2c:
uv run m2c asm/<file>.s - Guide user to https://decomp.me/new with:
- Target assembly (from the .s file)
- m2c output as starting point
- Function signatures for called functions
- Typedefs (s32, u32, etc.)
- User tries presets until one matches
Prefer functions with simple control flow - fewer branches = fewer ways to write equivalent C.
IDO Specifics
If compiler is IDO (most common), see the n64-decomp-ido skill for setting up asm-processor and build integration.
For compiler downloads, see https://github.com/decompme/compilers/blob/main/values.yaml
Converting to C
Change file type from asm to c in the yaml and rebuild. This generates:
- C file with function stubs
- Separate .s files per function in
asm/nonmatchings/
