#!/usr/bin/env python3 """ WoW 3.3.5 (WotLK, M2 v264) → GLTF 2.0 (.glb) converter. Usage: python m2_to_gltf.py path/to/Model.M2 [output_dir] Produces /.glb with: - Skinned mesh (all skin sections as primitives) - Full bone hierarchy with bind poses - All animation sequences as named GLTF animations """ import io import json import math import os import struct import sys from pathlib import Path try: from PIL import Image as _PIL_Image _PIL_OK = True except ImportError: _PIL_OK = False # ───────────────────────────────────────────────────────────────────────────── # BLP2 decoder → raw RGBA bytes (PIL required for DXT) # ───────────────────────────────────────────────────────────────────────────── def _blp_to_rgba(path: Path): """ Decode a BLP2 file to (width, height, rgba_bytes). Supports palette (compression=1) and DXT (compression=2). Returns None if decoding fails or PIL is unavailable for DXT. """ try: data = path.read_bytes() except OSError: return None if data[:4] not in (b'BLP2', b'BLP1'): return None # Header layout (C++ BlpStructure.h): # signature(4) version(4) compression(1) alphaDepth(1) alphaCompression(1) # mipLevels(1) width(4) height(4) offsets[16](64) sizes[16](64) = 148 bytes compression = data[8] # 1=palette, 2=DXT, 3=raw RGBA alpha_depth = data[9] # 0/1/4/8 bits alpha_compr = data[10] # for DXT: 0=DXT1, 1=DXT3, 7=DXT3 variant width = struct.unpack_from('> 1 a4 = (alpha_src[byte_idx] >> (4 * (i & 1))) & 0xF if byte_idx < len(alpha_src) else 0xF a = a4 | (a4 << 4) rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a elif alpha_depth == 1: alpha_src = mip_data[n:] for i in range(n): idx = indices[i] b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2] byte_idx = i >> 3 bit = (alpha_src[byte_idx] >> (i & 7)) & 1 if byte_idx < len(alpha_src) else 1 a = 255 if bit else 0 rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a else: # alpha_depth == 0: fully opaque for i in range(n): idx = indices[i] b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2] rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, 255 return width, height, bytes(rgba) # ── DXT (compression == 2) ───────────────────────────────────────────── if compression == 2: if not _PIL_OK: return None # Map alphaCompression → DDS fourCC # 0=DXT1, 1=DXT3(premul), 7=DXT5, 9=DXT5 fourcc_map = {0: b'DXT1', 1: b'DXT3', 7: b'DXT5', 9: b'DXT5'} fourcc = fourcc_map.get(alpha_compr, b'DXT1') # Build a minimal DDS file in memory DDSD_FLAGS = 0x1 | 0x2 | 0x4 | 0x1000 | 0x80000 # caps,h,w,pf,linearsize DDSCAPS_TEXTURE = 0x1000 DDPF_FOURCC = 0x4 header = struct.pack('<4sI', b'DDS ', 124) header += struct.pack(' bytes: """Encode raw RGBA bytes to PNG using PIL.""" img = _PIL_Image.frombytes('RGBA', (width, height), rgba) buf = io.BytesIO() img.save(buf, format='PNG', optimize=False) return buf.getvalue() def _find_blp(m2_dir: Path, name: str) -> Path | None: """Case-insensitive BLP lookup in m2_dir.""" name_lower = name.lower() for p in m2_dir.iterdir(): if p.suffix.lower() == '.blp' and p.stem.lower() == Path(name_lower).stem.lower(): return p return None # ───────────────────────────────────────────────────────────────────────────── # WoW animation sequence ID → name table (WotLK 3.3.5) # ───────────────────────────────────────────────────────────────────────────── ANIM_NAMES = { 0: "Stand", 1: "Death", 2: "Spell", 3: "Stop", 4: "Walk", 5: "Run", 6: "Dead", 7: "Rise", 8: "StandWound", 9: "CombatWound", 10: "CombatCritical", 11: "ShuffleLeft", 12: "ShuffleRight", 13: "WalkBackwards", 14: "Stun", 15: "HandsClosed", 16: "AttackUnarmed", 17: "Attack1H", 18: "Attack2H", 19: "Attack2HL", 20: "ParryUnarmed", 21: "Parry1H", 22: "Parry2H", 23: "Parry2HL", 24: "ShieldBlock", 25: "ReadyUnarmed", 26: "Ready1H", 27: "Ready2H", 28: "Ready2HL", 29: "ReadyBow", 30: "Dodge", 31: "SpellPrecast", 32: "SpellCast", 33: "SpellCastArea", 34: "NPCWelcome", 35: "NPCGoodbye", 36: "Block", 37: "JumpStart", 38: "Jump", 39: "JumpEnd", 40: "Fall", 41: "SwimIdle", 42: "Swim", 43: "SwimLeft", 44: "SwimRight", 45: "SwimBackwards", 46: "AttackBow", 47: "FireBow", 48: "ReadyRifle", 49: "AttackRifle", 50: "Loot", 51: "ReadySpellDirected", 52: "ReadySpellOmni", 53: "SpellCastDirected", 54: "SpellCastOmni", 55: "BattleRoar", 56: "ReadyAbility", 57: "Special1H", 58: "Special2H", 59: "ShieldBash", 60: "EmoteCheer", 61: "EmoteTalk", 62: "EmoteAnswer", 63: "EmoteBeg", 64: "EmoteEat", 65: "Mount", 66: "EmoteMount", 67: "EmoteAttackUnarmed", 68: "EmoteAttack1H", 69: "EmoteAttack2H", 70: "EmoteAttack2HL", 71: "EmoteParryUnarmed", 72: "EmoteParry1H", 73: "EmoteParry2H", 74: "EmoteParry2HL", 75: "EmoteShieldBlock", 76: "SpellCastOmniUp", 77: "InteractCrouch", 78: "Death2", 79: "AscentLoop", 80: "DescentLoop", 81: "Hover", 82: "FlyFall", 83: "FlySwimIdle", 84: "FlySwim", 85: "FlySwimLeft", 86: "FlySwimRight", 87: "FlySwimBackwards", 88: "AttackBow_NoPitch", 89: "FireBow_NoPitch", } # ───────────────────────────────────────────────────────────────────────────── # Math helpers # ───────────────────────────────────────────────────────────────────────────── def quat_mul(a, b): ax, ay, az, aw = a bx, by, bz, bw = b return ( aw*bx + ax*bw + ay*bz - az*by, aw*by - ax*bz + ay*bw + az*bx, aw*bz + ax*by - ay*bx + az*bw, aw*bw - ax*bx - ay*by - az*bz, ) def quat_norm(q): x, y, z, w = q m = math.sqrt(x*x + y*y + z*z + w*w) if m < 1e-10: return (0.0, 0.0, 0.0, 1.0) return (x/m, y/m, z/m, w/m) def quat_conj(q): x, y, z, w = q return (-x, -y, -z, w) def vec3_transform_quat(v, q): """Rotate vector v by quaternion q.""" # t = 2 * cross(q.xyz, v) qx, qy, qz, qw = q vx, vy, vz = v tx = 2.0 * (qy*vz - qz*vy) ty = 2.0 * (qz*vx - qx*vz) tz = 2.0 * (qx*vy - qy*vx) return ( vx + qw*tx + qy*tz - qz*ty, vy + qw*ty + qz*tx - qx*tz, vz + qw*tz + qx*ty - qy*tx, ) # WoW M2 model space → Godot (Y-up, right-handed) # WoW M2: X=right, Y=forward (into screen), Z=up # Godot: X=right, Y=up, Z=backward # So: gx=wx, gy=wz, gz=-wy def wow_pos_to_gltf(x, y, z): return (x, z, -y) def wow_quat_to_gltf(qx, qy, qz, qw): # Apply same basis change to quaternion # New basis: (1,0,0), (0,0,-1), (0,1,0) in old basis # This corresponds to rotating by 90° around X then adjusting # Effectively: new_q = basis_q * old_q * basis_q_conj # where basis_q rotates (0,1,0) → (0,0,1): that's 90° around X = (sin45, 0, 0, cos45) # Simpler: just remap components # If v' = (vx, vz, -vy) = M*v, and q rotates v, then q' = M * q * M^-1 # M = [[1,0,0],[0,0,1],[0,-1,0]] # New quat: x'=qx, y'=qz, z'=-qy, w'=qw return (qx, qz, -qy, qw) def wow_scale_to_gltf(sx, sy, sz): # Scale is symmetric, just remap axes return (sx, sz, sy) # ───────────────────────────────────────────────────────────────────────────── # M2 binary parser # ───────────────────────────────────────────────────────────────────────────── class M2Parser: def __init__(self, path): self.path = Path(path) self.dir = self.path.parent self.stem = self.path.stem with open(path, 'rb') as f: self.data = f.read() # Load per-animation external .anim buffers: anim_index → bytes self.anim_buffers = {} self._parse() # ── low-level helpers ──────────────────────────────────────────────────── def _u(self, fmt, off): return struct.unpack_from(fmt, self.data, off) def _read_array(self, off): """Read M2Array {count, offset} at file position off. Returns (count, data_ofs, next_off).""" count, data_ofs = struct.unpack_from('= n_ts or anim_idx >= n_vs: return [], [] # Get the inner M2Array for this animation index # Timestamps outer array: array of M2Array inner_ts_n, inner_ts_ofs = struct.unpack_from(' 0 else [] # ── Load animation sequences ────────────────────────────────────── # M2Sequence: id(2) variationIndex(2) duration(4) movespeed(4) # flags(4) frequency(2) _pad(2) replay(8) blendtime(4) # bounds(24) radius(4) variationNext(2) aliasNext(2) # Total = 64 bytes SEQ_SIZE = 64 self.sequences = [] for i in range(self.n_anim): base = self.ofs_anim + i * SEQ_SIZE seq_id, var_idx, duration, movespeed, flags, frequency = \ struct.unpack_from(' = 20 bytes # rotation: M2Track= 20 bytes # scale: M2Track = 20 bytes # pivot: Vec3 = 12 bytes # Total = 4+4+2+2+4 + 20+20+20 + 12 = 88 bytes BONE_SIZE = 88 self.bones = [] for i in range(self.n_bones): base = self.ofs_bones + i * BONE_SIZE key_bone_id, flags = struct.unpack_from(' 0 and ofs_fn > 0: try: raw = d[ofs_fn: ofs_fn + n_fn] filename = raw.rstrip(b'\x00').decode('utf-8', errors='replace') except Exception: pass self.textures.append({'type': tex_type, 'flags': tex_flags, 'filename': filename}) # ── textureLookupTable: uint16 per entry → index into self.textures ─ self.texture_lookup = list( struct.unpack_from(f'<{self.n_tex_look}H', d, self.ofs_tex_look) ) if self.n_tex_look > 0 else [] # ── Load M2 materials ────────────────────────────────────────────── # M2Material: flags(uint16) + blendingMode(uint16) = 4 bytes each # blendingMode: 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, 4=Mod, 5=Mod2x, 6=ModAdd, 7=InvSrcAlpha... self.materials_data = [] MAT_SIZE = 4 for i in range(self.n_mat): base = self.ofs_mat + i * MAT_SIZE mat_flags, blend_mode = struct.unpack_from(' 0 else [] # Triangle indices (into local_vertices) self.indices = list(struct.unpack_from(f'<{n_i}H', d, ofs_i)) # Skin sections / submeshes # M2SkinSection: skinSectionId(2) Level(2) vertexStart(2) vertexCount(2) # indexStart(2) indexCount(2) boneCount(2) startBones(2) boneInfluences(2) # centerBoneIndex(2) centerPos(12) sortCenterPos(12) sortRadius(4) # Total = 2+2+2+2+2+2+2+2+2+2+12+12+4 = 48 bytes SEC_SIZE = 48 self.sections = [] for i in range(n_s): base = ofs_s + i * SEC_SIZE (sec_id, level, vstart, vcount, istart, icount, bone_count, start_bones, bone_influences, center_bone) = \ struct.unpack_from('<10H', d, base) self.sections.append({ 'id': sec_id, 'vertex_start': vstart, 'vertex_count': vcount, 'index_start': istart, 'index_count': icount, }) # Render batches (texture units) # M2Batch (WotLK): flags(1) priorityPlane(1) shader_id(2) # skinSectionIndex(2) geosetIndex(2) colorIndex(2) materialIndex(2) # materialLayer(2) textureCount(2) textureLookupId(2) # textureUnitLookupId(2) transparencyLookupId(2) textureAnimLookupId(2) # Total = 1+1+2+2+2+2+2+2+2+2+2+2+2 = 24 bytes # M2Batch (WotLK, 24 bytes): # flags(1) priorityPlane(1) shader_id(2) # skinSectionIndex(2) geosetIndex(2) colorIndex(2) materialIndex(2) # materialLayer(2) textureCount(2) textureLookupId(2) # textureUnitLookupId(2) transparencyLookupId(2) textureAnimLookupId(2) BATCH_SIZE = 24 # Map skin section index → first texture_lookup_id / materialIndex found for it # Batch layout: flags(1) priority(1) shader_id(2) skinSectionIndex(2) geosetIndex(2) # colorIndex(2) materialIndex(2) materialLayer(2) textureCount(2) # textureLookupId(2) textureUnitLookupId(2) ... # offsets: 0 2 4 6 # 8 10 12 14 # 16 18 20 self.section_texture_lookup = {} # sec_idx → tex_look_id self.section_material_index = {} # sec_idx → m2 material index for i in range(n_bt): base = ofs_bt + i * BATCH_SIZE (sec_idx,) = struct.unpack_from(' int: """Append data to bin buffer (aligned), return byte offset.""" off = len(self.bin_data) pad = (alignment - off % alignment) % alignment self.bin_data.extend(b'\x00' * pad) off = len(self.bin_data) self.bin_data.extend(data) return off def _add_accessor(self, data: bytes, count: int, component_type: int, acc_type: str, min_vals=None, max_vals=None, normalized=False) -> int: off = self._add_bin(data) bv_idx = len(self.buffer_views) self.buffer_views.append({ "buffer": 0, "byteOffset": off, "byteLength": len(data), }) acc = { "bufferView": bv_idx, "byteOffset": 0, "componentType": component_type, "count": count, "type": acc_type, } if normalized: acc["normalized"] = True if min_vals is not None: acc["min"] = list(min_vals) if max_vals is not None: acc["max"] = list(max_vals) idx = len(self.accessors) self.accessors.append(acc) return idx # ── build scene ────────────────────────────────────────────────────────── def build(self, m2: M2Parser, skin: SkinParser, anim_filter=None) -> bytes: """ Build GLB bytes. anim_filter: optional set of animation IDs to include (None = all). """ # 1. Build bone node tree (returns all bone nodes + virtual Armature root) bone_node_indices, armature_idx = self._build_skeleton(m2) # 2. Resolve textures → GLTF material indices mat_map = self._build_materials(m2, skin) # 3. Build per-geoset mesh nodes geoset_node_indices = self._build_mesh(m2, skin, bone_node_indices, mat_map) # 4. Build skin; every geoset node shares the same skin skin_idx = self._build_skin(m2, bone_node_indices, armature_idx) for ni in geoset_node_indices: self.nodes[ni]["skin"] = skin_idx # 5. Build animations self._build_animations(m2, bone_node_indices, anim_filter) # 6. Compose scene – single Armature node + geoset mesh nodes scene_nodes = [armature_idx] + geoset_node_indices gltf = { "asset": self.asset, "scene": 0, "scenes": [{"nodes": scene_nodes}], "nodes": self.nodes, "meshes": self.meshes, "skins": self.skins, "accessors": self.accessors, "bufferViews": self.buffer_views, "buffers": [{"byteLength": len(self.bin_data)}], } if self.animations: gltf["animations"] = self.animations if self.materials: gltf["materials"] = self.materials if self.textures: gltf["textures"] = self.textures if self.images: gltf["images"] = self.images return self._write_glb(gltf) # ── texture / material builder ──────────────────────────────────────────── def _build_materials(self, m2: M2Parser, skin: SkinParser) -> dict: """ Returns a dict: section_index → gltf_material_index (or None). Textures are embedded as PNG in the binary buffer. WoW texture types used for character base appearance: 0 = hardcoded filename (usually shared parts) 1 = body skin → {stem}Skin00_00.blp 2 = object skin 6 = hair 8 = fur 11 = skin extra (naked torso/legs) → {stem}NakedTorsoSkin00_00.blp / NakedPelvisSkin00_00.blp """ if not _PIL_OK: return {} m2_dir = m2.path.parent # ── default skin BLP discovery ────────────────────────────────────── # Index BLP files in m2_dir (character-specific textures) blp_index = {} # lowercase stem → Path for p in m2_dir.iterdir(): if p.suffix.lower() == '.blp': blp_index[p.stem.lower()] = p # Also index race-level directory (one up from m2_dir) for shared # textures like hair (e.g. CHARACTER/BloodElf/HAIR00_00.BLP) race_blp_index = {} race_dir = m2_dir.parent if race_dir.is_dir(): for p in race_dir.iterdir(): if p.suffix.lower() == '.blp': race_blp_index[p.stem.lower()] = p stem_lower = m2.stem.lower() def _default_for_type(tex_type): """Return a Path candidate for a given replaceable texture type.""" if tex_type == 1: # Primary body skin: e.g. BloodElfFemaleSkin00_00.blp for k in blp_index: if k.startswith(stem_lower + 'skin00_'): return blp_index[k] elif tex_type == 11: # Naked torso or pelvis skin for k in blp_index: if 'nakedtorsoskin' in k or 'nakedpelvisskin' in k: return blp_index[k] elif tex_type == 6: # Hair: first look in char dir (e.g. BloodElfMaleHair01.blp), # then in race dir (e.g. CHARACTER/BloodElf/Hair00_00.BLP) for k in blp_index: if 'hair' in k or 'scalp' in k: return blp_index[k] for k in sorted(race_blp_index): if 'hair' in k or 'scalp' in k: return race_blp_index[k] elif tex_type == 2: # Object / cape skin – skip, equipment-specific return None return None # ── cache: blp_path → gltf image index ───────────────────────────── blp_cache: dict = {} # key: Path or (str, Path) → gltf texture index def _load_texture(blp_path: Path) -> int | None: """Decode BLP and add to GLTF images/textures, return texture index.""" if blp_path in blp_cache: return blp_cache[blp_path] result = _blp_to_rgba(blp_path) if result is None: blp_cache[blp_path] = None return None w, h, rgba = result png_bytes = _rgba_to_png_bytes(w, h, rgba) # Embed PNG in binary buffer png_off = self._add_bin(png_bytes, alignment=1) bv_idx = len(self.buffer_views) self.buffer_views.append({ "buffer": 0, "byteOffset": png_off, "byteLength": len(png_bytes), }) img_idx = len(self.images) self.images.append({ "name": blp_path.stem, "mimeType": "image/png", "bufferView": bv_idx, }) tex_idx = len(self.textures) self.textures.append({"source": img_idx}) blp_cache[blp_path] = tex_idx return tex_idx def _load_skin_composited(blp_path: Path) -> int | None: """ Load body skin BLP and composite all overlay textures for the naked state. WoW character skin UV layout (512x512): Upper-left panel (X:0-256, Y:0-128) NakedTorsoSkin – bra area Upper-left panel (X:0-256, Y:128-256) NakedPelvisSkin – panties area Lower-left quadrant (X:0-256, Y:256-512) = head/face: FaceUpper (256x64) at (0, 320) – forehead, upper face FaceLower (256x128) at (0, 384) – eyes, mouth, nose All layers are alpha-composited over the base skin in order. """ cache_key = ("skin_composited", blp_path) if cache_key in blp_cache: return blp_cache[cache_key] skin_result = _blp_to_rgba(blp_path) if skin_result is None: blp_cache[cache_key] = None return None sw, sh, skin_rgba = skin_result skin_img = _PIL_Image.frombytes('RGBA', (sw, sh), skin_rgba) # ── 1. Naked body overlays (torso + pelvis = underwear) ────────── # NakedTorsoSkin (256x128) at (0, 0) – upper-left, bra region # NakedPelvisSkin (256x128) at (0, 128) – below torso, panties region for key_frag, paste_x, paste_y in [ ('nakedtorsoskin', 0, 0), ('nakedpelvisskin', 0, 128), ]: naked_path = None for k, p in blp_index.items(): if key_frag in k: naked_path = p break if naked_path is None: continue naked_result = _blp_to_rgba(naked_path) if naked_result is None: continue nw, nh, naked_rgba = naked_result naked_img = _PIL_Image.frombytes('RGBA', (nw, nh), naked_rgba) skin_img.alpha_composite(naked_img, (paste_x, paste_y)) print(f" Composited {naked_path.name} onto skin at ({paste_x}, {paste_y})") # ── 2. Face overlays ───────────────────────────────────────────── # FaceLower (256x128) at (0, 384) – eyes, mouth, nose # FaceUpper (256x64) at (0, 320) – forehead/upper face face_lower_y = sh # updated after FaceLower is measured for face_key_fragment in ('facelower', 'faceupper'): face_path = None for k, p in blp_index.items(): if face_key_fragment in k: face_path = p break if face_path is None: continue face_result = _blp_to_rgba(face_path) if face_result is None: continue fw, fh, face_rgba = face_result face_img = _PIL_Image.frombytes('RGBA', (fw, fh), face_rgba) paste_x = 0 if face_key_fragment == 'facelower': paste_y = sh - fh face_lower_y = paste_y else: paste_y = face_lower_y - fh skin_img.alpha_composite(face_img, (paste_x, paste_y)) print(f" Composited {face_path.name} onto skin at ({paste_x}, {paste_y})") buf = io.BytesIO() skin_img.save(buf, format='PNG', optimize=False) png_bytes = buf.getvalue() # Embed PNG in binary buffer png_off = self._add_bin(png_bytes, alignment=1) bv_idx = len(self.buffer_views) self.buffer_views.append({ "buffer": 0, "byteOffset": png_off, "byteLength": len(png_bytes), }) img_idx = len(self.images) self.images.append({ "name": blp_path.stem, "mimeType": "image/png", "bufferView": bv_idx, }) tex_idx = len(self.textures) self.textures.append({"source": img_idx}) blp_cache[cache_key] = tex_idx return tex_idx # ── resolve texture for each M2 texture entry ─────────────────────── # tex_idx → gltf texture idx m2tex_to_gltf: dict[int, int] = {} for i, tex in enumerate(m2.textures): blp_path = None if tex['type'] == 0 and tex['filename']: # Hardcoded path: extract filename stem, search in m2_dir first, # then sibling directories (WoW sometimes points cross-gender). fn_stem = Path(tex['filename'].replace('\\', '/')).stem.lower() blp_path = blp_index.get(fn_stem) or race_blp_index.get(fn_stem) if blp_path is None: # Search sibling dirs (e.g. Female M2 refs Male eye glow) for sibling in m2_dir.parent.iterdir(): if sibling.is_dir() and sibling != m2_dir: for p in sibling.iterdir(): if p.suffix.lower() == '.blp' and p.stem.lower() == fn_stem: blp_path = p break if blp_path: break else: blp_path = _default_for_type(tex['type']) if blp_path: # Body skin (type=1) gets face overlay composited on top if tex['type'] == 1: gtex = _load_skin_composited(blp_path) else: gtex = _load_texture(blp_path) if gtex is not None: m2tex_to_gltf[i] = gtex # ── map each skin section → gltf material idx ────────────────────── # Key: (gltf_tex_idx, m2_blend_mode) → mat_idx so blend_mode=1 (AlphaKey) # gets a MASK material and blend_mode=2 gets a BLEND material. mat_cache: dict[tuple, int] = {} def _get_or_create_mat(gtex_idx: int, blend_mode: int = 2) -> int: key = (gtex_idx, blend_mode) if key in mat_cache: return mat_cache[key] # Map M2 blend mode → GLTF alphaMode if blend_mode == 0: # Opaque alpha_mode = "OPAQUE" elif blend_mode == 1: # AlphaKey (cutout) alpha_mode = "MASK" else: # Alpha, Add, Mod, etc. alpha_mode = "BLEND" mat = { "pbrMetallicRoughness": { "baseColorTexture": {"index": gtex_idx}, "metallicFactor": 0.0, "roughnessFactor": 1.0, }, "alphaMode": alpha_mode, "doubleSided": True, } if blend_mode == 1: mat["alphaCutoff"] = 0.5 mat_idx = len(self.materials) self.materials.append(mat) mat_cache[key] = mat_idx return mat_idx mat_map: dict[int, int] = {} # section_idx → gltf_mat_idx for sec_idx, tex_look_id in skin.section_texture_lookup.items(): # Determine this section's M2 blend mode from its material m2mat_idx = skin.section_material_index.get(sec_idx) blend_mode = 2 # default: alpha blend if m2mat_idx is not None and m2mat_idx < len(m2.materials_data): blend_mode = m2.materials_data[m2mat_idx]['blend_mode'] if tex_look_id < len(m2.texture_lookup): m2tex_idx = m2.texture_lookup[tex_look_id] gtex = m2tex_to_gltf.get(m2tex_idx) if gtex is not None: mat_map[sec_idx] = _get_or_create_mat(gtex, blend_mode) n_tex = len(self.images) n_mat = len(self.materials) if n_tex: print(f"Textures: {n_tex} embedded, {n_mat} materials") return mat_map # ── skeleton ───────────────────────────────────────────────────────────── def _build_skeleton(self, m2: M2Parser): bone_node_indices = [] for i, bone in enumerate(m2.bones): node_idx = len(self.nodes) bone_node_indices.append(node_idx) # Pivot in GLTF space (world-space first; parent-relative below) px, py, pz = wow_pos_to_gltf(*bone['pivot']) node = { "name": f"bone_{i}", "translation": [px, py, pz], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0], } self.nodes.append(node) # Wire up children for i, bone in enumerate(m2.bones): parent = bone['parent'] if parent >= 0 and parent < len(m2.bones): parent_node = bone_node_indices[parent] child_node = bone_node_indices[i] self.nodes[parent_node].setdefault("children", []).append(child_node) # Convert world-space pivots to parent-relative translations for i, bone in enumerate(m2.bones): parent = bone['parent'] if parent >= 0 and parent < len(m2.bones): ppx, ppy, ppz = wow_pos_to_gltf(*m2.bones[parent]['pivot']) node = self.nodes[bone_node_indices[i]] t = node["translation"] node["translation"] = [t[0]-ppx, t[1]-ppy, t[2]-ppz] # GLTF spec: skin.skeleton must be an ancestor of every joint node. # WoW models have multiple root bones (main skeleton + attachment/socket # bones each with parent=-1). Wrap them all under a single virtual # "Armature" node at the origin so the skin has a valid common root. root_bone_nodes = [bone_node_indices[i] for i, b in enumerate(m2.bones) if b['parent'] < 0] armature_idx = len(self.nodes) self.nodes.append({ "name": "Armature", "translation": [0.0, 0.0, 0.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0], "children": root_bone_nodes, }) n_roots = len(root_bone_nodes) if n_roots > 1: print(f"Skeleton: {len(m2.bones)} bones, {n_roots} root bones " f"-> wrapped under virtual Armature node") return bone_node_indices, armature_idx # ── mesh ───────────────────────────────────────────────────────────────── def _build_section_primitive(self, m2, skin, sec_idx, mat_map): """Build one GLTF primitive dict for skin section sec_idx. Returns None if empty.""" sec = skin.sections[sec_idx] vstart = sec['vertex_start'] vcount = sec['vertex_count'] istart = sec['index_start'] icount = sec['index_count'] if vcount == 0 or icount == 0: return None local_vert_slice = skin.local_vertices[vstart: vstart + vcount] positions = []; normals = []; uvs = []; joints = []; weights = [] for local_idx in range(vcount): global_vert_idx = local_vert_slice[local_idx] if global_vert_idx >= len(m2.vertices): continue v = m2.vertices[global_vert_idx] positions.extend(wow_pos_to_gltf(*v['pos'])) normals.extend(wow_pos_to_gltf(*v['normal'])) uvs.extend([v['uv0'][0], v['uv0'][1]]) bw = v['bone_weights'] bw_sum = sum(bw) or 1 weights.extend([b / bw_sum for b in bw]) # In WotLK M2, vertex bone_indices are direct M2 bone array indices. joints.extend(list(v['bone_indices'])) tri_indices = [skin.indices[k] - vstart for k in range(istart, istart + icount)] if not positions or not tri_indices: return None n_verts = len(positions) // 3 pos_bytes = struct.pack(f'<{len(positions)}f', *positions) nrm_bytes = struct.pack(f'<{len(normals)}f', *normals) uv_bytes = struct.pack(f'<{len(uvs)}f', *uvs) jnt_bytes = struct.pack(f'<{len(joints)}H', *joints) wgt_bytes = struct.pack(f'<{len(weights)}f', *weights) idx_bytes = struct.pack(f'<{len(tri_indices)}H', *tri_indices) xs = positions[0::3]; ys = positions[1::3]; zs = positions[2::3] pos_acc = self._add_accessor(pos_bytes, n_verts, 5126, "VEC3", [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)]) nrm_acc = self._add_accessor(nrm_bytes, n_verts, 5126, "VEC3") uv_acc = self._add_accessor(uv_bytes, n_verts, 5126, "VEC2") jnt_acc = self._add_accessor(jnt_bytes, n_verts, 5123, "VEC4") wgt_acc = self._add_accessor(wgt_bytes, n_verts, 5126, "VEC4") idx_acc = self._add_accessor(idx_bytes, len(tri_indices), 5123, "SCALAR") prim = { "attributes": { "POSITION": pos_acc, "NORMAL": nrm_acc, "TEXCOORD_0": uv_acc, "JOINTS_0": jnt_acc, "WEIGHTS_0": wgt_acc, }, "indices": idx_acc, "mode": 4, } mat_idx = mat_map.get(sec_idx) if mat_idx is not None: prim["material"] = mat_idx return prim def _build_mesh(self, m2: M2Parser, skin: SkinParser, bone_node_indices, mat_map: dict = None): """ Build one GLTF mesh node per unique geoset ID. WoW geoset ID convention: 0 = base body (always visible) 1xx = skin body variation (cat=1, var=xx; show only var=01 by default) 2xx = face lower 3xx = face upper 4xx = hair style 5xx = facial feature (beard, etc.) 6xx = eyelashes 7xx-9xx = accessories, tabard, cloak 1000+ = additional optional parts Each geoset becomes a separate MeshInstance3D in Godot so you can show/hide individual parts via GDScript. """ if mat_map is None: mat_map = {} # Correct geoset ID ranges from M2SkinMeshPartID (WotLK 3.3.5) # Source: pywowlib/enums/m2_enums.py :: M2SkinMeshPartID GEOSET_RANGES = [ (range(0, 1), "skin"), # base body – always shown (range(1, 35), "hair"), # hair styles (range(101, 125), "facial1_beard"), (range(201, 220), "facial2_mustache"), (range(301, 320), "facial3_sideburns"), (range(401, 406), "gloves"), (range(501, 511), "boots"), (range(601, 615), "shirt"), (range(701, 712), "ears"), (range(801, 805), "wristbands"), (range(901, 906), "kneepads"), (range(1001, 1005), "chest"), (range(1101, 1106), "pants"), (range(1201, 1205), "tabard"), (range(1301, 1304), "legs"), (range(1401, 1415), "shirt_doublet"), (range(1501, 1525), "cape"), (range(1601, 1615), "facial_jewelry"), (range(1701, 1706), "eye_effects"), (range(1801, 1805), "belt"), (range(1901, 1915), "trail"), (range(2001, 2009), "feet"), ] def _geoset_name(gid: int) -> str: for r, name in GEOSET_RANGES: if gid in r: variant = gid - r.start return f"geoset_{gid:04d}_{name}_v{variant:02d}" return f"geoset_{gid:04d}_unknown" # Group sections by geoset ID geoset_sections: dict[int, list[int]] = {} for sec_idx, sec in enumerate(skin.sections): gid = sec['id'] geoset_sections.setdefault(gid, []).append(sec_idx) geoset_node_indices = [] category_set = set() for gid in sorted(geoset_sections): primitives = [] for sec_idx in geoset_sections[gid]: prim = self._build_section_primitive(m2, skin, sec_idx, mat_map) if prim is not None: primitives.append(prim) if not primitives: continue node_name = _geoset_name(gid) # Extract category name (part between second and third underscore) cat_name = node_name.split("_")[2] if node_name.count("_") >= 2 else "unknown" category_set.add(cat_name) # Determine M2 blend_mode for this geoset (use first section's material) blend_mode = 0 for sec_idx in geoset_sections[gid]: mid = skin.section_material_index.get(sec_idx) if mid is not None and mid < len(m2.materials_data): blend_mode = m2.materials_data[mid]['blend_mode'] break mesh_idx = len(self.meshes) self.meshes.append({"name": node_name, "primitives": primitives}) node_idx = len(self.nodes) self.nodes.append({ "name": node_name, "mesh": mesh_idx, "extras": {"geoset_id": gid, "blend_mode": blend_mode}, }) geoset_node_indices.append(node_idx) print(f"Geosets: {len(geoset_node_indices)} nodes, types: {sorted(category_set)}") return geoset_node_indices # ── skin (armature binding) ─────────────────────────────────────────────── def _build_skin(self, m2: M2Parser, bone_node_indices, armature_idx: int): """Build inverse bind matrices and GLTF skin.""" n_bones = len(m2.bones) ibm_floats = [] for bone in m2.bones: # Inverse bind matrix for bone i: # At bind pose all rotations are identity, so world transform = T(pivot_world). # IBM = T(-pivot_world), column-major (GLTF convention). px, py, pz = wow_pos_to_gltf(*bone['pivot']) mat = [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -px, -py, -pz, 1.0, ] ibm_floats.extend(mat) ibm_bytes = struct.pack(f'<{len(ibm_floats)}f', *ibm_floats) ibm_acc = self._add_accessor(ibm_bytes, n_bones, 5126, "MAT4") skin_idx = len(self.skins) self.skins.append({ "name": f"{m2.stem}_armature", # skeleton = common ancestor of all joints (the virtual Armature node) "skeleton": armature_idx, "joints": bone_node_indices, "inverseBindMatrices": ibm_acc, }) return skin_idx # ── animations ─────────────────────────────────────────────────────────── def _build_animations(self, m2: M2Parser, bone_node_indices, anim_filter): for seq_idx, seq in enumerate(m2.sequences): anim_id = seq['id'] if anim_filter is not None and anim_id not in anim_filter: continue # Skip alias animations (point to another sequence) if seq['alias_next'] != seq_idx and seq['alias_next'] != 0xFFFF: continue duration_s = seq['duration'] / 1000.0 if duration_s <= 0: continue name = ANIM_NAMES.get(anim_id, f"Anim_{anim_id}") if seq['variation'] > 0: name += f"_var{seq['variation']}" channels = [] samplers = [] for bone_idx, bone in enumerate(m2.bones): node_idx = bone_node_indices[bone_idx] # Translation # M2 t_track values are OFFSETS from the bone's rest position (pivot # relative to parent pivot). GLTF animation overwrites node.translation # entirely, so we must emit: rest_local + m2_offset (both in GLTF space). ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3') if ts and vals: rest = self.nodes[node_idx].get("translation", [0.0, 0.0, 0.0]) rx, ry, rz = rest[0], rest[1], rest[2] # Use a factory to capture rx/ry/rz by value (avoid late-binding bug) def _make_trans_fn(rx, ry, rz): def fn(v): cx, cy, cz = wow_pos_to_gltf(*v) return (rx + cx, ry + cy, rz + cz) return fn s_idx = self._anim_sampler(ts, vals, 'VEC3', _make_trans_fn(rx, ry, rz)) if s_idx is not None: samplers.append(s_idx) channels.append({"sampler": len(samplers)-1, "target": {"node": node_idx, "path": "translation"}}) # Rotation # M2 r_track quaternions are the bone's absolute local rotation. # Rest rotation is identity so no adjustment needed. ts, vals = m2._resolve_track(bone['r_track'], seq_idx, 'quat_c') if ts and vals: s_idx = self._anim_sampler(ts, vals, 'VEC4', lambda v: wow_quat_to_gltf(*v)) if s_idx is not None: samplers.append(s_idx) channels.append({"sampler": len(samplers)-1, "target": {"node": node_idx, "path": "rotation"}}) # Scale # M2 s_track values are absolute local scale (1,1,1 at rest). ts, vals = m2._resolve_track(bone['s_track'], seq_idx, 'vec3') if ts and vals: s_idx = self._anim_sampler(ts, vals, 'VEC3', lambda v: wow_scale_to_gltf(*v)) if s_idx is not None: samplers.append(s_idx) channels.append({"sampler": len(samplers)-1, "target": {"node": node_idx, "path": "scale"}}) if channels: self.animations.append({ "name": name, "samplers": samplers, "channels": channels, }) print(f" Anim '{name}': {len(channels)} channels") def _anim_sampler(self, timestamps_ms, values, acc_type, conv_fn): """ Build a GLTF animation sampler dict from raw M2 timestamps (ms) and values. Returns the sampler dict to append to animation["samplers"], or None on error. """ if not timestamps_ms or not values or len(timestamps_ms) != len(values): return None n = len(timestamps_ms) # Input: timestamps in seconds times = [t / 1000.0 for t in timestamps_ms] t_bytes = struct.pack(f'<{n}f', *times) t_min = [min(times)] t_max = [max(times)] t_acc = self._add_accessor(t_bytes, n, 5126, "SCALAR", t_min, t_max) # Output: converted values try: converted = [conv_fn(v) for v in values] except Exception: converted = [tuple(v) for v in values] flat = [x for c in converted for x in c] v_bytes = struct.pack(f'<{len(flat)}f', *flat) if acc_type == 'VEC4': # Normalize quaternions norm_flat = [] for i in range(0, len(flat), 4): q = quat_norm(flat[i:i+4]) norm_flat.extend(q) v_bytes = struct.pack(f'<{len(norm_flat)}f', *norm_flat) v_acc = self._add_accessor(v_bytes, n, 5126, acc_type) return {"input": t_acc, "output": v_acc, "interpolation": "LINEAR"} # ── GLB writer ─────────────────────────────────────────────────────────── def _write_glb(self, gltf: dict) -> bytes: json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8') # Pad JSON to 4-byte boundary with spaces pad = (4 - len(json_bytes) % 4) % 4 json_bytes += b' ' * pad bin_bytes = bytes(self.bin_data) # Pad binary to 4-byte boundary bin_pad = (4 - len(bin_bytes) % 4) % 4 bin_bytes += b'\x00' * bin_pad # GLB header: magic(4) version(4) total_length(4) json_chunk = struct.pack(' Path | None: """ Export individual BLP texture layers as PNG files for runtime compositing. Scans the M2's directory for the following layer types and saves them to {out_dir}/{stem}_textures/ with a normalised naming scheme: skin_{color:02d}.png – base body skin variants naked_torso_{color:02d}.png – NakedTorsoSkin (bra area) naked_pelvis_{color:02d}.png – NakedPelvisSkin (panties area) face_lower_{style:02d}_{color:02d}.png face_upper_{style:02d}_{color:02d}.png Returns the textures directory Path, or None if PIL is not available. """ if not _PIL_OK: return None import re as _re m2_dir = m2_path.parent stem = m2_path.stem tex_dir = out_dir / f"{stem}_textures" tex_dir.mkdir(parents=True, exist_ok=True) stem_esc = _re.escape(stem.lower()) # (regex_pattern, layer_name, has_style_group) layer_rules = [ (_re.compile(rf'^{stem_esc}skin(\d+)_(\d+)$', _re.I), 'skin', False), (_re.compile(rf'^{stem_esc}nakedtorsoskin(\d+)_(\d+)$', _re.I), 'naked_torso', False), (_re.compile(rf'^{stem_esc}nakedpelvisskin(\d+)_(\d+)$', _re.I), 'naked_pelvis', False), (_re.compile(rf'^{stem_esc}facelower(\d+)_(\d+)$', _re.I), 'face_lower', True), (_re.compile(rf'^{stem_esc}faceupper(\d+)_(\d+)$', _re.I), 'face_upper', True), ] count = 0 for blp_file in sorted(m2_dir.iterdir()): if blp_file.suffix.lower() != '.blp': continue blp_stem = blp_file.stem for pattern, layer_name, has_style in layer_rules: m = pattern.match(blp_stem) if not m: continue style_idx = int(m.group(1)) color_idx = int(m.group(2)) if has_style: out_name = f"{layer_name}_{style_idx:02d}_{color_idx:02d}.png" else: out_name = f"{layer_name}_{color_idx:02d}.png" result = _blp_to_rgba(blp_file) if result is None: break w, h, rgba = result png_bytes = _rgba_to_png_bytes(w, h, rgba) (tex_dir / out_name).write_bytes(png_bytes) count += 1 break # matched – next BLP file if count: print(f"Texture layers: {count} PNGs -> {tex_dir.name}/") return tex_dir def convert(m2_path: str, output_dir: str = None, anim_filter=None): m2_path = Path(m2_path) if not m2_path.exists(): raise FileNotFoundError(f"M2 file not found: {m2_path}") print(f"\n=== Converting {m2_path.name} ===") # Find skin file: 00.skin skin_path = m2_path.parent / (m2_path.stem + "00.skin") if not skin_path.exists(): # Try uppercase for f in m2_path.parent.iterdir(): if f.suffix.lower() == '.skin': skin_path = f break if not skin_path.exists(): raise FileNotFoundError(f"Skin file not found near {m2_path}") print(f"Skin: {skin_path.name}") m2 = M2Parser(str(m2_path)) skin = SkinParser(str(skin_path)) builder = GltfBuilder() glb = builder.build(m2, skin, anim_filter=anim_filter) out_dir = Path(output_dir) if output_dir else m2_path.parent out_dir.mkdir(parents=True, exist_ok=True) out_path = out_dir / (m2_path.stem + ".glb") with open(out_path, 'wb') as f: f.write(glb) print(f"Wrote {len(glb):,} bytes -> {out_path}") # Export individual texture layers for runtime compositing _export_texture_layers(m2_path, out_dir) return str(out_path) if __name__ == '__main__': import argparse ap = argparse.ArgumentParser( description="WoW 3.3.5 M2 -> GLTF 2.0 GLB converter", formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument("input", nargs="?", help="Path to a single .M2 file (omit when using --all)") ap.add_argument("output_dir", nargs="?", help="Output directory for single-file mode") ap.add_argument("--all", metavar="SEARCH_DIR", help="Recursively convert all .M2 files under SEARCH_DIR") ap.add_argument("--out", metavar="OUT_ROOT", default=None, help="Root output dir for --all mode. " "Mirror the subpath relative to SEARCH_DIR. " "Defaults to /resources") args = ap.parse_args() if args.all: search_root = Path(args.all).resolve() out_root = Path(args.out).resolve() if args.out else Path("resources") m2_files = sorted(search_root.rglob("*.M2")) if not m2_files: print(f"No .M2 files found under {search_root}") sys.exit(1) print(f"Found {len(m2_files)} M2 file(s) under {search_root}") ok = err = 0 for m2 in m2_files: rel = m2.relative_to(search_root) out_dir = out_root / rel.parent out_dir.mkdir(parents=True, exist_ok=True) try: convert(str(m2), str(out_dir)) ok += 1 except Exception as e: print(f" ERROR {m2.name}: {e}") err += 1 print(f"\nDone: {ok} converted, {err} failed") elif args.input: convert(args.input, args.output_dir) else: ap.print_help() sys.exit(1)