diff --git a/dat_dtt/exporter/dat_dtt_ui_manager.py b/dat_dtt/exporter/dat_dtt_ui_manager.py index 2744c03..98ca576 100644 --- a/dat_dtt/exporter/dat_dtt_ui_manager.py +++ b/dat_dtt/exporter/dat_dtt_ui_manager.py @@ -1,6 +1,7 @@ import os import time import json +from ...wta_wtp.exporter import export_wta_wtp import bpy from bpy.props import StringProperty @@ -16,12 +17,8 @@ class ExportAllSteps(bpy.types.PropertyGroup): name = "Export WMB", default = True ) - useWtpStep: bpy.props.BoolProperty( - name = "Export WTP", - default = True - ) - useWtaStep: bpy.props.BoolProperty( - name = "Export WTA", + useWtaWtpStep: bpy.props.BoolProperty( + name = "Export WTA + WTP", default = True ) useColStep: bpy.props.BoolProperty( @@ -63,6 +60,16 @@ class ExportAllSteps(bpy.types.PropertyGroup): default = True ) + exportingForGame: bpy.props.EnumProperty( + name = "Export For Game", + default = "NIER", + items = [ + ("NIER", "NieR: Automata", "Exports for NieR (WTA: DDS only).", 1), + ("NIERSWITCH", "NieR: Automata (Switch)", "Exports for NieR on Nintendo Switch (WTA: DDS/ASTC)", 2), + ("ASTRALCHAIN", "Astral Chain", "Exports for Astral Chain (WTA: DDS/ASTC)", 3) + ] + ) + class DAT_DTT_PT_Export(bpy.types.Panel): bl_label = "NieR:Automata Export" bl_space_type = 'PROPERTIES' @@ -148,8 +155,7 @@ class DAT_DTT_PT_Export(bpy.types.Panel): row = box.row(align=True) row.scale_y = 1.125 row.prop(context.scene.ExportAllSteps, "useWmbStep", text="WMB", icon="PANEL_CLOSE" if context.scene.ExportAllSteps.useWmbStep else "ADD") - row.prop(context.scene.ExportAllSteps, "useWtpStep", text="WTP", icon="PANEL_CLOSE" if context.scene.ExportAllSteps.useWtpStep else "ADD") - row.prop(context.scene.ExportAllSteps, "useWtaStep", text="WTA", icon="PANEL_CLOSE" if context.scene.ExportAllSteps.useWtaStep else "ADD") + row.prop(context.scene.ExportAllSteps, "useWtaWtpStep", text="WTA + WTP", icon="PANEL_CLOSE" if context.scene.ExportAllSteps.useWtaWtpStep else "ADD") row = box.row(align=True) row.scale_y = 1.125 secondRowItemCount = 0 @@ -175,6 +181,10 @@ class DAT_DTT_PT_Export(bpy.types.Panel): row.prop(context.scene.ExportAllSteps, "centerOrigins", text="Center Origins", icon="OBJECT_ORIGIN") row.prop(context.scene.ExportAllSteps, "deleteLoose", text="Delete Loose", icon="SNAP_VERTEX") + row = box.row(align=True) + box = row.box() + box.prop(context.scene.ExportAllSteps, "exportingForGame", text="Export for") + layout.separator() row = layout.row() @@ -243,7 +253,9 @@ class ExportAll(bpy.types.Operator): sarColl = bpy.data.objects["Field-Root"].users_collection[0] for item in context.scene.DatContents: - if item.filepath.endswith('.wta'): + if item.filepath.endswith('.wmb'): + wmbFilePath = item.filepath + elif item.filepath.endswith('.wta'): wtaFilePath = item.filepath elif item.filepath.endswith('.col'): colFilePath = item.filepath @@ -276,15 +288,11 @@ class ExportAll(bpy.types.Operator): bpy.ops.b2n.deleteloosegeometryall() wmb_exporter.main(wmbFilePath) exportedFilesCount += 1 - from ...wta_wtp.exporter import export_wta, export_wtp - if exportSteps.useWtaStep: - print("Exporting WTA") - export_wta.main(context, wtaFilePath) - exportedFilesCount += 1 - if exportSteps.useWtpStep: - print("Exporting WTP") - export_wtp.main(context, wtpFilePath) - exportedFilesCount += 1 + from ...wta_wtp.exporter import export_wta_wtp + if exportSteps.useWtaWtpStep: + print("Exporting WTA/WTP") + export_wta_wtp.main(context, wtaFilePath, wtpFilePath, exportSteps.exportingForGame) + exportedFilesCount += 2 from ...col.exporter import col_exporter if exportSteps.useColStep: print("Exporting COL") @@ -326,7 +334,7 @@ class ExportAll(bpy.types.Operator): fileNames = [os.path.basename(file_path) for file_path in file_list] saveDatInfo(datInfoFilePath, fileNames, datFileName) # export dtt - export_dat.main(datFilePath, file_list) + export_dat.main(datFilePath, file_list, exportSteps.exportingForGame) exportedFilesCount += 1 if exportSteps.useDttStep: if len(context.scene.DttContents) == 0: @@ -342,7 +350,7 @@ class ExportAll(bpy.types.Operator): fileNames = [os.path.basename(file_path) for file_path in file_list] saveDatInfo(datInfoFilePath, fileNames, dttFileName) # export dtt - export_dat.main(dttFilePath, file_list) + export_dat.main(dttFilePath, file_list, exportSteps.exportingForGame) exportedFilesCount += 1 tDiff = int(time.time() - t1) diff --git a/dat_dtt/exporter/export_dat.py b/dat_dtt/exporter/export_dat.py index 7772fc8..4bdbc9b 100644 --- a/dat_dtt/exporter/export_dat.py +++ b/dat_dtt/exporter/export_dat.py @@ -7,7 +7,7 @@ from ...utils.util import * def to_string(bs, encoding = 'utf8'): return bs.split(b'\x00')[0].decode(encoding) -def main(export_filepath, file_list): +def main(export_filepath, file_list, exportingForGame): files = file_list fileNumber = len(files) from .datHashGenerator import generateHashData @@ -46,11 +46,23 @@ def main(export_filepath, file_list): #fileOffsets fileOffsets = [] currentOffset = hashMapOffset + hashMapSize + # ASTRAL CHAIN's DTT files start at 0x8000 bytes. + if exportingForGame == "ASTRALCHAIN" and ".dtt" in export_filepath: + currentOffset = (math.ceil(currentOffset / 0x8000)) * 0x8000 + + # NieR Switch's DTT files start at intervals of 0x200. + if exportingForGame == "NIERSWITCH": + currentOffset = (math.ceil(currentOffset / 0x200)) * 0x200 + for fp in files: currentOffset = (math.ceil(currentOffset / 16)) * 16 fileOffsets.append(currentOffset) currentOffset += os.path.getsize(fp) + # ASTRAL CHAIN's BNK files are padded out to 2048 bytes. + if exportingForGame == "ASTRALCHAIN" and ".bnk" in os.path.basename(fp): + currentOffset = (math.ceil(currentOffset / 2048)) * 2048 + # fileSizes fileSizes = [] for fp in files: diff --git a/utils/util.py b/utils/util.py index 63d6378..3739c1b 100644 --- a/utils/util.py +++ b/utils/util.py @@ -345,3 +345,23 @@ def saveDatInfo(filepath: str, files: List[str], filename: str): "ext": ext[1:] } json.dump(jsonFiles, f, indent=4) + +def resolveTextureDir(wmb_dir, wmbname): + texture_dir_dat = wmb_dir.replace(wmbname, "textures").replace(".dtt", ".dat") + texture_dir_dtt = wmb_dir.replace(wmbname, "textures").replace(".dat", ".dtt") + if os.path.exists(texture_dir_dat): + return texture_dir_dat + elif os.path.exists(texture_dir_dtt): + return texture_dir_dtt + else: + return "" + +def resolveTexturePaths(texture_dir, textureID): + dds_path = "%s/%s.dds" % (texture_dir, textureID) + png_path = "%s/%s.png" % (texture_dir, textureID) + if os.path.exists(dds_path): + return dds_path + elif os.path.exists(png_path): + return png_path + else: + return "" diff --git a/wmb/importer/wmb_importer.py b/wmb/importer/wmb_importer.py index caaf6a9..99050cb 100644 --- a/wmb/importer/wmb_importer.py +++ b/wmb/importer/wmb_importer.py @@ -5,7 +5,7 @@ import math from typing import List, Tuple from mathutils import Vector -from ...utils.util import ShowMessageBox, getPreferences, printTimings +from ...utils.util import ShowMessageBox, getPreferences, printTimings, resolveTexturePaths, resolveTextureDir from .wmb import * from ...wta_wtp.exporter.wta_wtp_ui_manager import isTextureTypeSupported, makeWtaMaterial @@ -180,7 +180,7 @@ def addWtaExportMaterial(texture_dir, material): material_name = material[0] textures = material[1] wtaTextures: List[Tuple[str, str, str]] = [ - (mapType, id, os.path.join(texture_dir, f"{id}.dds")) + (mapType, id, resolveTexturePaths(texture_dir, id)) for mapType, id in textures.items() if isTextureTypeSupported(mapType) ] @@ -245,7 +245,7 @@ def construct_materials(texture_dir, material): for texturesType in textures.keys(): textures_type = texturesType.lower() material[texturesType] = textures.get(texturesType) - texture_file = "%s/%s.dds" % (texture_dir, textures[texturesType]) + texture_file = resolveTexturePaths(texture_dir, textures[texturesType]) if os.path.exists(texture_file): if textures_type.find('albedo') > -1: albedo_maps[textures_type] = textures.get(texturesType) @@ -258,9 +258,10 @@ def construct_materials(texture_dir, material): # Albedo Nodes albedo_nodes = [] + albedo_uv_nodes = [] albedo_mixRGB_nodes = [] for i, textureID in enumerate(albedo_maps.values()): - texture_file = "%s/%s.dds" % (texture_dir, textureID) + texture_file = resolveTexturePaths(texture_dir, textureID) if os.path.exists(texture_file): albedo_image = nodes.new(type='ShaderNodeTexImage') albedo_nodes.append(albedo_image) @@ -277,6 +278,12 @@ def construct_materials(texture_dir, material): albedo_mixRGB_nodes.append(mixRGB_shader) mixRGB_shader.location = 300,(i-1)*-60 mixRGB_shader.hide = True + # UV map node for other textures + uv_shader = nodes.new(type='ShaderNodeUVMap') + albedo_uv_nodes.append(uv_shader) + uv_shader.uv_map = f"UVMap{i+1}" + uv_shader.location = -300,i*-60 + uv_shader.hide = True # Albedo Links if len(albedo_nodes) == 1: albedo_principled = links.new(albedo_nodes[0].outputs['Color'], principled.inputs['Base Color']) @@ -287,8 +294,12 @@ def construct_materials(texture_dir, material): for i in range(len(albedo_mixRGB_nodes)): albedo_link = links.new(albedo_nodes[i+1].outputs['Color'], albedo_mixRGB_nodes[i].inputs['Color1']) alpha_link = links.new(albedo_nodes[i].outputs['Alpha'], albedo_mixRGB_nodes[i].inputs['Fac']) + # UV is [usually] different for other AlbedoMaps + uv_link = links.new(albedo_uv_nodes[i].outputs['UV'], albedo_nodes[i+1].inputs['Vector']) if i > 0: mixRGB_link = links.new(albedo_mixRGB_nodes[i-1].outputs['Color'], albedo_mixRGB_nodes[i].inputs['Color2']) + # Preserve Alpha for last texture (ASTRAL CHAIN Harmony Square signage?) + links.new(albedo_nodes[-1].outputs['Alpha'], principled.inputs['Alpha']) mixRGB_link = links.new(albedo_mixRGB_nodes[-1].outputs['Color'], principled.inputs['Base Color']) # Mask Nodes @@ -297,7 +308,7 @@ def construct_materials(texture_dir, material): mask_sepRGB_nodes = [] mask_invert_nodes = [] for i, textureID in enumerate(mask_maps.values()): - texture_file = "%s/%s.dds" % (texture_dir, textureID) + texture_file = resolveTexturePaths(texture_dir, textureID) if os.path.exists(texture_file): mask_image = nodes.new(type='ShaderNodeTexImage') mask_nodes.append(mask_image) @@ -334,7 +345,7 @@ def construct_materials(texture_dir, material): normal_nodes = [] normal_mixRGB_nodes = [] for i, textureID in enumerate(normal_maps.values()): - texture_file = "%s/%s.dds" % (texture_dir, textureID) + texture_file = resolveTexturePaths(texture_dir, textureID) if os.path.exists(texture_file): normal_image = nodes.new(type='ShaderNodeTexImage') normal_nodes.append(normal_image) @@ -374,7 +385,7 @@ def construct_materials(texture_dir, material): curvature_sepRGB_nodes = [] curvature_mul_nodes = [] for i, textureID in enumerate(curvature_maps.values()): - texture_file = "%s/%s.dds" % (texture_dir, textureID) + texture_file = resolveTexturePaths(texture_dir, textureID) if os.path.exists(texture_file): curvature_image = nodes.new(type='ShaderNodeTexImage') curvature_nodes.append(curvature_image) @@ -574,14 +585,14 @@ def get_wmb_material(wmb, texture_dir): try: texture_stream = wmb.wta.getTextureByIdentifier(identifier,wmb.wtp_fp) if texture_stream: - if not os.path.exists(os.path.join(texture_dir, identifier + '.dds')): + if not os.path.exists(resolveTexturePaths(texture_dir, identifier)): create_dir(texture_dir) texture_fp = open(os.path.join(texture_dir, identifier + '.dds'), "wb") print('[+] could not find DDS texture, trying to find it in WTA; %s.dds'% identifier) texture_fp.write(texture_stream) texture_fp.close() else: - print('[+] Found %s.dds'% identifier) + print('[+] Found ' + resolveTexturePaths(texture_dir, identifier).split("/")[-1]) except: continue materials.append([material_name,textures,uniforms,shader_name,technique_name,parameterGroups]) @@ -592,7 +603,7 @@ def get_wmb_material(wmb, texture_dir): identifier = wmb.wta.wtaTextureIdentifier[textureIndex] texture_stream = wmb.wta.getTextureByIdentifier(identifier,wmb.wtp_fp) if texture_stream: - if not os.path.exists(os.path.join(texture_dir, identifier + '.dds')): + if not os.path.exists(resolveTexturePaths(texture_dir, identifier)): create_dir(texture_dir) texture_fp = open(os.path.join(texture_dir, identifier + '.dds'), "wb") print('[+] dumping %s.dds'% identifier) @@ -682,7 +693,7 @@ def main(only_extract = False, wmb_file = os.path.join(os.path.split(os.path.rea wmbCollection.children.link(col) #bpy.context.view_layer.active_layer_collection = bpy.context.view_layer.layer_collection.children[-1] - texture_dir = wmb_file.replace(wmbname, 'textures') + texture_dir = resolveTextureDir(wmb_file, wmbname) if wmb.hasBone: boneArray = [[bone.boneIndex, "bone%d"%bone.boneIndex, bone.parentIndex,"bone%d"%bone.parentIndex, bone.world_position, bone.world_rotation, bone.boneNumber, bone.local_position, bone.local_rotation, bone.world_rotation, bone.world_position_tpose] for bone in wmb.boneArray] armature_no_wmb = wmbname.replace('.wmb','') diff --git a/wta_wtp/astcenc-avx2.exe b/wta_wtp/astcenc-avx2.exe new file mode 100644 index 0000000..7c08388 Binary files /dev/null and b/wta_wtp/astcenc-avx2.exe differ diff --git a/wta_wtp/exporter/export_wta_wtp.py b/wta_wtp/exporter/export_wta_wtp.py new file mode 100644 index 0000000..5e79956 --- /dev/null +++ b/wta_wtp/exporter/export_wta_wtp.py @@ -0,0 +1,540 @@ +import subprocess +import asyncio +import math +import json +from ...utils.ioUtils import read_int32, write_Int32, write_uInt16, write_float16 +from . import generate_wta_wtp_data +from .wta_wtp_utils import * +from ..tegrax1swizzle import compressImageData, getFormatByIndex + +# Asynchronously encodes a PNG file to ASTC. +async def encode_astc(filePath, format: str): + script_file = os.path.realpath(__file__) + directory = os.path.dirname(script_file).replace("exporter", "") + appPath = os.path.join(directory, "astcenc-avx2.exe") + result = subprocess.run([appPath, "-cs", filePath, filePath.replace('.png', '.astc'), format.split("_")[1], "-medium"], cwd=directory) + +def main(context, export_filepath_wta, export_filepath_wtp, exportingForGame): + wta_fp = open(export_filepath_wta,'wb') + wtp_fp = open(export_filepath_wtp,'wb') + + # Assign data and check if valid + identifiers_array, texture_paths_array, albedo_indexes, metadata_path = generate_wta_wtp_data.generate(context, exportingForGame) + + if None in [identifiers_array, texture_paths_array, albedo_indexes]: + print("WTP Export Failed! :{") + return + + # Assign some shit + unknown04 = 3 + textureCount = len(texture_paths_array) + paddingAmount = ((textureCount + 7) // 8) * 8 #rounds up to the nearest 8th integer + textureOffsetArrayOffset = 32 + textureSizeArrayOffset = textureOffsetArrayOffset + (paddingAmount * 4) + unknownArrayOffset1 = textureSizeArrayOffset + (paddingAmount * 4) + textureIdentifierArrayOffset = unknownArrayOffset1 + (paddingAmount * 4) + textureInfoArrayOffset = textureIdentifierArrayOffset + (paddingAmount * 4) + wtaTextureOffset = [0] * textureCount + wtaTextureSize = [0] * textureCount + wtaTextureIdentifier = [0] * textureCount + unknownArray1 = [0] * textureCount + textureInfoArray = [] + paddingAmountArray = [] + + # Pad the DDS files + #pad_dds_files(texture_paths_array) + + current_wtaTextureOffset = 0 + + if exportingForGame == "NIER": + # Open every DDS texture + for i in range(textureCount): + dds_fp = open(texture_paths_array[i], 'rb') + dds_paddedSize = os.stat(texture_paths_array[i]).st_size + + #checks dds dxt and cube map info + dds_fp.seek(84) + dxt = dds_fp.read(4) + dds_fp.seek(112) + cube = dds_fp.read(4) + + #finds how much padding bytes are added to a dds + dds_padding = 0 + if i != len(texture_paths_array)-1: + dds_fp.seek(dds_paddedSize-4) + dds_padding = read_int32(dds_fp) + paddingAmountArray.append(dds_padding) + + #wtaTextureOffset + if i+1 in range(len(wtaTextureSize)): + """ + if dds_paddedSize < 12289: + wtaTextureOffset[i+1] = wtaTextureOffset[i] + 12288 + elif dds_paddedSize < 176129: + wtaTextureOffset[i+1] = wtaTextureOffset[i] + 176128 + elif dds_paddedSize < 352257: + wtaTextureOffset[i+1] = wtaTextureOffset[i] + 352256 + elif dds_paddedSize < 528385: + wtaTextureOffset[i+1] = wtaTextureOffset[i] + 528384 + elif dds_paddedSize < 700417: + wtaTextureOffset[i+1] = wtaTextureOffset[i] + 700416 + elif dds_paddedSize < 2797569: + wtaTextureOffset[i+1] = wtaTextureOffset[i] + 2797568 + else: + wtaTextureOffset[i+1] = dds_paddedSize + """ + wtaTextureOffset[i+1] = current_wtaTextureOffset + dds_paddedSize + current_wtaTextureOffset += dds_paddedSize + #wtaTextureSize + wtaTextureSize[i] = dds_paddedSize# - dds_padding + #wtaTextureIdentifier + wtaTextureIdentifier[i] = identifiers_array[i] + #unknownArray1 + if i in albedo_indexes: + unknownArray1[i] = 637534240 + else: + unknownArray1[i] = 570425376 + #unknownArray2 + if dxt not in [b'DXT1', b'DXT3', b'DXT5']: + print("Unknown DXT format! Make sure you use DXT1, DXT3 or DXT5!") + dds_fp.close() + wta_fp.close() + return + + if dxt == b'DXT1': + textureInfoArray.append(71) + textureInfoArray.append(3) + if cube == b'\x00\xfe\x00\x00': + textureInfoArray.append(4) + else: + textureInfoArray.append(0) + textureInfoArray.append(1) + textureInfoArray.append(0) + if dxt == b'DXT3': + textureInfoArray.append(74) + textureInfoArray.append(3) + if cube == b'\x00\xfe\x00\x00': + textureInfoArray.append(4) + else: + textureInfoArray.append(0) + textureInfoArray.append(1) + textureInfoArray.append(0) + if dxt == b'DXT5': + textureInfoArray.append(77) + textureInfoArray.append(3) + if cube == b'\x00\xfe\x00\x00': + textureInfoArray.append(4) + else: + textureInfoArray.append(0) + textureInfoArray.append(1) + textureInfoArray.append(0) + + # Write WTP + dds_fp.seek(0) + content = dds_fp.read() + #print("-Writing dds: " + texture_paths_array[i] + " to file: " + export_filepath + " at position: " + str(i)) + wtp_fp.write(content) + dds_fp.close() + + + dds_fp.close() + + # Write everything + padding = b'' + for i in range(paddingAmount - textureCount): + padding += b'\x00\x00\x00\x00' + + wta_fp.write(b'WTB\x00') + wta_fp.write(to_bytes(unknown04)) + wta_fp.write(to_bytes(textureCount)) + wta_fp.write(to_bytes(textureOffsetArrayOffset)) + wta_fp.write(to_bytes(textureSizeArrayOffset)) + wta_fp.write(to_bytes(unknownArrayOffset1)) + wta_fp.write(to_bytes(textureIdentifierArrayOffset)) + wta_fp.write(to_bytes(textureInfoArrayOffset)) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureOffset[i])) + wta_fp.write(padding) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureSize[i])) + wta_fp.write(padding) + for i in range(textureCount): + wta_fp.write(to_bytes(unknownArray1[i])) + wta_fp.write(padding) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureIdentifier[i])) + wta_fp.write(padding) + for i in range(textureCount): + wta_fp.write(to_bytes(textureInfoArray[(i*5)])) + wta_fp.write(to_bytes(textureInfoArray[(i*5)+1])) + wta_fp.write(to_bytes(textureInfoArray[(i*5)+2])) + wta_fp.write(to_bytes(textureInfoArray[(i*5)+3])) + wta_fp.write(to_bytes(textureInfoArray[(i*5)+4])) + wta_fp.write(padding) + + elif exportingForGame == "NIERSWITCH": + # NIER SWITCH + + # Change offsets + textureSizeArrayOffset = math.ceil((textureOffsetArrayOffset + (textureCount * 4)) / 0x20) * 0x20 + unknownArrayOffset1 = math.ceil((textureSizeArrayOffset + (textureCount * 4)) / 0x20) * 0x20 + textureIdentifierArrayOffset = math.ceil((unknownArrayOffset1 + (textureCount * 4)) / 0x20) * 0x20 + textureInfoArrayOffset = math.ceil((textureIdentifierArrayOffset + (textureCount * 4)) / 0x20) * 0x20 + + # Encode all PNG files to ASTC (asynchronously for speed) + tasks = [] + for i in range(textureCount): + if ".png" in texture_paths_array[i].lower(): + textureFormat = "ASTC_6x6_UNORM" + if identifiers_array[i].upper() in metadata.keys(): + textureFormat = getFormatByIndex(metadata[identifiers_array[i].upper()]["format"]) + tasks.append(asyncio.ensure_future(encode_astc(texture_paths_array[i], textureFormat))) + + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.gather(*tasks)) + + # Open every DDS texture + for i in range(textureCount): + path = texture_paths_array[i].replace(".png", ".astc") + + #wtaTextureIdentifier + wtaTextureIdentifier[i] = identifiers_array[i] + wtaTextureOffset[i] = wtp_fp.tell() + + # Default infos + info = { + "magic": b".tex", + "format": 0x7D, + "unk1": 1, + "width": 0, + "height": 0, + "depth": 1, + "mipCount": 1, + "unk2": 256, + "unk3": 0.25, + "unk4": 0, + # guesses used for swizzling (wtpImportOperator.py) + "type": 1, + "textureLayout": [4, 0], + "arrayCount": 1 + } + + if ".dds" in path.lower(): + dds_fp = open(path, 'rb') + dds_paddedSize = os.stat(texture_paths_array[i]).st_size + + dds_fp.seek(12) + info["width"] = int.from_bytes(dds_fp.read(4), "little") + info["height"] = int.from_bytes(dds_fp.read(4), "little") + + #checks dds dxt and cube map info + dds_fp.seek(84) + dxt = dds_fp.read(4) + dds_fp.seek(112) + cube = dds_fp.read(4) + + + # DXT checking + if info["format"] == 0x7D: # If not prefilled + if dxt == b'DXT1': + info["format"] = 0x46 # BC1_UNORM_SRGB + elif dxt == b'DXT3': + info["format"] = 0x47 # BC2_UNORM_SRGB + elif dxt == b'DXT5': + info["format"] = 0x48 # BC3_UNORM_SRGB + else: + info["format"] = 0x50 # BC6H_UF16 + + #unknownArray1 + unknownArray1[i] = 1677721632 # DDS textures are always SRGB + wtaTextureOffset[i] = wtp_fp.tell() + + dds_fp.seek(0x80) + blockHeightLog2 = info["textureLayout"][0] & 7 + wtp_fp.write(compressImageData( + getFormatByIndex(info['format']), + info['width'], + info['height'], + info['depth'], + info['arrayCount'], + info['mipCount'], + dds_fp.read(), + blockHeightLog2 + )) + wtaTextureSize[i] = wtp_fp.tell() - wtaTextureOffset[i] + if wtaTextureSize[i] < 90112: + wtaTextureSize[i] = 90112 + while wtp_fp.tell() < (wtaTextureSize[i] + wtaTextureOffset[i]): + wtp_fp.write(b'\x00') + info['imageSize'] = wtaTextureSize[i] + + dds_fp.close() + else: + # ASTC + astc_fp = open(path, 'rb') + astc_fp.seek(7) + info["width"] = int.from_bytes(astc_fp.read(3), "little") + info["height"] = int.from_bytes(astc_fp.read(3), "little") + + astc_fp.seek(16) + blockHeightLog2 = info["textureLayout"][0] & 7 + wtp_fp.write(compressImageData( + getFormatByIndex(info['format']), + info['width'], + info['height'], + info['depth'], + info['arrayCount'], + info['mipCount'], + astc_fp.read(), + blockHeightLog2 + )) + wtaTextureSize[i] = wtp_fp.tell() - wtaTextureOffset[i] + if wtaTextureSize[i] < 90112: + wtaTextureSize[i] = 90112 + while wtp_fp.tell() < (wtaTextureSize[i] + wtaTextureOffset[i]): + wtp_fp.write(b'\x00') + info['imageSize'] = wtaTextureSize[i] + + if "SRGB" in getFormatByIndex(info["format"]): + unknownArray1[i] = (1677721632) + else: + unknownArray1[i] = (1610612768) + + astc_fp.close() + # Remove the temporary ASTC file + os.remove(path) + + # MipCount needs to be set to 1 -- TODO: find what's causing this + #info["mipCount"] = int(math.log2(max(info["width"], info["height"]))) + 1 + textureInfoArray.append(info) + wtp_fp.seek(math.ceil(wtp_fp.tell() / 16) * 16) + + # Write everything + wta_fp.write(b'WTB\x00') + wta_fp.write(to_bytes(unknown04)) + wta_fp.write(to_bytes(textureCount)) + wta_fp.write(to_bytes(textureOffsetArrayOffset)) + wta_fp.write(to_bytes(textureSizeArrayOffset)) + wta_fp.write(to_bytes(unknownArrayOffset1)) + wta_fp.write(to_bytes(textureIdentifierArrayOffset)) + wta_fp.write(to_bytes(textureInfoArrayOffset)) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureOffset[i])) + wta_fp.seek(textureSizeArrayOffset) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureSize[i])) + wta_fp.seek(unknownArrayOffset1) + for i in range(textureCount): + wta_fp.write(to_bytes(unknownArray1[i])) + wta_fp.seek(textureIdentifierArrayOffset) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureIdentifier[i])) + + for i in range(textureCount): + wta_fp.seek(textureInfoArrayOffset + i * 0x100) + wta_fp.write(textureInfoArray[i]["magic"]) + write_Int32(wta_fp, textureInfoArray[i]["format"]) + write_Int32(wta_fp, 1) + write_Int32(wta_fp, textureInfoArray[i]["width"]) + write_Int32(wta_fp, textureInfoArray[i]["height"]) + write_Int32(wta_fp, textureInfoArray[i]["depth"]) + write_Int32(wta_fp, textureInfoArray[i]["mipCount"]) + write_Int32(wta_fp, textureInfoArray[i]["unk2"]) + write_float16(wta_fp, textureInfoArray[i]["unk3"]) + write_uInt16(wta_fp, textureInfoArray[i]["unk4"]) + while wta_fp.tell() % 16 != 0: + wta_fp.write(b'\x00') + + elif exportingForGame == "ASTRALCHAIN": + # ASTRAL CHAIN + + # Change offsets + textureSizeArrayOffset = math.ceil((textureOffsetArrayOffset + (textureCount * 4)) / 0x20) * 0x20 + unknownArrayOffset1 = math.ceil((textureSizeArrayOffset + (textureCount * 4)) / 0x20) * 0x20 + textureIdentifierArrayOffset = math.ceil((unknownArrayOffset1 + (textureCount * 4)) / 0x20) * 0x20 + textureInfoArrayOffset = math.ceil((textureIdentifierArrayOffset + (textureCount * 4)) / 0x20) * 0x20 + + # Open metadata + if metadata_path: + with open(metadata_path, "r") as metadata_fp: + metadata = json.load(metadata_fp) + else: + metadata = {} + + # Encode all PNG files to ASTC (asynchronously for speed) + tasks = [] + for i in range(textureCount): + if ".png" in texture_paths_array[i].lower(): + textureFormat = "ASTC_4x4_UNORM" + if identifiers_array[i].upper() in metadata.keys(): + textureFormat = getFormatByIndex(metadata[identifiers_array[i].upper()]["format"]) + tasks.append(asyncio.ensure_future(encode_astc(texture_paths_array[i], textureFormat))) + + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.gather(*tasks)) + + # Open every DDS texture + for i in range(textureCount): + path = texture_paths_array[i].replace(".png", ".astc") + + #wtaTextureIdentifier + wtaTextureIdentifier[i] = identifiers_array[i] + wtaTextureOffset[i] = wtp_fp.tell() + + # Default infos + info = { + "magic": b"XT1\x00", + "unk1": 16777473, # 0x 01 01 00 01 + "imageSize": 0, # Uint64 identical to header (which is weird because the header is uint32, lol) + "headerSize": 56, # Always 56 + "mipCount": 1, + "type": 1, + "format": 0x79, + "width": 0, + "height": 0, + "depth": 1, + "unk4": 32, # Always 32 (?) + "textureLayout": [1027, 65543], + "arrayCount": 1 + } + + if identifiers_array[i].upper() in metadata.keys(): + info["type"] = metadata[identifiers_array[i].upper()]["type"] + info["format"] = metadata[identifiers_array[i].upper()]["format"] + info["textureLayout"] = metadata[identifiers_array[i].upper()]["textureLayout"] + + if ".dds" in path.lower(): + dds_fp = open(path, 'rb') + dds_paddedSize = os.stat(texture_paths_array[i]).st_size + + dds_fp.seek(12) + info["width"] = int.from_bytes(dds_fp.read(4), "little") + info["height"] = int.from_bytes(dds_fp.read(4), "little") + + #checks dds dxt and cube map info + dds_fp.seek(84) + dxt = dds_fp.read(4) + dds_fp.seek(112) + cube = dds_fp.read(4) + + + # DXT checking + if info["format"] == 0x79: # If not prefilled + if dxt == b'DXT1': + info["format"] = 0x46 # BC1_UNORM_SRGB + elif dxt == b'DXT3': + info["format"] = 0x47 # BC2_UNORM_SRGB + elif dxt == b'DXT5': + info["format"] = 0x48 # BC3_UNORM_SRGB + else: + info["format"] = 0x50 # BC6H_UF16 + + # Astral Chain uses no padding (i think); probably unnecessary + + #unknownArray1 + unknownArray1[i] = 1677721632 # DDS textures are always SRGB + wtaTextureOffset[i] = wtp_fp.tell() + + dds_fp.seek(80) + blockHeightLog2 = info["textureLayout"][0] & 7 + wtp_fp.write(compressImageData( + getFormatByIndex(info['format']), + info['width'], + info['height'], + info['depth'], + info['arrayCount'], + info['mipCount'], + dds_fp.read(), + blockHeightLog2 + )) + wtaTextureSize[i] = wtp_fp.tell() - wtaTextureOffset[i] + if wtaTextureSize[i] < 90112: + wtaTextureSize[i] = 90112 + while wtp_fp.tell() < (wtaTextureSize[i] + wtaTextureOffset[i]): + wtp_fp.write(b'\x00') + info['imageSize'] = wtaTextureSize[i] + + dds_fp.close() + else: + # ASTC + astc_fp = open(path, 'rb') + astc_fp.seek(7) + info["width"] = int.from_bytes(astc_fp.read(3), "little") + info["height"] = int.from_bytes(astc_fp.read(3), "little") + + astc_fp.seek(16) + blockHeightLog2 = info["textureLayout"][0] & 7 + wtp_fp.write(compressImageData( + getFormatByIndex(info['format']), + info['width'], + info['height'], + info['depth'], + info['arrayCount'], + info['mipCount'], + astc_fp.read(), + blockHeightLog2 + )) + wtaTextureSize[i] = wtp_fp.tell() - wtaTextureOffset[i] + if wtaTextureSize[i] < 90112: + wtaTextureSize[i] = 90112 + while wtp_fp.tell() < (wtaTextureSize[i] + wtaTextureOffset[i]): + wtp_fp.write(b'\x00') + info['imageSize'] = wtaTextureSize[i] + + if "SRGB" in getFormatByIndex(info["format"]): + unknownArray1[i] = (1677721632) + else: + unknownArray1[i] = (1610612768) + + astc_fp.close() + # Remove the temporary ASTC file + os.remove(path) + + # MipCount needs to be set to 1 -- TODO: find what's causing this + #info["mipCount"] = int(math.log2(max(info["width"], info["height"]))) + 1 + textureInfoArray.append(info) + wtp_fp.seek(math.ceil(wtp_fp.tell() / 16) * 16) + + # Write everything + wta_fp.write(b'WTB\x00') + wta_fp.write(to_bytes(unknown04)) + wta_fp.write(to_bytes(textureCount)) + wta_fp.write(to_bytes(textureOffsetArrayOffset)) + wta_fp.write(to_bytes(textureSizeArrayOffset)) + wta_fp.write(to_bytes(unknownArrayOffset1)) + wta_fp.write(to_bytes(textureIdentifierArrayOffset)) + wta_fp.write(to_bytes(textureInfoArrayOffset)) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureOffset[i])) + wta_fp.seek(textureSizeArrayOffset) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureSize[i])) + wta_fp.seek(unknownArrayOffset1) + for i in range(textureCount): + wta_fp.write(to_bytes(unknownArray1[i])) + wta_fp.seek(textureIdentifierArrayOffset) + for i in range(textureCount): + wta_fp.write(to_bytes(wtaTextureIdentifier[i])) + wta_fp.seek(textureInfoArrayOffset) + for i in range(textureCount): + wta_fp.write(textureInfoArray[i]["magic"]) + write_Int32(wta_fp, textureInfoArray[i]["unk1"]) + write_Int32(wta_fp, 0) + write_Int32(wta_fp, textureInfoArray[i]["imageSize"]) + write_Int32(wta_fp, textureInfoArray[i]["headerSize"]) + write_Int32(wta_fp, textureInfoArray[i]["mipCount"]) + write_Int32(wta_fp, textureInfoArray[i]["type"]) + write_Int32(wta_fp, textureInfoArray[i]["format"]) + write_Int32(wta_fp, textureInfoArray[i]["width"]) + write_Int32(wta_fp, textureInfoArray[i]["height"]) + write_Int32(wta_fp, textureInfoArray[i]["depth"]) + write_Int32(wta_fp, textureInfoArray[i]["unk4"]) + write_Int32(wta_fp, textureInfoArray[i]["textureLayout"][0]) + write_Int32(wta_fp, textureInfoArray[i]["textureLayout"][1]) + while wta_fp.tell() % 16 != 0: + wta_fp.write(b'\x00') + + + wta_fp.close() + wtp_fp.close() + print('WTA + WTP Export Complete. :]}') \ No newline at end of file diff --git a/wta_wtp/exporter/generate_wta_wtp_data.py b/wta_wtp/exporter/generate_wta_wtp_data.py index 2858879..e008db2 100644 --- a/wta_wtp/exporter/generate_wta_wtp_data.py +++ b/wta_wtp/exporter/generate_wta_wtp_data.py @@ -1,14 +1,16 @@ import string +import os from ...utils.util import ShowMessageBox -def generate(context): +def generate(context, exportingForGame): wta_data = context.scene.WTAMaterials identifiers_array = [] texture_paths_array = [] albedo_indexes = [] + metadata_path = None true_index = 0 for index, texture in enumerate(wta_data): @@ -18,28 +20,33 @@ def generate(context): # Check if identifier is 8 chars long if len( texture.texture_identifier) != 8: - print('[!] WTA/WTP Export Error: A texture identifier is not 8 characters long.') - ShowMessageBox('A texture identifier is not 8 characters long.', 'WTA/WTP Export Error', 'ERROR') - return None, None, None + print('[!] WTA/WTP Export Error: A texture identifier is not characters long.') + ShowMessageBox('A texture identifier is not characters long.', 'WTA/WTP Export Error', 'ERROR') + return None, None, None, None # Check if identifier is valid hex if not all(c in string.hexdigits for c in texture.texture_identifier): print('[!] WTA/WTP Export Error: A texture identifier contains a non-hex character.') ShowMessageBox('A texture identifier contains a non-hex character.', 'WTA/WTP Export Error', 'ERROR') - return None, None, None + return None, None, None, None # Assign Identifier. identifiers_array.append(texture.texture_identifier) + # Check if game metadata exists. + xt1MetadataPath = os.path.join(*texture.texture_path.split('/')[:-1], "xt1_info.json") + if exportingForGame == "ASTRALCHAIN" and not metadata_path and os.path.exists(xt1MetadataPath): + metadata_path = xt1MetadataPath + # Check if path is valid and assign. - if not texture.texture_path.lower().endswith('.dds'): + if not texture.texture_path.lower().endswith('.dds') and not texture.texture_path.lower().endswith('.png'): if texture.parent_mat == "": print('[!] WTA/WTP Export Error: A manual ' + texture.texture_map_type + ' texture does not have a valid texture assigned.') ShowMessageBox('A manual ' + texture.texture_map_type + ' texture does not have a valid texture assigned.', 'WTA/WTP Export Error', 'ERROR') else: print('[!] WTA/WTP Export Error: A texture in material', texture.parent_mat, 'does not have a valid path assigned.') ShowMessageBox(texture.parent_mat + ' does not have a valid texture assigned to ' + texture.texture_map_type, 'WTA/WTP Export Error', 'ERROR') - return None, None, None + return None, None, None, None texture_paths_array.append(texture.texture_path) @@ -49,4 +56,4 @@ def generate(context): true_index += 1 - return identifiers_array, texture_paths_array, albedo_indexes \ No newline at end of file + return identifiers_array, texture_paths_array, albedo_indexes, metadata_path \ No newline at end of file diff --git a/wta_wtp/exporter/wta_wtp_ui_manager.py b/wta_wtp/exporter/wta_wtp_ui_manager.py index 23ff9c2..aa61fd8 100644 --- a/wta_wtp/exporter/wta_wtp_ui_manager.py +++ b/wta_wtp/exporter/wta_wtp_ui_manager.py @@ -118,7 +118,7 @@ def handleAutoSetTextureWarnings(operatorSelf, warnings: List[str]): print("\n".join(warnings)) def isTextureTypeSupported(textureType: str) -> bool: - for supportedTex in ['g_AlbedoMap', 'g_MaskMap', 'g_NormalMap', 'g_EnvMap', 'g_DetailNormalMap', 'g_IrradianceMap', 'g_CurvatureMap', 'g_SpreadPatternMap', 'g_LUT', 'g_LightMap', 'g_GradationMap', 'g_ParallaxMap']: + for supportedTex in ['g_AlbedoMap', 'g_ColorMask', 'g_MaskMap', 'g_NormalMap', 'g_EnvMap', 'g_DetailNormalMap', 'g_IrradianceMap', 'g_CurvatureMap', 'g_SpreadPatternMap', 'g_LUT', 'g_LightMap', 'g_GradationMap', 'g_ParallaxMap']: if supportedTex in textureType: return True return False @@ -286,8 +286,8 @@ class ExportWTAOperator(bpy.types.Operator, ExportHelper): filter_glob: StringProperty(default="*.wta", options={'HIDDEN'}) def execute(self, context): - from . import export_wta - export_wta.main(context, self.filepath) + from . import export_wta_wtp + export_wta_wtp.main(context, self.filepath) return{'FINISHED'} class FilepathSelector(bpy.types.Operator, ImportHelper): @@ -297,7 +297,7 @@ class FilepathSelector(bpy.types.Operator, ImportHelper): bl_options = {"UNDO"} filename_ext = ".dds" - filter_glob: StringProperty(default="*.dds", options={'HIDDEN'}) + filter_glob: StringProperty(default="*.dds,*.png", options={'HIDDEN'}) id : bpy.props.IntProperty(options={'HIDDEN'}) @@ -602,7 +602,7 @@ class WTA_WTP_PT_Hints(bpy.types.Panel): row = box.row() row.label(text='- Texture identifier has to be 8 HEX characters long.') row = box.row() - row.label(text='- Textures have to be in DDS format (DXT1, DXT3, DXT5).') + row.label(text='- Textures have to be in DDS format (DXT1, DXT3, DXT5) for NieR, or PNG (converting to ASTC) for Switch games.') row = box.row() row.label(text='- It is recommended to "Sync Identifiers in Materials" before WMB export.') diff --git a/wta_wtp/importer/wtpImportOperator.py b/wta_wtp/importer/wtpImportOperator.py index 8bbfcea..6b1d272 100644 --- a/wta_wtp/importer/wtpImportOperator.py +++ b/wta_wtp/importer/wtpImportOperator.py @@ -2,7 +2,13 @@ import os import bpy from bpy.props import StringProperty from bpy_extras.io_utils import ImportHelper +from struct import pack +import subprocess +import asyncio +import json from ...utils import ioUtils as io +from ..tegrax1swizzle import getFormatByIndex, getFormatTable, loadImageData + class WTAData: def __init__(self, f, wtpFile) -> None: @@ -41,14 +47,54 @@ class WTAData: self.idx.append(io.read_uint32(f)) # Texture Info - """ + f.seek(self.offsetTextureInfo) self.infos = [] - for i in range(self.num_files): - info = [] - info.append(io.read_uint32(f)) - info.append([io.read_uint32(f) for x in range(4)]) - self.infos.append(info) - """ + + infoFormat = f.read(4) + if infoFormat == b'XT1\x00': # Astral Chain, Bayonetta 3 WTA format + self.type = "XT1" + f.seek(f.tell() - 4) + for i in range(self.num_files): + info = { + "magic": f.read(4), + "unk1": io.read_uint32(f), + "imageSize": io.read_uint64(f), + "headerSize": io.read_uint32(f), + "mipCount": io.read_uint32(f), + "type": io.read_uint32(f), + "format": io.read_uint32(f), + "width": io.read_uint32(f), + "height": io.read_uint32(f), + "depth": io.read_uint32(f), + "unk4": io.read_uint32(f), + "textureLayout": [io.read_uint32(f), io.read_uint32(f)], + "arrayCount": 1 + } + if info["type"] == 3 or info["type"] == 8: # T_Cube or T_Cube_Array + info["arrayCount"] = 6 + self.infos.append(info) + elif infoFormat == b'.tex': # NieR Switch WTA format + self.type = "TEX" + for i in range(self.num_files): + f.seek(self.offsetTextureInfo + i * 0x100) + info = { + "magic": f.read(4), + "format": io.read_uint32(f), + "unk1": io.read_uint32(f), + "width": io.read_uint32(f), + "height": io.read_uint32(f), + "depth": io.read_uint32(f), + "mipCount": io.read_uint32(f), + "unk2": io.read_uint32(f), + "unk3": io.read_float16(f), + "unk4": io.read_uint16(f), + "type": 1, + "textureLayout": [4, 0], + "arrayCount": 1 + } + self.infos.append(info) + else: + self.type = "PC" self.data = wtpFile.read() @@ -58,13 +104,132 @@ class WTAData: count = 0 fileName = os.path.basename(self.wtaPath) dir = os.path.dirname(self.wtaPath) - for i in range(self.num_files): - os.makedirs(extractionDir, exist_ok=True) - with open(os.path.join(extractionDir, f"{self.idx[i]:0>8X}.dds"), "wb") as f: - f.write(self.data[self.offsets[i]:self.offsets[i]+self.sizes[i]]) - count += 1 + + if self.type == "PC": + for i in range(self.num_files): + os.makedirs(extractionDir, exist_ok=True) + with open(os.path.join(extractionDir, f"{self.idx[i]:0>8X}.dds"), "wb") as f: + f.write(self.data[self.offsets[i]:self.offsets[i]+self.sizes[i]]) + count += 1 + else: + # Switch games must construct the DDS/ASTC headers manually. + tasks = [] + for i in range(self.num_files): + os.makedirs(extractionDir, exist_ok=True) + # Unswizzle + textureFormat = getFormatByIndex(self.infos[i]["format"]) + blockHeightLog2 = self.infos[i]["textureLayout"][0] & 7 + texture = loadImageData( + textureFormat, + self.infos[i]['width'], + self.infos[i]['height'], + self.infos[i]['depth'], + self.infos[i]['arrayCount'], + self.infos[i]['mipCount'], + self.data[self.offsets[i]:self.offsets[i]+self.sizes[i]], + blockHeightLog2 + ) + + # Construct headers + if "ASTC" in textureFormat: + # ASTC + formatInfo = getFormatTable(textureFormat) + with open(os.path.join(extractionDir, f"{self.idx[i]:0>8X}.astc"), "wb") as f: + f.write(b''.join([ + b'\x13\xAB\xA1\x5C', formatInfo[1].to_bytes(1, "little"), + formatInfo[2].to_bytes(1, "little"), b'\1', + self.infos[i]['width'].to_bytes(3, "little"), + self.infos[i]['height'].to_bytes(3, "little"), b'\1\0\0', + texture, + ])) + + tasks.append(asyncio.ensure_future(decode_astc(os.path.join(extractionDir, f"{self.idx[i]:0>8X}.astc")))) + else: + # DDS + headerDataObject = DDSHeader(textureFormat, self.infos[i]['width'], self.infos[i]['height'], self.infos[i]['depth']) + with open(os.path.join(extractionDir, f"{self.idx[i]:0>8X}.dds"), "wb") as f: + f.write(headerDataObject.save()) + f.write(texture) + + count += 1 + + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.gather(*tasks)) + + # Write metadata file (need to store format and textureLayout for later) + metadata = {} + for i in range(self.num_files): + metadata[f"{self.idx[i]:0>8X}"] = { + "type": self.infos[i]["type"], + "format": self.infos[i]["format"], + "textureLayout": self.infos[i]["textureLayout"] + } + with open(os.path.join(extractionDir, "xt1_info.json"), "w") as f: + f.write(json.dumps(metadata, indent=4)) + return count +# Asynchronously decodes an ASTC file to PNG. +async def decode_astc(filePath): + script_file = os.path.realpath(__file__) + directory = os.path.dirname(script_file).replace("importer", "") + appPath = os.path.join(directory, "astcenc-avx2.exe") + result = subprocess.run([appPath, "-ds", filePath, filePath.replace('.astc', '.png')], cwd=directory) + os.remove(filePath) + +# WAY too lazy to reimplement this; remind me later i guess? +class DDSHeader(object): + # https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header + class DDSPixelFormat(object): + def __init__(self, textureFormat): + self.size = 32 + self.flags = 4 # contains fourcc + if textureFormat == "BC6H_UF16": + self.fourCC = b'DX10' + elif textureFormat.startswith("BC1"): + self.fourCC = b'DXT1' + elif textureFormat.startswith("BC2"): + self.fourCC = b'DXT3' + else: + self.fourCC = b'DXT5' + # BC1 = DXT1, BC2 = DXT3, above is DXT5 i think; BC6H is the only DX10 format + self.RGBBitCount = 0 + self.RBitMask = 0x00000000 + self.GBitMask = 0x00000000 + self.BBitMask = 0x00000000 + self.ABitMask = 0x00000000 + + def __init__(self, textureFormat, width, height, depth): + self.magic = b'DDS\x20' + self.size = 124 + self.flags = 0x1 + 0x2 + 0x4 + 0x1000 + 0x20000 + 0x80000 # Defaults (caps, height, width, pixelformat) + mipmapcount and linearsize + self.height = height + self.width = width + self.format = textureFormat + if self.format == "R8G8B8A8_UNORM": + self.pitchOrLinearSize = ((self.width + 1) >> 1) * 4 + else: + self.pitchOrLinearSize = int(max(1, ((self.width+3)/4) ) * getFormatTable(self.format)[0]) # https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide + self.depth = depth + self.mipmapCount = 1#texture.mipCount # Setting this to the normal value breaks everything, don't do that + self.reserved1 = [0x00000000] * 11 + self.ddspf = self.DDSPixelFormat(textureFormat) + self.caps = 4198408 # Defaults (DDSCAPS_TEXTURE) + mipmap and complex + self.caps2 = 0 + self.caps3 = 0 + self.caps4 = 0 + self.reserved2 = 0 + + def save(self): + output = self.magic + pack("20I4s10I", self.size, self.flags, self.height, self.width, self.pitchOrLinearSize, self.depth, + self.mipmapCount, self.reserved1[0], self.reserved1[1], self.reserved1[2], self.reserved1[3], self.reserved1[4], + self.reserved1[5], self.reserved1[6], self.reserved1[7], self.reserved1[8], self.reserved1[9], self.reserved1[10], + self.ddspf.size, self.ddspf.flags, self.ddspf.fourCC, self.ddspf.RGBBitCount, self.ddspf.RBitMask, self.ddspf.GBitMask, + self.ddspf.BBitMask, self.ddspf.ABitMask, self.caps, self.caps2, self.caps3, self.caps4, self.reserved2) + if self.format == "BC6H_UF16": + output += bytearray(b"\x5F\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00") + return output + class ExtractNierWtaWtp(bpy.types.Operator, ImportHelper): '''Extract textures from WTA/WTP files''' bl_idname = "import_scene.nier_wta_wtp" diff --git a/wta_wtp/tegrax1swizzle.py b/wta_wtp/tegrax1swizzle.py new file mode 100644 index 0000000..ae5b494 --- /dev/null +++ b/wta_wtp/tegrax1swizzle.py @@ -0,0 +1,255 @@ +# TegraX1Swizzle.py - cabalex [Updated Dec 2022] +# Based on: +# KillzXGaming's Switch Toolbox texture decoding - https://github.com/KillzXGaming/Switch-Toolbox/blob/604f7b3d369bc97d9d05632da3211ed11b990ba7/Switch_Toolbox_Library/Texture%20Decoding/Switch/TegraX1Swizzle.cs +# aboood40091's BNTX-Extractor - https://github.com/aboood40091/BNTX-Extractor/blob/master/swizzle.py +# [Format table] Ryujinx's image table - https://github.com/Ryujinx/Ryujinx/blob/c86aacde76b5f8e503e2b412385c8491ecc86b3b/Ryujinx.Graphics/Graphics3d/Texture/ImageUtils.cs + +formatTable = { + "R8G8B8A8_UNORM": [4, 1, 1, 1], + "BC1_UNORM": [8, 4, 4, 1], + "BC2_UNORM": [16, 4, 4, 1], + "BC3_UNORM": [16, 4, 4, 1], + "BC4_UNORM": [8, 4, 4, 1], + "BC1_UNORM_SRGB": [8, 4, 4, 1], + "BC2_UNORM_SRGB": [16, 4, 4, 1], + "BC3_UNORM_SRGB": [16, 4, 4, 1], + "BC4_SNORM": [8, 4, 4, 1], + "BC6H_UF16": [16, 4, 4, 1], + "ASTC_4x4_UNORM": [16, 4, 4, 1], + "ASTC_6x6_UNORM": [16, 6, 6, 1], + "ASTC_8x8_UNORM": [16, 8, 8, 1], + "ASTC_4x4_SRGB": [16, 4, 4, 1], + "ASTC_6x6_SRGB": [16, 6, 6, 1], + "ASTC_8x8_SRGB": [16, 8, 8, 1] +} +# each one: bytesPerPixel, blockWidth, blockHeight, blockDepth, targetBuffer (but i removed targetBuffer) + +formats = { + # DDS + 0x25: "R8G8B8A8_UNORM", + 0x42: "BC1_UNORM", + 0x43: "BC2_UNORM", + 0x44: "BC3_UNORM", + 0x45: "BC4_UNORM", + 0x46: "BC1_UNORM_SRGB", + 0x47: "BC2_UNORM_SRGB", + 0x48: "BC3_UNORM_SRGB", + 0x49: "BC4_SNORM", + 0x50: "BC6H_UF16", + # ASTC (weird texture formats ??) + 0x2D: "ASTC_4x4_UNORM", + 0x38: "ASTC_8x8_UNORM", + 0x3A: "ASTC_12x12_UNORM", + # ASTC + 0x79: "ASTC_4x4_UNORM", + 0x80: "ASTC_8x8_UNORM", + 0x87: "ASTC_4x4_SRGB", + 0x8E: "ASTC_8x8_SRGB", + + # Unknown NieR switch formats + 0x7D: "ASTC_6x6_UNORM", + 0x8B: "ASTC_6x6_SRGB", +} + +def getFormatTable(_format): + return formatTable[_format] + +def getFormatByIndex(_format): + return formats[_format] + +def pow2_round_up(x): + x -= 1 + x |= x >> 1 + x |= x >> 2 + x |= x >> 4 + x |= x >> 8 + x |= x >> 16 + return x + 1 + +def DIV_ROUND_UP(n, d): + return (n + d - 1) // d + +def subArray(data, offset, length): + return data[offset:offset+length] + +def round_up(x, y): + return ((x - 1) | (y - 1)) + 1 + + +def _swizzle(width, height, depth, blkWidth, blkHeight, blkDepth, roundPitch, bpp, tileMode, blockHeightLog2, data, toSwizzle): + block_height = 1 << blockHeightLog2 + + width = DIV_ROUND_UP(width, blkWidth) + height = DIV_ROUND_UP(height, blkHeight) + depth = DIV_ROUND_UP(depth, blkDepth) + + if tileMode == 1: + if roundPitch == 1: + pitch = round_up(width * bpp, 32) + else: + pitch = width * bpp + surfSize = round_up(pitch * height, 32) + + else: + pitch = round_up(width * bpp, 64) + surfSize = pitch * round_up(height, block_height * 8) + + result = bytearray(surfSize) + + for y in range(height): + for x in range(width): + if tileMode == 1: + pos = y * pitch + x * bpp + + else: + pos = getAddrBlockLinear(x, y, width, bpp, 0, block_height) + + pos_ = (y * width + x) * bpp + + if pos + bpp <= surfSize: + if toSwizzle == 1: + result[pos:pos + bpp] = data[pos_:pos_ + bpp] + + else: + result[pos_:pos_ + bpp] = data[pos:pos + bpp] + size = width * height * bpp + return result[:size] + +#def deswizzle(width, height, blkWidth, blkHeight, bpp, tileMode, alignment, size_range, data): +def deswizzle(width, height, depth, blkWidth, blkHeight, blkDepth, roundPitch, bpp, tileMode, size_range, data): + return _swizzle(width, height, depth, blkWidth, blkHeight, blkDepth, roundPitch, bpp, tileMode, size_range, bytes(data), 0) + #return _swizzle(width, height, blkWidth, blkHeight, bpp, tileMode, alignment, size_range, bytes(data), 0) + + +def swizzle(width, height, depth, blkWidth, blkHeight, blkDepth, roundPitch, bpp, tileMode, size_range, data): + return _swizzle(width, height, depth, blkWidth, blkHeight, blkDepth, roundPitch, bpp, tileMode, size_range, bytes(data), 1) + + +def getAddrBlockLinear(x, y, image_width, bytes_per_pixel, base_address, block_height): + """ + From the Tegra X1 TRM + """ + image_width_in_gobs = DIV_ROUND_UP(image_width * bytes_per_pixel, 64) + + GOB_address = (base_address + + (y // (8 * block_height)) * 512 * block_height * image_width_in_gobs + + (x * bytes_per_pixel // 64) * 512 * block_height + + (y % (8 * block_height) // 8) * 512) + + x *= bytes_per_pixel + + Address = (GOB_address + ((x % 64) // 32) * 256 + ((y % 8) // 2) * 64 + + ((x % 32) // 16) * 32 + (y % 2) * 16 + (x % 16)) + + return Address + +def loadImageData(format: str, width: int, height: int, depth: int, arrayCount: int, mipCount: int, imageData, blockHeightLog2, target=1, linearTileMode=False): + [bpp, blkWidth, blkHeight, blkDepth] = getFormatTable(format) + blockHeight = DIV_ROUND_UP(height, blkHeight) + pitch = 0 + dataAlignment = 512 + if linearTileMode: + tileMode = 1 + else: + tileMode = 0 + if depth > 1: + numDepth = depth + else: + numDepth = 1 + linesPerBlockHeight = (1 << int(blockHeightLog2)) * 8 + arrayOffset = 0 + for depthLevel in range(numDepth): + for arrayLevel in range(arrayCount): + surfaceSize = 0 + blockHeightShift = 0 + mipOffsets = [] + for mipLevel in range(mipCount): + width = max(1, width >> mipLevel) + height = max(1, height >> mipLevel) + depth = max(1, depth >> mipLevel) + size = DIV_ROUND_UP(width, blkWidth) * DIV_ROUND_UP(height, blkHeight) * bpp + if pow2_round_up(DIV_ROUND_UP(height, blkWidth)) < linesPerBlockHeight: + blockHeightShift += 1 + width__ = DIV_ROUND_UP(width, blkWidth) + height__ = DIV_ROUND_UP(height, blkHeight) + + # calculate the mip size instead + alignedData = bytearray(round_up(surfaceSize, dataAlignment) - surfaceSize) + surfaceSize += len(alignedData) + mipOffsets.append(surfaceSize) + + # get the first mip offset and current one and the total image size + msize = int((mipOffsets[0] + len(imageData) - mipOffsets[mipLevel]) / arrayCount) + + data_ = subArray(imageData, arrayOffset + mipOffsets[mipLevel], msize) + try: + pitch = round_up(width__ * bpp, 64) + surfaceSize += pitch * round_up(height__, max(1, blockHeight >> blockHeightShift) * 8) + result = deswizzle(width, height, depth, blkWidth, blkHeight, blkDepth, target, bpp, tileMode, max(0, blockHeightLog2 - blockHeightShift), data_) + + # the program creates a copy and uses that to remove unneeded data + # yeah, i'm not doing that + return result + except Exception as e: + raise e + print(f"Failed to swizzle texture! {e}") + return False + arrayOffset += len(imageData) / arrayCount + return False + +def compressImageData(format: str, width: int, height: int, depth: int, arrayCount: int, mipCount: int, imageData, blockHeightLog2, target=1, linearTileMode=False): + bpp = formatTable[format][0] + blkWidth = formatTable[format][1] + blkHeight = formatTable[format][2] + blkDepth = formatTable[format][3] + blockHeight = DIV_ROUND_UP(height, blkHeight) + pitch = 0 + dataAlignment = 512 + if linearTileMode: + tileMode = 1 + else: + tileMode = 0 + if depth > 1: + numDepth = depth + else: + numDepth = 1 + linesPerBlockHeight = (1 << int(blockHeightLog2)) * 8 + arrayOffset = 0 + for depthLevel in range(numDepth): + for arrayLevel in range(arrayCount): + surfaceSize = 0 + blockHeightShift = 0 + mipOffsets = [] + for mipLevel in range(mipCount): + width = max(1, width >> mipLevel) + height = max(1, height >> mipLevel) + depth = max(1, depth >> mipLevel) + size = DIV_ROUND_UP(width, blkWidth) * DIV_ROUND_UP(height, blkHeight) * bpp + if pow2_round_up(DIV_ROUND_UP(height, blkWidth)) < linesPerBlockHeight: + blockHeightShift += 1 + width__ = DIV_ROUND_UP(width, blkWidth) + height__ = DIV_ROUND_UP(height, blkHeight) + + # calculate the mip size instead + alignedData = bytearray(round_up(surfaceSize, dataAlignment) - surfaceSize) + surfaceSize += len(alignedData) + mipOffsets.append(surfaceSize) + + # get the first mip offset and current one and the total image size + msize = int((mipOffsets[0] + len(imageData) - mipOffsets[mipLevel]) / arrayCount) + + data_ = subArray(imageData, arrayOffset + mipOffsets[mipLevel], msize) + try: + pitch = round_up(width__ * bpp, 64) + surfaceSize += pitch * round_up(height__, max(1, blockHeight >> blockHeightShift) * 8) + result = swizzle(width, height, depth, blkWidth, blkHeight, blkDepth, target, bpp, tileMode, max(0, blockHeightLog2 - blockHeightShift), data_) + + # the program creates a copy and uses that to remove unneeded data + # yeah, i'm not doing that + return result + except Exception as e: + raise e + print(f"Failed to swizzle texture! {e}") + return False + arrayOffset += len(imageData) / arrayCount + return False \ No newline at end of file