mirror of
https://github.com/sal063/AC6_recomp
synced 2026-05-23 23:05:45 -04:00
1006 lines
38 KiB
Python
1006 lines
38 KiB
Python
#!/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("<I", DDS_MAGIC) + header + b"".join(mip_payloads_bgra)
|
|
|
|
|
|
def rgba_to_bgra(rgba: bytes) -> 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(
|
|
"<BBBHHBHHHHBB",
|
|
0,
|
|
0,
|
|
2,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
32,
|
|
0x28,
|
|
)
|
|
path.write_bytes(header + rgba_to_bgra(rgba))
|
|
|
|
|
|
def write_tga_gray(path: Path, width: int, height: int, gray: bytes) -> None:
|
|
header = struct.pack(
|
|
"<BBBHHBHHHHBB",
|
|
0,
|
|
0,
|
|
3,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
8,
|
|
0x20,
|
|
)
|
|
path.write_bytes(header + gray)
|
|
|
|
|
|
def write_png_rgba(path: Path, width: int, height: int, rgba: bytes) -> 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())
|