Files
Ghostship/tools/merge.py

620 lines
24 KiB
Python

import os
import re
import argparse
import struct
from enum import Enum
from io import BytesIO
from typing import Optional, Dict
from PIL import Image
class TextureType(Enum):
Error = 0
RGBA32bpp = 1
RGBA16bpp = 2
Palette4bpp = 3
Palette8bpp = 4
Grayscale4bpp = 5
Grayscale8bpp = 6
GrayscaleAlpha4bpp = 7
GrayscaleAlpha8bpp = 8
GrayscaleAlpha16bpp = 9
TexturePixelMultipliers: Dict[TextureType, float] = {
TextureType.Error: 0.0,
TextureType.RGBA32bpp: 4.0,
TextureType.RGBA16bpp: 2.0,
TextureType.Palette4bpp: 0.5,
TextureType.Palette8bpp: 1.0,
TextureType.Grayscale4bpp: 0.5,
TextureType.Grayscale8bpp: 1.0,
TextureType.GrayscaleAlpha4bpp: 0.5,
TextureType.GrayscaleAlpha8bpp: 1.0,
TextureType.GrayscaleAlpha16bpp: 2.0,
}
class TextureTypeUtils:
@staticmethod
def num_to_texture_type(num: int) -> TextureType:
mapping = {
1: TextureType.RGBA32bpp,
2: TextureType.RGBA16bpp,
3: TextureType.Palette4bpp,
4: TextureType.Palette8bpp,
5: TextureType.Grayscale4bpp,
6: TextureType.Grayscale8bpp,
7: TextureType.GrayscaleAlpha4bpp,
8: TextureType.GrayscaleAlpha8bpp,
9: TextureType.GrayscaleAlpha16bpp,
}
if num not in mapping:
raise ValueError(f"Unknown texture type number: {num}")
return mapping[num]
@staticmethod
def str_to_texture_type(string: str) -> TextureType:
mapping = {
'RGBA32': TextureType.RGBA32bpp,
'RGBA16': TextureType.RGBA16bpp,
'TLUT': TextureType.RGBA16bpp,
'CI4': TextureType.Palette4bpp,
'CI8': TextureType.Palette8bpp,
'I4': TextureType.Grayscale4bpp,
'I8': TextureType.Grayscale8bpp,
'IA4': TextureType.GrayscaleAlpha4bpp,
'IA8': TextureType.GrayscaleAlpha8bpp,
'IA16': TextureType.GrayscaleAlpha16bpp,
}
if string not in mapping:
raise ValueError(f"Unknown texture type string: {string}")
return mapping[string]
@staticmethod
def get_buffer_size(tex_type: TextureType, width: int, height: int) -> int:
pixels = width * height
if tex_type == TextureType.RGBA32bpp:
return pixels * 4
elif tex_type in (TextureType.RGBA16bpp, TextureType.GrayscaleAlpha16bpp):
return pixels * 2
elif tex_type in (TextureType.Palette8bpp, TextureType.Grayscale8bpp, TextureType.GrayscaleAlpha8bpp):
return pixels
elif tex_type in (TextureType.Palette4bpp, TextureType.Grayscale4bpp, TextureType.GrayscaleAlpha4bpp):
return (pixels + 1) // 2
return 0
class Texture:
def __init__(self, width: int, height: int, texture_type: TextureType, tex_data: bytearray = None, tlut: Optional['Texture'] = None):
self.width = width
self.height = height
self.texture_type = texture_type
self.tex_data_size = TextureTypeUtils.get_buffer_size(texture_type, width, height)
self.tex_data = tex_data if tex_data is not None else bytearray(self.tex_data_size)
self.tlut = tlut
def to_png_bytes(self) -> bytes:
return N64Graphics.convert_n64_to_png(self)
# --- Main Implementation ---
class N64Graphics:
@staticmethod
def has_alpha(texture: Texture) -> bool:
t = texture.texture_type
return t in (
TextureType.Palette4bpp,
TextureType.Palette8bpp,
TextureType.RGBA32bpp,
TextureType.RGBA16bpp,
TextureType.GrayscaleAlpha16bpp,
TextureType.GrayscaleAlpha8bpp,
TextureType.GrayscaleAlpha4bpp,
)
@staticmethod
def pixels_to_png(texture: Texture, data: bytes) -> bytes:
img = Image.frombytes("RGBA", (texture.width, texture.height), data)
out_buffer = BytesIO()
img.save(out_buffer, format="PNG")
return out_buffer.getvalue()
@staticmethod
def convert_raw_to_n64(texture: Texture, img: Image.Image) -> None:
texture.width = img.width
texture.height = img.height
texture.tex_data_size = TextureTypeUtils.get_buffer_size(texture.texture_type, texture.width, texture.height)
texture.tex_data = bytearray(texture.tex_data_size)
w, h = texture.width, texture.height
tex_data = texture.tex_data
tex_type = texture.texture_type
if tex_type == TextureType.RGBA16bpp:
img_rgba = img.convert("RGBA")
pixels = img_rgba.load()
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
pos = ((y * w) + x) * 2
r5 = r // 8
g5 = g // 8
b5 = b // 8
a_bit = 1 if a != 0 else 0
data_val = (r5 << 11) | (g5 << 6) | (b5 << 1) | a_bit
tex_data[pos] = (data_val & 0xFF00) >> 8
tex_data[pos + 1] = data_val & 0x00FF
elif tex_type == TextureType.RGBA32bpp:
img_rgba = img.convert("RGBA")
pixels = img_rgba.load()
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
pos = ((y * w) + x) * 4
tex_data[pos:pos+4] = [r, g, b, a]
elif tex_type == TextureType.Palette4bpp:
img_p = img.convert("P") if img.mode != "P" else img
pixels = img_p.load()
for y in range(h):
for x in range(0, w, 2):
pos = ((y * w) + x) // 2
idx1 = pixels[x, y]
idx2 = pixels[x + 1, y] if x + 1 < w else 0
tex_data[pos] = ((idx1 & 0x0F) << 4) | (idx2 & 0x0F)
elif tex_type == TextureType.Palette8bpp:
img_p = img.convert("P") if img.mode != "P" else img
pixels = img_p.load()
for y in range(h):
for x in range(w):
pos = (y * w) + x
tex_data[pos] = pixels[x, y] & 0xFF
elif tex_type == TextureType.Grayscale4bpp:
img_l = img.convert("L") if img.mode != "L" else img
pixels = img_l.load()
for y in range(h):
for x in range(0, w, 2):
pos = ((y * w) + x) // 2
r1 = pixels[x, y]
r2 = pixels[x + 1, y] if x + 1 < w else 0
tex_data[pos] = (((r1 // 16) << 4) | (r2 // 16)) & 0xFF
elif tex_type == TextureType.Grayscale8bpp:
img_l = img.convert("L") if img.mode != "L" else img
pixels = img_l.load()
for y in range(h):
for x in range(w):
pos = (y * w) + x
tex_data[pos] = pixels[x, y] & 0xFF
elif tex_type == TextureType.GrayscaleAlpha4bpp:
img_la = img.convert("LA") if img.mode != "LA" else img
pixels = img_la.load()
for y in range(h):
for x in range(0, w, 2):
pos = ((y * w) + x) // 2
l1, a1 = pixels[x, y]
l2, a2 = pixels[x + 1, y] if x + 1 < w else (0, 0)
alpha_bit1 = 1 if a1 != 0 else 0
alpha_bit2 = 1 if a2 != 0 else 0
data_val = ((((l1 // 32) << 1) | alpha_bit1) << 4) | (((l2 // 32) << 1) | alpha_bit2)
tex_data[pos] = data_val & 0xFF
elif tex_type == TextureType.GrayscaleAlpha8bpp:
img_la = img.convert("LA") if img.mode != "LA" else img
pixels = img_la.load()
for y in range(h):
for x in range(w):
pos = (y * w) + x
l, a = pixels[x, y]
tex_data[pos] = (((l // 16) << 4) | (a // 16)) & 0xFF
elif tex_type == TextureType.GrayscaleAlpha16bpp:
img_la = img.convert("LA") if img.mode != "LA" else img
pixels = img_la.load()
for y in range(h):
for x in range(w):
pos = ((y * w) + x) * 2
l, a = pixels[x, y]
tex_data[pos] = l & 0xFF
tex_data[pos + 1] = a & 0xFF
elif tex_type == TextureType.Error:
raise ValueError("Unknown texture type")
@staticmethod
def convert_n64_to_png(texture: Texture) -> Optional[bytes]:
w, h = texture.width, texture.height
tex_data = texture.tex_data
tex_type = texture.texture_type
is_palette = tex_type in (TextureType.Palette4bpp, TextureType.Palette8bpp)
img: Optional[Image.Image] = None
if tex_type == TextureType.RGBA32bpp:
img = Image.frombytes("RGBA", (w, h), bytes(tex_data))
elif tex_type == TextureType.RGBA16bpp:
img = Image.new("RGBA", (w, h))
pixels = img.load()
for y in range(h):
for x in range(w):
pos = ((y * w) + x) * 2
data_val = (tex_data[pos] << 8) | tex_data[pos + 1]
r = ((data_val & 0xF800) >> 11) * 8
g = ((data_val & 0x07C0) >> 6) * 8
b = ((data_val & 0x003E) >> 1) * 8
a = (data_val & 0x01) * 255
pixels[x, y] = (r, g, b, a)
elif tex_type == TextureType.Palette4bpp:
img = Image.new("P", (w, h))
pixels = img.load()
palette = [0] * (256 * 4)
for y in range(h):
for x in range(0, w, 2):
pos = ((y * w) + x) // 2
idx1 = (tex_data[pos] & 0xF0) >> 4
idx2 = tex_data[pos] & 0x0F
pixels[x, y] = idx1
if x + 1 < w:
pixels[x + 1, y] = idx2
palette[idx1*4 : idx1*4+4] = [idx1*16, idx1*16, idx1*16, 255]
palette[idx2*4 : idx2*4+4] = [idx2*16, idx2*16, idx2*16, 255]
elif tex_type == TextureType.Palette8bpp:
img = Image.new("P", (w, h))
pixels = img.load()
palette = [0] * (256 * 4)
for y in range(h):
for x in range(w):
pos = (y * w) + x
idx = tex_data[pos]
pixels[x, y] = idx
palette[idx*4 : idx*4+4] = [idx, idx, idx, 255]
elif tex_type == TextureType.Grayscale4bpp:
img = Image.new("L", (w, h))
pixels = img.load()
for y in range(h):
for x in range(0, w, 2):
pos = ((y * w) + x) // 2
g1 = tex_data[pos] & 0xF0
g2 = (tex_data[pos] & 0x0F) << 4
pixels[x, y] = g1
if x + 1 < w:
pixels[x + 1, y] = g2
elif tex_type == TextureType.Grayscale8bpp:
img = Image.new("L", (w, h))
pixels = img.load()
for y in range(h):
for x in range(w):
pos = (y * w) + x
pixels[x, y] = tex_data[pos]
elif tex_type == TextureType.GrayscaleAlpha4bpp:
img = Image.new("LA", (w, h))
pixels = img.load()
for y in range(h):
for x in range(0, w, 2):
pos = ((y * w) + x) // 2
val = tex_data[pos]
d1 = (val & 0xF0) >> 4
g1 = ((d1 & 0x0E) >> 1) * 32
a1 = (d1 & 0x01) * 255
pixels[x, y] = (g1, a1)
if x + 1 < w:
d2 = val & 0x0F
g2 = ((d2 & 0x0E) >> 1) * 32
a2 = (d2 & 0x01) * 255
pixels[x + 1, y] = (g2, a2)
elif tex_type == TextureType.GrayscaleAlpha8bpp:
img = Image.new("LA", (w, h))
pixels = img.load()
for y in range(h):
for x in range(w):
pos = (y * w) + x
val = tex_data[pos]
g = val & 0xF0
a = (val & 0x0F) << 4
pixels[x, y] = (g, a)
elif tex_type == TextureType.GrayscaleAlpha16bpp:
img = Image.new("LA", (w, h))
pixels = img.load()
for y in range(h):
for x in range(w):
pos = ((y * w) + x) * 2
g = tex_data[pos]
a = tex_data[pos + 1]
pixels[x, y] = (g, a)
else:
return None
if is_palette:
if texture.tlut:
pal_bytes = texture.tlut.to_png_bytes()
pal_img = Image.open(BytesIO(pal_bytes)).convert("RGBA")
pal_pixels = pal_img.load()
for y in range(pal_img.height):
for x in range(pal_img.width):
index = y * pal_img.width + x
if index >= 256:
continue
r, g, b, a = pal_pixels[x, y]
palette[index*4 : index*4+4] = [r, g, b, a]
img.putpalette(bytearray(palette), rawmode='RGBA')
out_buffer = BytesIO()
img.save(out_buffer, format="PNG")
return out_buffer.getvalue()
def convert_png_to_n64_texture(png_bytes: bytes, texture_type: TextureType) -> Texture:
img = Image.open(BytesIO(png_bytes))
texture = Texture(0, 0, texture_type)
N64Graphics.convert_raw_to_n64(texture, img)
return texture
def write_header(resource_type: int, version: int) -> bytes:
endianness = 0x00
game_version = 0
rom_crc = 0
rom_enum = 0
magic = 0xDEADBEEFDEADBEEF
# Struct format characters (assuming Little-Endian '<'):
# I = unsigned int (4 bytes)
# Q = unsigned long long (8 bytes)
#
# Order: Endianness(I), Type(I), Version(I), Magic(Q), GameVer(I), CRC(Q), Enum(I)
header_data = struct.pack(
'<IIIQIQI',
endianness, # [0x00] 4 bytes
resource_type, # [0x04] 4 bytes
version, # [0x08] 4 bytes
magic, # [0x0C] 8 bytes
game_version, # [0x14] 4 bytes
rom_crc, # [0x18] 8 bytes
rom_enum # [0x20] 4 bytes
)
# Pad with null bytes until we reach 0x40 (64 bytes)
padding_needed = 0x40 - len(header_data)
if padding_needed > 0:
header_data += b'\x00' * padding_needed
return header_data
def preprocess_asset(bytes_data: bytes, filename: str) -> bytes:
file = filename.lower()
if file.lower().endswith('.bin'):
return bytes_data
elif file.endswith('.png'):
img = Image.open(BytesIO(bytes_data))
width, height = img.size
texture_type = TextureType.RGBA32bpp.value
raw = bytes_data
resource_type = 0x52544558 # RTEX
tex_data_size = len(raw)
otr_header = write_header(resource_type=resource_type, version=0x1)
tex_header = struct.pack(
'<IIIIffI',
texture_type, # type
width, # width
height, # height
1 << 1, # flags: TEX_FLAG_LOAD_AS_IMG
1.0, # h_byte_scale
1.0, # v_pixel_scale
tex_data_size # image_data_size
)
return otr_header + tex_header + raw
else:
raise ValueError(f"Unsupported asset file type for {filename}")
def preprocess_embed(bytes_data: bytes, filename: str) -> bytes:
# If you want bytes to be embedded directly into the C file, you can preprocess them here.
return bytes_data
def amalgamate(file_path, include_dirs, processed, out_file, assets_dir):
abs_path = os.path.abspath(file_path)
if abs_path in processed:
return
processed.add(abs_path)
try:
with open(abs_path, 'r', encoding='utf-8') as f:
content = f.read()
# Step 1: Find and process `#asset` blocks ONLY
# Replaces `u8 array[] = { #asset ... };` with ALIGN_ASSET macro and extracts .bin
asset_block_pattern = re.compile(
r'(?:(?:static|const|ALIGNED8|extern)\s+)*u8\s+([a-zA-Z0-9_]+)\s*\[\]\s*=\s*\{\s*#asset\s+([<"])([^>"]+)[>"]\s*\};',
re.MULTILINE
)
def repl_asset_block(match):
var_name = match.group(1)
delimiter = match.group(2)
target_name = match.group(3)
target_path = None
search_paths = list(include_dirs)
if delimiter == '"':
search_paths.insert(0, os.path.dirname(abs_path))
for path in search_paths:
potential_path = os.path.join(path, target_name)
if os.path.exists(potential_path):
target_path = potential_path
break
if target_path:
try:
with open(target_path, 'rb') as bin_file:
raw_bytes = bin_file.read()
resource_name = target_name
if resource_name.lower().endswith('.png'):
resource_name = resource_name[:-4]
# Write the .bin companion file mirroring directory tree
if assets_dir:
companion_path = os.path.join(assets_dir, resource_name)
os.makedirs(os.path.dirname(companion_path), exist_ok=True)
with open(companion_path, 'wb') as cf:
cf.write(preprocess_asset(raw_bytes, target_name))
# Return formatted string literal block for C
return f'static const ALIGN_ASSET(2) char {var_name}[] = "__OTR__{resource_name}";'
except Exception as e:
print(f"Warning: Failed to read asset file {target_path}: {e}")
return f"/* ERROR: Failed to process {target_name} */"
else:
print(f"Warning: Could not find asset file {target_name} requested from {abs_path}")
return f"/* ERROR: Could not find {target_name} */"
# Apply the #asset replacement over the whole text file
content = asset_block_pattern.sub(repl_asset_block, content)
# Step 2: Process the remaining lines one by one for standard #embeds and #includes
active_stack = [True]
for line in content.splitlines(keepends=True):
stripped = line.strip()
if re.match(r'#\s*ifdef\s+__cplusplus\b', stripped) or \
re.match(r'#\s*if\s+defined\s*\(\s*__cplusplus\s*\)', stripped):
active_stack.append(False)
out_file.write(line)
continue
elif re.match(r'#\s*ifndef\s+__cplusplus\b', stripped):
active_stack.append(all(active_stack))
out_file.write(line)
continue
elif re.match(r'#\s*(ifdef|ifndef|if)\b', stripped):
active_stack.append(all(active_stack))
out_file.write(line)
continue
elif re.match(r'#\s*(else|elif)\b', stripped):
if len(active_stack) > 1:
parent_active = all(active_stack[:-1])
active_stack[-1] = parent_active and not active_stack[-1]
out_file.write(line)
continue
elif re.match(r'#\s*endif\b', stripped):
if len(active_stack) > 1:
active_stack.pop()
out_file.write(line)
continue
if not all(active_stack):
out_file.write(line)
continue
# Look for legacy #embed (leaves the surrounding u8 array intact, just injects hex)
embed = re.match(r'^\s*#\s*embed\s+([<"])([^>"]+)[>"]', line)
if embed:
delimiter = embed.group(1)
target_name = embed.group(2)
target_path = None
search_paths = list(include_dirs)
if delimiter == '"':
search_paths.insert(0, os.path.dirname(abs_path))
for path in search_paths:
potential_path = os.path.join(path, target_name)
if os.path.exists(potential_path):
target_path = potential_path
break
if target_path:
out_file.write(f"/* --- Start Embedded Bytes: {target_name} --- */\n")
try:
with open(target_path, 'rb') as bin_file:
raw_bytes = bin_file.read()
raw_bytes = preprocess_embed(raw_bytes, target_name)
# Write out raw hex array right into the C file
hex_array = [f"0x{b:02x}" for b in raw_bytes]
for i in range(0, len(hex_array), 16):
chunk = hex_array[i:i+16]
out_file.write(" " + ", ".join(chunk) + ",\n")
except Exception as e:
print(f"Warning: Failed to read embedded file {target_path}: {e}")
out_file.write(f"/* ERROR: Failed to read {target_name} */\n")
out_file.write(f"/* --- End Embedded Bytes: {target_name} --- */\n")
else:
out_file.write(f"/* ERROR: Could not find {target_name} */\n")
continue
# Standard #include amalgamation
include = re.match(r'^\s*#\s*include\s+([<"])([^>"]+)[>"]', line)
if include:
delimiter = include.group(1)
header_name = include.group(2)
header_path = None
search_paths = list(include_dirs)
if delimiter == '"':
search_paths.insert(0, os.path.dirname(abs_path))
for path in search_paths:
potential_path = os.path.join(path, header_name)
if os.path.exists(potential_path):
header_path = potential_path
break
if header_path:
out_file.write(f"/* --- Start: {header_name} --- */\n")
amalgamate(header_path, include_dirs, processed, out_file, assets_dir)
out_file.write(f"/* --- End: {header_name} --- */\n")
else:
out_file.write(line)
else:
out_file.write(line)
except Exception as e:
print(f"Warning: Could not process {abs_path}: {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate a Unity Build amalgamation.")
parser.add_argument('--out', required=True, help="The single output .c file")
parser.add_argument('--assets-dir', default=None, help="Root output directory for companion .bin files")
parser.add_argument('--includes', nargs='*', default=[], help="Include directories")
parser.add_argument('--srcs', nargs='+', required=True, help="Input .c files")
args = parser.parse_args()
global_processed = set()
with open(args.out, 'w', encoding='utf-8') as out_file:
for src_file in args.srcs:
out_file.write(f"\n/* ========================================= */\n")
out_file.write(f"/* === Start of Source: {os.path.basename(src_file)} === */\n")
out_file.write(f"/* ========================================= */\n\n")
amalgamate(src_file, args.includes, global_processed, out_file, args.assets_dir)