#!/usr/bin/env python3
"""Extract Skate 3 EB archives and render top-mip RX2 textures to PNG."""
from __future__ import annotations
import argparse
import html
import math
import shutil
import struct
import subprocess
from pathlib import Path
from PIL import Image
TEXTURE_FORMATS = {
0x52: ("DXT1", 8),
0x53: ("DXT3", 16),
0x54: ("DXT5", 16),
}
def safe_output_stem(path: Path, root: Path | None = None) -> str:
try:
label = path.relative_to(root) if root else path
except ValueError:
label = path
return "__".join(part.replace("/", "_").replace("\\", "_") for part in label.with_suffix("").parts)
def tiled_offset_2d_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_offset_2d_column(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 swap16(data: bytes) -> bytes:
output = bytearray(data)
for offset in range(0, len(output) - 1, 2):
output[offset], output[offset + 1] = output[offset + 1], output[offset]
return bytes(output)
def untile_blocks(data: bytes, blocks_w: int, blocks_h: int, bytes_per_block: int) -> bytes:
log2_bpp = 3 if bytes_per_block == 8 else 4
pitch_blocks = max(32, math.ceil(blocks_w / 32) * 32)
output = bytearray(blocks_w * blocks_h * bytes_per_block)
for y in range(blocks_h):
row_offset = tiled_offset_2d_row(y, pitch_blocks, log2_bpp)
for x in range(blocks_w):
input_offset = tiled_offset_2d_column(x, y, log2_bpp, row_offset)
input_offset = (input_offset >> log2_bpp) * bytes_per_block
output_offset = (y * blocks_w + x) * bytes_per_block
if input_offset + bytes_per_block <= len(data):
output[output_offset : output_offset + bytes_per_block] = data[
input_offset : input_offset + bytes_per_block
]
return swap16(output)
def make_dds(width: int, height: int, fourcc: str, pitch_or_size: int, payload: bytes) -> bytes:
header = bytearray(b"DDS ")
header += struct.pack(" tuple[int, int, int, int] | None:
if not data.startswith(b"\x89RW4xb2") or len(data) < 0x238:
return None
data_offset = int.from_bytes(data[0x44:0x48], "big")
if data_offset <= 0 or data_offset >= len(data):
data_offset = 0x238
fmt = data[0x167]
height = (int.from_bytes(data[0x168:0x16A], "big") + 1) * 8
width = (int.from_bytes(data[0x16A:0x16C], "big") + 1) & 0x1FFF
if width <= 0 or height <= 0 or width > 8192 or height > 8192:
return None
return fmt, width, height, data_offset
def render_rx2(
path: Path,
output_dir: Path,
root: Path | None = None,
layout: str = "tiled",
) -> Path | None:
output_path = output_dir / "images" / (safe_output_stem(path, root) + ".png")
if output_path.exists():
return output_path
data = path.read_bytes()
parsed = parse_rx2_header(data)
if not parsed:
return None
fmt, width, height, data_offset = parsed
format_info = TEXTURE_FORMATS.get(fmt)
if not format_info:
return None
fourcc, bytes_per_block = format_info
blocks_w = max(1, math.ceil(width / 4))
blocks_h = max(1, math.ceil(height / 4))
top_mip_size = blocks_w * blocks_h * bytes_per_block
payload = data[data_offset : data_offset + top_mip_size]
if len(payload) < top_mip_size:
return None
if layout == "linear":
decoded = swap16(payload)
elif layout == "tiled":
decoded = untile_blocks(payload, blocks_w, blocks_h, bytes_per_block)
else:
raise ValueError(f"Unknown RX2 layout: {layout}")
dds = make_dds(width, height, fourcc, top_mip_size, decoded)
output_path.parent.mkdir(parents=True, exist_ok=True)
Image.open(__import__("io").BytesIO(dds)).convert("RGBA").save(output_path)
return output_path
def write_texture_page(
rendered: list[tuple[Path, Path]],
output_dir: Path,
page_number: int,
page_count: int,
) -> str:
page_name = f"page-{page_number:03d}.html"
rows = [
"Skate 3 RX2 Textures: Page {page_number} / {page_count}
",
""
f"
{html.escape(source.name)}Skate 3 RX2 Textures
",
f"
{len(rendered)} textures rendered. Open one page at a time to avoid loading every PNG at once.
", "