Neverblender is installed + enabled in this machine's Blender 4.0.2 config and confirmed working headless via the flake. Add generator/export_mdl.py: assembles each tile as a Neverblender-native model (AuroraBase dummy/tile, trimesh, single aabb walkmesh) in its own scene and calls scene.nvb_mdlexport to emit real ASCII <resref>.mdl + <resref>.wok for all 40 tiles. Walkmesh surfacemats via create_wok_materials() slot order (walkable Stone=4, Nonwalk=7) -> verified correct per tile (CP1 all 4, CN1 all 7, BR_1/CE_S mixed). flake.nix: add `export` app (nix run .#export -- <blend> <outdir> [CODE...]). AGENTS.md: record the Neverblender node/surfacemat facts and the kit's tools; note the slot-index (not material-name) surfacemat gotcha. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
20 KiB
AGENTS.md — Building NWN:EE Tilesets
Operational guide for building Neverwinter Nights: Enhanced Edition tilesets in
this repo. Read this before touching any tileset directory. It bridges our
Blender-prototype conventions to the hard requirements of the NWN engine and
toolset, so geometry built here converts cleanly to a shippable .hak.
This is a reference, not a tutorial to read top-to-bottom. Jump to the section you need. When a fact here disagrees with the linked nwn.wiki pages (Sources), the wiki wins — tell the user and fix this file.
0. Repo layout
Each tileset lives in its own directory. One tileset = one 5-char prefix = one
.set + its models/walkmeshes/palette.
sow-module-tilesets/
├── AGENTS.md ← you are here (applies to all tilesets)
├── wgt01/ ← Westgate Urban Foundation MVP (streets, canals, ramps)
│ ├── KICKOFF.md ← design brief / tile list for this tileset (the spec)
│ ├── concept-art-1.png
│ ├── flake.nix ← Blender 4.0.2 + generator entry points
│ └── generator/ ← bpy scripts that build the .blend source
└── <future-tileset>/ ← same shape
- wgt01 is the urban foundation set: ground grammar only (streets, sidewalks, plazas, ramps, terraces, canals, bridges). Buildings/props are placeables layered on top later — not part of any tileset here.
- Other tilesets (interiors, dungeons, wilderness) may follow. They share these
rules; only their
KICKOFF.mdbrief and prefix differ. - A tileset's
KICKOFF.mdis authoritative for what tiles to build. This file is authoritative for how NWN needs them shaped and packaged.
1. Hard constraints (memorize these)
| Constraint | Value | Consequence if violated |
|---|---|---|
| Tile footprint | 10 m × 10 m on XY, centered at origin (X,Y ∈ [−5, +5]) | Misaligned adjacency, gaps |
| Ground level | Z = 0; walkable floor cannot go below 0 | — |
| Axes | Z up, X = West→East, Y = South→North | — |
| Resref (any model/wok/tga name) | ≤ 16 characters | Silent truncation / breakage |
| Tileset prefix | 5 chars, e.g. TWU01 (Tileset Westgate Urban 01) |
— |
| Walkmesh polycount | ~100 faces per tile ideal | GMax/exporter crash, broken AABB |
| Faces per walkmesh vertex | ≤ 8 | Export failure |
| Lights per tile | ≤ 2 mainlight + ≤ 2 sourcelight | — |
| Walkable faces | must face upward, single level, no overlap | Downward-facing walkable crashes the exporter and makes players jerk/hop |
| Walkmesh edges | exactly at ±5 m from center | Creatures can't cross tile boundary |
.set file |
INI text, DOS/CRLF line endings | Toolset crashes on area creation |
| Area size | ≤ 32 × 32 tiles | — |
| Module | ≤ 100 tilesets loaded | — |
Prototype-phase relief (from KICKOFF.md): geometry may be simple, materials
flat, exact NWN export is not required for the Blender MVP. But build with
these numbers in mind so conversion is mechanical, not a rework.
2. NWN tile fundamentals
2.1 Corners, edges, terrains, crossers
A tile connects to neighbors by matching corners and edges:
- 4 corners —
TopLeft, TopRight, BottomLeft, BottomRight. Each carries a terrain type (string, no whitespace) and a height (integer number of transition steps above 0). Two tiles are adjacency-compatible only if the shared corners' terrain and height match. - 4 edges —
Top, Right, Bottom, Left. Each carries an optional crosser (linear feature that splits the edge: road, stream, wall, canal). Blank = none.
Terrain = the surface a corner sits on (cobble, plaza, canal, void).
Crosser = a linear feature crossing an edge (street, sidewalk, canal_edge).
Both are referenced by name in the .set and .itp — names are
case-sensitive and cannot contain spaces.
Mixing terrains is expensive: every pair of terrains that can meet needs transition tiles.
- 2 terrains → ~6–7 tiles minimum (1 pure A, 1 pure B, 4–5 transitions).
- 3 terrains → +9 tiles per added terrain.
- 4 terrains → full matrix, substantially more. Keep the terrain/crosser vocabulary small and deliberate per tileset.
2.2 Heights
Transition(in[GENERAL]) = meters between height step 0 and step 1. Decimals allowed. BioWare original content used 5 m; custom sets use 2–3 m.- wgt01 uses a raised tier at Z = 1.5 m, so its
Transition = 1.5and the raised tiles sit at corner height1. - Walkable ground can only be raised in whole steps, never lowered below Z=0.
Visual water/void may dip (e.g. canal at Z = −0.3) only under a non-walkable
walkmesh — never a playable under/over space (matches
KICKOFF.md).
3. The tile model (.mdl)
ASCII MDL. Binary MDLs must be decompiled first. Structure:
3.1 AuroraBase (root dummy)
Mandatory root node. Node name must equal the filename (e.g. twu01_a01_01).
Set its classification/Type = Tile for any tile that has a .wok. Holds:
tile name, animation frames, supermodel link, and all child nodes.
3.2 Renderable geometry (trimesh)
The visible tile. Children of AuroraBase. Keep polys low, edges snap-clean at ±5 m, no geometry past bounds except explicitly-marked harmless trim.
3.3 Walkmesh (AABB node) — the .wok
Invisible collision + pathfinding mesh. Parented to AuroraBase only, exactly one AABB node per tile. Governs walkable/non-walkable triangles, surface material per triangle, water splashes, dynamic grass, camera collision, blood.
The 8 walkmesh rules (violating these crashes exporters or breaks pathing):
- ~100 faces max.
- ≤ 8 faces per vertex (non-walkable often exempt, still keep minimal).
- Single-level walkable — walkable-face vertices must not overlap other walkable faces. Non-walkable faces unrestricted.
- Walkable faces point up. Downward-facing walkable = exporter crash + player jerking. Downward non-walkable (ceilings) is fine.
- One AABB node only.
- No straight vertical edges (breaks no-overlap). Offset height changes ≥ 1 cm on XY per axis.
- Origin at tile center
(0,0,0), matching AuroraBase. No scale/rotation transforms — including on raised tiles. - Walkmesh edges at exactly ±5 m for cross-tile traversal. Sub-cm gaps usually still cross; larger gaps block.
Surface material types (per triangle; defined globally in surfacemat.2da,
sounds in footstepsounds.2da):
Dirt, Grass, Stone, Wood, Water, Carpet, Metal, Puddles, Leaves, Sand, Snow.
Non-walkable proxies use a non-walkable surfacemat (e.g. the "barebones" row that
blocks walking + camera but allows spell targeting). Surfacemat #3 (Grass)
triggers engine dynamic grass (configured in the .set [GRASS] section).
3.4 Lights (dummy nodes, not real lamps)
Named <tilename>ml1, <tilename>ml2 (mainlights — floating ambient, area-
controllable) and <tilename>sl1, <tilename>sl2 (sourcelights — tied to
torches/fires, respond to day/night). Max 2 of each. Colors restricted to
tilecolor.2da.
3.5 Animations (on AuroraBase)
Standard 50 frames @ 30 FPS:
animloop1/2/3 (independently toggleable mesh subsets), Day, Night,
Day2Night, Night2Day (1-hour transitions), tiledefault (optional fallback).
Bind individual mesh parts to an animloopN so one tile can serve multiple states.
3.6 A-node (transparency layer, optional)
Dummy <tilename>a parented to AuroraBase; its mesh children render in the
dynamic phase (after static meshes) for correct transparency ordering (glass,
foliage, water). Note: VFX particles are still occluded by A-node transparent
objects unless their texture's alpha = 0.
4. The .set file (INI, CRLF line endings)
Named <prefix>.set (e.g. twu01.set). Strict section order:
[GENERAL] → [GRASS] → Terrain Types → Crosser Types → Primary Rules → Secondary Rules → Tiles → Groups. Tiles are referenced by numeric index
here (the .itp references the same tiles by name — keep them in sync or
placement silently breaks).
[GENERAL]
Name (internal id, referenced by doortypes/loadscreens), Type=SET,
Version, Interior (0=exterior w/ weather+day/night, 1=interior),
HasHeightTransition (1 if raise/lower used), EnvMap (water env map),
Transition (meters/step, decimals ok), SelectorHeight (wireframe visual),
DisplayName (tlk ref) or UnlocalizedName, Border (edge terrain),
Default (terrain for new areas), Floor (eraser terrain).
[GRASS]
Grass (0/1), GrassTextureName, Density, Height (m),
Ambient{Red,Green,Blue}, Diffuse{Red,Green,Blue}.
Terrain / Crosser types
[TERRAIN TYPES] Count=N, then [TERRAIN0..N-1] each with Name (case-sensitive,
no spaces) and optional StrRef. [CROSSER TYPES] + [CROSSER#] identical shape.
Primary rules (toolset auto-picking)
[PRIMARY RULES] Count=N, then [PRIMARY RULE#] with
Placed, PlacedHeight, Adjacent, AdjacentHeight, Changed, ChangedHeight.
When a builder paints adjacent matching terrain, the toolset swaps in the
Changed tile automatically. [SECONDARY RULES] is unused in vanilla (Count=0).
[TILE#] (per-tile entry — the core)
[TILE0]
Model=twu01_a01_01 ; .mdl basename, ≤16 chars, == AuroraBase node name
WalkMesh=msb01 ; legacy walkmesh ref (BioWare default "msb01")
ImageMap2D=twu01_a01_01 ; minimap .tga/.dds, ≥16×16 px
TopLeft=cobble ; corner terrain
TopLeftHeight=0 ; corner height step
TopRight=cobble
TopRightHeight=0
BottomLeft=cobble
BottomLeftHeight=0
BottomRight=cobble
BottomRightHeight=0
Top=street ; edge crosser (blank if none)
Right=
Bottom=street
Left=
MainLight1=0 ; tilecolor.2da index
MainLight2=0
SourceLight1=0
SourceLight2=0
AnimLoop1=0
AnimLoop2=0
AnimLoop3=0
Orientation=0 ; rotation in ±90° steps; positive = counter-clockwise
PathNode=A ; pathfinding descriptor (A-Z,a-z, case-sensitive)
VisibilityNode= ; LOS; defaults to PathNode if blank (keep simpler)
Doors=0
Sounds=0
- PathNode is required. Without it, creatures and click-to-move break. Choose the simplest valid pattern that lets a creature cross the tile. NWN:EE added no new pathnode types — use the fixed set.
- VisibilityNode should usually be simpler than the pathnode (straight edge-to-edge). Omit to inherit the pathnode.
- Padding: reserve future slots with
Model=paddingand corners(null).
[TILE#_DOOR#] (only if that tile's Doors>0)
Type (doortypes.2da index; 0 = force generic from genericdoors.2da),
X/Y/Z (meters), Orientation (facing).
Groups / Features
[GROUPS] Count=N, [GROUP#] with Name, optional StrRef, Rows, Columns,
and Tile0.. indices ordered bottom-left → right → up → top-right. A
1-tile group is a feature. Rules:
- First tile must have a unique filename (ITP detects the group by it) and
cannot be
-1(random) — that crashes. - Other positions may use
-1for a randomized part. - Whole group rotates as a unit; no per-tile orientation.
- Any non-group tile sharing the first tile's filename is blocked from autopicking.
5. Supporting files (packaged into the .hak)
| File | Purpose | Notes |
|---|---|---|
<prefix>.set |
tileset definition | CRLF, references tiles by index |
<prefix>palstd.itp |
toolset palette tree | GFF binary; references tiles by name — must match .set |
*.mdl |
tile models (ASCII) | one per tile/variant |
*.wok |
tile walkmeshes | baked from the AABB node |
*.tga / *.dds |
minimap icons (ImageMap2D) |
≥16×16 px, name ≤16 chars |
<prefix>edge.2da |
edge/border tiles (optional) | boundary tiles, grayed + non-walkable, prefix often z |
doortypes.2da |
tileset-specific doors | mandatory if custom doors — must be merged with all other sets' versions and shipped to clients, or doors render blank |
genericdoors.2da |
tileset-agnostic doors | index 0 = player picks |
surfacemat.2da |
global walkmesh surface behavior | only edit if adding materials |
footstepsounds.2da |
per-surface audio | |
tilecolor.2da |
allowed light colors | mainlight/sourcelight picker source |
loadscreens.2da |
area load screens (optional) | TileSet column binds images |
areag.ini |
generic area presets (optional) | ambient sound, music, scripts, PvP, interior flag |
Name-sync gotcha: .set = tiles by index, .itp = tiles by name.
Any mismatch breaks placement silently. Regenerate both from one source of truth.
6. Blender → NWN pipeline
This repo standardizes on Blender 4.0.2 (blender-402-bin, exposed by the
NixOS overlay) specifically because the NWN toolchain (NeverBlender, cleanmodels)
tracks Blender 3.x–4.x, not nixpkgs' Blender 5.x. Do not "upgrade" it.
6.1 Tools (all on PATH in the dev shell)
- NeverBlender — Blender addon that exports NWN
.mdl+.wokand defines node types (trimesh, AABB walkmesh, dummy, light). It is not bundled in the stock binary, but is installed and enabled in this machine's Blender 4.0.2 user config (~/.config/blender/4.0) and works headless — verified via the flake'sblender-4.0.2 --background.wgt01/generator/export_mdl.pydrives it (see below). If working on another machine, don't assume it's present; checkaddon_utils.enable("neverblender")and tell the user. Key facts: root = Emptynvb.emptytype='dummy'+nvb.classification='tile'(name == resref); geometry meshesnvb.meshtype='trimesh'; one walkmesh meshnvb.meshtype='aabb'. Walkmesh face surfacemat = the mesh's material slot index afternvb_utils.create_wok_materials(mesh)fills slots 0..22 innvb_def.wok_materialsorder (walkable Stone=4, Nonwalk=7). Export:scene.nvb_mdlexport(filepath=..., export_walkmesh=True)on the active scene. - cleanmodels — cleans/optimizes ASCII MDL, fixes common walkmesh problems.
- neverwinter-nim — CLI (
nwn_erf,nwn_gff, …): pack the.hak/ERF, and read/write GFF (the.itpis GFF). - blender-mcp-nwn (see
/nix/nixos/blender-mcp-nwn) — MCP server exposingrun_blender_script,read_blend_metadata,export_for_nwnso Claude can drive Blender 4.0.2 headless against these.blendsources. - wgt01 generator kit (
wgt01/generator/, driven bywgt01/flake.nixapps):build.py(nix run .#build) builds the.blendtile library from the sharedtiles.pytable;export_mdl.py(nix run .#export -- <blend> <outdir>) exports real.mdl+.wokper tile via NeverBlender;make_wok.py(nix run .#wok) andmake_set.py(nix run .#set) are pure-Python (no Blender) emitters — an ASCII.wokfallback (format validated against NeverBlender's own output) and a first-pass.set.
6.2 Build → export → pack
- Generate
.blendsource —nix run .#build -- out.blend(or drive via the MCP server). Produces the tile library scene. - Model in NWN conventions — one AuroraBase per tile (name == resref, Type
Tile), trimesh children, one AABB walkmesh, ≤2+2 lights, animations on base. - Export — NeverBlender → ASCII
.mdl+.wokper tile. - Clean — run through
cleanmodels(walkmesh sanity, tri limits). - Author
.set— terrains, crossers, tiles (corners/edges/heights/pathnodes), groups. CRLF endings. - Author
.itp— palette tree (GFF), names matching the.set. - Minimaps — render/emit
ImageMap2D.tga/.ddsper tile. - Pack —
nwn_erfbundles.mdl+.wok+.set+.itp+ minimaps + any 2das into<prefix>.hak. Shipdoortypes.2dato clients if custom doors.
7. Mapping our prototype conventions → NWN
KICKOFF.md prototypes deliberately mirror NWN so conversion is mechanical:
| Prototype convention | NWN target |
|---|---|
| Tile centered at origin, X/Y ∈ [−5,+5], Z=0 ground, metric | Exactly NWN tile space |
Raised tier Z = 1.5 |
Height step 1 with Transition = 1.5 |
Canal water Z = −0.3, void |
Visual only, under a non-walkable walkmesh |
Collection CODE_name (e.g. ST_S_straight_street) |
Design ID → export resref <prefix>_A01_01 (grid position). Keep a code→resref map. |
VIS_<code> objects |
Renderable trimesh children of AuroraBase |
WOK_<code> objects |
The AABB walkmesh; assign walkable surfacemat (Stone/Cobble→Stone) |
BLOCK_<code> objects |
Non-walkable triangles in the walkmesh (blocker surfacemat) |
MARK_<code>_* / TILE_<code> empties |
Helpers/edge metadata — dropped or become dummies on export |
MAT_debug_walkmesh (green) |
Walkable surface material |
MAT_debug_blocker (red) |
Non-walkable surface material |
Edge metadata N/E/S/W ∈ {FLAT,RAISED,CANAL,RAMP,MIXED} |
.set corner terrain+height (FLAT=h0, RAISED=h1) and edge crossers (CANAL=non-walkable edge, RAMP=transition tile). Document each MIXED edge. |
TILE_EDGE_METADATA text block |
Source for authoring .set corners/edges |
wgt01 suggested prefix: TWU01 (Tileset Westgate Urban 01). Terrains e.g.
cobble, plaza, foundation, canal, void, raised; crossers e.g.
street, sidewalk, curb, gutter, canal_edge, ramp.
8. New-tileset checklist
- Create
<name>/with aKICKOFF.mdbrief (tiles, edge grammar, height plan). - Pick a unique 5-char prefix; define the terrain + crosser vocabulary (keep it minimal — every terrain pairing multiplies tile count, §2.1).
- Choose
Transition(m) and whetherInterior=0/1. - Map the pathnode type for each tile shape before modeling.
- Copy the
flake.nix+generator/pattern fromwgt01; adjust prefix/tiles. - Build
.blend, verify withread_blend_metadata(collection/object counts). - Export (NeverBlender) → clean (cleanmodels) → author
.set/.itp→ minimaps. - Pack
.hakwithnwn_erf; test in the toolset (create a small area, place every tile, walk it).
9. Gotchas (the stuff that silently breaks)
.setmust be CRLF. Unix line endings crash the toolset on area creation..setindex vs.itpname mismatch → tiles won't place, no error.- Resref > 16 chars truncates silently — plan short prefixes/positions.
- AuroraBase name ≠ filename → tile won't load correctly.
- Downward-facing walkable face → exporter crash + player jerking. Keep walkable faces up, single-level, non-overlapping.
- Vertical walkmesh edges / >100 walkmesh faces → broken AABB.
- Custom doors not in
doortypes.2da(and not merged/shipped) → blank doors. Transition=0disables adjacent placement of groups/features.- Group first tile
-1or non-unique filename → crash / no autopick. - NeverBlender walkmesh surfacemat comes from the face's material slot
index, not the material name — you must call
nvb_utils.create_wok_materials(mesh)first (fills slots 0..22) then set each face'smaterial_indexto the surfacemat id. Naming a lone materialwok_Stoneis silently ignored (falls back to slot order).export_mdl.pydoes this right. - NeverBlender not installed (other machines) →
export_for_nwngives you OBJ/FBX + anmdl.jsonconfig, not a real.mdl. Usemake_wok.pyfor an ASCII.wokfallback, or install the addon; say so rather than claiming MDL. - Don't upgrade Blender past 4.x here — breaks NeverBlender/cleanmodels.
Sources
- Tileset Construction Tutorial — nwn.wiki
- SET (file format) — nwn.wiki
- Tilesets — nwn.wiki
- NeverBlender — nwn.wiki
- Walkmesh — NWNWiki
- Tile — NWNWiki
- Common Errors and Their Causes — nwn.wiki
Verify specifics against the wiki before relying on them; correct this file when you find drift.