Part of the workspace-wide standards rollout. Establishes where work lives (issues vs ADRs vs standing law), scaffolds `docs/agents/` for the engineering skills, and adopts ADR-0001 locally. See `sow-platform` ADR-0021 for the `sow-docs` retirement.Reviewed-on: #1 Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
543 lines
31 KiB
Markdown
543 lines
31 KiB
Markdown
# 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 → ~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`.
|
||
- **"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.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.
|
||
- **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 A–Z/a–z, 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._
|
||
|
||
## 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.
|