mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-06-04 18:28:45 -04:00
Tools: add libbti and assets_config (#2182)
* Start d_menu_fmap2d * checkpoint * checkpoint * Add libbti and bti packaging, start libjaudio * Add asset config, move libast, formatting
This commit is contained in:
@@ -32,5 +32,9 @@ docs/doxygen/
|
||||
__pycache__/
|
||||
venv/
|
||||
|
||||
# m2ctx files
|
||||
m2ctx.py
|
||||
ctx.c
|
||||
|
||||
# Asset Config
|
||||
asset_config.json
|
||||
|
||||
@@ -156,7 +156,11 @@ tools: dirs $(ELF2DOL) $(YAZ0)
|
||||
|
||||
assets:
|
||||
@mkdir -p game
|
||||
$(PYTHON) tools/extract_game_assets.py $(IMAGENAME) game
|
||||
$(PYTHON) tools/extract_game_assets.py $(IMAGENAME) game native asset_config.json
|
||||
|
||||
assets-fast:
|
||||
@mkdir -p game
|
||||
$(PYTHON) tools/extract_game_assets.py $(IMAGENAME) game oead asset_config.json
|
||||
|
||||
docs:
|
||||
$(DOXYGEN) Doxyfile
|
||||
@@ -189,19 +193,19 @@ shiftedrels: shift $(RELS)
|
||||
|
||||
game: shiftedrels
|
||||
@mkdir -p game
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) copyCode native
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) copyCode native asset_config.json
|
||||
|
||||
game-fast: shiftedrels
|
||||
@mkdir -p game
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) copyCode oead
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) copyCode oead asset_config.json
|
||||
|
||||
game-nocompile:
|
||||
@mkdir -p game
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) noCopyCode native
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) noCopyCode native asset_config.json
|
||||
|
||||
game-nocompile-fast:
|
||||
@mkdir -p game
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) noCopyCode oead
|
||||
@$(PYTHON) tools/package_game_assets.py ./game $(BUILD_PATH) noCopyCode oead asset_config.json
|
||||
|
||||
rungame-nocompile: game-nocompile
|
||||
@echo If you are playing on a shifted game make sure Hyrule Field Speed hack is disabled in dolphin!
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import curses
|
||||
from sys import argv
|
||||
import json
|
||||
from os import path
|
||||
|
||||
CONFIG_DESCRIPTIONS = {
|
||||
"decompress_assets": "Decompress Yaz0 compressed assets (appends .c to the filename)",
|
||||
"extract_arc": "Extract archive (.arc) files",
|
||||
"convert_stages": "Convert Stage Files (.dzs and .dzr) to json",
|
||||
"convert_textures": "Convert Textures (.bti) to png",
|
||||
"update_copydate": "Update COPYDATE on build",
|
||||
"package_maps": "Package Symbol Map (.map) files on build",
|
||||
}
|
||||
|
||||
CONFIG_DEFAULTS = {
|
||||
"decompress_assets": True,
|
||||
"extract_arc": True,
|
||||
"convert_stages": True,
|
||||
"convert_textures": False,
|
||||
"update_copydate": True,
|
||||
"package_maps": True,
|
||||
}
|
||||
|
||||
CONFIGFILE_DEFAULT = "asset_config.json"
|
||||
|
||||
|
||||
def printMenu(stdscr, selected_idx, options):
|
||||
stdscr.clear()
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
left = min([(width // 2) - (len(option) // 2) for option in options])
|
||||
top = (height // 2) - (len(options) // 2)
|
||||
|
||||
stdscr.addstr(top - 2, left, "Configure Asset Extraction/Packaging (q to save):")
|
||||
|
||||
for idx, option in enumerate(options):
|
||||
x = left
|
||||
y = top + idx
|
||||
|
||||
if idx == selected_idx:
|
||||
stdscr.attron(curses.color_pair(1))
|
||||
stdscr.addstr(y, x, option)
|
||||
stdscr.attroff(curses.color_pair(1))
|
||||
else:
|
||||
stdscr.addstr(y, x, option)
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
def cursesMenu(stdscr, values):
|
||||
curses.curs_set(0)
|
||||
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||
|
||||
keys = list(values.keys())
|
||||
options = [
|
||||
f"{CONFIG_DESCRIPTIONS[key]}: ({"X" if values[key] else " "})" for key in keys
|
||||
]
|
||||
bool_values = [values[key] for key in keys]
|
||||
|
||||
selected_idx = 0
|
||||
|
||||
while True:
|
||||
printMenu(stdscr, selected_idx, options)
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
if key == curses.KEY_UP and selected_idx > 0:
|
||||
selected_idx -= 1
|
||||
elif key == curses.KEY_DOWN and selected_idx < len(options) - 1:
|
||||
selected_idx += 1
|
||||
elif key in (curses.KEY_ENTER, ord("\n"), 13):
|
||||
bool_values[selected_idx] = not bool_values[selected_idx]
|
||||
options[selected_idx] = (
|
||||
f"{CONFIG_DESCRIPTIONS[keys[selected_idx]]}: ({"X" if bool_values[selected_idx] else " "})"
|
||||
)
|
||||
elif key in (27, ord("q")): # Escape, q
|
||||
break
|
||||
|
||||
return {key: value for key, value in zip(keys, bool_values)}
|
||||
|
||||
|
||||
def saveConfig(values, configfile=CONFIGFILE_DEFAULT):
|
||||
with open(configfile, "w") as f:
|
||||
json.dump(values, f)
|
||||
|
||||
|
||||
def updateConfig(configfile=CONFIGFILE_DEFAULT):
|
||||
values = getConfig(configfile)
|
||||
|
||||
values = curses.wrapper(cursesMenu, values)
|
||||
saveConfig(values, configfile)
|
||||
return values
|
||||
|
||||
|
||||
def getConfig(configfile=CONFIGFILE_DEFAULT, update=False):
|
||||
values = CONFIG_DEFAULTS
|
||||
|
||||
if path.exists(configfile):
|
||||
with open(configfile, "r") as f:
|
||||
config = json.load(f)
|
||||
for key, value in config.items():
|
||||
if key in CONFIG_DEFAULTS:
|
||||
values[key] = value
|
||||
elif update == True:
|
||||
values = updateConfig(configfile)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def main():
|
||||
if len(argv) < 2:
|
||||
print(f"{argv[0]} Usage: python3 {argv[0]} asset_config.json")
|
||||
exit(1)
|
||||
|
||||
updateConfig(argv[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,6 +4,8 @@ import libarc
|
||||
from pathlib import Path
|
||||
import libyaz0
|
||||
import libstage
|
||||
import libbti
|
||||
import assets_config
|
||||
|
||||
"""
|
||||
Extracts the game assets and stores them in the game folder
|
||||
@@ -138,17 +140,24 @@ convertDefinitions = {
|
||||
".arc": {
|
||||
"function": libarc.extract_to_directory,
|
||||
"exceptions": ["archive/dat/speakerse.arc"],
|
||||
"config_key": "extract_arc"
|
||||
},
|
||||
".dzs": {
|
||||
"function": libstage.extract_to_json
|
||||
"function": libstage.extract_to_json,
|
||||
"config_key": "convert_stages"
|
||||
},
|
||||
".dzr": {
|
||||
"function": libstage.extract_to_json
|
||||
"function": libstage.extract_to_json,
|
||||
"config_key": "convert_stages"
|
||||
},
|
||||
".bti": {
|
||||
"function": libbti.bti_to_png,
|
||||
"config_key": "convert_textures"
|
||||
}
|
||||
}
|
||||
|
||||
def writeFile(name, data):
|
||||
if data[0:4] == bytes("Yaz0", "ascii"):
|
||||
if data[0:4] == bytes("Yaz0", "ascii") and config["decompress_assets"]:
|
||||
splitName = os.path.splitext(name)
|
||||
name = splitName[0] + ".c" + splitName[1]
|
||||
data = libyaz0.decompress(data)
|
||||
@@ -158,15 +167,19 @@ def writeFile(name, data):
|
||||
ext = splitName[1]
|
||||
if ext in convertDefinitions:
|
||||
extractDef = convertDefinitions[ext]
|
||||
if "exceptions" in extractDef and str(name) in extractDef["exceptions"]:
|
||||
if ("exceptions" in extractDef and str(name) in extractDef["exceptions"]) or ("config_key" in extractDef and config[extractDef["config_key"]] == False):
|
||||
extractDef = None
|
||||
|
||||
if config["decompress_assets"] == None:
|
||||
extractDef = None # If assets aren't being decompressed, just write the raw file
|
||||
|
||||
if extractDef == None:
|
||||
file = open(name, "wb")
|
||||
file.write(data)
|
||||
file.close()
|
||||
else:
|
||||
name = extractDef["function"](name, data, writeFile)
|
||||
# print(name)
|
||||
return name
|
||||
|
||||
|
||||
@@ -232,8 +245,7 @@ def getDolInfo(disc):
|
||||
|
||||
return dolOffset, dolSize
|
||||
|
||||
|
||||
def extract(isoPath: Path, gamePath: Path, yaz0Encoder):
|
||||
def extract(isoPath: Path, gamePath: Path, yaz0Encoder: str, config_file: str):
|
||||
if yaz0Encoder == "oead":
|
||||
try:
|
||||
from oead import yaz0
|
||||
@@ -241,6 +253,8 @@ def extract(isoPath: Path, gamePath: Path, yaz0Encoder):
|
||||
yaz0DecompressFunction = yaz0.decompress
|
||||
except:
|
||||
print("Extract: oead isn't installed, falling back to native yaz0")
|
||||
global config
|
||||
config = assets_config.getConfig(config_file,update=True)
|
||||
isoPath = isoPath.absolute()
|
||||
cwd = os.getcwd()
|
||||
os.chdir(gamePath)
|
||||
@@ -288,7 +302,7 @@ def extract(isoPath: Path, gamePath: Path, yaz0Encoder):
|
||||
|
||||
|
||||
def main():
|
||||
extract(Path(sys.argv[1]), Path(sys.argv[2]), "native")
|
||||
extract(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+11
-1
@@ -283,6 +283,7 @@ def getNodeIdent(fullName):
|
||||
def parseDirForPack(
|
||||
fileDataLines, path, convertFunction, nodes, dirs, currentNode, stringTable, data
|
||||
):
|
||||
# print(currentNode.directory_index)
|
||||
for i in range(
|
||||
currentNode.directory_index,
|
||||
currentNode.directory_count + currentNode.directory_index,
|
||||
@@ -400,12 +401,21 @@ def convert_dir_to_arc(sourceDir, convertFunction):
|
||||
nodes = []
|
||||
dirs = [None] * len(fileDataLines)
|
||||
stringTable = ".\0..\0"
|
||||
|
||||
numRootEntries = 0
|
||||
for line in fileDataLines:
|
||||
entry = line.split(":")[1]
|
||||
# Only count files and directories under the root
|
||||
# print(entry)
|
||||
if entry.startswith(rootName + "/") and entry[:len(entry)-1].count("/") == 1:
|
||||
numRootEntries += 1
|
||||
|
||||
nodes.append(
|
||||
Node(
|
||||
getNodeIdent("ROOT"),
|
||||
len(stringTable),
|
||||
computeHash(rootName),
|
||||
len(os.listdir(sourceDir / rootName)) + 2,
|
||||
numRootEntries,
|
||||
0,
|
||||
rootName,
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .libast import *
|
||||
@@ -0,0 +1 @@
|
||||
from .libbti import *
|
||||
@@ -0,0 +1,929 @@
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
|
||||
imageFormatToInt = {
|
||||
"I4": 0,
|
||||
"I8": 1,
|
||||
"IA4": 2,
|
||||
"IA8": 3,
|
||||
"RGB565": 4,
|
||||
"RGB5A3": 5,
|
||||
"RGBA32": 6,
|
||||
"C4": 8,
|
||||
"C8": 9,
|
||||
"C14X2": 0xA,
|
||||
"CMPR": 0xE,
|
||||
}
|
||||
|
||||
colorFormatToInt = {"IA8": 0, "RGB565": 1, "RGB5A3": 2}
|
||||
|
||||
wrapModeToInt = {"CLAMP": 0, "REPEAT": 1, "MIRROR": 2, "MAX_TEXWRAP_MODE": 3}
|
||||
|
||||
texFilterToInt = {
|
||||
"NEAR": 0,
|
||||
"LINEAR": 1,
|
||||
"NEAR_MIP_NEAR": 2,
|
||||
"LINEAR_MIP_NEAR": 3,
|
||||
"NEAR_MIP_LINEAR": 4,
|
||||
"LINEAR_MIP_LINEAR": 5,
|
||||
}
|
||||
|
||||
|
||||
def invertDict(dict):
|
||||
return {v: i for i, v in dict.items()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BTI_Header:
|
||||
format: int
|
||||
alphaEnabled: int
|
||||
width: int
|
||||
height: int
|
||||
wrapS: int
|
||||
wrapT: int
|
||||
indexTexture: int
|
||||
colorFormat: int
|
||||
numColors: int
|
||||
paletteOffset: int
|
||||
mipmapEnabled: int
|
||||
doEdgeLOD: int
|
||||
biasClamp: int
|
||||
maxAnisotropy: int
|
||||
minFilter: int
|
||||
magFilter: int
|
||||
minLOD: int
|
||||
maxLOD: int
|
||||
mipmapCount: int
|
||||
unk0: int
|
||||
LODBias: int
|
||||
imageOffset: int
|
||||
|
||||
@classmethod
|
||||
def fromBytes(cls, header_bytes):
|
||||
assert len(header_bytes) == 0x20
|
||||
unpacked = struct.unpack(">BBHHBBBBHIBBBBBBbbBBhI", header_bytes)
|
||||
return cls(*unpacked)
|
||||
|
||||
def getBytes(self):
|
||||
return struct.pack(
|
||||
">BBHHBBBBHIBBBBBBbbBBhI",
|
||||
self.format,
|
||||
self.alphaEnabled,
|
||||
self.width,
|
||||
self.height,
|
||||
self.wrapS,
|
||||
self.wrapT,
|
||||
self.indexTexture,
|
||||
self.colorFormat,
|
||||
self.numColors,
|
||||
self.paletteOffset,
|
||||
self.mipmapEnabled,
|
||||
self.doEdgeLOD,
|
||||
self.biasClamp,
|
||||
self.maxAnisotropy,
|
||||
self.minFilter,
|
||||
self.magFilter,
|
||||
self.minLOD,
|
||||
self.maxLOD,
|
||||
self.mipmapCount,
|
||||
self.unk0,
|
||||
self.LODBias,
|
||||
self.imageOffset,
|
||||
)
|
||||
|
||||
|
||||
def decodeIA8(bytes):
|
||||
intensity = bytes[1]
|
||||
return intensity, intensity, intensity, bytes[0]
|
||||
|
||||
|
||||
def encodeIA8(color):
|
||||
return ((color[3] << 8) & 0xFF00) | (((color[0] + color[1] + color[2]) // 3) & 0xFF)
|
||||
|
||||
|
||||
def decodeRGB565(bytes):
|
||||
full = (bytes[0] << 8) | bytes[1]
|
||||
return (full >> 11) * 8, ((full >> 5) & 0b111111) * 4, (full & 0b11111) * 8, 0xFF
|
||||
|
||||
|
||||
def encodeRGB565(color):
|
||||
return (
|
||||
(((color[0] // 8) & 0b11111) << 11)
|
||||
| (((color[1] // 4) & 0b111111) << 5)
|
||||
| ((color[2] // 8) & 0b11111)
|
||||
)
|
||||
|
||||
|
||||
def decodeRGB5A3(bytes):
|
||||
full = (bytes[0] << 8) | bytes[1]
|
||||
isAlpha = not (full & 0x8000) > 0
|
||||
if isAlpha:
|
||||
return (
|
||||
((full >> 8) & 0xF) * 0x11,
|
||||
((full >> 4) & 0xF) * 0x11,
|
||||
(full & 0xF) * 0x11,
|
||||
((full >> 12) & 0b111) * 0x20,
|
||||
)
|
||||
else:
|
||||
return (
|
||||
((full >> 10) & 0b11111) * 8,
|
||||
((full >> 5) & 0b11111) * 8,
|
||||
(full & 0b11111) * 8,
|
||||
0xFF,
|
||||
)
|
||||
|
||||
|
||||
def encodeRGB5A3(color):
|
||||
isAlpha = color[3] < 0xFF
|
||||
if isAlpha:
|
||||
return (
|
||||
(((color[3] // 0x20) & 0b111) << 12)
|
||||
| (((color[0] // 0x11) & 0xF) << 8)
|
||||
| (((color[1] // 0x11) & 0xF) << 4)
|
||||
| ((color[2] // 0x11) & 0xF)
|
||||
)
|
||||
else:
|
||||
return (
|
||||
(1 << 15)
|
||||
| (((color[0] // 8) & 0b11111) << 10)
|
||||
| (((color[1] // 8) & 0b11111) << 5)
|
||||
| ((color[2] // 8) & 0b11111)
|
||||
)
|
||||
|
||||
|
||||
class ImageBase:
|
||||
def __init__(self, header, data=None, imageBuffer=None):
|
||||
self.header = header
|
||||
self.data = data
|
||||
self.paletteColors = []
|
||||
if self.data == None:
|
||||
self.data = bytearray()
|
||||
self.imageBuffer = (
|
||||
imageBuffer
|
||||
if imageBuffer != None
|
||||
else [(0, 0, 0, 0xFF)] * (header.width * header.height)
|
||||
)
|
||||
|
||||
def decodePaletteColors(self):
|
||||
decodeFunc = None
|
||||
match self.header.colorFormat:
|
||||
case 0:
|
||||
decodeFunc = decodeIA8
|
||||
case 1:
|
||||
decodeFunc = decodeRGB565
|
||||
case 2:
|
||||
decodeFunc = decodeRGB5A3
|
||||
|
||||
colors = []
|
||||
|
||||
offset = self.header.paletteOffset
|
||||
for i in range(self.header.numColors):
|
||||
colors.append(decodeFunc(self.data[offset : offset + 2]))
|
||||
offset += 2
|
||||
|
||||
self.paletteColors = colors
|
||||
|
||||
def encodePaletteColors(self):
|
||||
encodeFunc = None
|
||||
match self.header.colorFormat:
|
||||
case 0:
|
||||
encodeFunc = encodeIA8
|
||||
case 1:
|
||||
encodeFunc = encodeRGB565
|
||||
case 2:
|
||||
encodeFunc = encodeRGB5A3
|
||||
|
||||
for color in self.paletteColors:
|
||||
self.data += struct.pack(">H", encodeFunc(color))
|
||||
|
||||
def writePixel(self, x, y, pixelValue):
|
||||
if x >= self.header.width or y >= self.header.height:
|
||||
return
|
||||
self.imageBuffer[(y * self.header.width) + x] = pixelValue
|
||||
|
||||
def readPixel(self, x, y):
|
||||
if x >= self.header.width or y >= self.header.height:
|
||||
return 0xFF, 0xFF, 0xFF, 0xFF
|
||||
return self.imageBuffer[(y * self.header.width) + x]
|
||||
|
||||
def decodeLoop(self, blockWidth, blockHeight, blockStride):
|
||||
widthInBlocks = ceil(self.header.width / blockWidth)
|
||||
heightInBlocks = ceil(self.header.height / blockHeight)
|
||||
|
||||
offset = self.header.imageOffset
|
||||
for blockY in range(heightInBlocks):
|
||||
for blockX in range(widthInBlocks):
|
||||
self.decodeBlock(
|
||||
blockX * blockWidth,
|
||||
blockY * blockHeight,
|
||||
self.data[offset : offset + blockStride],
|
||||
)
|
||||
offset += blockStride
|
||||
|
||||
def decode(self):
|
||||
pass
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
pass
|
||||
|
||||
def encodeLoop(self, blockWidth, blockHeight):
|
||||
widthInBlocks = ceil(self.header.width / blockWidth)
|
||||
heightInBlocks = ceil(self.header.height / blockHeight)
|
||||
|
||||
for blockY in range(heightInBlocks):
|
||||
for blockX in range(widthInBlocks):
|
||||
self.encodeBlock(blockX * blockWidth, blockY * blockHeight)
|
||||
|
||||
def encode(self):
|
||||
pass
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
pass
|
||||
|
||||
|
||||
class I4Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(8, 8, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(8):
|
||||
for inX in range(4):
|
||||
val = ((blockBytes[offset] & 0xF0) >> 4) * 0x11
|
||||
self.writePixel((x + (inX * 2)), y + inY, (val, val, val, 0xFF))
|
||||
val = (blockBytes[offset] & 0xF) * 0x11
|
||||
self.writePixel((x + (inX * 2)) + 1, y + inY, (val, val, val, 0xFF))
|
||||
offset += 1
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(8, 8)
|
||||
self.header.numColors = len(self.paletteColors)
|
||||
self.header.paletteOffset = len(self.data) + 0x20
|
||||
self.encodePaletteColors()
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(8):
|
||||
for inX in range(4):
|
||||
color1 = self.readPixel(x + (inX * 2), y + inY)
|
||||
intensity1 = ((color1[0] + color1[1] + color1[2]) // 3) // 0x11
|
||||
color2 = self.readPixel(x + (inX * 2) + 1, y + inY)
|
||||
intensity2 = ((color2[0] + color2[1] + color2[2]) // 3) // 0x11
|
||||
self.data.append((intensity1 << 4) | intensity2)
|
||||
|
||||
|
||||
class I8Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(8, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
intensity = blockBytes[offset]
|
||||
self.writePixel(
|
||||
x + inX, y + inY, (intensity, intensity, intensity, 0xFF)
|
||||
)
|
||||
offset += 1
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(8, 4)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
color = self.readPixel(x + inX, y + inY)
|
||||
intensity = (color[0] + color[1] + color[2]) // 3
|
||||
self.data.append(intensity)
|
||||
|
||||
|
||||
class IA4Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(8, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
byte = blockBytes[offset]
|
||||
intensity = (byte & 0b1111) * 0x11
|
||||
self.writePixel(
|
||||
x + inX,
|
||||
y + inY,
|
||||
(intensity, intensity, intensity, (byte >> 4) * 0x11),
|
||||
)
|
||||
offset += 1
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(8, 4)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
color = self.readPixel(x + inX, y + inY)
|
||||
intensity = (color[0] + color[1] + color[2]) // 3
|
||||
self.data.append(((color[3] // 0x11) << 4) | (intensity // 0x11))
|
||||
|
||||
|
||||
class IA8Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(4, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
self.writePixel(
|
||||
x + inX, y + inY, decodeIA8(blockBytes[offset : offset + 2])
|
||||
)
|
||||
offset += 2
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(4, 4)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
color = self.readPixel(x + inX, y + inY)
|
||||
self.data += struct.pack(">H", encodeIA8(color))
|
||||
|
||||
|
||||
class RGB565Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(4, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
self.writePixel(
|
||||
x + inX, y + inY, decodeRGB565(blockBytes[offset : offset + 2])
|
||||
)
|
||||
offset += 2
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(4, 4)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
color = self.readPixel(x + inX, y + inY)
|
||||
self.data += struct.pack(">H", encodeRGB565(color))
|
||||
|
||||
|
||||
class RGB5A3Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(4, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
self.writePixel(
|
||||
x + inX, y + inY, decodeRGB5A3(blockBytes[offset : offset + 2])
|
||||
)
|
||||
offset += 2
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(4, 4)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
color = self.readPixel(x + inX, y + inY)
|
||||
self.data += struct.pack(">H", encodeRGB5A3(color))
|
||||
|
||||
|
||||
class RGBA32Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(4, 4, 64)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
aOffset = 0
|
||||
rOffset = 1
|
||||
gOffset = 32
|
||||
bOffset = 33
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
self.writePixel(
|
||||
x + inX,
|
||||
y + inY,
|
||||
(
|
||||
blockBytes[rOffset],
|
||||
blockBytes[gOffset],
|
||||
blockBytes[bOffset],
|
||||
blockBytes[aOffset],
|
||||
),
|
||||
)
|
||||
aOffset += 2
|
||||
rOffset += 2
|
||||
gOffset += 2
|
||||
bOffset += 2
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(4, 4)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
aOffset = 0
|
||||
rOffset = 1
|
||||
gOffset = 32
|
||||
bOffset = 33
|
||||
buffer = bytearray(64)
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
color = self.readPixel(x + inX, y + inY)
|
||||
buffer[rOffset] = color[0]
|
||||
buffer[gOffset] = color[1]
|
||||
buffer[bOffset] = color[2]
|
||||
buffer[aOffset] = color[3]
|
||||
|
||||
aOffset += 2
|
||||
rOffset += 2
|
||||
gOffset += 2
|
||||
bOffset += 2
|
||||
|
||||
|
||||
class C4Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodePaletteColors()
|
||||
self.decodeLoop(8, 8, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(8):
|
||||
for inX in range(4):
|
||||
index = (blockBytes[offset] & 0xF0) >> 4
|
||||
if index >= self.header.numColors:
|
||||
offset += 1
|
||||
continue # Out of bounds, likely due to the block going out of the image
|
||||
self.writePixel((x + (inX * 2)), y + inY, self.paletteColors[index])
|
||||
index = blockBytes[offset] & 0xF
|
||||
if index >= self.header.numColors:
|
||||
offset += 1
|
||||
continue # Out of bounds, likely due to the block going out of the image
|
||||
self.writePixel((x + (inX * 2)) + 1, y + inY, self.paletteColors[index])
|
||||
offset += 1
|
||||
|
||||
def encode(self):
|
||||
# Mipmaps are currently not allowed for paletted textures
|
||||
assert (
|
||||
self.header.mipmapEnabled == 0 and self.header.mipmapCount == 1
|
||||
), "Mipmaps are not supported on Paletted textures!"
|
||||
self.encodeLoop(8, 8)
|
||||
self.header.numColors = len(self.paletteColors)
|
||||
self.header.paletteOffset = len(self.data) + 0x20
|
||||
self.encodePaletteColors()
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(8):
|
||||
for inX in range(4):
|
||||
pixelValue = self.readPixel(x + (inX * 2), y + inY)
|
||||
if not pixelValue in self.paletteColors:
|
||||
self.paletteColors.append(pixelValue)
|
||||
index = self.paletteColors.index(pixelValue)
|
||||
pixelValue = self.readPixel(x + (inX * 2) + 1, y + inY)
|
||||
if not pixelValue in self.paletteColors:
|
||||
self.paletteColors.append(pixelValue)
|
||||
index2 = self.paletteColors.index(pixelValue)
|
||||
self.data.append((index << 4) | index2)
|
||||
|
||||
|
||||
class C8Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodePaletteColors()
|
||||
self.decodeLoop(8, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
# print(self.header.numColors)
|
||||
if blockBytes[offset] >= self.header.numColors:
|
||||
offset += 1
|
||||
continue # Out of bounds, likely due to the block going out of the image
|
||||
self.writePixel(
|
||||
x + inX, y + inY, self.paletteColors[blockBytes[offset]]
|
||||
)
|
||||
offset += 1
|
||||
|
||||
def encode(self):
|
||||
# Mipmaps are currently not allowed for paletted textures
|
||||
assert (
|
||||
self.header.mipmapEnabled == 0 and self.header.mipmapCount == 1
|
||||
), "Mipmaps are not supported on Paletted textures!"
|
||||
self.encodeLoop(8, 4)
|
||||
self.header.numColors = len(self.paletteColors)
|
||||
self.header.paletteOffset = len(self.data) + 0x20
|
||||
self.encodePaletteColors()
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
pixelValue = self.readPixel(x + inX, y + inY)
|
||||
if not pixelValue in self.paletteColors:
|
||||
self.paletteColors.append(pixelValue)
|
||||
index = self.paletteColors.index(pixelValue)
|
||||
self.data.append(index)
|
||||
|
||||
|
||||
class C14X2Image(ImageBase):
|
||||
def decode(self):
|
||||
self.decodePaletteColors()
|
||||
self.decodeLoop(4, 4, 32)
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for inY in range(4):
|
||||
for inX in range(8):
|
||||
index = struct.unpack(">H", blockBytes[offset : offset + 2])[0] & 0x3FFF
|
||||
if index < self.header.numColors:
|
||||
offset += 2
|
||||
continue # Out of bounds, likely due to the block going out of the image
|
||||
self.writePixel(x + inX, y + inY, self.paletteColors[index])
|
||||
offset += 2
|
||||
|
||||
def encode(self):
|
||||
# Mipmaps are currently not allowed for paletted textures
|
||||
assert (
|
||||
self.header.mipmapEnabled == 0 and self.header.mipmapCount == 1
|
||||
), "Mipmaps are not supported on Paletted textures!"
|
||||
self.encodeLoop(4, 4)
|
||||
self.header.numColors = len(self.paletteColors)
|
||||
self.header.paletteOffset = len(self.data) + 0x20
|
||||
self.encodePaletteColors()
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
pixelValue = self.readPixel(x + inX, y + inY)
|
||||
if not pixelValue in self.paletteColors:
|
||||
self.paletteColors.append(pixelValue)
|
||||
index = self.paletteColors.index(pixelValue)
|
||||
self.data += struct.pack(">H", index)
|
||||
|
||||
|
||||
class CMPRImage(ImageBase):
|
||||
def decode(self):
|
||||
self.decodeLoop(8, 8, 32)
|
||||
|
||||
def interpolateColor(self, color1, color2, factor):
|
||||
return tuple(int(c1 + ((c2 - c1) * factor)) for c1, c2 in zip(color1, color2))
|
||||
|
||||
def decodeBlock(self, x, y, blockBytes):
|
||||
offset = 0
|
||||
for subY in range(2):
|
||||
for subX in range(2):
|
||||
colors = [
|
||||
decodeRGB565(blockBytes[offset + 0 : offset + 2]),
|
||||
decodeRGB565(blockBytes[offset + 2 : offset + 4]),
|
||||
]
|
||||
realVal1, realVal2 = struct.unpack(
|
||||
">HH", blockBytes[offset + 0 : offset + 4]
|
||||
)
|
||||
if realVal1 > realVal2:
|
||||
colors.append(self.interpolateColor(colors[0], colors[1], (1 / 3)))
|
||||
colors.append(self.interpolateColor(colors[0], colors[1], (2 / 3)))
|
||||
else:
|
||||
colors.append(self.interpolateColor(colors[0], colors[1], (1 / 2)))
|
||||
colors.append((0, 0, 0, 0)) # transparent
|
||||
|
||||
# print(colors)
|
||||
|
||||
val = struct.unpack(">I", blockBytes[offset + 4 : offset + 8])[0]
|
||||
|
||||
for inY in range(4):
|
||||
for inX in range(4):
|
||||
self.writePixel(
|
||||
x + (subX * 4) + inX,
|
||||
y + (subY * 4) + inY,
|
||||
colors[val >> 30],
|
||||
)
|
||||
val = (val << 2) & 0xFFFFFFFF
|
||||
|
||||
offset += 8
|
||||
|
||||
def encode(self):
|
||||
self.encodeLoop(8, 8)
|
||||
|
||||
def color_distance(self, color1, color2):
|
||||
return (
|
||||
((color1[0] - color2[0]) ** 2)
|
||||
+ ((color1[1] - color2[1]) ** 2)
|
||||
+ ((color1[2] - color2[2]) ** 2)
|
||||
)
|
||||
|
||||
def encodeBlock(self, x, y):
|
||||
for subY in range(2):
|
||||
for subX in range(2):
|
||||
block = [
|
||||
self.readPixel(x + (subX * 4) + inX, y + (subY * 4) + inY)
|
||||
for inY in range(4)
|
||||
for inX in range(4)
|
||||
]
|
||||
# print(f"{x} {y}")
|
||||
# print(block)
|
||||
|
||||
isTransparent = False
|
||||
for color in block:
|
||||
if color[3] < 200:
|
||||
isTransparent = True
|
||||
break
|
||||
|
||||
colors = [min(block), max(block)]
|
||||
|
||||
realColor1 = encodeRGB565(colors[0])
|
||||
realColor2 = encodeRGB565(colors[1])
|
||||
|
||||
if realColor1 < realColor2:
|
||||
colors[0], colors[1] = colors[1], colors[0]
|
||||
realColor1, realColor2 = realColor2, realColor1
|
||||
|
||||
if isTransparent and realColor1 > realColor2:
|
||||
colors[0], colors[1] = colors[1], colors[0]
|
||||
realColor1, realColor2 = realColor2, realColor1
|
||||
|
||||
# print(colors[0])
|
||||
|
||||
if not isTransparent:
|
||||
colors.append(self.interpolateColor(colors[0], colors[1], (1 / 3)))
|
||||
colors.append(self.interpolateColor(colors[0], colors[1], (2 / 3)))
|
||||
else:
|
||||
colors.append(self.interpolateColor(colors[0], colors[1], (1 / 2)))
|
||||
# colors.append((0,0,0,0))
|
||||
|
||||
indices = []
|
||||
for color in block:
|
||||
if color[3] < 200:
|
||||
indices.append(3)
|
||||
continue
|
||||
|
||||
distances = [
|
||||
self.color_distance(color, testColor) for testColor in colors
|
||||
]
|
||||
indices.append(distances.index(min(distances)))
|
||||
|
||||
indicesInt = 0
|
||||
for i in range(16):
|
||||
indicesInt = (indicesInt << 2) | (
|
||||
indices[i] & 0b11
|
||||
) # (indicesInt >> 2) | ((indices[i]&0b11)<<30)
|
||||
|
||||
# print(realColor1)
|
||||
# print(realColor2)
|
||||
self.data += struct.pack(">HHI", realColor1, realColor2, indicesInt)
|
||||
|
||||
|
||||
def bti_to_png_cli(inFile, outFile, data, writeFunc):
|
||||
bti_to_png(os.path.splitext(outFile)[0] + ".bti", data, writeFunc)
|
||||
|
||||
|
||||
def bti_to_png(name, data, writefunc):
|
||||
outName = os.path.splitext(name)[0] + ".png"
|
||||
print(f"Converting {name} to {outName}")
|
||||
|
||||
header = BTI_Header.fromBytes(data[0:0x20])
|
||||
# print(header.getBytes())
|
||||
|
||||
image = None
|
||||
|
||||
match header.format:
|
||||
case 0:
|
||||
image = I4Image(header, data)
|
||||
case 1:
|
||||
image = I8Image(header, data)
|
||||
case 2:
|
||||
image = IA4Image(header, data)
|
||||
case 3:
|
||||
image = IA8Image(header, data)
|
||||
case 4:
|
||||
image = RGB565Image(header, data)
|
||||
case 5:
|
||||
image = RGB5A3Image(header, data)
|
||||
case 6:
|
||||
image = RGBA32Image(header, data)
|
||||
case 8:
|
||||
image = C4Image(header, data)
|
||||
case 9:
|
||||
image = C8Image(header, data)
|
||||
case 10:
|
||||
image = C14X2Image(header, data)
|
||||
case 0xE:
|
||||
image = CMPRImage(header, data)
|
||||
case _:
|
||||
print("Invalid format!")
|
||||
|
||||
image.decode()
|
||||
|
||||
pilImage = Image.new("RGBA", (header.width, header.height))
|
||||
pilImage.putdata(image.imageBuffer)
|
||||
|
||||
with BytesIO() as output:
|
||||
pilImage.save(output, format="PNG")
|
||||
writefunc(outName, output.getvalue())
|
||||
|
||||
header_dict = asdict(header)
|
||||
# print(header_dict)
|
||||
|
||||
header_dict["format"] = invertDict(imageFormatToInt)[header.format]
|
||||
|
||||
if header.format >= 8 and header.format <= 10:
|
||||
header_dict["colorFormat"] = invertDict(colorFormatToInt)[header.colorFormat]
|
||||
else:
|
||||
del header_dict["colorFormat"]
|
||||
|
||||
header_dict["wrapS"] = invertDict(wrapModeToInt)[header.wrapS]
|
||||
header_dict["wrapT"] = invertDict(wrapModeToInt)[header.wrapT]
|
||||
|
||||
header_dict["minFilter"] = invertDict(texFilterToInt)[header.minFilter]
|
||||
header_dict["magFilter"] = invertDict(texFilterToInt)[header.magFilter]
|
||||
|
||||
del header_dict["width"]
|
||||
del header_dict["height"]
|
||||
del header_dict["numColors"]
|
||||
del header_dict["paletteOffset"]
|
||||
del header_dict["unk0"]
|
||||
del header_dict["imageOffset"]
|
||||
|
||||
# print(header_dict)
|
||||
|
||||
splitext = os.path.splitext(name)
|
||||
writefunc(
|
||||
splitext[0] + ".bti.json", bytes(json.dumps(header_dict, indent=4), "ascii")
|
||||
)
|
||||
|
||||
return outName
|
||||
|
||||
|
||||
def namedValueFromDict(value, dict):
|
||||
if not value in dict:
|
||||
print(
|
||||
f"Format {value} is not a valid type. Valid types are {", ".join(dict.keys())}"
|
||||
)
|
||||
return None
|
||||
return dict[value]
|
||||
|
||||
|
||||
def png_to_bti(name, convertFunc):
|
||||
# print(name)
|
||||
splitext = os.path.splitext(name)
|
||||
header_dict = json.load(open(splitext[0] + ".bti.json", "r"))
|
||||
|
||||
format = namedValueFromDict(header_dict["format"], imageFormatToInt)
|
||||
|
||||
colorFormat = 0
|
||||
|
||||
if format >= 8 and format <= 10:
|
||||
colorFormat = namedValueFromDict(header_dict["colorFormat"], colorFormatToInt)
|
||||
|
||||
wrapS = namedValueFromDict(header_dict["wrapS"], wrapModeToInt)
|
||||
wrapT = namedValueFromDict(header_dict["wrapT"], wrapModeToInt)
|
||||
|
||||
minFilter = namedValueFromDict(header_dict["minFilter"], texFilterToInt)
|
||||
magFilter = namedValueFromDict(header_dict["magFilter"], texFilterToInt)
|
||||
|
||||
pilImage = Image.open(name)
|
||||
|
||||
# Reduce the amount of colors to go within the palette bounds, if needed
|
||||
|
||||
if format >= 8 and format <= 10:
|
||||
unique_pixels = len(set(list(pilImage.getdata())))
|
||||
|
||||
match format:
|
||||
case 8: # C4
|
||||
if unique_pixels > 16:
|
||||
pilImage = pilImage.quantize(15)
|
||||
case 9: # C8
|
||||
if unique_pixels > 256:
|
||||
pilImage = pilImage.quantize(255)
|
||||
case 10: # C14X2
|
||||
if unique_pixels > 16384:
|
||||
pilImage = pilImage.quantize(16383)
|
||||
|
||||
pilImage = pilImage.convert("RGBA")
|
||||
|
||||
width = pilImage.width
|
||||
height = pilImage.height
|
||||
imageBuffer = list(pilImage.getdata())
|
||||
pilImage.close()
|
||||
|
||||
# Fill header with default values
|
||||
header = BTI_Header(
|
||||
format,
|
||||
header_dict["alphaEnabled"],
|
||||
width,
|
||||
height,
|
||||
wrapS,
|
||||
wrapT,
|
||||
header_dict["indexTexture"],
|
||||
colorFormat,
|
||||
0,
|
||||
0,
|
||||
header_dict["mipmapEnabled"],
|
||||
header_dict["doEdgeLOD"],
|
||||
header_dict["biasClamp"],
|
||||
header_dict["maxAnisotropy"],
|
||||
minFilter,
|
||||
magFilter,
|
||||
header_dict["minLOD"],
|
||||
header_dict["maxLOD"],
|
||||
header_dict["mipmapCount"],
|
||||
0,
|
||||
header_dict["LODBias"],
|
||||
32,
|
||||
)
|
||||
match header.format:
|
||||
case 0:
|
||||
image = I4Image(header, None, imageBuffer)
|
||||
case 1:
|
||||
image = I8Image(header, None, imageBuffer)
|
||||
case 2:
|
||||
image = IA4Image(header, None, imageBuffer)
|
||||
case 3:
|
||||
image = IA8Image(header, None, imageBuffer)
|
||||
case 4:
|
||||
image = RGB565Image(header, None, imageBuffer)
|
||||
case 5:
|
||||
image = RGB5A3Image(header, None, imageBuffer)
|
||||
case 6:
|
||||
image = RGBA32Image(header, None, imageBuffer)
|
||||
case 8:
|
||||
image = C4Image(header, None, imageBuffer)
|
||||
case 9:
|
||||
image = C8Image(header, None, imageBuffer)
|
||||
case 10:
|
||||
image = C14X2Image(header, None, imageBuffer)
|
||||
case 0xE:
|
||||
image = CMPRImage(header, None, imageBuffer)
|
||||
|
||||
if header.mipmapEnabled == 0 or header.mipmapCount == 1:
|
||||
image.encode()
|
||||
else:
|
||||
# Handle mipmaps
|
||||
for i in range(header.mipmapCount):
|
||||
image.encode()
|
||||
header.width = header.width // 2
|
||||
header.height = header.height // 2
|
||||
newPixels = [(0, 0, 0, 0xFF)] * (header.width * header.height)
|
||||
for y in range(header.height):
|
||||
for x in range(header.width):
|
||||
p1 = image.imageBuffer[(y * header.width * 4) + (x * 2)]
|
||||
p2 = image.imageBuffer[(y * header.width * 4) + (x * 2) + 1]
|
||||
p3 = image.imageBuffer[
|
||||
(header.width * 2) + (y * header.width * 4) + (x * 2)
|
||||
]
|
||||
p4 = image.imageBuffer[
|
||||
(header.width * 2) + (y * header.width * 4) + (x * 2) + 1
|
||||
]
|
||||
newPixels[(y * header.width) + x] = (
|
||||
(p1[0] + p2[0] + p3[0] + p4[0]) // 4,
|
||||
(p1[1] + p2[1] + p3[1] + p4[1]) // 4,
|
||||
(p1[2] + p2[2] + p3[2] + p4[2]) // 4,
|
||||
(p1[3] + p2[3] + p3[3] + p4[3]) // 4,
|
||||
)
|
||||
image.imageBuffer = newPixels
|
||||
# reset image info
|
||||
header.width = width
|
||||
header.height = height
|
||||
|
||||
# The encoding function should've modified the header for any changes
|
||||
|
||||
return header.getBytes() + image.data
|
||||
|
||||
|
||||
def testWriteFunc(name, data):
|
||||
with open(name, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(
|
||||
f"Usage: {sys.argv[0]} input.bti output.png OR {sys.argv[0]} input.png output.bti"
|
||||
)
|
||||
exit(1)
|
||||
|
||||
inputString = sys.argv[1]
|
||||
outputString = sys.argv[2]
|
||||
|
||||
if inputString.rfind(".png") != -1 and outputString.rfind(".bti") != -1:
|
||||
bti_bytes = png_to_bti(inputString, None)
|
||||
testWriteFunc(outputString, bti_bytes)
|
||||
elif inputString.rfind(".bti") != -1 and outputString.rfind(".png") != -1:
|
||||
bti_file = open(inputString, "rb")
|
||||
bti_bytes = bti_file.read()
|
||||
bti_file.close()
|
||||
|
||||
name = bti_to_png_cli(inputString, outputString, bti_bytes, testWriteFunc)
|
||||
else:
|
||||
print("Error: One of the provided arguments is not valid!")
|
||||
exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
import struct
|
||||
|
||||
class JAUAudioArcInterpreter:
|
||||
def __init__(self,file):
|
||||
self.offset = 0
|
||||
self.file = file
|
||||
self.nameTable = {"Sections": []}
|
||||
|
||||
def readU32_string(self):
|
||||
val = struct.unpack(">4s",self.file[self.offset:self.offset+4])[0]
|
||||
self.offset += 4
|
||||
return val
|
||||
|
||||
def readU32_(self):
|
||||
val = struct.unpack(">I",self.file[self.offset:self.offset+4])[0]
|
||||
self.offset += 4
|
||||
return val
|
||||
|
||||
def readU8_(self):
|
||||
val = struct.unpack(">B",self.file[self.offset:self.offset+1])[0]
|
||||
self.offset += 1
|
||||
return val
|
||||
|
||||
def getString(self,bytes,offset):
|
||||
string = bytes[offset:]
|
||||
string = string[:string.index(0)]
|
||||
return string
|
||||
|
||||
# More JAudio Decomp is required for this
|
||||
def readWS(self,bank_no,offset,waveArc):
|
||||
# print(bank_no)
|
||||
# print(offset)
|
||||
# print(waveArc)
|
||||
|
||||
size = struct.unpack(">I",self.file[offset+4:offset+8])[0]
|
||||
# print(size)
|
||||
wsys = self.file[offset:offset+size]
|
||||
someNumber,archiveBankOffset,ctrlGroupOffset = struct.unpack(">III",wsys[12:24])
|
||||
# print(ctrlGroupOffset)
|
||||
|
||||
def readBNK(self,param_1,param_2):
|
||||
pass
|
||||
|
||||
def beginBNKList(self,param_1,param_2):
|
||||
pass
|
||||
|
||||
def endBNKList(self):
|
||||
pass
|
||||
|
||||
def readBSC(self,param_1,param_2):
|
||||
pass
|
||||
|
||||
def readBST(self,offset,size):
|
||||
# print(offset)
|
||||
bst = self.file[offset:offset+size]
|
||||
numSections = struct.unpack(">I",bst[32:36])[0]
|
||||
|
||||
sectionOffsets = []
|
||||
for i in range(numSections):
|
||||
sectionOffsets.append(struct.unpack(">I",bst[36+(i*4):40+(i*4)])[0])
|
||||
for sectionOffset in sectionOffsets:
|
||||
numGroups = struct.unpack(">I",bst[sectionOffset:sectionOffset+4])[0]
|
||||
groupOffsets = []
|
||||
for i in range(numGroups):
|
||||
groupOffsets.append(struct.unpack(">I",bst[sectionOffset+4+(i*4):sectionOffset+8+(i*4)])[0])
|
||||
# print(groupOffsets)
|
||||
for groupOffset in groupOffsets:
|
||||
numEntries,unk = struct.unpack(">II",bst[groupOffset:groupOffset+8])
|
||||
entries = []
|
||||
for i in range(numEntries):
|
||||
val = struct.unpack(">I",bst[groupOffset+8+(i*4):groupOffset+12+(i*4)])[0]
|
||||
entries.append({"Type":val>>24,"Offset":val&0xFFFFFF})
|
||||
for entry in entries:
|
||||
|
||||
# print(f"{entry['Type']} {entry['Offset']}")
|
||||
match entry["Type"] & 0xf0:
|
||||
case 0x50: #SFX
|
||||
priority,unk,pad,swBit,volume = struct.unpack(">BBHIf",bst[entry["Offset"]:entry["Offset"]+12])
|
||||
# print(f"{priority:01x} {unk:01x} {pad:02x} {swBit:04x} {volume}")
|
||||
case 0x60: #Arc BGM
|
||||
priority,unk,arcIndex = struct.unpack(">BBH",bst[entry["Offset"]:entry["Offset"]+4])
|
||||
# print(f"{priority:01x} {unk:01x} Arc Index: {arcIndex}")
|
||||
case 0x70: #Streams
|
||||
priority,unk,resourceID,stringOffset = struct.unpack(">BBHI",bst[entry["Offset"]:entry["Offset"]+8])
|
||||
# print(f"{priority:01x} {unk:01x} {resourceID:02x} {stringOffset:04x} ")
|
||||
streamPath = self.getString(bst,stringOffset)
|
||||
# print(f"{streamPath}")
|
||||
|
||||
|
||||
def readBSTN(self,offset,size):
|
||||
# print(param_1)
|
||||
bstn = self.file[offset:offset+size]
|
||||
numSections = struct.unpack(">I",bstn[32:36])[0]
|
||||
# print(numSections)
|
||||
sectionOffsets = []
|
||||
for i in range(numSections):
|
||||
sectionOffsets.append(struct.unpack(">I",bstn[36+(i*4):40+(i*4)])[0])
|
||||
# print(sectionOffsets)
|
||||
sections = []
|
||||
for offset in sectionOffsets:
|
||||
numGroups,sectionNameOffset = struct.unpack(">II",bstn[offset:offset+8])
|
||||
sectionName = self.getString(bstn,sectionNameOffset)
|
||||
# print(f"Section {sectionName} has {numGroups} groups")
|
||||
section = {"Section Name":sectionName.decode('ascii'),
|
||||
"Groups":[]}
|
||||
groupOffsets = []
|
||||
for i in range(numGroups):
|
||||
groupOffsets.append(struct.unpack(">I",bstn[offset+8+(i*4):offset+12+(i*4)])[0])
|
||||
groups = []
|
||||
for groupOffset in groupOffsets:
|
||||
groupSize,groupNameOffset = struct.unpack(">II",bstn[groupOffset:groupOffset+8])
|
||||
groupName = self.getString(bstn,groupNameOffset)
|
||||
group = {"Group Name":groupName.decode('ascii'),
|
||||
"Names": []}
|
||||
# print(f"Group {groupName} has {groupSize} entries")
|
||||
nameOffsets = []
|
||||
for i in range(groupSize):
|
||||
nameOffsets.append(struct.unpack(">I",bstn[groupOffset+8+(i*4):groupOffset+12+(i*4)])[0])
|
||||
names = []
|
||||
for nameOffset in nameOffsets:
|
||||
names.append(self.getString(bstn,nameOffset).decode("ascii"))
|
||||
# print(names)
|
||||
group["Names"] = names
|
||||
groups.append(group)
|
||||
section["Groups"] = groups
|
||||
sections.append(section)
|
||||
self.nameTable["Sections"] = sections
|
||||
|
||||
|
||||
def readBMS(self,param_1,param_2,param_3):
|
||||
pass
|
||||
|
||||
def readBMS_fromArchive(self):
|
||||
pass
|
||||
|
||||
def newVoiceBank(self,param_1,param_2):
|
||||
pass
|
||||
|
||||
def newDynamicSeqBlock(self,param_1):
|
||||
pass
|
||||
|
||||
def readBSFT(self,param_1):
|
||||
pass
|
||||
|
||||
def readMaxSeCategory(self,param_1,param_2,param_3):
|
||||
pass
|
||||
|
||||
def parse(self):
|
||||
if self.readU32_string() != b'AA_<':
|
||||
return False
|
||||
|
||||
while self.readCommand_() == True:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def readCommand_(self):
|
||||
command = self.readU32_string()
|
||||
print(command)
|
||||
match command:
|
||||
case b'>_AA':
|
||||
return False
|
||||
case b'ws ':
|
||||
self.readWS(self.readU32_(),self.readU32_(),self.readU32_())
|
||||
case b'bnk ':
|
||||
self.readBNK(self.readU32_(),self.readU32_())
|
||||
case b'bl_<':
|
||||
self.beginBNKList(self.readU32_(),self.readU32_())
|
||||
case b'>_bl':
|
||||
self.endBNKList()
|
||||
case b'bsc ':
|
||||
start = self.readU32_()
|
||||
end = self.readU32_()
|
||||
self.readBSC(start,end-start)
|
||||
case b'bst ':
|
||||
start = self.readU32_()
|
||||
end = self.readU32_()
|
||||
self.readBST(start,end-start)
|
||||
case b'bstn':
|
||||
start = self.readU32_()
|
||||
end = self.readU32_()
|
||||
self.readBSTN(start,end-start)
|
||||
case b'bms ':
|
||||
param_1 = self.readU32_()
|
||||
start = self.readU32_()
|
||||
end = self.readU32_()
|
||||
self.readBMS(param_1,start,end-start)
|
||||
case b'bmsa':
|
||||
self.readBMS_fromArchive(self.readU32_())
|
||||
case b'vbnk':
|
||||
self.newVoiceBank(self.readU32_(),self.readU32_())
|
||||
case b'dsqb':
|
||||
self.newDynamicSeqBlock(self.readU32_())
|
||||
case b'bsft':
|
||||
self.readBSFT(self.readU32_())
|
||||
case b'sect':
|
||||
self.readU8_()
|
||||
self.readMaxSeCategory(self.readU8_(),self.readU8_(),self.readU8_())
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
baa = JAUAudioArcInterpreter(open("Z2Sound.baa","rb").read())
|
||||
baa.parse()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
from baa import JAUAudioArcInterpreter
|
||||
from sys import argv
|
||||
import re
|
||||
|
||||
def main():
|
||||
if len(argv) != 3:
|
||||
print(f"{argv[0]} Usage: python3 {argv[0]} input.baa output.h")
|
||||
baa = JAUAudioArcInterpreter(open(argv[1],"rb").read())
|
||||
if not baa.parse():
|
||||
return print("BAA Failed to parse!")
|
||||
|
||||
outfile = open(argv[2],"w")
|
||||
|
||||
header_guard_name = argv[2].upper()
|
||||
header_guard_name = re.sub(r'[^A-Z0-9]', '_', header_guard_name)
|
||||
|
||||
outfile.write(f"#ifndef {header_guard_name}\n#define {header_guard_name}\n\n")
|
||||
|
||||
for sectionNum,section in enumerate(baa.nameTable["Sections"]):
|
||||
for groupNum,group in enumerate(section["Groups"]):
|
||||
groupname = group["Group Name"]
|
||||
outfile.write(f"enum {groupname} {{\n")
|
||||
for nameId,name in enumerate(group["Names"]):
|
||||
if name == '':
|
||||
continue
|
||||
comma = ',' if nameId != len(group["Names"])-1 else ''
|
||||
outfile.write(f" {name} = 0x{sectionNum:02x}{groupNum:02x}{nameId:04x}{comma}\n")
|
||||
outfile.write("};\n\n")
|
||||
|
||||
outfile.write(f"#endif /* {header_guard_name} */\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,8 @@ import libyaz0
|
||||
import libarc
|
||||
import libstage
|
||||
from datetime import datetime
|
||||
import libbti
|
||||
import assets_config
|
||||
|
||||
|
||||
def getMaxDateFromDir(path):
|
||||
@@ -35,6 +37,16 @@ convertDefinitions = [
|
||||
"sourceExtension": "dzr.json",
|
||||
"destExtension": ".dzr",
|
||||
"convertFunction": libstage.package_from_json,
|
||||
},
|
||||
{
|
||||
"sourceExtension": "png",
|
||||
"destExtension": ".bti",
|
||||
"convertFunction": libbti.png_to_bti
|
||||
},
|
||||
{
|
||||
"sourceExtension": "bti.json",
|
||||
"destExtension": None,
|
||||
"convertFunction": None
|
||||
}
|
||||
]
|
||||
|
||||
@@ -54,6 +66,8 @@ def convertEntry(file, path, destPath, returnData):
|
||||
extractDef = None
|
||||
for extractData in convertDefinitions:
|
||||
if sourceExtension == extractData["sourceExtension"]:
|
||||
if extractData["destExtension"] == None and extractData["convertFunction"] == None:
|
||||
return
|
||||
extractDef = extractData
|
||||
if "exceptions" in extractData:
|
||||
for exception in extractData["exceptions"]:
|
||||
@@ -334,7 +348,7 @@ def copyMapFiles(buildPath):
|
||||
for map in (buildPath/"dolzel2/rel/").rglob("*.map"):
|
||||
open(buildPath/"dolzel2/game/files/map/Final/Release/"/map.name,"w").write(postprocessMapFile(open(map,"r").read()))
|
||||
|
||||
def main(gamePath, buildPath, copyCode, yaz0Encoding):
|
||||
def main(gamePath, buildPath, copyCode, yaz0Encoding, config_file):
|
||||
if yaz0Encoding == "oead":
|
||||
try:
|
||||
from oead import yaz0
|
||||
@@ -354,7 +368,10 @@ def main(gamePath, buildPath, copyCode, yaz0Encoding):
|
||||
|
||||
if not (gamePath / "files").exists() or not (gamePath / "sys").exists():
|
||||
print("ISO is not extracted; extracting...")
|
||||
extract_game_assets.extract(iso.absolute(),gamePath.absolute(),yaz0Encoding)
|
||||
extract_game_assets.extract(iso.absolute(),gamePath.absolute(),yaz0Encoding,config_file)
|
||||
|
||||
global config
|
||||
config = assets_config.getConfig(config_file, update = True)
|
||||
|
||||
print("Copying game files...")
|
||||
if os.path.exists(buildPath / "dolzel2") == False:
|
||||
@@ -376,13 +393,15 @@ def main(gamePath, buildPath, copyCode, yaz0Encoding):
|
||||
|
||||
copyRelFiles(gamePath, buildPath, aMemRels.splitlines(), mMemRels.splitlines())
|
||||
|
||||
shutil.copy(buildPath/"dolzel2/frameworkF.str",buildPath/"dolzel2/game/files/str/Final/Release/frameworkF.str")
|
||||
copyMapFiles(buildPath)
|
||||
if config["package_maps"]:
|
||||
shutil.copy(buildPath/"dolzel2/frameworkF.str",buildPath/"dolzel2/game/files/str/Final/Release/frameworkF.str")
|
||||
copyMapFiles(buildPath)
|
||||
|
||||
now = datetime.now()
|
||||
copydate = str(now.year)+"/"+str(now.month).zfill(2)+"/"+str(now.day).zfill(2)+" "+str(now.hour).zfill(2)+":"+str(now.minute).zfill(2)+"\n"
|
||||
open(buildPath/"dolzel2/game/files/str/Final/Release/COPYDATE","w").write(copydate)
|
||||
if config["update_copydate"]:
|
||||
now = datetime.now()
|
||||
copydate = str(now.year)+"/"+str(now.month).zfill(2)+"/"+str(now.day).zfill(2)+" "+str(now.hour).zfill(2)+":"+str(now.minute).zfill(2)+"\n"
|
||||
open(buildPath/"dolzel2/game/files/str/Final/Release/COPYDATE","w").write(copydate)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4])
|
||||
main(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4], sys.argv[5])
|
||||
|
||||
@@ -12,3 +12,4 @@ requests
|
||||
GitPython
|
||||
clang
|
||||
pyyaml
|
||||
pillow
|
||||
|
||||
+35
-2
@@ -153,7 +153,8 @@ elif platform.system() == "Darwin":
|
||||
)
|
||||
@click.option("--force-download/--no-force-download")
|
||||
@click.option("--skip-iso/--no-skip-iso", default=False)
|
||||
def setup(debug: bool, game_path: Path, tools_path: Path, yaz0_encoder: str, force_download: bool, skip_iso: bool):
|
||||
@click.option("--use-default-config/--no-use-default-config", default=False)
|
||||
def setup(debug: bool, game_path: Path, tools_path: Path, yaz0_encoder: str, force_download: bool, skip_iso: bool, use_default_config: bool):
|
||||
"""Setup project"""
|
||||
|
||||
if debug:
|
||||
@@ -362,10 +363,29 @@ def setup(debug: bool, game_path: Path, tools_path: Path, yaz0_encoder: str, for
|
||||
)
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
configfile_name = ""
|
||||
try:
|
||||
import assets_config
|
||||
configfile_name = assets_config.CONFIGFILE_DEFAULT
|
||||
if not use_default_config:
|
||||
text = Text("--- Prompting for Asset Configuration")
|
||||
text.stylize("bold magenta")
|
||||
CONSOLE.print(text)
|
||||
assets_config.updateConfig(configfile_name)
|
||||
else:
|
||||
assets_config.saveConfig(assets_config.CONFIG_DEFAULTS, configfile_name)
|
||||
except ImportError as ex:
|
||||
_handle_import_error(ex)
|
||||
except Exception as e:
|
||||
LOG.error(f"failure:")
|
||||
LOG.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import extract_game_assets
|
||||
extract_game_assets.extract(iso, game_path, yaz0_encoder)
|
||||
extract_game_assets.extract(iso, game_path, yaz0_encoder, configfile_name)
|
||||
except ImportError as ex:
|
||||
_handle_import_error(ex)
|
||||
except Exception as e:
|
||||
@@ -1712,6 +1732,19 @@ def upload_progress(debug: bool, base_url: str, api_key: str, project: str, vers
|
||||
LOG.error(f"HTTP request failed: {err}")
|
||||
exit(1)
|
||||
|
||||
@tp.command(name="assets-config")
|
||||
@click.argument('path', type=click.Path(), default="asset_config.json")
|
||||
def assets_config(path: str):
|
||||
"""Update the config for asset extraction/packaging"""
|
||||
try:
|
||||
import assets_config
|
||||
assets_config.updateConfig(path)
|
||||
except ImportError as ex:
|
||||
_handle_import_error(ex)
|
||||
except Exception as e:
|
||||
LOG.error(f"failure:")
|
||||
LOG.error(e)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
tp()
|
||||
|
||||
Reference in New Issue
Block a user