Skip to content

Quartermaster Model Learnings

LordOfMyatar edited this page May 24, 2026 · 3 revisions

Quartermaster Model Learnings

Summary of model types, rendering pipeline, and hard-won lessons from Quartermaster 3D preview development.

Home | Index | Quartermaster Developer Architecture

Table of Contents


MDL Node Types

Table of Contents

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)

Trimesh

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).

Skin Mesh

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.

Dangly Mesh

Trimesh + per-vertex constraints (0.0=free, 1.0=fixed), displacement, tightness, period. Physics simulation not implemented in preview — rendered as static trimesh.

Anim Mesh

Trimesh + per-frame vertex positions and texture coordinates. Only first frame rendered in static preview.

AABB Mesh

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).


Model Categories

Table of Contents

Determined by appearance.2da MODELTYPE column:

Part-Based Models (MODELTYPE contains "P")

Composite creatures assembled from individual body parts attached to skeleton bone positions.

Assembly process:

  1. Load skeleton model (base prefix, e.g., pfo0 for female orc phenotype 0)
  2. Load individual body parts: {prefix}_{partType}{number:D3} (e.g., pfo0_head001)
  3. Position parts at skeleton bone nodes (head_g, lbicep_g, etc.)
  4. 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).

Simple/Full Models (MODELTYPE "S" or other)

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).


Resource Resolution

Table of Contents

Load Order

Standard NWN resolution: Module Directory → Override → HAK → BIF via KEY

BIF-First Strategy

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).

Module-Aware HAK Scanning

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).

Cache Invalidation on Module Switch

Model and texture caches must be cleared when switching modules. Stale HAK configuration persisting across module loads causes texture corruption (bat wings reversed, #1867).


Rendering Pipeline

Table of Contents

OpenGL 3.3 via Silk.NET with GLSL ES 300 shaders.

Vertex Buffer Layout

8 floats per vertex: Position XYZ (3) + Normal XYZ (3) + TexCoord UV (2).

Transform Pipeline

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).

Projection

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).

Mesh Filtering

Skip conditions:

  • Render=false flag (#1754)
  • 0 vertices or 0 faces
  • Faces referencing out-of-bounds vertices
  • NaN vertices (replaced with zero, logged as warnings)

Lighting

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 Mesh Bone Data

Table of Contents

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.


Part-Based Model Assembly

Table of Contents

Texture Override Rule

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.

Seam Overlap Adjustment (#1557)

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

Composite Mesh Reparenting

Body part meshes must be reparented to prevent double-transform when applying hierarchy transforms (#1153).


Texture Handling

Table of Contents

Texture Types

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.

Fallback Chain

  1. Mesh bitmap name
  2. Model name (common for skin meshes with empty bitmaps)
  3. Remap failed texture to model texture if it loads
  4. Body part name-derived texture (for part-based models)
  5. Human fallback textures (for missing race-specific parts)

Body Texture Z-Fighting

Textures matching _body, _head, _neck, _hand patterns render behind armor to prevent z-fighting.


Bug Patterns and Fixes

Table of Contents

Binary Format Alignment (#1748, #1172)

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)

Parser Crashes (#1518)

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

Visual Bugs

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

Normal Source Selection (#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, matches cross(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.

Resource Resolution Bugs

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

Current Limitations

Table of Contents

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

Key Files

Table of Contents

Quartermaster

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

Radoub.Formats

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

Related PRs (Chronological)

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


Parley

Getting Started

User Guide

Features

Help


Manifest


Quartermaster


Relique


Reliquary


Fence

  • Fence - Merchant/Store Editor

Trebuchet


Shared Features


Developers

Parley Internals

Manifest Internals

Quartermaster Internals

Relique Internals

Reliquary Internals

Fence Internals

Marlinspike (Search Engine)

Trebuchet Internals

Radoub.UI


Radoub.Formats

Library

Low-Level Formats

High-Level Parsers


Legacy Bioware Docs

Original BioWare Aurora Engine file format specifications.

Core Formats

Object Blueprints

Module/Area Files

Reference


Page freshness: 2026-05-24

Index

Clone this wiki locally