Last active
December 7, 2023 00:42
-
-
Save bskari/68ab23176a1c1c1e31debe7092296f8d to your computer and use it in GitHub Desktop.
Tries to parse textures from Nintendo gigaleak N64 source files
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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!)