Skip to content
View in the app

A better way to browse. Learn more.

ResHax

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
Help us keep the site running.

Onee Chanbara Origins file format for extracting 3D models

Featured Replies

I want to know what's the engine for this game, the file format is .cat and I don't know how to extract models from it since I can't find any anywhere.
I'd appreciate a fast answer, thanks! 
i6Mb515.pngimage.thumb.png.34f24d836ecc4ff4a98069e8d664404d.png

  • 1 year later...
  • Localization

I only tested it in the "Model" directory. It won't work on other .cat files in other directories, because this tool is only for 3D models.
If anyone wants to extract other files from this game, please write to me.

import argparse
import math
import queue
import re
import struct
import threading
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Optional


try:
    import tkinter as tk
    from tkinter import filedialog, messagebox, ttk
except Exception:
    tk = None
    filedialog = None
    messagebox = None
    ttk = None


LogFn = Callable[[str], None]
SUPPORTED_EXTENSIONS = {".cat", ".tmd", ".tmd2", ".lds", ".ids"}
MODEL_EXTENSIONS = {".tmd", ".tmd2"}
TEXTURE_CONTAINER_EXTENSIONS = {".lds", ".ids"}


def clean_name(value: str, fallback: str = "item") -> str:
    value = value.strip().replace("\\", "_").replace("/", "_")
    value = re.sub(r"[^A-Za-z0-9_. -]+", "_", value)
    value = re.sub(r"\s+", "_", value)
    value = value.strip("._ ")
    return value or fallback


def cstring(data: bytes, offset: int, encoding: str = "utf-8") -> str:
    if offset < 0 or offset >= len(data):
        return ""
    end = data.find(b"\0", offset)
    if end < 0:
        end = min(len(data), offset + 256)
    return data[offset:end].decode(encoding, errors="replace")


class BinaryView:
    def __init__(self, data: bytes, endian: str = "<") -> None:
        self.data = data
        self.endian = endian

    def require(self, offset: int, size: int, label: str = "read") -> None:
        if offset < 0 or offset + size > len(self.data):
            raise ValueError(
                f"{label} outside buffer: off=0x{offset:X}, size=0x{size:X}, "
                f"buffer=0x{len(self.data):X}"
            )

    def u8(self, offset: int) -> int:
        self.require(offset, 1)
        return self.data[offset]

    def u16(self, offset: int) -> int:
        self.require(offset, 2)
        return struct.unpack_from(self.endian + "H", self.data, offset)[0]

    def i16(self, offset: int) -> int:
        self.require(offset, 2)
        return struct.unpack_from(self.endian + "h", self.data, offset)[0]

    def u32(self, offset: int) -> int:
        self.require(offset, 4)
        return struct.unpack_from(self.endian + "I", self.data, offset)[0]

    def i32(self, offset: int) -> int:
        self.require(offset, 4)
        return struct.unpack_from(self.endian + "i", self.data, offset)[0]

    def u64(self, offset: int) -> int:
        self.require(offset, 8)
        return struct.unpack_from(self.endian + "Q", self.data, offset)[0]

    def f32(self, offset: int) -> float:
        self.require(offset, 4)
        return struct.unpack_from(self.endian + "f", self.data, offset)[0]

    def floats(self, offset: int, count: int) -> tuple[float, ...]:
        self.require(offset, 4 * count)
        return struct.unpack_from(self.endian + ("f" * count), self.data, offset)


@dataclass
class CatEntry:
    index: int
    offset: int
    size: int
    data: bytes
    kind: str = "unknown"
    name: str = ""
    dds_indices: list[int] = field(default_factory=list)


@dataclass
class DdsTexture:
    global_index: int
    local_index: int
    source_entry: int
    data: bytes
    width: int = 0
    height: int = 0
    mip_count: int = 0
    fourcc: str = ""
    dxgi_format: Optional[int] = None
    file_name: str = ""
    png_file_name: str = ""


@dataclass
class TmdTextureRef:
    hash_value: int
    local_index: int
    width: int
    height: int
    fmt: int


@dataclass
class MatTextureRef:
    texture_hash: int
    texture_index: int
    unk1: int
    unk2: int
    slot: int


@dataclass
class Material:
    index: int
    hash_value: int
    shader_id: str
    texture_start: int
    texture_count: int
    param_start: int
    param_count: int
    unk: int
    mat_textures: list[MatTextureRef] = field(default_factory=list)


@dataclass
class Submesh:
    index: int
    triangle_count: int
    triangle_start: int
    material_index: int = -1
    model_index: int = -1


@dataclass
class ModelInfo:
    index: int
    name: str
    entry_start: int
    entry_count: int
    hash_value: int
    bbox: tuple[float, ...]
    submesh_indices: list[int] = field(default_factory=list)


@dataclass
class TmdModel:
    entry_index: int
    name: str
    raw_data: bytes
    flags: int
    version: int
    bbox: tuple[float, ...]
    vertices: list[dict]
    triangles: list[tuple[int, int, int]]
    textures: list[TmdTextureRef]
    mat_textures: list[MatTextureRef]
    materials: list[Material]
    submeshes: list[Submesh]
    models: list[ModelInfo]


