#!/usr/bin/env python3 """Self-contained Pygame/OpenGL M2 model viewer. Launched as a subprocess from the asset pipeline GUI to avoid Tkinter/Pygame conflicts. Supports textured rendering, skeletal animation playback, and orbit camera controls. """ from __future__ import annotations import hashlib import math import multiprocessing import os import shutil import struct import subprocess import sys import time from dataclasses import dataclass, field from pathlib import Path from typing import Any import numpy as np # --------------------------------------------------------------------------- # Matrix math utilities (pure NumPy, no external 3D lib needed) # --------------------------------------------------------------------------- def perspective(fov_deg: float, aspect: float, near: float, far: float) -> np.ndarray: f = 1.0 / math.tan(math.radians(fov_deg) / 2.0) m = np.zeros((4, 4), dtype=np.float32) m[0, 0] = f / aspect m[1, 1] = f m[2, 2] = (far + near) / (near - far) m[2, 3] = (2.0 * far * near) / (near - far) m[3, 2] = -1.0 return m def look_at(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray: f = target - eye f = f / np.linalg.norm(f) s = np.cross(f, up) s = s / (np.linalg.norm(s) + 1e-12) u = np.cross(s, f) m = np.eye(4, dtype=np.float32) m[0, :3] = s m[1, :3] = u m[2, :3] = -f m[0, 3] = -np.dot(s, eye) m[1, 3] = -np.dot(u, eye) m[2, 3] = np.dot(f, eye) return m def translate(tx: float, ty: float, tz: float) -> np.ndarray: m = np.eye(4, dtype=np.float32) m[0, 3] = tx m[1, 3] = ty m[2, 3] = tz return m def scale_mat4(sx: float, sy: float, sz: float) -> np.ndarray: m = np.eye(4, dtype=np.float32) m[0, 0] = sx m[1, 1] = sy m[2, 2] = sz return m def quat_to_mat4(q: np.ndarray) -> np.ndarray: """Quaternion (x,y,z,w) to 4x4 rotation matrix.""" x, y, z, w = q m = np.eye(4, dtype=np.float32) m[0, 0] = 1 - 2 * (y * y + z * z) m[0, 1] = 2 * (x * y - z * w) m[0, 2] = 2 * (x * z + y * w) m[1, 0] = 2 * (x * y + z * w) m[1, 1] = 1 - 2 * (x * x + z * z) m[1, 2] = 2 * (y * z - x * w) m[2, 0] = 2 * (x * z - y * w) m[2, 1] = 2 * (y * z + x * w) m[2, 2] = 1 - 2 * (x * x + y * y) return m def slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray: dot = np.dot(q0, q1) if dot < 0: q1 = -q1 dot = -dot dot = min(dot, 1.0) if dot > 0.9995: result = q0 + t * (q1 - q0) return result / np.linalg.norm(result) theta = math.acos(dot) sin_theta = math.sin(theta) a = math.sin((1 - t) * theta) / sin_theta b = math.sin(t * theta) / sin_theta result = a * q0 + b * q1 return result / np.linalg.norm(result) # --------------------------------------------------------------------------- # M2 Parser # --------------------------------------------------------------------------- @dataclass class M2Track: """Parsed animation track with per-sequence timestamps and keyframes.""" interp: int = 0 global_sequence: int = -1 timestamps: list[np.ndarray] = field(default_factory=list) # list of uint32 arrays per seq keys: list[np.ndarray] = field(default_factory=list) # list of value arrays per seq @dataclass class M2Bone: key_bone_id: int = -1 flags: int = 0 parent: int = -1 pivot: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float32)) translation: M2Track = field(default_factory=M2Track) rotation: M2Track = field(default_factory=M2Track) scale: M2Track = field(default_factory=M2Track) @dataclass class M2Submesh: vertex_start: int = 0 vertex_count: int = 0 index_start: int = 0 index_count: int = 0 @dataclass class M2Batch: submesh_index: int = 0 texture_combo_index: int = 0 @dataclass class M2Animation: anim_id: int = 0 variation: int = 0 duration: int = 0 speed: float = 0.0 flags: int = 0 class M2Parser: """Parse M2 binary data for rendering: vertices, UVs, normals, bones, skins, textures.""" def __init__(self, data: bytes): self.data = data self.version = struct.unpack_from(" int: """Return header offset for a given field, version-gated.""" offsets_wotlk = { "nGlobalSeq": 20, "ofsGlobalSeq": 24, "nAnims": 28, "ofsAnims": 32, "nBones": 44, "ofsBones": 48, "nVerts": 60, "ofsVerts": 64, "nTextures": 80, "ofsTextures": 84, "nTextureLookup": 128, "ofsTextureLookup": 132, "nBoneLookup": 120, "ofsBoneLookup": 124, } offsets_vanilla = { "nGlobalSeq": 20, "ofsGlobalSeq": 24, "nAnims": 28, "ofsAnims": 32, "nBones": 52, "ofsBones": 56, "nVerts": 68, "ofsVerts": 72, "nTextures": 92, "ofsTextures": 96, "nTextureLookup": 148, "ofsTextureLookup": 152, "nBoneLookup": 140, "ofsBoneLookup": 144, } table = offsets_vanilla if self.is_vanilla else offsets_wotlk return table[field_name] def _read_u32(self, offset: int) -> int: return struct.unpack_from(" tuple[int, int]: """Read count, offset for an M2Array header field.""" n_off = self._hdr(f"n{field_name}") o_off = self._hdr(f"ofs{field_name}") n = self._read_u32(n_off) o = self._read_u32(o_off) return n, o def _parse(self): self._parse_global_sequences() self._parse_vertices() self._parse_textures() self._parse_texture_lookup() self._parse_bone_lookup() self._parse_animations() self._parse_bones() self._parse_skin() def _parse_global_sequences(self): n, ofs = self._read_m2array("GlobalSeq") if n == 0 or n > 10000 or ofs + n * 4 > len(self.data): return self.global_sequences = list(struct.unpack_from(f"<{n}I", self.data, ofs)) def _parse_vertices(self): n, ofs = self._read_m2array("Verts") if n == 0 or n > 500000 or ofs + n * 48 > len(self.data): return # Parse all vertex fields using numpy for speed positions = np.empty((n, 3), dtype=np.float32) normals = np.empty((n, 3), dtype=np.float32) uvs = np.empty((n, 2), dtype=np.float32) bone_weights = np.empty((n, 4), dtype=np.uint8) bone_indices = np.empty((n, 4), dtype=np.uint8) for i in range(n): base = ofs + i * 48 positions[i] = struct.unpack_from("<3f", self.data, base) bone_weights[i] = struct.unpack_from("<4B", self.data, base + 12) bone_indices[i] = struct.unpack_from("<4B", self.data, base + 16) normals[i] = struct.unpack_from("<3f", self.data, base + 20) uvs[i] = struct.unpack_from("<2f", self.data, base + 32) self.positions = positions self.normals = normals self.uvs = uvs self.bone_weights = bone_weights self.bone_indices = bone_indices def _parse_textures(self): n, ofs = self._read_m2array("Textures") if n == 0 or n > 1000 or ofs + n * 16 > len(self.data): return for i in range(n): base = ofs + i * 16 tex_type, tex_flags = struct.unpack_from(" 1 and name_ofs + name_len <= len(self.data): raw = self.data[name_ofs:name_ofs + name_len] filename = raw.split(b"\x00", 1)[0].decode("ascii", errors="replace") self.textures.append({"type": tex_type, "flags": tex_flags, "filename": filename}) def _parse_texture_lookup(self): n, ofs = self._read_m2array("TextureLookup") if n == 0 or n > 10000 or ofs + n * 2 > len(self.data): return self.texture_lookup = list(struct.unpack_from(f"<{n}H", self.data, ofs)) def _parse_bone_lookup(self): n, ofs = self._read_m2array("BoneLookup") if n == 0 or n > 10000 or ofs + n * 2 > len(self.data): return self.bone_lookup = list(struct.unpack_from(f"<{n}H", self.data, ofs)) def _parse_animations(self): n, ofs = self._read_m2array("Anims") if n == 0 or n > 5000: return seq_size = 68 if self.is_vanilla else 64 if ofs + n * seq_size > len(self.data): return for i in range(n): base = ofs + i * seq_size anim_id, variation = struct.unpack_from(" M2Track: """Parse a WotLK M2TrackDisk (20 bytes) at given offset.""" track = M2Track() if base + 20 > len(self.data): return track interp, global_seq = struct.unpack_from(" 5000 or n_keys > 5000: return track # Each entry in n_ts is a sub-array header: {count(4), offset(4)} for s in range(n_ts): ts_hdr = ofs_ts + s * 8 if ts_hdr + 8 > len(self.data): track.timestamps.append(np.empty(0, dtype=np.uint32)) continue sub_count, sub_ofs = struct.unpack_from(" 50000 or sub_ofs + sub_count * 4 > len(self.data): track.timestamps.append(np.empty(0, dtype=np.uint32)) continue ts_data = np.frombuffer(self.data, dtype=np.uint32, count=sub_count, offset=sub_ofs) track.timestamps.append(ts_data.copy()) for s in range(n_keys): key_hdr = ofs_keys + s * 8 if key_hdr + 8 > len(self.data): track.keys.append(np.empty(0, dtype=np.float32)) continue sub_count, sub_ofs = struct.unpack_from(" 50000 or sub_ofs + sub_count * key_size > len(self.data): track.keys.append(np.empty(0, dtype=np.float32)) continue if key_dtype == "compressed_quat": raw = np.frombuffer(self.data, dtype=np.int16, count=sub_count * 4, offset=sub_ofs) raw = raw.reshape(sub_count, 4).astype(np.float32) # Decompress: (v < 0 ? v+32768 : v-32767) / 32767.0 result = np.where(raw < 0, raw + 32768.0, raw - 32767.0) / 32767.0 # Normalize each quaternion norms = np.linalg.norm(result, axis=1, keepdims=True) norms = np.maximum(norms, 1e-10) result = result / norms track.keys.append(result) elif key_dtype == "vec3": vals = np.frombuffer(self.data, dtype=np.float32, count=sub_count * 3, offset=sub_ofs) track.keys.append(vals.reshape(sub_count, 3).copy()) elif key_dtype == "float": vals = np.frombuffer(self.data, dtype=np.float32, count=sub_count, offset=sub_ofs) track.keys.append(vals.copy()) return track def _parse_track_vanilla(self, base: int, key_size: int, key_dtype: str) -> M2Track: """Parse a Vanilla M2TrackDiskVanilla (28 bytes) — flat arrays with M2Range indexing.""" track = M2Track() if base + 28 > len(self.data): return track interp, global_seq = struct.unpack_from(" 500000 or n_keys > 500000: return track # Read flat timestamp array all_ts = np.empty(0, dtype=np.uint32) if n_ts > 0 and ofs_ts + n_ts * 4 <= len(self.data): all_ts = np.frombuffer(self.data, dtype=np.uint32, count=n_ts, offset=ofs_ts).copy() # Read flat key array if key_dtype == "c4quat": all_keys_flat = np.empty(0, dtype=np.float32) if n_keys > 0 and ofs_keys + n_keys * 16 <= len(self.data): all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys * 4, offset=ofs_keys) all_keys_flat = all_keys_flat.reshape(n_keys, 4).copy() elif key_dtype == "vec3": all_keys_flat = np.empty((0, 3), dtype=np.float32) if n_keys > 0 and ofs_keys + n_keys * 12 <= len(self.data): all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys * 3, offset=ofs_keys) all_keys_flat = all_keys_flat.reshape(n_keys, 3).copy() else: all_keys_flat = np.empty(0, dtype=np.float32) if n_keys > 0 and ofs_keys + n_keys * key_size <= len(self.data): all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys, offset=ofs_keys).copy() # Read ranges and split into per-sequence arrays if n_ranges > 0 and n_ranges < 5000 and ofs_ranges + n_ranges * 8 <= len(self.data): for r in range(n_ranges): rng_start, rng_end = struct.unpack_from(" rng_start and rng_end <= len(all_ts): track.timestamps.append(all_ts[rng_start:rng_end]) if key_dtype in ("c4quat", "vec3") and rng_end <= len(all_keys_flat): track.keys.append(all_keys_flat[rng_start:rng_end]) elif rng_end <= len(all_keys_flat): track.keys.append(all_keys_flat[rng_start:rng_end]) else: track.keys.append(np.empty(0, dtype=np.float32)) else: track.timestamps.append(np.empty(0, dtype=np.uint32)) track.keys.append(np.empty(0, dtype=np.float32)) else: # No ranges — treat entire array as single sequence if len(all_ts) > 0: track.timestamps.append(all_ts) track.keys.append(all_keys_flat if len(all_keys_flat) > 0 else np.empty(0, dtype=np.float32)) return track def _parse_bones(self): n, ofs = self._read_m2array("Bones") if n == 0 or n > 5000: return if self.is_vanilla: bone_size = 108 # No boneNameCRC, 28-byte tracks: 4+4+2+2+3×28+12=108 for i in range(n): base = ofs + i * bone_size if base + bone_size > len(self.data): break bone = M2Bone() bone.key_bone_id = struct.unpack_from(" len(self.data): break bone = M2Bone() bone.key_bone_id = struct.unpack_from(" 500000: return if n_tris == 0 or n_tris > 500000: return # Vertex lookup if ofs_indices + n_indices * 2 <= len(skin_data): self.vertex_lookup = np.frombuffer(skin_data, dtype=np.uint16, count=n_indices, offset=ofs_indices).copy() # Raw triangle indices (indices into vertex_lookup) if ofs_tris + n_tris * 2 <= len(skin_data): self.triangles = np.frombuffer(skin_data, dtype=np.uint16, count=n_tris, offset=ofs_tris).copy() # Resolve two-level indirection: triangle idx -> vertex_lookup -> global vertex idx # This matches the C++ approach: model.indices stores global vertex indices if len(self.triangles) > 0 and len(self.vertex_lookup) > 0: n_verts = len(self.positions) if len(self.positions) > 0 else 65536 resolved = np.zeros(len(self.triangles), dtype=np.uint16) for i, tri_idx in enumerate(self.triangles): if tri_idx < len(self.vertex_lookup): global_idx = self.vertex_lookup[tri_idx] resolved[i] = global_idx if global_idx < n_verts else 0 else: resolved[i] = 0 self.resolved_indices = resolved # Submeshes (WotLK: 48 bytes, Vanilla: 32 bytes) submesh_size = 32 if self.is_vanilla else 48 if n_submeshes > 0 and n_submeshes < 10000 and ofs_submeshes + n_submeshes * submesh_size <= len(skin_data): for i in range(n_submeshes): base = ofs_submeshes + i * submesh_size sm = M2Submesh() # WotLK M2SkinSection: +0=skinSectionId(2), +2=Level(2), # +4=vertexStart(2), +6=vertexCount(2), +8=indexStart(2), +10=indexCount(2) sm.vertex_start = struct.unpack_from(" 0 and n_batches < 10000 and ofs_batches + n_batches * 24 <= len(skin_data): for i in range(n_batches): base = ofs_batches + i * 24 batch = M2Batch() # M2Batch: flags(1) + priority(1) + shaderId(2) + skinSectionIndex(2) # + geosetIndex(2) + colorIndex(2) + materialIndex(2) + materialLayer(2) # + textureCount(2) + textureComboIndex(2) + ... batch.submesh_index = struct.unpack_from(" 0: self.time_ms += dt * 1000.0 * self.speed self.time_ms = self.time_ms % anim.duration seq_idx = self.current_seq t = self.time_ms for i, bone in enumerate(self.parser.bones): local = self._eval_bone(bone, seq_idx, t) if bone.parent >= 0 and bone.parent < n_bones: self.bone_matrices[i] = self.bone_matrices[bone.parent] @ local else: self.bone_matrices[i] = local def _eval_bone(self, bone: M2Bone, seq_idx: int, time_ms: float) -> np.ndarray: """Compute local bone transform for one bone at given time.""" trans = self._interp_vec3(bone.translation, seq_idx, time_ms, np.zeros(3, dtype=np.float32)) rot = self._interp_quat(bone.rotation, seq_idx, time_ms) scl = self._interp_vec3(bone.scale, seq_idx, time_ms, np.ones(3, dtype=np.float32)) # local = T(pivot) * T(trans) * R(rot) * S(scl) * T(-pivot) p = bone.pivot m = translate(p[0], p[1], p[2]) m = m @ translate(trans[0], trans[1], trans[2]) m = m @ quat_to_mat4(rot) m = m @ scale_mat4(scl[0], scl[1], scl[2]) m = m @ translate(-p[0], -p[1], -p[2]) return m def _get_time_and_seq(self, track: M2Track, seq_idx: int, time_ms: float) -> tuple[int, float]: """Resolve sequence index and time, handling global sequences.""" if track.global_sequence >= 0 and track.global_sequence < len(self.parser.global_sequences): gs_dur = self.parser.global_sequences[track.global_sequence] actual_seq = 0 actual_time = time_ms % gs_dur if gs_dur > 0 else 0 else: actual_seq = seq_idx actual_time = time_ms return actual_seq, actual_time def _interp_vec3(self, track: M2Track, seq_idx: int, time_ms: float, default: np.ndarray) -> np.ndarray: si, t = self._get_time_and_seq(track, seq_idx, time_ms) if si >= len(track.timestamps) or si >= len(track.keys): return default ts = track.timestamps[si] keys = track.keys[si] if len(ts) == 0 or len(keys) == 0: return default if len(keys.shape) == 1: return default if t <= ts[0]: return keys[0] if t >= ts[-1]: return keys[-1] # Binary search idx = np.searchsorted(ts, t, side='right') - 1 idx = max(0, min(idx, len(ts) - 2)) t0, t1 = float(ts[idx]), float(ts[idx + 1]) frac = (t - t0) / (t1 - t0) if t1 != t0 else 0.0 if track.interp == 0: return keys[idx] return keys[idx] * (1.0 - frac) + keys[idx + 1] * frac def _interp_quat(self, track: M2Track, seq_idx: int, time_ms: float) -> np.ndarray: default = np.array([0, 0, 0, 1], dtype=np.float32) si, t = self._get_time_and_seq(track, seq_idx, time_ms) if si >= len(track.timestamps) or si >= len(track.keys): return default ts = track.timestamps[si] keys = track.keys[si] if len(ts) == 0 or len(keys) == 0: return default if len(keys.shape) == 1: return default if t <= ts[0]: return keys[0] if t >= ts[-1]: return keys[-1] idx = np.searchsorted(ts, t, side='right') - 1 idx = max(0, min(idx, len(ts) - 2)) t0, t1 = float(ts[idx]), float(ts[idx + 1]) frac = (t - t0) / (t1 - t0) if t1 != t0 else 0.0 if track.interp == 0: return keys[idx] return slerp(keys[idx], keys[idx + 1], frac) def skin_vertices(self, positions: np.ndarray, bone_weights: np.ndarray, bone_indices: np.ndarray, bone_lookup: list[int]) -> np.ndarray: """CPU vertex skinning (NumPy vectorized). Returns transformed positions.""" if len(self.bone_matrices) == 0 or len(bone_lookup) == 0: return positions.copy() n = len(positions) n_bones = len(self.bone_matrices) n_lookup = len(bone_lookup) lookup_arr = np.array(bone_lookup, dtype=np.int32) # Build homogeneous positions (n, 4) pos4 = np.ones((n, 4), dtype=np.float32) pos4[:, :3] = positions # Weights normalized to float (n, 4) weights = bone_weights.astype(np.float32) / 255.0 result = np.zeros((n, 4), dtype=np.float32) for j in range(4): w = weights[:, j] # (n,) mask = w > 0.001 if not np.any(mask): continue bi = bone_indices[mask, j].astype(np.int32) # Clamp bone lookup indices valid = bi < n_lookup bi = np.where(valid, bi, 0) global_bones = lookup_arr[bi] global_bones = np.where(valid, global_bones, 0) valid2 = valid & (global_bones < n_bones) global_bones = np.where(valid2, global_bones, 0) # Gather bone matrices for these vertices: (count, 4, 4) mats = self.bone_matrices[global_bones] # Transform: (count, 4, 4) @ (count, 4, 1) -> (count, 4, 1) transformed = np.einsum('nij,nj->ni', mats, pos4[mask]) # Apply weight and validity weighted = transformed * w[mask, np.newaxis] weighted[~valid2] = 0 result[mask] += weighted # De-homogenize w_col = result[:, 3:4] w_col = np.where(np.abs(w_col) > 0.001, w_col, 1.0) return (result[:, :3] / w_col).astype(np.float32) # --------------------------------------------------------------------------- # Orbit Camera # --------------------------------------------------------------------------- class OrbitCamera: def __init__(self): self.azimuth: float = 0.0 self.elevation: float = 0.3 self.distance: float = 5.0 self.target: np.ndarray = np.zeros(3, dtype=np.float32) self.pan_x: float = 0.0 self.pan_y: float = 0.0 def get_view_matrix(self) -> np.ndarray: eye = self._eye_pos() up = np.array([0, 0, 1], dtype=np.float32) target = self.target + np.array([self.pan_x, self.pan_y, 0], dtype=np.float32) return look_at(eye, target, up) def _eye_pos(self) -> np.ndarray: x = self.distance * math.cos(self.elevation) * math.cos(self.azimuth) y = self.distance * math.cos(self.elevation) * math.sin(self.azimuth) z = self.distance * math.sin(self.elevation) target = self.target + np.array([self.pan_x, self.pan_y, 0], dtype=np.float32) return target + np.array([x, y, z], dtype=np.float32) def orbit(self, dx: float, dy: float): self.azimuth += dx * 0.01 self.elevation = max(-math.pi / 2 + 0.01, min(math.pi / 2 - 0.01, self.elevation + dy * 0.01)) def zoom(self, delta: float): self.distance = max(0.5, self.distance * (1.0 - delta * 0.1)) def pan(self, dx: float, dy: float): self.pan_x += dx * self.distance * 0.002 self.pan_y += dy * self.distance * 0.002 # --------------------------------------------------------------------------- # M2 Renderer (OpenGL 3.3) # --------------------------------------------------------------------------- VERT_SHADER = """ #version 330 core layout(location=0) in vec3 aPos; layout(location=1) in vec3 aNormal; layout(location=2) in vec2 aUV; uniform mat4 uMVP; uniform mat4 uModel; out vec3 vNormal; out vec2 vUV; out vec3 vWorldPos; void main() { gl_Position = uMVP * vec4(aPos, 1.0); vNormal = mat3(uModel) * aNormal; vUV = aUV; vWorldPos = (uModel * vec4(aPos, 1.0)).xyz; } """ FRAG_SHADER = """ #version 330 core in vec3 vNormal; in vec2 vUV; in vec3 vWorldPos; uniform sampler2D uTexture; uniform int uHasTexture; uniform vec3 uLightDir; out vec4 FragColor; void main() { vec3 N = normalize(vNormal); float NdotL = abs(dot(N, uLightDir)); float ambient = 0.35; float diffuse = 0.65 * NdotL; float light = ambient + diffuse; vec4 texColor; if (uHasTexture == 1) { texColor = texture(uTexture, vUV); if (texColor.a < 0.1) discard; } else { texColor = vec4(0.6, 0.6, 0.65, 1.0); } FragColor = vec4(texColor.rgb * light, texColor.a); } """ WIRE_VERT = """ #version 330 core layout(location=0) in vec3 aPos; uniform mat4 uMVP; void main() { gl_Position = uMVP * vec4(aPos, 1.0); } """ WIRE_FRAG = """ #version 330 core out vec4 FragColor; void main() { FragColor = vec4(0.0, 0.8, 1.0, 0.4); } """ class M2Renderer: """OpenGL 3.3 renderer for M2 models.""" def __init__(self, parser: M2Parser, blp_paths: dict[str, str], blp_convert: str): self.parser = parser self.blp_paths = blp_paths # texture filename -> filesystem path self.blp_convert_path = blp_convert self.vao = 0 self.vbo = 0 self.ebo = 0 self.wire_vao = 0 self.wire_vbo = 0 self.wire_ebo = 0 self.shader = 0 self.wire_shader = 0 self.gl_textures: dict[int, int] = {} # batch index -> GL texture ID self.batch_texture_map: dict[int, int] = {} # batch idx -> texture array index self.show_wireframe = False self.n_indices = 0 self.n_wire_indices = 0 self.n_verts = 0 def init_gl(self): import OpenGL.GL as gl self._gl = gl # Build shaders self.shader = self._compile_program(VERT_SHADER, FRAG_SHADER) self.wire_shader = self._compile_program(WIRE_VERT, WIRE_FRAG) p = self.parser n_verts = len(p.positions) if n_verts == 0: return self.n_verts = n_verts # VBO: ALL model vertices, interleaved pos(12) + normal(12) + uv(8) = 32 bytes vbo_data = np.zeros((n_verts, 8), dtype=np.float32) vbo_data[:, 0:3] = p.positions vbo_data[:, 3:6] = p.normals if len(p.normals) == n_verts else np.zeros((n_verts, 3), dtype=np.float32) vbo_data[:, 6:8] = p.uvs if len(p.uvs) == n_verts else np.zeros((n_verts, 2), dtype=np.float32) # EBO: resolved global vertex indices (after two-level skin indirection) if len(p.resolved_indices) > 0: idx_data = p.resolved_indices.astype(np.uint16) elif len(p.triangles) > 0: idx_data = p.triangles.astype(np.uint16) else: idx_data = np.empty(0, dtype=np.uint16) # Create main VAO/VBO/EBO self.vao = gl.glGenVertexArrays(1) self.vbo = gl.glGenBuffers(1) self.ebo = gl.glGenBuffers(1) gl.glBindVertexArray(self.vao) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo) gl.glBufferData(gl.GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, gl.GL_DYNAMIC_DRAW) if len(idx_data) > 0: self.n_indices = len(idx_data) gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.ebo) gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW) stride = 32 gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0)) gl.glEnableVertexAttribArray(0) gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12)) gl.glEnableVertexAttribArray(1) gl.glVertexAttribPointer(2, 2, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(24)) gl.glEnableVertexAttribArray(2) gl.glBindVertexArray(0) # Wireframe VAO (positions only, same indices) self.wire_vao = gl.glGenVertexArrays(1) self.wire_vbo = gl.glGenBuffers(1) self.wire_ebo = gl.glGenBuffers(1) gl.glBindVertexArray(self.wire_vao) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.wire_vbo) gl.glBufferData(gl.GL_ARRAY_BUFFER, p.positions.nbytes, p.positions, gl.GL_DYNAMIC_DRAW) if len(idx_data) > 0: self.n_wire_indices = len(idx_data) gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.wire_ebo) gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW) gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, 12, gl.ctypes.c_void_p(0)) gl.glEnableVertexAttribArray(0) gl.glBindVertexArray(0) # Load textures self._load_textures() # Map batches to textures self._map_batch_textures() def _compile_program(self, vert_src: str, frag_src: str) -> int: gl = self._gl vs = gl.glCreateShader(gl.GL_VERTEX_SHADER) gl.glShaderSource(vs, vert_src) gl.glCompileShader(vs) if gl.glGetShaderiv(vs, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: log = gl.glGetShaderInfoLog(vs).decode() print(f"Vertex shader error: {log}") fs = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) gl.glShaderSource(fs, frag_src) gl.glCompileShader(fs) if gl.glGetShaderiv(fs, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: log = gl.glGetShaderInfoLog(fs).decode() print(f"Fragment shader error: {log}") prog = gl.glCreateProgram() gl.glAttachShader(prog, vs) gl.glAttachShader(prog, fs) gl.glLinkProgram(prog) if gl.glGetProgramiv(prog, gl.GL_LINK_STATUS) != gl.GL_TRUE: log = gl.glGetProgramInfoLog(prog).decode() print(f"Program link error: {log}") gl.glDeleteShader(vs) gl.glDeleteShader(fs) return prog def _load_textures(self): """Load BLP textures via blp_convert → PIL → GL texture.""" gl = self._gl try: from PIL import Image except ImportError: print("PIL not available, textures disabled") return cache_dir = Path(os.path.expanduser("~/.cache/m2_viewer")) cache_dir.mkdir(parents=True, exist_ok=True) for i, tex in enumerate(self.parser.textures): if tex["type"] != 0 or not tex["filename"]: continue fname = tex["filename"].replace("\\", "/") blp_path = self.blp_paths.get(fname) or self.blp_paths.get(fname.lower()) if not blp_path: continue # Convert BLP to PNG cache_key = hashlib.md5(blp_path.encode()).hexdigest() cached_png = cache_dir / f"{cache_key}.png" if not cached_png.exists(): try: # Copy BLP to temp dir for conversion (avoids read-only source dirs) import tempfile with tempfile.TemporaryDirectory() as tmpdir: tmp_blp = Path(tmpdir) / Path(blp_path).name shutil.copy2(blp_path, str(tmp_blp)) result = subprocess.run( [self.blp_convert_path, "--to-png", str(tmp_blp)], capture_output=True, text=True, timeout=10, ) output_png = tmp_blp.with_suffix(".png") if result.returncode != 0 or not output_png.exists(): print(f"blp_convert failed for {fname}: {result.stderr}") continue shutil.move(str(output_png), str(cached_png)) except Exception as e: print(f"BLP convert failed for {fname}: {e}") continue try: img = Image.open(cached_png) img = img.transpose(Image.FLIP_TOP_BOTTOM) if img.mode != "RGBA": img = img.convert("RGBA") img_data = np.array(img, dtype=np.uint8) tex_id = gl.glGenTextures(1) gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, img.width, img.height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_data) gl.glGenerateMipmap(gl.GL_TEXTURE_2D) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_REPEAT) self.gl_textures[i] = tex_id except Exception as e: print(f"Texture load failed for {fname}: {e}") def _map_batch_textures(self): """Resolve batch → texture combo → texture lookup → GL texture mapping.""" for bi, batch in enumerate(self.parser.batches): tci = batch.texture_combo_index if tci < len(self.parser.texture_lookup): tex_idx = self.parser.texture_lookup[tci] if tex_idx in self.gl_textures: self.batch_texture_map[bi] = self.gl_textures[tex_idx] def update_vertices(self, skinned_positions: np.ndarray): """Upload new skinned vertex positions to VBO.""" gl = self._gl if self.vao == 0 or len(skinned_positions) == 0: return p = self.parser n_verts = len(skinned_positions) # Rebuild interleaved VBO data with new positions vbo_data = np.zeros((n_verts, 8), dtype=np.float32) vbo_data[:, 0:3] = skinned_positions vbo_data[:, 3:6] = p.normals if len(p.normals) == n_verts else np.zeros((n_verts, 3), dtype=np.float32) vbo_data[:, 6:8] = p.uvs if len(p.uvs) == n_verts else np.zeros((n_verts, 2), dtype=np.float32) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo) gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, vbo_data.nbytes, vbo_data) # Update wireframe VBO too gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.wire_vbo) gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, skinned_positions.nbytes, skinned_positions) def render(self, mvp: np.ndarray, model: np.ndarray): gl = self._gl if self.vao == 0 or self.n_indices == 0: return gl.glEnable(gl.GL_DEPTH_TEST) gl.glDisable(gl.GL_CULL_FACE) gl.glUseProgram(self.shader) mvp_loc = gl.glGetUniformLocation(self.shader, "uMVP") model_loc = gl.glGetUniformLocation(self.shader, "uModel") tex_loc = gl.glGetUniformLocation(self.shader, "uTexture") has_tex_loc = gl.glGetUniformLocation(self.shader, "uHasTexture") light_loc = gl.glGetUniformLocation(self.shader, "uLightDir") gl.glUniformMatrix4fv(mvp_loc, 1, gl.GL_TRUE, mvp) gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, model) gl.glUniform1i(tex_loc, 0) # Light direction (normalized) light_dir = np.array([0.5, 0.3, 0.8], dtype=np.float32) light_dir /= np.linalg.norm(light_dir) gl.glUniform3fv(light_loc, 1, light_dir) gl.glBindVertexArray(self.vao) if self.parser.batches and self.parser.submeshes: # Per-batch rendering for bi, batch in enumerate(self.parser.batches): si = batch.submesh_index if si >= len(self.parser.submeshes): continue sm = self.parser.submeshes[si] # Bind texture if available gl_tex = self.batch_texture_map.get(bi) if gl_tex: gl.glActiveTexture(gl.GL_TEXTURE0) gl.glBindTexture(gl.GL_TEXTURE_2D, gl_tex) gl.glUniform1i(has_tex_loc, 1) else: gl.glUniform1i(has_tex_loc, 0) # Draw this submesh's triangles idx_start = sm.index_start idx_count = sm.index_count if idx_start + idx_count <= self.n_indices: gl.glDrawElements(gl.GL_TRIANGLES, idx_count, gl.GL_UNSIGNED_SHORT, gl.ctypes.c_void_p(idx_start * 2)) else: # Fallback: draw all triangles with no texture gl.glUniform1i(has_tex_loc, 0) gl.glDrawElements(gl.GL_TRIANGLES, self.n_indices, gl.GL_UNSIGNED_SHORT, gl.ctypes.c_void_p(0)) gl.glBindVertexArray(0) # Wireframe overlay if self.show_wireframe and self.wire_vao and self.n_wire_indices > 0: gl.glUseProgram(self.wire_shader) wire_mvp_loc = gl.glGetUniformLocation(self.wire_shader, "uMVP") gl.glUniformMatrix4fv(wire_mvp_loc, 1, gl.GL_TRUE, mvp) gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) gl.glDisable(gl.GL_CULL_FACE) gl.glBindVertexArray(self.wire_vao) gl.glDrawElements(gl.GL_TRIANGLES, self.n_wire_indices, gl.GL_UNSIGNED_SHORT, gl.ctypes.c_void_p(0)) gl.glBindVertexArray(0) gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) gl.glDisable(gl.GL_BLEND) # --------------------------------------------------------------------------- # M2 Viewer Window (Pygame main loop) # --------------------------------------------------------------------------- class M2ViewerWindow: """Pygame + OpenGL M2 model viewer window.""" def __init__(self, m2_path: str, blp_paths: dict[str, str], blp_convert: str): self.m2_path = m2_path self.blp_paths = blp_paths self.blp_convert = blp_convert self.parser: M2Parser | None = None self.anim_system: AnimationSystem | None = None self.renderer: M2Renderer | None = None self.camera = OrbitCamera() self.width = 1024 self.height = 768 self.running = True self.fps_clock = None self.font = None self._dragging = False self._panning = False self._last_mouse = (0, 0) def run(self): """Main entry point — parse, init GL, run loop.""" import pygame from pygame.locals import ( DOUBLEBUF, OPENGL, RESIZABLE, QUIT, KEYDOWN, MOUSEBUTTONDOWN, MOUSEBUTTONUP, MOUSEMOTION, VIDEORESIZE, K_SPACE, K_LEFT, K_RIGHT, K_PLUS, K_MINUS, K_EQUALS, K_r, K_w, K_ESCAPE, ) # Parse M2 data = Path(self.m2_path).read_bytes() if len(data) < 8 or data[:4] != b"MD20": print(f"Not a valid M2 file: {self.m2_path}") return self.parser = M2Parser(data) # Load skin file m2_p = Path(self.m2_path) skin_path = m2_p.with_name(m2_p.stem + "00.skin") if skin_path.exists(): self.parser.parse_skin_data(skin_path.read_bytes()) elif self.parser.is_vanilla: # Embedded skin at ofsViews if self.parser.version <= 256: # Read ofsViews from vanilla header if len(data) > 108: ofs_views = struct.unpack_from(" 0 and ofs_views < len(data): self.parser.parse_skin_data(data[ofs_views:]) # Init animation self.anim_system = AnimationSystem(self.parser) if self.parser.animations: self.anim_system.set_sequence(0) # Auto-fit camera if len(self.parser.positions) > 0: mins = self.parser.positions.min(axis=0) maxs = self.parser.positions.max(axis=0) center = (mins + maxs) / 2.0 extent = np.linalg.norm(maxs - mins) self.camera.target = center self.camera.distance = max(extent * 1.2, 1.0) # Init Pygame + OpenGL pygame.init() pygame.display.set_caption(f"M2 Viewer — {Path(self.m2_path).name}") pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE) pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE) self.fps_clock = pygame.time.Clock() self.font = pygame.font.SysFont("monospace", 14) import OpenGL.GL as gl # Init renderer self.renderer = M2Renderer(self.parser, self.blp_paths, self.blp_convert) self.renderer.init_gl() gl.glClearColor(0.12, 0.12, 0.18, 1.0) gl.glEnable(gl.GL_DEPTH_TEST) # Main loop while self.running: dt = self.fps_clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type == QUIT: self.running = False elif event.type == VIDEORESIZE: self.width, self.height = event.w, event.h pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE) elif event.type == KEYDOWN: self._handle_key(event.key) elif event.type == MOUSEBUTTONDOWN: if event.button == 1: self._dragging = True self._last_mouse = event.pos elif event.button == 3: self._panning = True self._last_mouse = event.pos elif event.button == 4: self.camera.zoom(1) elif event.button == 5: self.camera.zoom(-1) elif event.type == MOUSEBUTTONUP: if event.button == 1: self._dragging = False elif event.button == 3: self._panning = False elif event.type == MOUSEMOTION: if self._dragging: dx = event.pos[0] - self._last_mouse[0] dy = event.pos[1] - self._last_mouse[1] self.camera.orbit(dx, dy) self._last_mouse = event.pos elif self._panning: dx = event.pos[0] - self._last_mouse[0] dy = event.pos[1] - self._last_mouse[1] self.camera.pan(-dx, dy) self._last_mouse = event.pos # Update animation + skinning if self.anim_system: self.anim_system.update(dt) if (len(self.anim_system.bone_matrices) > 0 and len(self.parser.bone_lookup) > 0): skinned = self.anim_system.skin_vertices( self.parser.positions, self.parser.bone_weights, self.parser.bone_indices, self.parser.bone_lookup, ) self.renderer.update_vertices(skinned) # Render gl.glViewport(0, 0, self.width, self.height) gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) aspect = self.width / max(self.height, 1) proj = perspective(45.0, aspect, 0.01, 5000.0) view = self.camera.get_view_matrix() model = np.eye(4, dtype=np.float32) mvp = proj @ view @ model self.renderer.render(mvp, model) # HUD overlay self._draw_hud(pygame, gl) pygame.display.flip() pygame.quit() def _handle_key(self, key): import pygame if key == pygame.K_ESCAPE: self.running = False elif key == pygame.K_SPACE: if self.anim_system: self.anim_system.playing = not self.anim_system.playing elif key == pygame.K_RIGHT: if self.anim_system and self.parser.animations: idx = (self.anim_system.current_seq + 1) % len(self.parser.animations) self.anim_system.set_sequence(idx) elif key == pygame.K_LEFT: if self.anim_system and self.parser.animations: idx = (self.anim_system.current_seq - 1) % len(self.parser.animations) self.anim_system.set_sequence(idx) elif key in (pygame.K_PLUS, pygame.K_EQUALS, pygame.K_KP_PLUS): if self.anim_system: self.anim_system.speed = min(self.anim_system.speed + 0.25, 5.0) elif key in (pygame.K_MINUS, pygame.K_KP_MINUS): if self.anim_system: self.anim_system.speed = max(self.anim_system.speed - 0.25, 0.0) elif key == pygame.K_r: if self.anim_system: self.anim_system.time_ms = 0.0 self.anim_system.playing = False self.anim_system.bone_matrices = np.empty(0) elif key == pygame.K_w: if self.renderer: self.renderer.show_wireframe = not self.renderer.show_wireframe def _draw_hud(self, pygame, gl): """Draw text overlay using Pygame font → texture approach.""" if not self.font: return lines = [Path(self.m2_path).name] n_verts = len(self.parser.positions) n_tris = len(self.parser.triangles) // 3 lines.append(f"{n_verts} verts, {n_tris} tris, {len(self.parser.textures)} tex") if self.parser.animations and self.anim_system: anim = self.parser.animations[self.anim_system.current_seq] name = _ANIM_NAMES.get(anim.anim_id, f"Anim {anim.anim_id}") state = "Playing" if self.anim_system.playing else "Paused" lines.append(f"[{self.anim_system.current_seq + 1}/{len(self.parser.animations)}] " f"{name} ({anim.duration}ms) - {state} x{self.anim_system.speed:.1f}") else: lines.append("No animations") fps = self.fps_clock.get_fps() if self.fps_clock else 0 lines.append(f"FPS: {fps:.0f}") lines.append("") lines.append("LMB: orbit | RMB: pan | Scroll: zoom") lines.append("Space: play/pause | Left/Right: anim | +/-: speed") lines.append("W: wireframe | R: reset | Esc: quit") # Render text to surface, then blit via orthographic projection # Use a simple texture-based approach line_height = 18 total_height = len(lines) * line_height + 8 surf_width = 450 surf = pygame.Surface((surf_width, total_height), pygame.SRCALPHA) surf.fill((0, 0, 0, 160)) for i, line in enumerate(lines): text_surf = self.font.render(line, True, (220, 220, 240)) surf.blit(text_surf, (6, 4 + i * line_height)) # Convert to OpenGL texture and draw text_data = pygame.image.tostring(surf, "RGBA", True) tex_id = gl.glGenTextures(1) gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, surf_width, total_height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, text_data) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) # Draw fullscreen quad in ortho — use compatibility approach with glWindowPos + glDrawPixels # Simpler: use a small shader-less blit via fixed function emulation # Actually, let's just use the modern approach with a screen quad self._blit_texture(gl, tex_id, 8, self.height - total_height - 8, surf_width, total_height) gl.glDeleteTextures(1, [tex_id]) def _blit_texture(self, gl, tex_id, x, y, w, h): """Blit a texture to screen at (x,y) using a temporary screen-space quad.""" # Simple blit using glBlitFramebuffer alternative: # Create a minimal screen-space shader + quad if not hasattr(self, '_blit_shader'): blit_vert = """ #version 330 core layout(location=0) in vec2 aPos; layout(location=1) in vec2 aUV; out vec2 vUV; void main() { gl_Position = vec4(aPos, 0.0, 1.0); vUV = aUV; } """ blit_frag = """ #version 330 core in vec2 vUV; uniform sampler2D uTex; out vec4 FragColor; void main() { FragColor = texture(uTex, vUV); } """ self._blit_shader = self.renderer._compile_program(blit_vert, blit_frag) self._blit_vao = gl.glGenVertexArrays(1) self._blit_vbo = gl.glGenBuffers(1) # Convert pixel coords to NDC x0 = 2.0 * x / self.width - 1.0 y0 = 2.0 * y / self.height - 1.0 x1 = 2.0 * (x + w) / self.width - 1.0 y1 = 2.0 * (y + h) / self.height - 1.0 quad = np.array([ x0, y0, 0.0, 0.0, x1, y0, 1.0, 0.0, x1, y1, 1.0, 1.0, x0, y0, 0.0, 0.0, x1, y1, 1.0, 1.0, x0, y1, 0.0, 1.0, ], dtype=np.float32) gl.glBindVertexArray(self._blit_vao) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._blit_vbo) gl.glBufferData(gl.GL_ARRAY_BUFFER, quad.nbytes, quad, gl.GL_DYNAMIC_DRAW) gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(0)) gl.glEnableVertexAttribArray(0) gl.glVertexAttribPointer(1, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(8)) gl.glEnableVertexAttribArray(1) gl.glDisable(gl.GL_DEPTH_TEST) gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glUseProgram(self._blit_shader) gl.glActiveTexture(gl.GL_TEXTURE0) gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) gl.glUniform1i(gl.glGetUniformLocation(self._blit_shader, "uTex"), 0) gl.glDrawArrays(gl.GL_TRIANGLES, 0, 6) gl.glBindVertexArray(0) gl.glEnable(gl.GL_DEPTH_TEST) gl.glDisable(gl.GL_BLEND) # --------------------------------------------------------------------------- # WMO Parser # --------------------------------------------------------------------------- @dataclass class WMOBatch: start_index: int = 0 index_count: int = 0 material_id: int = 0 @dataclass class WMOMaterial: flags: int = 0 shader: int = 0 blend_mode: int = 0 texture1_ofs: int = 0 texture2_ofs: int = 0 texture3_ofs: int = 0 color1: int = 0 color2: int = 0 @dataclass class WMOGroup: positions: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) normals: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) uvs: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) indices: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.uint16)) batches: list = field(default_factory=list) class WMOParser: """Parse WMO root + group files for rendering.""" def __init__(self): self.textures: list[str] = [] self.texture_offset_map: dict[int, int] = {} # MOTX byte offset -> texture index self.materials: list[WMOMaterial] = [] self.groups: list[WMOGroup] = [] self.n_groups_expected: int = 0 def parse_root(self, data: bytes): """Parse root WMO file for textures and materials.""" pos = 0 while pos + 8 <= len(data): chunk_id = data[pos:pos + 4] chunk_size = struct.unpack_from(" len(data): break cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1] if cid == b"MOHD" and chunk_size >= 16: # nTextures at +0, nGroups at +4 self.n_groups_expected = struct.unpack_from(" WMOGroup: """Parse a WMO group file for geometry.""" group = WMOGroup() pos = 0 # Scan for MOGP chunk which wraps all sub-chunks mogp_start = -1 mogp_end = len(data) while pos + 8 <= len(data): chunk_id = data[pos:pos + 4] chunk_size = struct.unpack_from("= 0 else 0 pos = scan_start while pos + 8 <= mogp_end: chunk_id = data[pos:pos + 4] chunk_size = struct.unpack_from(" mogp_end: break cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1] if cid == b"MOVT": n = chunk_size // 12 group.positions = np.zeros((n, 3), dtype=np.float32) for i in range(n): group.positions[i] = struct.unpack_from("<3f", data, chunk_start + i * 12) elif cid == b"MOVI": n = chunk_size // 2 group.indices = np.frombuffer(data, dtype=np.uint16, count=n, offset=chunk_start).copy() elif cid == b"MONR": n = chunk_size // 12 group.normals = np.zeros((n, 3), dtype=np.float32) for i in range(n): group.normals[i] = struct.unpack_from("<3f", data, chunk_start + i * 12) elif cid == b"MOTV": n = chunk_size // 8 group.uvs = np.zeros((n, 2), dtype=np.float32) for i in range(n): group.uvs[i] = struct.unpack_from("<2f", data, chunk_start + i * 8) elif cid == b"MOBA": n = chunk_size // 24 for i in range(n): base = chunk_start + i * 24 batch = WMOBatch() batch.start_index = struct.unpack_from(" str: """Resolve a MOTX byte offset to a texture filename.""" idx = self.texture_offset_map.get(motx_offset) if idx is not None and idx < len(self.textures): return self.textures[idx] return "" # --------------------------------------------------------------------------- # WMO Renderer # --------------------------------------------------------------------------- class WMORenderer: """OpenGL 3.3 renderer for WMO models.""" def __init__(self, parser: WMOParser, blp_paths: dict[str, str], blp_convert: str): self.parser = parser self.blp_paths = blp_paths self.blp_convert_path = blp_convert self.show_wireframe = False # Per-group GL state self._group_vaos: list[int] = [] self._group_vbos: list[int] = [] self._group_ebos: list[int] = [] self._group_n_indices: list[int] = [] self._group_batches: list[list[WMOBatch]] = [] self.shader = 0 self.wire_shader = 0 self._gl = None # material_id -> GL texture id self._mat_textures: dict[int, int] = {} def init_gl(self): import OpenGL.GL as gl self._gl = gl self.shader = self._compile_program(VERT_SHADER, FRAG_SHADER) self.wire_shader = self._compile_program(WIRE_VERT, WIRE_FRAG) self._load_textures() for group in self.parser.groups: self._upload_group(group) def _upload_group(self, group: WMOGroup): gl = self._gl n_verts = len(group.positions) if n_verts == 0: self._group_vaos.append(0) self._group_vbos.append(0) self._group_ebos.append(0) self._group_n_indices.append(0) self._group_batches.append([]) return # Interleaved: pos(12) + normal(12) + uv(8) = 32 bytes vbo_data = np.zeros((n_verts, 8), dtype=np.float32) vbo_data[:, 0:3] = group.positions if len(group.normals) == n_verts: vbo_data[:, 3:6] = group.normals if len(group.uvs) == n_verts: vbo_data[:, 6:8] = group.uvs vao = gl.glGenVertexArrays(1) vbo = gl.glGenBuffers(1) ebo = gl.glGenBuffers(1) gl.glBindVertexArray(vao) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo) gl.glBufferData(gl.GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, gl.GL_STATIC_DRAW) n_idx = 0 if len(group.indices) > 0: idx_data = group.indices.astype(np.uint16) n_idx = len(idx_data) gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, ebo) gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW) stride = 32 gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0)) gl.glEnableVertexAttribArray(0) gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12)) gl.glEnableVertexAttribArray(1) gl.glVertexAttribPointer(2, 2, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(24)) gl.glEnableVertexAttribArray(2) gl.glBindVertexArray(0) self._group_vaos.append(vao) self._group_vbos.append(vbo) self._group_ebos.append(ebo) self._group_n_indices.append(n_idx) self._group_batches.append(group.batches) def _load_textures(self): gl = self._gl try: from PIL import Image except ImportError: return cache_dir = Path(os.path.expanduser("~/.cache/m2_viewer")) cache_dir.mkdir(parents=True, exist_ok=True) loaded: dict[str, int] = {} # filename -> GL tex id for mat_idx, mat in enumerate(self.parser.materials): tex_name = self.parser.get_texture_name(mat.texture1_ofs) if not tex_name: continue if tex_name in loaded: self._mat_textures[mat_idx] = loaded[tex_name] continue norm = tex_name.replace("\\", "/") blp_path = self.blp_paths.get(norm) or self.blp_paths.get(norm.lower()) if not blp_path: continue cache_key = hashlib.md5(blp_path.encode()).hexdigest() cached_png = cache_dir / f"{cache_key}.png" if not cached_png.exists(): try: import tempfile with tempfile.TemporaryDirectory() as tmpdir: tmp_blp = Path(tmpdir) / Path(blp_path).name shutil.copy2(blp_path, str(tmp_blp)) result = subprocess.run( [self.blp_convert_path, "--to-png", str(tmp_blp)], capture_output=True, text=True, timeout=10, ) output_png = tmp_blp.with_suffix(".png") if result.returncode != 0 or not output_png.exists(): continue shutil.move(str(output_png), str(cached_png)) except Exception: continue try: img = Image.open(cached_png) img = img.transpose(Image.FLIP_TOP_BOTTOM) if img.mode != "RGBA": img = img.convert("RGBA") img_data = np.array(img, dtype=np.uint8) tex_id = gl.glGenTextures(1) gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, img.width, img.height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_data) gl.glGenerateMipmap(gl.GL_TEXTURE_2D) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_REPEAT) loaded[tex_name] = tex_id self._mat_textures[mat_idx] = tex_id except Exception: continue def _compile_program(self, vert_src: str, frag_src: str) -> int: gl = self._gl vs = gl.glCreateShader(gl.GL_VERTEX_SHADER) gl.glShaderSource(vs, vert_src) gl.glCompileShader(vs) fs = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) gl.glShaderSource(fs, frag_src) gl.glCompileShader(fs) prog = gl.glCreateProgram() gl.glAttachShader(prog, vs) gl.glAttachShader(prog, fs) gl.glLinkProgram(prog) gl.glDeleteShader(vs) gl.glDeleteShader(fs) return prog def render(self, mvp: np.ndarray, model: np.ndarray): gl = self._gl gl.glEnable(gl.GL_DEPTH_TEST) gl.glDisable(gl.GL_CULL_FACE) gl.glUseProgram(self.shader) mvp_loc = gl.glGetUniformLocation(self.shader, "uMVP") model_loc = gl.glGetUniformLocation(self.shader, "uModel") tex_loc = gl.glGetUniformLocation(self.shader, "uTexture") has_tex_loc = gl.glGetUniformLocation(self.shader, "uHasTexture") light_loc = gl.glGetUniformLocation(self.shader, "uLightDir") gl.glUniformMatrix4fv(mvp_loc, 1, gl.GL_TRUE, mvp) gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, model) gl.glUniform1i(tex_loc, 0) light_dir = np.array([0.5, 0.3, 0.8], dtype=np.float32) light_dir /= np.linalg.norm(light_dir) gl.glUniform3fv(light_loc, 1, light_dir) for gi in range(len(self._group_vaos)): vao = self._group_vaos[gi] n_idx = self._group_n_indices[gi] batches = self._group_batches[gi] if vao == 0 or n_idx == 0: continue gl.glBindVertexArray(vao) if batches: for batch in batches: gl_tex = self._mat_textures.get(batch.material_id) if gl_tex: gl.glActiveTexture(gl.GL_TEXTURE0) gl.glBindTexture(gl.GL_TEXTURE_2D, gl_tex) gl.glUniform1i(has_tex_loc, 1) else: gl.glUniform1i(has_tex_loc, 0) si = batch.start_index ic = batch.index_count if si + ic <= n_idx: gl.glDrawElements(gl.GL_TRIANGLES, ic, gl.GL_UNSIGNED_SHORT, gl.ctypes.c_void_p(si * 2)) else: gl.glUniform1i(has_tex_loc, 0) gl.glDrawElements(gl.GL_TRIANGLES, n_idx, gl.GL_UNSIGNED_SHORT, gl.ctypes.c_void_p(0)) gl.glBindVertexArray(0) # Wireframe overlay if self.show_wireframe: gl.glUseProgram(self.wire_shader) wire_mvp_loc = gl.glGetUniformLocation(self.wire_shader, "uMVP") gl.glUniformMatrix4fv(wire_mvp_loc, 1, gl.GL_TRUE, mvp) gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) for gi in range(len(self._group_vaos)): vao = self._group_vaos[gi] n_idx = self._group_n_indices[gi] if vao == 0 or n_idx == 0: continue gl.glBindVertexArray(vao) gl.glDrawElements(gl.GL_TRIANGLES, n_idx, gl.GL_UNSIGNED_SHORT, gl.ctypes.c_void_p(0)) gl.glBindVertexArray(0) gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) gl.glDisable(gl.GL_BLEND) # --------------------------------------------------------------------------- # WMO Viewer Window # --------------------------------------------------------------------------- class WMOViewerWindow: """Pygame + OpenGL WMO model viewer window.""" def __init__(self, wmo_root_path: str, group_paths: list[str], blp_paths: dict[str, str], blp_convert: str): self.wmo_root_path = wmo_root_path self.group_paths = group_paths self.blp_paths = blp_paths self.blp_convert = blp_convert self.parser: WMOParser | None = None self.renderer: WMORenderer | None = None self.camera = OrbitCamera() self.width = 1024 self.height = 768 self.running = True self.fps_clock = None self.font = None self._dragging = False self._panning = False self._last_mouse = (0, 0) def run(self): import pygame from pygame.locals import ( DOUBLEBUF, OPENGL, RESIZABLE, QUIT, KEYDOWN, MOUSEBUTTONDOWN, MOUSEBUTTONUP, MOUSEMOTION, VIDEORESIZE, ) # Parse WMO self.parser = WMOParser() if self.wmo_root_path and Path(self.wmo_root_path).exists(): self.parser.parse_root(Path(self.wmo_root_path).read_bytes()) total_verts = 0 total_tris = 0 for gp in self.group_paths: if Path(gp).exists(): group = self.parser.parse_group(Path(gp).read_bytes()) self.parser.groups.append(group) total_verts += len(group.positions) total_tris += len(group.indices) // 3 if total_verts == 0: print("No geometry found in WMO groups") return # Auto-fit camera all_pos = np.vstack([g.positions for g in self.parser.groups if len(g.positions) > 0]) mins = all_pos.min(axis=0) maxs = all_pos.max(axis=0) center = (mins + maxs) / 2.0 extent = np.linalg.norm(maxs - mins) self.camera.target = center self.camera.distance = max(extent * 1.2, 1.0) # Init Pygame pygame.init() name = Path(self.wmo_root_path or self.group_paths[0]).stem pygame.display.set_caption(f"WMO Viewer — {name}") pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE) pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE) self.fps_clock = pygame.time.Clock() self.font = pygame.font.SysFont("monospace", 14) import OpenGL.GL as gl self.renderer = WMORenderer(self.parser, self.blp_paths, self.blp_convert) self.renderer.init_gl() gl.glClearColor(0.12, 0.12, 0.18, 1.0) while self.running: self.fps_clock.tick(60) for event in pygame.event.get(): if event.type == QUIT: self.running = False elif event.type == VIDEORESIZE: self.width, self.height = event.w, event.h pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE) elif event.type == KEYDOWN: if event.key == pygame.K_ESCAPE: self.running = False elif event.key == pygame.K_w: self.renderer.show_wireframe = not self.renderer.show_wireframe elif event.type == MOUSEBUTTONDOWN: if event.button == 1: self._dragging = True self._last_mouse = event.pos elif event.button == 3: self._panning = True self._last_mouse = event.pos elif event.button == 4: self.camera.zoom(1) elif event.button == 5: self.camera.zoom(-1) elif event.type == MOUSEBUTTONUP: if event.button == 1: self._dragging = False elif event.button == 3: self._panning = False elif event.type == MOUSEMOTION: if self._dragging: dx = event.pos[0] - self._last_mouse[0] dy = event.pos[1] - self._last_mouse[1] self.camera.orbit(dx, dy) self._last_mouse = event.pos elif self._panning: dx = event.pos[0] - self._last_mouse[0] dy = event.pos[1] - self._last_mouse[1] self.camera.pan(-dx, dy) self._last_mouse = event.pos gl.glViewport(0, 0, self.width, self.height) gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) aspect = self.width / max(self.height, 1) proj = perspective(45.0, aspect, 0.1, 10000.0) view = self.camera.get_view_matrix() model_mat = np.eye(4, dtype=np.float32) mvp = proj @ view @ model_mat self.renderer.render(mvp, model_mat) # HUD self._draw_hud(pygame, gl, total_verts, total_tris) pygame.display.flip() pygame.quit() def _draw_hud(self, pygame, gl, total_verts, total_tris): if not self.font: return name = Path(self.wmo_root_path or self.group_paths[0]).name lines = [ name, f"{len(self.parser.groups)} groups, {total_verts} verts, {total_tris} tris", f"{len(self.parser.materials)} materials, {len(self.parser.textures)} textures", f"FPS: {self.fps_clock.get_fps():.0f}", "", "LMB: orbit | RMB: pan | Scroll: zoom", "W: wireframe | Esc: quit", ] line_height = 18 total_height = len(lines) * line_height + 8 surf_width = 420 surf = pygame.Surface((surf_width, total_height), pygame.SRCALPHA) surf.fill((0, 0, 0, 160)) for i, line in enumerate(lines): text_surf = self.font.render(line, True, (220, 220, 240)) surf.blit(text_surf, (6, 4 + i * line_height)) text_data = pygame.image.tostring(surf, "RGBA", True) tex_id = gl.glGenTextures(1) gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, surf_width, total_height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, text_data) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) # Blit using the same approach as M2ViewerWindow if not hasattr(self, '_blit_shader'): blit_vert = """ #version 330 core layout(location=0) in vec2 aPos; layout(location=1) in vec2 aUV; out vec2 vUV; void main() { gl_Position = vec4(aPos, 0.0, 1.0); vUV = aUV; } """ blit_frag = """ #version 330 core in vec2 vUV; uniform sampler2D uTex; out vec4 FragColor; void main() { FragColor = texture(uTex, vUV); } """ self._blit_shader = self.renderer._compile_program(blit_vert, blit_frag) self._blit_vao = gl.glGenVertexArrays(1) self._blit_vbo = gl.glGenBuffers(1) x, y, w, h = 8, self.height - total_height - 8, surf_width, total_height x0 = 2.0 * x / self.width - 1.0 y0 = 2.0 * y / self.height - 1.0 x1 = 2.0 * (x + w) / self.width - 1.0 y1 = 2.0 * (y + h) / self.height - 1.0 quad = np.array([ x0, y0, 0, 0, x1, y0, 1, 0, x1, y1, 1, 1, x0, y0, 0, 0, x1, y1, 1, 1, x0, y1, 0, 1, ], dtype=np.float32) gl.glBindVertexArray(self._blit_vao) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._blit_vbo) gl.glBufferData(gl.GL_ARRAY_BUFFER, quad.nbytes, quad, gl.GL_DYNAMIC_DRAW) gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(0)) gl.glEnableVertexAttribArray(0) gl.glVertexAttribPointer(1, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(8)) gl.glEnableVertexAttribArray(1) gl.glDisable(gl.GL_DEPTH_TEST) gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glUseProgram(self._blit_shader) gl.glUniform1i(gl.glGetUniformLocation(self._blit_shader, "uTex"), 0) gl.glDrawArrays(gl.GL_TRIANGLES, 0, 6) gl.glBindVertexArray(0) gl.glEnable(gl.GL_DEPTH_TEST) gl.glDisable(gl.GL_BLEND) gl.glDeleteTextures(1, [tex_id]) # --------------------------------------------------------------------------- # Launch entry points (multiprocessing-safe) # --------------------------------------------------------------------------- def _viewer_main(m2_path: str, blp_paths: dict[str, str], blp_convert: str): """Entry point for M2 viewer subprocess.""" viewer = M2ViewerWindow(m2_path, blp_paths, blp_convert) viewer.run() def _wmo_viewer_main(wmo_root: str, group_paths: list[str], blp_paths: dict[str, str], blp_convert: str): """Entry point for WMO viewer subprocess.""" viewer = WMOViewerWindow(wmo_root, group_paths, blp_paths, blp_convert) viewer.run() def launch_m2_viewer(m2_path: str, blp_paths: dict[str, str], blp_convert: str): """Launch M2 viewer in a separate process to avoid Tkinter/Pygame conflicts.""" p = multiprocessing.Process(target=_viewer_main, args=(m2_path, blp_paths, blp_convert), daemon=True) p.start() return p def launch_wmo_viewer(wmo_root: str, group_paths: list[str], blp_paths: dict[str, str], blp_convert: str): """Launch WMO viewer in a separate process.""" p = multiprocessing.Process(target=_wmo_viewer_main, args=(wmo_root, group_paths, blp_paths, blp_convert), daemon=True) p.start() return p if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python m2_viewer.py [blp_convert_path]") sys.exit(1) file_path = sys.argv[1] blp_conv = sys.argv[2] if len(sys.argv) > 2 else "" if file_path.lower().endswith(".wmo"): # Detect root vs group and find all group files p = Path(file_path) name = p.name.lower() is_group = len(name) > 8 and name[-8:-4].isdigit() and name[-9] == "_" if is_group: # Derive root from group stem = p.stem root_stem = stem.rsplit("_", 1)[0] root_path = p.parent / f"{root_stem}.wmo" groups = sorted(p.parent.glob(f"{root_stem}_*.wmo")) else: root_path = p stem = p.stem groups = sorted(p.parent.glob(f"{stem}_*.wmo")) root_str = str(root_path) if root_path.exists() else "" group_strs = [str(g) for g in groups] if not group_strs and is_group: group_strs = [file_path] viewer = WMOViewerWindow(root_str, group_strs, {}, blp_conv) viewer.run() else: viewer = M2ViewerWindow(file_path, {}, blp_conv) viewer.run()