Files
open-wc/tools/m2_to_gltf.py
T
2026-04-20 20:26:14 +04:00

1579 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <output_dir>/<ModelName>.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('<I', data, 12)[0]
height = struct.unpack_from('<I', data, 16)[0]
mip_offsets = struct.unpack_from('<16I', data, 20)
mip_sizes = struct.unpack_from('<16I', data, 84)
if width == 0 or height == 0 or not mip_offsets[0]:
return None
mip_data = data[mip_offsets[0]: mip_offsets[0] + mip_sizes[0]]
# ── Palette (compression == 1) ────────────────────────────────────────
if compression == 1:
# BGRA palette of 256 entries at offset 148
pal_raw = data[148: 148 + 1024]
n = width * height
indices = mip_data[:n]
rgba = bytearray(n * 4)
if alpha_depth == 8:
# Color indices followed immediately by alpha bytes
alpha_bytes = mip_data[n: n + 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]
a = alpha_bytes[i] if i < len(alpha_bytes) else 255
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
elif alpha_depth == 4:
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 >> 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('<I', DDSD_FLAGS)
header += struct.pack('<II', height, width)
header += struct.pack('<I', mip_sizes[0]) # linearSize
header += struct.pack('<II', 0, 0) # depth, mipMapCount
header += b'\x00' * 44 # reserved
# DDS_PIXELFORMAT
header += struct.pack('<II', 32, DDPF_FOURCC)
header += fourcc
header += struct.pack('<IIIII', 0, 0, 0, 0, 0)
# Caps
header += struct.pack('<IIII', DDSCAPS_TEXTURE, 0, 0, 0)
header += struct.pack('<I', 0)
dds_bytes = header + mip_data
try:
img = _PIL_Image.open(io.BytesIO(dds_bytes))
img = img.convert('RGBA')
return img.width, img.height, img.tobytes()
except Exception:
return None
# ── Raw RGBA (compression == 3) ────────────────────────────────────────
if compression == 3:
n = width * height
rgba = bytearray(n * 4)
# Stored as BGRA uint32 each
for i in range(n):
b, g, r, a = mip_data[i*4], mip_data[i*4+1], mip_data[i*4+2], mip_data[i*4+3]
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
return width, height, bytes(rgba)
return None
def _rgba_to_png_bytes(width: int, height: int, rgba: bytes) -> 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('<II', self.data, off)
return count, data_ofs, off + 8
def _read_track(self, off, buf_for_anim=None):
"""
Parse M2Track header starting at `off`.
Returns (track_dict, next_off) where track_dict has:
interp, global_seq, timestamps[anim_idx], values[anim_idx]
timestamps/values are lazy only header parsed here.
"""
interp, global_seq = struct.unpack_from('<hh', self.data, off); off += 4
n_ts, ofs_ts, off = self._read_array(off)
n_vs, ofs_vs, off = self._read_array(off)
return {
'interp': interp,
'global_seq': global_seq,
'_n_ts': n_ts, '_ofs_ts': ofs_ts,
'_n_vs': n_vs, '_ofs_vs': ofs_vs,
}, off
def _resolve_track(self, track, anim_idx, value_fmt):
"""
Read actual timestamps+values for animation index anim_idx from this track.
value_fmt: 'vec3' | 'quat_c' (compressed int16×4) | 'float'
Returns (timestamps: list[int], values: list[tuple])
"""
n_ts = track['_n_ts']
ofs_ts = track['_ofs_ts']
n_vs = track['_n_vs']
ofs_vs = track['_ofs_vs']
if anim_idx >= n_ts or anim_idx >= n_vs:
return [], []
# Get the inner M2Array for this animation index
# Timestamps outer array: array of M2Array<uint32>
inner_ts_n, inner_ts_ofs = struct.unpack_from('<II', self.data, ofs_ts + anim_idx * 8)
inner_vs_n, inner_vs_ofs = struct.unpack_from('<II', self.data, ofs_vs + anim_idx * 8)
if inner_ts_n == 0 or inner_vs_n == 0:
return [], []
# Choose buffer: external .anim file or M2 file
buf = self.anim_buffers.get(anim_idx, self.data)
# Safety: offset 0 in external buffer means no data
if inner_ts_ofs == 0 and buf is not self.data:
return [], []
try:
timestamps = list(struct.unpack_from(f'<{inner_ts_n}I', buf, inner_ts_ofs))
except struct.error:
return [], []
values = []
try:
if value_fmt == 'vec3':
for j in range(inner_vs_n):
x, y, z = struct.unpack_from('<3f', buf, inner_vs_ofs + j * 12)
values.append((x, y, z))
elif value_fmt == 'quat_c':
# WoW M2 CompQuat: 4 × uint16 (unsigned), biased by 0x8000.
# Mapping: 0x0000 → -1.0, 0x8000 → 0.0, 0xFFFF → +1.0
# Formula: (uint16 - 0x8000) / 0x7FFF
# NOTE: must read as '<4H' (unsigned), NOT '<4h' (signed int16).
for j in range(inner_vs_n):
x, y, z, w = struct.unpack_from('<4H', buf, inner_vs_ofs + j * 8)
values.append((
(x - 0x8000) / 32767.0,
(y - 0x8000) / 32767.0,
(z - 0x8000) / 32767.0,
(w - 0x8000) / 32767.0,
))
elif value_fmt == 'float':
for j in range(inner_vs_n):
v, = struct.unpack_from('<f', buf, inner_vs_ofs + j * 4)
values.append((v,))
except struct.error:
pass
return timestamps, values
# ── main parse ───────────────────────────────────────────────────────────
def _parse(self):
d = self.data
# Magic + version
magic = d[0:4]
if magic != b'MD20':
raise ValueError(f"Not an M2 file (magic={magic!r})")
version, = struct.unpack_from('<I', d, 4)
if version != 264:
raise ValueError(f"Unsupported M2 version {version} (expected 264 for WotLK)")
off = 8
# ── Header fields ─────────────────────────────────────────────────
# name
n_name, ofs_name, off = self._read_array(off)
# globalFlags
self.global_flags, = struct.unpack_from('<I', d, off); off += 4
# globalSequences
self.n_gseq, self.ofs_gseq, off = self._read_array(off)
# animations
self.n_anim, self.ofs_anim, off = self._read_array(off)
# animationLookup
self.n_alook, self.ofs_alook, off = self._read_array(off)
# bones
self.n_bones, self.ofs_bones, off = self._read_array(off)
# keyBoneLookup
n_kbl, ofs_kbl, off = self._read_array(off)
# vertices
self.n_verts, self.ofs_verts, off = self._read_array(off)
# numSkinProfiles (uint32, NOT M2Array)
self.n_skins, = struct.unpack_from('<I', d, off); off += 4
# colors(8) textures(8) textureWeights(8) textureTransforms(8)
# replaceableTextureLookup(8) materials(8) boneLookupTable(8)
# textureLookupTable(8) texUnitLookupTable(8) transparencyLookupTable(8)
# textureTransformLookupTable(8) → 11 × 8 = 88 bytes total
self.n_tex, self.ofs_tex, _ = self._read_array(off + 8) # textures
self.n_mat, self.ofs_mat, _ = self._read_array(off + 5*8) # materials
self.n_tex_look, self.ofs_tex_look, _ = self._read_array(off + 7*8) # textureLookupTable
off += 11 * 8
# boundingBox (CAaBox = 2 × Vec3 = 24), boundingSphereRadius (float)
off += 24 + 4
# collisionBox (24), collisionSphereRadius (4)
off += 24 + 4
# collision triangles/vertices/normals (3 × 8)
off += 3 * 8
# attachments, attachmentLookup, events, lights, cameras,
# cameraLookup, ribbonEmitters, particleEmitters (8 × 8)
off += 8 * 8
# ── Load global sequences ─────────────────────────────────────────
self.global_sequences = list(
struct.unpack_from(f'<{self.n_gseq}I', d, self.ofs_gseq)
) if self.n_gseq > 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('<HHIfIh', d, base)
# aliasNext at offset 62 of the sequence
alias_next, = struct.unpack_from('<H', d, base + 62)
self.sequences.append({
'id': seq_id,
'variation': var_idx,
'duration': duration, # ms
'flags': flags,
'alias_next': alias_next,
'index': i,
})
# ── Load external .anim files ─────────────────────────────────────
for i, seq in enumerate(self.sequences):
anim_id = seq['id']
anim_var = seq['variation']
anim_file = self.dir / f"{self.stem}{anim_id:04d}-{anim_var:02d}.anim"
if anim_file.exists():
with open(anim_file, 'rb') as f:
self.anim_buffers[i] = f.read()
# print(f" Loaded external anim {anim_id} var{anim_var}: {anim_file.name}")
# ── Load vertices ─────────────────────────────────────────────────
# M2Vertex: pos(12) boneWeights(4) boneIndices(4) normal(12) texCoords(16) = 48
VERT_SIZE = 48
self.vertices = []
for i in range(self.n_verts):
base = self.ofs_verts + i * VERT_SIZE
px, py, pz = struct.unpack_from('<3f', d, base)
bw = struct.unpack_from('<4B', d, base + 12)
bi = struct.unpack_from('<4B', d, base + 16)
nx, ny, nz = struct.unpack_from('<3f', d, base + 20)
u0, v0, u1, v1 = struct.unpack_from('<4f', d, base + 32)
self.vertices.append({
'pos': (px, py, pz),
'bone_weights': bw,
'bone_indices': bi,
'normal': (nx, ny, nz),
'uv0': (u0, v0),
})
# ── Load bones ────────────────────────────────────────────────────
# M2CompBone (WotLK): keyBoneId(4) flags(4) parentBoneId(2) submeshId(2)
# bone_name_crc(4) ← present in all WotLK builds
# translation: M2Track<Vec3> = 20 bytes
# rotation: M2Track<CompQuat>= 20 bytes
# scale: M2Track<Vec3> = 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('<iI', d, base)
parent, submesh = struct.unpack_from('<hH', d, base + 8)
boff = base + 16 # +4 for bone_name_crc
t_track, boff = self._read_track(boff)
r_track, boff = self._read_track(boff)
s_track, boff = self._read_track(boff)
px, py, pz = struct.unpack_from('<3f', d, boff)
self.bones.append({
'key_id': key_bone_id,
'flags': flags,
'parent': parent, # -1 = root
'pivot': (px, py, pz),
't_track': t_track,
'r_track': r_track,
's_track': s_track,
})
# ── Load textures ──────────────────────────────────────────────────
# M2Texture: type(4) flags(4) filename M2Array(8) = 16 bytes
TEX_SIZE = 16
self.textures = []
for i in range(self.n_tex):
base = self.ofs_tex + i * TEX_SIZE
tex_type, tex_flags = struct.unpack_from('<II', d, base)
n_fn, ofs_fn = struct.unpack_from('<II', d, base + 8)
filename = ""
if n_fn > 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('<HH', d, base)
self.materials_data.append({'flags': mat_flags, 'blend_mode': blend_mode})
print(f"M2: {self.n_verts} vertices, {self.n_bones} bones, "
f"{self.n_anim} animations, {len(self.anim_buffers)} external anims, "
f"{self.n_mat} materials")
# ─────────────────────────────────────────────────────────────────────────────
# Skin (.skin) parser
# ─────────────────────────────────────────────────────────────────────────────
class SkinParser:
def __init__(self, path):
with open(path, 'rb') as f:
self.data = f.read()
self._parse()
def _parse(self):
d = self.data
magic = d[0:4]
if magic != b'SKIN':
raise ValueError(f"Not a skin file (magic={magic!r})")
off = 4
def ra(o):
count, ofs = struct.unpack_from('<II', d, o)
return count, ofs, o + 8
n_v, ofs_v, off = ra(off)
n_i, ofs_i, off = ra(off)
n_b, ofs_b, off = ra(off) # bone lookup table: uint16[] maps local→global bone index
n_s, ofs_s, off = ra(off) # skin sections (submeshes)
n_bt, ofs_bt, off = ra(off) # render batches
self.bone_count_max, = struct.unpack_from('<I', d, off)
# Local vertex indices (into M2 global vertex list)
self.local_vertices = list(struct.unpack_from(f'<{n_v}H', d, ofs_v))
# Bone lookup table: vertex.bone_indices[j] → bone_lookup[j] → M2 global bone index
self.bone_lookup = list(struct.unpack_from(f'<{n_b}H', d, ofs_b)) if n_b > 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('<H', d, base + 4)
(mat_idx,) = struct.unpack_from('<H', d, base + 10) # materialIndex
(tex_look_id,) = struct.unpack_from('<H', d, base + 16) # textureLookupId
if sec_idx not in self.section_texture_lookup:
self.section_texture_lookup[sec_idx] = tex_look_id
if sec_idx not in self.section_material_index:
self.section_material_index[sec_idx] = mat_idx
print(f"Skin: {n_v} local verts, {n_i//3} triangles, {n_s} sections")
# ─────────────────────────────────────────────────────────────────────────────
# Minimal GLTF 2.0 / GLB writer
# ─────────────────────────────────────────────────────────────────────────────
class GltfBuilder:
def __init__(self):
self.asset = {"version": "2.0", "generator": "WoW M2 to GLTF converter"}
self.nodes = []
self.meshes = []
self.skins = []
self.accessors = []
self.buffer_views = []
self.animations = []
self.materials = []
self.images = []
self.textures = []
self.bin_data = bytearray()
# ── binary buffer helpers ────────────────────────────────────────────────
def _add_bin(self, data: bytes, alignment: int = 4) -> 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('<II', len(json_bytes), 0x4E4F534A) + json_bytes
bin_chunk = struct.pack('<II', len(bin_bytes), 0x004E4942) + bin_bytes
total = 12 + len(json_chunk) + len(bin_chunk)
header = struct.pack('<III', 0x46546C67, 2, total)
return header + json_chunk + bin_chunk
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
def _export_texture_layers(m2_path: Path, out_dir: Path) -> 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: <stem>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 <cwd>/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)