def read_cat_entries(data: bytes) -> list[CatEntry]:
    br = BinaryView(data)
    flags = br.u32(0)
    subcat_count = br.u32(4)
    if not (flags & 1):
        raise ValueError("Unsupported CAT layout: top-level flags do not include 1")

    offsets_pos = 12
    sizes_pos = offsets_pos + subcat_count * 4
    cat_offsets = [br.u32(offsets_pos + i * 4) for i in range(subcat_count)]
    cat_sizes = [br.u32(sizes_pos + i * 4) for i in range(subcat_count)]

    entries: list[CatEntry] = []
    entry_index = 0
    for sub_offset, sub_size in zip(cat_offsets, cat_sizes):
        br.require(sub_offset, sub_size, "subcat")
        sub_data = data[sub_offset : sub_offset + sub_size]
        if len(sub_data) < 20:
            continue
        sub = BinaryView(sub_data)
        item_count = sub.u32(4)
        item_offsets_pos = 20
        item_sizes_pos = item_offsets_pos + item_count * 4
        if item_sizes_pos + item_count * 4 > len(sub_data):
            continue
        for i in range(item_count):
            item_offset = sub.u32(item_offsets_pos + i * 4)
            item_size = sub.u32(item_sizes_pos + i * 4)
            if item_offset + item_size > len(sub_data):
                continue
            abs_offset = sub_offset + item_offset
            chunk = data[abs_offset : abs_offset + item_size]
            entries.append(CatEntry(entry_index, abs_offset, item_size, chunk))
            entry_index += 1
    return entries


def classify_entries(entries: list[CatEntry]) -> None:
    for entry in entries:
        data = entry.data
        if data.startswith(b"tmd0") or data.startswith(b"0dmt"):
            entry.kind = "tmd"
        elif looks_like_lds(data):
            entry.kind = "lds"
        elif looks_like_texture_map(data):
            entry.kind = "texture_map"
        elif looks_like_name_block(data):
            entry.kind = "names"
        else:
            entry.kind = "unknown"

        if entry.kind == "texture_map":
            entry.dds_indices = parse_texture_map(data)


def looks_like_lds(data: bytes) -> bool:
    if len(data) < 16:
        return False
    br = BinaryView(data)
    try:
        data_start = br.u32(0)
        count = br.u32(4)
        file_size = br.u32(8)
    except ValueError:
        return False
    if count == 0 or count > 4096:
        return False
    offsets_table_end = 12 + count * 4
    if offsets_table_end > len(data):
        return False
    if file_size not in (0, len(data)) and file_size > len(data) + 0x100:
        return False
    if data_start != offsets_table_end:
        return False
    offsets = [br.u32(12 + i * 4) for i in range(count)]
    if offsets[0] != 0:
        return False
    if any(offsets[i] > offsets[i + 1] for i in range(len(offsets) - 1)):
        return False
    if data_start + offsets[-1] >= len(data):
        return False
    return b"DDS " in data[data_start : min(len(data), data_start + offsets[-1] + 1024)]


def looks_like_texture_map(data: bytes) -> bool:
    if len(data) < 8 or len(data) % 4:
        return False
    br = BinaryView(data)
    try:
        count = br.u32(0)
    except ValueError:
        return False
    if count == 0 or count > 128:
        return False
    if len(data) != (count + 1) * 4:
        return False
    values = [br.u32(4 + i * 4) for i in range(count)]
    return all(v < 100000 for v in values)


def looks_like_name_block(data: bytes) -> bool:
    if len(data) < 24:
        return False
    strings = re.findall(rb"[\x20-\x7E]{4,}", data[:512])
    return bool(strings) and b"DDS " not in data[:512]


def parse_texture_map(data: bytes) -> list[int]:
    br = BinaryView(data)
    count = br.u32(0)
    return [br.u32(4 + i * 4) for i in range(count)]


def parse_dds_info(data: bytes) -> tuple[int, int, int, str, Optional[int]]:
    if not data.startswith(b"DDS "):
        return 0, 0, 0, "", None
    br = BinaryView(data)
    try:
        height = br.u32(12)
        width = br.u32(16)
        mip_count = br.u32(28)
        fourcc = data[84:88].decode("ascii", errors="replace")
        dxgi = br.u32(128) if fourcc == "DX10" and len(data) >= 148 else None
    except ValueError:
        return 0, 0, 0, "", None
    return width, height, mip_count, fourcc, dxgi


def extract_lds_textures(entry: CatEntry, first_global_index: int) -> list[DdsTexture]:
    data = entry.data
    br = BinaryView(data)
    count = br.u32(4)
    pos = 12 + count * 4
    offsets = [br.u32(12 + i * 4) for i in range(count)]
    textures: list[DdsTexture] = []
    for i, rel_offset in enumerate(offsets):
        start = pos + rel_offset
        end = pos + offsets[i + 1] if i + 1 < count else len(data)
        chunk = data[start:end]
        dds_at = chunk.find(b"DDS ")
        if dds_at > 0:
            chunk = chunk[dds_at:]
        if not chunk.startswith(b"DDS "):
            continue
        width, height, mips, fourcc, dxgi = parse_dds_info(chunk)
        textures.append(
            DdsTexture(
                global_index=first_global_index + len(textures),
                local_index=i,
                source_entry=entry.index,
                data=chunk,
                width=width,
                height=height,
                mip_count=mips,
                fourcc=fourcc,
                dxgi_format=dxgi,
            )
        )
    return textures


def normalize_packed_vector(values: tuple[int, int, int, int]) -> tuple[float, float, float]:
    x = values[0] / 255.0 * 2.0 - 1.0
    y = values[1] / 255.0 * 2.0 - 1.0
    z = values[2] / 255.0 * 2.0 - 1.0
    length = math.sqrt(x * x + y * y + z * z)
    if length <= 0.000001:
        return 0.0, 0.0, 1.0
    return x / length, y / length, z / length


