Phase 3: Conversion to N64 Format
Goals
Convert the Blender model and textures into raw N64 binary format ready for ROM injection.
Step 1: Export Display Lists with Fast64
Fast64 is a Blender addon that exports meshes as F3DEX2 display lists.
Installing Fast64
- Download from GitHub releases
- In Blender: Edit → Preferences → Add-ons → Install → select the zip
- Enable "Fast64" in the addon list
Export Settings
Configure Fast64 for N64 THPS-compatible output:
- Microcode: F3DEX2 (matches THPS N64)
- Export format: C source (we'll convert to binary after)
- Texture format: CI4 or CI8 (match what Phase 1 found)
- Vertex lighting: Use vertex colors OR normals (match original)
- Culling: Back-face culling ON
Export Process
For each body segment:
- Select the mesh object
- Fast64 panel → Export Display List
- This generates C arrays like:
Vtx nyjah_head_vtx[] = {
{{{120, 45, -30}, 0, {512, 256}, {127, 0, 0, 255}}},
// ... more vertices
};
Gfx nyjah_head_dl[] = {
gsSPVertex(nyjah_head_vtx, 16, 0),
gsSP2Triangles(0, 1, 2, 0, 3, 4, 5, 0),
// ... more commands
gsSPEndDisplayList(),
};
Step 2: Convert C Display Lists to Binary
Write a Python script to convert the exported C arrays into raw binary.
F3DEX2 Command Binary Format
Each display list command is 8 bytes (two 32-bit words):
import struct
def encode_gsSPVertex(vtx_addr, num_verts, start_index):
"""G_VTX command - load vertices into RSP buffer"""
word0 = (0x01 << 24) | ((num_verts * 2) << 12) | ((start_index + num_verts) * 2)
word1 = vtx_addr # ROM/RDRAM address of vertex data
return struct.pack('>II', word0, word1)
def encode_gsSP2Triangles(v0, v1, v2, flag0, v3, v4, v5, flag1):
"""G_TRI2 command - draw two triangles"""
word0 = (0x06 << 24) | (v0*2 << 16) | (v1*2 << 8) | (v2*2)
word1 = (v3*2 << 16) | (v4*2 << 8) | (v5*2)
return struct.pack('>II', word0, word1)
def encode_vertex(x, y, z, s, t, nx, ny, nz, a):
"""16-byte vertex entry"""
return struct.pack('>hhhHhhbbbb', x, y, z, 0, s, t, nx, ny, nz, a)
Vertex Binary Format (16 bytes each)
Offset Size Field
0x00 2 X position (signed 16-bit)
0x02 2 Y position (signed 16-bit)
0x04 2 Z position (signed 16-bit)
0x06 2 Padding/flags
0x08 2 Texture S coordinate (signed 16-bit, 10.5 fixed-point)
0x0A 2 Texture T coordinate (signed 16-bit, 10.5 fixed-point)
0x0C 1 Normal X / Color R
0x0D 1 Normal Y / Color G
0x0E 1 Normal Z / Color B
0x0F 1 Alpha
Step 3: Convert Textures to N64 Binary
Use Texture64 or write a Python script.
CI4 Texture Conversion
import struct
from PIL import Image
def convert_to_ci4(png_path):
"""Convert a 16-color PNG to CI4 binary format"""
img = Image.open(png_path).convert('P', colors=16)
palette = img.getpalette()[:16*3] # 16 RGB entries
pixels = list(img.getdata())
# Pack two 4-bit pixels per byte (high nibble first)
texture_data = bytearray()
for i in range(0, len(pixels), 2):
byte = (pixels[i] << 4) | pixels[i+1]
texture_data.append(byte)
# Convert palette to RGBA5551 (16-bit per color)
palette_data = bytearray()
for i in range(0, 16*3, 3):
r = (palette[i] >> 3) & 0x1F
g = (palette[i+1] >> 3) & 0x1F
b = (palette[i+2] >> 3) & 0x1F
rgba5551 = (r << 11) | (g << 6) | (b << 1) | 1 # alpha=1
palette_data += struct.pack('>H', rgba5551)
return texture_data, palette_data
CI8 Texture Conversion
def convert_to_ci8(png_path):
"""Convert a 256-color PNG to CI8 binary format"""
img = Image.open(png_path).convert('P', colors=256)
palette = img.getpalette()[:256*3]
pixels = list(img.getdata())
# One pixel per byte
texture_data = bytearray(pixels)
# 256-entry RGBA5551 palette
palette_data = bytearray()
for i in range(0, 256*3, 3):
r = (palette[i] >> 3) & 0x1F
g = (palette[i+1] >> 3) & 0x1F
b = (palette[i+2] >> 3) & 0x1F
rgba5551 = (r << 11) | (g << 6) | (b << 1) | 1
palette_data += struct.pack('>H', rgba5551)
return texture_data, palette_data
Texture Size Reference
| Format | 32x32 | 64x64 |
|---|---|---|
| CI4 | 512 bytes + 32 byte palette | 2048 bytes + 32 byte palette |
| CI8 | 1024 bytes + 512 byte palette | 4096 bytes + 512 byte palette |
| RGBA16 | 2048 bytes (no palette) | 8192 bytes (no palette) |
Step 4: Assemble Final Binary
Combine all segments into a single binary blob matching the original layout:
def assemble_character_binary(segments, textures, original_size):
"""Assemble all segments into final binary matching original size"""
output = bytearray()
# Write each segment's display list and vertex data
for segment in segments:
output += segment['vertices'] # Vertex data
output += segment['display_list'] # Display list commands
# Write texture data
for tex in textures:
output += tex['palette']
output += tex['pixels']
# Verify size constraint
if len(output) > original_size:
raise ValueError(f"Model too large! {len(output)} > {original_size} bytes")
# Pad to original size
output += b'\x00' * (original_size - len(output))
return output
Step 5: Size Optimization (if needed)
If the model exceeds the original byte budget:
- Reduce triangle count — merge coplanar faces, remove hidden geometry
- Simplify display lists — use G_TRI2 (2 triangles per command) instead of G_TRI1
- Share vertices — maximize vertex reuse within 32-vertex buffer loads
- Reduce texture size — drop from 64x64 to 32x32 where possible
- Use CI4 over CI8 — halves texture data size
- Strip unnecessary DL commands — remove redundant state-setting commands
Deliverables
After completing this phase:
- Binary file containing all display list data
- Binary file containing all texture data (pixels + palettes)
- Python conversion scripts (reusable for iteration)
- Size comparison: new model vs. original byte budget
- Memory map showing where each segment's data will go in ROM