#!/usr/bin/env python3 from __future__ import annotations import argparse import json import struct from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageFilter DDS_MAGIC = 0x20534444 DDS_PF_ALPHAPIXELS = 0x00000001 DDS_PF_RGB = 0x00000040 DDS_HEADER_FLAGS_TEXTURE = 0x00001007 DDS_HEADER_FLAGS_PITCH = 0x00000008 DDS_HEADER_FLAGS_MIPMAP = 0x00020000 DDS_CAPS_TEXTURE = 0x00001000 DDS_CAPS_COMPLEX = 0x00000008 DDS_CAPS_MIPMAP = 0x00400000 def be16(blob: bytes, offset: int) -> int: return struct.unpack_from(">H", blob, offset)[0] def be32(blob: bytes, offset: int) -> int: return struct.unpack_from(">I", blob, offset)[0] def align_up(value: int, alignment: int) -> int: return ((value + alignment - 1) // alignment) * alignment def safe_ascii(blob: bytes) -> str: return "".join(chr(b) if 32 <= b < 127 else "." for b in blob) @dataclass class ExportPlan: layout: str format_name: str visible_width: int visible_height: int storage_width: int storage_height: int mip_count: int payload_offset: int payload_size: int mip_sizes: list[int] notes: list[str] def bc_mip0_storage_size(width: int, height: int, bytes_per_block: int) -> int: width_blocks = (width + 3) // 4 height_blocks = (height + 3) // 4 return align_up(width_blocks, 32) * align_up(height_blocks, 32) * bytes_per_block def looks_like_gray_argb(payload: bytes) -> bool: if len(payload) < 4 or len(payload) % 4 != 0: return False for i in range(0, len(payload), 4): if payload[i] != 0xFF: return False if payload[i + 1] != payload[i + 2] or payload[i + 2] != payload[i + 3]: return False return True def classify_ntxr(blob: bytes) -> tuple[ExportPlan | None, str | None]: if len(blob) < 0x60 or blob[:4] != b"NTXR": return None, "not_ntxr" variant_a = be16(blob, 0x20) variant_b = be16(blob, 0x22) width = be16(blob, 0x24) height = be16(blob, 0x26) declared_payload_size = be32(blob, 0x18) if width == 0 or height == 0 or declared_payload_size == 0: return None, "invalid_dimensions" explicit_mip_sizes = [] if variant_a > 1 and len(blob) >= 0x40 + (variant_a * 4): explicit_mip_sizes = [be32(blob, 0x40 + (i * 4)) for i in range(variant_a)] if ( variant_a > 1 and explicit_mip_sizes and all(size > 0 for size in explicit_mip_sizes) and sum(explicit_mip_sizes) == declared_payload_size and len(blob) >= 0x70 + declared_payload_size and variant_b == 2 ): first_mip_size = explicit_mip_sizes[0] if first_mip_size == width * height * 4: format_name = "rgba8" elif first_mip_size == width * height: format_name = "r8" else: return None, f"unsupported_explicit_mip_size_{first_mip_size}" return ( ExportPlan( layout=f"{format_name}_mipped_0x70", format_name=format_name, visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=variant_a, payload_offset=0x70, payload_size=declared_payload_size, mip_sizes=explicit_mip_sizes, notes=["explicit mip sizes at 0x40"], ), None, ) if ( variant_a > 1 and variant_b in (1, 2) and len(explicit_mip_sizes) >= 2 and len(blob) >= 0x1000 + declared_payload_size ): bc3_mip0_size = bc_mip0_storage_size(width, height, 16) if explicit_mip_sizes[0] == bc3_mip0_size and explicit_mip_sizes[0] + explicit_mip_sizes[1] == declared_payload_size: return ( ExportPlan( layout="bc3_swap16_mipped_0x1000", format_name="bc3_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=variant_a, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=explicit_mip_sizes, notes=["packed BC3 mip chain", "16-bit endian-swapped blocks", "preview exports mip 0"], ), None, ) if variant_a == 1 and variant_b == 19 and declared_payload_size == width * height * 4: # Some variant_b=19 textures store a short header only, while others # have a larger metadata/padding block and the texel payload starts at # 0x1000. Prefer the larger offset when present - it fixes the torn # aircraft/body atlases and still produces sane results for the simpler # UI textures. if len(blob) >= 0x1000 + declared_payload_size: payload_offset = 0x1000 layout = "rgba8_single_0x1000" notes = ["variant_b=19", "ARGB payload at 0x1000"] elif len(blob) >= 0x60 + declared_payload_size: payload_offset = 0x60 layout = "rgba8_single_0x60" notes = ["variant_b=19", "ARGB payload at 0x60"] else: return None, "truncated_rgba_single" return ( ExportPlan( layout=layout, format_name="rgba8", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=payload_offset, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=notes, ), None, ) if variant_b == 0 and len(blob) >= 0x1000 + declared_payload_size: bc1_mip0_size = bc_mip0_storage_size(width, height, 8) bc3_mip0_size = bc_mip0_storage_size(width, height, 16) if variant_a == 1 and declared_payload_size == bc1_mip0_size * 6: return ( ExportPlan( layout="bc1_swap16_cube6_0x1000", format_name="bc1_swap16_cube6", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=[bc1_mip0_size] * 6, notes=["six BC1 faces", "16-bit endian-swapped blocks", "preview exports contact sheet"], ), None, ) if variant_a == 1 and declared_payload_size == bc1_mip0_size: return ( ExportPlan( layout="bc1_swap16_single_0x1000", format_name="bc1_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["BC1 texture", "16-bit endian-swapped blocks"], ), None, ) if variant_a == 1 and declared_payload_size == bc3_mip0_size: return ( ExportPlan( layout="bc3_swap16_single_0x1000", format_name="bc3_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["BC3 texture", "16-bit endian-swapped blocks"], ), None, ) if ( variant_a > 1 and len(explicit_mip_sizes) >= 2 and explicit_mip_sizes[0] == bc1_mip0_size and explicit_mip_sizes[0] + explicit_mip_sizes[1] == declared_payload_size ): return ( ExportPlan( layout="bc1_swap16_mipped_0x1000", format_name="bc1_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=variant_a, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=explicit_mip_sizes, notes=["packed BC1 mip chain", "16-bit endian-swapped blocks", "preview exports mip 0"], ), None, ) if variant_b == 0: bc1_mip0_size = bc_mip0_storage_size(width, height, 8) bc3_mip0_size = bc_mip0_storage_size(width, height, 16) payload_offset = None if len(blob) >= 0x1000 + declared_payload_size: payload_offset = 0x1000 elif len(blob) >= 0x70 + declared_payload_size: payload_offset = 0x70 elif len(blob) >= 0x60 + declared_payload_size: payload_offset = 0x60 if payload_offset is not None: if variant_a == 1 and declared_payload_size == bc1_mip0_size * 6: return ( ExportPlan( layout=f"bc1_swap16_cube6_{payload_offset:#x}", format_name="bc1_swap16_cube6", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=payload_offset, payload_size=declared_payload_size, mip_sizes=[bc1_mip0_size] * 6, notes=["six BC1 faces", "16-bit endian-swapped blocks", "preview exports contact sheet"], ), None, ) if variant_a == 1 and declared_payload_size == bc1_mip0_size: return ( ExportPlan( layout=f"bc1_swap16_single_{payload_offset:#x}", format_name="bc1_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=payload_offset, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["BC1 texture", "16-bit endian-swapped blocks"], ), None, ) if variant_a == 1 and declared_payload_size == bc3_mip0_size: return ( ExportPlan( layout=f"bc3_swap16_single_{payload_offset:#x}", format_name="bc3_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=payload_offset, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["BC3 texture", "16-bit endian-swapped blocks"], ), None, ) if ( variant_a > 1 and len(explicit_mip_sizes) >= 2 and explicit_mip_sizes[0] == bc1_mip0_size and explicit_mip_sizes[0] + explicit_mip_sizes[1] == declared_payload_size ): return ( ExportPlan( layout=f"bc1_swap16_mipped_{payload_offset:#x}", format_name="bc1_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=variant_a, payload_offset=payload_offset, payload_size=declared_payload_size, mip_sizes=explicit_mip_sizes, notes=["packed BC1 mip chain", "16-bit endian-swapped blocks", "preview exports mip 0"], ), None, ) if variant_a == 1 and variant_b == 1 and len(blob) >= 0x1000 + declared_payload_size: bc3_mip0_size = bc_mip0_storage_size(width, height, 16) if declared_payload_size == bc3_mip0_size: return ( ExportPlan( layout="bc3_swap16_single_0x1000_b1", format_name="bc3_swap16", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["variant_b=1", "BC3 texture", "16-bit endian-swapped blocks"], ), None, ) if variant_a == 1 and variant_b == 20 and declared_payload_size == width * height * 4: payload_offset = 0x60 if len(blob) >= payload_offset + declared_payload_size: payload = blob[payload_offset : payload_offset + declared_payload_size] if looks_like_gray_argb(payload): return ( ExportPlan( layout="gray_argb_linear_0x60", format_name="gray_argb_linear", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=payload_offset, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["variant_b=20", "linear grayscale stored as opaque ARGB"], ), None, ) if variant_a == 1 and variant_b == 2: storage_width = align_up(width, 128) storage_height = align_up(height, 128) if declared_payload_size == storage_width * storage_height and len(blob) >= 0x1000 + declared_payload_size: return ( ExportPlan( layout="r8_single_aligned_0x1000", format_name="r8", visible_width=width, visible_height=height, storage_width=storage_width, storage_height=storage_height, mip_count=1, payload_offset=0x1000, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["128-aligned backing rectangle"], ), None, ) if variant_a == 1 and variant_b == 1 and declared_payload_size == width * height: if len(blob) >= 0x9000 + declared_payload_size: return ( ExportPlan( layout="r8_single_0x9000", format_name="r8", visible_width=width, visible_height=height, storage_width=width, storage_height=height, mip_count=1, payload_offset=0x9000, payload_size=declared_payload_size, mip_sizes=[declared_payload_size], notes=["0x9000 payload offset"], ), None, ) return None, f"unsupported_variant_a{variant_a}_b{variant_b}" def build_legacy_bgra_dds(width: int, height: int, mip_payloads_bgra: list[bytes]) -> bytes: header_flags = DDS_HEADER_FLAGS_TEXTURE | DDS_HEADER_FLAGS_PITCH caps = DDS_CAPS_TEXTURE if len(mip_payloads_bgra) > 1: header_flags |= DDS_HEADER_FLAGS_MIPMAP caps |= DDS_CAPS_COMPLEX | DDS_CAPS_MIPMAP pitch = width * 4 header_values = [ 124, header_flags, height, width, pitch, 0, max(len(mip_payloads_bgra), 1), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, DDS_PF_RGB | DDS_PF_ALPHAPIXELS, 0, 32, 0x00FF0000, 0x0000FF00, 0x000000FF, 0xFF000000, caps, 0, 0, 0, 0, ] assert len(header_values) == 31 header = struct.pack("<31I", *header_values) return struct.pack(" bytes: bgra = bytearray(len(rgba)) for i in range(0, len(rgba), 4): r = rgba[i + 0] g = rgba[i + 1] b = rgba[i + 2] a = rgba[i + 3] bgra[i + 0] = b bgra[i + 1] = g bgra[i + 2] = r bgra[i + 3] = a return bytes(bgra) def argb_to_rgba(argb: bytes) -> bytes: rgba = bytearray(len(argb)) for i in range(0, len(argb), 4): a = argb[i + 0] r = argb[i + 1] g = argb[i + 2] b = argb[i + 3] rgba[i + 0] = r rgba[i + 1] = g rgba[i + 2] = b rgba[i + 3] = a return bytes(rgba) def gray_to_rgba(gray: bytes) -> bytes: rgba = bytearray(len(gray) * 4) out = 0 for value in gray: rgba[out + 0] = value rgba[out + 1] = value rgba[out + 2] = value rgba[out + 3] = 255 out += 4 return bytes(rgba) def swap16(data: bytes) -> bytes: out = bytearray(len(data)) for i in range(0, len(data), 2): out[i : i + 2] = data[i : i + 2][::-1] return bytes(out) def rgb565(color: int) -> tuple[int, int, int]: return ( ((color >> 11) & 31) * 255 // 31, ((color >> 5) & 63) * 255 // 63, (color & 31) * 255 // 31, ) def decode_bc4_block(block: bytes) -> list[int]: alpha0 = block[0] alpha1 = block[1] bits = int.from_bytes(block[2:8], "little") values = [alpha0, alpha1] if alpha0 > alpha1: values += [ (6 * alpha0 + 1 * alpha1) // 7, (5 * alpha0 + 2 * alpha1) // 7, (4 * alpha0 + 3 * alpha1) // 7, (3 * alpha0 + 4 * alpha1) // 7, (2 * alpha0 + 5 * alpha1) // 7, (1 * alpha0 + 6 * alpha1) // 7, ] else: values += [ (4 * alpha0 + 1 * alpha1) // 5, (3 * alpha0 + 2 * alpha1) // 5, (2 * alpha0 + 3 * alpha1) // 5, (1 * alpha0 + 4 * alpha1) // 5, 0, 255, ] return [values[(bits >> (3 * i)) & 7] for i in range(16)] def decode_bc3_to_rgba(data: bytes, width: int, height: int) -> bytes: blocks_w = (width + 3) // 4 blocks_h = (height + 3) // 4 out = bytearray(width * height * 4) cursor = 0 for block_y in range(blocks_h): for block_x in range(blocks_w): alpha = decode_bc4_block(data[cursor : cursor + 8]) color0 = int.from_bytes(data[cursor + 8 : cursor + 10], "little") color1 = int.from_bytes(data[cursor + 10 : cursor + 12], "little") color_bits = int.from_bytes(data[cursor + 12 : cursor + 16], "little") cursor += 16 r0, g0, b0 = rgb565(color0) r1, g1, b1 = rgb565(color1) colors = [(r0, g0, b0), (r1, g1, b1)] if color0 > color1: colors += [ ((2 * r0 + r1) // 3, (2 * g0 + g1) // 3, (2 * b0 + b1) // 3), ((r0 + 2 * r1) // 3, (g0 + 2 * g1) // 3, (b0 + 2 * b1) // 3), ] else: colors += [ ((r0 + r1) // 2, (g0 + g1) // 2, (b0 + b1) // 2), (0, 0, 0), ] for py in range(4): for px in range(4): x = block_x * 4 + px y = block_y * 4 + py if x >= width or y >= height: continue color_index = (color_bits >> (2 * (py * 4 + px))) & 3 r, g, b = colors[color_index] a = alpha[py * 4 + px] out_index = (y * width + x) * 4 out[out_index : out_index + 4] = bytes((r, g, b, a)) return bytes(out) def decode_bc1_to_rgba(data: bytes, width: int, height: int) -> bytes: blocks_w = (width + 3) // 4 blocks_h = (height + 3) // 4 out = bytearray(width * height * 4) cursor = 0 for block_y in range(blocks_h): for block_x in range(blocks_w): color0 = int.from_bytes(data[cursor : cursor + 2], "little") color1 = int.from_bytes(data[cursor + 2 : cursor + 4], "little") color_bits = int.from_bytes(data[cursor + 4 : cursor + 8], "little") cursor += 8 r0, g0, b0 = rgb565(color0) r1, g1, b1 = rgb565(color1) colors = [(r0, g0, b0, 255), (r1, g1, b1, 255)] if color0 > color1: colors += [ ((2 * r0 + r1) // 3, (2 * g0 + g1) // 3, (2 * b0 + b1) // 3, 255), ((r0 + 2 * r1) // 3, (g0 + 2 * g1) // 3, (b0 + 2 * b1) // 3, 255), ] else: colors += [ ((r0 + r1) // 2, (g0 + g1) // 2, (b0 + b1) // 2, 255), (0, 0, 0, 0), ] for py in range(4): for px in range(4): x = block_x * 4 + px y = block_y * 4 + py if x >= width or y >= height: continue color = colors[(color_bits >> (2 * (py * 4 + px))) & 3] out_index = (y * width + x) * 4 out[out_index : out_index + 4] = bytes(color) return bytes(out) def tiled_row(y: int, width: int, log2_bpp: int) -> int: macro = ((y // 32) * (width // 32)) << (log2_bpp + 7) micro = ((y & 6) << 2) << log2_bpp return macro + ((micro & ~0xF) << 1) + (micro & 0xF) + ((y & 8) << (3 + log2_bpp)) + ( (y & 1) << 4 ) def tiled_col(x: int, y: int, log2_bpp: int, base_offset: int) -> int: macro = (x // 32) << (log2_bpp + 7) micro = (x & 7) << log2_bpp offset = base_offset + (macro + ((micro & ~0xF) << 1) + (micro & 0xF)) return ( ((offset & ~0x1FF) << 3) + ((offset & 0x1C0) << 2) + (offset & 0x3F) + ((y & 16) << 7) + (((((y & 8) >> 2) + (x >> 3)) & 3) << 6) ) def untile_blocks(src: bytes, width: int, height: int, pitch: int, bytes_per_block: int) -> bytes: log2_bpp = (bytes_per_block // 4) + ((bytes_per_block // 2) >> (bytes_per_block // 4)) out = bytearray(width * height * bytes_per_block) for y in range(height): base = tiled_row(y, pitch, log2_bpp) row_off = y * width * bytes_per_block for x in range(width): off = tiled_col(x, y, log2_bpp, base) >> log2_bpp src_off = off * bytes_per_block dst_off = row_off + x * bytes_per_block out[dst_off:dst_off + bytes_per_block] = src[src_off:src_off + bytes_per_block] return bytes(out) def write_tga_rgba(path: Path, width: int, height: int, rgba: bytes) -> None: header = struct.pack( " None: header = struct.pack( " None: Image.frombytes("RGBA", (width, height), rgba).save(path) def write_png_gray(path: Path, width: int, height: int, gray: bytes) -> None: Image.frombytes("L", (width, height), gray).save(path) def write_png_r8_alpha(path: Path, width: int, height: int, gray: bytes) -> None: alpha = Image.frombytes("L", (width, height), gray) rgba = Image.new("RGBA", (width, height), (255, 255, 255, 0)) rgba.putalpha(alpha) rgba.save(path) def write_png_r8_preview(path: Path, width: int, height: int, gray: bytes) -> None: # These one-channel atlases are often glyph / mask data. A lightly smoothed # composited preview is easier to inspect than raw dithered luma. alpha = Image.frombytes("L", (width, height), gray).filter(ImageFilter.BoxBlur(0.5)) preview = Image.new("RGBA", (width, height), (20, 20, 24, 255)) fg = Image.new("RGBA", (width, height), (245, 245, 245, 255)) fg.putalpha(alpha) preview.alpha_composite(fg) preview.save(path) def extract_r8_visible(payload: bytes, storage_width: int, visible_width: int, visible_height: int) -> bytes: rows = [] for row in range(visible_height): start = row * storage_width rows.append(payload[start:start + visible_width]) return b"".join(rows) def export_ntxr(input_path: Path, output_root: Path, source_root: Path) -> dict: blob = input_path.read_bytes() plan, reason = classify_ntxr(blob) output_base = output_root / input_path.relative_to(source_root) entry = { "source": str(input_path.relative_to(source_root)).replace("\\", "/"), "size": len(blob), "header": { "variant_a": be16(blob, 0x20) if len(blob) >= 0x22 else None, "variant_b": be16(blob, 0x22) if len(blob) >= 0x24 else None, "width": be16(blob, 0x24) if len(blob) >= 0x26 else None, "height": be16(blob, 0x26) if len(blob) >= 0x28 else None, "declared_payload_size": be32(blob, 0x18) if len(blob) >= 0x1C else None, "tag_0x40": safe_ascii(blob[0x40:0x44]) if len(blob) >= 0x44 else None, "tag_0x50": safe_ascii(blob[0x50:0x54]) if len(blob) >= 0x54 else None, }, } if plan is None: stale_paths = ( output_base.with_suffix(".dds"), output_base.with_suffix(".tga"), output_base.with_suffix(".png"), output_base.with_suffix(".json"), output_base.with_name(output_base.stem + ".raw.png"), output_base.with_name(output_base.stem + ".alpha.png"), ) for stale_path in stale_paths: if stale_path.exists(): stale_path.unlink() for stale_face in output_base.parent.glob(output_base.stem + ".face*.png"): stale_face.unlink() entry["status"] = "skipped" entry["reason"] = reason return entry payload = blob[plan.payload_offset:plan.payload_offset + plan.payload_size] output_base.parent.mkdir(parents=True, exist_ok=True) preview_path = output_base.with_suffix(".tga") png_path = output_base.with_suffix(".png") dds_path = output_base.with_suffix(".dds") json_path = output_base.with_suffix(".json") raw_png_path = output_base.with_name(output_base.stem + ".raw.png") alpha_png_path = output_base.with_name(output_base.stem + ".alpha.png") dds_rgba_payloads: list[bytes] = [] preview_rgba: bytes | None = None preview_gray: bytes | None = None preview_width = plan.visible_width preview_height = plan.visible_height face_pngs: list[str] = [] if plan.format_name == "rgba8": cursor = 0 for mip_index, mip_size in enumerate(plan.mip_sizes): mip_blob = payload[cursor:cursor + mip_size] if len(mip_blob) != mip_size: entry["status"] = "skipped" entry["reason"] = "truncated_mip_payload" return entry untiled_mip_blob = untile_blocks( mip_blob, max(plan.visible_width >> mip_index, 1), max(plan.visible_height >> mip_index, 1), max(plan.storage_width >> mip_index, 1), 4, ) rgba_mip = argb_to_rgba(untiled_mip_blob) dds_rgba_payloads.append(rgba_mip) if mip_index == 0: preview_rgba = rgba_mip[: plan.visible_width * plan.visible_height * 4] cursor += mip_size assert preview_rgba is not None write_tga_rgba(preview_path, plan.visible_width, plan.visible_height, preview_rgba) write_png_rgba(png_path, plan.visible_width, plan.visible_height, preview_rgba) elif plan.format_name == "r8": cursor = 0 mip_width = plan.visible_width mip_height = plan.visible_height for mip_index, mip_size in enumerate(plan.mip_sizes): mip_blob = payload[cursor:cursor + mip_size] if len(mip_blob) != mip_size: entry["status"] = "skipped" entry["reason"] = "truncated_mip_payload" return entry storage_width = max(plan.storage_width >> mip_index, 1) untiled_gray = untile_blocks(mip_blob, mip_width, mip_height, storage_width, 1) expected_size = mip_width * mip_height visible_gray = untiled_gray[:expected_size] dds_rgba_payloads.append(gray_to_rgba(visible_gray)) if mip_index == 0: preview_gray = visible_gray cursor += mip_size mip_width = max(mip_width >> 1, 1) mip_height = max(mip_height >> 1, 1) assert preview_gray is not None write_tga_gray(preview_path, plan.visible_width, plan.visible_height, preview_gray) write_png_r8_preview(png_path, plan.visible_width, plan.visible_height, preview_gray) write_png_gray(raw_png_path, plan.visible_width, plan.visible_height, preview_gray) write_png_r8_alpha(alpha_png_path, plan.visible_width, plan.visible_height, preview_gray) elif plan.format_name == "bc3_swap16": width_blocks = (plan.visible_width + 3) // 4 height_blocks = (plan.visible_height + 3) // 4 pitch_blocks = align_up(width_blocks, 32) mip0_size = plan.mip_sizes[0] mip0_blob = payload[:mip0_size] untiled = untile_blocks(mip0_blob, width_blocks, height_blocks, pitch_blocks, 16) preview_rgba = decode_bc3_to_rgba(swap16(untiled), plan.visible_width, plan.visible_height) dds_rgba_payloads.append(preview_rgba) write_tga_rgba(preview_path, plan.visible_width, plan.visible_height, preview_rgba) write_png_rgba(png_path, plan.visible_width, plan.visible_height, preview_rgba) elif plan.format_name == "bc1_swap16": width_blocks = (plan.visible_width + 3) // 4 height_blocks = (plan.visible_height + 3) // 4 pitch_blocks = align_up(width_blocks, 32) mip0_size = plan.mip_sizes[0] mip0_blob = payload[:mip0_size] untiled = untile_blocks(mip0_blob, width_blocks, height_blocks, pitch_blocks, 8) preview_rgba = decode_bc1_to_rgba(swap16(untiled), plan.visible_width, plan.visible_height) dds_rgba_payloads.append(preview_rgba) write_tga_rgba(preview_path, plan.visible_width, plan.visible_height, preview_rgba) write_png_rgba(png_path, plan.visible_width, plan.visible_height, preview_rgba) elif plan.format_name == "bc1_swap16_cube6": width_blocks = (plan.visible_width + 3) // 4 height_blocks = (plan.visible_height + 3) // 4 pitch_blocks = align_up(width_blocks, 32) face_size = plan.mip_sizes[0] faces: list[bytes] = [] for face_index in range(6): face_blob = payload[face_index * face_size : (face_index + 1) * face_size] untiled = untile_blocks(face_blob, width_blocks, height_blocks, pitch_blocks, 8) face_rgba = decode_bc1_to_rgba(swap16(untiled), plan.visible_width, plan.visible_height) faces.append(face_rgba) face_path = output_base.with_name(output_base.stem + f".face{face_index}.png") write_png_rgba(face_path, plan.visible_width, plan.visible_height, face_rgba) face_pngs.append(str(face_path.relative_to(output_root)).replace("\\", "/")) dds_rgba_payloads.append(faces[0]) preview_width = plan.visible_width * 3 preview_height = plan.visible_height * 2 preview_image = Image.new("RGBA", (preview_width, preview_height), (0, 0, 0, 255)) for face_index, face_rgba in enumerate(faces): x = (face_index % 3) * plan.visible_width y = (face_index // 3) * plan.visible_height preview_image.paste(Image.frombytes("RGBA", (plan.visible_width, plan.visible_height), face_rgba), (x, y)) preview_rgba = preview_image.tobytes() write_tga_rgba(preview_path, preview_width, preview_height, preview_rgba) write_png_rgba(png_path, preview_width, preview_height, preview_rgba) elif plan.format_name == "gray_argb_linear": gray = payload[1::4] preview_gray = gray dds_rgba_payloads.append(gray_to_rgba(gray)) write_tga_gray(preview_path, plan.visible_width, plan.visible_height, preview_gray) write_png_r8_preview(png_path, plan.visible_width, plan.visible_height, preview_gray) write_png_gray(raw_png_path, plan.visible_width, plan.visible_height, preview_gray) write_png_r8_alpha(alpha_png_path, plan.visible_width, plan.visible_height, preview_gray) else: entry["status"] = "skipped" entry["reason"] = f"unhandled_format_{plan.format_name}" return entry dds_bytes = build_legacy_bgra_dds( width=plan.visible_width, height=plan.visible_height, mip_payloads_bgra=[rgba_to_bgra(mip) for mip in dds_rgba_payloads], ) dds_path.write_bytes(dds_bytes) json_path.write_text( json.dumps( { "source": entry["source"], "layout": plan.layout, "format": plan.format_name, "visible_width": plan.visible_width, "visible_height": plan.visible_height, "storage_width": plan.storage_width, "storage_height": plan.storage_height, "mip_count": plan.mip_count, "payload_offset": plan.payload_offset, "payload_size": plan.payload_size, "dds_encoding": "legacy_a8r8g8b8_view", "notes": plan.notes, "preview_png": png_path.name, "raw_png": raw_png_path.name if plan.format_name in ("r8", "gray_argb_linear") else None, "alpha_png": alpha_png_path.name if plan.format_name in ("r8", "gray_argb_linear") else None, "preview_width": preview_width, "preview_height": preview_height, "face_pngs": face_pngs if face_pngs else None, }, indent=2, ), encoding="utf-8", ) entry["status"] = "exported" entry["layout"] = plan.layout entry["format"] = plan.format_name entry["dds"] = str(dds_path.relative_to(output_root)).replace("\\", "/") entry["preview"] = str(preview_path.relative_to(output_root)).replace("\\", "/") entry["preview_png"] = str(png_path.relative_to(output_root)).replace("\\", "/") if plan.format_name in ("r8", "gray_argb_linear"): entry["raw_preview_png"] = str(raw_png_path.relative_to(output_root)).replace("\\", "/") entry["alpha_preview_png"] = str(alpha_png_path.relative_to(output_root)).replace("\\", "/") if face_pngs: entry["face_pngs"] = face_pngs entry["metadata"] = str(json_path.relative_to(output_root)).replace("\\", "/") entry["visible_width"] = plan.visible_width entry["visible_height"] = plan.visible_height entry["storage_width"] = plan.storage_width entry["storage_height"] = plan.storage_height entry["mip_count"] = plan.mip_count entry["notes"] = plan.notes return entry def main() -> int: parser = argparse.ArgumentParser(description="Export known AC6 NTXR textures to DDS and TGA.") parser.add_argument( "--input", type=Path, default=Path("out") / "ac6_runtime_fhm_typed", help="Root directory containing extracted .ntxr files", ) parser.add_argument( "--output", type=Path, default=Path("out") / "ac6_runtime_ntxr_exported", help="Output directory for exported textures", ) args = parser.parse_args() source_root = args.input.resolve() output_root = args.output.resolve() output_root.mkdir(parents=True, exist_ok=True) exported = [] skipped = [] for input_path in sorted(source_root.rglob("*.ntxr")): result = export_ntxr(input_path, output_root, source_root) if result["status"] == "exported": exported.append(result) else: skipped.append(result) manifest = { "input": str(source_root), "output": str(output_root), "exported_count": len(exported), "skipped_count": len(skipped), "exported": exported, "skipped": skipped, } (output_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") print( json.dumps( { "exported_count": len(exported), "skipped_count": len(skipped), "output": str(output_root), }, indent=2, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())