def parse_vertices(data: bytes, flags: int, offset: int, count: int) -> list[dict]:
    vertices: list[dict] = []
    pos = offset
    use_float_uv = bool(flags & 0x1000)

    for _ in range(count):
        vertex: dict = {}
        if flags & 0x2:
            vertex["position"] = struct.unpack_from("<3f", data, pos)
            pos += 12
        if flags & 0x400:
            vertex["bone_weights"] = struct.unpack_from("<4B", data, pos)
            pos += 4
            vertex["bone_ids"] = struct.unpack_from("<4B", data, pos)
            pos += 4
        if flags & 0x8000:
            vertex["bone_weights2"] = struct.unpack_from("<4B", data, pos)
            pos += 4
            vertex["bone_ids2"] = struct.unpack_from("<4B", data, pos)
            pos += 4
        if flags & 0x4:
            packed = struct.unpack_from("<4B", data, pos)
            vertex["normal"] = normalize_packed_vector(packed)
            pos += 4
        if flags & 0x8:
            packed = struct.unpack_from("<4B", data, pos)
            vertex["tangent"] = normalize_packed_vector(packed)
            pos += 4
            packed = struct.unpack_from("<4B", data, pos)
            vertex["binormal"] = normalize_packed_vector(packed)
            pos += 4
        if flags & 0x80:
            vertex["color"] = tuple(v / 255.0 for v in struct.unpack_from("<4B", data, pos))
            pos += 4
        if flags & 0x100:
            packed = struct.unpack_from("<4B", data, pos)
            vertex["normal2"] = normalize_packed_vector(packed)
            pos += 4
        if flags & 0x200:
            vertex["color2"] = tuple(v / 255.0 for v in struct.unpack_from("<4B", data, pos))
            pos += 4

        for uv_name in ("uv", "uv2", "uv3"):
            flag = {"uv": 0x10, "uv2": 0x20, "uv3": 0x40}[uv_name]
            if not (flags & flag):
                continue
            if use_float_uv:
                u, v = struct.unpack_from("<2f", data, pos)
                pos += 8
            else:
                u_i, v_i = struct.unpack_from("<2h", data, pos)
                u, v = u_i / 1024.0, v_i / 1024.0
                pos += 4
            vertex[uv_name] = (u, v)
        vertices.append(vertex)
    return vertices


def parse_tmd(entry: CatEntry) -> TmdModel:
    data = entry.data
    br = BinaryView(data)
    magic = data[:4]
    if magic != b"tmd0":
        raise ValueError("Only little-endian tmd0 files are supported for OBJ export")

    flag1 = br.u8(4)
    flag2 = br.u8(5)
    flags = br.u16(6)
    version = br.u16(10)
    bbox = br.floats(16, 6)

    models_offset = br.u64(40)
    submesh_entries_offset = br.u32(52)
    materials_offset = br.u32(56)
    shader_params_offset = br.u32(60)
    names_offset = br.u32(64)
    submesh_offset = br.u32(72)
    triangles_offset = br.u32(76)
    textures_offset = br.u64(80)
    mat_textures_offset = br.u32(88)
    vertices_offset = br.u32(92)
    model_count = br.u32(104)
    submesh_entries_count = br.u32(116)
    material_count = br.u32(120)
    shader_params_count = br.u32(124)
    submesh_count = br.u32(136)
    triangle_count = br.u32(140)
    texture_count = br.u64(144)
    mat_texture_count = br.u32(152)
    vertex_count = br.u32(156)

    if not (flag1 or flag2 or version or shader_params_offset or shader_params_count):
        raise ValueError("TMD header looks empty/corrupt")

    textures: list[TmdTextureRef] = []
    for i in range(texture_count):
        off = textures_offset + i * 12
        textures.append(
            TmdTextureRef(
                hash_value=br.u32(off),
                local_index=br.u16(off + 4),
                width=br.u16(off + 6),
                height=br.u16(off + 8),
                fmt=br.u16(off + 10),
            )
        )

    mat_textures: list[MatTextureRef] = []
    for i in range(mat_texture_count):
        off = mat_textures_offset + i * 12
        mat_textures.append(
            MatTextureRef(
                texture_hash=br.u32(off),
                texture_index=br.i16(off + 4),
                unk1=br.i16(off + 6),
                unk2=br.i16(off + 8),
                slot=br.i16(off + 10) >> 8,
            )
        )

    materials: list[Material] = []
    for i in range(material_count):
        off = materials_offset + i * 20
        shader = data[off + 4 : off + 8].decode("ascii", errors="replace").rstrip("\0")
        texture_start = br.u16(off + 8)
        texture_ref_count = br.u16(off + 10)
        param_start = br.u16(off + 12)
        param_count = br.u16(off + 14)
        material = Material(
            index=i,
            hash_value=br.u32(off),
            shader_id=shader,
            texture_start=texture_start,
            texture_count=texture_ref_count,
            param_start=param_start,
            param_count=param_count,
            unk=br.i32(off + 16),
            mat_textures=mat_textures[texture_start : texture_start + texture_ref_count],
        )
        materials.append(material)

    vertices = parse_vertices(data, flags, vertices_offset, vertex_count)

    triangle_index_size = 4 if flags & 0x800 else 2
    triangles: list[tuple[int, int, int]] = []
    tri_pos = triangles_offset
    for _ in range(triangle_count):
        if triangle_index_size == 4:
            tri = struct.unpack_from("<3I", data, tri_pos)
            tri_pos += 12
        else:
            tri = struct.unpack_from("<3H", data, tri_pos)
            tri_pos += 6
        triangles.append((int(tri[0]), int(tri[1]), int(tri[2])))

    entries: list[tuple[int, int]] = []
    for i in range(submesh_entries_count):
        off = submesh_entries_offset + i * 2
        entries.append((br.u8(off), br.u8(off + 1)))

    submeshes: list[Submesh] = []
    for i in range(submesh_count):
        off = submesh_offset + i * 8
        submeshes.append(
            Submesh(
                index=i,
                triangle_count=br.u32(off),
                triangle_start=br.u32(off + 4),
            )
        )

    models: list[ModelInfo] = []
    model_size = 44
    for i in range(model_count):
        off = models_offset + i * model_size
        model_bbox = br.floats(off, 6)
        entry_count = br.u16(off + 24)
        entry_start = br.u32(off + 28)
        name_offset = br.i32(off + 32)
        hash_value = br.u32(off + 36)
        name = ""
        if names_offset and name_offset != -1:
            name = cstring(data, names_offset + name_offset)
        if not name:
            name = f"model_{i:02d}_{hash_value:08X}"
        models.append(
            ModelInfo(
                index=i,
                name=clean_name(name, f"model_{i:02d}"),
                entry_start=entry_start,
                entry_count=entry_count,
                hash_value=hash_value,
                bbox=model_bbox,
            )
        )

    for model in models:
        material_index = -1
        for entry_type_index, entry_type in entries[
            model.entry_start : model.entry_start + model.entry_count
        ]:
            if entry_type == 96:
                material_index = entry_type_index
            elif entry_type == 48 and entry_type_index < len(submeshes):
                submesh = submeshes[entry_type_index]
                submesh.material_index = material_index
                submesh.model_index = model.index
                model.submesh_indices.append(entry_type_index)

    base_name = f"entry_{entry.index:03d}_tmd0"
    return TmdModel(
        entry_index=entry.index,
        name=base_name,
        raw_data=data,
        flags=flags,
        version=version,
        bbox=bbox,
        vertices=vertices,
        triangles=triangles,
        textures=textures,
        mat_textures=mat_textures,
        materials=materials,
        submeshes=submeshes,
        models=models,
    )


