Skip to content

Instantly share code, notes, and snippets.

@bskari
Last active December 7, 2023 00:42
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bskari/68ab23176a1c1c1e31debe7092296f8d to your computer and use it in GitHub Desktop.
Save bskari/68ab23176a1c1c1e31debe7092296f8d to your computer and use it in GitHub Desktop.
Tries to parse textures from Nintendo gigaleak N64 source files
"""Finds textures that have been converted to N64 source code and restores them
as PNGs.
"""
# Copyright 2020 Brandon Skari
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import codecs
import enum
import glob
import os
import pathlib
import re
import sys
import typing
try:
import png
except:
print("You need to install pypng")
sys.exit(1)
OUTPUT_DIRECTORY = "textures"
def convert_all_files() -> None:
"""Converts all the files in a particular N64 directory to PNGs."""
# Different games use different formats, so we need to have different parsers
game_directory_to_converter = {
"sm64": convert_sm64,
"f0x": convert_f0x,
}
current_directory = str(pathlib.Path().absolute())
called = False
for game, function in game_directory_to_converter.items():
if game in current_directory:
try:
os.mkdir(OUTPUT_DIRECTORY)
except:
pass
print(f"Saving images for {game}")
function()
called = True
break
if not called:
print("This needs to be run from within a supported N64 directory.")
def get_line_iterator(file_name: str) -> typing.Iterator[str]:
"""Returns a line iterator for a file."""
# Just try all of the Japanese codecs until one works, I'm too lazy to find
# out which one they used in 1994
for codec in (
"utf8",
"cp932",
"euc_jp",
"euc_jis_2004",
"euc_jisx0213",
"iso2022_jp",
"iso2022_jp_1",
"iso2022_jp_2",
"iso2022_jp_2004",
"iso2022_jp_3",
"iso2022_jp_ext",
"shift_jis",
"shift_jis_2004",
"shift_jisx0213",
):
success = False
try:
with codecs.open(file_name, "r", codec) as file:
for line in file:
yield line
except:
continue
else:
# That codec worked! Let's abort
return
def convert_sm64() -> None:
"""Converts all SM64 texture files and saves them into a folder."""
# I think the only textures that SM64 has are RGBA5551. I'm also not sure
# about this list of file extensions, I might have too much here.
for targets in ("**/*.h", "**/*.c", "**/*.sou", "**/*.tex", "**/*.txt"):
for file_name in glob.iglob(targets, recursive=True):
try:
iterator = get_line_iterator(file_name)
convert_rgba5551_c_file(iterator)
except Exception as exc:
print(f"Failed to parse {file_name}: {exc}")
def convert_f0x() -> None:
"""Converts all F0X texture files and saves them into a folder."""
# F0X uses G_IM_FMT_A, G_IM_FMT_CI, G_IM_FMT_I, G_IM_FMT_IA, G_IM_FMT_RGBA
type_regex = re.compile("Format of texel: (\w+)")
bits_per_texel_re = re.compile("Number of bits per texel: (\d+)")
bits_per_texel = 0
for targets in ("**/*.c", "**/*.tex"):
for file_name in glob.iglob(targets, recursive=True):
#if "mo_US_edm_weight" not in file_name:
# continue
try:
iterator = get_line_iterator(file_name)
# We need to read the file format to determine which type it is
found_type = False
for line in iterator:
# Bits per texel comes before format, so we need to grab and save it first
match = bits_per_texel_re.search(line)
if match:
bits_per_texel = int(match.groups()[0])
# Check file format
match = type_regex.search(line)
if not match:
continue
file_format = match.groups()[0]
if file_format == "G_IM_FMT_RGBA":
convert_rgba5551_c_file(iterator)
elif file_format == "G_IM_FMT_IA":
convert_ia_c_file(iterator)
elif file_format == "G_IM_FMT_A":
convert_a_c_file(iterator, bits_per_texel)
elif file_format == "G_IM_FMT_I":
convert_i_c_file(iterator, bits_per_texel)
elif file_format == "G_IM_FMT_CI":
convert_ci_c_file(iterator, bits_per_texel)
else:
print(f"Unsupported image format: '{file_format}'")
except Exception as exc:
print(f"Failed to parse {file_name}: {exc}")
def convert_rgba5551_c_file(line_iterator: typing.Iterator[str]) -> None:
"""Converts a C source file RGBA5551 file into PNGs."""
rgba5551_regex = re.compile(r"unsigned\s+short\s+(\w+)\[\]\s+=\s+{")
opened_bracket = False
data = []
for line in line_iterator:
match = rgba5551_regex.search(line)
if match and "}" not in line:
opened_bracket = True
matched_file_name = match.groups()[0]
data = []
elif opened_bracket:
if "}" in line:
opened_bracket = False
save_rgba5551_png(matched_file_name, data)
else:
if "/*" in line or "*/" in line:
continue
if len(line.strip()) == 0:
continue
ints = line.replace(" ", "").replace("0x", "").strip().split(",")
ints = [i for i in ints if len(i) > 0]
words = [int(i, 16) for i in ints]
data.append(words)
def convert_ia_c_file(line_iterator: typing.Iterator[str]) -> None:
"""Converts a C source file IA file into PNGs."""
for file_name, chunks in _get_unsigned_char_data(line_iterator):
save_ia8_png(file_name, chunks)
def convert_i_c_file(line_iterator: typing.Iterator[str], bits_per_texel: int) -> None:
"""Converts a C source file intensity file into PNGs."""
for file_name, chunks in _get_unsigned_char_data(line_iterator):
save_i_png(file_name, chunks, bits_per_texel)
def convert_a_c_file(line_iterator: typing.Iterator[str], bits_per_texel: int) -> None:
"""Converts a C source file A file into PNGs."""
# I don't know what an A file (G_IM_FMT_A) is, so this is a guess, but
# guessing from the ASCII preview, I think it's 0 == alpha, anything else
# is intensity?
assert bits_per_texel == 4, f"Bad bits_per_texel {bits_per_texel} in convert_a_c_file"
def convert(value: int) -> int:
if value == 0:
return 0x00 # 100% alpha
return (value << 4) | 0x0F
for file_name, chunks in _get_unsigned_char_data(line_iterator):
formatted_chunks = []
for row in chunks:
formatted_row = []
for value in row:
formatted_row.append(convert((value & 0xF0) >> 4))
formatted_row.append(convert(value & 0x0F))
formatted_chunks.append(formatted_row)
save_ia8_png(file_name, formatted_chunks)
def _get_unsigned_char_data(
line_iterator: typing.Iterator[str]
) -> typing.Tuple[str, typing.List[typing.List[int]]]:
"""Returns unsigned char data from an array."""
unsigned_char_re = re.compile(r"unsigned\s+char\s+(\w+)\[\]\s+=\s+{")
opened_bracket = False
data = []
for line in line_iterator:
match = unsigned_char_re.search(line)
if match and "}" not in line:
opened_bracket = True
matched_file_name = match.groups()[0]
data = []
elif opened_bracket:
if "}" in line:
opened_bracket = False
yield matched_file_name, data
else:
if "/*" in line or "*/" in line:
continue
if len(line.strip()) == 0:
continue
ints = line.replace(" ", "").replace("0x", "").strip().split(",")
ints = [i for i in ints if len(i) > 0]
words = [int(i, 16) for i in ints]
data.append(words)
def convert_ci_c_file(line_iterator: typing.Iterator[str], bits_per_texel: int) -> None:
"""Converts a C source file CI index file into PNGs."""
# CI is a list of indexes into a separate palette
indexes_re = re.compile(r"unsigned\s+char\s+(\w+)\[\]\s+=\s+{")
palette_re = re.compile(r"unsigned\s+short\s+(\w+)\[\]\s+=\s+{")
opened_bracket = False
indexes = []
palette_colors = []
# I used to have a 3rd state for looking for bits, which is why I have this
# instead of a bool
@enum.unique
class CiState(enum.Enum):
LOOKING_FOR_INDEX = 2
LOOKING_FOR_PALETTE = 3
state = CiState.LOOKING_FOR_INDEX
for line in line_iterator:
if state == CiState.LOOKING_FOR_INDEX:
match = indexes_re.search(line)
if match and "}" not in line:
opened_bracket = True
file_name = match.groups()[0]
indexes = []
elif opened_bracket:
if "}" in line:
state = CiState.LOOKING_FOR_PALETTE
else:
if "/*" in line or "*/" in line:
continue
if len(line.strip()) == 0:
continue
ints = line.replace(" ", "").replace("0x", "").strip().split(",")
ints = [i for i in ints if len(i) > 0]
words = [int(i, 16) for i in ints]
if bits_per_texel == 8:
indexes.append(words)
elif bits_per_texel == 4:
words_2 = []
for word in words:
words_2.append((word & 0xF0) >> 4)
words_2.append(word & 0x0F)
indexes.append(words_2)
else:
assert False, f"Bad bits per texel {bits_per_texel}"
elif state == CiState.LOOKING_FOR_PALETTE:
match = palette_re.search(line)
if match and "}" not in line:
palette_colors = []
elif opened_bracket:
if "}" in line:
# I don't know if any file has multiple images per file, so
# this might be unnecessary. I haven't seen any. But if
# there are any, this will need work. Also, if there are
# multiples, do they share palettes? Or bit depth?
state = CiState.LOOKING_FOR_INDEX
save_ci_png(file_name, indexes, palette_colors)
else:
if "/*" in line or "*/" in line:
continue
if len(line.strip()) == 0:
continue
ints = line.replace(" ", "").replace("0x", "").strip().split(",")
ints = [i for i in ints if len(i) > 0]
words = [int(i, 16) for i in ints]
palette_colors += words
else:
assert False, "Bad state {state}"
def byte_to_ca(word: int) -> typing.List[int]:
"""Converts an IA8 to 2-tuple."""
return [
(word & 0b1111_0000),
(word & 0b0000_1111) << 4,
]
def save_ia8_png(
file_name: str,
data: typing.List[typing.List[int]],
) -> None:
"""Saves IA8 data to a PNG."""
# Each entry is an 8-bit CCCCAAAA
converted = []
for row in data:
converted_row = []
for byte in row:
for item in byte_to_ca(byte):
converted_row.append(item)
converted.append(converted_row)
width = len(converted[0]) // 2
height = len(converted)
png_file_name = f"{OUTPUT_DIRECTORY}{os.sep}{file_name}.png"
with open(png_file_name, "wb") as file:
writer = png.Writer(width, height, greyscale=True, alpha=True)
writer.write(file, converted)
def save_i_png(
file_name: str,
data: typing.List[typing.List[int]],
bits_per_texel: int,
) -> None:
"""Saves intensity data to a PNG."""
# Each entry is an N-bit CCCCAAAA
converted = []
for row in data:
converted_row = []
for byte in row:
if bits_per_texel == 8:
converted_row.append(byte)
elif bits_per_texel == 4:
converted_row.append((byte & 0xF0) >> 4)
converted_row.append(byte & 0x0F)
else:
assert False, f"Bad bits per texel {bits_per_texel}"
converted.append(converted_row)
width = len(converted[0])
height = len(converted)
png_file_name = f"{OUTPUT_DIRECTORY}{os.sep}{file_name}.png"
with open(png_file_name, "wb") as file:
writer = png.Writer(width, height, greyscale=True, alpha=False)
writer.write(file, converted)
def word_to_rgb(word: int) -> typing.List[int]:
"""Converts an RGBA5551 to 4-tuple."""
part = (
(word & 0b11111_00000_00000_0) >> 11,
(word & 0b00000_11111_00000_0) >> 6,
(word & 0b00000_00000_11111_0) >> 1,
(word & 0b00000_00000_00000_1) >> 0,
)
return [
int(i * 255. / 0b11111)
for i in part[:3]
] + [int(part[3] * 255)]
def save_rgba5551_png(
file_name: str,
data: typing.List[typing.List[int]],
) -> None:
"""Saves RGBA5551 data to a PNG."""
# Each entry is a 16-bit word RGBA5551
converted = []
for row in data:
converted_row = []
for word in row:
for item in word_to_rgb(word):
converted_row.append(item)
converted.append(converted_row)
width = len(converted[0]) // 4
height = len(converted)
png_file_name = f"{OUTPUT_DIRECTORY}{os.sep}{file_name}.png"
with open(png_file_name, "wb") as file:
writer = png.Writer(width, height, greyscale=False, alpha=True)
writer.write(file, converted)
def dword_to_rgb(dword: int) -> typing.List[int]:
"""Converts an RGBA4444 dword to 4-tuple."""
return [
(dword & 0xFF00_0000) >> 24,
(dword & 0x00FF_0000) >> 16,
(dword & 0x0000_FF00) >> 8,
(dword & 0x0000_00FF) >> 0,
]
def save_rgba32_png(
file_name: str,
data: typing.List[typing.List[int]],
) -> None:
"""Saves RGBA32 data to a PNG."""
# Each entry is a 32-bit RGBA8888 dword
converted = []
for row in data:
converted_row = []
for dword in row:
for item in dword_to_rgb(dword):
converted_row.append(item)
converted.append(converted_row)
width = len(converted[0]) // 4
height = len(converted)
png_file_name = f"{OUTPUT_DIRECTORY}{os.sep}{file_name}.png"
with open(png_file_name, "wb") as file:
writer = png.Writer(width, height, greyscale=False, alpha=True)
writer.write(file, converted)
def save_ci_png(
file_name: str,
indexes: typing.List[int],
palette_colors: typing.List[int],
) -> None:
"""Saves CI data to a PNG."""
# We could cache all the palettes instead of converting them each time, but
# who cares
converted = []
for row in indexes:
converted_row = []
for index in row:
for item in word_to_rgb(palette_colors[index]):
converted_row.append(item)
converted.append(converted_row)
width = len(converted[0]) // 4
height = len(converted)
png_file_name = f"{OUTPUT_DIRECTORY}{os.sep}{file_name}.png"
with open(png_file_name, "wb") as file:
writer = png.Writer(width, height, greyscale=False, alpha=True)
writer.write(file, converted)
if __name__ == "__main__":
convert_all_files()
@iProgramMC
Copy link

This ignores the transparency, all the pixels would have 0 transparency. Easiest fix in the world:
https://gist.github.com/bskari/68ab23176a1c1c1e31debe7092296f8d#file-save_textures-py-L391
change Line 391 and 392 so that the 3 becomes a 4 (transparency should be included, too!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment