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

Noesis plugin (.fmt) for XMX/LDMX models โ€“ broken mesh due to index/stride issues

Featured Replies

๐Ÿงต [REQUEST] Noesis plugin for XMX/LDMX โ€“ mesh corruption (index/stride issue)

Hi,

I'm reverse engineering a proprietary XMX / LDMX model format and I'm getting consistent mesh corruption when parsing it.

I'd like help identifying the correct vertex layout and eventually building a Noesis plugin (.fmt or Python) to export to FBX/OBJ.


๐Ÿ“ฆ Format info (confirmed)

Header:

4C 44 4D 58 โ†’ "LDMX"

Chunks found:

  • EMAN (likely "NAME", reversed)

  • MNXT (textures)

  • VPRG (vertex block)

  • INDX (index buffer)


๐Ÿ”น Vertex data

Float data detected:

E0 FE 48 BE โ†’ -0.196
48 E7 E7 BE โ†’ -0.453
80 09 A7 3C โ†’ 0.020

Likely:

  • Position (float3)

  • followed by normals / UVs / etc.

โ— Unknown:

  • vertex stride (suspected 32 / 36 / 40 bytes)


๐Ÿ”น Index buffer

58 44 4E 49 โ†’ "INDX"
  • Appears to be uint16 (little endian)

Example:

01 00 02 00 03 00

โš ๏ธ Problem

Mesh is corrupted:

  • degenerate triangles like:

22 00 22 00 22 00
25 00 25 00 25 00

Result:

  • collapsed faces

  • broken topology


๐Ÿง  Suspected causes

  • Wrong vertex stride

  • Incorrect index offset

  • Vertex count mismatch

  • Possible mixed endianness


๐Ÿ“‚ Textures

Paths inside file:

d:\FS\tex_zb01.zip/:xxxx.dds

โ†’ DDS inside ZIP archives


๐ŸŽฏ What I need

  • Help identifying correct vertex layout / stride

  • Eventually a Noesis plugin that:

    • reads vertices correctly

    • fixes mesh

    • exports to FBX/OBJ


๐Ÿงช I can provide

  • Sample files

  • Hex dumps

  • Test results


โ“ Question

Has anyone seen a similar format or can help determine the correct vertex layout / stride?

Best result, but it's not entirely correct; it always reads shitty padding or interspersed garbage. One or more triangles always come out wrong, but it's the best versionโ€”the one where the mesh turns out almost perfect...

Almost all the files to be converted are located at: https://github.com/randalfcastro-tech/xmx-testing


from inc_noesis import *
import noesis
import rapi
import struct

# =========================
# REGISTER
# =========================

def registerNoesisTypes():
handle = noesis.register("XMX REAL FINAL STABLE", ".xmx")
noesis.setHandlerTypeCheck(handle, xmxCheckType)
noesis.setHandlerLoadModel(handle, xmxLoadModel)
return 1

def xmxCheckType(data):
return 1 if data[:4] == b"LDMX" else 0


# =========================
# FIND REAL REGION
# =========================

def findRealMeshRegion(data):
vprg = data.find(b"VPRG")
if vprg == -1:
return -1, -1

xtrv = data.find(b"XTRV", vprg)
xdni = data.find(b"XDNI", xtrv)

return xtrv, xdni


# =========================
# VERTICES (REAL)
# =========================

def parseVertices(data):
xtrv, xdni = findRealMeshRegion(data)

if xtrv == -1 or xdni == -1:
print(" Missing real mesh blocks")
return None

start = xtrv + 4
end = xdni

bs = NoeBitStream(data)
bs.seek(start)

verts = []
norms = []
uvs = []

stride = 32
MAX_VERTS = 200000

while bs.tell() + stride <= end and len(verts) < MAX_VERTS:
try:
x = bs.readFloat()
y = bs.readFloat()
z = bs.readFloat()

nx = bs.readFloat()
ny = bs.readFloat()
nz = bs.readFloat()

u = bs.readFloat()
v = bs.readFloat()

# sanity check (evita basura)
if abs(x) > 10000 or abs(y) > 10000 or abs(z) > 10000:
break

verts.append((x,y,z))
norms.append((nx,ny,nz))
uvs.append((u,1.0-v))

except:
break

print(" Vertices:", len(verts))
return verts, norms, uvs


