Files
ac-decomp/tools/texture_tool.py
2024-02-23 12:06:03 -05:00

456 lines
16 KiB
Python

import argparse
import os
import subprocess
import sys
import numpy as np
from pathlib import Path
from PIL import Image
current_path = sys.path.copy()
sys.path.append(str(Path(__file__).parent.parent))
import common as c
sys.path = current_path
def unswizzle(input_list, width, height, pixels_per_block_w=8, pixels_per_block_h=8):
if width * height > len(input_list):
raise Exception(f"There are not enough elements in input_list for the specified Width and Height!"
f"\nExpected a length of {width * height}, but got a length of {len(input_list)}!")
block_x_count = width // pixels_per_block_w
block_y_count = height // pixels_per_block_h
if block_y_count < 1:
block_y_count = 1 # Hack for small textures -- Not sure if this is correct.
output_buffer = [None] * len(input_list)
pixel_index = 0
for y_block in range(block_y_count):
for x_block in range(block_x_count):
for y_pixel in range(pixels_per_block_h):
for x_pixel in range(pixels_per_block_w):
output_buffer_index = (width * pixels_per_block_h * y_block) + y_pixel * width + x_block * pixels_per_block_w + x_pixel
output_buffer[output_buffer_index] = input_list[pixel_index]
pixel_index += 1
return output_buffer
def swizzle(input_list, width, height, pixels_per_block_w=8, pixels_per_block_h=8):
if width * height > len(input_list):
raise Exception(f"There are not enough elements in input_list for the specified Width and Height!\n"
f"Width = {width} | Height = {height} | Width * Height = {width * height} | Input List Length = {len(input_list)}")
block_x_count = width // pixels_per_block_w
block_y_count = height // pixels_per_block_h
output_buffer = [None] * len(input_list)
output_buffer_index = 0
for y_block in range(block_y_count):
for x_block in range(block_x_count):
for y_pixel in range(pixels_per_block_h):
for x_pixel in range(pixels_per_block_w):
pixel_index = (width * pixels_per_block_h * y_block) + y_pixel * width + x_block * pixels_per_block_w + x_pixel
output_buffer[output_buffer_index] = input_list[pixel_index]
output_buffer_index += 1
return output_buffer
def read_rgb5a3_colors(input_bytes, n):
"""
Reads N unsigned 16-bit values from an input bytearray as big-endian RGB5A3 colors.
Args:
- input_bytes (bytearray): The input bytearray.
- n (int): The number of 16-bit values to read.
Returns:
- List of tuples representing RGB5A3 colors.
"""
if len(input_bytes) < n * 2:
raise ValueError("Input bytearray does not contain enough data for N colors")
colors = []
for i in range(n):
# Extract 16-bit value for each color, assuming big-endian order
color_value = int.from_bytes(input_bytes[i*2:i*2+2], 'big')
colors.append(color_value)
return colors
def to_argb8(pixel):
if (pixel & 0x8000) == 0x8000:
# No Alpha Channel
a = 0xFF
# Separate RGB from bits
r = (pixel & 0x7C00) >> 10
g = (pixel & 0x03E0) >> 5
b = (pixel & 0x001F)
# Convert to RGB8 values
r = (r << (8 - 5)) | (r >> (10 - 8))
g = (g << (8 - 5)) | (g >> (10 - 8))
b = (b << (8 - 5)) | (b >> (10 - 8))
else:
# An Alpha Channel Exists, 3 bits for Alpha Channel and 4 bits each for RGB
a = (pixel & 0x7000) >> 12
r = (pixel & 0x0F00) >> 8
g = (pixel & 0x00F0) >> 4
b = (pixel & 0x000F)
a = (a << (8 - 3)) | (a << (8 - 6)) | (a >> (9 - 8))
r = (r << (8 - 4)) | r
g = (g << (8 - 4)) | g
b = (b << (8 - 4)) | b
# Ensure the values are byte-sized (0-255)
a = a & 0xFF
r = r & 0xFF
g = g & 0xFF
b = b & 0xFF
return r, g, b, a
def rgb5a3_to_argb8(colors):
"""
Converts an array of RGB5A3 colors to standard ARGB8 colors.
Args:
- colors (List[int]): List of integers representing RGB5A3 colors.
Returns:
- List of tuples representing ARGB8 colors.
"""
argb8_colors = []
for color in colors:
argb8_colors.append((to_argb8(color)))
return argb8_colors
def extract_texture_asset(byte_array, width, height, tex_count):
texture_data_size = (width * height) // 2
entry_size = 2 * 16 + texture_data_size * tex_count
n_entries = len(byte_array) // entry_size
entries = []
for entry_index in range(n_entries):
offset = entry_index * entry_size
palette_bytes = byte_array[offset:offset + 2 * 16]
palette_colors = read_rgb5a3_colors(palette_bytes, 16)
textures = []
for texture_index in range(tex_count):
texture_offset = offset + 2 * 16 + (texture_index * texture_data_size)
texture_bytes = byte_array[texture_offset:texture_offset + texture_data_size]
textures.append(texture_bytes)
entries.append({'palette': palette_colors, 'textures': textures})
return entries
def generate_c_source_entries_with_all_textures(entries, width):
"""
Generates a list of C source strings from extracted palette and textures data.
Each string contains one palette and all textures formatted for a C source file,
where each entry is represented as a separate string in the list.
Args:
- entries (List[Dict]): Extracted data containing 'palette' and 'textures' for each entry.
Returns:
- List of strings, each representing the C source code for one entry including one palette and all textures.
"""
c_source_entries = []
for i, entry in enumerate(entries):
c_source = ""
# Palette
c_source += f"// clang-format off\nunsigned short floor{i:02d}_pal[] = {{\n "
for j, color in enumerate(entry['palette']):
c_source += f"0x{color:04X},"
if (j + 1) % 8 == 0 and (j + 1) < 16: # Newline after every 8 entries
c_source += "\n "
else:
c_source += " "
c_source = c_source.rstrip(", \n") + "\n};\n// clang-format on\n\n" # Remove trailing comma and add closing bracket
# Textures
c_source += f"// clang-format off\nunsigned char floor{i:02d}_tex[] = {{\n // texture 0\n "
for texture_index, texture in enumerate(entry['textures']):
for k, pixel in enumerate(texture):
c_source += f"0x{pixel:02X},"
if (k + 1) % (width // 2) == 0: # Newline after width/2 texture pixels
if k + 1 != len(texture) or texture_index < len(entry['textures']) - 1:
c_source += "\n "
else:
c_source += " "
if texture_index < len(entry['textures']) - 1:
c_source += f"// texture {texture_index + 1}\n " # Add a newline after each texture except the last
c_source = c_source.rstrip(", \n") + "\n};\n// clang-format on\n" # Close texture array
c_source_entries.append(c_source)
return c_source_entries
def save_palette_as_hex(palette, directory_path):
"""
Saves an RGB5A3 palette list to a text file with hex values.
Args:
- palette (List[int]): List of colors in RGB5A3 format.
- directory_path (Path): The directory where 'palette.txt' will be saved.
"""
# Ensure the directory exists
directory_path.mkdir(parents=True, exist_ok=True)
# Define the file path
file_path = directory_path / 'palette.txt'
with file_path.open(mode='w') as file:
for color in palette:
# Write each color as a hexadecimal value
file.write(f'{color:04X}\n')
def save_textures_as_png(base_path, entries, width, height, base_name):
for i, entry in enumerate(entries):
folder_name = base_path / f"{base_name}{i:02d}"
folder_name.mkdir(parents=True, exist_ok=True) # Create the directory, including any necessary parent directories
# Save RGB5A3 palette to 'palette.txt'
save_palette_as_hex(entry['palette'], folder_name)
# Convert to RGBA8
palette = rgb5a3_to_argb8(entry['palette'])
for j, texture_data in enumerate(entry['textures']):
# Construct an image from the 4-bit indexed data
img_data = bytearray(len(texture_data) * 2) # Each byte in texture_data represents two pixels
for idx, byte in enumerate(texture_data):
img_data[idx * 2] = byte >> 4 # High nibble for the first pixel
img_data[idx * 2 + 1] = byte & 0x0F # Low nibble for the second pixel
# Map the indexed pixels to RGBA using the palette
rgba_img = [palette[pix] for pix in img_data]
# Unswizzle the RGBA image data
unswizzled_data = unswizzle(rgba_img, width, height) # Assume unswizzle is defined elsewhere
# Create the final image and save it
img = Image.new("RGBA", (width, height))
img.putdata(unswizzled_data)
img_file_path = folder_name / f"texture{j:02d}.png"
img.save(img_file_path)
def generate_includes(n, input_string):
includes = ""
for i in range(n):
includes += f'#include "./{input_string}{i:02d}.c"\n'
return includes
def extract_player_room_floor(byte_array: bytearray, out_dir: str):
entries = extract_texture_asset(byte_array, 64, 64, 4)
entry_sources = generate_c_source_entries_with_all_textures(entries, 64)
Path(out_dir).mkdir(parents=True, exist_ok=True) # make dirs
for i in range(len(entries)):
path = Path(out_dir) / ('floor%02d.c' % i)
with path.open("w") as f:
f.write(entry_sources[i])
save_textures_as_png(Path(out_dir) / 'tex', entries, 64, 64, "floor")
# write aggregate file
with (Path(out_dir) / 'player_room_floor.c').open('w') as f:
f.write(generate_includes(len(entries), "floor"))
def extract_player_room_wall(byte_array: bytearray, out_dir: str):
entries = extract_texture_asset(byte_array, 64, 64, 2)
entry_sources = generate_c_source_entries_with_all_textures(entries, 64)
Path(out_dir).mkdir(parents=True, exist_ok=True) # make dirs
for i in range(len(entries)):
path = Path(out_dir) / ('wall%02d.c' % i)
with path.open("w") as f:
f.write(entry_sources[i])
save_textures_as_png(Path(out_dir) / 'tex', entries, 64, 64, "wall")
# write aggregate file
with (Path(out_dir) / 'player_room_wall.c').open('w') as f:
f.write(generate_includes(len(entries), "wall"))
# packing
def rgba8_to_rgb5a3(r, g, b, a):
if a >= 224: # Treat as opaque
return ((1 << 15) | ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3))
else: # Use 3 bits for alpha
return (((a >> 5) << 12) | ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4))
def find_closest_color(color, palette):
min_dist, index = float('inf'), -1
for i, p in enumerate(palette):
# Directly compare the integer values
dist = (color - p) ** 2
if dist < min_dist:
min_dist, index = dist, i
return index
def load_palette_from_hex(directory_path):
"""
Loads an RGB5A3 palette list from a text file containing hex values.
Args:
- directory_path (Path): The directory where 'palette.txt' is located.
Returns:
- List[int]: List of colors in RGB5A3 format.
"""
# Define the file path
file_path = directory_path / 'palette.txt'
palette = []
with file_path.open(mode='r') as file:
for line in file:
# Convert each hexadecimal string back to an integer
color = int(line.strip(), 16)
palette.append(color)
return palette
def process_png_image(image_path, palette):
img = Image.open(image_path).convert("RGBA")
pixels = np.array(img)
# Create a texture map with indices
texture_map = []
for row in pixels:
row_indices = []
for r, g, b, a in row:
rgb5a3 = rgba8_to_rgb5a3(r, g, b, a)
closest_index = find_closest_color(rgb5a3, palette)
row_indices.append(closest_index)
texture_map.extend(row_indices)
# Swizzle texture
swizzled_texture = swizzle(texture_map, img.width, img.height)
# Pack texture into 4bpp texels
packed_texture_map = [0] * (len(swizzled_texture) // 2)
for i, p in enumerate(swizzled_texture):
if (i % 2) == 0:
packed_texture_map[i // 2] = (p & 0xF) << 4
else:
packed_texture_map[i // 2] |= p & 0xF
return packed_texture_map
def pack_player_room_floor(main_path: Path):
objects = []
for dir in (main_path / 'tex').iterdir():
# load palette file
palette = load_palette_from_hex(dir)
# load texture00.png-texture03.png
textures = []
for i in range(0, 4):
textures.append(process_png_image(dir / f'texture{i:02d}.png', palette))
obj = {'palette': palette, 'textures': textures, 'idx': int(dir.name.replace('floor', ''))}
objects.append(obj)
# sort list
objects.sort(key=lambda x: x['idx'])
# process texture data into C source
c_source = generate_c_source_entries_with_all_textures(objects, 64)
# output C source to files
for i in range(len(objects)):
with (main_path / f'floor{i:02d}.c').open('w') as f:
f.write(c_source[i])
# build elf file and dump .data section
os.chdir(str(Path(__file__).parent.parent))
cmd = c.CC_R
out_elf = main_path / f'{main_path.name}.o'
if cmd.find('wibo') >= 0:
subprocess.run(['wibo', './' + cmd.replace('wibo ', ''), f'-I{str(main_path)}', '-c', main_path / f'{main_path.name}.c', '-o', out_elf])
else:
subprocess.run([cmd, f'-I{str(main_path)}', '-c', main_path / f'{main_path.name}.c', '-o', out_elf])
out_obj = main_path / f'{main_path.name}.bin'
subprocess.run(['powerpc-eabi-objcopy', '--dump-section', f'.data={out_obj}', out_elf])
# restore current dir
os.chdir(str(Path(__file__).parent))
def pack_player_room_wall(main_path: Path):
objects = []
for dir in (main_path / 'tex').iterdir():
# load palette file
palette = load_palette_from_hex(dir)
# load texture00.png-texture03.png
textures = []
for i in range(0, 2):
textures.append(process_png_image(dir / f'texture{i:02d}.png', palette))
obj = {'palette': palette, 'textures': textures, 'idx': int(dir.name.replace('wall', ''))}
objects.append(obj)
# sort list
objects.sort(key=lambda x: x['idx'])
# process texture data into C source
c_source = generate_c_source_entries_with_all_textures(objects, 64)
# output C source to files
for i in range(len(objects)):
with (main_path / f'wall{i:02d}.c').open('w') as f:
f.write(c_source[i])
# build elf file and dump .data section
os.chdir(str(Path(__file__).parent.parent))
cmd = c.CC_R
out_elf = main_path / f'{main_path.name}.o'
if cmd.find('wibo') >= 0:
subprocess.run(['wibo', './' + cmd.replace('wibo ', ''), f'-I{str(main_path)}', '-c', main_path / f'{main_path.name}.c', '-o', out_elf])
else:
subprocess.run([cmd, f'-I{str(main_path)}', '-c', main_path / f'{main_path.name}.c', '-o', out_elf])
out_obj = main_path / f'{main_path.name}.bin'
subprocess.run(['powerpc-eabi-objcopy', '--dump-section', f'.data={out_obj}', out_elf])
def unpack():
with open('src/data/bin2/data/player_room_floor.bin', 'rb') as f:
extract_player_room_floor(bytearray(f.read()), 'src/data/item/player_room_floor')
with open('src/data/bin2/data/player_room_wall.bin', 'rb') as f:
extract_player_room_wall(bytearray(f.read()), 'src/data/item/player_room_wall')
def pack():
cwd = os.getcwd()
pack_player_room_floor(Path('src/data/item/player_room_floor'))
os.chdir(cwd)
pack_player_room_wall(Path('src/data/item/player_room_wall'))
os.chdir(cwd)
def main():
parser = argparse.ArgumentParser(description='Pack or dump Animal Crossing player_room_[floor][wall].bin files.')
parser.add_argument('-m', help="The mode to run. Valid arguments are un[pack].")
args = parser.parse_args()
if args.m.lower() == "pack":
pack()
elif args.m.lower() == "unpack":
unpack()
else:
raise Exception(f'Invalid mode! Please use -m un[pack]')
if __name__ == '__main__':
main()