Skip to main content

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

  1. Download from GitHub releases
  2. In Blender: Edit → Preferences → Add-ons → Install → select the zip
  3. 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:

  1. Select the mesh object
  2. Fast64 panel → Export Display List
  3. 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

Format32x3264x64
CI4512 bytes + 32 byte palette2048 bytes + 32 byte palette
CI81024 bytes + 512 byte palette4096 bytes + 512 byte palette
RGBA162048 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:

  1. Reduce triangle count — merge coplanar faces, remove hidden geometry
  2. Simplify display lists — use G_TRI2 (2 triangles per command) instead of G_TRI1
  3. Share vertices — maximize vertex reuse within 32-vertex buffer loads
  4. Reduce texture size — drop from 64x64 to 32x32 where possible
  5. Use CI4 over CI8 — halves texture data size
  6. Strip unnecessary DL commands — remove redundant state-setting commands

Deliverables

After completing this phase:

  1. Binary file containing all display list data
  2. Binary file containing all texture data (pixels + palettes)
  3. Python conversion scripts (reusable for iteration)
  4. Size comparison: new model vs. original byte budget
  5. Memory map showing where each segment's data will go in ROM