blender import script
"""Blender importer for House of the Dead PC XMX and Xbox X3X/XAX assets.
Paste this file into Blender's Text Editor and press Run Script, or install it as
an add-on. It registers:
File > Import > HOD3 PC Sega XMDL/XBOX v6 (.xmx)
File > Import > HOD3 Xbox X3X/XAX (.x3x)
"""
from __future__ import annotations
bl_info = {
"name": "HOD3 PC/Xbox Model Importer",
"author": "mariokart64n",
"version": (2, 0, 0),
"blender": (3, 6, 0),
"location": "File > Import > HOD3 PC XMX / HOD3 Xbox X3X",
"description": "Import HOD3 PC XMX/XMDL v6 and Xbox X3X/XAX model/deformation packages",
"category": "Import-Export",
}
import argparse
import dataclasses
import json
import math
import os
import shutil
import struct
import sys
import tempfile
import time
import zipfile
from collections import Counter
from pathlib import Path
from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
try: # Blender is optional so the parser can be validated from normal Python.
import bpy # type: ignore
from bpy.props import BoolProperty, EnumProperty, FloatProperty, StringProperty # type: ignore
from bpy_extras.io_utils import ImportHelper # type: ignore
from mathutils import Matrix as _MU_Matrix, Vector as _MU_Vector # type: ignore
HAS_BLENDER = True
except Exception: # pragma: no cover - normal outside Blender.
bpy = None # type: ignore
ImportHelper = object # type: ignore
HAS_BLENDER = False
def BoolProperty(**kwargs): return None # type: ignore
def EnumProperty(**kwargs): return None # type: ignore
def FloatProperty(**kwargs): return None # type: ignore
def StringProperty(**kwargs): return None # type: ignore
# -----------------------------------------------------------------------------
# Format constants
# -----------------------------------------------------------------------------
MODEL_BASE = 0x10
FILE_HEADER_SIZE = 0x10
MODEL_HEADER_SIZE = 0x70
MATERIAL_SIZE = 0xE8
PRIMITIVE_SIZE = 0x48
TEXTURE_STAGE_SIZE = 0x14
TEXTURE_STAGE_COUNT = 4
TOPOLOGY_MASK = 0x003
CULL_MASK = 0x00C
SHADE_MASK = 0x030
LAYOUT_MASK = 0x380
INDEX_UINT8_BIT = 0x0800
# Direct3D-ish layout selected by primitive.flags & 0x380. The cached FVF and
# cached runtime stride are redundant and verified only as metadata.
LAYOUTS: Dict[int, Dict[str, Any]] = {
0x000: {"name": "P3_N3_UV2", "disk_stride": 32, "runtime_fvf": 0x0112, "runtime_stride": 32, "has_normal": True, "has_diffuse": False, "has_specular": False, "packed_normal": False, "uv_offset": 24},
0x080: {"name": "P3_N3_D4_S4_UV2", "disk_stride": 40, "runtime_fvf": 0x01D2, "runtime_stride": 40, "has_normal": True, "has_diffuse": True, "has_specular": True, "packed_normal": False, "uv_offset": 32},
0x100: {"name": "P3_D4_S4_UV2", "disk_stride": 28, "runtime_fvf": 0x01C2, "runtime_stride": 28, "has_normal": False, "has_diffuse": True, "has_specular": True, "packed_normal": False, "uv_offset": 20},
0x180: {"name": "P3_N111110_UV2", "disk_stride": 24, "runtime_fvf": 0x0112, "runtime_stride": 32, "has_normal": True, "has_diffuse": False, "has_specular": False, "packed_normal": True, "uv_offset": 16},
0x200: {"name": "P3_N111110_D4_S4_UV2", "disk_stride": 32, "runtime_fvf": 0x01D2, "runtime_stride": 40, "has_normal": True, "has_diffuse": True, "has_specular": True, "packed_normal": True, "uv_offset": 24},
}
# D3D fixed-function state values from decomp mapping. Names are included for
# metadata/readability but numeric values are preserved too.
SRC_BLEND_SELECTOR_TO_D3D = {0x0000: 1, 0x0080: 2, 0x0100: 5, 0x0180: 6, 0x0200: 9, 0x0280: 10}
DST_BLEND_SELECTOR_TO_D3D = {0x0000: 1, 0x0800: 2, 0x1000: 5, 0x1800: 6, 0x2000: 3, 0x2800: 4}
D3D_BLEND_NAMES = {
1: "ZERO", 2: "ONE", 3: "SRCCOLOR", 4: "INVSRCCOLOR", 5: "SRCALPHA",
6: "INVSRCALPHA", 9: "DESTCOLOR", 10: "INVDESTCOLOR",
}
COMBINER_PRESETS = {
0: (4, 1, 2, 1), 1: (2, 1, 2, 1), 2: (13, 1, 2, 1), 3: (14, 1, 2, 1),
4: (7, 1, 2, 1), 5: (8, 1, 2, 1), 6: (10, 1, 1, 2), 7: (3, 1, 2, 1),
8: (4, 1, 0, 1), 9: (4, 1, 4, 1), 10: (16, 1, 2, 1), 11: (5, 1, 2, 1),
12: (6, 1, 2, 1), 13: (25, 1, 2, 4), 14: (5, 1, 0, 1), 15: (6, 1, 0, 1),
}
# -----------------------------------------------------------------------------
# Parser data classes
# -----------------------------------------------------------------------------
class XmxError(ValueError):
"""Raised for structurally invalid XMX data."""
class Reader:
def __init__(self, data: bytes, source: str = "<memory>"):
self.data = data
self.source = source
self.size = len(data)
def require(self, off: int, size: int, what: str = "data") -> None:
if off < 0 or size < 0 or off + size > self.size:
raise XmxError(f"{what} out of range: 0x{off:X}+0x{size:X} > 0x{self.size:X}")
def u8(self, off: int) -> int:
self.require(off, 1)
return self.data[off]
def u16(self, off: int) -> int:
self.require(off, 2)
return struct.unpack_from("<H", self.data, off)[0]
def u32(self, off: int) -> int:
self.require(off, 4)
return struct.unpack_from("<I", self.data, off)[0]
def i32(self, off: int) -> int:
self.require(off, 4)
return struct.unpack_from("<i", self.data, off)[0]
def f32(self, off: int) -> float:
self.require(off, 4)
return struct.unpack_from("<f", self.data, off)[0]
def words(self, off: int, count: int) -> Tuple[int, ...]:
self.require(off, 4 * count)
return struct.unpack_from(f"<{count}I", self.data, off)
def cstr(self, off: int, max_len: int = 4096) -> str:
self.require(off, 1, "string")
end = self.data.find(b"\0", off, min(self.size, off + max_len))
if end < 0:
raise XmxError(f"unterminated string at 0x{off:X}")
return self.data[off:end].decode("cp1252", errors="replace")
@staticmethod
def rel(value: int) -> int:
return MODEL_BASE + value
def _bits_to_float(u: int) -> float:
return struct.unpack("<f", struct.pack("<I", u & 0xFFFFFFFF))[0]
def _argb_to_rgba_tuple(argb: int) -> Tuple[float, float, float, float]:
a = ((argb >> 24) & 0xFF) / 255.0
r = ((argb >> 16) & 0xFF) / 255.0
g = ((argb >> 8) & 0xFF) / 255.0
b = (argb & 0xFF) / 255.0
return (r, g, b, a)
def _d3dcolor_to_rgba_tuple(c: int) -> Tuple[float, float, float, float]:
# D3DCOLOR is ARGB in memory as uint32. Return Blender RGBA.
return _argb_to_rgba_tuple(c)
def _signed_bits(v: int, bits: int) -> int:
sign = 1 << (bits - 1)
return (v ^ sign) - sign
def decode_packed_normal(v: int) -> Tuple[float, float, float]:
x = _signed_bits(v & 0x7FF, 11) / 1023.0
y = _signed_bits((v >> 11) & 0x7FF, 11) / 1023.0
z = _signed_bits((v >> 22) & 0x3FF, 10) / 511.0
return (x, y, z)
def sanitize_float(v: float, fallback: float = 0.0) -> float:
return v if math.isfinite(v) else fallback
def sanitize_vec3(vec: Tuple[float, float, float], fallback: Tuple[float, float, float] = (0.0, 0.0, 1.0)) -> Tuple[float, float, float]:
return tuple(vec[i] if math.isfinite(vec[i]) else fallback[i] for i in range(3)) # type: ignore
def rot_x_up(v: Tuple[float, float, float]) -> Tuple[float, float, float]:
"""+90 deg rotation about X: (x, y, z) -> (x, -z, y).
Proper rotation (det +1, orthonormal): triangle winding and normal
orientation are preserved, so no index re-ordering and no normal
negation are needed. Linear with no translation, so the same map is
valid for positions, normals, bone head/tail and bounding-sphere
centers; radius scalars pass through untouched. This does NOT correct
LH(D3D)/RH(Blender) handedness -- that is a separate concern from this
axis rotation.
"""
return (v[0], -v[2], v[1])
def apply_world_row(v: Tuple[float, float, float], m: Sequence[Sequence[float]]) -> Tuple[float, float, float]:
"""Transform a point by a row-major, row-vector 4x4 matrix: world = [x y z 1] @ M.
The X3X embedded models are stored in the LOCAL space of their owning skeletal
node; multiplying by that node's SKEL bind-world matrix (matrices[0]) places the
model into world space so mesh and skeleton coincide.
"""
x, y, z = v[0], v[1], v[2]
return (x * m[0][0] + y * m[1][0] + z * m[2][0] + m[3][0],
x * m[0][1] + y * m[1][1] + z * m[2][1] + m[3][1],
x * m[0][2] + y * m[1][2] + z * m[2][2] + m[3][2])
def apply_rot_row(v: Tuple[float, float, float], m: Sequence[Sequence[float]]) -> Tuple[float, float, float]:
"""Rotate a direction (normal) by the 3x3 part of a row-major matrix (no translation).
The SKEL bind matrices are rigid (orthonormal, det +1), so the rotation part
transforms normals correctly without an inverse-transpose.
"""
x, y, z = v[0], v[1], v[2]
return (x * m[0][0] + y * m[1][0] + z * m[2][0],
x * m[0][1] + y * m[1][1] + z * m[2][1],
x * m[0][2] + y * m[1][2] + z * m[2][2])
def mat16_to_rows(vals: Sequence[float]) -> list[list[float]]:
"""Row-major 16-float SKEL matrix -> 4x4 nested list."""
return [list(vals[0:4]), list(vals[4:8]), list(vals[8:12]), list(vals[12:16])]
@dataclasses.dataclass
class XmxVertex:
co: Tuple[float, float, float]
normal: Optional[Tuple[float, float, float]]
uv: Tuple[float, float]
diffuse: Optional[Tuple[float, float, float, float]]
specular: Optional[Tuple[float, float, float, float]]
@dataclasses.dataclass
class XmxTextureStage:
index: int
offset: int
name_rel: int
flags: int
authoring_scalar_raw: int
mip_lod_bias_raw: int
runtime_texture: int
name: Optional[str]
@property
def authoring_scalar(self) -> float:
return _bits_to_float(self.authoring_scalar_raw)
@property
def mip_lod_bias(self) -> float:
return _bits_to_float(self.mip_lod_bias_raw)
@property
def has_named_texture(self) -> bool:
return bool(self.name) and ((self.flags & 0x0000C000) == 0x00004000)
@property
def address_u(self) -> str:
return "mirror" if self.flags & 0x4 else ("clamp" if self.flags & 0x1 else "wrap")
@property
def address_v(self) -> str:
return "mirror" if self.flags & 0x8 else ("clamp" if self.flags & 0x2 else "wrap")
@property
def color_combiner_id(self) -> int:
return (self.flags >> 5) & 0xF
@property
def alpha_combiner_id(self) -> int:
return (self.flags >> 9) & 0xF
def to_dict(self) -> Dict[str, Any]:
return {
"index": self.index,
"offset": self.offset,
"name_rel": self.name_rel,
"flags": self.flags,
"flags_hex": f"0x{self.flags:08X}",
"name": self.name,
"has_named_texture": self.has_named_texture,
"address_u": self.address_u,
"address_v": self.address_v,
"generated_coordinate_path": bool(self.flags & 0x0010),
"color_combiner_id": self.color_combiner_id,
"alpha_combiner_id": self.alpha_combiner_id,
"color_combiner": COMBINER_PRESETS.get(self.color_combiner_id),
"alpha_combiner": COMBINER_PRESETS.get(self.alpha_combiner_id),
"placeholder_generated_texture": bool(self.flags & 0x2000),
"runtime_ownership_bit": bool(self.flags & 0x10000),
"point_filter_branch": bool(self.flags & 0x40000),
"linear_filter_branch": bool(self.flags & 0x80000),
"authoring_scalar": self.authoring_scalar,
"mip_lod_bias": self.mip_lod_bias,
"runtime_texture": self.runtime_texture,
}
@dataclasses.dataclass
class XmxPrimitive:
material_index: int
primitive_index: int
offset: int
words: Tuple[int, ...]
flags: int
index_flags: int
vertex_stream_selector: int
index_stream_selector: int
vertex_count: int
strip_correction_count: int
vertex_rel: int
vertex_offset: int
layout_bits: int
layout_name: str
disk_stride: int
cached_fvf: int
cached_runtime_stride: int
stale_usage_word: int
index_rel: int
index_offset: int
index_count: int
index_width: int
topology_kind: str
indices: Tuple[int, ...]
vertices: Optional[List[XmxVertex]] = None
@property
def cull_selector(self) -> int:
return self.flags & CULL_MASK
@property
def shade_selector(self) -> int:
return self.flags & SHADE_MASK
@property
def shade_mode(self) -> str:
return "flat" if self.shade_selector == 0x10 else "gouraud"
@property
def uses_8bit_indices(self) -> bool:
return self.index_width == 1
def triangles(self) -> List[Tuple[int, int, int]]:
return indices_to_triangles(self.indices, self.topology_kind, self.index_width)
def to_dict(self, include_words: bool = True) -> Dict[str, Any]:
d = {
"material_index": self.material_index,
"primitive_index": self.primitive_index,
"offset": self.offset,
"flags": self.flags,
"flags_hex": f"0x{self.flags:08X}",
"index_flags": self.index_flags,
"index_flags_hex": f"0x{self.index_flags:04X}",
"vertex_stream_selector": self.vertex_stream_selector,
"index_stream_selector": self.index_stream_selector,
"vertex_count": self.vertex_count,
"strip_correction_count": self.strip_correction_count,
"vertex_rel": self.vertex_rel,
"vertex_offset": self.vertex_offset,
"layout_bits": self.layout_bits,
"layout_bits_hex": f"0x{self.layout_bits:03X}",
"layout_name": self.layout_name,
"disk_stride": self.disk_stride,
"cached_fvf": self.cached_fvf,
"cached_fvf_hex": f"0x{self.cached_fvf:04X}",
"cached_runtime_stride": self.cached_runtime_stride,
"stale_usage_word": self.stale_usage_word,
"index_rel": self.index_rel,
"index_offset": self.index_offset,
"index_count": self.index_count,
"index_width": self.index_width,
"topology_kind": self.topology_kind,
"cull_selector": self.cull_selector,
"shade_mode": self.shade_mode,
}
if include_words:
d["raw_words"] = [int(x) for x in self.words]
return d
@dataclasses.dataclass
class XmxMaterial:
index: int
offset: int
words: Tuple[int, ...]
name_rel: int
name: Optional[str]
flags: int
sphere: Tuple[float, float, float, float]
depth_sort_bias: float
ambient_argb: int
diffuse_argb: int
specular_argb: int
source_power: float
emissive_argb: int
texture_factor: int
d3d_material: Dict[str, Any]
primitive_count: int
primitive_rel: int
primitive_offset: int
source_vertex_count_hint: int
source_triangle_index_hint: int
texture_capacity: int
active_textures_rt: int
textures: List[XmxTextureStage]
serialized_tail0: int
serialized_tail1: int
primitives: List[XmxPrimitive]
@property
def src_blend_selector(self) -> int:
return self.flags & 0x00000780
@property
def dst_blend_selector(self) -> int:
return self.flags & 0x00007800
@property
def src_blend_value(self) -> int:
return SRC_BLEND_SELECTOR_TO_D3D.get(self.src_blend_selector, -1)
@property
def dst_blend_value(self) -> int:
return DST_BLEND_SELECTOR_TO_D3D.get(self.dst_blend_selector, -1)
@property
def zwrite_enable(self) -> bool:
return bool(self.flags & 0x00008000)
@property
def fog_disabled_for_material(self) -> bool:
return bool(self.flags & 0x00400000)
@property
def specular_enable(self) -> bool:
# Decomp indicates this bit controls D3DRS_SPECULARENABLE. Treat set as enabled.
return bool(self.flags & 0x20000000)
@property
def sorted_cache_path(self) -> bool:
return bool(self.flags & 0x00000040)
@property
def named_textures(self) -> List[XmxTextureStage]:
return [t for t in self.textures if t.has_named_texture]
def to_dict(self, include_words: bool = True) -> Dict[str, Any]:
d = {
"index": self.index,
"offset": self.offset,
"name_rel": self.name_rel,
"name": self.name,
"flags": self.flags,
"flags_hex": f"0x{self.flags:08X}",
"sphere": self.sphere,
"depth_sort_bias": self.depth_sort_bias,
"ambient_argb": f"0x{self.ambient_argb:08X}",
"diffuse_argb": f"0x{self.diffuse_argb:08X}",
"specular_argb": f"0x{self.specular_argb:08X}",
"emissive_argb": f"0x{self.emissive_argb:08X}",
"texture_factor": f"0x{self.texture_factor:08X}",
"source_power": self.source_power,
"d3d_material": self.d3d_material,
"primitive_count": self.primitive_count,
"primitive_rel": self.primitive_rel,
"primitive_offset": self.primitive_offset,
"source_vertex_count_hint": self.source_vertex_count_hint,
"source_triangle_index_hint": self.source_triangle_index_hint,
"texture_capacity": self.texture_capacity,
"active_textures_rt": self.active_textures_rt,
"serialized_tail0": f"0x{self.serialized_tail0:08X}",
"serialized_tail1": f"0x{self.serialized_tail1:08X}",
"blend": {
"src_selector": f"0x{self.src_blend_selector:04X}",
"dst_selector": f"0x{self.dst_blend_selector:04X}",
"src_d3d_value": self.src_blend_value,
"dst_d3d_value": self.dst_blend_value,
"src_name": D3D_BLEND_NAMES.get(self.src_blend_value, "UNKNOWN"),
"dst_name": D3D_BLEND_NAMES.get(self.dst_blend_value, "UNKNOWN"),
},
"render_flags": {
"zwrite_enable": self.zwrite_enable,
"fog_disabled_for_material": self.fog_disabled_for_material,
"specular_enable": self.specular_enable,
"sorted_cache_path": self.sorted_cache_path,
},
"texture_stages": [t.to_dict() for t in self.textures],
"primitives": [p.to_dict(include_words=False) for p in self.primitives],
}
if include_words:
d["raw_words"] = [int(x) for x in self.words]
return d
@dataclasses.dataclass
class XmxModel:
source: str
file_size: int
payload_size: int
version_minor: int
version_major: int
model_tag: bytes
header_words: Tuple[int, ...]
name_rel: int
name: Optional[str]
sphere: Tuple[float, float, float, float]
material_count: int
material_rel: int
material_offset: int
source_vertex_count_hint: int
source_draw_group_hint: int
materials: List[XmxMaterial]
warnings: List[str]
@property
def primitives(self) -> List[XmxPrimitive]:
return [p for m in self.materials for p in m.primitives]
@property
def vertices_total(self) -> int:
return sum(p.vertex_count for p in self.primitives)
@property
def indices_total(self) -> int:
return sum(p.index_count for p in self.primitives)
@property
def triangles_total(self) -> int:
return sum(len(p.triangles()) for p in self.primitives)
@property
def model_name_for_blender(self) -> str:
return sanitize_name(self.name or Path(self.source.split("!/", 1)[-1]).stem or "XMDL")
def to_dict(self, include_materials: bool = True) -> Dict[str, Any]:
d = {
"source": self.source,
"file_size": self.file_size,
"payload_size": self.payload_size,
"version": f"{self.version_major}.{self.version_minor}",
"model_tag": self.model_tag.decode("ascii", errors="replace"),
"name_rel": self.name_rel,
"name": self.name,
"sphere": self.sphere,
"material_count": self.material_count,
"material_rel": self.material_rel,
"material_offset": self.material_offset,
"source_vertex_count_hint": self.source_vertex_count_hint,
"source_draw_group_hint": self.source_draw_group_hint,
"primitive_count": len(self.primitives),
"vertices": self.vertices_total,
"indices": self.indices_total,
"triangles": self.triangles_total,
"warnings": list(self.warnings),
}
if include_materials:
d["materials"] = [m.to_dict(include_words=False) for m in self.materials]
return d
# -----------------------------------------------------------------------------
# Parser and decoders
# -----------------------------------------------------------------------------
def sanitize_name(s: str, limit: int = 63) -> str:
bad = '<>:"/\\|?*\0\n\r\t'
out = ''.join('_' if ch in bad else ch for ch in s).strip()
return (out[:limit] or "unnamed")
def topology_name(flags: int) -> str:
t = flags & TOPOLOGY_MASK
if t == 2:
return "triangle_list"
if t in (1, 3):
return "triangle_fan"
return "triangle_strip"
def parse_indices(r: Reader, off: int, count: int, width: int) -> Tuple[int, ...]:
if count == 0:
return ()
r.require(off, count * width, "index payload")
if width == 1:
return tuple(r.data[off:off + count])
return struct.unpack_from(f"<{count}H", r.data, off)
def indices_to_triangles(indices: Sequence[int], topology_kind: str, index_width: int = 2) -> List[Tuple[int, int, int]]:
"""Convert D3D index stream to triangle list.
For strips, degenerate windows are skipped but parity still advances. This
matches D3D triangle strip behavior and is important for connector patterns.
"""
tris: List[Tuple[int, int, int]] = []
if not indices:
return tris
restart = 0xFF if index_width == 1 else 0xFFFF
if topology_kind == "triangle_list":
for i in range(0, len(indices) - 2, 3):
a, b, c = int(indices[i]), int(indices[i + 1]), int(indices[i + 2])
if restart in (a, b, c):
continue
if a == b or b == c or a == c:
continue
tris.append((a, b, c))
return tris
if topology_kind == "triangle_fan":
if len(indices) < 3:
return tris
anchor: Optional[int] = int(indices[0])
if anchor == restart:
anchor = None
for i in range(1, len(indices) - 1):
a = anchor
b, c = int(indices[i]), int(indices[i + 1])
if a is None or b == restart:
anchor = None
continue
if c == restart:
anchor = None
continue
if a == b or b == c or a == c:
continue
tris.append((a, b, c))
return tris
# triangle strip
flip = False
for i in range(len(indices) - 2):
a, b, c = int(indices[i]), int(indices[i + 1]), int(indices[i + 2])
if a == restart or b == restart or c == restart:
flip = False
continue
if a != b and b != c and a != c:
tris.append((b, a, c) if flip else (a, b, c))
flip = not flip
return tris
def decode_vertex_payload(r: Reader, off: int, count: int, layout_bits: int) -> List[XmxVertex]:
if layout_bits not in LAYOUTS:
raise XmxError(f"unsupported layout bits 0x{layout_bits:03X}")
layout = LAYOUTS[layout_bits]
stride = int(layout["disk_stride"])
r.require(off, count * stride, "vertex payload")
verts: List[XmxVertex] = []
for i in range(count):
q = off + i * stride
x, y, z = struct.unpack_from("<3f", r.data, q)
if not all(math.isfinite(v) for v in (x, y, z)):
raise XmxError(f"non-finite position at vertex {i} offset 0x{q:X}")
co = (x, y, z)
normal: Optional[Tuple[float, float, float]] = None
if layout["has_normal"]:
if layout["packed_normal"]:
normal = decode_packed_normal(struct.unpack_from("<I", r.data, q + 12)[0])
else:
nx, ny, nz = struct.unpack_from("<3f", r.data, q + 12)
if not all(math.isfinite(v) for v in (nx, ny, nz)):
raise XmxError(f"non-finite normal at vertex {i} offset 0x{q + 12:X}")
normal = (nx, ny, nz)
diffuse: Optional[Tuple[float, float, float, float]] = None
specular: Optional[Tuple[float, float, float, float]] = None
if layout_bits == 0x080:
diffuse = _d3dcolor_to_rgba_tuple(struct.unpack_from("<I", r.data, q + 24)[0])
specular = _d3dcolor_to_rgba_tuple(struct.unpack_from("<I", r.data, q + 28)[0])
elif layout_bits == 0x100:
diffuse = _d3dcolor_to_rgba_tuple(struct.unpack_from("<I", r.data, q + 12)[0])
specular = _d3dcolor_to_rgba_tuple(struct.unpack_from("<I", r.data, q + 16)[0])
elif layout_bits == 0x200:
diffuse = _d3dcolor_to_rgba_tuple(struct.unpack_from("<I", r.data, q + 16)[0])
specular = _d3dcolor_to_rgba_tuple(struct.unpack_from("<I", r.data, q + 20)[0])
u, v = struct.unpack_from("<2f", r.data, q + int(layout["uv_offset"]))
# HOD3 has quiet-NaN UV sentinels in a few files. Never pass non-finite
# floats into Blender mesh attributes.
uv = (sanitize_float(u, 0.0), sanitize_float(v, 0.0))
verts.append(XmxVertex(co, normal, uv, diffuse, specular))
return verts
def parse_xmx(data: bytes, source: str = "<memory>", decode_vertices: bool = True) -> XmxModel:
r = Reader(data, source)
r.require(0, FILE_HEADER_SIZE + 4, "file header")
if data[0:4] != b"LDMX":
raise XmxError(f"bad XMDL magic {data[0:4]!r}; expected raw b'LDMX'")
if data[4:8] != b"XOBX":
raise XmxError(f"bad platform tag {data[4:8]!r}; expected raw b'XOBX'")
minor, major = struct.unpack_from("<HH", data, 8)
if major != 6 or minor > 0:
raise XmxError(f"unsupported XMDL/XBOX version {major}.{minor}; importer targets 6.0")
payload = r.u32(0x0C)
if payload != len(data) - FILE_HEADER_SIZE:
raise XmxError(f"payload size 0x{payload:X} != file_size-0x10 0x{len(data) - 16:X}")
if data[MODEL_BASE:MODEL_BASE + 4] != b"LEDM":
raise XmxError(f"bad model chunk tag {data[MODEL_BASE:MODEL_BASE + 4]!r}; expected raw b'LEDM'")
warnings: List[str] = []
root_off = MODEL_BASE + 4
hw = r.words(root_off, MODEL_HEADER_SIZE // 4)
name_rel = hw[4]
model_name: Optional[str] = None
if name_rel:
no = r.rel(name_rel)
model_name = r.cstr(no)
if no < 4 or data[no - 4:no] != b"EMAN":
warnings.append(f"model name pointer 0x{no:X} is not immediately preceded by EMAN")
sphere = tuple(struct.unpack_from("<4f", data, root_off + 0x14)) # center xyz, radius
material_count = hw[9]
material_rel = hw[10]
material_offset = r.rel(material_rel)
r.require(material_offset, material_count * MATERIAL_SIZE, "material table")
if material_offset < 4 or data[material_offset - 4:material_offset] != b"RTAM":
raise XmxError(f"material table at 0x{material_offset:X} not preceded by RTAM")
materials: List[XmxMaterial] = []
for mi in range(material_count):
mo = material_offset + mi * MATERIAL_SIZE
w = r.words(mo, MATERIAL_SIZE // 4)
mat_name_rel = w[0]
mat_name: Optional[str] = r.cstr(r.rel(mat_name_rel)) if mat_name_rel else None
mat_sphere = tuple(struct.unpack_from("<4f", data, mo + 0x08))
depth_sort_bias = r.f32(mo + 0x18)
d3d_vals = struct.unpack_from("<17f", data, mo + 0x34)
d3d_material = {
"diffuse": tuple(d3d_vals[0:4]),
"ambient": tuple(d3d_vals[4:8]),
"specular": tuple(d3d_vals[8:12]),
"emissive": tuple(d3d_vals[12:16]),
"power": d3d_vals[16],
}
textures: List[XmxTextureStage] = []
for si in range(TEXTURE_STAGE_COUNT):
so = mo + 0x90 + si * TEXTURE_STAGE_SIZE
sw = r.words(so, 5)
sname: Optional[str] = r.cstr(r.rel(sw[0])) if sw[0] else None
textures.append(XmxTextureStage(si, so, sw[0], sw[1], sw[2], sw[3], sw[4], sname))
primitive_count = w[30]
primitive_rel = w[31]
primitive_offset = r.rel(primitive_rel) if primitive_count else 0
if primitive_count:
# A material can point into the middle of the global VPRG primitive pool.
# Only the first primitive pool pointer in the model is expected to be
# immediately preceded by raw tag b"VPRG".
r.require(primitive_offset, primitive_count * PRIMITIVE_SIZE, "primitive table")
primitives: List[XmxPrimitive] = []
for pi in range(primitive_count):
po = primitive_offset + pi * PRIMITIVE_SIZE
pw = r.words(po, 18)
flags = pw[0]
index_flags = pw[1] & 0xFFFF
vertex_stream_selector = (pw[1] >> 16) & 0xFF
index_stream_selector = (pw[1] >> 24) & 0xFF
vertex_count = pw[2]
layout_bits = flags & LAYOUT_MASK
if layout_bits not in LAYOUTS:
raise XmxError(f"primitive {mi}:{pi} unknown vertex layout bits 0x{layout_bits:03X} at 0x{po:X}")
layout = LAYOUTS[layout_bits]
vertex_offset = r.rel(pw[4])
index_width = 1 if (index_flags & INDEX_UINT8_BIT) else 2
index_offset = r.rel(pw[16])
indices = parse_indices(r, index_offset, pw[17], index_width)
if indices and max(indices) >= vertex_count:
raise XmxError(f"primitive {mi}:{pi} local index {max(indices)} >= vertex_count {vertex_count}")
vertices: Optional[List[XmxVertex]] = None
if decode_vertices:
vertices = decode_vertex_payload(r, vertex_offset, vertex_count, layout_bits)
else:
r.require(vertex_offset, vertex_count * int(layout["disk_stride"]), "vertex payload")
if pw[10] and pw[10] != int(layout["runtime_fvf"]):
warnings.append(f"primitive {mi}:{pi} cached FVF 0x{pw[10]:X} != expected 0x{int(layout['runtime_fvf']):X}")
if pw[11] and pw[11] != int(layout["runtime_stride"]):
warnings.append(f"primitive {mi}:{pi} cached runtime stride {pw[11]} != expected {layout['runtime_stride']}")
primitives.append(XmxPrimitive(
material_index=mi,
primitive_index=pi,
offset=po,
words=pw,
flags=flags,
index_flags=index_flags,
vertex_stream_selector=vertex_stream_selector,
index_stream_selector=index_stream_selector,
vertex_count=vertex_count,
strip_correction_count=pw[3],
vertex_rel=pw[4],
vertex_offset=vertex_offset,
layout_bits=layout_bits,
layout_name=str(layout["name"]),
disk_stride=int(layout["disk_stride"]),
cached_fvf=pw[10],
cached_runtime_stride=pw[11],
stale_usage_word=pw[12],
index_rel=pw[16],
index_offset=index_offset,
index_count=pw[17],
index_width=index_width,
topology_kind=topology_name(flags),
indices=indices,
vertices=vertices,
))
materials.append(XmxMaterial(
index=mi,
offset=mo,
words=w,
name_rel=mat_name_rel,
name=mat_name,
flags=w[1],
sphere=mat_sphere,
depth_sort_bias=depth_sort_bias,
ambient_argb=w[7],
diffuse_argb=w[8],
specular_argb=w[9],
source_power=_bits_to_float(w[10]),
emissive_argb=w[11],
texture_factor=w[12],
d3d_material=d3d_material,
primitive_count=primitive_count,
primitive_rel=primitive_rel,
primitive_offset=primitive_offset,
source_vertex_count_hint=w[32],
source_triangle_index_hint=w[33],
texture_capacity=w[34],
active_textures_rt=w[35],
textures=textures,
serialized_tail0=w[56],
serialized_tail1=w[57],
primitives=primitives,
))
# Pool marker sanity checks. Pointers may target slices inside the pool, so
# only the minimum pointer for each pool is expected to sit after its tag.
prim_offsets = [m.primitive_offset for m in materials if m.primitive_count]
if prim_offsets:
first = min(prim_offsets)
if first < 4 or data[first - 4:first] != b"VPRG":
warnings.append(f"first primitive pool pointer 0x{first:X} is not preceded by VPRG")
tex_offsets = [r.rel(t.name_rel) for m in materials for t in m.textures if t.name_rel]
if tex_offsets:
first = min(tex_offsets)
if first < 4 or data[first - 4:first] != b"MNXT":
warnings.append(f"first texture-name pointer 0x{first:X} is not preceded by MNXT")
mat_name_offsets = [r.rel(m.name_rel) for m in materials if m.name_rel]
if mat_name_offsets:
first = min(mat_name_offsets)
if first < 4 or data[first - 4:first] != b"EMAN":
warnings.append(f"first material-name pointer 0x{first:X} is not preceded by EMAN")
return XmxModel(
source=source,
file_size=len(data),
payload_size=payload,
version_minor=minor,
version_major=major,
model_tag=data[MODEL_BASE:MODEL_BASE + 4],
header_words=hw,
name_rel=name_rel,
name=model_name,
sphere=sphere,
material_count=material_count,
material_rel=material_rel,
material_offset=material_offset,
source_vertex_count_hint=hw[11],
source_draw_group_hint=hw[12],
materials=materials,
warnings=warnings,
)
# -----------------------------------------------------------------------------
# Texture helpers
# -----------------------------------------------------------------------------
def _norm_texture_path(path: str) -> str:
return path.replace("\\", "/").replace("//", "/")
def _split_zip_texture_ref(ref: str) -> Tuple[Optional[str], Optional[str]]:
n = _norm_texture_path(ref)
low = n.lower()
marker = ".zip"
i = low.find(marker)
if i < 0:
return None, None
zip_part = n[:i + len(marker)]
member = n[i + len(marker):]
member = member.lstrip("/:\\")
return zip_part, member or None
def _parse_vfs_member(member: str) -> Optional[Tuple[int, int]]:
"""Decode an offset-addressed VFS member 'HEXOFF,HEXSIZE.dds'.
The X3X texture stages reference textures as '<archive>.zip/:<off>,<size>.dds',
where off/size are hex byte ranges into the archive. The archive is a packed
blob (ZIP-shaped, entries STORED not deflated), so the DDS is simply the byte
slice [off : off+size]. Returns (off, size) or None if not this scheme.
"""
m = member
if m.lower().endswith(".dds"):
m = m[:-4]
if "," not in m:
return None
a, b = m.split(",", 1)
try:
return (int(a, 16), int(b, 16))
except ValueError:
return None
def find_loose_texture_for_model(model_name: str, xmx_path: Path, texture_root: str = "") -> Optional[Path]:
"""Best-effort texture when the offset-addressed archive isn't available.
The X3X stages reference textures by byte-offset into a packed archive
(e.g. xtx_zb02.zip) that ships with the game. When only loose extracted DDS
files are present, there is no mapping in the file data, so this matches by
body-part keyword derived from the (Japanese) model name. Heuristic -- the
chosen file is recorded on the material so it can be corrected.
"""
if not model_name:
return None
low = model_name.lower()
# model-name token -> ordered list of loose-DDS filename keywords to try
if "kao" in low:
cats = ["kao", "face"]
elif "rte" in low or "lte" in low or "hand" in low or "_te" in low:
cats = ["hand", "te"]
elif "asi" in low or "kyaku" in low or "momo" in low or "sune" in low or "leg" in low:
cats = ["arm_leg", "leg", "asi", "arm"]
elif "teeth" in low or "_ha" in low:
cats = ["teeth"]
elif "mask" in low:
cats = ["mask"]
else:
cats = ["body", "mune", "face"]
roots = _candidate_search_roots(xmx_path, texture_root)
files: List[Path] = []
for root in roots:
try:
files.extend(sorted(root.glob("*.dds")))
except Exception:
continue
for cat in cats:
for f in files:
if cat in f.stem.lower():
return f
return None
def _candidate_search_roots(xmx_path: Path, texture_root: str = "") -> List[Path]:
roots: List[Path] = []
if xmx_path and xmx_path.parent.exists():
roots.append(xmx_path.parent)
if texture_root:
p = Path(texture_root)
if p.exists():
roots.append(p)
# Stable unique list.
out: List[Path] = []
seen = set()
for r in roots:
rr = r.resolve()
if rr not in seen:
out.append(rr)
seen.add(rr)
return out
def find_texture_file(ref: str, xmx_path: Path, texture_root: str = "", recursive: bool = False, extract_zip: bool = True) -> Optional[Path]:
"""Find a texture referenced by XMX texture stage.
Supports refs such as d:/FS/tex_zb01.zip/:foo.dds. If a ZIP member is
found and extract_zip is true, it extracts the member into a temp cache and
returns that file. Recursive search is off by default to avoid Blender UI
stalls on large game folders.
"""
if not ref:
return None
ref_norm = _norm_texture_path(ref)
roots = _candidate_search_roots(xmx_path, texture_root)
zip_part, member = _split_zip_texture_ref(ref_norm)
if zip_part and member:
zip_name = Path(zip_part).name
zip_candidates: List[Path] = []
for root in roots:
direct = root / zip_name
if direct.exists():
zip_candidates.append(direct)
if recursive:
zip_candidates.extend(root.rglob(zip_name))
# Offset-addressed VFS member: '<off>,<size>.dds' -> raw byte slice.
vfs = _parse_vfs_member(member)
if vfs is not None:
off, size = vfs
for zpath in zip_candidates:
try:
blob = zpath.read_bytes()
if off + size > len(blob):
continue
dds = blob[off:off + size]
if extract_zip:
cache_root = Path(tempfile.gettempdir()) / "xmx_xmdl_v6_texture_cache" / zpath.stem
cache_root.mkdir(parents=True, exist_ok=True)
out = cache_root / f"{off:x}_{size:x}.dds"
if not out.exists() or out.stat().st_size != size:
out.write_bytes(dds)
return out
except Exception:
continue
return None
for zpath in zip_candidates:
try:
with zipfile.ZipFile(zpath) as zf:
names = zf.namelist()
# Match exact, slash-normalized, or basename fallback.
member_norm = member.replace("\\", "/")
match = None
for nm in names:
if nm.replace("\\", "/").lower() == member_norm.lower():
match = nm; break
if match is None:
mb = Path(member_norm).name.lower()
for nm in names:
if Path(nm).name.lower() == mb:
match = nm; break
if match and extract_zip:
cache_root = Path(tempfile.gettempdir()) / "xmx_xmdl_v6_texture_cache" / zpath.stem
cache_root.mkdir(parents=True, exist_ok=True)
out = cache_root / sanitize_name(Path(match).name, 128)
if not out.exists() or out.stat().st_size != zf.getinfo(match).file_size:
out.write_bytes(zf.read(match))
return out
except Exception:
continue
return None
# Direct file path. Try absolute, relative full path, then basename.
maybe = Path(ref_norm)
if maybe.exists():
return maybe
basename = maybe.name
for root in roots:
candidates = [root / ref_norm, root / basename]
for c in candidates:
if c.exists():
return c
if recursive:
for c in root.rglob(basename):
if c.exists():
return c
return None
# -----------------------------------------------------------------------------
# Optional sidecar skeleton/weights support
# -----------------------------------------------------------------------------
SIDECAR_NAMES = ("{stem}.skeleton.json", "{stem}.bones.json", "{stem}.xmx.json")
def load_sidecar(path: Path) -> Optional[Dict[str, Any]]:
if not path:
return None
for fmt in SIDECAR_NAMES:
p = path.with_name(fmt.format(stem=path.stem))
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
return None
# -----------------------------------------------------------------------------
# Xbox HOD3 X3X/XAX POD parser
# -----------------------------------------------------------------------------
class XboxFormatError(ValueError):
pass
class XboxReader:
def __init__(self, data: bytes, source: str = '<memory>'):
self.data = data
self.size = len(data)
self.source = source
def require(self, off: int, size: int, what: str = 'data') -> None:
if off < 0 or size < 0 or off + size > self.size:
raise XboxFormatError(f'{what} out of range: 0x{off:X}+0x{size:X} > 0x{self.size:X}')
def u8(self, off: int) -> int:
self.require(off, 1); return self.data[off]
def u16(self, off: int) -> int:
self.require(off, 2); return struct.unpack_from('<H', self.data, off)[0]
def i16(self, off: int) -> int:
self.require(off, 2); return struct.unpack_from('<h', self.data, off)[0]
def u32(self, off: int) -> int:
self.require(off, 4); return struct.unpack_from('<I', self.data, off)[0]
def i32(self, off: int) -> int:
self.require(off, 4); return struct.unpack_from('<i', self.data, off)[0]
def f32(self, off: int) -> float:
self.require(off, 4); return struct.unpack_from('<f', self.data, off)[0]
def f32s(self, off: int, count: int) -> tuple[float, ...]:
self.require(off, 4 * count); return struct.unpack_from('<%df' % count, self.data, off)
def u32s(self, off: int, count: int) -> tuple[int, ...]:
self.require(off, 4 * count); return struct.unpack_from('<%dI' % count, self.data, off)
def cstr(self, off: int, max_len: int = 4096) -> str:
self.require(off, 1, 'string')
end = self.data.find(b'\0', off, min(self.size, off + max_len))
if end < 0:
raise XboxFormatError(f'unterminated string at 0x{off:X}')
return self.data[off:end].decode('cp1252', errors='replace')
def _wrapper(r: XboxReader, magic: bytes, major: int, minor_max: int) -> tuple[int, int, int]:
r.require(0, 0x10, 'file header')
if r.data[0:4] != magic:
raise XboxFormatError(f'bad magic {r.data[0:4]!r}, expected {magic!r}')
if r.data[4:8] != b'XOBX':
raise XboxFormatError(f'bad platform {r.data[4:8]!r}, expected XOBX')
minor, maj = struct.unpack_from('<HH', r.data, 8)
if maj != major or minor > minor_max:
raise XboxFormatError(f'unsupported version {maj}.{minor}')
payload = r.u32(0x0C)
if payload != r.size - 0x10:
raise XboxFormatError(f'payload 0x{payload:X} != file_size-0x10 0x{r.size-0x10:X}')
return minor, maj, payload
def _mat_to_rows(vals: Sequence[float]) -> list[list[float]]:
return [list(vals[i:i+4]) for i in range(0, 16, 4)]
@dataclasses.dataclass
class X3XModelBlock:
slot: int
block_offset: int
size: int
mdel_offset: int
name: Optional[str] = None
materials: Optional[int] = None
primitives: Optional[int] = None
vertices: Optional[int] = None
indices: Optional[int] = None
error: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return dataclasses.asdict(self)
@dataclasses.dataclass
class X3XNode:
offset: int
name: Optional[str]
matrix0: tuple[float, ...]
matrix1: tuple[float, ...]
name_rel: int
model_mask_flags: int
model_count: int
model_rels: tuple[int, ...]
transform_flags: int
translate_primary: tuple[float, ...]
rotate_primary: tuple[float, ...]
scale_primary: tuple[float, ...]
translate_secondary: tuple[float, ...]
rotate_secondary: tuple[float, ...]
scale_secondary: tuple[float, ...]
direct_child_count: int
first_child_rel: int
next_sibling_rel: int
node_id: int
reserved_tail: tuple[int, int, int]
models: list[X3XModelBlock]
child: Optional['X3XNode'] = None
sibling: Optional['X3XNode'] = None
def to_dict(self, recursive: bool = False) -> dict[str, Any]:
d = {
'offset': self.offset, 'name': self.name,
'matrix0': _mat_to_rows(self.matrix0), 'matrix1': _mat_to_rows(self.matrix1),
'name_rel': self.name_rel,
'model_mask_flags': self.model_mask_flags, 'model_mask_flags_hex': f'0x{self.model_mask_flags:08X}',
'model_count': self.model_count, 'model_rels': list(self.model_rels),
'transform_flags': self.transform_flags, 'transform_flags_hex': f'0x{self.transform_flags:08X}',
'translate_primary': self.translate_primary, 'rotate_primary': self.rotate_primary, 'scale_primary': self.scale_primary,
'translate_secondary': self.translate_secondary, 'rotate_secondary': self.rotate_secondary, 'scale_secondary': self.scale_secondary,
'direct_child_count': self.direct_child_count, 'first_child_rel': self.first_child_rel, 'next_sibling_rel': self.next_sibling_rel,
'node_id': self.node_id, 'reserved_tail': self.reserved_tail,
'models': [m.to_dict() for m in self.models],
}
if recursive:
d['child'] = self.child.to_dict(True) if self.child else None
d['sibling'] = self.sibling.to_dict(True) if self.sibling else None
return d
@dataclasses.dataclass
class X3XFile:
source: str
file_size: int
version: str
payload_size: int
root: X3XNode
nodes: list[X3XNode]
model_blocks: list[X3XModelBlock]
warnings: list[str]
def to_dict(self) -> dict[str, Any]:
return {
'source': self.source, 'file_size': self.file_size, 'version': self.version,
'payload_size': self.payload_size, 'nodes': len(self.nodes), 'model_blocks': len(self.model_blocks),
'warnings': self.warnings,
'flat_nodes': [n.to_dict(False) for n in self.nodes],
}
# minimal embedded XMDL inspection; avoids importing the larger PC parser as a dependency
def _inspect_embedded_mdel(chunk: bytes) -> dict[str, Any]:
# chunk begins with LEDM and is exactly the MDEL body from X3X_ModelBlock.
if len(chunk) < 0x74 or chunk[:4] != b'LEDM':
raise XboxFormatError('embedded model is not LEDM')
# XMDL body layout is the same MDEL chunk body used by PC XMDL v6, with pointers relative to chunk start.
def u32(o): return struct.unpack_from('<I', chunk, o)[0]
def cstr(o):
end = chunk.find(b'\0', o, min(len(chunk), o + 4096))
if end < 0: raise XboxFormatError('unterminated embedded model string')
return chunk[o:end].decode('cp1252','replace')
name_rel = u32(4 + 0x10)
name = cstr(name_rel) if name_rel else None
material_count = u32(4 + 0x24)
material_rel = u32(4 + 0x28)
if material_count:
mo = material_rel
if mo < 4 or chunk[mo-4:mo] != b'RTAM':
raise XboxFormatError('embedded model material table missing RTAM')
vertices = indices = primitives = 0
for mi in range(material_count):
m = mo + mi * 0xE8
if m + 0xE8 > len(chunk): raise XboxFormatError('embedded material OOB')
pc = u32(m + 0x78)
pr = u32(m + 0x7C)
primitives += pc
for pi in range(pc):
p = pr + pi * 0x48
if p + 0x48 > len(chunk): raise XboxFormatError('embedded primitive OOB')
vertices += u32(p + 0x08)
indices += u32(p + 0x44)
else:
vertices = indices = primitives = 0
return {'name': name, 'materials': material_count, 'primitives': primitives, 'vertices': vertices, 'indices': indices}
def parse_x3x(data: bytes, source: str = '<memory>', inspect_models: bool = True) -> X3XFile:
r = XboxReader(data, source)
minor, major, payload = _wrapper(r, b'OD3X', 6, 0)
if data[0x10:0x14] != b'3JBO':
raise XboxFormatError('root is not OBJ3')
visiting: set[int] = set(); seen: set[int] = set(); warnings: list[str] = []; all_models: list[X3XModelBlock] = []
def parse_node(off: int, depth: int = 0) -> X3XNode:
if depth > 4096: raise XboxFormatError('OBJ3 depth limit exceeded')
if off in visiting: raise XboxFormatError(f'OBJ3 cycle at 0x{off:X}')
if off in seen: raise XboxFormatError(f'OBJ3 alias/reused node pointer at 0x{off:X}')
visiting.add(off); seen.add(off)
r.require(off, 0x124, 'OBJ3 fixed record')
if r.data[off:off+4] != b'3JBO':
raise XboxFormatError(f'bad OBJ3 tag at 0x{off:X}')
matrix0 = r.f32s(off + 0x04, 16)
matrix1 = r.f32s(off + 0x44, 16)
name_rel = r.u32(off + 0x90)
name = None
if name_rel:
no = off + name_rel
if no < 4 or r.data[no-4:no] != b'EMAN': raise XboxFormatError(f'OBJ3 NAME mismatch at 0x{no:X}')
name = r.cstr(no)
flags = r.u32(off + 0x94)
model_count = r.u32(off + 0x98)
if model_count > 8: raise XboxFormatError(f'OBJ3 model_count {model_count}>8 at 0x{off:X}')
model_rels = r.u32s(off + 0x9C, 8)
models: list[X3XModelBlock] = []
for slot in range(model_count):
rel = model_rels[slot]
if not rel: raise XboxFormatError(f'OBJ3 null model pointer in live slot {slot} at 0x{off:X}')
bo = off + rel
r.require(bo, 8, 'X3X model block')
size = r.u32(bo)
mdel_off = bo + 4
r.require(mdel_off, size, 'embedded MDEL')
if r.data[mdel_off:mdel_off+4] != b'LEDM': raise XboxFormatError(f'embedded model missing LEDM at 0x{mdel_off:X}')
mb = X3XModelBlock(slot, bo, size, mdel_off)
if inspect_models:
try:
mb.__dict__.update(_inspect_embedded_mdel(r.data[mdel_off:mdel_off+size]))
except Exception as e:
mb.error = str(e)
models.append(mb); all_models.append(mb)
for slot in range(model_count, 8):
if model_rels[slot]: warnings.append(f'nonzero unused model pointer at OBJ3 0x{off:X} slot {slot}')
vals = r.f32s(off + 0xC0, 18)
child_count = r.u32(off + 0x108)
child_rel = r.u32(off + 0x10C)
sib_rel = r.u32(off + 0x110)
n = X3XNode(
offset=off, name=name, matrix0=matrix0, matrix1=matrix1, name_rel=name_rel,
model_mask_flags=flags, model_count=model_count, model_rels=model_rels,
transform_flags=r.u32(off + 0xBC),
translate_primary=vals[0:3], rotate_primary=vals[3:6], scale_primary=vals[6:9],
translate_secondary=vals[9:12], rotate_secondary=vals[12:15], scale_secondary=vals[15:18],
direct_child_count=child_count, first_child_rel=child_rel, next_sibling_rel=sib_rel,
node_id=r.i32(off + 0x114), reserved_tail=r.u32s(off + 0x118, 3), models=models)
if child_rel: n.child = parse_node(off + child_rel, depth + 1)
if sib_rel: n.sibling = parse_node(off + sib_rel, depth)
visiting.remove(off)
return n
root = parse_node(0x10)
def flatten(node: Optional[X3XNode]) -> list[X3XNode]:
return [] if node is None else [node] + flatten(node.child) + flatten(node.sibling)
nodes = flatten(root)
for n in nodes:
c = 0; q = n.child
while q is not None:
c += 1; q = q.sibling
if c != n.direct_child_count: raise XboxFormatError(f'OBJ3 child_count mismatch at 0x{n.offset:X}: stored {n.direct_child_count}, actual {c}')
return X3XFile(source, len(data), f'{major}.{minor}', payload, root, nodes, all_models, warnings)
@dataclasses.dataclass
class XAXWeight:
skeleton_index: int
weight: float
def to_dict(self): return {'skeleton_index': self.skeleton_index, 'weight': self.weight}
@dataclasses.dataclass
class XAXEnvironment:
offset: int
name: Optional[str]
record_count: int
vtxd_offset: int
records: list[dict[str, Any]]
def to_dict(self): return {'offset': self.offset, 'name': self.name, 'record_count': self.record_count, 'vtxd_offset': self.vtxd_offset, 'records': self.records[:8], 'records_truncated': max(0, len(self.records)-8)}
@dataclasses.dataclass
class XAXSkeleton:
offset: int
matrices: list[tuple[float, ...]]
skeleton_index: int
state_word: int
scale0: tuple[float, ...]
scale1: tuple[float, ...]
def to_dict(self): return {'offset': self.offset, 'skeleton_index': self.skeleton_index, 'state_word': self.state_word, 'scale0': self.scale0, 'scale1': self.scale1}
@dataclasses.dataclass
class XAXDeform:
offset: int
matrices: list[tuple[float, ...]]
deform_index: int
scale: tuple[float, ...]
influence_skeleton_indices: list[int]
active_env_mask: int
env_count: int
envs: list[Optional[XAXEnvironment]]
def to_dict(self): return {'offset': self.offset, 'deform_index': self.deform_index, 'scale': self.scale, 'influence_skeleton_indices': self.influence_skeleton_indices, 'active_env_mask': self.active_env_mask, 'env_count': self.env_count, 'envs': [e.to_dict() if e else None for e in self.envs]}
@dataclasses.dataclass
class XAXNode:
block_offset: int
block_size: int
offset: int
name: Optional[str]
matrix: tuple[float, ...]
flags: int
base_translate: tuple[float, ...]
base_rotate: tuple[float, ...]
base_scale: tuple[float, ...]
current_translate: tuple[float, ...]
current_rotate: tuple[float, ...]
current_scale: tuple[float, ...]
deform_rel: int
skeleton_rel: int
direct_child_count: int
first_child_rel: int
next_sibling_rel: int
revision0: int
revision1: int
source_value: int
reserved_tail: tuple[int, int, int]
skeleton: Optional[XAXSkeleton]
deform: Optional[XAXDeform]
child: Optional['XAXNode'] = None
sibling: Optional['XAXNode'] = None
@property
def has_translation(self) -> bool: return bool(self.flags & 0x1)
@property
def has_rotation(self) -> bool: return bool(self.flags & 0x2)
@property
def has_scale(self) -> bool: return bool(self.flags & 0x4)
@property
def has_deform(self) -> bool: return bool(self.flags & 0x200)
@property
def has_skeleton(self) -> bool: return bool(self.flags & 0x400)
def to_dict(self, recursive: bool = False) -> dict[str, Any]:
d = {'block_offset': self.block_offset, 'block_size': self.block_size, 'offset': self.offset, 'name': self.name, 'matrix': _mat_to_rows(self.matrix), 'flags': self.flags, 'flags_hex': f'0x{self.flags:08X}', 'has_translation': self.has_translation, 'has_rotation': self.has_rotation, 'has_scale': self.has_scale, 'has_deform': self.has_deform, 'has_skeleton': self.has_skeleton, 'base_translate': self.base_translate, 'base_rotate': self.base_rotate, 'base_scale': self.base_scale, 'current_translate': self.current_translate, 'current_rotate': self.current_rotate, 'current_scale': self.current_scale, 'deform_rel': self.deform_rel, 'skeleton_rel': self.skeleton_rel, 'direct_child_count': self.direct_child_count, 'first_child_rel': self.first_child_rel, 'next_sibling_rel': self.next_sibling_rel, 'revision0': self.revision0, 'revision1': self.revision1, 'source_value': self.source_value, 'reserved_tail': self.reserved_tail, 'skeleton': self.skeleton.to_dict() if self.skeleton else None, 'deform': self.deform.to_dict() if self.deform else None}
if recursive:
d['child'] = self.child.to_dict(True) if self.child else None
d['sibling'] = self.sibling.to_dict(True) if self.sibling else None
return d
@dataclasses.dataclass
class XAXFile:
source: str
file_size: int
version: str
payload_size: int
actor_flags: int
weights: list[list[XAXWeight]]
skeleton_count: int
deform_count: int
skeleton_table: list[int]
deform_table: list[int]
root: XAXNode
nodes: list[XAXNode]
warnings: list[str]
def to_dict(self):
return {'source': self.source, 'file_size': self.file_size, 'version': self.version, 'payload_size': self.payload_size, 'actor_flags': self.actor_flags, 'weight_lists': len(self.weights), 'skeleton_count': self.skeleton_count, 'deform_count': self.deform_count, 'warnings': self.warnings, 'flat_nodes': [n.to_dict(False) for n in self.nodes]}
def parse_xax(data: bytes, source: str = '<memory>') -> XAXFile:
r = XboxReader(data, source); minor, major, payload = _wrapper(r, b'TCAX', 7, 2)
A = 0x10
if r.data[A:A+4] != b'RTCA': raise XboxFormatError('root is not ACTR')
actor_flags = r.u32(A + 0x04)
amdl_body = A + r.u32(A + 0x08)
wlst_body = A + r.u32(A + 0x0C)
warnings: list[str] = []
if r.data[wlst_body-4:wlst_body] != b'TSLW': raise XboxFormatError('WLST pointer/tag mismatch')
weight_count = r.u32(wlst_body)
wary = A + r.u32(wlst_body + 4)
if r.data[wary-4:wary] != b'YRAW': raise XboxFormatError('WARY pointer/tag mismatch')
weights: list[list[XAXWeight]] = []
for i in range(weight_count):
rel = r.u32(wary + i * 4)
pairs: list[XAXWeight] = []
if rel:
po = A + rel
# Only the beginning of the concatenated data stream is preceded by TADW.
guard = 0
while True:
r.require(po, 8, 'weight pair')
skel = r.i32(po)
wt = r.f32(po + 4)
if skel == -1:
if abs(wt) > 1e-8: warnings.append(f'weight list {i} terminator has nonzero weight {wt}')
break
pairs.append(XAXWeight(skel, wt))
po += 8; guard += 1
if guard > 256: raise XboxFormatError(f'weight list {i} terminator not found')
weights.append(pairs)
if r.data[amdl_body-4:amdl_body] != b'LDMA': raise XboxFormatError('AMDL pointer/tag mismatch')
root_rel, skel_count, skel_table_rel, deform_count, deform_table_rel = struct.unpack_from('<5I', r.data, amdl_body)
skel_table_off = A + skel_table_rel if skel_count else 0
deform_table_off = A + deform_table_rel if deform_count else 0
if skel_count:
if r.data[skel_table_off-4:skel_table_off] != b'LKSL': raise XboxFormatError('LSKL pointer/tag mismatch')
r.require(skel_table_off, skel_count * 4, 'skeleton table')
if deform_count:
if r.data[deform_table_off-4:deform_table_off] != b'MFDL': raise XboxFormatError('LDFM pointer/tag mismatch')
r.require(deform_table_off, deform_count * 4, 'deform table')
skeleton_table = list(r.u32s(skel_table_off, skel_count)) if skel_count else []
deform_table = list(r.u32s(deform_table_off, deform_count)) if deform_count else []
skel_index_to_node_off: dict[int, int] = {}
deform_index_to_node_off: dict[int, int] = {}
visiting: set[int] = set(); seen: set[int] = set()
def parse_skel(mdli_off: int, body: int) -> XAXSkeleton:
if r.data[body-4:body] != b'LEKS': raise XboxFormatError(f'SKEL tag mismatch at 0x{body:X}')
r.require(body, 0x120, 'SKEL body')
mats = [r.f32s(body + 64*i, 16) for i in range(4)]
idx = r.i32(body + 0x100)
state = r.u32(body + 0x104)
skel_index_to_node_off[idx] = mdli_off
return XAXSkeleton(body, mats, idx, state, r.f32s(body + 0x108, 3), r.f32s(body + 0x114, 3))
def parse_deform(mdli_off: int, body: int, base: int) -> XAXDeform:
if r.data[body-4:body] != b'MFED': raise XboxFormatError(f'DEFM tag mismatch at 0x{body:X}')
r.require(body, 0xC0, 'DEFM body')
idx = r.i32(body + 0x80)
lookup_count = r.u32(body + 0x90)
lookup_rel = r.u32(body + 0x94)
influence: list[int] = []
if lookup_count:
lo = base + lookup_rel
if r.data[lo-4:lo] != b'FDML': raise XboxFormatError(f'LMDF tag mismatch at 0x{lo:X}')
influence = list(r.u32s(lo, lookup_count))
active_mask = r.u32(body + 0x98)
env_count = r.u32(body + 0x9C)
if env_count > 8: raise XboxFormatError(f'env_count {env_count}>8 at 0x{body:X}')
envs: list[Optional[XAXEnvironment]] = []
for i in range(8):
er = r.u32(body + 0xA0 + i * 4)
if i >= env_count:
envs.append(None)
if er: warnings.append(f'env pointer beyond count at DEFM 0x{body:X}, slot {i}')
continue
if not er:
envs.append(None); continue
eb = base + er
if r.data[eb-4:eb] != b'DVNE': raise XboxFormatError(f'ENVD tag mismatch at 0x{eb:X}')
name_rel = r.u32(eb); rec_count = r.u32(eb + 4); vtxd_rel = r.u32(eb + 8)
name = None
if name_rel:
no = base + name_rel
if r.data[no-4:no] != b'EMAN': raise XboxFormatError(f'ENVD NAME mismatch at 0x{no:X}')
name = r.cstr(no)
vo = base + vtxd_rel
if r.data[vo-4:vo] != b'DXTV': raise XboxFormatError(f'VTXD tag mismatch at 0x{vo:X}')
r.require(vo, rec_count * 0x3C, 'VTXD records')
recs: list[dict[str, Any]] = []
for j in range(rec_count):
q = vo + j * 0x3C
vals = r.f32s(q + 0x0C, 6)
base_vals = r.f32s(q + 0x24, 6)
recs.append({'state': r.u32(q), 'vertex_index': r.u32(q + 4), 'weight_list_index': r.u32(q + 8), 'current_position': vals[0:3], 'current_normal': vals[3:6], 'base_position': base_vals[0:3], 'base_normal': base_vals[3:6]})
envs.append(XAXEnvironment(eb, name, rec_count, vo, recs))
deform_index_to_node_off[idx] = mdli_off
return XAXDeform(body, [r.f32s(body,16), r.f32s(body + 0x40,16)], idx, r.f32s(body + 0x84,3), influence, active_mask, env_count, envs)
def parse_node(block_off: int, depth: int = 0) -> XAXNode:
if depth > 4096: raise XboxFormatError('MDLI depth limit exceeded')
if block_off in visiting: raise XboxFormatError(f'MDLI cycle at 0x{block_off:X}')
if block_off in seen: raise XboxFormatError(f'MDLI alias/reused node pointer at 0x{block_off:X}')
visiting.add(block_off); seen.add(block_off)
r.require(block_off, 8, 'MDLI block')
block_size = r.u32(block_off); off = block_off + 4
r.require(off, block_size, 'MDLI chunk')
if r.data[off:off+4] != b'ILDM': raise XboxFormatError(f'MDLI tag mismatch at 0x{off:X}')
if block_size < 0xC4: raise XboxFormatError(f'MDLI block too small {block_size}')
name_rel = r.u32(off + 0x44); name = None
if name_rel:
no = off + name_rel
if r.data[no-4:no] != b'EMAN': raise XboxFormatError(f'MDLI NAME mismatch at 0x{no:X}')
name = r.cstr(no)
flags = r.u32(off + 0x48)
deform_rel = r.u32(off + 0x9C); skel_rel = r.u32(off + 0xA0)
skel = parse_skel(off, off + skel_rel) if skel_rel else None
deform = parse_deform(off, off + deform_rel, off) if deform_rel else None
child_rel = r.u32(off + 0xA8); sib_rel = r.u32(off + 0xAC)
n = XAXNode(
block_offset=block_off, block_size=block_size, offset=off, name=name, matrix=r.f32s(off + 4, 16), flags=flags,
base_translate=r.f32s(off + 0x54, 3), base_rotate=r.f32s(off + 0x60, 3), base_scale=r.f32s(off + 0x6C, 3),
current_translate=r.f32s(off + 0x78, 3), current_rotate=r.f32s(off + 0x84, 3), current_scale=r.f32s(off + 0x90, 3),
deform_rel=deform_rel, skeleton_rel=skel_rel, direct_child_count=r.u32(off + 0xA4), first_child_rel=child_rel, next_sibling_rel=sib_rel,
revision0=r.u16(off + 0xB0), revision1=r.u16(off + 0xB2), source_value=r.u32(off + 0xB4), reserved_tail=r.u32s(off + 0xB8, 3),
skeleton=skel, deform=deform)
if child_rel: n.child = parse_node(off + child_rel, depth + 1)
if sib_rel: n.sibling = parse_node(off + sib_rel, depth)
visiting.remove(block_off)
return n
root_block = A + root_rel
root = parse_node(root_block)
def flatten(node: Optional[XAXNode]) -> list[XAXNode]:
return [] if node is None else [node] + flatten(node.child) + flatten(node.sibling)
nodes = flatten(root)
for n in nodes:
c = 0; q = n.child
while q is not None:
c += 1; q = q.sibling
if c != n.direct_child_count: raise XboxFormatError(f'MDLI child_count mismatch at 0x{n.offset:X}: stored {n.direct_child_count}, actual {c}')
for i, rel in enumerate(skeleton_table):
actual = A + rel if rel else 0
expected = skel_index_to_node_off.get(i, 0)
if actual != expected: raise XboxFormatError(f'LSKL lookup mismatch index {i}: table 0x{actual:X}, expected 0x{expected:X}')
for i, rel in enumerate(deform_table):
actual = A + rel if rel else 0
expected = deform_index_to_node_off.get(i, 0)
if actual != expected: raise XboxFormatError(f'LDFM lookup mismatch index {i}: table 0x{actual:X}, expected 0x{expected:X}')
# Validate influence indices and per-record weight-list indices.
for wi, wl in enumerate(weights):
for w in wl:
if w.skeleton_index < 0 or w.skeleton_index >= skel_count:
raise XboxFormatError(f'weight list {wi} skeleton index {w.skeleton_index} outside skeleton_count {skel_count}')
for n in nodes:
if n.deform:
if n.deform.active_env_mask != ((1 << n.deform.env_count) - 1):
warnings.append(f'DEFM {n.deform.deform_index} active mask 0x{n.deform.active_env_mask:X} != env_count mask')
for sk in n.deform.influence_skeleton_indices:
if sk >= skel_count: raise XboxFormatError(f'DEFM influence skeleton index {sk} outside skeleton_count {skel_count}')
for env in n.deform.envs:
if env:
for rec in env.records:
if rec['weight_list_index'] >= len(weights):
raise XboxFormatError(f'VTXD weight_list_index {rec["weight_list_index"]} outside WLST count {len(weights)}')
return XAXFile(source, len(data), f'{major}.{minor}', payload, actor_flags, weights, skel_count, deform_count, skeleton_table, deform_table, root, nodes, warnings)
# -----------------------------------------------------------------------------
# Blender importer
# -----------------------------------------------------------------------------
if HAS_BLENDER:
def set_custom_property_block(obj: Any, key: str, value: Any) -> None:
try:
# Blender custom props dislike deeply nested Python objects in some
# versions. Store a compact JSON string for faithful round-trip.
obj[key] = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except Exception:
obj[key] = str(value)
def make_blender_material(xmat: XmxMaterial, xmx_path: Path, texture_root: str, load_textures: bool, recursive_texture_search: bool, model_name: str = "") -> Any:
name = sanitize_name(xmat.name or f"material_{xmat.index:03d}")
mat = bpy.data.materials.new(name)
mat.use_nodes = True
d3d_diffuse = tuple(float(v) for v in xmat.d3d_material.get("diffuse", (1, 1, 1, 1)))
source_diffuse = _argb_to_rgba_tuple(xmat.diffuse_argb)
base = d3d_diffuse if any(abs(v) > 1e-8 for v in d3d_diffuse[:3]) else source_diffuse
base = tuple(sanitize_float(float(v), 1.0) for v in base)
mat.diffuse_color = base
# Approximate fixed-function material in Blender's Principled BSDF.
try:
nodes = mat.node_tree.nodes
bsdf = nodes.get("Principled BSDF")
if bsdf:
if "Base Color" in bsdf.inputs:
bsdf.inputs["Base Color"].default_value = base
if "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = base[3]
if "Specular IOR Level" in bsdf.inputs:
bsdf.inputs["Specular IOR Level"].default_value = 1.0 if xmat.specular_enable else 0.25
elif "Specular" in bsdf.inputs:
bsdf.inputs["Specular"].default_value = 1.0 if xmat.specular_enable else 0.25
# D3D power is shininess. Translate roughly: high power -> low roughness.
power = sanitize_float(float(xmat.d3d_material.get("power", 0.0)), 0.0)
roughness = max(0.05, min(1.0, 1.0 / (1.0 + max(power, 0.0) / 16.0)))
if "Roughness" in bsdf.inputs:
bsdf.inputs["Roughness"].default_value = roughness
except Exception:
pass
if base[3] < 0.999 or xmat.src_blend_value not in (1, -1) or xmat.dst_blend_value not in (1, -1):
mat.blend_method = "BLEND"
mat.use_screen_refraction = False
mat.show_transparent_back = True
try:
mat.use_nodes = True
except Exception:
pass
# Resolve a base-color texture: try each named stage's offset-addressed ref
# against the packed archive; if none resolve (archive absent), fall back to a
# body-part-matched loose DDS. All four stage records stay in metadata.
if load_textures:
resolved: Optional[Path] = None
resolved_ref: str = ""
heuristic = False
for stage in xmat.named_textures:
tex_path = find_texture_file(stage.name or "", xmx_path, texture_root, recursive_texture_search, extract_zip=True)
if tex_path and tex_path.exists():
resolved = tex_path
resolved_ref = stage.name or ""
break
if resolved is None:
loose = find_loose_texture_for_model(model_name, xmx_path, texture_root)
if loose and loose.exists():
resolved = loose
resolved_ref = str(loose)
heuristic = True
if resolved is not None:
try:
img = bpy.data.images.load(str(resolved), check_existing=True)
nodes = mat.node_tree.nodes
links = mat.node_tree.links
bsdf = nodes.get("Principled BSDF")
texnode = nodes.new(type="ShaderNodeTexImage")
texnode.name = f"XMX BaseColor: {Path(resolved_ref or resolved).name}"
texnode.image = img
if bsdf and "Base Color" in bsdf.inputs:
links.new(texnode.outputs.get("Color"), bsdf.inputs["Base Color"])
if bsdf and "Alpha" in bsdf.inputs and texnode.outputs.get("Alpha"):
links.new(texnode.outputs.get("Alpha"), bsdf.inputs["Alpha"])
mat.blend_method = "BLEND"
mat["xmx_loaded_texture"] = str(resolved)
mat["xmx_texture_source_ref"] = resolved_ref
if heuristic:
mat["xmx_texture_heuristic"] = "loose DDS matched by model-name body part; verify"
except Exception as e:
mat["xmx_texture_load_error"] = str(e)
set_custom_property_block(mat, "xmx_material_v6", xmat.to_dict(include_words=True))
return mat
def mesh_has_vertex_colors(verts: Sequence[XmxVertex], attr: str) -> bool:
if attr == "diffuse":
return any(v.diffuse is not None for v in verts)
if attr == "specular":
return any(v.specular is not None for v in verts)
return False
def create_color_attribute(mesh: Any, name: str, domain: str, values: List[Tuple[float, float, float, float]]) -> None:
if not values:
return
try:
attr = mesh.color_attributes.new(name=name, type="BYTE_COLOR", domain=domain)
flat: List[float] = []
for col in values:
flat.extend(col)
attr.data.foreach_set("color", flat)
return
except Exception:
pass
# Legacy fallback: loop-domain vertex colors.
try:
layer = mesh.vertex_colors.new(name=name)
for i, col in enumerate(values[:len(layer.data)]):
layer.data[i].color = col
except Exception:
pass
def create_float_vector_attribute(mesh: Any, name: str, domain: str, values: List[Tuple[float, float, float]]) -> None:
try:
attr = mesh.attributes.new(name=name, type="FLOAT_VECTOR", domain=domain)
flat: List[float] = []
for v in values:
flat.extend(v)
attr.data.foreach_set("vector", flat)
except Exception:
pass
def build_mesh_object(
model: XmxModel,
xmat: XmxMaterial,
primitives: List[XmxPrimitive],
mat: Any,
collection: Any,
split_label: str,
flip_v: bool,
import_normals: bool,
import_vertex_colors: bool,
scale: float,
) -> Optional[Any]:
positions: List[Tuple[float, float, float]] = []
normals: List[Optional[Tuple[float, float, float]]] = []
uvs: List[Tuple[float, float]] = []
diffuse_cols: List[Optional[Tuple[float, float, float, float]]] = []
specular_cols: List[Optional[Tuple[float, float, float, float]]] = []
faces: List[Tuple[int, int, int]] = []
face_primitive_indices: List[int] = []
vert_base = 0
for prim in primitives:
if prim.vertices is None:
raise XmxError("build_mesh_object requires decoded vertices")
for v in prim.vertices:
p = rot_x_up(v.co)
positions.append((p[0] * scale, p[1] * scale, p[2] * scale))
normals.append(rot_x_up(v.normal) if v.normal is not None else None)
uu, vv = v.uv
uvs.append((uu, 1.0 - vv if flip_v else vv))
diffuse_cols.append(v.diffuse)
specular_cols.append(v.specular)
for tri in prim.triangles():
faces.append((tri[0] + vert_base, tri[1] + vert_base, tri[2] + vert_base))
face_primitive_indices.append(prim.primitive_index)
vert_base += prim.vertex_count
if not positions or not faces:
return None
obj_name = sanitize_name(f"{model.model_name_for_blender}_{split_label}", 96)
mesh = bpy.data.meshes.new(obj_name + "_Mesh")
mesh.from_pydata(positions, [], faces)
mesh.update(calc_edges=False)
obj = bpy.data.objects.new(obj_name, mesh)
collection.objects.link(obj)
obj.data.materials.append(mat)
for poly in mesh.polygons:
poly.material_index = 0
try:
poly.use_smooth = True
except Exception:
pass
# UVs: stored per vertex in XMX, copied to each Blender loop.
if uvs:
uv_layer = mesh.uv_layers.new(name="UV0")
loop_uvs: List[float] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
vi = mesh.loops[li].vertex_index
loop_uvs.extend(uvs[vi])
try:
uv_layer.data.foreach_set("uv", loop_uvs)
except Exception:
for i in range(len(uv_layer.data)):
uv_layer.data[i].uv = loop_uvs[i * 2:i * 2 + 2]
# Custom normals: per-loop copy from vertex normals. Only set if every
# used vertex has a normal.
if import_normals and normals and all(n is not None for n in normals):
loop_normals: List[Tuple[float, float, float]] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
vi = mesh.loops[li].vertex_index
loop_normals.append(sanitize_vec3(normals[vi] or (0.0, 0.0, 1.0)))
try:
mesh.polygons.foreach_set("use_smooth", [True] * len(mesh.polygons))
mesh.normals_split_custom_set(loop_normals)
mesh.use_auto_smooth = True
except Exception:
try:
mesh.normals_split_custom_set(loop_normals)
except Exception:
pass
# Vertex colors: create both point-domain colors and loop-domain fallback
# where available. Missing colors default to white.
if import_vertex_colors:
if any(c is not None for c in diffuse_cols):
point_values = [c if c is not None else (1.0, 1.0, 1.0, 1.0) for c in diffuse_cols]
create_color_attribute(mesh, "xmx_diffuse", "POINT", point_values)
loop_values: List[Tuple[float, float, float, float]] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
loop_values.append(point_values[mesh.loops[li].vertex_index])
create_color_attribute(mesh, "xmx_diffuse_loop", "CORNER", loop_values)
if any(c is not None for c in specular_cols):
point_values = [c if c is not None else (0.0, 0.0, 0.0, 1.0) for c in specular_cols]
create_color_attribute(mesh, "xmx_specular", "POINT", point_values)
# Preserve primitive membership per face as an INT polygon attribute if possible.
try:
attr = mesh.attributes.new(name="xmx_primitive_index", type="INT", domain="FACE")
attr.data.foreach_set("value", face_primitive_indices)
except Exception:
pass
# Material and primitive vertex groups are useful for selecting authored
# chunks. These are not skin weights; they are selection/group metadata.
try:
vg_mat = obj.vertex_groups.new(name=f"material_{xmat.index:03d}")
vg_mat.add(list(range(len(positions))), 1.0, "ADD")
offset = 0
for prim in primitives:
vg = obj.vertex_groups.new(name=f"prim_{xmat.index:03d}_{prim.primitive_index:02d}")
vg.add(list(range(offset, offset + prim.vertex_count)), 1.0, "ADD")
offset += prim.vertex_count
except Exception:
pass
set_custom_property_block(obj, "xmx_material_v6", xmat.to_dict(include_words=True))
set_custom_property_block(obj, "xmx_primitives_v6", [p.to_dict(include_words=True) for p in primitives])
obj["xmx_note_skinning"] = "No skeleton or per-vertex skin weights are serialized in confirmed HOD3 PC XMX/XMDL v6 samples. Vertex groups here are material/primitive selection groups, not skin weights."
return obj
def create_bounds_objects(model: XmxModel, collection: Any, scale: float) -> None:
# Use empties instead of mesh spheres; cheap and preserves exact center/radius.
def add_sphere_empty(name: str, sphere: Tuple[float, float, float, float], parent: Optional[Any] = None) -> Any:
empty = bpy.data.objects.new(sanitize_name(name), None)
empty.empty_display_type = "SPHERE"
empty.empty_display_size = max(float(sphere[3]) * scale, 0.001)
c = rot_x_up((sphere[0], sphere[1], sphere[2]))
empty.location = (c[0] * scale, c[1] * scale, c[2] * scale)
if parent:
empty.parent = parent
collection.objects.link(empty)
return empty
root_empty = add_sphere_empty(f"{model.model_name_for_blender}_model_bounds", model.sphere)
for mat in model.materials:
add_sphere_empty(f"mat_{mat.index:03d}_bounds", mat.sphere, root_empty)
def create_armature_from_sidecar(sidecar: Dict[str, Any], collection: Any, model_name: str, scale: float) -> Optional[Any]:
# Simple, documented sidecar schema:
# {"bones":[{"name":"root","parent":null,"head":[0,0,0],"tail":[0,0,1]}]}
bones = sidecar.get("bones") if isinstance(sidecar, dict) else None
if not bones or not isinstance(bones, list):
return None
arm_data = bpy.data.armatures.new(sanitize_name(model_name + "_ArmatureData"))
arm_obj = bpy.data.objects.new(sanitize_name(model_name + "_Armature"), arm_data)
collection.objects.link(arm_obj)
bpy.context.view_layer.objects.active = arm_obj
arm_obj.select_set(True)
try:
bpy.ops.object.mode_set(mode="EDIT")
created = {}
for b in bones:
if not isinstance(b, dict):
continue
name = sanitize_name(str(b.get("name", f"bone_{len(created):03d}")))
eb = arm_data.edit_bones.new(name)
head = b.get("head", [0, 0, 0])
tail = b.get("tail", [0, 0, 1])
h = rot_x_up((float(head[0]), float(head[1]), float(head[2])))
t = rot_x_up((float(tail[0]), float(tail[1]), float(tail[2])))
eb.head = (h[0] * scale, h[1] * scale, h[2] * scale)
eb.tail = (t[0] * scale, t[1] * scale, t[2] * scale)
created[name] = eb
for b in bones:
if not isinstance(b, dict):
continue
name = sanitize_name(str(b.get("name", "")))
parent = b.get("parent")
if parent is not None and name in created:
parent_name = sanitize_name(str(parent)) if not isinstance(parent, int) else None
if isinstance(parent, int) and 0 <= parent < len(bones):
parent_name = sanitize_name(str(bones[parent].get("name", "")))
if parent_name in created:
created[name].parent = created[parent_name]
bpy.ops.object.mode_set(mode="OBJECT")
arm_obj["xmx_sidecar_skeleton"] = json.dumps(sidecar, ensure_ascii=False)
return arm_obj
except Exception:
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
pass
return arm_obj
def import_xmx_to_blender(
filepath: str,
*,
split_mode: str = "MATERIAL",
flip_v: bool = True,
import_normals: bool = True,
import_vertex_colors: bool = True,
load_textures: bool = True,
texture_root: str = "",
recursive_texture_search: bool = False,
create_bounds: bool = False,
import_sidecar_skeleton: bool = True,
scale: float = 0.1,
) -> set:
path = Path(filepath)
data = path.read_bytes()
model = parse_xmx(data, str(path), decode_vertices=True)
root_collection = bpy.data.collections.new(model.model_name_for_blender)
bpy.context.scene.collection.children.link(root_collection)
set_custom_property_block(root_collection, "xmx_model_v6", model.to_dict(include_materials=False))
materials = [make_blender_material(m, path, texture_root, load_textures, recursive_texture_search, model.name or "") for m in model.materials]
created_objects: List[Any] = []
if split_mode == "MODEL":
# One object for all geometry, but each material is appended as a slot
# and assigned per polygon. This is fastest for huge stage chunks.
positions: List[Tuple[float, float, float]] = []
normals: List[Optional[Tuple[float, float, float]]] = []
uvs: List[Tuple[float, float]] = []
diffuse_cols: List[Optional[Tuple[float, float, float, float]]] = []
specular_cols: List[Optional[Tuple[float, float, float, float]]] = []
faces: List[Tuple[int, int, int]] = []
face_mats: List[int] = []
vert_base = 0
for xmat in model.materials:
for prim in xmat.primitives:
assert prim.vertices is not None
for v in prim.vertices:
p = rot_x_up(v.co)
positions.append((p[0] * scale, p[1] * scale, p[2] * scale))
normals.append(rot_x_up(v.normal) if v.normal is not None else None)
uvs.append((v.uv[0], 1.0 - v.uv[1] if flip_v else v.uv[1]))
diffuse_cols.append(v.diffuse)
specular_cols.append(v.specular)
for tri in prim.triangles():
faces.append((tri[0] + vert_base, tri[1] + vert_base, tri[2] + vert_base))
face_mats.append(xmat.index)
vert_base += prim.vertex_count
mesh = bpy.data.meshes.new(model.model_name_for_blender + "_Mesh")
mesh.from_pydata(positions, [], faces)
mesh.update(calc_edges=False)
obj = bpy.data.objects.new(model.model_name_for_blender, mesh)
root_collection.objects.link(obj)
for mat in materials:
obj.data.materials.append(mat)
for poly, mi in zip(mesh.polygons, face_mats):
poly.material_index = mi
poly.use_smooth = True
if uvs:
uv_layer = mesh.uv_layers.new(name="UV0")
flat_uv: List[float] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
flat_uv.extend(uvs[mesh.loops[li].vertex_index])
uv_layer.data.foreach_set("uv", flat_uv)
if import_normals and all(n is not None for n in normals):
loop_normals: List[Tuple[float, float, float]] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
loop_normals.append(sanitize_vec3(normals[mesh.loops[li].vertex_index] or (0.0, 0.0, 1.0)))
try:
mesh.normals_split_custom_set(loop_normals)
mesh.use_auto_smooth = True
except Exception:
pass
if import_vertex_colors and any(c is not None for c in diffuse_cols):
vals = [c if c is not None else (1, 1, 1, 1) for c in diffuse_cols]
create_color_attribute(mesh, "xmx_diffuse", "POINT", vals)
if import_vertex_colors and any(c is not None for c in specular_cols):
vals = [c if c is not None else (0, 0, 0, 1) for c in specular_cols]
create_color_attribute(mesh, "xmx_specular", "POINT", vals)
set_custom_property_block(obj, "xmx_model_v6", model.to_dict(include_materials=True))
obj["xmx_note_skinning"] = "No skeleton or per-vertex skin weights are serialized in confirmed HOD3 PC XMX/XMDL v6 samples."
created_objects.append(obj)
else:
for xmat in model.materials:
if not xmat.primitives:
continue
if split_mode == "PRIMITIVE":
for prim in xmat.primitives:
obj = build_mesh_object(model, xmat, [prim], materials[xmat.index], root_collection, f"mat{xmat.index:03d}_prim{prim.primitive_index:02d}", flip_v, import_normals, import_vertex_colors, scale)
if obj:
created_objects.append(obj)
else: # MATERIAL default
obj = build_mesh_object(model, xmat, xmat.primitives, materials[xmat.index], root_collection, f"mat{xmat.index:03d}_{sanitize_name(xmat.name or 'material')}", flip_v, import_normals, import_vertex_colors, scale)
if obj:
created_objects.append(obj)
armature = None
if import_sidecar_skeleton:
sidecar = load_sidecar(path)
if sidecar:
armature = create_armature_from_sidecar(sidecar, root_collection, model.model_name_for_blender, scale)
# Optional sidecar weights can be applied by exact object/vertex index.
# Schema intentionally explicit to avoid guessing from XMX runtime fields:
# {"weights":{"ObjectName":{"0":[["Bone",1.0]], ...}}}
if armature and isinstance(sidecar.get("weights"), dict):
for obj in created_objects:
weights_for_obj = sidecar["weights"].get(obj.name) or sidecar["weights"].get("*")
if not isinstance(weights_for_obj, dict):
continue
groups: Dict[str, Any] = {}
for bone in armature.data.bones:
groups[bone.name] = obj.vertex_groups.new(name=bone.name)
for vi_s, assignments in weights_for_obj.items():
try:
vi = int(vi_s)
except Exception:
continue
if not isinstance(assignments, list):
continue
for item in assignments:
if isinstance(item, list) and len(item) >= 2:
bn, wt = str(item[0]), float(item[1])
if bn in groups:
groups[bn].add([vi], wt, "ADD")
mod = obj.modifiers.new("XMX Sidecar Armature", "ARMATURE")
mod.object = armature
if create_bounds:
create_bounds_objects(model, root_collection, scale)
# Select created model objects.
try:
bpy.ops.object.select_all(action="DESELECT")
for obj in created_objects:
obj.select_set(True)
if created_objects:
bpy.context.view_layer.objects.active = created_objects[0]
except Exception:
pass
# Metadata text block for debugging/exporter development.
text_name = sanitize_name(model.model_name_for_blender + "_XMX_Metadata.json", 128)
text = bpy.data.texts.new(text_name)
text.write(json.dumps(model.to_dict(include_materials=True), indent=2, ensure_ascii=False))
return {"FINISHED"}
# -------------------------------------------------------------------------
# Xbox X3X/XAX Blender import path
# -------------------------------------------------------------------------
def wrap_embedded_mdel_as_xmx(mdel_chunk: bytes) -> bytes:
"""Embedded X3X MDEL chunks are the XMDL model body without the 0x10 wrapper."""
return b"LDMX" + b"XOBX" + struct.pack("<HHI", 0, 6, len(mdel_chunk)) + mdel_chunk
def build_xmx_model_single_object(
model: XmxModel,
materials: List[Any],
collection: Any,
object_name: str,
*,
flip_v: bool,
import_normals: bool,
import_vertex_colors: bool,
scale: float,
owner_world: Optional[Sequence[Sequence[float]]] = None,
only_material_index: Optional[int] = None,
) -> Optional[Any]:
positions: List[Tuple[float, float, float]] = []
normals: List[Optional[Tuple[float, float, float]]] = []
uvs: List[Tuple[float, float]] = []
diffuse_cols: List[Optional[Tuple[float, float, float, float]]] = []
specular_cols: List[Optional[Tuple[float, float, float, float]]] = []
faces: List[Tuple[int, int, int]] = []
face_mats: List[int] = []
vert_base = 0
for xmat in model.materials:
if only_material_index is not None and xmat.index != only_material_index:
continue
for prim in xmat.primitives:
if prim.vertices is None:
continue
for v in prim.vertices:
co = apply_world_row(v.co, owner_world) if owner_world is not None else v.co
p = rot_x_up(co)
positions.append((p[0] * scale, p[1] * scale, p[2] * scale))
if v.normal is not None:
nrm = apply_rot_row(v.normal, owner_world) if owner_world is not None else v.normal
normals.append(rot_x_up(nrm))
else:
normals.append(None)
uvs.append((v.uv[0], 1.0 - v.uv[1] if flip_v else v.uv[1]))
diffuse_cols.append(v.diffuse)
specular_cols.append(v.specular)
for tri in prim.triangles():
faces.append((tri[0] + vert_base, tri[1] + vert_base, tri[2] + vert_base))
# single-material split collapses to slot 0
face_mats.append(0 if only_material_index is not None else xmat.index)
vert_base += prim.vertex_count
if not positions or not faces:
return None
obj_name = sanitize_name(object_name, 96)
mesh = bpy.data.meshes.new(obj_name + "_Mesh")
mesh.from_pydata(positions, [], faces)
mesh.update(calc_edges=False)
obj = bpy.data.objects.new(obj_name, mesh)
collection.objects.link(obj)
obj_materials = [materials[only_material_index]] if (only_material_index is not None and only_material_index < len(materials)) else materials
for mat in obj_materials:
obj.data.materials.append(mat)
for poly, mi in zip(mesh.polygons, face_mats):
poly.material_index = mi if mi < len(obj_materials) else 0
try:
poly.use_smooth = True
except Exception:
pass
if uvs:
uv_layer = mesh.uv_layers.new(name="UV0")
flat_uv: List[float] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
flat_uv.extend(uvs[mesh.loops[li].vertex_index])
try:
uv_layer.data.foreach_set("uv", flat_uv)
except Exception:
for i in range(len(uv_layer.data)):
uv_layer.data[i].uv = flat_uv[i * 2:i * 2 + 2]
if import_normals and normals and all(n is not None for n in normals):
loop_normals: List[Tuple[float, float, float]] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
loop_normals.append(sanitize_vec3(normals[mesh.loops[li].vertex_index] or (0.0, 0.0, 1.0)))
try:
mesh.polygons.foreach_set("use_smooth", [True] * len(mesh.polygons))
mesh.normals_split_custom_set(loop_normals)
mesh.use_auto_smooth = True
except Exception:
try:
mesh.normals_split_custom_set(loop_normals)
except Exception:
pass
if import_vertex_colors:
if any(c is not None for c in diffuse_cols):
point_values = [c if c is not None else (1.0, 1.0, 1.0, 1.0) for c in diffuse_cols]
create_color_attribute(mesh, "xmx_diffuse", "POINT", point_values)
loop_values: List[Tuple[float, float, float, float]] = []
for poly in mesh.polygons:
for li in poly.loop_indices:
loop_values.append(point_values[mesh.loops[li].vertex_index])
create_color_attribute(mesh, "xmx_diffuse_loop", "CORNER", loop_values)
if any(c is not None for c in specular_cols):
point_values = [c if c is not None else (0.0, 0.0, 0.0, 1.0) for c in specular_cols]
create_color_attribute(mesh, "xmx_specular", "POINT", point_values)
set_custom_property_block(obj, "xmx_model_v6", model.to_dict(include_materials=True))
return obj
def _xax_flatten_pairs(root: Any) -> List[Tuple[Any, Optional[Any]]]:
out: List[Tuple[Any, Optional[Any]]] = []
def walk(node: Any, parent: Optional[Any]) -> None:
cur = node
while cur is not None:
out.append((cur, parent))
if cur.child is not None:
walk(cur.child, cur)
cur = cur.sibling
if root is not None:
walk(root, None)
return out
# rot_x_up basis (x,y,z)->(x,-z,y) as a proper column-vector rotation, and its
# homogeneous 4x4 form B. Used to move the SKEL bind-world matrices from the
# file's D3D/model frame into Blender's frame by similarity: Mb = B * M * B^-1.
_XAX_B = _MU_Matrix(((1, 0, 0, 0),
(0, 0, -1, 0),
(0, 1, 0, 0),
(0, 0, 0, 1))) if HAS_BLENDER else None
# Bone-local -> joint-local axis remap. The rig stores per-joint local +X as the
# "down the bone" direction (verified against the executable's deform data: each
# parent joint's local +X axis aligns with the direction to its child, dot ~= 1.0).
# Blender bones point along local +Y, so we map bone(X,Y,Z) -> joint(Z,X,Y) (a proper
# cyclic permutation, det +1) so that bone +Y coincides with joint +X.
_XAX_AXIS_REMAP = _MU_Matrix(((0, 1, 0, 0),
(0, 0, 1, 0),
(1, 0, 0, 0),
(0, 0, 0, 1))) if HAS_BLENDER else None
def _xax_skel_world_matrix(node: Any, scale: float) -> Any:
"""Blender-space bind-world matrix for a skeletal node.
The SKEL block stores four 4x4 matrices (row-major):
[0] bind/current world, [1] inverse of [0],
[2] bind world, [3] inverse bind.
For a static/rest import [0]==[2] and [1]==[3] (verified numerically), so
index 2 (true bind world) is used. The matrix is absolute world-space
(the whole skeleton is globally coherent -- NOT parent-relative), so no
hierarchical accumulation is needed here.
"""
mats = node.skeleton.matrices
vals = mats[2] if len(mats) > 2 else mats[0]
# file is row-major/row-vector -> transpose into mathutils column-major.
m = _MU_Matrix((tuple(vals[0:4]), tuple(vals[4:8]),
tuple(vals[8:12]), tuple(vals[12:16]))).transposed()
m = _XAX_B @ m @ _XAX_B.inverted() # into Blender frame
t = m.translation.copy()
m = m @ _XAX_AXIS_REMAP # bone +Y := joint +X
m.translation = t * float(scale) # match mesh scaling (translation only)
return m
def create_xax_armature(xax: XAXFile, collection: Any, base_name: str, scale: float) -> Tuple[Optional[Any], Dict[int, str]]:
pairs = _xax_flatten_pairs(xax.root)
nodes_with_skel = [(node, parent) for node, parent in pairs if node.skeleton is not None]
if not nodes_with_skel:
return None, {}
# Nearest skeletal ancestor for each skeletal node (helper nodes chn_/eff_
# carry no SKEL and must be skipped when wiring the armature hierarchy).
parent_of: Dict[int, Optional[Any]] = {id(n): p for n, p in pairs}
def nearest_skel_ancestor(p: Any) -> Optional[Any]:
while p is not None and p.skeleton is None:
p = parent_of.get(id(p))
return p
skel_children: Dict[int, list] = {}
for node, parent in nodes_with_skel:
anc = nearest_skel_ancestor(parent)
if anc is not None:
skel_children.setdefault(id(anc), []).append(node)
world_by_node: Dict[int, Any] = {n.offset: _xax_skel_world_matrix(n, scale) for n, _ in nodes_with_skel}
# Reasonable default bone length (median inter-joint distance) for leaves and
# zero-length parents, so no bone is auto-culled by Blender for degeneracy.
lens = []
for node, _ in nodes_with_skel:
for c in skel_children.get(id(node), []):
d = (world_by_node[c.offset].translation - world_by_node[node.offset].translation).length
if d > 1e-6:
lens.append(d)
default_len = (sorted(lens)[len(lens) // 2] if lens else max(0.1 * scale, 0.01)) * 0.5
if default_len <= 0:
default_len = max(0.1 * scale, 0.01)
arm_data = bpy.data.armatures.new(sanitize_name(base_name + "_XAX_Armature", 96))
arm_obj = bpy.data.objects.new(arm_data.name, arm_data)
collection.objects.link(arm_obj)
bpy.context.view_layer.objects.active = arm_obj
arm_obj.select_set(True)
bone_by_node: Dict[int, Any] = {}
bone_name_by_index: Dict[int, str] = {}
try:
bpy.ops.object.mode_set(mode="EDIT")
for node, _ in nodes_with_skel:
idx = int(node.skeleton.skeleton_index)
bname = sanitize_name(node.name or f"skel_{idx:03d}", 63)
bone = arm_data.edit_bones.new(bname)
mw = world_by_node[node.offset]
head = mw.translation
# Length: distance to a single skeletal child, else default.
kids = skel_children.get(id(node), [])
length = default_len
if len(kids) == 1:
d = (world_by_node[kids[0].offset].translation - head).length
if d > 1e-6:
length = d
# Seed head/tail to establish length, then let the full matrix set
# head position, bone direction (+Y = joint +X) and roll exactly.
bone.head = head
bone.tail = head + _MU_Vector((0.0, length, 0.0))
bone.matrix = mw
# matrix assignment can renormalize length; restore it explicitly.
bone.length = length
bone_by_node[node.offset] = bone
bone_name_by_index[idx] = bname
for node, _ in nodes_with_skel:
bone = bone_by_node.get(node.offset)
anc = nearest_skel_ancestor(parent_of.get(id(node)))
if bone is not None and anc is not None and anc.offset in bone_by_node:
bone.parent = bone_by_node[anc.offset]
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
try:
bpy.ops.object.mode_set(mode="OBJECT")
except Exception:
pass
set_custom_property_block(arm_obj, "xax_actor_v7", xax.to_dict())
return arm_obj, bone_name_by_index
def apply_xax_env_weights(obj: Any, env: XAXEnvironment, xax: XAXFile, armature: Optional[Any], bone_name_by_index: Dict[int, str], scale: float = 1.0, rigid_bone_index: Optional[int] = None, owner_world: Optional[Sequence[Sequence[float]]] = None) -> None:
if armature is None or not bone_name_by_index:
set_custom_property_block(obj, "xax_deform_environment", env.to_dict())
return
# VTXD.vertex_index numbers a separate deform/skin domain, not the render
# vertex buffer. Each record's base_position coincides exactly with the render
# vertex it drives (verified dist^2 == 0), so bind by position, transformed by
# the same rot_x_up()*scale used to build the mesh. Coincident render verts
# (UV/normal seams) share the weights. Render verts not covered by any record
# are rigidly bound to the model's owning node bone (rigid_bone_index) -- in
# DOA skin models only a subset of verts is skinned; the rest follow the base
# joint 1:1, so leaving them unweighted would detach them under animation.
def qkey(x: float, y: float, z: float) -> Tuple[int, int, int]:
return (round(x * 10000.0), round(y * 10000.0), round(z * 10000.0))
verts = obj.data.vertices if hasattr(obj.data, "vertices") else []
vcount = len(verts)
pos_to_verts: Dict[Tuple[int, int, int], List[int]] = {}
for vidx, v in enumerate(verts):
co = v.co
pos_to_verts.setdefault(qkey(co[0], co[1], co[2]), []).append(vidx)
# bone_name -> {vertex_index: weight}. Overwrite (not accumulate) within a
# bone so duplicate deform records (same position, identical weights) don't
# double-count; different bones on the same vertex coexist as separate keys.
bone_vw: Dict[str, Dict[int, float]] = {}
matched_verts: set = set()
unmatched_records = 0
for rec in env.records:
wi = int(rec.get("weight_list_index", -1))
if wi < 0 or wi >= len(xax.weights):
continue
bp = rec.get("base_position") or (0.0, 0.0, 0.0)
bpw = apply_world_row((float(bp[0]), float(bp[1]), float(bp[2])), owner_world) if owner_world is not None else (float(bp[0]), float(bp[1]), float(bp[2]))
tp = rot_x_up(bpw)
targets = pos_to_verts.get(qkey(tp[0] * scale, tp[1] * scale, tp[2] * scale))
if not targets:
unmatched_records += 1
continue
for t in targets:
matched_verts.add(t)
for w in xax.weights[wi]:
bname = bone_name_by_index.get(int(w.skeleton_index))
if bname is None:
continue
d = bone_vw.setdefault(bname, {})
for t in targets:
d[t] = float(w.weight)
# Rigid fallback for render verts no record touched.
rigid_name = bone_name_by_index.get(int(rigid_bone_index)) if rigid_bone_index is not None else None
rigid_count = 0
if rigid_name is not None:
d = bone_vw.setdefault(rigid_name, {})
for vidx in range(vcount):
if vidx not in matched_verts:
d[vidx] = 1.0
rigid_count += 1
applied = 0
for bname, vw in bone_vw.items():
try:
grp = obj.vertex_groups.new(name=bname)
except Exception:
continue
# Batch by weight value to minimize add() calls.
by_weight: Dict[int, List[int]] = {}
for vidx, wv in vw.items():
by_weight.setdefault(round(wv * 100000.0), []).append(vidx)
for wq, vlist in by_weight.items():
try:
grp.add(vlist, wq / 100000.0, "REPLACE")
applied += len(vlist)
except Exception:
pass
if applied:
try:
mod = obj.modifiers.new("XAX Armature", "ARMATURE")
mod.object = armature
except Exception:
pass
obj["xax_weight_assignments_applied"] = applied
obj["xax_weight_records_unmatched"] = unmatched_records
obj["xax_rigid_fallback_verts"] = rigid_count
set_custom_property_block(obj, "xax_deform_environment", env.to_dict())
def import_x3x_to_blender(
filepath: str,
*,
import_matching_xax: bool = True,
xax_filepath: str = "",
create_xax_armature_option: bool = True,
apply_xax_weights_option: bool = True,
flip_v: bool = True,
import_normals: bool = True,
import_vertex_colors: bool = True,
load_textures: bool = True,
texture_root: str = "",
recursive_texture_search: bool = False,
create_node_empties: bool = True,
split_materials: bool = True,
scale: float = 0.1,
) -> set:
path = Path(filepath)
data = path.read_bytes()
x3x = parse_x3x(data, str(path), inspect_models=True)
xax: Optional[XAXFile] = None
if import_matching_xax:
xp = Path(xax_filepath) if xax_filepath else path.with_suffix(".xax")
if xp.exists():
xax = parse_xax(xp.read_bytes(), str(xp))
root_collection = bpy.data.collections.new(sanitize_name(path.stem + "_X3X", 96))
bpy.context.scene.collection.children.link(root_collection)
set_custom_property_block(root_collection, "x3x_scene_v6", x3x.to_dict())
if xax:
set_custom_property_block(root_collection, "xax_actor_v7_summary", xax.to_dict())
# Node empties preserve OBJ3 hierarchy. Geometry is parented below the matching node.
empty_by_node: Dict[int, Any] = {}
if create_node_empties:
for node in x3x.nodes:
ename = sanitize_name(node.name or f"OBJ3_{node.offset:06X}", 96)
empty = bpy.data.objects.new(ename, None)
empty.empty_display_type = "PLAIN_AXES"
empty.empty_display_size = 0.1 * scale
root_collection.objects.link(empty)
set_custom_property_block(empty, "x3x_obj3_node", node.to_dict(False))
empty_by_node[node.offset] = empty
def parent_walk(node: Any, parent_empty: Optional[Any]) -> None:
cur = node
while cur is not None:
e = empty_by_node.get(cur.offset)
if e is not None:
e.parent = parent_empty
if cur.child is not None:
parent_walk(cur.child, e)
cur = cur.sibling
parent_walk(x3x.root, None)
armature = None
bone_name_by_index: Dict[int, str] = {}
if xax and create_xax_armature_option:
armature, bone_name_by_index = create_xax_armature(xax, root_collection, path.stem, scale)
# Each embedded model is stored in the LOCAL space of its owning skeletal node.
# Map X3X node name -> that node's SKEL bind-world matrix (from the XAX), so the
# mesh can be lifted into world space to coincide with the skeleton. X3X and XAX
# are parallel trees with matching node names.
xax_world_by_name: Dict[str, list] = {}
if xax:
for xn in xax.nodes:
if xn.skeleton is not None and xn.name:
xax_world_by_name[xn.name] = mat16_to_rows(xn.skeleton.matrices[0])
# X3X parent chain (by name) for owner-world fallback when a model's own node
# carries no skeleton -- walk up to the nearest skeletal ancestor.
x3x_parent_name: Dict[int, Optional[str]] = {}
def _x3x_walk(nd: Any, pname: Optional[str]) -> None:
cur = nd
while cur is not None:
x3x_parent_name[cur.offset] = pname
if cur.child is not None:
_x3x_walk(cur.child, cur.name)
cur = cur.sibling
if x3x.root is not None:
_x3x_walk(x3x.root, None)
x3x_node_by_name: Dict[str, Any] = {n.name: n for n in x3x.nodes if n.name}
def owner_world_for(node: Any) -> Optional[list]:
seen = 0
cur = node
while cur is not None and seen < 4096:
if cur.name in xax_world_by_name:
return xax_world_by_name[cur.name]
pname = x3x_parent_name.get(cur.offset)
cur = x3x_node_by_name.get(pname) if pname else None
seen += 1
return None
created_objects: List[Any] = []
objects_by_model_name: Dict[str, List[Any]] = {}
owner_world_by_obj: Dict[int, Optional[list]] = {}
# Import every embedded MDEL/XMDL body through the same exact v6 material/primitive parser.
for node in x3x.nodes:
parent_empty = empty_by_node.get(node.offset)
ow = owner_world_for(node)
for mb in node.models:
mdel = data[mb.mdel_offset:mb.mdel_offset + mb.size]
wrapped = wrap_embedded_mdel_as_xmx(mdel)
model = parse_xmx(wrapped, f"{path}::{node.name or node.offset}:slot{mb.slot}", decode_vertices=True)
materials = [make_blender_material(m, path, texture_root, load_textures, recursive_texture_search, model.name or "") for m in model.materials]
label = sanitize_name(f"{node.name or 'node'}_{model.name or mb.name or 'model'}_slot{mb.slot}", 96)
if split_materials and len(model.materials) > 1:
built = []
for xmat in model.materials:
mlabel = sanitize_name(f"{label}_m{xmat.index:02d}_{xmat.name or 'mat'}", 96)
o = build_xmx_model_single_object(model, materials, root_collection, mlabel, flip_v=flip_v, import_normals=import_normals, import_vertex_colors=import_vertex_colors, scale=scale, owner_world=ow, only_material_index=xmat.index)
if o:
built.append(o)
else:
o = build_xmx_model_single_object(model, materials, root_collection, label, flip_v=flip_v, import_normals=import_normals, import_vertex_colors=import_vertex_colors, scale=scale, owner_world=ow)
built = [o] if o else []
for obj in built:
if parent_empty is not None:
obj.parent = parent_empty
obj["xbox_source_format"] = "X3X embedded MDEL/XMDL"
obj["x3x_node_name"] = node.name or ""
obj["x3x_model_slot"] = int(mb.slot)
set_custom_property_block(obj, "x3x_model_block", mb.to_dict())
created_objects.append(obj)
owner_world_by_obj[id(obj)] = ow
if model.name:
objects_by_model_name.setdefault(model.name, []).append(obj)
# Attach XAX deformation metadata and conservative vertex groups where names and indices line up.
if xax:
for node in xax.nodes:
if node.deform:
rigid_idx = node.skeleton.skeleton_index if node.skeleton else None
env_owner_world = mat16_to_rows(node.skeleton.matrices[0]) if node.skeleton else None
for env in node.deform.envs:
if env and env.name:
for obj in objects_by_model_name.get(env.name, []):
if apply_xax_weights_option:
apply_xax_env_weights(obj, env, xax, armature, bone_name_by_index, scale, rigid_idx, env_owner_world)
else:
set_custom_property_block(obj, "xax_deform_environment", env.to_dict())
text = bpy.data.texts.new(sanitize_name(path.stem + "_XAX_Metadata.json", 128))
text.write(json.dumps(xax.to_dict(), indent=2, ensure_ascii=False))
text = bpy.data.texts.new(sanitize_name(path.stem + "_X3X_Metadata.json", 128))
text.write(json.dumps(x3x.to_dict(), indent=2, ensure_ascii=False))
try:
bpy.ops.object.select_all(action="DESELECT")
for obj in created_objects:
obj.select_set(True)
if created_objects:
bpy.context.view_layer.objects.active = created_objects[0]
except Exception:
pass
return {"FINISHED"}
class IMPORT_OT_xmx_xmdl_v6(bpy.types.Operator, ImportHelper):
"""Import Sega XMDL/XBOX v6 .xmx files from HOD3 PC."""
bl_idname = "import_scene.xmx_xmdl_v6"
bl_label = "Import Sega XMDL/XBOX v6 (.xmx)"
bl_options = {"PRESET", "UNDO"}
filename_ext = ".xmx"
filter_glob: StringProperty(default="*.xmx", options={"HIDDEN"})
split_mode: EnumProperty(
name="Split Mode",
description="How to create Blender objects",
items=(
("MATERIAL", "One object per material", "Default; merges all primitives owned by each material"),
("PRIMITIVE", "One object per primitive", "Maximum structural preservation; more objects"),
("MODEL", "Single object", "Fastest; assigns material slots per face"),
),
default="MATERIAL",
)
scale: FloatProperty(name="Scale", default=0.1, min=0.000001, max=1000000.0, description="Game units are ~decimeters; 0.1 maps to Blender meters")
flip_v: BoolProperty(name="Flip V", default=True, description="Convert Direct3D UV origin to Blender-style display")
import_normals: BoolProperty(name="Import normals", default=True)
import_vertex_colors: BoolProperty(name="Import vertex colors", default=True)
load_textures: BoolProperty(name="Load DDS textures", default=True, description="Resolve offset-addressed textures from the packed archive (xtx_*.zip) if present, else match a loose DDS by body part")
texture_root: StringProperty(name="Texture root", default="", subtype="DIR_PATH")
recursive_texture_search: BoolProperty(name="Recursive texture search", default=False, description="Slow; disabled by default")
create_bounds: BoolProperty(name="Create bound empties", default=False)
import_sidecar_skeleton: BoolProperty(name="Import sidecar skeleton", default=True, description="XMX itself has no confirmed bones/weights; this reads optional .skeleton.json/.bones.json")
def execute(self, context: Any) -> set:
try:
return import_xmx_to_blender(
self.filepath,
split_mode=self.split_mode,
flip_v=self.flip_v,
import_normals=self.import_normals,
import_vertex_colors=self.import_vertex_colors,
load_textures=self.load_textures,
texture_root=self.texture_root,
recursive_texture_search=self.recursive_texture_search,
create_bounds=self.create_bounds,
import_sidecar_skeleton=self.import_sidecar_skeleton,
scale=self.scale,
)
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
class IMPORT_OT_hod3_xbox_x3x(bpy.types.Operator, ImportHelper):
"""Import HOD3 Xbox .x3x scene/model files and optional paired .xax deformation data."""
bl_idname = "import_scene.hod3_xbox_x3x"
bl_label = "Import HOD3 Xbox X3X/XAX (.x3x)"
bl_options = {"PRESET", "UNDO"}
filename_ext = ".x3x"
filter_glob: StringProperty(default="*.x3x", options={"HIDDEN"})
scale: FloatProperty(name="Scale", default=0.1, min=0.000001, max=1000000.0, description="Game units are ~decimeters; 0.1 maps to Blender meters")
flip_v: BoolProperty(name="Flip V", default=True)
import_normals: BoolProperty(name="Import normals", default=True)
import_vertex_colors: BoolProperty(name="Import vertex colors", default=True)
load_textures: BoolProperty(name="Load DDS textures", default=True)
texture_root: StringProperty(name="Texture root", default="", subtype="DIR_PATH")
recursive_texture_search: BoolProperty(name="Recursive texture search", default=False)
create_node_empties: BoolProperty(name="Create OBJ3 node empties", default=True)
split_materials: BoolProperty(name="Split object per material", default=True, description="One object per material so each keeps its own UV island + texture (avoids overlapping UVs)")
import_matching_xax: BoolProperty(name="Import matching .xax", default=True)
xax_filepath: StringProperty(name="Optional .xax path", default="", subtype="FILE_PATH")
create_xax_armature_option: BoolProperty(name="Create XAX armature", default=True)
apply_xax_weights_option: BoolProperty(name="Apply XAX weights when direct indices match", default=True)
def execute(self, context: Any) -> set:
try:
return import_x3x_to_blender(
self.filepath,
import_matching_xax=self.import_matching_xax,
xax_filepath=self.xax_filepath,
create_xax_armature_option=self.create_xax_armature_option,
apply_xax_weights_option=self.apply_xax_weights_option,
flip_v=self.flip_v,
import_normals=self.import_normals,
import_vertex_colors=self.import_vertex_colors,
load_textures=self.load_textures,
texture_root=self.texture_root,
recursive_texture_search=self.recursive_texture_search,
create_node_empties=self.create_node_empties,
split_materials=self.split_materials,
scale=self.scale,
)
except Exception as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
def menu_func_import(self: Any, context: Any) -> None:
self.layout.operator(IMPORT_OT_xmx_xmdl_v6.bl_idname, text="HOD3 PC Sega XMDL/XBOX v6 (.xmx)")
self.layout.operator(IMPORT_OT_hod3_xbox_x3x.bl_idname, text="HOD3 Xbox X3X/XAX (.x3x)")
CLASSES = (IMPORT_OT_xmx_xmdl_v6, IMPORT_OT_hod3_xbox_x3x)
def register() -> None:
# Unregister old versions if the user ran this repeatedly from Text Editor.
try:
unregister()
except Exception:
pass
for cls in CLASSES:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
def unregister() -> None:
try:
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
except Exception:
pass
for cls in reversed(CLASSES):
try:
bpy.utils.unregister_class(cls)
except Exception:
pass
else:
def register() -> None: # type: ignore
raise RuntimeError("Blender bpy module is not available")
def unregister() -> None: # type: ignore
pass
# -----------------------------------------------------------------------------
# CLI validator for normal Python
# -----------------------------------------------------------------------------
def iter_inputs(path: Path) -> Iterator[Tuple[str, bytes]]:
if path.is_dir():
for p in sorted(path.rglob("*")):
if p.is_file() and p.suffix.lower() == ".xmx":
yield str(p), p.read_bytes()
elif zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as zf:
for name in sorted(zf.namelist()):
if name.lower().endswith(".xmx") and not name.endswith("/"):
yield f"{path}!/{name}", zf.read(name)
else:
yield str(path), path.read_bytes()
def validate_input(path: Path, decode_vertices: bool = True) -> Dict[str, Any]:
started = time.perf_counter()
totals: Counter[str] = Counter()
layouts: Counter[str] = Counter()
topologies: Counter[str] = Counter()
material_flags: Counter[str] = Counter()
warnings: List[Dict[str, Any]] = []
errors: List[Dict[str, str]] = []
files: List[Dict[str, Any]] = []
for source, data in iter_inputs(path):
try:
m = parse_xmx(data, source, decode_vertices=decode_vertices)
tris = m.triangles_total
files.append({
"source": source,
"model_name": m.name,
"materials": m.material_count,
"primitives": len(m.primitives),
"vertices": m.vertices_total,
"indices": m.indices_total,
"triangles": tris,
"warnings": m.warnings,
})
totals["files"] += 1
totals["bytes"] += m.file_size
totals["materials"] += m.material_count
totals["primitives"] += len(m.primitives)
totals["vertices"] += m.vertices_total
totals["indices"] += m.indices_total
totals["triangles"] += tris
totals["materials_with_multiple_primitives"] += sum(1 for mat in m.materials if mat.primitive_count > 1)
totals["named_texture_stages"] += sum(1 for mat in m.materials for t in mat.textures if t.has_named_texture)
totals["files_with_warnings"] += int(bool(m.warnings))
if m.warnings:
warnings.append({"source": source, "warnings": m.warnings})
for mat in m.materials:
material_flags[f"0x{mat.flags:08X}"] += 1
for p in mat.primitives:
layouts[f"0x{p.layout_bits:03X}:{p.layout_name}:{p.disk_stride}"] += 1
topologies[p.topology_kind] += 1
if p.uses_8bit_indices:
totals["u8_index_primitives"] += 1
except Exception as e:
totals["failed_files"] += 1
errors.append({"source": source, "error": str(e)})
return {
"input": str(path),
"elapsed_seconds": time.perf_counter() - started,
"totals": dict(totals),
"layouts": dict(layouts),
"topologies": dict(topologies),
"top_material_flags": material_flags.most_common(50),
"warnings": warnings[:100],
"errors": errors,
"files": files,
"note_skinning": "Confirmed HOD3 PC XMX/XMDL v6 files contain no real skeleton/skin-weight records. Optional sidecar skeletons are supported in Blender only.",
}
def iter_inputs_ext(path: Path, ext: str) -> Iterator[Tuple[str, bytes]]:
if path.is_dir():
for p in sorted(path.rglob("*")):
if p.is_file() and p.suffix.lower() == ext:
yield str(p), p.read_bytes()
elif zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as zf:
for name in sorted(zf.namelist()):
if name.lower().endswith(ext) and not name.endswith("/"):
yield f"{path}!/{name}", zf.read(name)
else:
yield str(path), path.read_bytes()
def validate_x3x_input(path: Path) -> Dict[str, Any]:
totals: Counter[str] = Counter()
errors: List[Dict[str, str]] = []
warnings: List[Dict[str, Any]] = []
files: List[Dict[str, Any]] = []
for source, data in iter_inputs_ext(path, ".x3x"):
try:
x = parse_x3x(data, source, inspect_models=True)
files.append({"source": source, "nodes": len(x.nodes), "embedded_models": len(x.model_blocks), "warnings": x.warnings})
totals["files"] += 1
totals["nodes"] += len(x.nodes)
totals["embedded_models"] += len(x.model_blocks)
totals["embedded_models_valid"] += sum(1 for m in x.model_blocks if not m.error)
if x.warnings:
warnings.append({"source": source, "warnings": x.warnings})
except Exception as e:
errors.append({"source": source, "error": str(e)})
return {"input": str(path), "format": "x3x", "totals": dict(totals), "warnings": warnings[:100], "errors": errors, "files": files}
def validate_xax_input(path: Path) -> Dict[str, Any]:
totals: Counter[str] = Counter()
errors: List[Dict[str, str]] = []
warnings: List[Dict[str, Any]] = []
files: List[Dict[str, Any]] = []
for source, data in iter_inputs_ext(path, ".xax"):
try:
x = parse_xax(data, source)
skels = sum(1 for n in x.nodes if n.skeleton)
deforms = sum(1 for n in x.nodes if n.deform)
envs = sum(1 for n in x.nodes if n.deform for e in n.deform.envs if e)
vtxd = sum(e.record_count for n in x.nodes if n.deform for e in n.deform.envs if e)
files.append({"source": source, "nodes": len(x.nodes), "skeletons": skels, "deforms": deforms, "envs": envs, "vtxd": vtxd, "warnings": x.warnings})
totals["files"] += 1
totals["nodes"] += len(x.nodes)
totals["weight_lists"] += len(x.weights)
totals["skeletons"] += skels
totals["deforms"] += deforms
totals["envs"] += envs
totals["vtxd"] += vtxd
if x.warnings:
warnings.append({"source": source, "warnings": x.warnings})
except Exception as e:
errors.append({"source": source, "error": str(e)})
return {"input": str(path), "format": "xax", "totals": dict(totals), "warnings": warnings[:100], "errors": errors, "files": files}
def main(argv: Optional[Sequence[str]] = None) -> int:
ap = argparse.ArgumentParser(description="Validate/importer-test HOD3 PC XMX and Xbox X3X/XAX files outside Blender.")
ap.add_argument("input", type=Path, nargs="?")
ap.add_argument("--format", choices=("auto", "xmx", "x3x", "xax"), default="auto", help="Input format for validation")
ap.add_argument("--validate", action="store_true", help="Validate a file, directory, or ZIP")
ap.add_argument("--single", action="store_true", help="Print one parsed file summary")
ap.add_argument("--json", type=Path, help="Write JSON report")
ap.add_argument("--no-vertices", action="store_true", help="Only validate XMX ranges; skip decoding vertex payload fields")
args = ap.parse_args(argv)
if not args.input:
ap.print_help()
return 2
fmt = args.format
if fmt == "auto":
low = str(args.input).lower()
if low.endswith(".x3x") or low.endswith("x3x.zip"):
fmt = "x3x"
elif low.endswith(".xax") or low.endswith("xax.zip"):
fmt = "xax"
else:
fmt = "xmx"
if args.single and args.input.is_file() and not zipfile.is_zipfile(args.input):
data = args.input.read_bytes()
if fmt == "x3x":
out = parse_x3x(data, str(args.input), inspect_models=True).to_dict()
elif fmt == "xax":
out = parse_xax(data, str(args.input)).to_dict()
else:
out = parse_xmx(data, str(args.input), decode_vertices=not args.no_vertices).to_dict(include_materials=True)
else:
if fmt == "x3x":
out = validate_x3x_input(args.input)
elif fmt == "xax":
out = validate_xax_input(args.input)
else:
out = validate_input(args.input, decode_vertices=not args.no_vertices)
text = json.dumps(out, indent=2, ensure_ascii=False)
if args.json:
args.json.write_text(text, encoding="utf-8")
print(text if args.single else json.dumps({k: v for k, v in out.items() if k != "files"}, indent=2, ensure_ascii=False))
return 0 if not out.get("errors") else 1
if __name__ == "__main__":
if HAS_BLENDER:
register()
else:
raise SystemExit(main())
also implemented viewing into my own customer viewer
By
mariokart64n ·