# =========================
# INDICES (REAL)
# =========================

def parseIndices(data):
_, xdni = findRealMeshRegion(data)

if xdni == -1:
print(" No XDNI")
return None

bs = NoeBitStream(data)
bs.seek(xdni + 4)

indices = []
MAX_INDICES = 500000

while bs.tell() + 2 <= len(data) and len(indices) < MAX_INDICES:
indices.append(bs.readUShort())

print(" Indices:", len(indices))
return indices


# =========================
# STRIP โ†’ TRIANGLES
# =========================

RESTART = 0xFFFF

def stripToTriangles(indices, vertCount):
tris = []
flip = False

for i in range(len(indices)-2):
a = indices[i]
b = indices[i+1]
c = indices[i+2]

if a == RESTART or b == RESTART or c == RESTART:
flip = False
continue

if a == b or b == c or a == c:
flip = not flip
continue

if a >= vertCount or b >= vertCount or c >= vertCount:
continue

if flip:
tris.extend([b, a, c])
else:
tris.extend([a, b, c])

flip = not flip

return tris


# =========================
# BUILD
# =========================

def buildMesh(verts, norms, uvs, indices):

tris = stripToTriangles(indices, len(verts))

# sanitizar
clean = []
maxVert = len(verts)

for i in tris:
if not isinstance(i, int):
continue
if i < 0 or i >= maxVert:
continue
clean.append(i)

