Scaffold the per-repo configuration the engineering skills expect, and put
the three-way split of work into standing law:
- Live work -> wayfinder maps + Gitea issues (closeable, assignable,
queryable).
- Settled decisions -> ADR files in docs/adr/ (immutable, findable, never
closed).
- Standing law -> DOCTRINE.md / AGENTS.md / CONTEXT.md.
Adds docs/agents/{issue-tracker,triage-labels,domain}.md and an
"Agent skills" section in AGENTS.md. The tracker doc records Gitea via the
tea CLI, the wayfinder map/ticket/blocking operations, and the rule that
issues follow ownership: file work in the repo that owns it, not the one you
happen to be standing in.
ADR-0001 (markdown is reference, law, or an ADR - nothing else) is adopted
from sow-codebase so the rule is local to every repo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31 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. - "Raised" is a height, NOT a terrain type. Verified vs
tcn01: every raised corner is stillcobblewith…Height=1; only cobble ever carries a non-zero height (water/building stay at 0). Raise/Lower is the toolbar height tool acting on cobble corners — do not add araisedentry to[TERRAIN TYPES]. Making it paintable requires three things together:HasHeightTransition=1, a set of ungrouped transition tiles covering the cobble:0 ↔ cobble:1 corner patterns (ramps / retaining edges), and the[PRIMARY RULES]that let the brush step a corner's height. Any one missing and Raise/Lower is inert. - 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).
2.3 How a builder places tiles — TWO systems (this defines "paintable")
The toolset has two independent placement systems. Confusing them is why a palette can list tile names that refuse to place (the wgt01 2026-07-04 symptom).
System 1 — Paint Terrain / Paint Features (this lays the ground). The builder
picks a terrain (or a crosser) and brushes it across the grid. The toolset
then auto-selects which tile model to drop by matching the four corner
terrains + heights (and edge crossers) against the neighbours — the builder
never picks a specific model. This is how streets/plazas/canals actually get laid.
It needs, in the .set:
[TERRAIN TYPES]/[CROSSER TYPES]— the paintable vocabulary;- enough
[TILE#]entries that every corner combination the brush can produce is covered by at least one tile (else the brush has nothing to place); [GENERAL]:Default(terrain that fills a new area),Border(area edge),Floor(the terrain the eraser paints),HasHeightTransition=1(turns on Raise/Lower).[PRIMARY RULES]only refine auto-picking (cascading corner transitions) and are optional even for multi-terrain paint — verified: stockwut01.setpaints 6 terrains with[PRIMARY RULES] Count=0. What actually makes a terrain paintable is having a[TILE#]for every corner pattern the brush can request (all rotations), each ungrouped. Rules just smooth cascading auto-transitions; stocktcn01(City, 5 terrains + heights) ships 76 for polish, but you can shipCount=0and add them later. Eraser, Raise/Lower, and fill are built-in toolbar actions driven by those[GENERAL]fields — they are not palette entries.
The random-fill pool (verified vs tcn01, 2026-07-04). For a given corner
pattern the brush picks a random tile among the ungrouped tiles that match —
membership in any [GROUP#] removes a tile from that pool. So the ungrouped
same-corner tiles are the fill: the cobble the Default terrain spawns a new area
with, the variants the brush scatters, the tile the eraser resets to. Consequences
that bite:
- Grouping every tile empties the
Defaultpool → the New Area wizard null-derefs (Access violation … Write of 00000040). Keep ≥1 ungrouped tile per paintable corner pattern. - Every terrain/transition tile must stay UNGROUPED to take part in painting.
[GROUP#]is only for click-place prefabs (streets, bridges, statues) that the builder should not get at random. A "canal edge" you want the brush to auto-lay is a terrain tile (ungrouped), not a feature. tcn01's 513 tiles collapse to 58 distinct corner-tuples — the surplus is art variety. A geometry-only set needs roughly one tile per corner-tuple, and one model can serve all four rotations of a tuple as four[TILE#]entries differing only inOrientation(0/90/180/270) and the correspondingly-rotated corners/edges (the engine rotates model and walkmesh together).
System 2 — the palette (.itp): Features & Groups only. The right-side
"Standard" tree click-places prefabs: a feature (1×1) or a group (N×M). A
palette entry is placeable only if it is backed by a [GROUP#] in the .set —
the entry's RESREF is that group's first-tile model name and its NAME
matches the group's Name; the toolset locates the first tile in [GROUPS] and
pulls the rest from there. A palette entry pointing at a raw tile with no
[GROUP#] shows its name but cannot be placed.
Verified palette shape (wut01palstd.itp, GFF ITP ): MAIN = a few
category structs, each {ID byte, STRREF (dialog.tlk folder label), LIST};
leaves are {NAME cexostring, RESREF}. RESREF means different things per folder:
Folder (its STRREF) |
Leaf RESREF is… |
Holds |
|---|---|---|
| Terrain (8282) | the terrain/crosser name (Sand, Bridge, …), plus a magic eraser leaf (STRREF 63291, no NAME) |
the paintable terrains + crossers + eraser — i.e. the "Terrain" section a builder expects |
| Features (63261) | the feature's first-tile resref | 1×1 prefabs; NAME == its [GROUP#] Name |
| Groups (63262) | the group's first-tile resref | multi-tile prefabs (3×3, 4×1, 3×5…) |
So the "Terrain" section (paintable terrains + eraser) is category-2 of the
.itp — terrains listed by name with an eraser entry — not a list of models.
The three folder STRREFs above are stock dialog.tlk labels; reuse them.
wgt01's bug (root cause): its .itp was one flat folder of 40 raw tile
resrefs — no Terrain folder, no eraser, and the .set had [GROUPS] Count=0.
So none of the 40 entries is a feature/group → names render but nothing places; and
with no Terrain folder there's no paint/eraser UI at all. Fix: emit the
3-folder palette (Terrain+eraser / Features / Groups) and add [GROUP#] entries
for anything click-placeable, then lay ground via Paint Terrain, not by
clicking tiles. See wgt01/STATUS.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; 3 folders — Terrain+eraser / Features / Groups (§2.3). Feature/Group leaves must be backed by a [GROUP#]; Terrain leaves are terrain/crosser names. |
*.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. - Community authoring/inspection tools (not in-repo; handy for reverse-checking
our generated files against stock sets):
SetEditor 0.85 (Jlen)
— GUI
.seteditor; BioWare GFF Editor — edit.itp/GFF; NWN Explorer Reborn — browse/extract stock BIFs (pull a reference.set/.itp); Basic Interior Tileset Template (Symphony) — minimal working set to diff against. We can inspect any of these headlessly vianwn_gff/nwn_erfinstead of the GUIs. - 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.
6.3 Testing without a .hak (loose files)
For iteration, skip packing: the toolset reads loose resources from
~/Documents/Neverwinter Nights/development/. Copy the .set + all .mdl +
.wok (and, once authored, the .itp/minimaps/2das) there, then create a small
area with the tileset and walk-test. Much faster than pack→reload; pack the
.hak only once the set is validated.
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. - NeverBlender flattens multi-material tiles. A mesh with N materials exports
as one
trimeshwith a single arbitrarymaterialname— the per-face grammar is lost. NWN wants onetrimeshnode per texture. Either split the mesh by material into separate objects before export, or emit the.mdldirectly (wgt01'smake_mdl.pydoes the latter, in pure Python). - A tile
.mdlneedstverts+bitmap. No UV map → NeverBlender emits notverts; a node-based material with no image →materialname(a.mtrlookup) instead of a loadablebitmap. A trimesh with faces but no tverts/ bitmap won't load → blank tileset name + "could not create area" in the New Area wizard. Give each material abitmap <resref≤16>and ship the.tga. - Toolset needs
<set>palstd.itpto register a tileset. Every stock/custom set ships one (GFFITP:MAINlist of category structs, eachIDbyte +LISTof tile entries). Missing → set won't register. .itpentries need a localNAMEcexostring, not just aSTRREF. Sets without a tileset TLK give each leaf{NAME (cexostring), RESREF}(verified vswut01palstd.itp) — theNAMEis the palette label. An entry with onlySTRREF 0xFFFFFFFF(or no name field) renders blank.- A named
.itpleaf still won't place unless it's a real terrain or a[GROUP#]-backed prefab (§2.3). The palette only paints terrain or click-places features/groups; it does not place raw tile models. A flat palette of tile resrefs (wgt01's first cut) shows names but nothing is selectable/paintable — you need a Terrain folder (terrains-by-name +eraser) and[GROUPS]for any click-placeable tile. Ground is laid by Paint Terrain (corner-matching auto-picks the model), not by picking tiles. WalkMesh=in the.setis a label, not a file — 95k stock tiles usemsb01, others use author codes; per-tile walkmesh is matched by model name.- 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
- SET (file format) — nwn.wiki — terrain/crosser/rules/groups fields (§2.3, §4)
- Tile Path Nodes — nwn.wiki — pathnode types A–Z/a–z, exit-to-region, alignment
- Tutorial: Creating Custom Tiles (Mr X) — Neverwinter Vault — comprehensive walkthrough (Cloudflare-gated to WebFetch; open in a browser)
- Resources for custom NWN tileset creation — NWN Workshop — tool/tutorial index (SetEditor, GFF Editor, NWN Explorer, templates)
Verify specifics against the wiki before relying on them; correct this file when you find drift.
Agent skills
Issue tracker
Issues live in Gitea at git.westgate.pw (ShadowsOverWestgate/sow-module-tilesets), managed with the tea CLI. Issues follow ownership — file work in the repo that owns it, not the one you happen to be standing in. See docs/agents/issue-tracker.md.
Triage labels
Default label vocabulary (needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix). See docs/agents/triage-labels.md.
Domain docs
Single-context: CONTEXT.md at the repo root plus docs/adr/. See docs/agents/domain.md.
Where work lives
Markdown here is reference, law, or an ADR — nothing else (sow-codebase ADR-0001). Live work -> wayfinder maps + Gitea issues (closeable, assignable, queryable). Settled decisions -> ADR files in docs/adr/ (immutable, never closed, only superseded). Standing law -> DOCTRINE.md / AGENTS.md / CONTEXT.md. Current-state reference -> docs describing what the code does now. Everything else — plans, specs, concepts, handoffs, trackers — is process: it belongs in a Gitea issue, not a file. Harvest unfinished intent to an issue before deleting a process doc. sow-docs is deprecated and read-only.