October 4, 20241 yr 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!
May 24May 24 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