Yesterday at 03:29 AM1 day Hey, everyone guys:I'm trying to reverse engineer a proprietary model format used by The House Of The Dead 3 . My goal is to write (or help writing) a Noesis importer capable of importing the render mesh (geometry, UVs ,materials and skeleton if that's possible ).I have attached two files from the same asset:zc03.x3x zc03.xaxFile informationzc03.x3xSize: 48612 bytes Header: 4F 44 33 58 58 4F 42 58 ASCII: OD3XXOBXImmediately followed by00 00 06 00 D4 BD 00 00zc03.xaxSize: 43492 bytes Header: 54 43 41 58 58 4F 42 58 ASCII: TCAXXOBXImmediately followed by02 00 07 00 D4 A9 00 00The two files clearly belong to the same resource family.EndiannessThe files appear to be Little Endian.Chunk identifiers are stored as reversed ASCII.Example:58 54 52 56 ↓ VRTXinstead of56 52 54 58X3X Chunk LayoutCurrent chunk map:OffsetStored bytesChunk0x00103JBOOBJ30x0134EMANNAME0x04DCLEDMMDEL0x0568RTAMMATR0x0750MNXTTXNM0x079CVPRGUnknown0x0830XTRVVRTX0x1314XDNIINDXUnknown chunks may have additional internal structures.NAME chunkContains readable strings.Examples:zc03_sk_kao root chn1 chn2 eff2 jnt_mune jnt_rhiza jnt_rmomo zc03_sk_mune_2damThis strongly suggests that the model contains a skeleton hierarchy.MATR chunkStarts at0x0568ContainsPDmat3 mat3Material names appear to be stored here.TXNM chunkStarts at0x0750Contains external texture references.Examples:d:\FS\xtx_zc03.zip/:4a9a,b9ec.dds d:\FS\xtx_common.zip/:92,141e.ddsTextures are external DDS files.VRTX chunkStarts exactly at0x0830The first bytes are76 CD 12 C0 D7 42 E7 BE 68 97 00 40Interpreted as float32:X = -2.2938 Y = -0.4517 Z = 2.0092These values appear to be valid model-space coordinates.The following bytes continue as floating point values.Example:AE B3 99 3D 5D CF 7D BF 50 B3 DA 3D 64 2C B9 3E 1C 88 71 3E 9F FE 10 C0 01 75 F7 BE 31 A8 32 3F E2 CF BA 3D 03 C2 53 BF ...The vertex layout is currently unknown.Possible vertex stride candidates:48 52 56 60 64 bytesI have not yet identified:Normal formatTangent formatUV offsetSkin weightsBone indicesINDX chunkStarts exactly at0x1314Beginning of the data:00 00 01 00 02 00 03 00 03 00 02 00 02 00 02 00 00 00 04 00 05 00 06 00 07 00 08 00 09 00 ...This strongly indicates a 16-bit index buffer (uint16).I found no evidence of uint32 indices.MDELStarts at0x04DCUnknown structure.Possibly mesh descriptor.May contain counts or offsets.OBJ3First chunk in the file.Likely stores object information or transforms.Unknown structure.VPRGStarts at0x079CUnknown.May describe primitive groups, draw calls or submeshes.First bytes:56 50 52 47 00 00 00 00 00 00 00 00 33 00 00 00 00 00 00 00 58 03 00 00 ...SkeletonThe model clearly contains bone names.However I have not yet identifiedinverse bind matricesparent tablebone transformsOnly names are currently confirmed.AnimationI could not find any obvious animation data.No chunks similar toANIM KEYS TIME CLIP MOTNwere found.Animations may be stored in another resource.XAX AnalysisHeaderTCAXXOBXUnlike the X3X file, this file does not contain obviousVRTX INDX MATRchunks.Instead I found the following chunk identifiers:RTCA YRAW TADW LDMA LKSL MFDL ILDM EMAN LEKSKnown offsets:OffsetChunk0x0010RTCA0x019CTADW0x09F0LDMA0x0A08LKSL0x0A54MFDL0x0A90ILDM0x0B54EMAN0x0B70LEKSThe only readable object name iszc03_sk_kaoNo obvious render geometry is present.This file appears to be a companion resource.Its exact purpose is still unknown.Current assumptions✔ Little Endian✔ Chunk-based container✔ Reversed ASCII chunk identifiers✔ Render mesh stored inside X3X✔ External DDS textures✔ Skeleton names present✔ uint16 index buffer✔ Vertex buffer starts at 0x0830✔ Index buffer starts at 0x1314UnknownsThe following information is still missing:Exact VRTX structureVertex stridePosition offsetNormal formatUV offsetTangent formatColor formatSkin weightsBone indicesPrimitive group structureMDEL structureVPRG structureGoalThe objective is to write a Noesis importer capable of importing:GeometryUVsMaterial namesExternal texture referencesSkeletons and animations are not required.QuestionsHas anyone encountered the OD3XXOBX / TCAXXOBX container before?Can anyone identify the VRTX vertex layout?Does MDEL contain vertex/index counts and offsets?Is VPRG the primitive group / draw call table?Is XAX a companion resource containing skeleton metadata, collision, helpers or animation-related data?Would anyone be interested in helping write a Noesis importer for this format?The ObjetiveCreate a universal Noesis script that can read all the face indices and their geometry and that can be converted to other formatsFiles Testing:I'll upload those formats to the GitHub account https://github.com/randalfcastro-tech/THOD3; and now I think the real fun begins ...If you need more samples, let me know and I’ll be happy to provide them, or your full dump in hexadecimal.Also, I think a Noesis script is better, it will take less time and be faster, guys.But thanks again, we're going to make it. 🙂 Edited yesterday at 04:01 AM1 day by Randalf2theReturn
Yesterday at 06:15 AM1 day hey your work is amazing i have done the same with hotd1 not 2 fully yet but when would this release?
Yesterday at 07:16 AM1 day Localization Hello, from quick look chunks seems to contain relative offsets to other chunksOBJ3 (offsets are relative to right before OBJ3 identifier): at 144 (counting from before OBJ3) offset to it's name right after NAME identifier at 156 offset (or zero) to 4 bytes before MDLE which contains exact size of MDLE chunk in bytes at 268 and 272 offsets (or zeroes) to another OBJ3 (children in tree hierarchy?) MDLE (offsets are relative to right before MDLE identifier): at 20 (counting from before MDLE) offset to it's name right after NAME identifier at 40 int32 count of materials following with offset to material data inside MATR chunkFor each material there seems to be 232 bytes of data inside MATR chunk (offsets are still relative to MDLE): at 0 (counting from material data start) offset to it's name at 124 offset to (vertex group?) data inside GRPVFor each material there seems to be 72 bytes of data inside GRPV chunk (offsets are still relative to MDLE): at 8 (counting from group data start) int32 count of vertices at 16 offset to vertex data inside VRTX chunk at 64 offset to indices inside INDX chunk following with int32 count of indicesVertex layout seems the same but may vary with other files:3x float32 position, 3x float32 normal, 2x float32 uvIndices are seems to be triangle stripsHere are one of the meshes (some kind of clothing?):
Yesterday at 09:36 AM1 day Author 3 hours ago, HiddenParadise said:hey your work is amazing i have done the same with hotd1 not 2 fully yet but when would this release?1 hour ago, other1 said:Hello, from quick look chunks seems to contain relative offsets to other chunksOBJ3 (offsets are relative to right before OBJ3 identifier):at 144 (counting from before OBJ3) offset to it's name right after NAME identifierat 156 offset (or zero) to 4 bytes before MDLE which contains exact size of MDLE chunk in bytesat 268 and 272 offsets (or zeroes) to another OBJ3 (children in tree hierarchy?)MDLE (offsets are relative to right before MDLE identifier):at 20 (counting from before MDLE) offset to it's name right after NAME identifierat 40 int32 count of materials following with offset to material data inside MATR chunkFor each material there seems to be 232 bytes of data inside MATR chunk (offsets are still relative to MDLE):at 0 (counting from material data start) offset to it's nameat 124 offset to (vertex group?) data inside GRPVFor each material there seems to be 72 bytes of data inside GRPV chunk (offsets are still relative to MDLE):at 8 (counting from group data start) int32 count of verticesat 16 offset to vertex data inside VRTX chunkat 64 offset to indices inside INDX chunk following with int32 count of indicesVertex layout seems the same but may vary with other files:3x float32 position, 3x float32 normal, 2x float32 uvIndices are seems to be triangle stripsHere are one of the meshes (some kind of clothing?):From what I've seen of the .x3x / OD3XXOBX format:✔ I've already located the chunks (XTRV, XDNI, RTAM, MNXT, EMAN...).✔ I know there are several submeshes.✔ The indices can already be read.✔ Material and texture names are already showing up.✔ We've already found the approximate start of the vertex block.What I still don't know is the actual layout of XTRV:final stride (32, 36, 40, 44, 48...),exact UV position,type of normals,existence of tangents,whether the UVs are float, half float, or normalized short,whether the index is a triangle strip or list,if there are multiple vertex streams.What's left is the important part: identifying the exact format of the mesh data. With what I have, I still need to figure out:The actual stride of the vertices (don’t assume 32).The layout of each vertex:position,normals,UVs,tangents (if they exist),bone weights and indices (if they exist).Whether XDNI uses a triangle list or triangle strip.If there are multiple vertex streams associated with the same submesh.The structure of LEDM, which probably contains the layout description or of each submesh.How materials (RTAM) and textures (MNXT) are linked to each submesh.Once that’s identified, the rest is relatively straightforward: bind the correct buffers (rpgBindPositionBufferOfs, rpgBindNormalBufferOfs, rpgBindUV1BufferOfs, etc.), create materials, and build the model. fmt_x3x_thotd3.py
14 hours ago14 hr Solution 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
Create an account or sign in to comment