# 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 └── / ← 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 → ~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.5` and the raised tiles sit at corner height `1`. - 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): 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 `ml1`, `ml2` (mainlights — floating ambient, area- controllable) and `sl1`, `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 `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 `.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 | |---|---|---| | `.set` | tileset definition | CRLF, references tiles by index | | `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 | | `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` + `.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. - **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 -- `) 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 `.hak`. Ship `doortypes.2da` to 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 `_A01_01` (grid position). Keep a code→resref map. | | `VIS_` objects | Renderable trimesh children of AuroraBase | | `WOK_` objects | The AABB walkmesh; assign walkable surfacemat (Stone/Cobble→Stone) | | `BLOCK_` objects | Non-walkable triangles in the walkmesh (blocker surfacemat) | | `MARK__*` / `TILE_` 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 `/` 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. - **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) _Verify specifics against the wiki before relying on them; correct this file when you find drift._