def choose_textures_for_tmd(
    entries: list[CatEntry],
    tmd_entry_index: int,
    tmd: TmdModel,
    all_textures: list[DdsTexture],
    entry_to_textures: dict[int, list[DdsTexture]],
) -> list[Optional[DdsTexture]]:
    needed = len(tmd.textures)
    if needed == 0:
        return []

    by_global = {tex.global_index: tex for tex in all_textures}
    by_entry = {entry.index: i for i, entry in enumerate(entries)}
    pos = by_entry.get(tmd_entry_index, -1)

    if pos >= 0 and pos + 1 < len(entries) and entries[pos + 1].kind == "texture_map":
        mapped = [by_global.get(i) for i in entries[pos + 1].dds_indices[:needed]]
        if any(mapped):
            return mapped

    if pos >= 0 and pos - 1 >= 0 and entries[pos - 1].kind == "texture_map":
        mapped = [by_global.get(i) for i in entries[pos - 1].dds_indices[:needed]]
        if any(mapped):
            return mapped

    for neighbor in (pos + 1, pos - 1):
        if 0 <= neighbor < len(entries) and entries[neighbor].kind == "lds":
            group = entry_to_textures.get(entries[neighbor].index, [])
            if len(group) >= needed:
                return [group[i] for i in range(needed)]

    if len(all_textures) >= needed:
        return [all_textures[ref.local_index] if ref.local_index < len(all_textures) else None for ref in tmd.textures]

    result: list[Optional[DdsTexture]] = []
    for ref in tmd.textures:
        result.append(all_textures[ref.local_index] if ref.local_index < len(all_textures) else None)
    return result


def write_obj_and_mtl(
    tmd: TmdModel,
    output_dir: Path,
    texture_refs: list[Optional[DdsTexture]],
    texture_rel_dir: str,
) -> tuple[Path, Path]:
    obj_path = output_dir / f"{tmd.name}.obj"
    mtl_path = output_dir / f"{tmd.name}.mtl"

    material_names: dict[int, str] = {}
    with mtl_path.open("w", encoding="utf-8", newline="\n") as mtl:
        mtl.write(f"# Materials for {tmd.name}\n")
        for material in tmd.materials:
            mat_name = f"mat_{material.index:03d}_{clean_name(material.shader_id, 'shader')}_{material.hash_value:08X}"
            material_names[material.index] = mat_name
            mtl.write(f"\nnewmtl {mat_name}\n")
            mtl.write("Ka 0.800000 0.800000 0.800000\n")
            mtl.write("Kd 0.800000 0.800000 0.800000\n")
            mtl.write("Ks 0.000000 0.000000 0.000000\n")
            mtl.write("d 1.000000\n")

            selected = select_material_texture(material, texture_refs)
            if selected and selected.file_name:
                texture_name = selected.png_file_name or selected.file_name
                mtl.write(f"map_Kd {texture_rel_dir}/{texture_name}\n")
            for mat_tex in material.mat_textures:
                mtl.write(
                    f"# slot={mat_tex.slot} local_texture={mat_tex.texture_index} "
                    f"hash={mat_tex.texture_hash:08X}\n"
                )

    with obj_path.open("w", encoding="utf-8", newline="\n") as obj:
        obj.write(f"# Exported from CAT/TMD entry {tmd.entry_index}\n")
        obj.write(f"mtllib {mtl_path.name}\n")
        obj.write(f"# flags=0x{tmd.flags:04X} version=0x{tmd.version:X}\n\n")

        for vertex in tmd.vertices:
            x, y, z = vertex.get("position", (0.0, 0.0, 0.0))
            obj.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")

        has_uv = any("uv" in vertex for vertex in tmd.vertices)
        if has_uv:
            for vertex in tmd.vertices:
                u, v = vertex.get("uv", (0.0, 0.0))
                obj.write(f"vt {u:.9g} {1.0 - v:.9g}\n")

        has_normals = any("normal" in vertex for vertex in tmd.vertices)
        if has_normals:
            for vertex in tmd.vertices:
                nx, ny, nz = vertex.get("normal", (0.0, 0.0, 1.0))
                obj.write(f"vn {nx:.9g} {ny:.9g} {nz:.9g}\n")

        obj.write("\n")
        for model in tmd.models:
            obj.write(f"o {clean_name(model.name, f'model_{model.index:02d}')}\n")
            for submesh_index in model.submesh_indices:
                if submesh_index >= len(tmd.submeshes):
                    continue
                submesh = tmd.submeshes[submesh_index]
                obj.write(f"g model_{model.index:02d}_submesh_{submesh.index:03d}\n")
                if submesh.material_index in material_names:
                    obj.write(f"usemtl {material_names[submesh.material_index]}\n")
                start = submesh.triangle_start
                end = start + submesh.triangle_count
                for a, b, c in tmd.triangles[start:end]:
                    obj.write("f ")
                    obj.write(" ".join(format_obj_index(i, has_uv, has_normals) for i in (a, b, c)))
                    obj.write("\n")
                obj.write("\n")

    return obj_path, mtl_path


