July 15Jul 15 ๐งต [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 dataFloat data detected:E0 FE 48 BE โ -0.196 48 E7 E7 BE โ -0.453 80 09 A7 3C โ 0.020Likely:Position (float3)followed by normals / UVs / etc.โ Unknown:vertex stride (suspected 32 / 36 / 40 bytes)๐น Index buffer58 44 4E 49 โ "INDX"Appears to be uint16 (little endian)Example:01 00 02 00 03 00โ ๏ธ ProblemMesh is corrupted:degenerate triangles like:22 00 22 00 22 00 25 00 25 00 25 00Result:collapsed facesbroken topology๐ง Suspected causesWrong vertex strideIncorrect index offsetVertex count mismatchPossible mixed endianness๐ TexturesPaths inside file:d:\FS\tex_zb01.zip/:xxxx.ddsโ DDS inside ZIP archives๐ฏ What I needHelp identifying correct vertex layout / strideEventually a Noesis plugin that:reads vertices correctlyfixes meshexports to FBX/OBJ๐งช I can provideSample filesHex dumpsTest resultsโ QuestionHas 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 fmt_parser_xmx.py Edited July 15Jul 15 by Ralp1670
July 15Jul 15 Supporter degenerated triangles: just a wild guess (without checking the details): maybe the indices of those models with that problem are DWords, not shorts?
July 15Jul 15 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.
July 15Jul 15 Supporter 1 hour ago, Ralp1670 said:No, son.Your disrespect forces me to leave the thread. Good luck.
July 15Jul 15 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...
July 15Jul 15 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?
July 15Jul 15 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.
July 15Jul 15 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 rangeNo valid mesh produced when interpreting as uint32However, 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 startOr mixed vertex layouts within the same blockIโ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-testingIf 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.
July 15Jul 15 the vertex data length in this format is not fixed; according to my inspection, it can be 28/32/40 Edited July 15Jul 15 by kurokozerefx
July 15Jul 15 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.
July 15Jul 15 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!
July 15Jul 15 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 countOffset 0x10 - Offset to start of submesh vertex dataOffset 0x2c - Vertex strideOffset 0x40 - Offset to start of submesh face indicesOffset 0x44 - Face index count
July 16Jul 16 8 hours ago, kurokozerefx said:fmt_xmx.pyThat'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 countOffset 0x10 - Offset to start of submesh vertex dataOffset 0x2c - Vertex strideOffset 0x40 - Offset to start of submesh face indicesOffset 0x44 - Face index countHi, 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 bufferTriangle 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 ยท GitHubThese 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 slightlyIโ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 July 16Jul 16 by Randalf2theReturn
July 16Jul 16 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๐คฃ
July 16Jul 16 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:
July 16Jul 16 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
July 16Jul 16 The game is a PC port of House of the Dead by Sega. Below is a blender plugin """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())
July 17Jul 17 2 hours ago, mariokart64n said:The game is a PC port of House of the Dead by Sega. Below is a blender plugin"""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 July 17Jul 17 by Randalf2theReturn
July 18Jul 18 Just a reminder, if you're posting scripts, please add them as attachments and not a block of text in the thread, as it reduces readability for other users.
Create an account or sign in to comment