Update Wii U 3 Texture Tool

This commit is contained in:
Cainan 2024-07-05 20:00:26 +01:00
parent 83fd57ccac
commit 6f8bebf9d6
3 changed files with 338 additions and 124 deletions

View File

@ -3,9 +3,23 @@
Tool to generate song textures for Taiko no Tatsujin Wii U 1-3, using a modern wordlist.json file.
Only supports Japanese text for now.
Usage: python generate.py song_id genreNo
Usage: generate.py <id> <genreNo> [-ura]
There is also an additional script in here to convert the folder of textures to a .nut texture.
The code in this was partially based on the NUT code found in [Smash Forge](https://github.com/jam1garner/Smash-Forge)
Usage: generate_nut.py input_folder output_file
Usage: generate_nut.py [-h] [--format {dxt1,dxt3,dxt5}] input_folder output_file
Generate NUT file from PNG files.
positional arguments:
input_folder Input folder containing PNG files.
output_file Output NUT file.
options:
-h, --help show this help message and exit
--format {dxt1,dxt3,dxt5}
Texture compression format.
Requirements:
[NVIDIA Texture Tools Exporter](https://developer.nvidia.com/texture-tools-exporter) installed and added to PATH

View File

@ -26,7 +26,9 @@ rotated_chars = {
'': '', '': '',
'': '', '': '',
'': '', '': '',
'': '', '': ''
'': '', '': '',
'(': '', ')': '',
'-': 'l'
}
rotated_letters = {
@ -94,7 +96,7 @@ def generate_image(draw, text, font, rotated_font, size, position, alignment, st
char = rotated_chars.get(char, char)
char = rotated_letters.get(char, char)
text_bbox = get_text_bbox(draw, char, char_font)
char_height = 40
char_height = (text_bbox[3] + text_bbox[1])
char_width = text_bbox[2] - text_bbox[0]
draw.text((text_position[0] - char_width / 2, y_offset), char, font=char_font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill)
y_offset += char_height
@ -102,11 +104,10 @@ def generate_image(draw, text, font, rotated_font, size, position, alignment, st
y_offset = 5
for char in text:
char_font = rotated_font if char in rotated_chars else font
char = rotated_letters.get(char, char)
char = rotated_chars.get(char, char)
char = rotated_letters.get(char, char)
text_bbox = get_text_bbox(draw, char, char_font)
char_height = 27
char_height = (text_bbox[3] + text_bbox[1])
char_width = text_bbox[2] - text_bbox[0]
draw.text((text_position[0] - char_width / 2, y_offset), char, font=char_font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill)
y_offset += char_height
@ -158,7 +159,12 @@ def create_images(data, id, genreNo, font_path, rotated_font_path, append_ura=Fa
if append_ura:
japanese_text += ""
japanese_text += " "
#japanese_text = " " + japanese_text
#japanese_text += " "
padded_japanese_text = japanese_text + " "
even_more_padded_japanese_text = " " + japanese_text
padded_japanese_sub_text = japanese_sub_text + " "
# Check if texts were found
if not japanese_text:
@ -174,23 +180,148 @@ def create_images(data, id, genreNo, font_path, rotated_font_path, append_ura=Fa
rotated_font = ImageFont.truetype(rotated_font_path, int(font_size_medium))
# Image 0.png
img0 = Image.new('RGBA', (720, 64), color=(0, 0, 0, 0))
img0_width = 720
img0 = Image.new('RGBA', (img0_width, 64), color=(0, 0, 0, 0))
draw0 = ImageDraw.Draw(img0)
generate_image(draw0, japanese_text, font_large, rotated_font, (720, 64), (17, 10), 'right', 5, 'black', 'white')
img0.save(os.path.join(folder_name, '0.png'))
temp_img0 = Image.new('RGBA', (2880, 64), (0, 0, 0, 0)) # Temporary image with 2880px width
temp_draw0 = ImageDraw.Draw(temp_img0)
# Generate the image with the Japanese text
generate_image(temp_draw0, even_more_padded_japanese_text, font_large, rotated_font, (2880, 64), (0, 10), 'right', 5, 'black', 'white')
# Calculate the bounding box of the entire text
text_bbox = get_text_bbox(temp_draw0, japanese_text, font_large)
text_width = (text_bbox[2] - text_bbox[0]) + 5
# Resize the image if it exceeds the specified height
if text_width > img0_width:
cropped_img0 = temp_img0.crop((2880 - text_width, 0, 2880, 64))
scaled_img0 = cropped_img0.resize((img0_width, 64), Image.Resampling.LANCZOS)
final_img0 = Image.new('RGBA', (img0_width, 64), (0, 0, 0, 0))
final_img0.paste(scaled_img0)
else:
# Crop the temporary image to the actual width of the text
cropped_img0 = temp_img0.crop((2880 - text_width, 0, 2880, 64))
final_img0 = Image.new('RGBA', (img0_width, 64), (0, 0, 0, 0))
final_img0.paste(cropped_img0, (img0_width - text_width, 0))
# Create a new image with the specified width and right-align the text
#final_img0 = Image.new('RGBA', (img0_width, 64), (0, 0, 0, 0))
#final_img0.paste(cropped_img, (img0_width - text_width, 0))
# Save the final image
final_img0.save(os.path.join(folder_name, '0.png'))
# Image 1.png
img1 = Image.new('RGBA', (720, 104), color=(0, 0, 0, 0))
img1_width = 720
img1 = Image.new('RGBA', (img1_width, 104), color=(0, 0, 0, 0))
draw1 = ImageDraw.Draw(img1)
generate_image(draw1, japanese_text, font_extra_large, rotated_font, (720, 104), (0, 13), 'center', 5, 'black', 'white')
generate_image(draw1, japanese_sub_text, font_medium, rotated_font, (720, 104), (0, 68), 'center', 4, 'black', 'white')
img1.save(os.path.join(folder_name, '1.png'))
temp_img1 = Image.new('RGBA', (2880, 104), (0, 0, 0, 0)) # Temporary image with 2880px width
temp_draw1 = ImageDraw.Draw(temp_img1)
temp_sub_img1 = Image.new('RGBA', (2880, 104), (0, 0, 0, 0)) # Temporary image with 2880px width
temp_sub_draw1 = ImageDraw.Draw(temp_sub_img1)
# Generate the image with the Japanese text
generate_image(temp_draw1, japanese_text, font_extra_large, rotated_font, (2880, 104), (0, 13), 'center', 5, 'black', 'white')
# Calculate the bounding box of the entire text
text_bbox = get_text_bbox(temp_draw1, japanese_text, font_extra_large)
text_width = (text_bbox[2] - text_bbox[0]) + 5
# Resize the image if it exceeds the specified width
if text_width > img1_width:
# Calculate the crop box to crop equally from both sides
left_crop = (2880 - text_width) // 2
right_crop = 2880 - left_crop
cropped_img1 = temp_img1.crop((left_crop, 0, right_crop, 104))
scaled_img1 = cropped_img1.resize((img1_width, 104), Image.Resampling.LANCZOS)
img1_1 = Image.new('RGBA', (img1_width, 104), (0, 0, 0, 0))
img1_1.paste(scaled_img1)
else:
# Crop the temporary image to the actual width of the text
left_crop = (2880 - text_width) // 2
right_crop = 2880 - left_crop
cropped_img1 = temp_img1.crop((left_crop, 0, right_crop, 104))
img1_1 = Image.new('RGBA', (img1_width, 104), (0, 0, 0, 0))
offset = (img1_width - text_width) // 2
img1_1.paste(cropped_img1, (offset, 0))
# Generate the image with the Japanese sub-text
generate_image(temp_sub_draw1, japanese_sub_text, font_medium, rotated_font, (2880, 104), (0, 68), 'center', 4, 'black', 'white')
# Calculate the bounding box of the entire sub-text
text_bbox_sub = get_text_bbox(temp_sub_draw1, japanese_sub_text, font_medium)
text_width_sub = (text_bbox_sub[2] - text_bbox_sub[0]) + 5
# Resize the sub-image if it exceeds the specified width
if text_width_sub > img1_width:
# Calculate the crop box to crop equally from both sides
left_crop_sub = (2880 - text_width_sub) // 2
right_crop_sub = 2880 - left_crop_sub
cropped_img1_sub = temp_sub_img1.crop((left_crop_sub, 0, right_crop_sub, 104))
scaled_img1_sub = cropped_img1_sub.resize((img1_width, 104), Image.Resampling.LANCZOS)
img1_2 = Image.new('RGBA', (img1_width, 104), (0, 0, 0, 0))
img1_2.paste(scaled_img1_sub)
else:
# Crop the temporary sub-image to the actual width of the sub-text
left_crop_sub = (2880 - text_width_sub) // 2
right_crop_sub = 2880 - left_crop_sub
cropped_img1_sub = temp_sub_img1.crop((left_crop_sub, 0, right_crop_sub, 104))
img1_2 = Image.new('RGBA', (img1_width, 104), (0, 0, 0, 0))
offset_sub = (img1_width - text_width_sub) // 2
img1_2.paste(cropped_img1_sub, (offset_sub, 0))
final_img1 = Image.new('RGBA', (img1_width, 104), (0, 0, 0, 0))
final_img1.paste(img1_1, (0, 0))
final_img1.paste(img1_2, (0, 0), img1_2)
final_img1.save(os.path.join(folder_name, '1.png'))
# Image 2.png
img2 = Image.new('RGBA', (720, 64), color=(0, 0, 0, 0))
img2_width = 720
img2 = Image.new('RGBA', (img2_width, 64), color=(0, 0, 0, 0))
draw2 = ImageDraw.Draw(img2)
generate_image(draw2, japanese_text, font_large, rotated_font, (720, 64), (0, 4), 'center', 5, 'black', 'white')
img2.save(os.path.join(folder_name, '2.png'))
temp_img2 = Image.new('RGBA', (2880, 64), (0, 0, 0, 0)) # Temporary image with 2880px width
temp_draw2 = ImageDraw.Draw(temp_img2)
# Generate the image with the Japanese text
generate_image(temp_draw2, japanese_text, font_large, rotated_font, (2880, 64), (0, 4), 'center', 5, 'black', 'white')
# Calculate the bounding box of the entire text
text_bbox = get_text_bbox(temp_draw2, japanese_text, font_large)
text_width = (text_bbox[2] - text_bbox[0]) + 5
# Resize the image if it exceeds the specified height
if text_width > img2_width:
# Calculate the crop box to crop equally from both sides
left_crop = (2880 - text_width) // 2
right_crop = 2880 - left_crop
cropped_img2 = temp_img2.crop((left_crop, 0, right_crop, 64))
scaled_img2 = cropped_img2.resize((img2_width, 64), Image.Resampling.LANCZOS)
final_img2 = Image.new('RGBA', (img2_width, 64), (0, 0, 0, 0))
final_img2.paste(scaled_img2)
else:
# Crop the temporary image to the actual width of the text
left_crop = (2880 - text_width) // 2
right_crop = 2880 - left_crop
cropped_img2 = temp_img2.crop((left_crop, 0, right_crop, 64))
final_img2 = Image.new('RGBA', (img2_width, 64), (0, 0, 0, 0))
offset = (img2_width - text_width) // 2
final_img2.paste(cropped_img2, (offset, 0))
final_img2.save(os.path.join(folder_name, '2.png'))
# Image 3.png
@ -201,22 +332,22 @@ def create_images(data, id, genreNo, font_path, rotated_font_path, append_ura=Fa
img3_2 = Image.new('RGBA', (96, 400), color=(0, 0, 0, 0))
draw3 = ImageDraw.Draw(img3)
temp_img3 = Image.new('RGBA', (96, 1000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_img3 = Image.new('RGBA', (96, 3000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_draw3 = ImageDraw.Draw(temp_img3)
temp_sub_img3 = Image.new('RGBA', (96, 1000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_sub_img3 = Image.new('RGBA', (96, 3000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_sub_draw3 = ImageDraw.Draw(temp_sub_img3)
generate_image(temp_draw3, japanese_text, font_large, rotated_font, (96, 1000), (89, 0), 'center', 5, 'black', 'white', vertical=True)
generate_image(temp_draw3, padded_japanese_text, font_large, rotated_font, (96, 3000), (89, 0), 'center', 5, 'black', 'white', vertical=True)
# Crop the temporary image to the actual height of the text
y_offset = 0
for char in japanese_text:
for char in padded_japanese_text:
char_font = rotated_font if char in rotated_chars else font_large
char = rotated_chars.get(char, char)
char = rotated_letters.get(char, char)
text_bbox = get_text_bbox(temp_draw3, char, char_font)
char_height = 42
char_height = (text_bbox[3] + text_bbox[1])
y_offset += char_height
# Crop the temporary image to the actual height of the text
@ -228,16 +359,16 @@ def create_images(data, id, genreNo, font_path, rotated_font_path, append_ura=Fa
else:
img3_1 = temp_img3.crop((0, 0, 96, img3_height))
generate_image(temp_sub_draw3, japanese_sub_text, font_medium, rotated_font, (96, 1000), (32, 156), 'center', 4, 'black', 'white', vertical_small=True)
generate_image(temp_sub_draw3, japanese_sub_text, font_medium, rotated_font, (96, 3000), (32, 156), 'center', 4, 'black', 'white', vertical_small=True)
# Crop the temporary image to the actual height of the text
y_offset = 0
for char in japanese_sub_text:
char_font = rotated_font if char in rotated_chars else font_large
char_font = rotated_font if char in rotated_chars else font_medium
char = rotated_chars.get(char, char)
char = rotated_letters.get(char, char)
text_bbox = get_text_bbox(temp_sub_draw3, char, char_font)
char_height = 28
char_height = round((text_bbox[3] + text_bbox[1]) * 1.1)
y_offset += char_height
# Crop the temporary image to the actual height of the text
@ -259,19 +390,19 @@ def create_images(data, id, genreNo, font_path, rotated_font_path, append_ura=Fa
img4 = Image.new('RGBA', (56, 400), color=(0, 0, 0, 0))
draw4 = ImageDraw.Draw(img4)
temp_img4 = Image.new('RGBA', (56, 1000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_img4 = Image.new('RGBA', (56, 3000), (0, 0, 0, 0)) # Temporary image with 3000px height
temp_draw4 = ImageDraw.Draw(temp_img4)
generate_image(temp_draw4, japanese_text, font_large, rotated_font, (56, 400), (48, 0), 'center', 5, genre_color, 'white', vertical=True)
generate_image(temp_draw4, padded_japanese_text, font_large, rotated_font, (56, 400), (48, 0), 'center', 5, genre_color, 'white', vertical=True)
# Crop the temporary image to the actual height of the text
y_offset = 0
for char in japanese_text:
for char in padded_japanese_text:
char_font = rotated_font if char in rotated_chars else font_large
char = rotated_chars.get(char, char)
char = rotated_letters.get(char, char)
text_bbox = get_text_bbox(temp_draw4, char, char_font)
char_height = 42
char_height = (text_bbox[3] + text_bbox[1])
y_offset += char_height
# Crop the temporary image to the actual height of the text
@ -291,19 +422,19 @@ def create_images(data, id, genreNo, font_path, rotated_font_path, append_ura=Fa
img5 = Image.new('RGBA', (56, 400), color=(0, 0, 0, 0))
draw5 = ImageDraw.Draw(img5)
temp_img5 = Image.new('RGBA', (56, 1000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_img5 = Image.new('RGBA', (56, 3000), (0, 0, 0, 0)) # Temporary image with 1000px height
temp_draw5 = ImageDraw.Draw(temp_img5)
generate_image(temp_draw5, japanese_text, font_large, rotated_font, (56, 400), (48, 0), 'center', 5, 'black', 'white', vertical=True)
generate_image(temp_draw5, padded_japanese_text, font_large, rotated_font, (56, 400), (48, 0), 'center', 5, 'black', 'white', vertical=True)
# Crop the temporary image to the actual height of the text
y_offset = 0
for char in japanese_text:
for char in padded_japanese_text:
char_font = rotated_font if char in rotated_chars else font_large
char = rotated_chars.get(char, char)
char = rotated_letters.get(char, char)
text_bbox = get_text_bbox(temp_draw5, char, char_font)
char_height = 42
char_height = (text_bbox[3] + text_bbox[1])
y_offset += char_height
# Crop the temporary image to the actual height of the text

View File

@ -1,7 +1,8 @@
import argparse
import os
from PIL import Image
import struct
import subprocess
#from PIL import Image
class TextureSurface:
def __init__(self):
@ -14,6 +15,7 @@ class NutTexture:
self.Height = height
self.pixelInternalFormat = pixel_format
self.pixelFormat = pixel_type
self.HashId = 0
def add_mipmap(self, mipmap_data):
self.surfaces[0].mipmaps.append(mipmap_data)
@ -23,13 +25,27 @@ class NutTexture:
return len(self.surfaces[0].mipmaps)
def getNutFormat(self):
if self.pixelInternalFormat == 'RGBA':
if self.pixelInternalFormat == 'CompressedRgbaS3tcDxt1Ext':
return 0
elif self.pixelInternalFormat == 'CompressedRgbaS3tcDxt3Ext':
return 1
elif self.pixelInternalFormat == 'CompressedRgbaS3tcDxt5Ext':
return 2
elif self.pixelInternalFormat == 'RGBA':
if self.pixelFormat == 'RGBA':
return 14
raise NotImplementedError("Only RGBA format is implemented")
elif self.pixelFormat == 'ABGR':
return 16
else:
return 17
else:
raise NotImplementedError(f"Unknown pixel format {self.pixelInternalFormat}")
class NUT:
def __init__(self):
self.textures = []
self.endian = 'big'
self.version = 0x0200
def add_texture(self, texture):
self.textures.append(texture)
@ -39,108 +55,161 @@ class NUT:
f.write(self.build())
def build(self):
o = bytearray()
data = bytearray()
if self.endian == 'big':
o.extend(struct.pack('>I', 0x4E545033)) # NTP3
else:
o.extend(struct.pack('>I', 0x4E545744)) # NTWD
if self.version > 0x0200:
self.version = 0x0200
o.extend(struct.pack('>H', self.version))
num_textures = len(self.textures)
# File header
header = struct.pack(">IHH", 0x4E545033, 0x0200, num_textures)
data.extend(header)
if num_textures != 1 and num_textures != 6:
raise ValueError("The number of images must be either 1 or 6.")
o.extend(struct.pack('>H', num_textures))
o.extend(b'\x00' * 8) # Reserved space
# Initial offset (0x18 bytes for the header, then 0x4 bytes per texture offset)
texture_offset_base = 0x18 + (0x4 * num_textures)
texture_headers_offset = texture_offset_base
texture_data_offset = texture_headers_offset + (0x50 * num_textures)
# Ensure texture data starts at the correct offset (0x42E0)
texture_data_offset = max(texture_data_offset, 0x4000)
# Offset table
texture_offsets = []
header_length = 0
for texture in self.textures:
texture_offsets.append(texture_data_offset)
texture_data_offset += 0x50 + sum(len(mipmap) for mipmap in texture.surfaces[0].mipmaps)
for offset in texture_offsets:
data.extend(struct.pack(">I", offset))
# Texture headers and mipmaps
for texture, offset in zip(self.textures, texture_offsets):
data.extend(self.build_texture_header(texture, offset))
surface_count = len(texture.surfaces)
is_cubemap = surface_count == 6
if surface_count < 1 or surface_count > 6:
raise NotImplementedError(f"Unsupported surface amount {surface_count} for texture. 1 to 6 faces are required.")
if surface_count > 1 and surface_count < 6:
raise NotImplementedError(f"Unsupported cubemap face amount for texture. Six faces are required.")
mipmap_count = len(texture.surfaces[0].mipmaps)
header_size = 0x50 + (0x10 if is_cubemap else 0) + (mipmap_count * 4 if mipmap_count > 1 else 0)
header_size = (header_size + 0xF) & ~0xF # Align to 16 bytes
header_length += header_size
for texture in self.textures:
for mipmap in texture.surfaces[0].mipmaps:
surface_count = len(texture.surfaces)
is_cubemap = surface_count == 6
mipmap_count = len(texture.surfaces[0].mipmaps)
data_size = sum((len(mipmap) + 0xF) & ~0xF for mipmap in texture.surfaces[0].mipmaps)
header_size = 0x50 + (0x10 if is_cubemap else 0) + (mipmap_count * 4 if mipmap_count > 1 else 0)
header_size = (header_size + 0xF) & ~0xF
o.extend(struct.pack('>I', data_size + header_size))
o.extend(b'\x00' * 4) # Padding
o.extend(struct.pack('>I', data_size))
o.extend(struct.pack('>H', header_size))
o.extend(b'\x00' * 2) # Padding
o.extend(b'\x00')
o.extend(struct.pack('B', mipmap_count))
o.extend(b'\x00')
o.extend(struct.pack('B', texture.getNutFormat()))
o.extend(struct.pack('>HH', texture.Width, texture.Height))
o.extend(b'\x00' * 4) # Padding
o.extend(struct.pack('>I', 0)) # DDS Caps2 placeholder
if self.version >= 0x0200:
o.extend(struct.pack('>I', header_length + len(data)))
else:
o.extend(b'\x00' * 4) # Padding
header_length -= header_size
o.extend(b'\x00' * 12) # Reserved space
if is_cubemap:
o.extend(struct.pack('>II', len(texture.surfaces[0].mipmaps[0]), len(texture.surfaces[0].mipmaps[0])))
o.extend(b'\x00' * 8) # Padding
if texture.getNutFormat() == 14 or texture.getNutFormat() == 17:
self.swap_channel_order_down(texture)
for surface in texture.surfaces:
for mipmap in surface.mipmaps:
mip_start = len(data)
data.extend(mipmap)
while len(data) % 0x10 != 0:
data.extend(b'\x00')
if mipmap_count > 1:
mip_end = len(data)
o.extend(struct.pack('>I', mip_end - mip_start))
return data
while len(o) % 0x10 != 0:
o.extend(b'\x00')
def build_texture_header(self, texture, offset):
mipmap_count = texture.MipMapsPerSurface
size = texture.Width * texture.Height * 4 # Texture size
header = struct.pack(">IIIIHHIIII",
size, texture.Width, texture.Height, 0, 0,
mipmap_count, texture.getNutFormat(),
texture.Width, texture.Height, 0)
additional_data = b'\x65\x58\x74\x00\x00\x00\x00\x20\x00\x00\x00\x10\x00\x00\x00\x00' \
b'\x47\x49\x44\x58\x00\x00\x00\x10\x00\x00\x00\x05\x00\x00\x00\x00'
return header + additional_data.ljust(0x50 - len(header), b'\x00')
if texture.getNutFormat() == 14 or texture.getNutFormat() == 17:
self.swap_channel_order_up(texture)
def modify_nut_file(self, file_path, output_path):
# Set replacement bytes to 00
o.extend(b'\x65\x58\x74\x00') # "eXt\0"
o.extend(struct.pack('>II', 0x20, 0x10))
o.extend(b'\x00' * 4)
with open(file_path, 'rb') as f:
data = bytearray(f.read())
o.extend(b'\x47\x49\x44\x58') # "GIDX"
o.extend(struct.pack('>I', 0x10))
o.extend(struct.pack('>I', texture.HashId)) # Texture ID
o.extend(b'\x00' * 4)
# Replace bytes from 0x00 to 0x1F0
#data[0x00:0x1EF] = replacement_bytes
# Delete bytes from 0x42E0 to 0x42F3 (0x42E0 to 0x42F4 inclusive)
del data[0x42E0:0x42F3]
del data[0x0040:0x0044]
data[0x1F0:0x1F0] = b'\x00\x00\x00\x00'
data[0x008:0x010] = b'\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x010:0x040] = b'\x00\x02\xd0P\x00\x00\x00\x00\x00\x02\xd0\x00\x00P\x00\x00\x00\x01\x00\x0e\x02\xd0\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xe0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x060:0x090] = b'\x00\x04\x92P\x00\x00\x00\x00\x00\x04\x92\x00\x00P\x00\x00\x00\x01\x00\x0e\x02\xd0\x00h\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xd1\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x0B0:0x0E0] = b'\x00\x02\xd0P\x00\x00\x00\x00\x00\x02\xd0\x00\x00P\x00\x00\x00\x01\x00\x0e\x02\xd0\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07c@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x100:0x130] = b'\x00\x02X\x50\x00\x00\x00\x00\x00\x02X\x00\x00P\x00\x00\x00\x01\x00\x0e\x00`\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\n2\xf0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x150:0x180] = b'\x00\x01^P\x00\x00\x00\x00\x00\x01^\x00\x00P\x00\x00\x00\x01\x00\x0e\x00\x38\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x8a\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x1A0:0x1D0] = b'\x00\x01^P\x00\x00\x00\x00\x00\x01^\x00\x00P\x00\x00\x00\x01\x00\x0e\x00\x38\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\r\xe8P\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
data[0x5B:0x5C] = b'\x00'
data[0xAB:0xAC] = b'\x01'
data[0xFB:0xFC] = b'\x02'
data[0x14B:0x14C] = b'\x03'
data[0x19B:0x19C] = b'\x04'
# Add three 0x00 bytes to the end of the file
data.extend(b'\x00\x00\x00')
o.extend(data)
with open(output_path, 'wb') as f:
f.write(data)
return o
def load_png_to_texture(filepath):
with Image.open(filepath) as img:
img = img.convert("RGBA")
width, height = img.size
mipmap_data = img.tobytes()
texture = NutTexture(width, height, "RGBA", "RGBA")
texture.add_mipmap(mipmap_data)
def swap_channel_order_down(self, texture):
for surface in texture.surfaces:
for i, mipmap in enumerate(surface.mipmaps):
mipmap = bytearray(mipmap)
for j in range(0, len(mipmap), 4):
mipmap[j], mipmap[j + 2] = mipmap[j + 2], mipmap[j]
surface.mipmaps[i] = bytes(mipmap)
def swap_channel_order_up(self, texture):
for surface in texture.surfaces:
for i, mipmap in enumerate(surface.mipmaps):
mipmap = bytearray(mipmap)
for j in range(0, len(mipmap), 4):
mipmap[j], mipmap[j + 2] = mipmap[j + 2], mipmap[j]
surface.mipmaps[i] = bytes(mipmap)
def nvcompress_png_to_dds(png_filepath, dds_filepath, format_option):
format_map = {
'dxt1': '-bc1',
'dxt3': '-bc2',
'dxt5': '-bc3',
}
format_arg = format_map.get(format_option.lower(), '-bc1')
command = f"nvcompress {format_arg} {png_filepath} {dds_filepath}"
subprocess.run(command, shell=True, check=True)
def load_dds_to_texture(dds_filepath, index, pixel_format):
DDS_HEADER_SIZE = 128 # DDS header size in bytes
with open(dds_filepath, 'rb') as dds_file:
dds_data = dds_file.read()
print(f"Length of dds_data: {len(dds_data)}")
print(f"Bytes from 12 to 20: {dds_data[12:20]}")
width, height = struct.unpack_from('<II', dds_data, 12)[:2]
texture = NutTexture(height, width, pixel_format, pixel_format)
texture.add_mipmap(dds_data[DDS_HEADER_SIZE:]) # Skip the DDS header
texture.HashId = index # Set HashId based on the index
return texture
def generate_nut_from_pngs(png_folder, output_nut_path, format_option):
nut = NUT()
png_files = [f for f in os.listdir(png_folder) if f.lower().endswith('.png')]
for index, png_file in enumerate(png_files):
png_path = os.path.join(png_folder, png_file)
dds_path = os.path.splitext(png_path)[0] + '.dds'
nvcompress_png_to_dds(png_path, dds_path, format_option)
texture = load_dds_to_texture(dds_path, index, f'CompressedRgbaS3tc{format_option.capitalize()}Ext')
nut.add_texture(texture)
nut.save(output_nut_path)
def main():
parser = argparse.ArgumentParser(description="Convert a folder of PNGs to a NUT file.")
parser.add_argument("input_folder", help="Folder containing PNG files")
parser.add_argument("output_file", help="Output NUT file")
parser = argparse.ArgumentParser(description='Generate NUT file from PNG files.')
parser.add_argument('input_folder', type=str, help='Input folder containing PNG files.')
parser.add_argument('output_file', type=str, help='Output NUT file.')
parser.add_argument('--format', type=str, default='dxt5', choices=['dxt1', 'dxt3', 'dxt5'], help='Texture compression format.')
args = parser.parse_args()
nut = NUT()
for filename in os.listdir(args.input_folder):
if filename.endswith(".png"):
texture = load_png_to_texture(os.path.join(args.input_folder, filename))
nut.add_texture(texture)
generate_nut_from_pngs(args.input_folder, args.output_file, args.format)
# Save the NUT file
nut_filename = args.output_file
nut.save(nut_filename)
# Modify the saved NUT file
output_filename = nut_filename # You can modify this if needed
nut.modify_nut_file(nut_filename, output_filename)
if __name__ == "__main__":
if __name__ == '__main__':
main()