def select_material_texture(
    material: Material, texture_refs: list[Optional[DdsTexture]]
) -> Optional[DdsTexture]:
    valid_refs = [
        ref
        for ref in material.mat_textures
        if ref.texture_index >= 0 and ref.texture_index < len(texture_refs) and texture_refs[ref.texture_index]
    ]
    if not valid_refs:
        return None

    for preferred_slot in (0, 1):
        for ref in valid_refs:
            if ref.slot == preferred_slot:
                return texture_refs[ref.texture_index]
    return texture_refs[valid_refs[0].texture_index]


def format_obj_index(index: int, has_uv: bool, has_normals: bool) -> str:
    obj_index = index + 1
    if has_uv and has_normals:
        return f"{obj_index}/{obj_index}/{obj_index}"
    if has_uv:
        return f"{obj_index}/{obj_index}"
    if has_normals:
        return f"{obj_index}//{obj_index}"
    return str(obj_index)


def write_textures(
    textures: list[DdsTexture],
    texture_dir: Path,
    cat_stem: str,
    convert_png: bool = False,
    log: Optional[LogFn] = None,
) -> None:
    texture_dir.mkdir(parents=True, exist_ok=True)
    used_names: set[str] = set()
    for tex in textures:
        fourcc = clean_name(tex.fourcc or "DDS", "DDS")
        dxgi = f"_dxgi{tex.dxgi_format}" if tex.dxgi_format is not None else ""
        base = (
            f"{cat_stem}_tex_{tex.global_index:03d}_entry{tex.source_entry:03d}_"
            f"{tex.width}x{tex.height}_{fourcc}{dxgi}.dds"
        )
        name = base
        n = 1
        while name.lower() in used_names:
            name = base.replace(".dds", f"_{n}.dds")
            n += 1
        used_names.add(name.lower())
        tex.file_name = name
        dds_path = texture_dir / name
        dds_path.write_bytes(tex.data)
        if convert_png:
            try:
                tex.png_file_name = write_png_copy(dds_path, texture_dir)
            except Exception as exc:
                tex.png_file_name = ""
                if log:
                    log(f"  PNG conversion failed for {name}: {exc}")


def write_png_copy(dds_path: Path, texture_dir: Path) -> str:
    try:
        from PIL import Image
    except Exception as exc:
        raise RuntimeError("Pillow is not installed, PNG conversion is unavailable") from exc

    png_name = dds_path.with_suffix(".png").name
    png_path = texture_dir / png_name
    with Image.open(dds_path) as image:
        image.convert("RGBA").save(png_path)
    return png_name


def write_raw_tmd(tmd: TmdModel, raw_dir: Path) -> Path:
    raw_dir.mkdir(parents=True, exist_ok=True)
    path = raw_dir / f"{tmd.name}.tmd"
    path.write_bytes(tmd.raw_data)
    return path


def extract_lds_file(
    lds_path: Path,
    output_root: Path,
    export_dds: bool = True,
    convert_png: bool = True,
    log: Optional[LogFn] = None,
) -> dict[str, int]:
    def say(message: str) -> None:
        if log:
            log(message)

    data = lds_path.read_bytes()
    entry = CatEntry(index=0, offset=0, size=len(data), data=data, kind="lds")
    if not looks_like_lds(data):
        raise ValueError("This file does not look like an LDS texture container")

    out_dir = output_root / clean_name(lds_path.stem, "lds")
    texture_dir = out_dir / "textures"
    out_dir.mkdir(parents=True, exist_ok=True)

    say(f"\n{lds_path.name}")
    textures = extract_lds_textures(entry, 0)
    say(f"  LDS textures: {len(textures)} DDS")

    png_count = 0
    if export_dds and textures:
        write_textures(
            textures,
            texture_dir,
            clean_name(lds_path.stem, "lds"),
            convert_png=convert_png,
            log=say,
        )
        say(f"  DDS saved: {len(textures)}")
        png_count = sum(1 for tex in textures if tex.png_file_name)
        if convert_png:
            say(f"  PNG saved: {png_count}")

    return {
        "entries": 1,
        "textures": len(textures),
        "png": png_count,
        "tmd": 0,
        "obj": 0,
        "errors": 0,
    }


def load_matching_lds_textures(
    lds_path: Path,
    texture_dir: Path,
    output_stem: str,
    export_dds: bool,
    convert_png: bool,
    log: Optional[LogFn],
) -> list[DdsTexture]:
    if not lds_path.exists():
        return []
    data = lds_path.read_bytes()
    if not looks_like_lds(data):
        return []
    entry = CatEntry(index=0, offset=0, size=len(data), data=data, kind="lds")
    textures = extract_lds_textures(entry, 0)
    if export_dds and textures:
        write_textures(textures, texture_dir, output_stem, convert_png=convert_png, log=log)
    return textures


def choose_textures_for_standalone_tmd(
    tmd: TmdModel, textures: list[DdsTexture]
) -> list[Optional[DdsTexture]]:
    result: list[Optional[DdsTexture]] = []
    for ref in tmd.textures:
        result.append(textures[ref.local_index] if ref.local_index < len(textures) else None)
    return result


