Skip to content

Instantly share code, notes, and snippets.

@gcr
Last active February 16, 2026 14:30
Show Gist options
  • Select an option

  • Save gcr/c77ba63956447e96e3f2618dd2190d89 to your computer and use it in GitHub Desktop.

Select an option

Save gcr/c77ba63956447e96e3f2618dd2190d89 to your computer and use it in GitHub Desktop.
quake 1 MDL to GLTF

Prompt (opus 4.6)

hii! could you help me create a python script that turns quake .MDL files into a more modern 3d format? (dealer's choice, but maybe gltf might be nice)

to assist you, you have:

• a copy of the ironwail source port, in ironwail/;

• some specs on .MDL files in specs/, plus a simple C mdl decoder;

• the quake brutalist map jam which contains some .MDLs to work with for examples. I'm especially interested in qbj3/progs/v_wrench.mdl and qbj3/progs/violin.mdl.

thanks!

Full conversation...

Charm™ CRUSH ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱

I'll start by exploring the project structure and understanding the MDL format, then build a converter.

✓ To-Do created 5 todos, starting first

  → Studying MDL specs and reference decoder
  • Study ironwail MDL loading code for additional insights
  • Build Python MDL parser
  • Build glTF exporter
  • Test with sample MDL files
  
  
  ✓ List ~/quake-mdl-to-3dmodel/specs

   The directory tree is shown up to a depth of 2. Use a higher depth and a specific path to see more level…

   - /Users/kimmy/quake-mdl-to-3dmodel/specs/
     - anorms.h
     - colormap.h
     - mdl-specs-en.html
     - mdl.c


✓ List ~/quake-mdl-to-3dmodel

   The directory tree is shown up to a depth of 1. Use a higher depth and a specific path to see more level…

   - /Users/kimmy/quake-mdl-to-3dmodel/
     - specs/
     - qbj3/
     - ironwail/


✓ Bash ls /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/*.mdl 2>/dev/null | head -20

   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/b_g_key.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/b_key.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/b_s_key.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/backpack_big.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/backpack.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/banner01.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/bolt.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/boss.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/buckshot_casing.mdl
   /Users/kimmy/quake-mdl-to-3dmodel/qbj3/progs/bullet_casing.mdl
  … (10 lines hidden) [click or space to expand]
  
  ✓ View ~/quake-mdl-to-3dmodel/specs/mdl.c

    1   /*
    2    * mdl.c -- mdl model loader
    3    * last modification: mar. 21, 2015
    4    *
    5    * Copyright (c) 2005-2015 David HENRY
    6    *
    7    * Permission is hereby granted, free of charge, to any person
    8    * obtaining a copy of this software and associated documentation
    9    * files (the "Software"), to deal in the Software without
   10    * restriction, including without limitation the rights to use,
   11
    … (572 lines hidden) [click or space to expand]
    
    
✓ View ~/quake-mdl-to-3dmodel/specs/anorms.h

    1   /*
    2    *    anorms.h - header file
    3    */
    4
    5   { -0.525731f,  0.000000f,  0.850651f },
    6   { -0.442863f,  0.238856f,  0.864188f },
    7   { -0.295242f,  0.000000f,  0.955423f },
    8   { -0.309017f,  0.500000f,  0.809017f },
    9   { -0.162460f,  0.262866f,  0.951056f },
   10   {  0.000000f,  0.000000f,  1.000000f },
   11
    … (156 lines hidden) [click or space to expand]
    
    
Let me read the HTML spec directly and check the ironwail source for group frame handling.

✓ View ~/quake-mdl-to-3dmodel/specs/mdl-specs-en.html

    1   <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-stri…
    2   <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
    3     <head>
    4       <meta http-equiv="Content-type" content="text/html; charset=UTF-8" />
    5       <meta http-equiv="Content-style-type" content="text/css" />
    6       <meta http-equiv="Content-language" content="en" />
    7       <title>MDL file format specifications (Quake's models)</title>
    8       <meta name="author" content="David Henry" />
    9       <meta name="keywords" content="OpenGL, MDL, MDL format, Quake, Specifications" />
   10       <meta name="description" content="MDL file format specifications (Quake's models) -
    … (513 lines hidden) [click or space to expand]
  
  
  ✓ Agent

   Task  Search the ironwail source code at /Users/kimmy/quake-mdl-to-3dmodel/ironwail/ for the MDL model
         loading code. I need to understand how it handles: 1. Group skins (skin groups with multiple sub-
         skins and timing intervals) 2. Group frames (frame groups with multiple sub-frames and timing
         intervals)  3. The complete reading/parsing flow  Look for files related to model loading -
         likely something like "gl_model.c", "mod_*.c", "r_alias.c", or similar. Search for
         "ReadMDLModel", "Mod_LoadAliasModel", "IDPOLYHEADER", or "ALIAS_VERSION". Return the complete
         relevant parsing code for MDL files, especially the parts dealing with group skins and group
         frames.
  ├─── ✓ Glob **/*.c (path=/Users/kimmy/quake-mdl-to-3dmodel/ironwail)
  ├─── ✓ Grep Mod_LoadAliasModel|IDPOLYHEADER|ALIAS_VERSION|LoadAlias
  ├─── ✓ Grep IDPOLYHEADER|ALIAS_VERSION|mdl_t|aliashdr
  ├─── ✓ View ~/quake-mdl-to-3dmodel/ironwail/Quake/gl_model.c (limit=200, offset=2740)
  ├─── ✓ View ~/quake-mdl-to-3dmodel/ironwail/Quake/modelgen.h
  ├─── ✓ View ~/quake-mdl-to-3dmodel/ironwail/Quake/gl_model.h (limit=120, offset=270)
  ├─── ✓ View ~/quake-mdl-to-3dmodel/ironwail/Quake/gl_model.c (limit=200, offset=2935)
  ╰─── ✓ View ~/quake-mdl-to-3dmodel/ironwail/Quake/gl_model.c (limit=250, offset=3280)

  Here is the complete MDL model loading code from ironwail. Here are the three key areas:

  --------

  ## File:  /Users/kimmy/quake-mdl-to-3dmodel/ironwail/Quake/modelgen.h  — On-disk structures

    #define ALIAS_VERSION   6
    #define IDPOLYHEADER    (('O'<<24)+('P'<<16)+('D'<<8)+'I')  // "IDPO"

    typedef enum { ALIAS_SINGLE=0, ALIAS_GROUP } aliasframetype_t;
  … (190 lines hidden) [click or space to expand]

✓ View ~/quake-mdl-to-3dmodel/specs/colormap.h

    1   {  0,   0,   0}, { 15,  15,  15}, { 31,  31,  31}, { 47,  47,  47},
    2   { 63,  63,  63}, { 75,  75,  75}, { 91,  91,  91}, {107, 107, 107},
    3   {123, 123, 123}, {139, 139, 139}, {155, 155, 155}, {171, 171, 171},
    4   {187, 187, 187}, {203, 203, 203}, {219, 219, 219}, {235, 235, 235},
    5   { 15,  11,   7}, { 23,  15,  11}, { 31,  23,  11}, { 39,  27,  15},
    6   { 47,  35,  19}, { 55,  43,  23}, { 63,  47,  23}, { 75,  55,  27},
    7   { 83,  59,  27}, { 91,  67,  31}, { 99,  75,  31}, {107,  83,  31},
    8   {115,  87,  31}, {123,  95,  35}, {131, 103,  35}, {143, 111,  35},
    9   { 11,  11,  15}, { 19,  19,  27}, { 27,  27,  39}, { 39,  39,  51},
   10   { 47,  47,  63}, { 55,  55,  75}, { 63,  63,  87}, { 71,  71, 103},
   11
    … (54 lines hidden) [click or space to expand]

✓ To-Do 2/5 · completed 2, starting next

  → Building Python MDL parser

Now I have a thorough understanding of the format. Let me build the converter.

✓ Write ~/quake-mdl-to-3dmodel/mdl2gltf.py

     1   #!/usr/bin/env python3
     2   """
     3   mdl2gltf - Convert Quake .MDL files to glTF 2.0 (.glb)
     4
     5   Supports:
     6     - Single and group skins (first skin used as texture)
     7     - Single and group frames (all sub-frames flattened as animation targets)
     8     - Proper UV seam handling (backface onseam offset)
     9     - Vertex normals from Quake's precalculated normal table
    10     - Morph-target animation exported as glTF morph targets
[...]



Both converted. Let me validate the glTF output and also test a few more models to make sure group
skins/frames work.

✓ Bash cd /Users/kimmy/quake-mdl-to-3dmodel && python3 -c " import struct, json  def validate_glb(path):   …

   output/v_wrench.glb:
     glTF version 2, total 2594328 bytes
     JSON chunk: 21540 bytes
     BIN chunk: 2572760 bytes
     Meshes: 1
     Accessors: 76
     Materials: 1
     Images: 1
     Animations: 1
     Morph targets: 70
  … (10 lines hidden) [click or space to expand]

Let me also test with a couple more models to ensure robustness, and try one that might have group frames.

✓ Bash cd /Users/kimmy/quake-mdl-to-3dmodel && for f in qbj3/progs/boss.mdl qbj3/progs/candle1.mdl qbj3/pro…

   MDL: boss.mdl
     Skin: 512x256, 1 skin(s)
     Mesh: 1276 verts, 1958 tris
     Frames: 106
     -> output/boss.glb
   MDL: candle1.mdl
     Skin: 128x128, 1 skin(s)
     Mesh: 47 verts, 52 tris
     Frames: 96
     -> output/candle1.glb
  … (5 lines hidden) [click or space to expand]

All working. Let me also try to find an MDL with group skins to verify that code path.

✓ Bash cd /Users/kimmy/quake-mdl-to-3dmodel && python3 -c " import struct, os, glob  for f in sorted(glob.g…

   qbj3/progs/jm_pope.mdl: num_skins=1, 2048x2048, first_skin_group=1

✓ Bash cd /Users/kimmy/quake-mdl-to-3dmodel && python3 mdl2gltf.py qbj3/progs/jm_pope.mdl output/jm_pope.gl…

   MDL: jm_pope.mdl
     Skin: 2048x2048, 1 skin(s)
     Mesh: 837 verts, 834 tris
     Frames: 1
     -> output/jm_pope.glb

Group skins handled correctly too. Let me also do a quick sanity check that the glTF files can be parsed by a
standard validator.



✓ To-Do 5/5 · completed all

  ✓ Study MDL specs and reference decoder
  ✓ Study ironwail MDL loading code for additional insights
  ✓ Build Python MDL parser
  ✓ Build glTF exporter
  ✓ Test with sample MDL files

Done! The converter is at  mdl2gltf.py . Here's what it does:

• Parses the full MDL format: header, single + group skins, texcoords with seam handling, triangles, single +
group frames
• Exports glTF 2.0 binary ( .glb ) with embedded PNG texture, vertex normals from Quake's 162-entry normal
table, and correct Quake-to-glTF coordinate transform
• Animated models get morph-target animation (70 targets for the wrench at 10fps = 7s loop)
• Static models (like violin) export clean single-frame meshes
• Texture uses  NEAREST  filtering and  MASK  alpha mode (palette index 255 = transparent), so it keeps that
crispy Quake look

  python3 mdl2gltf.py qbj3/progs/v_wrench.mdl   # -> v_wrench.glb (2.5MB, 71 frames)
  python3 mdl2gltf.py qbj3/progs/violin.mdl      # -> violin.glb (76KB, static)

The  .glb  files can be opened in Blender, VS Code (glTF Tools), the glTF Viewer https://gltf-
viewer.donmccurdy.com/, three.js, etc.

Claude Opus 4.6 via Anthropic 7m50s
Cost: $1.49
#!/usr/bin/env python3
"""
mdl2gltf - Convert Quake .MDL files to glTF 2.0 (.glb)
Supports:
- Single and group skins (first skin used as texture)
- Single and group frames (all sub-frames flattened as animation targets)
- Proper UV seam handling (backface onseam offset)
- Vertex normals from Quake's precalculated normal table
- Morph-target animation exported as glTF morph targets
- Embedded PNG texture in the .glb binary
Usage:
python mdl2gltf.py input.mdl [output.glb]
"""
import struct
import sys
import json
import io
import math
from pathlib import Path
ANORMS = [
(-0.525731, 0.000000, 0.850651), (-0.442863, 0.238856, 0.864188),
(-0.295242, 0.000000, 0.955423), (-0.309017, 0.500000, 0.809017),
(-0.162460, 0.262866, 0.951056), ( 0.000000, 0.000000, 1.000000),
( 0.000000, 0.850651, 0.525731), (-0.147621, 0.716567, 0.681718),
( 0.147621, 0.716567, 0.681718), ( 0.000000, 0.525731, 0.850651),
( 0.309017, 0.500000, 0.809017), ( 0.525731, 0.000000, 0.850651),
( 0.295242, 0.000000, 0.955423), ( 0.442863, 0.238856, 0.864188),
( 0.162460, 0.262866, 0.951056), (-0.681718, 0.147621, 0.716567),
(-0.809017, 0.309017, 0.500000), (-0.587785, 0.425325, 0.688191),
(-0.850651, 0.525731, 0.000000), (-0.864188, 0.442863, 0.238856),
(-0.716567, 0.681718, 0.147621), (-0.688191, 0.587785, 0.425325),
(-0.500000, 0.809017, 0.309017), (-0.238856, 0.864188, 0.442863),
(-0.425325, 0.688191, 0.587785), (-0.716567, 0.681718,-0.147621),
(-0.500000, 0.809017,-0.309017), (-0.525731, 0.850651, 0.000000),
( 0.000000, 0.850651,-0.525731), (-0.238856, 0.864188,-0.442863),
( 0.000000, 0.955423,-0.295242), (-0.262866, 0.951056,-0.162460),
( 0.000000, 1.000000, 0.000000), ( 0.000000, 0.955423, 0.295242),
(-0.262866, 0.951056, 0.162460), ( 0.238856, 0.864188, 0.442863),
( 0.262866, 0.951056, 0.162460), ( 0.500000, 0.809017, 0.309017),
( 0.238856, 0.864188,-0.442863), ( 0.262866, 0.951056,-0.162460),
( 0.500000, 0.809017,-0.309017), ( 0.850651, 0.525731, 0.000000),
( 0.716567, 0.681718, 0.147621), ( 0.716567, 0.681718,-0.147621),
( 0.525731, 0.850651, 0.000000), ( 0.425325, 0.688191, 0.587785),
( 0.864188, 0.442863, 0.238856), ( 0.688191, 0.587785, 0.425325),
( 0.809017, 0.309017, 0.500000), ( 0.681718, 0.147621, 0.716567),
( 0.587785, 0.425325, 0.688191), ( 0.955423, 0.295242, 0.000000),
( 1.000000, 0.000000, 0.000000), ( 0.951056, 0.162460, 0.262866),
( 0.850651,-0.525731, 0.000000), ( 0.955423,-0.295242, 0.000000),
( 0.864188,-0.442863, 0.238856), ( 0.951056,-0.162460, 0.262866),
( 0.809017,-0.309017, 0.500000), ( 0.681718,-0.147621, 0.716567),
( 0.850651, 0.000000, 0.525731), ( 0.864188, 0.442863,-0.238856),
( 0.809017, 0.309017,-0.500000), ( 0.951056, 0.162460,-0.262866),
( 0.525731, 0.000000,-0.850651), ( 0.681718, 0.147621,-0.716567),
( 0.681718,-0.147621,-0.716567), ( 0.850651, 0.000000,-0.525731),
( 0.809017,-0.309017,-0.500000), ( 0.864188,-0.442863,-0.238856),
( 0.951056,-0.162460,-0.262866), ( 0.147621, 0.716567,-0.681718),
( 0.309017, 0.500000,-0.809017), ( 0.425325, 0.688191,-0.587785),
( 0.442863, 0.238856,-0.864188), ( 0.587785, 0.425325,-0.688191),
( 0.688191, 0.587785,-0.425325), (-0.147621, 0.716567,-0.681718),
(-0.309017, 0.500000,-0.809017), ( 0.000000, 0.525731,-0.850651),
(-0.525731, 0.000000,-0.850651), (-0.442863, 0.238856,-0.864188),
(-0.295242, 0.000000,-0.955423), (-0.162460, 0.262866,-0.951056),
( 0.000000, 0.000000,-1.000000), ( 0.295242, 0.000000,-0.955423),
( 0.162460, 0.262866,-0.951056), (-0.442863,-0.238856,-0.864188),
(-0.309017,-0.500000,-0.809017), (-0.162460,-0.262866,-0.951056),
( 0.000000,-0.850651,-0.525731), (-0.147621,-0.716567,-0.681718),
( 0.147621,-0.716567,-0.681718), ( 0.000000,-0.525731,-0.850651),
( 0.309017,-0.500000,-0.809017), ( 0.442863,-0.238856,-0.864188),
( 0.162460,-0.262866,-0.951056), ( 0.238856,-0.864188,-0.442863),
( 0.500000,-0.809017,-0.309017), ( 0.425325,-0.688191,-0.587785),
( 0.716567,-0.681718,-0.147621), ( 0.688191,-0.587785,-0.425325),
( 0.587785,-0.425325,-0.688191), ( 0.000000,-0.955423,-0.295242),
( 0.000000,-1.000000, 0.000000), ( 0.262866,-0.951056,-0.162460),
( 0.000000,-0.850651, 0.525731), ( 0.000000,-0.955423, 0.295242),
( 0.238856,-0.864188, 0.442863), ( 0.262866,-0.951056, 0.162460),
( 0.500000,-0.809017, 0.309017), ( 0.716567,-0.681718, 0.147621),
( 0.525731,-0.850651, 0.000000), (-0.238856,-0.864188,-0.442863),
(-0.500000,-0.809017,-0.309017), (-0.262866,-0.951056,-0.162460),
(-0.850651,-0.525731, 0.000000), (-0.716567,-0.681718,-0.147621),
(-0.716567,-0.681718, 0.147621), (-0.525731,-0.850651, 0.000000),
(-0.500000,-0.809017, 0.309017), (-0.238856,-0.864188, 0.442863),
(-0.262866,-0.951056, 0.162460), (-0.864188,-0.442863, 0.238856),
(-0.809017,-0.309017, 0.500000), (-0.688191,-0.587785, 0.425325),
(-0.681718,-0.147621, 0.716567), (-0.442863,-0.238856, 0.864188),
(-0.587785,-0.425325, 0.688191), (-0.309017,-0.500000, 0.809017),
(-0.147621,-0.716567, 0.681718), (-0.425325,-0.688191, 0.587785),
(-0.162460,-0.262866, 0.951056), ( 0.442863,-0.238856, 0.864188),
( 0.162460,-0.262866, 0.951056), ( 0.309017,-0.500000, 0.809017),
( 0.147621,-0.716567, 0.681718), ( 0.000000,-0.525731, 0.850651),
( 0.425325,-0.688191, 0.587785), ( 0.587785,-0.425325, 0.688191),
( 0.688191,-0.587785, 0.425325), (-0.955423, 0.295242, 0.000000),
(-0.951056, 0.162460, 0.262866), (-1.000000, 0.000000, 0.000000),
(-0.850651, 0.000000, 0.525731), (-0.955423,-0.295242, 0.000000),
(-0.951056,-0.162460, 0.262866), (-0.864188, 0.442863,-0.238856),
(-0.951056, 0.162460,-0.262866), (-0.809017, 0.309017,-0.500000),
(-0.864188,-0.442863,-0.238856), (-0.951056,-0.162460,-0.262866),
(-0.809017,-0.309017,-0.500000), (-0.681718, 0.147621,-0.716567),
(-0.681718,-0.147621,-0.716567), (-0.850651, 0.000000,-0.525731),
(-0.688191, 0.587785,-0.425325), (-0.587785, 0.425325,-0.688191),
(-0.425325, 0.688191,-0.587785), (-0.425325,-0.688191,-0.587785),
(-0.587785,-0.425325,-0.688191), (-0.688191,-0.587785,-0.425325),
]
QUAKE_PALETTE = [
( 0, 0, 0), ( 15, 15, 15), ( 31, 31, 31), ( 47, 47, 47),
( 63, 63, 63), ( 75, 75, 75), ( 91, 91, 91), (107, 107, 107),
(123, 123, 123), (139, 139, 139), (155, 155, 155), (171, 171, 171),
(187, 187, 187), (203, 203, 203), (219, 219, 219), (235, 235, 235),
( 15, 11, 7), ( 23, 15, 11), ( 31, 23, 11), ( 39, 27, 15),
( 47, 35, 19), ( 55, 43, 23), ( 63, 47, 23), ( 75, 55, 27),
( 83, 59, 27), ( 91, 67, 31), ( 99, 75, 31), (107, 83, 31),
(115, 87, 31), (123, 95, 35), (131, 103, 35), (143, 111, 35),
( 11, 11, 15), ( 19, 19, 27), ( 27, 27, 39), ( 39, 39, 51),
( 47, 47, 63), ( 55, 55, 75), ( 63, 63, 87), ( 71, 71, 103),
( 79, 79, 115), ( 91, 91, 127), ( 99, 99, 139), (107, 107, 151),
(115, 115, 163), (123, 123, 175), (131, 131, 187), (139, 139, 203),
( 0, 0, 0), ( 7, 7, 0), ( 11, 11, 0), ( 19, 19, 0),
( 27, 27, 0), ( 35, 35, 0), ( 43, 43, 7), ( 47, 47, 7),
( 55, 55, 7), ( 63, 63, 7), ( 71, 71, 7), ( 75, 75, 11),
( 83, 83, 11), ( 91, 91, 11), ( 99, 99, 11), (107, 107, 15),
( 7, 0, 0), ( 15, 0, 0), ( 23, 0, 0), ( 31, 0, 0),
( 39, 0, 0), ( 47, 0, 0), ( 55, 0, 0), ( 63, 0, 0),
( 71, 0, 0), ( 79, 0, 0), ( 87, 0, 0), ( 95, 0, 0),
(103, 0, 0), (111, 0, 0), (119, 0, 0), (127, 0, 0),
( 19, 19, 0), ( 27, 27, 0), ( 35, 35, 0), ( 47, 43, 0),
( 55, 47, 0), ( 67, 55, 0), ( 75, 59, 7), ( 87, 67, 7),
( 95, 71, 7), (107, 75, 11), (119, 83, 15), (131, 87, 19),
(139, 91, 19), (151, 95, 27), (163, 99, 31), (175, 103, 35),
( 35, 19, 7), ( 47, 23, 11), ( 59, 31, 15), ( 75, 35, 19),
( 87, 43, 23), ( 99, 47, 31), (115, 55, 35), (127, 59, 43),
(143, 67, 51), (159, 79, 51), (175, 99, 47), (191, 119, 47),
(207, 143, 43), (223, 171, 39), (239, 203, 31), (255, 243, 27),
( 11, 7, 0), ( 27, 19, 0), ( 43, 35, 15), ( 55, 43, 19),
( 71, 51, 27), ( 83, 55, 35), ( 99, 63, 43), (111, 71, 51),
(127, 83, 63), (139, 95, 71), (155, 107, 83), (167, 123, 95),
(183, 135, 107), (195, 147, 123), (211, 163, 139), (227, 179, 151),
(171, 139, 163), (159, 127, 151), (147, 115, 135), (139, 103, 123),
(127, 91, 111), (119, 83, 99), (107, 75, 87), ( 95, 63, 75),
( 87, 55, 67), ( 75, 47, 55), ( 67, 39, 47), ( 55, 31, 35),
( 43, 23, 27), ( 35, 19, 19), ( 23, 11, 11), ( 15, 7, 7),
(187, 115, 159), (175, 107, 143), (163, 95, 131), (151, 87, 119),
(139, 79, 107), (127, 75, 95), (115, 67, 83), (107, 59, 75),
( 95, 51, 63), ( 83, 43, 55), ( 71, 35, 43), ( 59, 31, 35),
( 47, 23, 27), ( 35, 19, 19), ( 23, 11, 11), ( 15, 7, 7),
(219, 195, 187), (203, 179, 167), (191, 163, 155), (175, 151, 139),
(163, 135, 123), (151, 123, 111), (135, 111, 95), (123, 99, 83),
(107, 87, 71), ( 95, 75, 59), ( 83, 63, 51), ( 67, 51, 39),
( 55, 43, 31), ( 39, 31, 23), ( 27, 19, 15), ( 15, 11, 7),
(111, 131, 123), (103, 123, 111), ( 95, 115, 103), ( 87, 107, 95),
( 79, 99, 87), ( 71, 91, 79), ( 63, 83, 71), ( 55, 75, 63),
( 47, 67, 55), ( 43, 59, 47), ( 35, 51, 39), ( 31, 43, 31),
( 23, 35, 23), ( 15, 27, 19), ( 11, 19, 11), ( 7, 11, 7),
(255, 243, 27), (239, 223, 23), (219, 203, 19), (203, 183, 15),
(187, 167, 15), (171, 151, 11), (155, 131, 7), (139, 115, 7),
(123, 99, 7), (107, 83, 0), ( 91, 71, 0), ( 75, 55, 0),
( 59, 43, 0), ( 43, 31, 0), ( 27, 15, 0), ( 11, 7, 0),
( 0, 0, 255), ( 11, 11, 239), ( 19, 19, 223), ( 27, 27, 207),
( 35, 35, 191), ( 43, 43, 175), ( 47, 47, 159), ( 47, 47, 143),
( 47, 47, 127), ( 47, 47, 111), ( 47, 47, 95), ( 43, 43, 79),
( 35, 35, 63), ( 27, 27, 47), ( 19, 19, 31), ( 11, 11, 15),
( 43, 0, 0), ( 59, 0, 0), ( 75, 7, 0), ( 95, 7, 0),
(111, 15, 0), (127, 23, 7), (147, 31, 7), (163, 39, 11),
(183, 51, 15), (195, 75, 27), (207, 99, 43), (219, 127, 59),
(227, 151, 79), (231, 171, 95), (239, 191, 119), (247, 211, 139),
(167, 123, 59), (183, 155, 55), (199, 195, 55), (231, 227, 87),
(127, 191, 255), (171, 231, 255), (215, 255, 255), (103, 0, 0),
(139, 0, 0), (179, 0, 0), (215, 0, 0), (255, 0, 0),
(255, 243, 147), (255, 247, 199), (255, 255, 255), (159, 91, 83),
]
class MDLReader:
def __init__(self, data: bytes):
self.data = data
self.pos = 0
def read(self, n: int) -> bytes:
result = self.data[self.pos:self.pos + n]
self.pos += n
return result
def read_int(self) -> int:
return struct.unpack('<i', self.read(4))[0]
def read_float(self) -> float:
return struct.unpack('<f', self.read(4))[0]
def read_vec3(self) -> tuple:
return struct.unpack('<3f', self.read(12))
def read_vertex(self) -> tuple:
v = struct.unpack('<3B', self.read(3))
ni = struct.unpack('<B', self.read(1))[0]
return v, ni
def parse_mdl(filepath: str) -> dict:
with open(filepath, 'rb') as f:
data = f.read()
r = MDLReader(data)
ident = r.read_int()
version = r.read_int()
assert ident == 1330660425, f"Bad magic: {ident:#x}"
assert version == 6, f"Bad version: {version}"
scale = r.read_vec3()
translate = r.read_vec3()
boundingradius = r.read_float()
eyeposition = r.read_vec3()
num_skins = r.read_int()
skinwidth = r.read_int()
skinheight = r.read_int()
num_verts = r.read_int()
num_tris = r.read_int()
num_frames = r.read_int()
synctype = r.read_int()
flags = r.read_int()
size = r.read_float()
skin_size = skinwidth * skinheight
skins = []
for _ in range(num_skins):
group = r.read_int()
if group == 0:
pixel_data = r.read(skin_size)
skins.append(pixel_data)
else:
nb = r.read_int()
for _ in range(nb):
r.read_float()
for j in range(nb):
pixel_data = r.read(skin_size)
if j == 0:
skins.append(pixel_data)
texcoords = []
for _ in range(num_verts):
onseam = r.read_int()
s = r.read_int()
t = r.read_int()
texcoords.append((onseam, s, t))
triangles = []
for _ in range(num_tris):
facesfront = r.read_int()
v0 = r.read_int()
v1 = r.read_int()
v2 = r.read_int()
triangles.append((facesfront, (v0, v1, v2)))
frames = []
for _ in range(num_frames):
frame_type = r.read_int()
if frame_type == 0:
bboxmin = r.read_vertex()
bboxmax = r.read_vertex()
name = r.read(16).split(b'\x00')[0].decode('ascii', errors='replace')
verts = []
for _ in range(num_verts):
verts.append(r.read_vertex())
frames.append({'name': name, 'verts': verts})
else:
nb = r.read_int()
group_bboxmin = r.read_vertex()
group_bboxmax = r.read_vertex()
for _ in range(nb):
r.read_float()
for j in range(nb):
bboxmin = r.read_vertex()
bboxmax = r.read_vertex()
name = r.read(16).split(b'\x00')[0].decode('ascii', errors='replace')
verts = []
for _ in range(num_verts):
verts.append(r.read_vertex())
frames.append({'name': name, 'verts': verts})
return {
'scale': scale,
'translate': translate,
'skinwidth': skinwidth,
'skinheight': skinheight,
'num_verts': num_verts,
'num_tris': num_tris,
'skins': skins,
'texcoords': texcoords,
'triangles': triangles,
'frames': frames,
}
def skin_to_png(pixel_data: bytes, width: int, height: int) -> bytes:
try:
import png
rows = []
for y in range(height):
row = []
for x in range(width):
idx = pixel_data[y * width + x]
r, g, b = QUAKE_PALETTE[idx]
alpha = 0 if idx == 255 else 255
row.extend([r, g, b, alpha])
rows.append(row)
buf = io.BytesIO()
w = png.Writer(width=width, height=height, alpha=True, greyscale=False)
w.write(buf, rows)
return buf.getvalue()
except ImportError:
return _write_png_manual(pixel_data, width, height)
def _write_png_manual(pixel_data: bytes, width: int, height: int) -> bytes:
import zlib
def chunk(chunk_type: bytes, data: bytes) -> bytes:
c = chunk_type + data
crc = struct.pack('>I', zlib.crc32(c) & 0xffffffff)
return struct.pack('>I', len(data)) + c + crc
raw = bytearray()
for y in range(height):
raw.append(0)
for x in range(width):
idx = pixel_data[y * width + x]
r, g, b = QUAKE_PALETTE[idx]
alpha = 0 if idx == 255 else 255
raw.extend([r, g, b, alpha])
compressed = zlib.compress(bytes(raw), 9)
out = io.BytesIO()
out.write(b'\x89PNG\r\n\x1a\n')
out.write(chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)))
out.write(chunk(b'IDAT', compressed))
out.write(chunk(b'IEND', b''))
return out.getvalue()
def build_mesh_data(mdl: dict, frame_idx: int):
"""
Build per-triangle-vertex position/normal/uv arrays for a given frame.
MDL uses indexed vertices but UVs depend on triangle face direction,
so we need to "unroll" to per-face-vertex.
Quake coordinate system: X = forward, Y = left, Z = up
glTF coordinate system: X = right, Y = up, Z = forward (toward viewer)
Transform: gltf_x = -mdl_y, gltf_y = mdl_z, gltf_z = mdl_x
"""
scale = mdl['scale']
translate = mdl['translate']
skinwidth = mdl['skinwidth']
skinheight = mdl['skinheight']
texcoords = mdl['texcoords']
triangles = mdl['triangles']
frame = mdl['frames'][frame_idx]
positions = []
normals = []
uvs = []
for facesfront, (v0, v1, v2) in triangles:
for vi in (v0, v1, v2):
vert_data, normal_idx = frame['verts'][vi]
px = scale[0] * vert_data[0] + translate[0]
py = scale[1] * vert_data[1] + translate[1]
pz = scale[2] * vert_data[2] + translate[2]
gx = -py
gy = pz
gz = px
positions.append((gx, gy, gz))
ni = min(normal_idx, 161)
nx, ny, nz = ANORMS[ni]
normals.append((-ny, nz, nx))
onseam, s, t = texcoords[vi]
if not facesfront and onseam:
s += skinwidth * 0.5
u = (s + 0.5) / skinwidth
v = (t + 0.5) / skinheight
uvs.append((u, v))
return positions, normals, uvs
def compute_morph_deltas(mdl: dict, base_positions, frame_idx: int):
"""Compute position deltas for a morph target relative to the base frame."""
scale = mdl['scale']
translate = mdl['translate']
triangles = mdl['triangles']
frame = mdl['frames'][frame_idx]
deltas = []
vert_i = 0
for _, (v0, v1, v2) in triangles:
for vi in (v0, v1, v2):
vert_data, _ = frame['verts'][vi]
px = scale[0] * vert_data[0] + translate[0]
py = scale[1] * vert_data[1] + translate[1]
pz = scale[2] * vert_data[2] + translate[2]
gx = -py
gy = pz
gz = px
bx, by, bz = base_positions[vert_i]
deltas.append((gx - bx, gy - by, gz - bz))
vert_i += 1
return deltas
def pad_to_4(data: bytes) -> bytes:
remainder = len(data) % 4
if remainder:
data += b'\x00' * (4 - remainder)
return data
def pack_floats(float_list) -> bytes:
return struct.pack(f'<{len(float_list)}f', *float_list)
def flatten(tuples_list):
out = []
for t in tuples_list:
out.extend(t)
return out
def minmax_vec3(tuples_list):
xs = [t[0] for t in tuples_list]
ys = [t[1] for t in tuples_list]
zs = [t[2] for t in tuples_list]
return [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)]
def build_glb(mdl: dict, output_path: str):
num_frames = len(mdl['frames'])
use_morph_targets = num_frames > 1
base_positions, base_normals, base_uvs = build_mesh_data(mdl, 0)
num_vertices = len(base_positions)
num_triangles = mdl['num_tris']
indices = list(range(num_vertices))
morph_deltas = []
if use_morph_targets:
for fi in range(1, num_frames):
deltas = compute_morph_deltas(mdl, base_positions, fi)
morph_deltas.append(deltas)
pos_min, pos_max = minmax_vec3(base_positions)
norm_min, norm_max = minmax_vec3(base_normals)
bin_blobs = []
def add_blob(data: bytes) -> tuple:
offset = sum(len(b) for b in bin_blobs)
padded = pad_to_4(data)
bin_blobs.append(padded)
return offset, len(data)
idx_data = struct.pack(f'<{len(indices)}H', *indices) if num_vertices <= 65535 else struct.pack(f'<{len(indices)}I', *indices)
idx_component = 5123 if num_vertices <= 65535 else 5125
idx_offset, idx_len = add_blob(idx_data)
pos_data = pack_floats(flatten(base_positions))
pos_offset, pos_len = add_blob(pos_data)
norm_data = pack_floats(flatten(base_normals))
norm_offset, norm_len = add_blob(norm_data)
uv_data = pack_floats(flatten(base_uvs))
uv_offset, uv_len = add_blob(uv_data)
morph_offsets = []
for deltas in morph_deltas:
d_min, d_max = minmax_vec3(deltas)
d_data = pack_floats(flatten(deltas))
d_off, d_len = add_blob(d_data)
morph_offsets.append((d_off, d_len, d_min, d_max))
png_data = skin_to_png(mdl['skins'][0], mdl['skinwidth'], mdl['skinheight'])
img_offset, img_len = add_blob(png_data)
total_bin_size = sum(len(b) for b in bin_blobs)
buffer_views = []
accessors = []
def add_buffer_view(offset, length, target=None):
bv = {"buffer": 0, "byteOffset": offset, "byteLength": length}
if target:
bv["target"] = target
idx = len(buffer_views)
buffer_views.append(bv)
return idx
def add_accessor(bv_idx, component_type, count, acc_type, min_val=None, max_val=None):
acc = {
"bufferView": bv_idx,
"componentType": component_type,
"count": count,
"type": acc_type,
}
if min_val is not None:
acc["min"] = min_val
if max_val is not None:
acc["max"] = max_val
idx = len(accessors)
accessors.append(acc)
return idx
idx_bv = add_buffer_view(idx_offset, idx_len, 34963)
idx_acc = add_accessor(idx_bv, idx_component, num_vertices, "SCALAR",
[0], [num_vertices - 1])
pos_bv = add_buffer_view(pos_offset, pos_len, 34962)
pos_acc = add_accessor(pos_bv, 5126, num_vertices, "VEC3", pos_min, pos_max)
norm_bv = add_buffer_view(norm_offset, norm_len, 34962)
norm_acc = add_accessor(norm_bv, 5126, num_vertices, "VEC3", norm_min, norm_max)
uv_bv = add_buffer_view(uv_offset, uv_len, 34962)
uv_acc = add_accessor(uv_bv, 5126, num_vertices, "VEC2")
morph_acc_indices = []
for d_off, d_len, d_min, d_max in morph_offsets:
m_bv = add_buffer_view(d_off, d_len, 34962)
m_acc = add_accessor(m_bv, 5126, num_vertices, "VEC3", d_min, d_max)
morph_acc_indices.append(m_acc)
img_bv = add_buffer_view(img_offset, img_len)
mesh_primitive = {
"attributes": {
"POSITION": pos_acc,
"NORMAL": norm_acc,
"TEXCOORD_0": uv_acc,
},
"indices": idx_acc,
"material": 0,
}
if use_morph_targets:
targets = []
for m_acc in morph_acc_indices:
targets.append({"POSITION": m_acc})
mesh_primitive["targets"] = targets
mesh = {"primitives": [mesh_primitive]}
if use_morph_targets:
mesh["weights"] = [0.0] * len(morph_deltas)
gltf = {
"asset": {"version": "2.0", "generator": "mdl2gltf"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [mesh],
"accessors": accessors,
"bufferViews": buffer_views,
"buffers": [{"byteLength": total_bin_size}],
"images": [{"bufferView": img_bv, "mimeType": "image/png"}],
"textures": [{"source": 0, "sampler": 0}],
"samplers": [{"magFilter": 9728, "minFilter": 9728, "wrapS": 10497, "wrapT": 10497}],
"materials": [{
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 0},
"metallicFactor": 0.0,
"roughnessFactor": 1.0,
},
"alphaMode": "MASK",
"alphaCutoff": 0.5,
"doubleSided": True,
}],
}
if use_morph_targets:
fps = 10.0
time_step = 1.0 / fps
timestamps = []
for fi in range(num_frames):
timestamps.append(fi * time_step)
time_data = pack_floats(timestamps)
time_off, time_len = add_blob(time_data)
weight_values = []
num_targets = len(morph_deltas)
for fi in range(num_frames):
weights = [0.0] * num_targets
if fi > 0:
weights[fi - 1] = 1.0
weight_values.extend(weights)
weight_data = pack_floats(weight_values)
weight_off, weight_len = add_blob(weight_data)
total_bin_size = sum(len(b) for b in bin_blobs)
gltf["buffers"][0]["byteLength"] = total_bin_size
time_bv = add_buffer_view(time_off, time_len)
time_acc = add_accessor(time_bv, 5126, num_frames, "SCALAR",
[timestamps[0]], [timestamps[-1]])
weight_bv = add_buffer_view(weight_off, weight_len)
weight_acc = add_accessor(weight_bv, 5126, num_frames * num_targets, "SCALAR")
gltf["animations"] = [{
"name": "animation",
"channels": [{
"sampler": 0,
"target": {"node": 0, "path": "weights"},
}],
"samplers": [{
"input": time_acc,
"output": weight_acc,
"interpolation": "STEP",
}],
}]
json_str = json.dumps(gltf, separators=(',', ':'))
json_bytes = json_str.encode('utf-8')
json_padded = json_bytes + b' ' * ((4 - len(json_bytes) % 4) % 4)
bin_data = b''.join(bin_blobs)
bin_padded = bin_data + b'\x00' * ((4 - len(bin_data) % 4) % 4)
total_length = 12 + 8 + len(json_padded) + 8 + len(bin_padded)
with open(output_path, 'wb') as f:
f.write(struct.pack('<I', 0x46546C67)) # glTF magic
f.write(struct.pack('<I', 2)) # version
f.write(struct.pack('<I', total_length))
f.write(struct.pack('<I', len(json_padded)))
f.write(struct.pack('<I', 0x4E4F534A)) # JSON chunk
f.write(json_padded)
f.write(struct.pack('<I', len(bin_padded)))
f.write(struct.pack('<I', 0x004E4942)) # BIN chunk
f.write(bin_padded)
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <input.mdl> [output.glb]")
sys.exit(1)
input_path = sys.argv[1]
if len(sys.argv) >= 3:
output_path = sys.argv[2]
else:
output_path = str(Path(input_path).with_suffix('.glb'))
mdl = parse_mdl(input_path)
print(f"MDL: {Path(input_path).name}")
print(f" Skin: {mdl['skinwidth']}x{mdl['skinheight']}, {len(mdl['skins'])} skin(s)")
print(f" Mesh: {mdl['num_verts']} verts, {mdl['num_tris']} tris")
print(f" Frames: {len(mdl['frames'])}")
build_glb(mdl, output_path)
print(f" -> {output_path}")
if __name__ == '__main__':
main()
@gcr
Copy link
Author

gcr commented Feb 16, 2026

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment