papermario/tools/splat_ext/tex_archives.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

586 lines
19 KiB
Python
Raw Normal View History

from dataclasses import dataclass
from math import ceil
import struct
import json
from pathlib import Path
import png
import n64img.image
from common import iter_in_groups
from sys import path
path.append(str(Path(__file__).parent.parent / "build"))
from img.build import Converter
def decode_null_terminated_ascii(data):
length = 0
for byte in data:
if byte == 0:
break
length += 1
return data[:length].decode("ascii")
def parse_palette(data):
palette = []
# RRRRRGGG GGBBBBBA
def unpack_color(data):
s = int.from_bytes(data[0:2], byteorder="big")
r = (s >> 11) & 0x1F
g = (s >> 6) & 0x1F
b = (s >> 1) & 0x1F
a = (s & 1) * 0xFF
r = ceil(0xFF * (r / 31))
g = ceil(0xFF * (g / 31))
b = ceil(0xFF * (b / 31))
return r, g, b, a
for a, b in iter_in_groups(data, 2):
palette.append(unpack_color([a, b]))
return palette
FMT_RGBA = 0
FMT_CI = 2
FMT_IA = 3
FMT_I = 4
DEPTH_4_BIT = 0
DEPTH_8_BIT = 1
DEPTH_16_BIT = 2
DEPTH_32_BIT = 3
# extra tile modes
TILES_BASIC = 0
TILES_MIPMAPS = 1
TILES_SHARED_AUX = 2
TILES_INDEPENDENT_AUX = 3
AUX_COMBINE_MODES = {
0x00: "None", # multiply main * prim, ignore aux
0x08: "Multiply", # multiply main * aux * prim
0x0D: "ModulateAlpha", # use prim color, but multiply alpha by the difference between main and aux red channels
0x10: "LerpMainAux", # use prim alpha to lerp between main and aux color, use main alpha
}
AUX_COMBINE_MODES_INV = {v: k for k, v in AUX_COMBINE_MODES.items()}
wrap_modes = {
0: "Repeat",
1: "Mirror",
2: "Clamp",
}
wrap_modes_inv = {v: k for k, v in wrap_modes.items()}
# correspond to modes provided to gSetTextureFilter, only 0 and 2 are ever used
filter_modes = {
0: "Nearest",
2: "Bilerp",
3: "Average",
}
filter_modes_inv = {v: k for k, v in filter_modes.items()}
def get_format_name(fmt, depth):
# get image from bytes for valid combinations of fmt and bit depth
if fmt == FMT_RGBA:
if depth == DEPTH_16_BIT:
return "RGBA16"
if depth == DEPTH_32_BIT:
return "RGBA32"
elif fmt == FMT_CI:
if depth == DEPTH_4_BIT:
return "CI4"
elif depth == DEPTH_8_BIT:
return "CI8"
elif fmt == FMT_IA:
if depth == DEPTH_4_BIT:
return "IA4"
elif depth == DEPTH_8_BIT:
return "IA8"
elif depth == DEPTH_16_BIT:
return "IA16"
elif fmt == FMT_I:
if depth == DEPTH_4_BIT:
return "I4"
elif depth == DEPTH_8_BIT:
return "I8"
else:
raise Exception(f"Invalid format/depth pair: {fmt} and {depth}")
def get_format_code(name):
# get image from bytes for valid combinations of fmt and bit depth
if name == "RGBA16":
return (FMT_RGBA, DEPTH_16_BIT)
elif name == "RGBA32":
return (FMT_RGBA, DEPTH_32_BIT)
elif name == "CI4":
return (FMT_CI, DEPTH_4_BIT)
elif name == "CI8":
return (FMT_CI, DEPTH_8_BIT)
elif name == "IA4":
return (FMT_IA, DEPTH_4_BIT)
elif name == "IA8":
return (FMT_IA, DEPTH_8_BIT)
elif name == "IA16":
return (FMT_IA, DEPTH_16_BIT)
elif name == "I4":
return (FMT_I, DEPTH_4_BIT)
elif name == "I8":
return (FMT_I, DEPTH_8_BIT)
else:
raise Exception(f"Invalid format: {name}")
# class for reading a tex file buffer one chunk at a time
@dataclass
class TexBuffer:
data: bytes
pos: int = 0
@property
def capacity(self):
return len(self.data)
def get(self, count):
amt = int(min(count, self.capacity - self.pos))
ret = self.data[self.pos : self.pos + amt]
self.pos += amt
return ret
def remaining(self):
return self.capacity - self.pos
class TexImage:
# utility function for unpacking aux/main property pairs from a single byte
def split_byte(self, byte):
return (byte >> 4 & 0xF), (byte & 0xF)
# utility function for unpacking aux/main property pairs from a single byte
def pack_byte(self, aux, main):
return ((aux & 0xF) << 4) | (main & 0xF)
# get n64img object from the buffer
def get_n64_img(self, texbuf: TexBuffer, fmt, depth, w, h):
# calculate size for bit depth
if depth == DEPTH_4_BIT:
size = w * h // 2
elif depth == DEPTH_8_BIT:
size = w * h
elif depth == DEPTH_16_BIT:
size = w * h * 2
elif depth == DEPTH_32_BIT:
size = w * h * 4
else:
raise Exception(f"Invalid bit depth: {depth}")
bytes = texbuf.get(size)
# get image from bytes for valid combinations of fmt and bit depth
fmt_name = get_format_name(fmt, depth)
if fmt_name == "RGBA16":
img = n64img.image.RGBA16(data=bytes, width=w, height=h)
elif fmt_name == "RGBA32":
img = n64img.image.RGBA32(data=bytes, width=w, height=h)
elif fmt_name == "CI4":
img = n64img.image.CI4(data=bytes, width=w, height=h)
elif fmt_name == "CI8":
img = n64img.image.CI8(data=bytes, width=w, height=h)
elif fmt_name == "IA4":
img = n64img.image.IA4(data=bytes, width=w, height=h)
elif fmt_name == "IA8":
img = n64img.image.IA8(data=bytes, width=w, height=h)
elif fmt_name == "IA16":
img = n64img.image.IA16(data=bytes, width=w, height=h)
elif fmt_name == "I4":
img = n64img.image.I4(data=bytes, width=w, height=h)
elif fmt_name == "I8":
img = n64img.image.I8(data=bytes, width=w, height=h)
else:
raise Exception(f"Invalid format: {fmt_name}")
img.flip_v = True
return img
# get palette from the buffer
def get_n64_pal(self, texbuf, fmt, depth):
if fmt == FMT_CI:
if depth == DEPTH_4_BIT:
return parse_palette(texbuf.get(0x20))
elif depth == DEPTH_8_BIT:
return parse_palette(texbuf.get(0x200))
# extract texture properties and rasters from buffer
def from_bytes(self, texbuf: TexBuffer):
# strip area prefix and original extension suffix
raw_name = decode_null_terminated_ascii(texbuf.get(32))
self.img_name = raw_name[4:-3]
self.raw_ext = raw_name[-3:]
(
self.aux_width,
self.main_width,
self.aux_height,
self.main_height,
self.is_variant,
self.extra_tiles,
self.combine_mode,
fmts,
depths,
hwraps,
vwraps,
self.filter_mode,
) = struct.unpack(">HHHHBBBBBBBB", texbuf.get(16))
# unpack upper/lower nibbles for aux/main
(self.aux_fmt, self.main_fmt) = self.split_byte(fmts)
(self.aux_depth, self.main_depth) = self.split_byte(depths)
(self.aux_hwrap, self.main_hwrap) = self.split_byte(hwraps)
(self.aux_vwrap, self.main_vwrap) = self.split_byte(vwraps)
self.has_mipmaps = False
self.has_aux = False
# main img only
if self.extra_tiles == TILES_BASIC:
self.main_img = self.get_n64_img(
texbuf,
self.main_fmt,
self.main_depth,
self.main_width,
self.main_height,
)
if self.main_fmt == FMT_CI:
self.main_img.palette = self.get_n64_pal(texbuf, self.main_fmt, self.main_depth)
# main img + mipmaps
elif self.extra_tiles == TILES_MIPMAPS:
self.has_mipmaps = True
self.main_img = self.get_n64_img(
texbuf,
self.main_fmt,
self.main_depth,
self.main_width,
self.main_height,
)
# read mipmaps
self.mipmaps = []
divisor = 2
if self.main_width >= (32 >> self.main_depth):
while True:
if (self.main_width // divisor) <= 0:
break
mmw = self.main_width // divisor
mmh = self.main_height // divisor
mipmap = self.get_n64_img(texbuf, self.main_fmt, self.main_depth, mmw, mmh)
self.mipmaps.append(mipmap)
divisor = divisor * 2
if (self.main_width // divisor) < (16 >> self.main_depth):
break
# read palette and assign to all images
if self.main_fmt == FMT_CI:
shared_pal = self.get_n64_pal(texbuf, self.main_fmt, self.main_depth)
self.main_img.palette = shared_pal
for mipmap in self.mipmaps:
mipmap.palette = shared_pal
# main + aux (shared attributes)
elif self.extra_tiles == TILES_SHARED_AUX:
self.has_aux = True
self.main_img = self.get_n64_img(
texbuf,
self.main_fmt,
self.main_depth,
self.main_width,
self.main_height // 2,
)
self.aux_img = self.get_n64_img(
texbuf,
self.main_fmt,
self.main_depth,
self.main_width,
self.main_height // 2,
)
if self.main_fmt == FMT_CI:
shared_pal = self.get_n64_pal(texbuf, self.main_fmt, self.main_depth)
self.main_img.palette = shared_pal
self.aux_img.palette = shared_pal
# main + aux (independent attributes)
elif self.extra_tiles == TILES_INDEPENDENT_AUX:
self.has_aux = True
# read main
self.main_img = self.get_n64_img(
texbuf,
self.main_fmt,
self.main_depth,
self.main_width,
self.main_height,
)
if self.main_fmt == FMT_CI:
pal = self.get_n64_pal(texbuf, self.main_fmt, self.main_depth)
self.main_img.palette = pal
# read aux
self.aux_img = self.get_n64_img(texbuf, self.aux_fmt, self.aux_depth, self.aux_width, self.aux_height)
if self.aux_fmt == FMT_CI:
self.aux_img.palette = self.get_n64_pal(texbuf, self.aux_fmt, self.aux_depth)
# constructs a dictionary entry for the tex archive for this texture
def get_json_entry(self):
out = {}
out["name"] = self.img_name
# only a single texture in 'tst_tex' has 'rgb', otherwise this is always 'tif'
if self.raw_ext != "tif":
out["ext"] = self.raw_ext
out["main"] = {
"format": get_format_name(self.main_fmt, self.main_depth),
"hwrap": wrap_modes.get(self.main_hwrap),
"vwrap": wrap_modes.get(self.main_vwrap),
}
if self.has_aux:
if self.extra_tiles == TILES_SHARED_AUX:
out["aux"] = {
"format": "Shared",
"hwrap": wrap_modes.get(self.aux_hwrap),
"vwrap": wrap_modes.get(self.aux_vwrap),
}
else:
out["aux"] = {
"format": get_format_name(self.aux_fmt, self.aux_depth),
"hwrap": wrap_modes.get(self.aux_hwrap),
"vwrap": wrap_modes.get(self.aux_vwrap),
}
if self.has_mipmaps:
out["hasMipmaps"] = True
if self.filter_mode == 2:
out["filter"] = True
out["combine"] = AUX_COMBINE_MODES.get(self.combine_mode)
if self.is_variant:
out["variant"] = True
return out
def save_images(self, tex_path):
self.main_img.write(tex_path / f"{self.img_name}.png")
if self.has_aux:
self.aux_img.write(tex_path / f"{self.img_name}_AUX.png")
if self.has_mipmaps:
for idx, mipmap in enumerate(self.mipmaps):
mipmap.write(tex_path / f"{self.img_name}_MM{idx + 1}.png")
def read_json_img(self, img_data, tile_name, img_name):
fmt_str = img_data.get("format")
if fmt_str == None:
raise Exception(f"Texture {img_name} is missing 'format' for '{tile_name}'")
hwrap_str = img_data.get("hwrap", "Missing")
hwrap = wrap_modes_inv.get(hwrap_str)
if hwrap == None:
raise Exception(f"Texture {img_name} has invalid 'hwrap' for '{tile_name}'")
vwrap_str = img_data.get("vwrap", "Missing")
vwrap = wrap_modes_inv.get(vwrap_str)
if vwrap == None:
raise Exception(f"Texture {img_name} has invalid 'vwrap' for '{tile_name}'")
return fmt_str, hwrap, vwrap
def get_img_file(self, fmt_str, img_file: str):
def pack_color(r, g, b, a):
r = r >> 3
g = g >> 3
b = b >> 3
a = a >> 7
return (r << 11) | (g << 6) | (b << 1) | a
(out_img, out_w, out_h) = Converter(mode=fmt_str.lower(), infile=img_file, flip_y=True).convert()
out_pal = bytearray()
if fmt_str == "CI4" or fmt_str == "CI8":
img = png.Reader(img_file)
img.preamble(True)
palette = img.palette(alpha="force")
# load_texture_by_name assumes palettes have a particular length
palette_count = (0x20 if fmt_str == "CI4" else 0x200) // 2
if len(palette) > palette_count:
palette = palette[:palette_count]
self.warn(f"{self.img_name} has more than {palette_count} colors, truncating")
elif len(palette) < palette_count:
palette += [(0, 0, 0, 0)] * (palette_count - len(palette))
for rgba in palette:
if rgba[3] not in (0, 0xFF):
self.warn("alpha mask mode but translucent pixels used")
color = pack_color(*rgba)
out_pal += color.to_bytes(2, byteorder="big")
return (out_img, out_pal, out_w, out_h)
# write texture header and image raster/palettes to byte array
def add_bytes(self, tex_name: str, bytes: bytearray):
pos = len(bytes)
# form raw name and write to header
raw_name = tex_name[:4] + self.img_name + self.raw_ext
name_bytes = raw_name.encode("ascii")
bytes += name_bytes
# pad name out to 32 bytes
pad_len = 32 - len(name_bytes)
assert pad_len > 0
bytes += b"\0" * pad_len
# write header fields
bytes += struct.pack(
">HHHHBBBBBBBB",
self.aux_width,
self.main_width,
self.aux_height,
self.main_height,
self.is_variant,
self.extra_tiles,
self.combine,
self.pack_byte(self.aux_fmt, self.main_fmt),
self.pack_byte(self.aux_depth, self.main_depth),
self.pack_byte(self.aux_hwrap, self.main_hwrap),
self.pack_byte(self.aux_vwrap, self.main_vwrap),
self.filter_mode,
)
# write rasters and palettes
if self.extra_tiles == TILES_BASIC:
bytes += self.main_img
if self.main_fmt == FMT_CI:
bytes += self.main_pal
elif self.extra_tiles == TILES_MIPMAPS:
bytes += self.main_img
for mipmap in self.mipmaps:
bytes += mipmap
if self.main_fmt == FMT_CI:
bytes += self.main_pal
elif self.extra_tiles == TILES_SHARED_AUX:
bytes += self.main_img
bytes += self.aux_img
if self.main_fmt == FMT_CI:
bytes += self.main_pal
elif self.extra_tiles == TILES_INDEPENDENT_AUX:
bytes += self.main_img
if self.main_fmt == FMT_CI:
bytes += self.main_pal
bytes += self.aux_img
if self.aux_fmt == FMT_CI:
bytes += self.aux_pal
size = len(bytes) - pos
assert size == self.expected_size(), f"{raw_name}: size mismatch: {size} != {self.expected_size()}"
def expected_size(self) -> int:
"""
Uses logic from load_texture_by_name to calculate the expected size.
"""
raster_size = self.main_width * self.main_height
# compute mipmaps size
if self.main_depth == DEPTH_4_BIT:
if self.has_mipmaps:
divisor = 2
while self.main_width // divisor >= 16 and self.main_height // divisor > 0:
raster_size += self.main_width // divisor * self.main_height // divisor
divisor *= 2
raster_size //= 2
elif self.main_depth == DEPTH_8_BIT:
if self.has_mipmaps:
divisor = 2
while self.main_width // divisor >= 8 and self.main_height // divisor > 0:
raster_size += self.main_width // divisor * self.main_height // divisor
divisor *= 2
elif self.main_depth == DEPTH_16_BIT:
if self.has_mipmaps:
divisor = 2
while self.main_width // divisor >= 4 and self.main_height // divisor > 0:
raster_size += self.main_width // divisor * self.main_height // divisor
divisor *= 2
raster_size *= 2
elif self.main_depth == DEPTH_32_BIT:
if self.has_mipmaps:
divisor = 2
while self.main_width // divisor >= 2 and self.main_height // divisor > 0:
raster_size += self.main_width // divisor * self.main_height // divisor
divisor *= 2
raster_size *= 4
# compute palette size
if self.main_fmt == FMT_CI:
palette_size = 0x20
if self.main_depth == DEPTH_8_BIT:
palette_size = 0x200
else:
palette_size = 0
# compute aux tile size
if self.extra_tiles == TILES_INDEPENDENT_AUX:
aux_raster_size = self.aux_width * self.aux_height
if self.aux_depth == DEPTH_4_BIT:
aux_raster_size //= 2
elif self.aux_depth == DEPTH_16_BIT:
aux_raster_size *= 2
elif self.aux_depth == DEPTH_32_BIT:
aux_raster_size *= 4
if self.aux_fmt == FMT_CI:
aux_palette_size = 0x20
if self.aux_depth == DEPTH_8_BIT:
aux_palette_size = 0x200
else:
aux_palette_size = 0
else:
aux_palette_size = 0
aux_raster_size = 0
return raster_size + palette_size + 48 + aux_raster_size + aux_palette_size
class TexArchive:
@staticmethod
def extract(bytes, tex_path: Path):
textures = []
texbuf = TexBuffer(bytes)
while texbuf.remaining() > 0:
img = TexImage()
img.from_bytes(texbuf)
textures.append(img)
tex_path.mkdir(parents=True, exist_ok=True)
out = []
for texture in textures:
texture.save_images(tex_path)
out.append(texture.get_json_entry())
json_out = json.dumps(out, sort_keys=False, indent=4)
json_fn = str(tex_path) + ".json"
with open(json_fn, "w") as f:
f.write(json_out)