def extract_tmd_file(
    tmd_path: Path,
    output_root: Path,
    export_dds: bool = True,
    export_obj: bool = True,
    export_raw_tmd: bool = False,
    convert_png: bool = True,
    matching_lds: Optional[Path] = None,
    log: Optional[LogFn] = None,
) -> dict[str, int]:
    def say(message: str) -> None:
        if log:
            log(message)

    data = tmd_path.read_bytes()
    entry = CatEntry(index=0, offset=0, size=len(data), data=data, kind="tmd")
    out_dir = output_root / clean_name(tmd_path.stem, "tmd")
    texture_dir = out_dir / "textures"
    raw_dir = out_dir / "raw_tmd"
    out_dir.mkdir(parents=True, exist_ok=True)

    say(f"\n{tmd_path.name}")
    tmd = parse_tmd(entry)
    tmd.name = clean_name(tmd_path.stem, "model")
    say(
        f"  TMD2: vertices={len(tmd.vertices)}, triangles={len(tmd.triangles)}, "
        f"submeshes={len(tmd.submeshes)}, textures={len(tmd.textures)}"
    )

    textures: list[DdsTexture] = []
    if matching_lds:
        say(f"  matching LDS: {matching_lds.name}")
        textures = load_matching_lds_textures(
            matching_lds,
            texture_dir,
            clean_name(tmd_path.stem, "tmd"),
            export_dds=export_dds,
            convert_png=convert_png,
            log=say,
        )
        say(f"  LDS textures available: {len(textures)}")

    if export_raw_tmd:
        write_raw_tmd(tmd, raw_dir)

    obj_count = 0
    if export_obj:
        texture_refs = choose_textures_for_standalone_tmd(tmd, textures)
        write_obj_and_mtl(tmd, out_dir, texture_refs, "textures")
        obj_count = 1
        say("  OBJ saved: 1")

    png_count = sum(1 for tex in textures if tex.png_file_name)
    return {
        "entries": 1,
        "textures": len(textures),
        "png": png_count,
        "tmd": 1,
        "obj": obj_count,
        "errors": 0,
    }


def extract_cat_file(
    cat_path: Path,
    output_root: Path,
    export_dds: bool = True,
    export_obj: bool = True,
    export_raw_tmd: bool = False,
    convert_png: bool = True,
    log: Optional[LogFn] = None,
) -> dict[str, int]:
    def say(message: str) -> None:
        if log:
            log(message)

    data = cat_path.read_bytes()
    entries = read_cat_entries(data)
    classify_entries(entries)

    out_dir = output_root / clean_name(cat_path.stem, "cat")
    texture_dir = out_dir / "textures"
    raw_dir = out_dir / "raw_tmd"
    out_dir.mkdir(parents=True, exist_ok=True)

    all_textures: list[DdsTexture] = []
    entry_to_textures: dict[int, list[DdsTexture]] = {}
    tmds: list[TmdModel] = []
    errors = 0

    say(f"\n{cat_path.name}")
    say(f"  entries: {len(entries)}")

    for entry in entries:
        if entry.kind == "lds":
            textures = extract_lds_textures(entry, len(all_textures))
            entry_to_textures[entry.index] = textures
            all_textures.extend(textures)
            say(f"  LDS entry {entry.index}: {len(textures)} DDS")

    if export_dds and all_textures:
        write_textures(
            all_textures,
            texture_dir,
            clean_name(cat_path.stem, "cat"),
            convert_png=convert_png,
            log=say,
        )
        say(f"  DDS saved: {len(all_textures)}")
        png_count = sum(1 for tex in all_textures if tex.png_file_name)
        if convert_png:
            say(f"  PNG saved: {png_count}")
    else:
        png_count = 0

    for entry in entries:
        if entry.kind != "tmd":
            continue
        try:
            tmd = parse_tmd(entry)
            tmd.name = f"{clean_name(cat_path.stem, 'cat')}_model_{entry.index:03d}"
            tmds.append(tmd)
            say(
                f"  TMD entry {entry.index}: vertices={len(tmd.vertices)}, "
                f"triangles={len(tmd.triangles)}, submeshes={len(tmd.submeshes)}"
            )
        except Exception as exc:
            errors += 1
            say(f"  TMD entry {entry.index}: parse error: {exc}")

    obj_count = 0
    raw_count = 0
    for tmd in tmds:
        if export_raw_tmd:
            write_raw_tmd(tmd, raw_dir)
            raw_count += 1
        if export_obj:
            texture_refs = choose_textures_for_tmd(
                entries, tmd.entry_index, tmd, all_textures, entry_to_textures
            )
            write_obj_and_mtl(tmd, out_dir, texture_refs, "textures")
            obj_count += 1

    say(f"  OBJ saved: {obj_count}")
    if export_raw_tmd:
        say(f"  raw TMD saved: {raw_count}")
    if errors:
        say(f"  warnings/errors: {errors}")

    return {
        "entries": len(entries),
        "textures": len(all_textures),
        "png": png_count,
        "tmd": len(tmds),
        "obj": obj_count,
        "errors": errors,
    }


def discover_input_files(paths: Iterable[Path]) -> list[Path]:
    result: list[Path] = []
    for path in paths:
        path = path.expanduser().resolve()
        if path.is_dir():
            for ext in sorted(SUPPORTED_EXTENSIONS):
                result.extend(sorted(path.glob(f"*{ext}")))
        elif path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS:
            result.append(path)
    seen: set[Path] = set()
    unique: list[Path] = []
    for path in result:
        if path not in seen:
            seen.add(path)
            unique.append(path)
    return unique


def discover_cat_files(paths: Iterable[Path]) -> list[Path]:
    return discover_input_files(paths)


