-
Notifications
You must be signed in to change notification settings - Fork 0
Quartermaster Model Learnings
Summary of model types, rendering pipeline, and hard-won lessons from Quartermaster 3D preview development.
Home | Index | Quartermaster Developer Architecture
- MDL Node Types
- Model Categories
- Resource Resolution
- Rendering Pipeline
- Skin Mesh Bone Data
- Part-Based Model Assembly
- Texture Handling
- Bug Patterns and Fixes
- Current Limitations
- Key Files
Binary MDL files use flag-based node type detection. Priority order: AABB > Dangly > Anim > Skin > Trimesh > Light > Emitter > Reference > Dummy.
All mesh types inherit from MdlTrimeshNode and share a 512-byte (0x200) mesh header.
| Type | Flag | Extension Size | Purpose | Static Preview |
|---|---|---|---|---|
| Trimesh | 0x00000021 | None | Basic textured triangle mesh | Full render |
| Skin | 0x00000061 | 0x64 bytes | Skeletal animation mesh | Bind-pose only (no bone weighting) |
| Dangly | 0x00000121 | 0x0C bytes | Physics mesh (hair, cloth, flags) | Static (no physics sim) |
| Anim | 0x000000A1 | Variable | Vertex animation frames | First frame only |
| AABB | 0x00000221 | Variable | Collision detection tree | Skipped (not rendered) |
Core mesh type. Vertices, normals, texture coordinates, faces, material properties. All other mesh types extend this.
Vertex data in raw section: position (3 floats), normal (3 floats), UV (2 floats per set, up to 4 sets), vertex colors (RGBA uint32).
Trimesh + bone weight/index arrays + inverse bind-pose transforms. Critical lesson: For static preview, use bind-pose vertices directly (same as trimesh). The inverse bind matrices (m_aQBoneRefInv/m_aTBoneRefInv) are for runtime animation only. Learned by comparing against NWNExplorer's approach (#1519).
Two separate raw data arrays (NOT interleaved):
-
m_pafSkinWeights: 4 floats per vertex (16 bytes) -
m_pasSkinBoneRefs: 4 int16 per vertex (8 bytes)
Early assumption of interleaved 24-byte records was wrong and caused data misalignment.
Trimesh + per-vertex constraints (0.0=free, 1.0=fixed), displacement, tightness, period. Physics simulation not implemented in preview — rendered as static trimesh.
Trimesh + per-frame vertex positions and texture coordinates. Only first frame rendered in static preview.
Trimesh + binary bounding box tree for collision. 40 bytes per tree node. Not rendered — collision geometry only. Tree parsing needs cycle detection and depth limit (max 64 levels).
Determined by appearance.2da MODELTYPE column:
Composite creatures assembled from individual body parts attached to skeleton bone positions.
Assembly process:
- Load skeleton model (base prefix, e.g.,
pfo0for female orc phenotype 0) - Load individual body parts:
{prefix}_{partType}{number:D3}(e.g.,pfo0_head001) - Position parts at skeleton bone nodes (
head_g,lbicep_g, etc.) - Apply armor overrides (chest armor can replace body parts)
Fallback: Missing race-specific parts fall back to human models (pmh0_ or pfh0_).
Body part value 0 = invisible (always skipped, overrides armor appearance).
Single MDL file loaded directly. Includes most non-humanoid creatures.
486 standalone models exist in base game; 146 use Render=false meshes that must be honored (#1754).
Standard NWN resolution: Module Directory → Override → HAK → BIF via KEY
LoadModelPreferBIF() and LoadTexturePreferBIF() try BIF first, then fall back to full resolution. Reason: CEP HAK files contain MDL format variants that can crash the parser or produce visual corruption (#1314, #1867).
Read module.ifo HakList to determine active HAKs — scan only those, not all 80+ HAK files on disk. UpdateHakPaths() updates resolver in-place while preserving KEY/BIF index (#1314).
Model and texture caches must be cleared when switching modules. Stale HAK configuration persisting across module loads causes texture corruption (bat wings reversed, #1867).
OpenGL 3.3 via Silk.NET with GLSL ES 300 shaders.
8 floats per vertex: Position XYZ (3) + Normal XYZ (3) + TexCoord UV (2).
World transform = accumulated S × R × T (Scale × Rotation × Translation) from node up to hierarchy root. Applied to vertices and normals.
Matrix convention: System.Numerics uses row-vector (vM). GLSL uses column-vector (Mv). Uniforms must be transposed (#1205).
30° FOV perspective projection. Camera auto-distances so model fills ~90% of view height at zoom=1. Z-axis up (matches NWN coordinate system).
Orthographic was wrong: Caused perspective distortion during rotation. Switched to perspective projection (#1205).
Skip conditions:
-
Render=falseflag (#1754) - 0 vertices or 0 faces
- Faces referencing out-of-bounds vertices
- NaN vertices (replaced with zero, logged as warnings)
Two-sided: abs(dot(norm, lightDir)) — required for thin single-layer polygons (bat wings, dragon membranes) that Aurora Engine renders from both directions (#1867).
Gamma correction: pow(color, 1/1.6) + improved ambient to match NWN toolset brightness (#1557).
Backface culling disabled: NWN models have inconsistent winding order.
Skin extension header (0x64 bytes at meshHeader + 0x200):
| Offset | Type | Field | Purpose |
|---|---|---|---|
| 0x00 | CNwnArray | m_aWeights | String-based weight list (skipped) |
| 0x0C | uint32* | m_pafSkinWeights | Raw data: 4 floats/vertex |
| 0x10 | uint32* | m_pasSkinBoneRefs | Raw data: 4 int16/vertex |
| 0x14 | uint32* | m_pasNodeToBoneMap | Model data: int16 array |
| 0x18 | uint32 | m_ulNodeToBoneCount | NodeToBoneMap entry count |
| 0x1C | uint32 | qBoneArrayOffset | Quaternion array offset |
| 0x20 | uint32 | qBoneCount | Quaternion count |
| 0x28 | uint32 | tBoneArrayOffset | Translation array offset |
| 0x2C | uint32 | tBoneCount | Translation count |
Quaternion storage: (W,X,Y,Z) on disk → converted to Quaternion(X,Y,Z,W) in code.
NodeToBoneMap: Maps node indices to bone slot indices. Per-vertex bone references are node indices that go through this map to index into the quaternion/translation arrays.
Key lesson: These transforms are inverse bind-pose for animation. Static preview ignores them entirely — vertices are already in bind-pose space.
Body part textures are derived from the model name, not the stored bitmap field. Body part MDLs often contain stale/garbage texture data.
Convention: {basePrefix}_{partType}{number:D3} (e.g., pfo0_head001).
Human fallback parts use human texture prefix.
NWN relies on skeletal deformation for seamless joints. Static rigid mesh preview exposes gaps.
Solution: Measure vertex overlap at head/neck and neck/chest pairs. Nudge parts closer when overlap < 0.10 world units.
Measured overlaps by race:
| Race | Overlap (world units) |
|---|---|
| Human | 0.112 |
| Dwarf | 0.090 |
| Elf | 0.048 |
| Halfling | ~0.05 |
Body part meshes must be reparented to prevent double-transform when applying hierarchy transforms (#1153).
| Format | Notes |
|---|---|
| PLT | Palette texture — must flip vertically for OpenGL. Color channels for tinting. |
| TGA | Standard image. Type 3/11 = grayscale (needed for ghost creatures). |
| DDS | BioWare proprietary variant — NO standard header. Custom implementation required. |
- Mesh bitmap name
- Model name (common for skin meshes with empty bitmaps)
- Remap failed texture to model texture if it loads
- Body part name-derived texture (for part-based models)
- Human fallback textures (for missing race-specific parts)
Textures matching _body, _head, _neck, _hand patterns render behind armor to prevent z-fighting.
| Issue | Root Cause | Fix |
|---|---|---|
| Scrambled vertex data | Header field 2 is model data SIZE, not file offset. Raw section starts at 12 + modelDataSize
|
12-byte offset correction |
| Faceted shading | Normal offsets not adjusted by avgNormalSkip
|
Apply same skip to normals as vertices |
| Wrong UV mapping | UV offsets not adjusted by avgNormalSkip
|
Apply same skip to UVs |
| Skin data misalignment | Assumed interleaved 24-byte bone records | Two separate arrays (16-byte weights + 8-byte refs) |
| Issue | Root Cause | Fix |
|---|---|---|
| StackOverflow on c_kocrachn | Circular node tree references | Depth limit (128) + cycle detection |
| AABB infinite recursion | Malformed collision tree | Depth limit (64) + cycle detection |
| Null pointer dereference | Pointer value 0 not treated as NULL | Explicit 0-check for all pointers |
| ASCII face corruption | Split vertex/tvert indexing wrong | Correct separate-array indexing |
| Issue | Root Cause | Fix | PR |
|---|---|---|---|
| Limb distortion on rotation | Orthographic projection | 30° FOV perspective | #1517 |
| Elf head/neck gap | No skeletal deformation in static preview | Seam overlap nudging | #1583 |
| Dragon wings detached | Not rendering skin mesh correctly | Use bind-pose vertices, skip bone weighting | #1748 |
| Bat wings reversed | Stale HAK textures after module switch | Clear caches on module load | #1877 |
| Ghost creatures invisible | Grayscale TGA not supported | Add image type 3/11 parsing | #1451 |
| Too dark compared to toolset | No gamma correction | pow(1/1.6) + ambient boost | #1583 |
| 146 models render wrong | Render=false meshes displayed | Honor MDL Render flag | #1866 |
| Head shading misaligned / faceted bodies | Wrong normal source per mesh kind; too-strong directional light | Per-mesh strategy (smoothgroup-aware vs stored) + vertex welding + texture-dominant lighting | #2026 |
NWN MDL encodes surface smoothing in two incompatible ways. Picking the wrong data source for a given mesh produces flat/faceted shading or misaligned shadow bands.
Data present on every face record:
-
PlaneNormal(3 floats) — face normal, matchescross(b-a, c-a)within fp precision (verified across 206/206 head faces) -
SurfaceId(uint32) — smoothing-group bitmask; faces sharing at least one bit should blend at shared vertices
Data present per vertex:
- Stored
Normals[]array — BioWare compiler output, reliability varies by mesh
Distinct patterns observed across body parts on pfh0_*:
| Part | Verts | Faces | Distinct SurfaceIds | Hard edges encoded via |
|---|---|---|---|---|
| head001 | 166 | 206 | 2 (0x1, 0x2) |
Smoothgroup bitmasks |
| neck001 | 20 | 16 | 1 | Stored per-vertex normals |
| chest001 | 132 | 90 | 1 | Stored per-vertex normals (vertex duplication) |
| pelvis001 | 118 | 82 | 1 | Stored per-vertex normals (vertex duplication) |
| handl001 | 28 | 16 | 6 | Smoothgroup bitmasks |
| footl001 | 32 | 20 | 1 | Stored per-vertex normals |
Multi-smoothgroup meshes (heads, some extremities): per-vertex normals stored in the file are unreliable — pfh0_head001 has ~27% of mirror-pair vertices with stored normals that don't mirror each other; some deltas exceed 1.0 (essentially random directions). These are a 2002-era BioWare compiler artifact from the mirrored-half authoring workflow. Compute from face PlaneNormal + SurfaceId instead — faces in the same smoothgroup blend, faces in disjoint groups stay hard-edged.
Single-smoothgroup meshes (most body parts): all faces share SurfaceId=0x1. The author encodes intentional hard edges (shoulder/waist/elbow seams) by duplicating vertices at the same position with different stored normals. Topology-averaging would flatten those creases — use the stored data as-is.
Detection rule: distinct_smoothgroups > 1 → compute from smoothgroups, otherwise trust stored.
Welding step: after selecting the normal source, deduplicate face-corners by (position, normal, UV) triple so matching corners share a GPU vertex (normal interpolates across the shared edge) while disjoint smoothgroups and UV seams remain split. Equivalent to what Aurora does at load time.
Implementation: SmoothGroupNormals.ComputePerCorner + VertexWelder.Build in Quartermaster/Controls/.
Lighting coupling: NWN textures carry painted-in shading (skin highlights, fabric folds). Strong directional lighting on top of that fights the texture and exposes every low-poly facet. Aurora's own toolset runs with near-full ambient (~0.95) and minimal directional (~0.15); Radoub matches that tuning.
| Issue | Root Cause | Fix | PR |
|---|---|---|---|
| CEP creatures crash parser | HAK MDL variants incompatible | BIF-first loading strategy | #1673 |
| Wrong textures after module switch | HAK config not cleared | Clear caches + reconfigure HAKs on load | #1877 |
| All appearances shown when no filter | Logic inversion in checkbox state | Fix filter predicate | #1866 |
| Feature | Status | Notes |
|---|---|---|
| Skeletal animation | Not implemented | Skin meshes render in bind-pose only |
| Physics simulation | Not implemented | Dangly meshes rendered static |
| Vertex animation | Not implemented | Anim meshes use first frame only |
| Backface culling | Disabled | NWN models have inconsistent winding |
| CEP model parsing | Partial | BIF-first strategy avoids most issues |
| File | Lines | Purpose |
|---|---|---|
Services/ModelService.cs |
827 | Model loading, part assembly, caching |
Controls/ModelPreviewGLControl.cs |
1257 | OpenGL rendering, shaders, mesh buffers |
Services/TextureService.cs |
651 | PLT/TGA/DDS loading, BIF-first resolution |
| File | Purpose |
|---|---|
Mdl/MdlStructures.cs |
Node type enums, mesh data structures |
Mdl/MdlBinaryReader.cs |
Main binary MDL parser entry point |
Mdl/MdlBinaryReader.MeshParsing.cs |
Mesh header + type-specific extension parsing |
Mdl/MdlBinaryReader.NodeParsing.cs |
Node tree traversal, type detection |
Mdl/MdlBinaryReader.PointerHelpers.cs |
Pointer-to-offset conversion (model data vs raw data) |
Mdl/MdlBinaryReader.DataReading.cs |
Low-level data extraction |
| PR | Issue | Summary |
|---|---|---|
| #766 | #617 | Initial 3D preview implementation |
| #1172 | #1171 | MDL format restart (offset alignment fixes) |
| #1451 | #1153 | Texture/coloring fixes, DDS support |
| #1517 | #1205 | Perspective projection fix |
| #1583 | #1557 | Elf head/neck gap, seam overlap |
| #1673 | #1314 | Module-aware HAK scanning, BIF-first |
| #1680 | #1518 | Parser crash hardening |
| #1748 | #1519 | Skin mesh (dragon wings) fix |
| #1866 | #1754 | Standalone model Render flag |
| #1877 | #1867 | CEP texture cache invalidation |
Page freshness: 2026-05-24
Getting Started
User Guide
Features
Help
- Manifest - Journal Editor
- Quartermaster - Creature/Inventory Editor
- Relique - Item Editor
- Reliquary - Placeable Editor (Alpha)
- Fence - Merchant/Store Editor
- Trebuchet - Radoub Launcher
- Marlinspike - Search and Replace
- Spell Check - Dictionary-based spell checking
- Token System - Dialog tokens and custom colors
Parley Internals
Manifest Internals
Quartermaster Internals
Relique Internals
Reliquary Internals
Fence Internals
Marlinspike (Search Engine)
Trebuchet Internals
Radoub.UI
Library
Low-Level Formats
High-Level Parsers
- JRL Format (.jrl)
- UTI Format (.uti) - Item blueprints
- UTC Format (.utc) - Creature blueprints
- UTM Format (.utm) - Store blueprints
- UTP Format (.utp) - Placeable blueprints
- UTD Format (.utd) - Door blueprints
- ARE Format (.are) - Area properties
- BIC Format (.bic) - Player characters
Original BioWare Aurora Engine file format specifications.
Core Formats
- GFF Format - Generic File Format
- KEY/BIF Format - Resource archives
- ERF Format - Encapsulated resources
- TLK Format - Talk tables
- 2DA Format - Data tables
- Localized Strings
- Common GFF Structs
Object Blueprints
- Creature Format (.utc)
- Item Format (.uti)
- Store Format (.utm)
- Door/Placeable (.utd/.utp)
- Encounter Format (.ute)
- Sound Object (.uts)
- Trigger Format (.utt)
- Waypoint Format (.utw)
Module/Area Files
- Conversation Format (.dlg)
- Journal Format (.jrl)
- Area File Format (.are/.git/.gic)
- Module Info (.ifo)
- Faction Format (.fac)
- Palette/ITP Format (.itp)
- SSF Format - Sound sets
Reference
Page freshness: 2026-05-24