clean = clean[:(len(clean)//3)*3]

if len(clean) < 3:
print(" No valid triangles")
return

vb = b''.join([struct.pack("3f", *v) for v in verts])
nb = b''.join([struct.pack("3f", *n) for n in norms])
ub = b''.join([struct.pack("2f", *u) for u in uvs])

rapi.rpgBindPositionBuffer(vb, noesis.RPGEODATA_FLOAT, 12)
rapi.rpgBindNormalBuffer(nb, noesis.RPGEODATA_FLOAT, 12)
rapi.rpgBindUV1Buffer(ub, noesis.RPGEODATA_FLOAT, 8)

try:
ib = struct.pack("%dH" % len(clean), *clean)
except Exception as e:
print(" PACK ERROR:", e)
return

rapi.rpgCommitTriangles(
ib,
noesis.RPGEODATA_USHORT,
len(clean),
noesis.RPGEO_TRIANGLE,
1
)


# =========================
# MAIN
# =========================

def xmxLoadModel(data, mdlList):
ctx = rapi.rpgCreateContext()

parsed = parseVertices(data)
if not parsed:
return 0

verts, norms, uvs = parsed

indices = parseIndices(data)
if not indices:
return 0

print(" Building mesh")

buildMesh(verts, norms, uvs, indices)

mdl = rapi.rpgConstructModel()
mdlList.append(mdl)

return 1

zb01_chain.xmx.png

stc_capsule.xmx.png

zc04_sk013.xmx.png

zb03_sk_c01.xmx.png

fmt_parser_xmx.py

Edited by Ralp1670

  • Supporter

degenerated triangles: just a wild guess (without checking the details): maybe the indices of those models with that problem are DWords, not shorts?

  • Author
15 minutes ago, shak-otay said:

degenerated triangles: just a wild guess (without checking the details): maybe the indices of those models with that problem are DWords, not shorts?

๐Ÿ‘‰ NOT DWORD (uint32)
โœ” These models correctly use UINT16.

  • Author
6 minutes ago, shak-otay said:

Your disrespect forces me to leave the thread. Good luck.

6 minutes ago, shak-otay said:

Your disrespect forces me to leave the thread. Good luck.

Friend, I thought you would ask questions or present arguments, but you are taking things for granted that simply aren't true; even if it sounds like a clever answer, it isn't backed by factsโ€”and Iโ€™ve tried to be as polite as possible...

  • Localization
4 hours ago, Ralp1670 said:

I'm reverse engineering a proprietary XMX / LDMX model format and I'm getting consistent mesh corruption when parsing it.

Can you tell us which game (?) uses this .xmx format?

  • Supporter

Since there is FFFF strip terminator it could be some xbox / 360 game. But now it's a history. Also i can't see any samples.

Hi everyone,

First of all, Iโ€™d like to apologize for my previous tone โ€” that wasnโ€™t appropriate, and I appreciate the time youโ€™re taking to help.

Regarding the suggestion about DWORD indices:

Iโ€™ve double-checked the data and Iโ€™m fairly confident the index buffer is uint16, based on:

  • Buffer size alignment (2 bytes per index)

  • Values staying within vertex range

  • No valid mesh produced when interpreting as uint32

However, the issue with degenerate triangles still persists, so I believe the problem is more likely related to:

  • Incorrect vertex stride (possible padding or interleaved data)

  • Misaligned vertex buffer start

  • Or mixed vertex layouts within the same block

Iโ€™m currently getting almost correct meshes, but with occasional corrupted triangles, which suggests Iโ€™m very close but still reading some garbage data.

To help debugging, Iโ€™ve provided sample files here:
https://github.com/randalfcastro-tech/xmx-testing

If anyone has seen a similar format (especially with VPRG/XTRV/XDNI chunks), Iโ€™d really appreciate any insight.

Also answering the question:
Iโ€™m still trying to identify the exact game/engine using this format โ€” Iโ€™ll update as soon as I confirm it.

Thanks again for your time.

From what I can see, you need to parse the "VPRG" block, which seems to be the info for each submesh. The count value is in the file header at offset 0x38, and each VPRG entry looks to be 0x48 bytes, although I haven't checked every file to confirm that exactly. Each VPRG entry contains offsets, counts and stride values for each submesh's vertex and face data, and probably other info as well. The stride varies between submeshes, and the face indices start from 0 again for each submesh, so you have to read each vertex/face block separately for each submesh.

Also, how do you not know which game/platform it is? Where did you get the samples? I have seen similar formats in other games, but can't recall off the top of my head which ones.

Hi, thanks a lot for the detailed explanation โ€” that clarifies things a lot.

I didnโ€™t realize the VPRG block was driving the submesh layout. Parsing each submesh separately with its own stride and index buffer starting at 0 makes total sense now, especially with the 28/32/40 variation.

Iโ€™ll try to restructure my importer around VPRG entries (0x48 each) and see if that fixes the remaining corrupted triangles โ€” thatโ€™s probably where my misalignment is coming from.

Regarding the game: I got these samples from extracted assets, but Iโ€™m still in the process of confirming the exact title/engine. I should be able to share more details once I verify it properly.

By the way, have you already implemented a Noesis script for this format, or something similar? Even a partial example (especially how you're handling the VPRG parsing and variable strides) would be extremely helpful for getting this fully correct.

Also, have you tested this approach on larger/more complex files? I'm wondering if there are still edge cases when multiple submeshes with different layouts are involved.

Thanks again โ€” really appreciate the insight!

No, I haven't got any Noesis script for these. I was just basing my information on a quick look at some of the files, and I could see stride values that matched the vertex data, and face counts for each submesh. A few bits of info for each VPRG entry - note that the offset values all seem to need 0x10 adding to them to get the correct offset.

Offset 8 - Vertex count

Offset 0x10 - Offset to start of submesh vertex data

Offset 0x2c - Vertex stride

Offset 0x40 - Offset to start of submesh face indices

Offset 0x44 - Face index count

8 hours ago, kurokozerefx said:

fmt_xmx.py

That's about it.

14 hours ago, DKDave said:

No, I haven't got any Noesis script for these. I was just basing my information on a quick look at some of the files, and I could see stride values that matched the vertex data, and face counts for each submesh. A few bits of info for each VPRG entry - note that the offset values all seem to need 0x10 adding to them to get the correct offset.

Offset 8 - Vertex count

Offset 0x10 - Offset to start of submesh vertex data

Offset 0x2c - Vertex stride

Offset 0x40 - Offset to start of submesh face indices

Offset 0x44 - Face index count

Hi, thanks again for all the detailed information โ€” especially the clarification about the VPRG block and per-submesh stride, that helped a lot.

Iโ€™ve been testing the script that was shared, and while it works on some files, Iโ€™m still running into a very common issue on others. The typical error I get is:

RuntimeError: Normal buffer would have been read out of bounds by provided indices.

From what I can see, this usually happens when:

  • Indices reference vertices outside the current vertex buffer

  • Triangle strips are interpreted incorrectly (TRIANGLE_STRIP vs TRIANGLE)

  • Or there is still some misalignment between vertex and index buffers (likely due to stride/offset per submesh)

Iโ€™ve collected several problematic samples here:

xmx-testing/bugs at main ยท randalfcastro-tech/xmx-testing ยท GitHub

These are the files that consistently trigger that error, even when the mesh looks almost correct otherwise.

Based on your explanation, I suspect this is happening in cases where:

  • Multiple submeshes (VPRG entries) use different strides (28/32/40)

  • The parser isnโ€™t fully isolating each submesh (vertex + index buffers starting from 0)

  • Or some vertex layouts include padding/interleaved data that shifts offsets slightly

Iโ€™m currently restructuring my importer so each VPRG entry is treated as a completely independent submesh (with its own vertex buffer, stride and index base), but Iโ€™m still seeing edge cases where some meshes break.

So I wanted to ask:

  • In your approach, do you strictly reset and bind buffers per submesh before committing triangles?

  • Have you needed to sanitize/filter indices before calling rpgCommitTriangles, or should they always be valid if parsed correctly?

  • And most importantly โ€” do you happen to have a more complete Noesis script for this format (even if not fully polished)? Seeing a full implementation of VPRG parsing + variable strides would really help resolve these remaining edge cases.

Once I confirm everything is working correctly, Iโ€™ll also be happy to share the exact game/engine these files come from โ€” I just want to make sure the format is fully understood first.

Thanks again, really appreciate the help ๐Ÿ‘

Edited by Randalf2theReturn

  • Supporter

I can confirm it's xbox game. It's in header. Why you don't tell what game it is? Are you selling it or what? Because it seems so. Or maybe i am paranoid๐Ÿคฃ

From looking at the files a bit more - the "RTAM" section at the start (for materials), seems like it needs to be parsed first as it has pointers to the submesh info for that material. The count at offset 0x38 in the header is the number of "RTAM" sections (0xe8 bytes per entry). There are more submesh entries than material entries, so some of them reference more than 1 submesh for that material.

This is just a test using hardcoded offsets for the first of your files that don't work - this should be all 86 submeshes for st2_h_boss_die.xmx:

image.png

And here's a basic Noesis script that parses the RTAM entries and the relevant submeshes for each material.

All it does is read the vertex data and face indices, but it should give you a base to do other stuff like UVs, normals, adding textures, etc.

It makes some assumptions that every face section in a triangle strip, and it seems to work with all of your samples, including the ones with bugs.

xmx.py

The game is a PC port of House of the Dead by Sega. Below is a blender plugin
image.png


"""Blender importer for Sega XMX / XMDL-XBOX v6 files used by HOD3 PC.

Paste this file into Blender's Text Editor and press Run Script, or install it as
an add-on. It registers:

    File > Import > Sega XMDL/XBOX v6 (.xmx)
"""
from __future__ import annotations

bl_info = {
    "name": "Sega XMDL/XBOX v6 XMX Importer",
    "author": "mariokart64n",
    "version": (1, 0, 0),
    "blender": (3, 6, 0),
    "location": "File > Import > Sega XMDL/XBOX v6 (.xmx)",
    "description": "Import HOD3 PC XMX/XMDL v6 models",
    "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
    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])


@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 _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))
        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

# -----------------------------------------------------------------------------
# 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) -> 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

        # Load first named texture stage as the visible base-color texture.  All
        # four stage records are preserved in metadata either way.
        if load_textures:
            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():
                    try:
                        img = bpy.data.images.load(str(tex_path), 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 Stage {stage.index}: {Path(stage.name or tex_path.name).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(tex_path)
                        break
                    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 = False,
        texture_root: str = "",
        recursive_texture_search: bool = False,
        create_bounds: bool = False,
        import_sidecar_skeleton: bool = True,
        scale: float = 1.0,
    ) -> 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) 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"}


    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=1.0, min=0.000001, max=1000000.0)
        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=False, description="Try to load first named texture stage. Off by default to avoid DDS/ZIP stalls")
        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"}


    def menu_func_import(self: Any, context: Any) -> None:
        self.layout.operator(IMPORT_OT_xmx_xmdl_v6.bl_idname, text="Sega XMDL/XBOX v6 (.xmx)")


    CLASSES = (IMPORT_OT_xmx_xmdl_v6,)

    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 main(argv: Optional[Sequence[str]] = None) -> int:
    ap = argparse.ArgumentParser(description="Validate/importer-test Sega XMDL/XBOX v6 .xmx files outside Blender.")
    ap.add_argument("input", type=Path, nargs="?")
    ap.add_argument("--validate", action="store_true", help="Validate a file, directory, or ZIP of .xmx files")
    ap.add_argument("--single", action="store_true", help="Print one parsed model summary")
    ap.add_argument("--json", type=Path, help="Write JSON report")
    ap.add_argument("--no-vertices", action="store_true", help="Only validate ranges; skip decoding vertex payload fields")
    args = ap.parse_args(argv)
    if not args.input:
        ap.print_help()
        return 2
    if args.single and args.input.is_file() and not zipfile.is_zipfile(args.input):
        model = parse_xmx(args.input.read_bytes(), str(args.input), decode_vertices=not args.no_vertices)
        out = model.to_dict(include_materials=True)
    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())