def run_batch(
    input_files: list[Path],
    output_dir: Path,
    export_dds: bool,
    export_obj: bool,
    export_raw_tmd: bool,
    convert_png: bool = True,
    log: Optional[LogFn] = None,
) -> dict[str, int]:
    totals = {"files": 0, "entries": 0, "textures": 0, "png": 0, "tmd": 0, "obj": 0, "errors": 0}
    output_dir.mkdir(parents=True, exist_ok=True)
    lds_by_stem = {
        path.stem.lower(): path
        for path in input_files
        if path.suffix.lower() in TEXTURE_CONTAINER_EXTENSIONS
    }
    for input_path in input_files:
        try:
            suffix = input_path.suffix.lower()
            if suffix == ".cat":
                stats = extract_cat_file(
                    input_path,
                    output_dir,
                    export_dds=export_dds,
                    export_obj=export_obj,
                    export_raw_tmd=export_raw_tmd,
                    convert_png=convert_png,
                    log=log,
                )
            elif suffix in TEXTURE_CONTAINER_EXTENSIONS:
                stats = extract_lds_file(
                    input_path,
                    output_dir,
                    export_dds=export_dds,
                    convert_png=convert_png,
                    log=log,
                )
            elif suffix in MODEL_EXTENSIONS:
                matching_lds = lds_by_stem.get(input_path.stem.lower())
                if matching_lds is None:
                    for candidate_ext in TEXTURE_CONTAINER_EXTENSIONS:
                        candidate = input_path.with_suffix(candidate_ext)
                        if candidate.exists():
                            matching_lds = candidate
                            break
                stats = extract_tmd_file(
                    input_path,
                    output_dir,
                    export_dds=export_dds,
                    export_obj=export_obj,
                    export_raw_tmd=export_raw_tmd,
                    convert_png=convert_png,
                    matching_lds=matching_lds,
                    log=log,
                )
            else:
                continue
            totals["files"] += 1
            for key in ("entries", "textures", "png", "tmd", "obj", "errors"):
                totals[key] += stats.get(key, 0)
        except Exception as exc:
            totals["errors"] += 1
            if log:
                log(f"\n{input_path.name}: fatal error: {exc}")
                log(traceback.format_exc())
    return totals


