Skip to content
View in the app

A better way to browse. Learn more.

ResHax

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

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

Reverse Engineering Request - OD3XXOBX / TCAXXOBX model format (Noesis Importer)

Featured Replies

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.xax

File information

zc03.x3x

Size: 48612 bytes

Header:

4F 44 33 58 58 4F 42 58

ASCII:

OD3XXOBX

Immediately followed by

00 00
06 00
D4 BD 00 00

zc03.xax

Size: 43492 bytes

Header:

54 43 41 58 58 4F 42 58

ASCII:

TCAXXOBX

Immediately followed by

02 00
07 00
D4 A9 00 00

The two files clearly belong to the same resource family.


Endianness

The files appear to be Little Endian.

Chunk identifiers are stored as reversed ASCII.

Example:

58 54 52 56

↓

VRTX

instead of

56 52 54 58

X3X Chunk Layout

Current chunk map:

Offset

Stored bytes

Chunk

0x0010

3JBO

OBJ3

0x0134

EMAN

NAME

0x04DC

LEDM

MDEL

0x0568

RTAM

MATR

0x0750

MNXT

TXNM

0x079C

VPRG

Unknown

0x0830

XTRV

VRTX

0x1314

XDNI

INDX

Unknown chunks may have additional internal structures.


NAME chunk

Contains readable strings.

Examples:

zc03_sk_kao

root

chn1

chn2

eff2

jnt_mune

jnt_rhiza

jnt_rmomo

zc03_sk_mune_2dam

This strongly suggests that the model contains a skeleton hierarchy.


MATR chunk

Starts at

0x0568

Contains

PDmat3

mat3

Material names appear to be stored here.


TXNM chunk

Starts at

0x0750

Contains external texture references.

Examples:

d:\FS\xtx_zc03.zip/:4a9a,b9ec.dds

d:\FS\xtx_common.zip/:92,141e.dds

Textures are external DDS files.


VRTX chunk

Starts exactly at

0x0830

The first bytes are

76 CD 12 C0
D7 42 E7 BE
68 97 00 40

Interpreted as float32:

X = -2.2938

Y = -0.4517

Z = 2.0092

These 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 bytes

I have not yet identified:

  • Normal format

  • Tangent format

  • UV offset

  • Skin weights

  • Bone indices


INDX chunk

Starts exactly at

0x1314

Beginning 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.


MDEL

Starts at

0x04DC

Unknown structure.

Possibly mesh descriptor.

May contain counts or offsets.


OBJ3

First chunk in the file.

Likely stores object information or transforms.

Unknown structure.


VPRG

Starts at

0x079C

Unknown.

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
...

Skeleton

The model clearly contains bone names.

However I have not yet identified

  • inverse bind matrices

  • parent table

  • bone transforms

Only names are currently confirmed.


Animation

I could not find any obvious animation data.

No chunks similar to

ANIM

KEYS

TIME

CLIP

MOTN

were found.

Animations may be stored in another resource.


XAX Analysis

Header

TCAXXOBX

Unlike the X3X file, this file does not contain obvious

VRTX

INDX

MATR

chunks.

Instead I found the following chunk identifiers:

RTCA

YRAW

TADW

LDMA

LKSL

MFDL

ILDM

EMAN

LEKS

Known offsets:

Offset

Chunk

0x0010

RTCA

0x019C

TADW

0x09F0

LDMA

0x0A08

LKSL

0x0A54

MFDL

0x0A90

ILDM

0x0B54

EMAN

0x0B70

LEKS

The only readable object name is

zc03_sk_kao

No 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 0x1314


Unknowns

The following information is still missing:

  • Exact VRTX structure

  • Vertex stride

  • Position offset

  • Normal format

  • UV offset

  • Tangent format

  • Color format

  • Skin weights

  • Bone indices

  • Primitive group structure

  • MDEL structure

  • VPRG structure


Goal

The objective is to write a Noesis importer capable of importing:

  • Geometry

  • UVs

  • Material names

  • External texture references

Skeletons and animations are not required.


Questions

  1. Has anyone encountered the OD3XXOBX / TCAXXOBX container before?

  2. Can anyone identify the VRTX vertex layout?

  3. Does MDEL contain vertex/index counts and offsets?

  4. Is VPRG the primitive group / draw call table?

  5. Is XAX a companion resource containing skeleton metadata, collision, helpers or animation-related data?

  6. Would anyone be interested in helping write a Noesis importer for this format?


The Objetive

Create a universal Noesis script that can read all the face indices and their geometry and that can be converted to other formats

Files 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. 🙂

THOTDIII.jpg

Edited by Randalf2theReturn

Solved by mariokart64n

hey your work is amazing i have done the same with hotd1 not 2 fully yet but when would this release?

  • Localization

Hello, from quick look chunks seems to contain relative offsets to other chunks
OBJ3 (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 chunk

For 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 GRPV

For 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 indices

Vertex layout seems the same but may vary with other files:
3x float32 position, 3x float32 normal, 2x float32 uv

Indices are seems to be triangle strips
Here are one of the meshes (some kind of clothing?):
image.png

  • 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 chunks
OBJ3 (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 chunk

For 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 GRPV

For 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 indices

Vertex layout seems the same but may vary with other files:
3x float32 position, 3x float32 normal, 2x float32 uv

Indices are seems to be triangle strips
Here are one of the meshes (some kind of clothing?):
image.png

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

  • Solution

image.png

 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
image.png

Create an account or sign in to comment

Account

Navigation

Search

Search

Configure browser push notifications

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