2 hours ago, mariokart64n said:

The game is a PC port of House of the Dead by Sega. Below is a blender plugin
image.png

"""Blender importer for Sega XMX / XMDL-XBOX v6 files used by HOD3 PC.

Paste this file into Blender's Text Editor and press Run Script, or install it as
an add-on. It registers:

    File > Import > Sega XMDL/XBOX v6 (.xmx)
"""from __future__ import annotations

bl_info = {
    "name": "Sega XMDL/XBOX v6 XMX Importer",
    "author": "mariokart64n",
    "version": (1, 0, 0),
    "blender": (3, 6, 0),
    "location": "File > Import > Sega XMDL/XBOX v6 (.xmx)",
    "description": "Import HOD3 PC XMX/XMDL v6 models",
    "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
    HAS_BLENDER = Trueexcept 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: ignoredef 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])


@dataclasses.dataclassclass 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.dataclassclass 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.dataclassclass 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.dataclassclass 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.dataclassclass 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 Nonedef _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))
        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

# -----------------------------------------------------------------------------# 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) -> 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

        # Load first named texture stage as the visible base-color texture.  All
        # four stage records are preserved in metadata either way.
        if load_textures:
            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():
                    try:
                        img = bpy.data.images.load(str(tex_path), 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 Stage {stage.index}: {Path(stage.name or tex_path.name).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(tex_path)
                        break
                    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 = False,
        texture_root: str = "",
        recursive_texture_search: bool = False,
        create_bounds: bool = False,
        import_sidecar_skeleton: bool = True,
        scale: float = 1.0,
    ) -> 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) 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"}


    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=1.0, min=0.000001, max=1000000.0)
        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=False, description="Try to load first named texture stage. Off by default to avoid DDS/ZIP stalls")
        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"}


    def menu_func_import(self: Any, context: Any) -> None:
        self.layout.operator(IMPORT_OT_xmx_xmdl_v6.bl_idname, text="Sega XMDL/XBOX v6 (.xmx)")


    CLASSES = (IMPORT_OT_xmx_xmdl_v6,)

    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 main(argv: Optional[Sequence[str]] = None) -> int:
    ap = argparse.ArgumentParser(description="Validate/importer-test Sega XMDL/XBOX v6 .xmx files outside Blender.")
    ap.add_argument("input", type=Path, nargs="?")
    ap.add_argument("--validate", action="store_true", help="Validate a file, directory, or ZIP of .xmx files")
    ap.add_argument("--single", action="store_true", help="Print one parsed model summary")
    ap.add_argument("--json", type=Path, help="Write JSON report")
    ap.add_argument("--no-vertices", action="store_true", help="Only validate ranges; skip decoding vertex payload fields")
    args = ap.parse_args(argv)
    if not args.input:
        ap.print_help()
        return 2
    if args.single and args.input.is_file() and not zipfile.is_zipfile(args.input):
        model = parse_xmx(args.input.read_bytes(), str(args.input), decode_vertices=not args.no_vertices)
        out = model.to_dict(include_materials=True)
    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 1if __name__ == "__main__":
    if HAS_BLENDER:
        register()
    else:
        raise SystemExit(main())

Good friends and hi everyone, that game is nice, but does your py also work for large files?

Edited by Randalf2theReturn

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.