class ExtractorApp:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.root.title("CAT TMD2 / DDS / OBJ extractor")
        self.root.geometry("900x620")
        self.queue: queue.Queue[tuple[str, object]] = queue.Queue()
        self.files: list[Path] = []
        self.worker: Optional[threading.Thread] = None

        default_output = Path.cwd() / "_extracted"

        self.output_var = tk.StringVar(value=str(default_output))
        self.extract_dds_var = tk.BooleanVar(value=True)
        self.export_obj_var = tk.BooleanVar(value=True)
        self.convert_png_var = tk.BooleanVar(value=True)
        self.raw_tmd_var = tk.BooleanVar(value=False)

        self.build_ui()
        self.root.after(100, self.process_queue)

    def build_ui(self) -> None:
        self.root.columnconfigure(0, weight=1)
        self.root.rowconfigure(1, weight=1)

        top = ttk.Frame(self.root, padding=12)
        top.grid(row=0, column=0, sticky="ew")
        top.columnconfigure(0, weight=1)

        buttons = ttk.Frame(top)
        buttons.grid(row=0, column=0, sticky="w")
        ttk.Button(buttons, text="Add files", command=self.add_files).grid(row=0, column=0, padx=(0, 6))
        ttk.Button(buttons, text="Add folder", command=self.add_folder).grid(row=0, column=1, padx=(0, 6))
        ttk.Button(buttons, text="Remove selected", command=self.remove_selected).grid(row=0, column=2, padx=(0, 6))
        ttk.Button(buttons, text="Clear", command=self.clear_files).grid(row=0, column=3, padx=(0, 6))

        output = ttk.Frame(top)
        output.grid(row=1, column=0, sticky="ew", pady=(10, 0))
        output.columnconfigure(1, weight=1)
        ttk.Label(output, text="Output folder").grid(row=0, column=0, sticky="w", padx=(0, 8))
        ttk.Entry(output, textvariable=self.output_var).grid(row=0, column=1, sticky="ew")
        ttk.Button(output, text="Browse", command=self.choose_output).grid(row=0, column=2, padx=(8, 0))

        options = ttk.Frame(top)
        options.grid(row=2, column=0, sticky="w", pady=(10, 0))
        ttk.Checkbutton(options, text="Extract DDS", variable=self.extract_dds_var).grid(row=0, column=0, padx=(0, 16))
        ttk.Checkbutton(options, text="Export OBJ/MTL", variable=self.export_obj_var).grid(row=0, column=1, padx=(0, 16))
        ttk.Checkbutton(options, text="Convert textures to PNG", variable=self.convert_png_var).grid(row=0, column=2, padx=(0, 16))
        ttk.Checkbutton(options, text="Save raw TMD", variable=self.raw_tmd_var).grid(row=0, column=3, padx=(0, 16))

        body = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        body.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))

        files_frame = ttk.Frame(body)
        files_frame.rowconfigure(0, weight=1)
        files_frame.columnconfigure(0, weight=1)
        self.files_list = tk.Listbox(files_frame, selectmode=tk.EXTENDED)
        self.files_list.grid(row=0, column=0, sticky="nsew")
        files_scroll = ttk.Scrollbar(files_frame, orient=tk.VERTICAL, command=self.files_list.yview)
        files_scroll.grid(row=0, column=1, sticky="ns")
        self.files_list.configure(yscrollcommand=files_scroll.set)
        body.add(files_frame, weight=1)

        log_frame = ttk.Frame(body)
        log_frame.rowconfigure(0, weight=1)
        log_frame.columnconfigure(0, weight=1)
        self.log_text = tk.Text(log_frame, wrap="word", height=20)
        self.log_text.grid(row=0, column=0, sticky="nsew")
        log_scroll = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=self.log_text.yview)
        log_scroll.grid(row=0, column=1, sticky="ns")
        self.log_text.configure(yscrollcommand=log_scroll.set)
        body.add(log_frame, weight=2)

        bottom = ttk.Frame(self.root, padding=(12, 0, 12, 12))
        bottom.grid(row=2, column=0, sticky="ew")
        bottom.columnconfigure(0, weight=1)
        self.progress = ttk.Progressbar(bottom, mode="indeterminate")
        self.progress.grid(row=0, column=0, sticky="ew", padx=(0, 8))
        self.start_button = ttk.Button(bottom, text="Start", command=self.start)
        self.start_button.grid(row=0, column=1)

    def add_files(self) -> None:
        paths = filedialog.askopenfilenames(
            title="Choose files",
            filetypes=[
                ("Supported files", "*.cat *.tmd2 *.tmd *.lds"),
                ("CAT files", "*.cat"),
                ("TMD2 model files", "*.tmd2 *.tmd"),
                ("LDS texture files", "*.lds"),
                ("All files", "*.*"),
            ],
        )
        self.add_paths([Path(p) for p in paths])

    def add_folder(self) -> None:
        folder = filedialog.askdirectory(title="Choose a folder with CAT/TMD2/LDS files")
        if folder:
            self.add_paths(discover_input_files([Path(folder)]))

    def add_paths(self, paths: Iterable[Path]) -> None:
        existing = {p.resolve() for p in self.files}
        for path in paths:
            path = path.resolve()
            if path.suffix.lower() in SUPPORTED_EXTENSIONS and path not in existing:
                self.files.append(path)
                existing.add(path)
        self.refresh_files()

    def refresh_files(self) -> None:
        self.files_list.delete(0, tk.END)
        for path in self.files:
            self.files_list.insert(tk.END, str(path))

    def remove_selected(self) -> None:
        selected = set(self.files_list.curselection())
        self.files = [path for i, path in enumerate(self.files) if i not in selected]
        self.refresh_files()

    def clear_files(self) -> None:
        self.files.clear()
        self.refresh_files()

    def choose_output(self) -> None:
        folder = filedialog.askdirectory(title="Choose output folder")
        if folder:
            self.output_var.set(folder)

    def log(self, message: str) -> None:
        self.queue.put(("log", message))

    def start(self) -> None:
        if self.worker and self.worker.is_alive():
            return
        if not self.files:
            messagebox.showwarning("No files", "Add at least one .cat, .tmd2, or .lds file.")
            return
        output_dir = Path(self.output_var.get()).expanduser()
        self.log_text.delete("1.0", tk.END)
        self.start_button.configure(state=tk.DISABLED)
        self.progress.start(10)

        args = (
            list(self.files),
            output_dir,
            self.extract_dds_var.get(),
            self.export_obj_var.get(),
            self.raw_tmd_var.get(),
            self.convert_png_var.get(),
        )
        self.worker = threading.Thread(target=self.run_worker, args=args, daemon=True)
        self.worker.start()

    def run_worker(
        self,
        files: list[Path],
        output_dir: Path,
        export_dds: bool,
        export_obj: bool,
        export_raw_tmd: bool,
        convert_png: bool,
    ) -> None:
        try:
            totals = run_batch(
                files,
                output_dir,
                export_dds,
                export_obj,
                export_raw_tmd,
                convert_png=convert_png,
                log=self.log,
            )
            self.queue.put(("done", totals))
        except Exception as exc:
            self.queue.put(("error", f"{exc}\n{traceback.format_exc()}"))

    def process_queue(self) -> None:
        try:
            while True:
                kind, payload = self.queue.get_nowait()
                if kind == "log":
                    self.log_text.insert(tk.END, str(payload) + "\n")
                    self.log_text.see(tk.END)
                elif kind == "done":
                    self.progress.stop()
                    self.start_button.configure(state=tk.NORMAL)
                    totals = payload
                    summary = (
                        f"\nDone. Files: {totals['files']}, DDS: {totals['textures']}, PNG: {totals['png']}, "
                        f"TMD: {totals['tmd']}, OBJ: {totals['obj']}, errors: {totals['errors']}"
                    )
                    self.log_text.insert(tk.END, summary + "\n")
                    self.log_text.see(tk.END)
                    messagebox.showinfo("Done", summary.strip())
                elif kind == "error":
                    self.progress.stop()
                    self.start_button.configure(state=tk.NORMAL)
                    self.log_text.insert(tk.END, "\nERROR:\n" + str(payload) + "\n")
                    self.log_text.see(tk.END)
                    messagebox.showerror("Error", str(payload))
        except queue.Empty:
            pass
        self.root.after(100, self.process_queue)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Extract Tamsoft CAT/TMD2/LDS files to DDS, PNG, and OBJ.")
    parser.add_argument("paths", nargs="*", type=Path, help="CAT/TMD2/LDS files or folders containing them")
    parser.add_argument("-o", "--output", type=Path, default=Path("_extracted"), help="Output folder")
    parser.add_argument("--no-gui", action="store_true", help="Run without GUI")
    parser.add_argument("--no-dds", action="store_true", help="Do not write DDS files")
    parser.add_argument("--no-obj", action="store_true", help="Do not write OBJ/MTL files")
    parser.add_argument("--no-png", action="store_true", help="Do not convert DDS textures to PNG")
    parser.add_argument("--raw-tmd", action="store_true", help="Also write raw TMD files")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if args.no_gui or args.paths:
        input_files = discover_input_files(args.paths or [Path.cwd()])
        if not input_files:
            print("No .cat, .tmd2, .tmd, or .lds files found.")
            return 1
        totals = run_batch(
            input_files,
            args.output,
            export_dds=not args.no_dds,
            export_obj=not args.no_obj,
            export_raw_tmd=args.raw_tmd,
            convert_png=not args.no_png,
            log=print,
        )
        print(
            f"\nDone. files={totals['files']} dds={totals['textures']} png={totals['png']} "
            f"tmd={totals['tmd']} obj={totals['obj']} errors={totals['errors']}"
        )
        return 0 if totals["errors"] == 0 else 2

    if tk is None:
        print("Tkinter is not available. Run with --no-gui instead.")
        return 1
    root = tk.Tk()
    ExtractorApp(root)
    root.mainloop()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())



 

Create an account or sign in to comment

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.