Files
sow-module-tilesets/AGENTS.md
T
2026-07-05 01:46:27 +02:00

525 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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.md` brief and prefix differ.
- A tileset's `KICKOFF.md` is 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 → ~67 tiles minimum (1 pure A, 1 pure B, 45 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 **23 m**.
- **wgt01 uses a raised tier at Z = 1.5 m**, so its `Transition = 1.5` and the
raised tiles sit at corner height `1`.
- **"Raised" is a height, NOT a terrain type.** Verified vs `tcn01`: every raised
corner is still `cobble` with `…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 a `raised` entry 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: stock `wut01.set` paints **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; stock `tcn01` (City,
5 terrains + heights) ships **76** for polish, but you can ship `Count=0` and 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 `Default` pool → 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 in `Orientation` (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 `STRREF`s 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):
1. ~100 faces max.
2. ≤ 8 faces per vertex (non-walkable often exempt, still keep minimal).
3. Single-level walkable — walkable-face vertices must not overlap other
walkable faces. Non-walkable faces unrestricted.
4. Walkable faces point **up**. Downward-facing walkable = exporter crash +
player jerking. Downward non-walkable (ceilings) is fine.
5. One AABB node only.
6. No straight vertical edges (breaks no-overlap). Offset height changes ≥ 1 cm
on XY per axis.
7. Origin at tile center `(0,0,0)`, matching AuroraBase. No scale/rotation
transforms — including on raised tiles.
8. 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=padding` and 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 `-1` for 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.x4.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` + `.wok` and 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's `blender-4.0.2 --background`. `wgt01/generator/export_mdl.py` drives it
(see below). If working on another machine, don't assume it's present; check
`addon_utils.enable("neverblender")` and tell the user.
Key facts: root = Empty `nvb.emptytype='dummy'` + `nvb.classification='tile'`
(name == resref); geometry meshes `nvb.meshtype='trimesh'`; one walkmesh mesh
`nvb.meshtype='aabb'`. Walkmesh face surfacemat = the mesh's material *slot*
index after `nvb_utils.create_wok_materials(mesh)` fills slots 0..22 in
`nvb_def.wok_materials` order (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 `.itp` is GFF).
- **blender-mcp-nwn** (see `/nix/nixos/blender-mcp-nwn`) — MCP server exposing
`run_blender_script`, `read_blend_metadata`, `export_for_nwn` so Claude can
drive Blender 4.0.2 headless against these `.blend` sources.
- **Community authoring/inspection tools** (not in-repo; handy for reverse-checking
our generated files against stock sets):
[SetEditor 0.85 (Jlen)](https://neverwintervault.org/project/nwn1/other/tool/set-editor-beta-085)
— GUI `.set` editor; [BioWare GFF Editor](https://neverwintervault.org/project/nwn1/other/tool/gffeditor)
— edit `.itp`/GFF; [NWN Explorer Reborn](https://neverwintervault.org/project/nwn1/other/tool/nwn-explorer-reborn)
— browse/extract stock BIFs (pull a reference `.set`/`.itp`);
[Basic Interior Tileset Template (Symphony)](https://neverwintervault.org/project/nwn1/hakpak/tileset/basic-interior-tileset-template)
— minimal working set to diff against. We can inspect any of these headlessly via
`nwn_gff` / `nwn_erf` instead of the GUIs.
- **wgt01 generator kit** (`wgt01/generator/`, driven by `wgt01/flake.nix` apps):
`build.py` (`nix run .#build`) builds the `.blend` tile library from the shared
`tiles.py` table; `export_mdl.py` (`nix run .#export -- <blend> <outdir>`)
exports real `.mdl`+`.wok` per tile via NeverBlender; `make_wok.py`
(`nix run .#wok`) and `make_set.py` (`nix run .#set`) are pure-Python (no
Blender) emitters — an ASCII `.wok` fallback (format validated against
NeverBlender's own output) and a first-pass `.set`.
### 6.2 Build → export → pack
1. **Generate `.blend` source**`nix run .#build -- out.blend` (or drive via the
MCP server). Produces the tile library scene.
2. **Model in NWN conventions** — one AuroraBase per tile (name == resref, Type
`Tile`), trimesh children, one AABB walkmesh, ≤2+2 lights, animations on base.
3. **Export** — NeverBlender → ASCII `.mdl` + `.wok` per tile.
4. **Clean** — run through `cleanmodels` (walkmesh sanity, tri limits).
5. **Author `.set`** — terrains, crossers, tiles (corners/edges/heights/pathnodes),
groups. **CRLF endings.**
6. **Author `.itp`** — palette tree (GFF), names matching the `.set`.
7. **Minimaps** — render/emit `ImageMap2D` `.tga`/`.dds` per tile.
8. **Pack**`nwn_erf` bundles `.mdl` + `.wok` + `.set` + `.itp` + minimaps +
any 2das into `<prefix>.hak`. Ship `doortypes.2da` to 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
1. Create `<name>/` with a `KICKOFF.md` brief (tiles, edge grammar, height plan).
2. Pick a unique **5-char prefix**; define the **terrain + crosser vocabulary**
(keep it minimal — every terrain pairing multiplies tile count, §2.1).
3. Choose `Transition` (m) and whether `Interior=0/1`.
4. Map the **pathnode** type for each tile shape *before* modeling.
5. Copy the `flake.nix` + `generator/` pattern from `wgt01`; adjust prefix/tiles.
6. Build `.blend`, verify with `read_blend_metadata` (collection/object counts).
7. Export (NeverBlender) → clean (cleanmodels) → author `.set`/`.itp` → minimaps.
8. Pack `.hak` with `nwn_erf`; test in the toolset (create a small area, place
every tile, walk it).
---
## 9. Gotchas (the stuff that silently breaks)
- **`.set` must be CRLF.** Unix line endings crash the toolset on area creation.
- **`.set` index vs `.itp` name 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=0`** disables adjacent placement of groups/features.
- **Group first tile `-1` or 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's `material_index` to the surfacemat id. Naming a lone material `wok_Stone`
is silently ignored (falls back to slot order). `export_mdl.py` does this right.
- **NeverBlender not installed** (other machines) → `export_for_nwn` gives you
OBJ/FBX + an `mdl.json` config, **not** a real `.mdl`. Use `make_wok.py` for an
ASCII `.wok` fallback, or install the addon; say so rather than claiming MDL.
- **NeverBlender flattens multi-material tiles.** A mesh with N materials exports
as **one** `trimesh` with a single arbitrary `materialname` — the per-face
grammar is lost. NWN wants one `trimesh` node **per texture**. Either split the
mesh by material into separate objects before export, or emit the `.mdl`
directly (wgt01's `make_mdl.py` does the latter, in pure Python).
- **A tile `.mdl` needs `tverts` + `bitmap`.** No UV map → NeverBlender emits no
`tverts`; a node-based material with no image → `materialname` (a `.mtr`
lookup) instead of a loadable `bitmap`. 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 a `bitmap <resref≤16>` and ship the `.tga`.
- **Toolset needs `<set>palstd.itp`** to register a tileset. Every stock/custom
set ships one (GFF `ITP `: `MAIN` list of category structs, each `ID` byte +
`LIST` of tile entries). Missing → set won't register.
- **`.itp` entries need a local `NAME` cexostring, not just a `STRREF`.** Sets
without a tileset TLK give each leaf `{NAME (cexostring), RESREF}` (verified
vs `wut01palstd.itp`) — the `NAME` is the palette label. An entry with only
`STRREF 0xFFFFFFFF` (or no name field) renders **blank**.
- **A named `.itp` leaf 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 `.set` is a label, not a file** — 95k stock tiles use
`msb01`, 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](https://nwn.wiki/display/NWN1/Tileset+Construction+Tutorial)
- [SET (file format) — nwn.wiki](https://nwn.wiki/display/NWN1/SET)
- [Tilesets — nwn.wiki](https://nwn.wiki/display/NWN1/Tilesets)
- [NeverBlender — nwn.wiki](https://nwn.wiki/display/NWN1/NeverBlender)
- [Walkmesh — NWNWiki](https://nwn.fandom.com/wiki/Walkmesh)
- [Tile — NWNWiki](https://nwn.fandom.com/wiki/Tile)
- [Common Errors and Their Causes — nwn.wiki](https://nwn.wiki/display/NWN1/Common%20Errors%20and%20Their%20Causes)
- [SET (file format) — nwn.wiki](https://nwn.wiki/display/NWN1/SET) — terrain/crosser/rules/groups fields (§2.3, §4)
- [Tile Path Nodes — nwn.wiki](https://nwn.wiki/spaces/NWN1/pages/139689996/Tile+Path+Nodes) — pathnode types AZ/az, exit-to-region, alignment
- [Tutorial: Creating Custom Tiles (Mr X) — Neverwinter Vault](https://neverwintervault.org/article/tutorial/tutorial-creating-custom-tiles) — comprehensive walkthrough (Cloudflare-gated to WebFetch; open in a browser)
- [Resources for custom NWN tileset creation — NWN Workshop](https://www.nwnworkshop.org/forums/topic/resources-for-custom-nwn-tileset-creation/) — 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._