diff --git a/tools/asset_pipeline_gui.py b/tools/asset_pipeline_gui.py index 24a94d4b..c784ef35 100755 --- a/tools/asset_pipeline_gui.py +++ b/tools/asset_pipeline_gui.py @@ -41,6 +41,20 @@ PIPELINE_DIR = ROOT_DIR / "asset_pipeline" STATE_FILE = PIPELINE_DIR / "state.json" +def _audio_subprocess(file_path: str) -> None: + """Play an audio file using pygame.mixer in a subprocess.""" + try: + import pygame + pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=2048) + pygame.mixer.music.load(file_path) + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + pygame.time.wait(100) + pygame.mixer.quit() + except Exception: + pass + + @dataclass class PackInfo: pack_id: str @@ -487,6 +501,7 @@ class AssetPipelineGUI: def _build_browser_tab(self) -> None: self._browser_manifest: dict[str, dict] = {} + self._browser_manifest_lc: dict[str, str] = {} self._browser_manifest_list: list[str] = [] self._browser_tree_populated: set[str] = set() self._browser_photo: Any = None # prevent GC of PhotoImage @@ -587,6 +602,11 @@ class AssetPipelineGUI: self._browser_manifest_list = sorted(self._browser_manifest.keys(), key=str.lower) self._browser_count_var.set(f"{len(self._browser_manifest)} entries") + # Build case-insensitive lookup: lowercase forward-slash path -> actual manifest path + self._browser_manifest_lc: dict[str, str] = {} + for p in self._browser_manifest: + self._browser_manifest_lc[p.lower()] = p + # Build directory tree indices: one full, one filtered # Single O(N) pass so tree operations are O(1) lookups _hidden_exts = {".anim", ".skin"} @@ -851,7 +871,147 @@ class AssetPipelineGUI: except Exception as exc: ttk.Label(self._browser_preview_frame, text=f"Image load error: {exc}").pack(expand=True) - # ── M2 Wireframe Preview ── + # ── M2 Preview (wireframe + textures + animations) ── + + # Common animation ID names + _ANIM_NAMES: dict[int, str] = { + 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", + 60: "SpellChannelDirected", 61: "SpellChannelOmni", + 69: "CombatAbility", 70: "CombatAbility2H", + 94: "Kneel", 113: "Loot", + 135: "ReadyRifle", 138: "Fly", 143: "CustomSpell01", + 157: "EmoteTalk", 185: "FlyIdle", + } + + # Texture type names for non-filename textures + _TEX_TYPE_NAMES: dict[int, str] = { + 0: "Filename", 1: "Body/Skin", 2: "Object Skin", 3: "Weapon Blade", + 4: "Weapon Handle", 5: "Environment", 6: "Hair", 7: "Facial Hair", + 8: "Skin Extra", 9: "UI Skin", 10: "Tauren Mane", 11: "Monster Skin 1", + 12: "Monster Skin 2", 13: "Monster Skin 3", 14: "Item Icon", + } + + def _browser_parse_m2_textures(self, data: bytes, version: int) -> list[dict]: + """Parse M2 texture definitions. Returns list of {type, flags, filename}.""" + if version <= 256: + ofs = 92 + else: + ofs = 80 + + if len(data) < ofs + 8: + return [] + + n_tex, ofs_tex = struct.unpack_from(" 1000 or ofs_tex + n_tex * 16 > len(data): + return [] + + textures = [] + for i in range(n_tex): + base = ofs_tex + i * 16 + tex_type, tex_flags = struct.unpack_from(" 1 and name_ofs + name_len <= len(data): + raw = data[name_ofs:name_ofs + name_len] + filename = raw.split(b"\x00", 1)[0].decode("ascii", errors="replace") + textures.append({"type": tex_type, "flags": tex_flags, "filename": filename}) + return textures + + def _browser_parse_m2_animations(self, data: bytes, version: int) -> list[dict]: + """Parse M2 animation sequences. Returns list of {id, variation, duration, speed, flags}.""" + if len(data) < 36: + return [] + + n_anim, ofs_anim = struct.unpack_from(" 5000: + return [] + + seq_size = 68 if version <= 256 else 64 + if ofs_anim + n_anim * seq_size > len(data): + return [] + + anims = [] + for i in range(n_anim): + base = ofs_anim + i * seq_size + anim_id, variation = struct.unpack_from(" Path | None: + """Resolve a BLP filename from M2 texture to a filesystem path, case-insensitively.""" + # Normalize: backslash -> forward slash + normalized = blp_name.replace("\\", "/") + lc = normalized.lower() + + # Try direct manifest lookup + actual = self._browser_manifest_lc.get(lc) + if actual: + return self._browser_resolve_path(actual) + + # Try without leading slash + if lc.startswith("/"): + actual = self._browser_manifest_lc.get(lc[1:]) + if actual: + return self._browser_resolve_path(actual) + + return None + + def _browser_load_blp_thumbnail(self, blp_path: Path, size: int = 64) -> Any: + """Convert BLP to PNG and return a PhotoImage thumbnail, or None.""" + if not HAS_PILLOW: + return None + + blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert" + if not blp_convert.exists(): + return None + + cache_dir = PIPELINE_DIR / "preview_cache" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_key = hashlib.md5(str(blp_path).encode()).hexdigest() + cached_png = cache_dir / f"{cache_key}.png" + + if not cached_png.exists(): + try: + result = subprocess.run( + [str(blp_convert), "--to-png", str(blp_path)], + capture_output=True, text=True, timeout=10, + ) + output_png = blp_path.with_suffix(".png") + if result.returncode != 0 or not output_png.exists(): + return None + shutil.move(str(output_png), cached_png) + except Exception: + return None + + try: + img = Image.open(cached_png) + img.thumbnail((size, size), Image.LANCZOS) + return ImageTk.PhotoImage(img) + except Exception: + return None def _browser_preview_m2(self, path: str, entry: dict) -> None: file_path = self._browser_resolve_path(path) @@ -872,10 +1032,7 @@ class AssetPipelineGUI: version = struct.unpack_from("", self._browser_wf_mouse_down) + canvas.bind("", self._browser_wf_mouse_drag) + canvas.bind("", self._browser_wf_scroll) + canvas.bind("", lambda e: self._browser_wf_scroll_linux(e, 1)) + canvas.bind("", lambda e: self._browser_wf_scroll_linux(e, -1)) + canvas.bind("", lambda e: self._browser_wf_render()) + self.root.after(50, self._browser_wf_render) + + # Right: textures + animations sidebar + right_frame = ttk.Frame(main_pane) + main_pane.add(right_frame, weight=1) + + # --- Textures section --- + ttk.Label(right_frame, text="Textures", font=("", 10, "bold")).pack(anchor="w", pady=(4, 2)) + + # Keep references to thumbnail PhotoImages to prevent GC + self._browser_m2_thumbs: list[Any] = [] + + if textures: + tex_frame = ttk.Frame(right_frame) + tex_frame.pack(fill="x", padx=2) + + for i, tex in enumerate(textures): + row_frame = ttk.Frame(tex_frame) + row_frame.pack(fill="x", pady=1) + + tex_type = tex["type"] + filename = tex["filename"] + + if tex_type == 0 and filename: + # Try to load BLP thumbnail + display_name = filename.replace("\\", "/").split("/")[-1] + blp_fs_path = self._browser_resolve_blp_path(filename) + thumb = None + if blp_fs_path: + thumb = self._browser_load_blp_thumbnail(blp_fs_path) + + if thumb: + self._browser_m2_thumbs.append(thumb) + lbl_img = ttk.Label(row_frame, image=thumb) + lbl_img.pack(side="left", padx=(0, 4)) + + lbl_text = ttk.Label(row_frame, text=display_name, wraplength=180) + lbl_text.pack(side="left", fill="x") + else: + type_name = self._TEX_TYPE_NAMES.get(tex_type, f"Type {tex_type}") + lbl = ttk.Label(row_frame, text=f"[{type_name}]", foreground="#888") + lbl.pack(side="left") + else: + ttk.Label(right_frame, text="(none)", foreground="#888").pack(anchor="w") + + # --- Separator --- + ttk.Separator(right_frame, orient="horizontal").pack(fill="x", pady=6) + + # --- Animations section --- + ttk.Label(right_frame, text="Animations", font=("", 10, "bold")).pack(anchor="w", pady=(0, 2)) + + if animations: + anim_frame = ttk.Frame(right_frame) + anim_frame.pack(fill="both", expand=True) + + anim_scroll = ttk.Scrollbar(anim_frame, orient="vertical") + anim_tree = ttk.Treeview( + anim_frame, columns=("id", "name", "var", "dur", "spd"), + show="headings", height=8, + yscrollcommand=anim_scroll.set, + ) + anim_scroll.config(command=anim_tree.yview) + + anim_tree.heading("id", text="ID") + anim_tree.heading("name", text="Name") + anim_tree.heading("var", text="Var") + anim_tree.heading("dur", text="Dur(ms)") + anim_tree.heading("spd", text="Speed") + + anim_tree.column("id", width=35, minwidth=30) + anim_tree.column("name", width=90, minwidth=60) + anim_tree.column("var", width=30, minwidth=25) + anim_tree.column("dur", width=55, minwidth=40) + anim_tree.column("spd", width=45, minwidth=35) + + for anim in animations: + aid = anim["id"] + name = self._ANIM_NAMES.get(aid, "") + anim_tree.insert("", "end", values=( + aid, name, anim["variation"], + anim["duration"], f"{anim['speed']:.1f}", + )) + + anim_tree.pack(side="left", fill="both", expand=True) + anim_scroll.pack(side="right", fill="y") + else: + ttk.Label(right_frame, text="(none)", foreground="#888").pack(anchor="w") except Exception as exc: ttk.Label(self._browser_preview_frame, text=f"M2 parse error: {exc}").pack(expand=True) @@ -995,8 +1292,74 @@ class AssetPipelineGUI: self._browser_el = 0.3 self._browser_zoom = 1.0 + top_bar = ttk.Frame(self._browser_preview_frame) + top_bar.pack(fill="x", pady=(4, 2)) + info = f"WMO: {len(verts)} vertices, {len(tris)} triangles" - ttk.Label(self._browser_preview_frame, text=info).pack(pady=(4, 2)) + ttk.Label(top_bar, text=info).pack(side="left", fill="x", expand=True) + + def _open_wmo_viewer(fp=file_path, ig=is_group): + blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert" + if not blp_convert.exists(): + messagebox.showerror("Error", "blp_convert not found in build/bin/") + return + # Determine root and group files + if ig: + stem = fp.stem + root_stem = stem.rsplit("_", 1)[0] + root_path = fp.parent / f"{root_stem}.wmo" + groups = sorted(fp.parent.glob(f"{root_stem}_*.wmo")) + else: + root_path = fp + groups = sorted(fp.parent.glob(f"{fp.stem}_*.wmo")) + # Parse root for texture names, resolve BLP paths + blp_map: dict[str, str] = {} + if root_path.exists(): + import struct as _st + rdata = root_path.read_bytes() + pos = 0 + while pos + 8 <= len(rdata): + cid = rdata[pos:pos + 4] + csz = _st.unpack_from(" len(rdata): + break + tag = cid if cid[:1].isupper() else cid[::-1] + if tag == b"MOTX": + off = 0 + while off < csz: + end = rdata.find(b"\x00", cs + off, ce) + if end < 0: + break + s = rdata[cs + off:end].decode("ascii", errors="replace") + if s: + resolved = self._browser_resolve_blp_path(s) + if resolved: + norm = s.replace("\\", "/") + blp_map[norm] = str(resolved) + blp_map[norm.lower()] = str(resolved) + off = end - cs + 1 + else: + off += 1 + break + pos = ce + try: + from tools.m2_viewer import launch_wmo_viewer + launch_wmo_viewer( + str(root_path) if root_path.exists() else "", + [str(g) for g in groups], + blp_map, str(blp_convert)) + except ImportError: + try: + from m2_viewer import launch_wmo_viewer as lwv + lwv(str(root_path) if root_path.exists() else "", + [str(g) for g in groups], blp_map, str(blp_convert)) + except ImportError: + messagebox.showerror("Error", "m2_viewer.py not found. Requires pygame, PyOpenGL, numpy, Pillow.") + + ttk.Button(top_bar, text="Open 3D Viewer", command=_open_wmo_viewer).pack(side="right", padx=4) + self._browser_create_wireframe_canvas() except Exception as exc: @@ -1423,7 +1786,40 @@ class AssetPipelineGUI: text = "\n".join(info_lines) lbl = ttk.Label(self._browser_preview_frame, text=text, justify="left", anchor="nw") - lbl.pack(expand=True, padx=20, pady=20) + lbl.pack(padx=20, pady=(20, 8)) + + # Audio playback controls + btn_frame = ttk.Frame(self._browser_preview_frame) + btn_frame.pack(padx=20, pady=4) + + self._audio_status_var = tk.StringVar(value="Stopped") + status_lbl = ttk.Label(self._browser_preview_frame, textvariable=self._audio_status_var) + status_lbl.pack(padx=20, pady=(4, 0)) + + def _play_audio(): + self._browser_stop_audio() + try: + import multiprocessing + self._audio_proc = multiprocessing.Process( + target=_audio_subprocess, args=(str(file_path),), daemon=True) + self._audio_proc.start() + self._audio_status_var.set("Playing...") + except Exception as exc: + self._audio_status_var.set(f"Error: {exc}") + + def _stop_audio(): + self._browser_stop_audio() + self._audio_status_var.set("Stopped") + + ttk.Button(btn_frame, text="Play", command=_play_audio).pack(side="left", padx=4) + ttk.Button(btn_frame, text="Stop", command=_stop_audio).pack(side="left", padx=4) + + def _browser_stop_audio(self): + proc = getattr(self, "_audio_proc", None) + if proc and proc.is_alive(): + proc.terminate() + proc.join(timeout=1) + self._audio_proc = None # ── Hex Dump Preview ── diff --git a/tools/m2_viewer.py b/tools/m2_viewer.py new file mode 100644 index 00000000..8648948f --- /dev/null +++ b/tools/m2_viewer.py @@ -0,0 +1,2170 @@ +#!/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()