This commit is contained in:
2026-07-05 01:46:27 +02:00
parent 0a939e3a85
commit 08f544b358
23 changed files with 970 additions and 217 deletions
+33 -1
View File
@@ -92,6 +92,14 @@ Keep the terrain/crosser vocabulary **small and deliberate** per tileset.
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`).
@@ -114,10 +122,34 @@ It needs, in the `.set`:
`Floor` (the terrain the **eraser** paints), `HasHeightTransition=1` (turns on
Raise/Lower).
`[PRIMARY RULES]` only *refine* auto-picking (cascading corner transitions) and are
**optional**stock custom sets ship `Count=0` (verified: `wut01.set`). **Eraser,
**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`**
@@ -0,0 +1,281 @@
# wgt01 Texture Kit Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the 8-slot dark-gothic PBR texture kit for the wgt01 tileset (`wgt01/textures/`), regraded to the concept-art palette, with provenance notes and materials wired into `wgt01.blend`.
**Architecture:** Copy the best `tcn01` PBR maps out of `/home/vicky/Blender/PBR_Procampur` into a `wgt01_` namespace, run every diffuse through one shared ImageMagick regrade script, pull CC0 sets from ambientCG only for the plaza (and optionally sidewalk) slots, author the water map, then load everything into `wgt01.blend` as `MAT_wgt01_*` materials via headless Blender.
**Tech Stack:** bash + ImageMagick (`magick`), `curl` (ambientCG API), Blender 4.0.2 headless (via `nix develop ../twu01``$BLENDER_BIN`), NWN `.mtr` text files.
## Global Constraints
- All shipped names ≤ 16 chars, lowercase, `wgt01_` prefix (spec: resref limit).
- Kit lives in `wgt01/textures/`; never modify originals under `/home/vicky/Blender`.
- Every diffuse goes through `wgt01/regrade.sh` — no hand-edited one-off images.
- Height/normal maps copy through untouched.
- Every shipped file gets a `SOURCES.md` line (origin path or CC0 URL + license).
- TGA only in the kit (convert any DDS); NWN also reads TGA directly.
- Working branch: `wgt01-tileset-kit`. NixOS: no ad-hoc global installs; use `nix shell nixpkgs#imagemagick -c` / the twu01 dev shell.
- This is an asset pipeline, not TDD code: each task's "test" is a runnable visual or scripted check, and the plan's self-checks (`assert`-style bash) stand in for unit tests.
---
### Task 1: Kit scaffold + regrade script
**Files:**
- Create: `wgt01/textures/` (dir), `wgt01/regrade.sh`, `wgt01/textures/SOURCES.md` (header only)
**Interfaces:**
- Produces: `regrade.sh <in> <out.tga>` — darkens ~35%, desaturates, cool-shifts any diffuse. All later tasks call it. Env overrides: `MOD` (modulate triple, default `65,55,100`), `TINT` (default `#232830`), `TINTPCT` (default `12`).
- [ ] **Step 1: Write the script**
```bash
mkdir -p wgt01/textures
cat > wgt01/regrade.sh <<'EOF'
#!/usr/bin/env bash
# Uniform Westgate regrade: darken, desaturate, cool tint.
# Usage: regrade.sh <in> <out.tga> (env: MOD, TINT, TINTPCT to override per-slot)
set -euo pipefail
MOD="${MOD:-65,55,100}"; TINT="${TINT:-#232830}"; TINTPCT="${TINTPCT:-12}"
magick "$1" -modulate "$MOD" -fill "$TINT" -colorize "${TINTPCT}%" -level '0%,95%' "$2"
EOF
chmod +x wgt01/regrade.sh
printf '# wgt01/textures — sources & licenses\n\n| file | origin | license |\n|---|---|---|\n' > wgt01/textures/SOURCES.md
```
- [ ] **Step 2: Self-check — regraded output is darker and less saturated**
```bash
nix shell nixpkgs#imagemagick -c bash -c '
./wgt01/regrade.sh /home/vicky/Blender/PBR_Procampur/tcn01_cobb02_d.tga /tmp/rg_test.tga
b_in=$(magick /home/vicky/Blender/PBR_Procampur/tcn01_cobb02_d.tga -colorspace Gray -format "%[fx:mean]" info:)
b_out=$(magick /tmp/rg_test.tga -colorspace Gray -format "%[fx:mean]" info:)
awk -v a="$b_in" -v b="$b_out" "BEGIN{ if (b < a*0.75) print \"OK darker: \" a \" -> \" b; else { print \"FAIL\"; exit 1 } }"'
```
Expected: `OK darker: <in> -> <out>` with out ≲ 0.75×in.
- [ ] **Step 3: Eyeball it**`magick montage` the original vs regraded cobble side by side, Read the PNG, compare against the concept sheet's wet-cobblestone swatch. Tune `MOD`/`TINT` defaults in the script if it reads too green or too bright; re-run Step 2.
- [ ] **Step 4: Commit**
```bash
git add wgt01/regrade.sh wgt01/textures/SOURCES.md
git commit -m "wgt01: texture kit scaffold + shared regrade script"
```
---
### Task 2: Procampur slot extraction (slots 1,2,4,5,6 + grime decals)
**Files:**
- Create: `wgt01/textures/wgt01_{cobble,worn,sidewlk,wall,bridge}_{d,n,h}.tga`, matching `wgt01_<slot>.mtr`, `wgt01_grime1_d.tga`, `wgt01_wtrline_d.tga`
- Modify: `wgt01/textures/SOURCES.md`
**Interfaces:**
- Consumes: `wgt01/regrade.sh` (Task 1).
- Produces: slot basenames `wgt01_cobble`, `wgt01_worn`, `wgt01_sidewlk`, `wgt01_wall`, `wgt01_bridge` (each `_d/_n/_h` + `.mtr`) — Tasks 56 reference exactly these.
- [ ] **Step 1: Extract, regrade diffuses, copy n/h, emit .mtr**
```bash
cd wgt01 && P=/home/vicky/Blender/PBR_Procampur
declare -A SRC=( [cobble]=tcn01_cobb02 [worn]=tcn01_cobb05 [sidewlk]=tcn01_stone04 [wall]=tcn01_brick03 [bridge]=tcn01_stone04 )
nix shell nixpkgs#imagemagick -c bash -c '
set -euo pipefail; P=/home/vicky/Blender/PBR_Procampur
for slot in cobble worn sidewlk wall bridge; do
case $slot in cobble) s=tcn01_cobb02;; worn) s=tcn01_cobb05;; sidewlk) s=tcn01_stone04;; wall) s=tcn01_brick03;; bridge) s=tcn01_stone04;; esac
./regrade.sh "$P/${s}_d.tga" "textures/wgt01_${slot}_d.tga"
cp "$P/${s}_n.tga" "textures/wgt01_${slot}_n.tga"
cp "$P/${s}_h.tga" "textures/wgt01_${slot}_h.tga"
printf "renderhint NormalAndSpecMapped\r\n\r\n// Textures\r\ntexture0 wgt01_%s_d\r\ntexture1 wgt01_%s_n\r\ntexture2 black\r\ntexture3 null\r\ntexture4 wgt01_%s_h\r\ntexture5 null\r\n" "$slot" "$slot" "$slot" > "textures/wgt01_${slot}.mtr"
printf "| wgt01_%s_* | PBR_Procampur/%s_* | see PBR_Procampur readme |\n" "$slot" "$s" >> textures/SOURCES.md
done
# grime decals: pass through (alpha overlays, already neutral)
magick "$P/tcn01_splotch2_d.tga" textures/wgt01_grime1_d.tga
magick "$P/tcn01_watrstn02.tga" textures/wgt01_wtrline_d.tga
printf "| wgt01_grime1_d | PBR_Procampur/tcn01_splotch2_d | see readme |\n| wgt01_wtrline_d | PBR_Procampur/tcn01_watrstn02 | see readme |\n" >> textures/SOURCES.md'
```
Note: `bridge` starts as a duplicate of `stone04` with a darker override (`MOD=58,50,100 ./regrade.sh …`) so it separates visually from sidewalk; if it still reads identical at Task 6, the internet arch-stone fallback from Task 3's API search replaces it.
- [ ] **Step 2: Check — every slot complete, names ≤16**
```bash
cd wgt01/textures && for f in wgt01_{cobble,worn,sidewlk,wall,bridge}{_d.tga,_n.tga,_h.tga,.mtr}; do
[ -f "$f" ] || { echo "MISSING $f"; exit 1; }
b="${f%.*}"; [ ${#b} -le 16 ] || { echo "TOO LONG $b"; exit 1; }
done && echo OK
```
Expected: `OK`.
- [ ] **Step 3: Contact sheet** — montage all `wgt01_*_d.tga`, Read it, compare with concept-art material swatches. Adjust per-slot `MOD`/`TINT` env overrides and re-run Step 1 for any slot that's off.
- [ ] **Step 4: Commit**
```bash
git add wgt01/textures && git commit -m "wgt01: Procampur-derived slots (cobble/worn/sidewalk/wall/bridge) + grime decals"
```
---
### Task 3: Internet pull — plaza slabs (slot 3)
**Files:**
- Create: `wgt01/textures/wgt01_plaza_{d,n,h}.tga`, `wgt01/textures/wgt01_plaza.mtr`
- Modify: `wgt01/textures/SOURCES.md`
**Interfaces:**
- Consumes: `regrade.sh`. Produces: basename `wgt01_plaza` for Tasks 56.
- [ ] **Step 1: Find a CC0 large-slab flagstone on ambientCG**
```bash
curl -s 'https://ambientcg.com/api/v2/full_json?q=paving+stones&type=Material&limit=50' \
| nix shell nixpkgs#jq -c jq -r '.foundAssets[] | [.assetId, .displayName] | @tsv' | head -30
```
Pick a large/irregular slab asset (PavingStones-family; prefer big flat slates over small cobbles — this is the plaza). Download the 1K JPG zip:
```bash
cd /tmp/claude-*/*/scratchpad 2>/dev/null || cd "$SCRATCHPAD"
curl -Lo plaza.zip 'https://ambientcg.com/get?file=<AssetID>_1K-JPG.zip' && nix shell nixpkgs#unzip -c unzip -o plaza.zip -d plaza/
ls plaza/ # expect <AssetID>_1K_Color.jpg, _NormalGL.jpg, _Displacement.jpg
```
- [ ] **Step 2: Convert + regrade into the kit**
```bash
cd <repo>/wgt01 && S=<scratchpad>/plaza A=<AssetID>
nix shell nixpkgs#imagemagick -c bash -c '
./regrade.sh "'$S'/'$A'_1K_Color.jpg" textures/wgt01_plaza_d.tga
magick "'$S'/'$A'_1K_NormalGL.jpg" textures/wgt01_plaza_n.tga
magick "'$S'/'$A'_1K_Displacement.jpg" -colorspace Gray textures/wgt01_plaza_h.tga'
printf "renderhint NormalAndSpecMapped\r\n\r\n// Textures\r\ntexture0 wgt01_plaza_d\r\ntexture1 wgt01_plaza_n\r\ntexture2 black\r\ntexture3 null\r\ntexture4 wgt01_plaza_h\r\ntexture5 null\r\n" > textures/wgt01_plaza.mtr
printf "| wgt01_plaza_* | ambientcg.com asset %s (1K JPG) | CC0 |\n" "$A" >> textures/SOURCES.md
```
- [ ] **Step 3: Check** — same completeness/length loop as Task 2 Step 2 for `wgt01_plaza`; montage plaza next to cobble/sidewalk and Read: plaza must read as *larger, flatter slabs* than both, in the same palette.
- [ ] **Step 4: Commit**`git add wgt01/textures && git commit -m "wgt01: plaza slab slot from ambientCG (CC0)"`
---
### Task 4: Authored slots — canal water (8) + gutter/drain (7)
**Files:**
- Create: `wgt01/textures/wgt01_water_d.tga`, `wgt01_water.mtr`, `wgt01_gutter_{d,n,h}.tga`, `wgt01_gutter.mtr`
- Modify: `wgt01/textures/SOURCES.md`
**Interfaces:**
- Produces: basenames `wgt01_water`, `wgt01_gutter` for Tasks 56.
- [ ] **Step 1: Water — near-black, subtle ripple, from procedural noise (no source needed)**
```bash
cd wgt01 && nix shell nixpkgs#imagemagick -c bash -c '
magick -size 512x512 plasma:gray20-gray35 -blur 0x6 -modulate 40,20,100 \
-fill "#0a0e14" -colorize 70% -level "0%,60%" textures/wgt01_water_d.tga'
printf "renderhint NormalAndSpecMapped\r\n\r\n// Textures\r\ntexture0 wgt01_water_d\r\ntexture1 null\r\ntexture2 black\r\ntexture3 null\r\ntexture4 null\r\ntexture5 null\r\n" > textures/wgt01_water.mtr
printf "| wgt01_water_d | authored (ImageMagick plasma) | ours |\n" >> textures/SOURCES.md
```
(EnvMap-driven sheen comes from the `.set` later — out of scope here.)
- [ ] **Step 2: Gutter — extra-dark stone channel from the cobble source**
```bash
cd wgt01 && nix shell nixpkgs#imagemagick -c bash -c '
P=/home/vicky/Blender/PBR_Procampur
MOD=45,40,100 TINT="#14181f" TINTPCT=20 ./regrade.sh "$P/tcn01_cobb02_d.tga" textures/wgt01_gutter_d.tga
cp "$P/tcn01_cobb02_n.tga" textures/wgt01_gutter_n.tga
cp "$P/tcn01_cobb02_h.tga" textures/wgt01_gutter_h.tga'
printf "renderhint NormalAndSpecMapped\r\n\r\n// Textures\r\ntexture0 wgt01_gutter_d\r\ntexture1 wgt01_gutter_n\r\ntexture2 black\r\ntexture3 null\r\ntexture4 wgt01_gutter_h\r\ntexture5 null\r\n" > textures/wgt01_gutter.mtr
printf "| wgt01_gutter_* | PBR_Procampur/tcn01_cobb02_* (extra-dark regrade) | see readme |\n" >> textures/SOURCES.md
```
Try converting `$P/tcn01_grate01.dds` for an iron grate decal (`magick` failed on its header earlier; try `nix shell nixpkgs#directx-shader-compiler`? No — try `texconv` alt: `nix shell nixpkgs#imagemagick -c magick dds:...` or Gimp headless). If unreadable, skip: the drain grate becomes modeled geometry with `wgt01_gutter`, note it in SOURCES.md — do not block on it.
- [ ] **Step 3: Check + commit** — completeness loop for `wgt01_water`, `wgt01_gutter`; montage; `git add wgt01/textures && git commit -m "wgt01: authored water + gutter slots"`.
---
### Task 5: Wire materials into wgt01.blend
**Files:**
- Create: `wgt01/wire_materials.py`
- Modify: `wgt01/wgt01.blend` (headless save)
**Interfaces:**
- Consumes: all 8 slot basenames from Tasks 24.
- Produces: Blender materials named `MAT_wgt01_<slot>` (Principled BSDF, image texture node on `_d`, normal-map node on `_n` where present, relative paths `//textures/…`).
- [ ] **Step 1: Write `wgt01/wire_materials.py`**
```python
"""Create/refresh MAT_wgt01_* materials in wgt01.blend (run headless)."""
import bpy, os
SLOTS = ["cobble", "worn", "plaza", "sidewlk", "wall", "bridge", "gutter", "water"]
BASE = os.path.dirname(bpy.data.filepath)
for slot in SLOTS:
name = f"MAT_wgt01_{slot}"
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree; nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
d = os.path.join(BASE, "textures", f"wgt01_{slot}_d.tga")
img = bpy.data.images.load(d, check_existing=True)
img.filepath = f"//textures/wgt01_{slot}_d.tga"
tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
n = os.path.join(BASE, "textures", f"wgt01_{slot}_n.tga")
if os.path.exists(n):
nimg = bpy.data.images.load(n, check_existing=True)
nimg.filepath = f"//textures/wgt01_{slot}_n.tga"; nimg.colorspace_settings.name = "Non-Color"
ntex = nt.nodes.new("ShaderNodeTexImage"); ntex.image = nimg
nmap = nt.nodes.new("ShaderNodeNormalMap")
nt.links.new(ntex.outputs["Color"], nmap.inputs["Color"])
nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
# wet look: low roughness on ground slots
bsdf.inputs["Roughness"].default_value = 0.35 if slot in ("cobble", "gutter", "water") else 0.6
bpy.ops.wm.save_mainfile()
print("WIRED:", ", ".join(f"MAT_wgt01_{s}" for s in SLOTS))
```
- [ ] **Step 2: Run headless and verify**
```bash
cd wgt01 && nix develop ../twu01 -c "$BLENDER_BIN" --background wgt01.blend --python wire_materials.py 2>&1 | grep WIRED
```
(If `$BLENDER_BIN` is unset outside the dev shell, run `nix develop ../twu01` first — it exports it.) Expected: `WIRED: MAT_wgt01_cobble, …` and `wgt01.blend` mtime updated.
- [ ] **Step 3: Verify images resolve** — re-open headless: `--python-expr 'import bpy; missing=[i.name for i in bpy.data.images if not i.has_data]; print("MISSING:", missing); assert not missing'`. Expected: `MISSING: []`.
- [ ] **Step 4: Commit**`git add wgt01/wire_materials.py wgt01/wgt01.blend && git commit -m "wgt01: wire MAT_wgt01_* materials into blend"`.
---
### Task 6: Final verification — palette contact sheet
**Files:**
- Create: `wgt01/textures/contact-sheet.png` (committed as the kit's visual record)
- [ ] **Step 1: Build the sheet**
```bash
cd wgt01 && nix shell nixpkgs#imagemagick -c bash -c '
magick montage -label "%f" -background "#111" -fill "#ccc" textures/wgt01_*_d.tga \
-tile 4x -geometry 256x256+6+6 textures/contact-sheet.png'
```
- [ ] **Step 2: Read the sheet next to `concept-art-1.png`'s Material Language column.** Acceptance: all 8 slots present, one shared dark/cool palette, plaza ≠ sidewalk ≠ cobble at a glance, bridge ≠ sidewalk, water near-black. Fix any failing slot by adjusting its regrade override and re-running that task's Step 1.
- [ ] **Step 3: SOURCES.md audit**
```bash
cd wgt01/textures && for f in wgt01_*; do grep -q "${f%%_[dnhs].tga}" SOURCES.md || grep -q "${f%.mtr}" SOURCES.md || { echo "UNSOURCED $f"; exit 1; }; done && echo OK
```
- [ ] **Step 4: Commit**`git add wgt01/textures/contact-sheet.png && git commit -m "wgt01: texture kit contact sheet"`.
@@ -0,0 +1,87 @@
# wgt01 Texture Kit & Design — Spec
Date: 2026-07-05 · Branch: `wgt01-tileset-kit` · Status: approved-in-conversation, pending spec review
## Goal
Assemble the material/texture kit for **wgt01** (Westgate Urban Foundation
tileset): dark gothic ground grammar only — streets, sidewalks, plazas, curbs,
gutters, raised tiers, ramps, canals, bridges. No buildings (placeables later).
Palette and material language follow `wgt01/concept-art-1.png` (8 material
slots) and `wgt01/VIBE.md` (noir dark fantasy, rain-slick, early
industrial-magical shown only through ground grammar).
## Approach (decided)
**Hybrid curated**, Procampur-first with a small internet pull:
- Base structure from `/home/vicky/Blender/PBR_Procampur` (PBR retexture of
stock `tcn01` City set: `_d/_h/_n` maps + `.mtr`, NWN-correct tiling scale).
Confirmed too grey-green/warm as-is → every diffuse gets a uniform regrade.
- Fresh CC0 pulls (ambientCG / Polyhaven or similar) only for slots Procampur
lacks: plaza slabs, possibly sidewalk flagstone and bridge arch stone.
- `pd textures main lib` is fallback only (Roads is mostly modern asphalt; the
dark pebble-cobble pattern is a candidate wet-cobble alternate).
Rejected: pure Procampur rename (not dark enough); all-internet (discards
ready-made NWN conventions, most work).
## Deliverables
1. `wgt01/textures/` — the kit. Per material slot: `wgt01_<slot>_d/_h/_n.tga`
(+ `_s` where available) and `wgt01_<slot>.mtr`. All resrefs ≤ 16 chars,
`wgt01_` namespace. No originals modified in `/home/vicky/Blender`.
2. `wgt01/textures/SOURCES.md` — per-file provenance + license notes
(Procampur origin filename, or CC0 URL/asset id).
3. `wgt01/regrade.sh` (or small script) — the single ImageMagick recipe
(levels + desaturate + cool tint, ~35% darken) applied to all diffuses so
the kit shares one palette. Reproducible; height/normal maps pass through
untouched; missing `_s` generated from darkened diffuse.
4. Materials wired into `wgt01/wgt01.blend` on the tile prototypes
(named `MAT_wgt01_<slot>`, image-based, matching KICKOFF material roles).
## Material slots
| # | Slot (concept sheet) | Source | Notes |
|---|---|---|---|
| 1 | Wet cobblestone | `tcn01_cobb02` d/h/n | regrade; specular boosted for rain-slick |
| 2 | Worn stone / patches | `tcn01_cobb05` | regrade; broken/repair variant |
| 3 | Plaza stone (large slabs) | internet CC0 flagstone/slate | grade to palette |
| 4 | Curb / sidewalk stone | `tcn01_stone04` or internet flagstone | pick after regrade test |
| 5 | Retaining wall stone | `tcn01_brick03` + `tcn01_slum01`; `tcn01_watrstn02` waterline overlay near canals | regrade |
| 6 | Bridge stone | `tcn01_stone04`/`brick03` variant; internet arch stone if needed | regrade |
| 7 | Dark gutter / drain | `tcn01_grate01`/`grill01` iron + darkened stone channel | near-black, wet; grate01 is DDS-only → convert |
| 8 | Canal / dark water | authored near-black water diffuse; `.set` `EnvMap` | `tcn01_water01` too green; animation later |
Grime layer (not a slot): `tcn01_splotch*`/`stain*` alpha decals reused as-is.
## Tile grammar (context, unchanged)
Prefix `wgt01`; terrains `cobble` / `plaza` / `canal`; crossers `street` /
`sidewalk` / `canal_edge`; `Transition=1.5`; raised tier = cobble at corner
height 1 (not a terrain). Kept minimal per the terrain-pairing cost rule in
root `AGENTS.md` §2.1. Canal water sits at Z = 0.3 under a non-walkable
walkmesh.
## Out of scope
Buildings/facades/props; `.set`/`.itp`/`.mdl` emission (twu01 generator kit is
the reference when we get there); water animation; minimaps; hak packing.
## Verification
- Contact sheet (ImageMagick montage) of all regraded diffuses next to
concept-art swatches — visual palette check.
- Every slot has `_d/_h/_n` + `.mtr`, names ≤ 16 chars, TGA (no stray DDS).
- `wgt01.blend` opens in Blender 4.0.2 with all `MAT_wgt01_*` images resolving
(relative paths into `wgt01/textures/`).
- `SOURCES.md` covers every shipped file.
## Risks
- Regrade recipe may need per-slot tweaks; keep overrides in the script, not
hand-edited images.
- Internet pulls are network-dependent and need license capture at download
time.
- Procampur's own redistribution license should be checked before the kit
ships in a public `.hak`; fine for internal prototyping.
+88 -18
View File
@@ -44,14 +44,20 @@ nix run .#build -- out.blend # optional: editable .blend preview
walkmesh. E.g. `st_s` → 3 trimesh nodes (swlk/curb/wcob); `cp1` → 1 with
`bitmap twu01_wcob`. Walkmesh surfacemats: 4 (`wok_Stone`, walkable) / 7
(`wok_Nonwalk`). Matches the stock `trs02` tile structure field-for-field.
- **`.itp`**: `twu01palstd.itp` round-trips through `nwn_gff``ITP ` type,
40 named entries. Names render, but the **structure is wrong** (flat folder of
raw tile resrefs → unplaceable, no Terrain/eraser folder). See Diagnosis below;
needs a rebuild to the 3-folder Terrain/Features/Groups shape.
- **`.set`**: CRLF, 40 tiles, 3 terrains (`cobble`/`raised`/`canal`), 2 crossers
(`ramp`/`canal_edge`). `Version=V1.0`, stock field order (`WalkMesh=msb01`,
lights=1, `VisibilityNode=A`, `ImageMap2D` last). Corners/heights from each
tile's N/E/S/W edge grammar; non-walkable tiles → `PathNode=N`.
- **`.itp` (rebuilt 2026-07-04, pass 4)**: `twu01palstd.itp` round-trips through
`nwn_gff` with the **real structure** (../AGENTS.md §2.3): a **Features** folder
(STRREF 63261) with a leaf per **non-ground** tile (37), and a **Terrain** folder
(STRREF 8282) = paint `cobble` + the magic `eraser` leaf (STRREF 63291). Ground
tiles (CP1/CP2/CP3) are omitted from Features on purpose — they're the painted
fill, not click-placed. Every feature leaf is backed by a 1×1 `[GROUP#]` in the
`.set` (Name==leaf NAME, `Tile0`==that tile). Guarded by
`generator/test_namesync.py` (passes). **Pending GUI re-test.**
- **`.set` (pass 4)**: CRLF, 40 tiles, 3 terrains (`cobble`/`raised`/`canal`), 2
crossers (`ramp`/`canal_edge`). `HasHeightTransition=0` (MVP paints flat cobble
only; matches stock). **37 `[GROUP#]`, one per non-ground tile — CP1/CP2/CP3 stay
ungrouped so they remain the random terrain-fill pool** (see Root cause below).
`Version=V1.0`, stock field order. Corners/heights from each tile's edge grammar;
non-walkable tiles → `PathNode=N`.
- **install path**: `~/Documents/Neverwinter Nights/development` is a symlink to
`~/.local/share/Neverwinter Nights/development` (the toolset's real dir); one
`nix run .#dev-install` stages all 94 loose files there.
@@ -125,17 +131,81 @@ label, LIST}`; leaves `{NAME, RESREF}`. Folder 8282 = **Terrain** (leaf RESREF =
terrain/crosser *name*, plus a magic `eraser` leaf STRREF 63291); 63261 =
**Features** (1×1, RESREF = first-tile resref); 63262 = **Groups** (N×M).
## Next passes
## Root cause 2026-07-04 — New Area crash (pass 3 regression, fixed pass 4)
1. **Rebuild `make_itp.py` + `make_set.py` to the real structure** (../AGENTS.md
§2.3):
- `.itp`: a **Terrain** folder (each `tiles.TERRAINS`/`CROSSERS` by name + an
`eraser` leaf) so terrain-painting + eraser exist; a **Features** folder with a
1×1 entry per tile we want click-placeable.
- `.set`: add `[GROUP#]` (1×1 feature) entries backing those Features leaves
(`Name` == palette NAME, `Tile0` == that tile's index).
- Then in the toolset lay ground via **Paint Terrain** (cobble/raised/canal), not
by clicking tiles.
Creating a new area threw `Access violation ... Write of address 00000040` in
`nwtoolset.exe`. **Root cause: an empty terrain-fill pool.** A grouped tile is
excluded from the toolset's random fill; pass 3 put *all 40* tiles in 1×1 groups,
so `Default=cobble` had **no** tile to fill with → null deref. Verified against
stock `tcn01` (City Exterior): of its 320 cobble-cornered tiles, 314 are group
members and only **6** (non-group `a20_*`/`o01`) form the real fill pool.
**Fix (pass 4):** ground tiles `CP1/CP2/CP3` (`tiles.GROUND_CODES`) are left
ungrouped → they are the interchangeable cobble the area spawns with and the
eraser resets to. The other 37 tiles keep their `[GROUP#]` (palette features).
Also set `HasHeightTransition=0` (matches stock; we don't paint height
transitions this pass). `test_namesync.py::test_ground_tiles_stay_in_fill_pool`
is the regression guard. This also fixes the earlier "random different tiles at
different elevations" fill — only CP1/CP2/CP3 now auto-fill.
## Pass 4 result (2026-07-04, confirmed in toolset)
✅ New Area succeeds, spawns plain cobble. ✅ Features click-place (the `[flat]`
prefabs). ✅ Eraser resets to cobble. ❌ **Canal and Raise/Lower are inert** — as
predicted by the ponytail note. Root cause (now fully understood, see AGENTS.md
§2.2/§2.3):
1. Canal & raised tiles are `[GROUP#]` features → excluded from the paint pool, so
the brush has nothing to auto-lay.
2. Only `cobble` is exposed in the `.itp` Terrain folder → no canal brush.
3. `HasHeightTransition=0` → Raise/Lower greyed out.
4. `[PRIMARY RULES] Count=0` → the brush can't reconcile a 2nd terrain / a height
step against neighbours.
5. `raised` is modelled as a *terrain* (`TERRAINS`), but in NWN it's a **height on
cobble** — the model is wrong.
## Pass 5a done (2026-07-04) — CANAL is now paintable
Staged via `nix run .#dev-install`: 51 tile placements from 41 models, 33 feature
groups, matrix complete, `namesync OK`. What changed vs pass 4:
- `tiles.py`: split spec→placement (`placements()`, rotate one model into 4
orientations); `is_paint`/`is_transition`; added diagonal-pinch tile `CE_D`;
fixed `CE_I` corners (its geometry is 1 canal corner, edges wrongly read 3);
`canal_paint_matrix()` completeness helper.
- `make_set.py`: emits rotated `[TILE#]` placements, groups only NON-paint tiles,
blanks crossers on paint tiles (corner-match only), **hard-fails if the 16-pattern
cobble/canal matrix is incomplete** (caught the CE_I bug).
- `make_itp.py`: Terrain folder = `cobble` + `canal` + eraser; Features = non-paint.
- `HasHeightTransition` still 0 (raise/lower = pass 5b).
**Re-test:** (a) is there a **canal** brush in Terrain, and does painting it lay
black-water tiles? (b) do canal/land **edges auto-appear** (straight, both corners,
diagonal) without seams or gaps? (c) does the **eraser** still reset to cobble, and
New Area still spawn cobble (no regression)? (d) do the click-place features (streets
etc.) still place? Report back — then 5b adds raise/lower on the same machinery.
## Pass 5b plan — raise/lower (next) + original full plan below
Reference: stock `tcn01` (513 tiles → 58 corner-tuples; 76 primary rules;
`HasHeightTransition=1`). Our geometry-only version needs ~1 tile per tuple.
- **Model change in `tiles.py`:** split *tile spec* (a model) from *placement* (a
`[TILE#]` = model + `Orientation` + rotated corners/edges). One transition model
→ up to 4 placements (0/90/180/270). Drop `raised` from `TERRAINS`
`TERRAINS=["cobble","canal"]`; raised = `cobble` corner at `Height=1`.
- **Ungroup the terrain tiles:** canal fill, canal-edge (straight/outer/inner),
ramp, retaining-edge (straight/outer/inner) leave `[GROUPS]` and join the paint
pool. Streets/sidewalks/bridges/drains **stay** grouped features.
- **Corner matrix:** generate the full cobble/canal patterns and the full
cobble:0/cobble:1 patterns (via orientation) so the brush never requests a missing
tile. Keep canal and raised in **separate** matrices (a tile is never
canal-corner + raised-corner) to bound the count.
- **`[PRIMARY RULES]`:** skip (stay `Count=0`). Verified not required — `wut01`
paints 6 terrains with `Count=0`. They only polish cascading transitions; add
later if seams need it.
- **`[GENERAL]`:** `HasHeightTransition=1`.
- **`.itp` Terrain folder:** `cobble`, `canal`, + `eraser`.
- **Guard:** a completeness self-check that fails generation if any corner pattern
the brush can request lacks a tile (so an incomplete matrix can't ship a crash).
2. Walk-test → tune PathNodes, crossers, corner terrains; add a tileset TLK if
we later want localized palette names (local `NAME` strings suffice for now).
3. **Textures (art pass, separate note).** Source assets live in
BIN
View File
Binary file not shown.
View File
+87
View File
@@ -0,0 +1,87 @@
"""Generate the tileset palette `twu01palstd.itp` from tiles.py.
python make_itp.py [out.itp] # default: twu01palstd.itp
The toolset loads `<set>palstd.itp` when it registers a tileset; without it the
set fails to register (blank name in the New Area wizard, "could not create
area"). It's a GFF `ITP ` file: MAIN = a list of palette *category* folders, each
{ID byte, STRREF (dialog.tlk folder label), LIST of leaves}.
We emit TWO folders (the verified structure — AGENTS.md §2.3; wut01palstd.itp):
* Features (STRREF 63261): one leaf per NON-paint tile, {NAME, RESREF=tile
resref}. Each leaf is placeable ONLY because make_set.py backs it with a 1x1
[GROUP#] whose Name == this NAME and Tile0 == that tile; the toolset finds the
group by the RESREF (its first tile). Paint tiles (tiles.is_paint) are omitted
here on purpose — they're brush-laid terrain, not click-placed features.
* Terrain (STRREF 8282): the paintable vocabulary — `cobble` and `canal` (each
RESREF = terrain name) plus the magic `eraser` leaf (RESREF `eraser`, STRREF
63291, no NAME). `raised` is NOT here: it's a height on cobble (Raise/Lower),
deferred to the next pass with HasHeightTransition=1 + a cobble-height matrix.
Multi-tile groups (folder 63262) would go in a third folder; we have none yet.
Built as JSON and converted to GFF with neverwinter.nim's nwn_gff (on PATH).
"""
import json
import os
import subprocess
import sys
import tempfile
import tiles
STRREF_FEATURES = 63261 # dialog.tlk label for the "Features" palette folder
STRREF_TERRAIN = 8282 # dialog.tlk label for the "Terrain" palette folder
STRREF_ERASER = 63291 # the magic eraser leaf (no NAME)
def leaf(name, resref):
return {"__struct_id": 0,
"NAME": {"type": "cexostring", "value": name},
"RESREF": {"type": "resref", "value": resref}}
def eraser_leaf():
return {"__struct_id": 0,
"RESREF": {"type": "resref", "value": "eraser"},
"STRREF": {"type": "dword", "value": STRREF_ERASER}}
def category(cid, strref, leaves):
return {"__struct_id": 0,
"ID": {"type": "byte", "value": cid},
"STRREF": {"type": "dword", "value": strref},
"LIST": {"type": "list", "value": leaves}}
def build():
features = [leaf(tiles.group_name(s), tiles.resref(s))
for s in tiles.TILES if not tiles.is_paint(s)]
# Paintable terrains = every .set terrain EXCEPT 'raised' (which is a height on
# cobble, not a terrain — the Raise/Lower tool, deferred to the next pass).
paintable = [t for t in tiles.TERRAINS if t != "raised"] # -> cobble, canal
terrain = [leaf(t, t) for t in paintable] + [eraser_leaf()]
return {
"__data_type": "ITP ",
"MAIN": {"type": "list", "value": [
category(0, STRREF_FEATURES, features), # ID/STRREF mirror wut01palstd.itp
category(2, STRREF_TERRAIN, terrain),
]},
}
def main():
out = sys.argv[1] if len(sys.argv) > 1 else f"{tiles.PREFIX}palstd.itp"
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tf:
json.dump(build(), tf)
js = tf.name
try:
subprocess.run(["nwn_gff", "-i", js, "-l", "json", "-k", "gff", "-o", out],
check=True)
finally:
os.unlink(js)
nfeat = sum(1 for s in tiles.TILES if not tiles.is_paint(s))
print(f"wrote {out} ({nfeat} features + paint:cobble,canal + eraser)")
if __name__ == "__main__":
main()
@@ -14,12 +14,15 @@ Each tile .mdl contains:
* the aabb walkmesh node (shared with make_wok, embedded per NWN tile rules).
The standalone `.wok` (same walkmesh) is written alongside.
Textures: a solid-colour <=16-char .tga per tiles.TEXTURES (KICKOFF.md: geometry
not art; flat colours double as an in-toolset grammar read-out). Real art can
replace these files later without touching geometry.
Textures: a <=16-char .tga per tiles.TEXTURES. Each is a procedural structural
read-out (distinct role colour + dark border + orientation pip) so geometry reads
in the toolset (KICKOFF.md: geometry not art); SRC_TEX swaps in a real stock tga
where a photographic surface helps (e.g. canal water). Real art can replace any of
these files later without touching geometry.
"""
import os
import shutil
import sys
import tiles
@@ -27,17 +30,42 @@ import make_wok
VIS_MATID = 65536 # constant last-field on visible faces (matches stock tiles)
# Real stock-NWN textures copied verbatim for surfaces where a photographic read
# beats a flat colour (user: "pull cobbles/water from refs"). Everything else is
# the procedural structural read-out below. Missing source -> procedural fallback.
REFS = "/home/vicky/Projects/nwnee-refs"
SRC_TEX = {
"MAT_canal_black_water": f"{REFS}/aurora_tin.bif/tin01_water01.tga", # animated-look water
}
TEX_N = 64 # texture size (power of 2)
_BORDER = 3 # dark inset ring — makes within-tile paint seams read
_PIP = 9 # bright corner pip — reveals tile ORIENTATION (debug rotations)
def write_tga(path, rgb):
"""Minimal 2x2 uncompressed 24-bit TGA in solid `rgb` (0..1 floats)."""
"""64x64 24-bit TGA: base `rgb` (0..1), a dark inset border, and a white
corner pip. Not art a structural read-out: the pip exposes each tile's
orientation (so the paint auto-picker's rotations are visible) and the border
outlines internal texture edges. TGA origin is lower-left (descriptor 0), so
the pip sits at the tile's south-west (x=-5, y=-5) corner."""
r, g, b = (max(0, min(255, round(c * 255))) for c in rgb)
w = h = 2
header = bytes([0, 0, 2, 0, 0, 0, 0, 0,
0, 0, 0, 0,
w & 0xFF, w >> 8, h & 0xFF, h >> 8, 24, 0])
body = bytes([b, g, r]) * (w * h) # TGA stores BGR
dr, dg, db = (v * 35 // 100 for v in (r, g, b)) # 0.35x -> border
n = TEX_N
body = bytearray()
for j in range(n):
for i in range(n):
if i < _PIP and j < _PIP:
body += bytes((255, 255, 255)) # pip
elif i < _BORDER or i >= n - _BORDER or j < _BORDER or j >= n - _BORDER:
body += bytes((db, dg, dr)) # border (BGR)
else:
body += bytes((b, g, r)) # base (BGR)
assert len(body) == n * n * 3
header = bytes([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0,
n & 0xFF, n >> 8, n & 0xFF, n >> 8, 24, 0])
with open(path, "wb") as fh:
fh.write(header + body)
fh.write(header + bytes(body))
def _trimesh(ref, idx, tex, verts, quads):
@@ -128,9 +156,15 @@ def main():
with open(os.path.join(outdir, ref + ".wok"), "w") as fh:
fh.write(wok)
# write every texture (not just `used`) so nothing dangles if a tile changes
# write every texture (not just `used`) so nothing dangles if a tile changes;
# copy a real stock tga where SRC_TEX has one, else the procedural read-out.
for mat, tex in tiles.TEXTURES.items():
write_tga(os.path.join(outdir, tex + ".tga"), tiles.COLORS[mat])
dst = os.path.join(outdir, tex + ".tga")
src = SRC_TEX.get(mat)
if src and os.path.exists(src):
shutil.copyfile(src, dst)
else:
write_tga(dst, tiles.COLORS[mat])
print(f"wrote {len(tiles.TILES)} .mdl + .wok and "
f"{len(tiles.TEXTURES)} .tga to {outdir}/")
+145
View File
@@ -0,0 +1,145 @@
"""Generate the first-pass NWN tileset definition `twu01.set` from tiles.py.
python make_set.py [out.set] # default: twu01.set
Pure Python, no Blender. Emits a valid, DOS/CRLF `.set` (AGENTS.md §4/§9: Unix
line endings crash the toolset). Corners/heights/crossers derive from each tile's
edge grammar; this is a DRAFT to load and hand-tune in the toolset, not a final
set. See CAVEATS below.
Every NON-paint tile gets a 1x1 [GROUP#] (a "feature") so it is click-placeable
from the palette (AGENTS.md §2.3) — the .itp Features folder points its leaves at
these groups by first-tile resref, and Name must match tiles.group_name().
PAINT tiles (tiles.is_paint: cobble fill CP1/2/3, canal fill CN1, canal edges
CE_*) are deliberately NOT grouped: a grouped tile is excluded from the toolset's
terrain-paint pool, so leaving them ungrouped IS what makes cobble/canal
paintable and the eraser work. Grouping every tile empties the fill pool and the
New Area wizard crashes (verified vs stock tcn01, 2026-07-04). Transition tiles
expand into 4 orientations (tiles.placements) so the corner matrix is complete;
make_set.main() hard-fails if tiles.canal_paint_matrix() is missing any pattern.
CAVEATS (first pass — tune in the toolset):
* PathNode is a placeholder ('A' walkable / 'N' non-walkable). Real pathnode
letters govern creature routing and must be verified against the toolset.
* ImageMap2D references minimaps (twu01_<code>.tga) that are not rendered yet.
"""
import re
import sys
import tiles
PREFIX = tiles.PREFIX
def tile_block(i, spec, orient, corners, edges):
tl, tr, bl, br = corners
n, e, s, w = edges
ref = tiles.resref(spec)
walkable = spec["kind"] != "canal"
# Paint tiles connect by CORNER match only (like stock wut01 terrains) — blank
# their edge crossers so the brush isn't over-constrained; features keep theirs.
cr = (lambda edge: "") if tiles.is_paint(spec) else tiles.nwn_crosser
# Field order/values mirror the stock BioWare sets (the toolset's .set reader
# is picky); WalkMesh=msb01 is the universal legacy label, not a file.
L = [f"[TILE{i}]"]
L += [
f"Model={ref}",
"WalkMesh=msb01",
f"TopLeft={tl[0]}", f"TopLeftHeight={tl[1]}",
f"TopRight={tr[0]}", f"TopRightHeight={tr[1]}",
f"BottomLeft={bl[0]}", f"BottomLeftHeight={bl[1]}",
f"BottomRight={br[0]}", f"BottomRightHeight={br[1]}",
f"Top={cr(n)}",
f"Right={cr(e)}",
f"Bottom={cr(s)}",
f"Left={cr(w)}",
"MainLight1=1", "MainLight2=1", "SourceLight1=1", "SourceLight2=1",
"AnimLoop1=1", "AnimLoop2=1", "AnimLoop3=1",
"Doors=0",
"Sounds=0",
f"PathNode={'A' if walkable else 'N'}", # placeholder — verify in toolset
f"Orientation={orient}", # 0/90/180/270 rotations of one model
"VisibilityNode=A",
"VisibilityOrientation=0",
f"ImageMap2D={ref}", # minimap resref (no .tga yet; blank in toolset)
]
return L
def build():
L = []
L += [
"[GENERAL]",
f"Name={PREFIX}",
"Type=SET",
"Interior=0",
"HasHeightTransition=0", # MVP paints flat cobble only; matches stock
"Transition=1.5", # metres per height step = Z=1.5 raised tier (unused while HHT=0)
"Border=cobble",
"Default=cobble",
"Floor=cobble",
"DisplayName=-1",
"UnlocalizedName=Westgate Urban Foundation",
"Version=V1.0",
"EnvMap=",
"SelectorHeight=3",
"",
"[GRASS]",
"Grass=0",
"GrassTextureName=",
"Density=0.0",
"Height=0.0",
"AmbientRed=0.0", "AmbientGreen=0.0", "AmbientBlue=0.0",
"DiffuseRed=0.0", "DiffuseGreen=0.0", "DiffuseBlue=0.0",
"",
"[TERRAIN TYPES]",
f"Count={len(tiles.TERRAINS)}",
]
for idx, t in enumerate(tiles.TERRAINS):
L += ["", f"[TERRAIN{idx}]", f"Name={t}", "StrRef=-1"]
L += ["", "[CROSSER TYPES]", f"Count={len(tiles.CROSSERS)}"]
for idx, cr in enumerate(tiles.CROSSERS):
L += ["", f"[CROSSER{idx}]", f"Name={cr}", "StrRef=-1"]
L += ["", "[PRIMARY RULES]", "Count=0", # optional; wut01 paints 6 terrains w/ 0
"", "[SECONDARY RULES]", "Count=0"]
# Expand each spec into its placement(s): transition tiles emit 4 rotations, so
# one model backs several [TILE#] entries. Track each spec's FIRST tile index so
# its [GROUP#] (if any) points at the right tile.
placed, first_idx = [], {}
for spec in tiles.TILES:
for pl in tiles.placements(spec):
first_idx.setdefault(spec["code"], len(placed))
placed.append((spec, pl))
L += ["", "[TILES]", f"Count={len(placed)}"]
for i, (spec, (orient, corners, edges)) in enumerate(placed):
L.append("")
L += tile_block(i, spec, orient, corners, edges)
# One 1x1 group ("feature") per NON-paint tile so the palette can click-place it.
# Paint tiles (fills + terrain transitions) stay ungrouped -> the brush pool.
feats = [s for s in tiles.TILES if not tiles.is_paint(s)]
L += ["", "[GROUPS]", f"Count={len(feats)}"]
for gi, spec in enumerate(feats):
L += ["", f"[GROUP{gi}]", f"Name={tiles.group_name(spec)}",
"Rows=1", "Columns=1", f"Tile0={first_idx[spec['code']]}"]
L += [""]
return L
def main():
_, _cov, missing = tiles.canal_paint_matrix()
if missing: # incomplete matrix = paint crash; never stage
raise SystemExit(f"INCOMPLETE canal paint matrix — {len(missing)} pattern(s) "
f"have no tile: {sorted(missing)}")
out = sys.argv[1] if len(sys.argv) > 1 else f"{PREFIX}.set"
lines = build()
with open(out, "w", newline="") as fh: # newline='' + explicit \r\n = CRLF
fh.write("\r\n".join(lines))
ntiles = sum(1 for l in lines if re.fullmatch(r"\[TILE\d+\]", l))
print(f"wrote {out} ({ntiles} tile placements from {len(tiles.TILES)} models, "
f"{len(tiles.TERRAINS)} terrains, {len(tiles.CROSSERS)} crossers, CRLF)")
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
"""Guard the invariants that silently break the toolset (AGENTS.md §5, §2.3):
python test_namesync.py # asserts; no nwn_gff / Blender needed
1. Every .itp Features leaf is backed by a matching .set [GROUP#] whose first tile
is that leaf's model (Name + Tile0).
2. Paint tiles (fills + terrain transitions) stay OUT of every group/feature, so the
brush pool is non-empty — an empty pool crashes the New Area wizard (the
2026-07-04 access violation). Regression guard for that crash.
3. The cobble/canal paint matrix covers every corner pattern the brush can request
(an incomplete matrix = paint crash).
4. The .itp Terrain folder exposes exactly the paintable terrains + eraser.
Runs on make_set.build() and make_itp.build() directly, so it fails fast if
group_name / resref / is_paint / placements drift apart.
"""
import re
import tiles
import make_set
import make_itp
def _groups(setf):
return {m.group(1): int(m.group(2))
for m in re.finditer(r"\[GROUP\d+\]\nName=(.+?)\n(?:.*\n)*?Tile0=(\d+)", setf)}
def _tile_models(setf):
return {int(m.group(1)): m.group(2)
for m in re.finditer(r"\[TILE(\d+)\]\nModel=(\S+)", setf)}
def _features():
return [l for c in make_itp.build()["MAIN"]["value"] if c["ID"]["value"] == 0
for l in c["LIST"]["value"]]
def test_features_back_every_group():
setf = "\n".join(make_set.build())
groups, models = _groups(setf), _tile_models(setf)
feat = _features()
nonpaint = [s for s in tiles.TILES if not tiles.is_paint(s)]
assert len(feat) == len(groups) == len(nonpaint), \
f"count mismatch: {len(feat)} features / {len(groups)} groups / {len(nonpaint)} non-paint"
for lf in feat:
name, resref = lf["NAME"]["value"], lf["RESREF"]["value"]
assert name in groups, f".itp feature {name!r} has no matching .set [GROUP#]"
tile0 = groups[name]
assert models[tile0] == resref, \
f"{name}: group Tile0={tile0} model {models[tile0]!r} != leaf RESREF {resref!r}"
def test_paint_tiles_stay_in_brush_pool():
paint = [s for s in tiles.TILES if tiles.is_paint(s)]
assert paint, "no paint tiles -> empty brush pool -> New Area crash"
grouped = set(_groups("\n".join(make_set.build())))
featured = {l["NAME"]["value"] for l in _features()}
for s in paint:
gn = tiles.group_name(s)
assert gn not in grouped, f"paint tile {gn!r} is grouped -> dropped from brush pool"
assert gn not in featured, f"paint tile {gn!r} is a feature -> dropped from brush pool"
def test_canal_matrix_complete():
want, _covered, missing = tiles.canal_paint_matrix()
assert not missing, f"paint matrix missing {len(missing)} pattern(s): {sorted(missing)}"
assert want, "empty matrix?"
def test_terrain_folder_is_paintables_plus_eraser():
terr = [l for c in make_itp.build()["MAIN"]["value"] if c["ID"]["value"] == 2
for l in c["LIST"]["value"]]
names = {l["RESREF"]["value"] for l in terr}
assert names == {"cobble", "canal", "eraser"}, names
if __name__ == "__main__":
test_features_back_every_group()
test_paint_tiles_stay_in_brush_pool()
test_canal_matrix_complete()
test_terrain_folder_is_paintables_plus_eraser()
print("namesync OK")
@@ -15,18 +15,20 @@ HALF = 5.0
Z_RAISED = 1.5
Z_WATER = -0.3
# --- prototype materials (flat viewport colours; KICKOFF.md wants geometry, not art)
# --- prototype materials. Bright, DISTINCT per role so structure reads at a glance
# in the toolset (KICKOFF.md wants geometry not art; the old near-black palette made
# every tile invisible). Curb/edge is deliberately loud so canal/height edges pop.
COLORS = {
"MAT_wet_cobble_dark": (0.05, 0.05, 0.06),
"MAT_cobble_patch": (0.12, 0.10, 0.09),
"MAT_plaza_slab": (0.30, 0.29, 0.27),
"MAT_sidewalk_stone": (0.22, 0.22, 0.23),
"MAT_curb_dark_stone": (0.10, 0.10, 0.11),
"MAT_canal_black_water": (0.02, 0.03, 0.05),
"MAT_void_black": (0.00, 0.00, 0.00),
"MAT_ramp_stone": (0.18, 0.16, 0.14),
"MAT_drain_iron": (0.08, 0.08, 0.09),
"MAT_foundation_pad": (0.25, 0.24, 0.22),
"MAT_wet_cobble_dark": (0.42, 0.42, 0.47), # cobble ground — cool mid grey
"MAT_cobble_patch": (0.50, 0.34, 0.22), # repair patch — brown
"MAT_plaza_slab": (0.74, 0.68, 0.52), # plaza — warm tan
"MAT_sidewalk_stone": (0.64, 0.65, 0.70), # sidewalk — light grey
"MAT_curb_dark_stone": (0.98, 0.55, 0.08), # EDGES/curbs — loud orange (pop)
"MAT_canal_black_water": (0.10, 0.38, 0.80), # canal — strong blue (real tga overrides)
"MAT_void_black": (0.03, 0.03, 0.05),
"MAT_ramp_stone": (0.80, 0.25, 0.68), # ramps/stairs — magenta (height reads)
"MAT_drain_iron": (0.22, 0.24, 0.30), # iron — dark steel
"MAT_foundation_pad": (0.58, 0.54, 0.34), # lots — olive
"MAT_debug_walkmesh": (0.00, 0.80, 0.20),
"MAT_debug_blocker": (0.80, 0.10, 0.10),
}
@@ -108,7 +110,15 @@ TILES = [
T("CN2", "narrow_drainage_cut", "cut", (C, F, C, F), "cut"),
T("CE_S", "canal_edge_straight", "canal_edge", (F, C, F, F), "canal_edge", which="E"),
T("CE_C", "canal_edge_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_outer"),
T("CE_I", "canal_edge_inside_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_inner"),
# Inside (concave) corner: geometry (ES_inner) is canal in the SE quadrant only,
# i.e. ONE canal corner at BR — the edge tuple would wrongly read as 3, so pin it.
T("CE_I", "canal_edge_inside_corner", "canal_edge", (F, C, C, F), "canal_edge",
which="ES_inner", corners=[("cobble", 0), ("cobble", 0), ("cobble", 0), ("canal", 0)]),
# Diagonal "pinch": canal on TL+BR, land on TR+BL. Edge-derived corners can't
# express a diagonal (see nwn_corners), so this one carries explicit corners.
# Completes the 16-pattern cobble/canal paint matrix (canal_paint_matrix()).
T("CE_D", "canal_edge_diagonal_pinch", "canal_edge", (C, C, C, C), "canal_edge",
which="diag", corners=[("canal", 0), ("cobble", 0), ("cobble", 0), ("canal", 0)]),
T("BR_1", "narrow_bridge", "bridge", (F, C, F, C), "bridge", hw=1.5),
T("BR_2", "broad_bridge", "bridge", (F, C, F, C), "bridge", hw=3.0),
T("SEW", "sewer_mouth_culvert", "canal_edge", (F, F, C, F), "sewer", which="S"),
@@ -164,6 +174,7 @@ def _land_fn(which):
"S": lambda x, y: y >= 0.0, # canal on south
"ES_outer": lambda x, y: (x <= 0.0 and y >= 0.0), # land = NW quadrant only
"ES_inner": lambda x, y: not (x > 0.0 and y < 0.0), # land = all but SE quadrant
"diag": lambda x, y: (x >= 0.0 and y >= 0.0) or (x <= 0.0 and y <= 0.0), # land NE+SW
}[which]
@@ -296,6 +307,9 @@ def wok_quads(spec):
if w == "ES_inner":
return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, 0, 5, 0.0), True),
(_rect(0, 5, -5, 0, 0.0), False)]
if w == "diag": # NE+SW land, NW+SE canal
return [(_rect(0, 5, 0, 5, 0.0), True), (_rect(-5, 0, -5, 0, 0.0), True),
(_rect(-5, 0, 0, 5, 0.0), False), (_rect(0, 5, -5, 0, 0.0), False)]
if k == "bridge":
hw = spec.get("hw", 1.5)
return [(_rect(-hw, hw, -5, 5, 0.2), True),
@@ -341,6 +355,79 @@ def resref(spec):
return f"{PREFIX}_{spec['code'].lower()}" # <=16 chars, valid identifier
def group_name(spec):
"""Palette label for a tile — MUST be identical in the .itp leaf NAME and the
.set [GROUP#] Name (name-sync gotcha, AGENTS.md §5). e.g. '[flat] cobble_plain'."""
return f"[{spec['kind']}] {spec['name']}"
# --- paint pool vs palette features (AGENTS.md §2.3) --------------------------
# A tile in ANY [GROUP#] is excluded from the toolset's terrain-paint pool. So the
# tiles the brush auto-lays (fills + terrain transitions) must stay UNGROUPED; only
# click-place prefabs get grouped. Grouping *every* tile empties the fill pool ->
# the New Area wizard null-derefs ("Access violation ... Write of 00000040").
GROUND_CODES = {"CP1", "CP2", "CP3"} # cobble fill pool (Default/Floor)
CANAL_PAINT_CODES = {"CN1", "CE_S", "CE_C", "CE_I", "CE_D"} # canal fill + edge transitions
PAINT_CODES = GROUND_CODES | CANAL_PAINT_CODES # ungrouped, brush-laid terrain
TRANSITION_CODES = {"CE_S", "CE_C", "CE_I", "CE_D"} # emit all 4 rotations (matrix)
def is_ground(spec):
return spec["code"] in GROUND_CODES
def is_paint(spec):
"""True = ungrouped terrain tile the brush auto-picks (never a click-place feature)."""
return spec["code"] in PAINT_CODES
def is_transition(spec):
return spec["code"] in TRANSITION_CODES
def _rot_corners(c): # one CCW 90° step: (TL,TR,BL,BR) -> (TR,BR,TL,BL)
return (c[1], c[3], c[0], c[2])
def _rot_edges(e): # one CCW 90° step: (N,E,S,W) -> (E,S,W,N)
return (e[1], e[2], e[3], e[0])
def base_corners(spec):
"""(TL,TR,BL,BR) each (terrain, height) — explicit override or edge-derived."""
if "corners" in spec:
return tuple(tuple(c) for c in spec["corners"])
c = nwn_corners(spec["edges"])
return (c["TopLeft"], c["TopRight"], c["BottomLeft"], c["BottomRight"])
def placements(spec):
"""[(orient_deg, corners4, edges4)] backing this tile's [TILE#] entries. A
transition tile emits each DISTINCT rotation (one model covers the whole
rotation class via Orientation the engine rotates model+walkmesh together),
so the paint matrix is complete without extra models. Others place upright."""
c, e = base_corners(spec), tuple(spec["edges"])
if not is_transition(spec):
return [(0, c, e)]
seen, out = set(), []
for k in range(4):
if c not in seen:
seen.add(c)
out.append((k * 90, c, e))
c, e = _rot_corners(c), _rot_edges(e)
return out
def canal_paint_matrix():
"""(want, covered, missing) sets of 4-corner cobble/canal patterns. `missing`
non-empty = a pattern the brush can request has no tile -> paint/New-Area crash.
The generator hard-fails on this, so an incomplete matrix can never stage."""
opts = [("cobble", 0), ("canal", 0)]
want = {(tl, tr, bl, br) for tl in opts for tr in opts for bl in opts for br in opts}
have = {c for s in TILES if is_paint(s) for _o, c, _e in placements(s)}
return want, have & want, want - have
def _terr(e):
return {"RAISED": "raised", "CANAL": "canal"}.get(e, "cobble")
+15
View File
@@ -0,0 +1,15 @@
DESIGN CONSTRAINTS
- Do not model buildings.
- Do not model full walls except retaining/canal/bridge support walls needed by terrain.
- Do not model roofs.
- Do not model decorative clutter except minimal drains/grates required to communicate tile purpose.
- Avoid tiny details.
- Prioritize edge alignment, walkability, and builder usefulness.
- Keep polygon counts low.
- Keep all tile edges snap-clean.
- Avoid overhangs at tile boundaries.
- Avoid geometry extending beyond -5/+5 bounds unless it is an explicitly marked harmless visual trim.
- Use bevels sparingly.
- Prefer clean simple geometry over visual polish.
- Use material slots consistently so final textures can replace prototype materials later.
+1
View File
@@ -0,0 +1 @@
Westgates intended atmosphere is noir dark fantasy: rain-slick streets, mercantile decay, old stone, smoke, fog, gothic silhouettes, and grimy desperation. The city also has an early industrial-magical layer: steam, alchemy, bound elementals, lightning engines, magical lamps, printing presses, and machinery beneath the gutters. For this pass, represent that only through ground grammar: drains, grates, canal cuts, raised streets, bridge foundations, dark cobbles, and industrial service channels. Do not make buildings.
BIN
View File
Binary file not shown.
-64
View File
@@ -1,64 +0,0 @@
"""Generate the tileset palette `twu01palstd.itp` from tiles.py.
python make_itp.py [out.itp] # default: twu01palstd.itp
The toolset loads `<set>palstd.itp` when it registers a tileset; without it the
set fails to register (blank name in the New Area wizard, "could not create
area"). Every stock/custom set on disk ships one. It's a GFF `ITP ` file: a MAIN
list of palette categories, each a struct with an ID byte and a LIST of
{RESREF, STRREF} tile entries.
We emit one flat category listing all 40 tile model resrefs. Each entry carries
a local NAME cexostring (NOT a STRREF) — this is what stock/custom sets without a
tileset TLK do (verified against wut01palstd.itp). Entries with neither NAME nor
a valid STRREF render blank AND non-selectable in the toolset palette. Built as
JSON and converted to GFF with neverwinter.nim's nwn_gff (must be on PATH).
"""
import json
import os
import subprocess
import sys
import tempfile
import tiles
NO_STRREF = 0xFFFFFFFF # category folder: no dialog.tlk name (cosmetic, blank folder)
def tile_label(s):
"""Readable palette name, e.g. '[flat] cobble_plain'."""
return f"[{s['kind']}] {s['name']}"
def build():
entries = [{"__struct_id": 0,
"NAME": {"type": "cexostring", "value": tile_label(s)},
"RESREF": {"type": "resref", "value": tiles.resref(s)}}
for s in tiles.TILES]
return {
"__data_type": "ITP ",
"MAIN": {"type": "list", "value": [
{"__struct_id": 0,
"ID": {"type": "byte", "value": 0},
"STRREF": {"type": "dword", "value": NO_STRREF},
"LIST": {"type": "list", "value": entries}},
]},
}
def main():
out = sys.argv[1] if len(sys.argv) > 1 else f"{tiles.PREFIX}palstd.itp"
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tf:
json.dump(build(), tf)
js = tf.name
try:
subprocess.run(["nwn_gff", "-i", js, "-l", "json", "-k", "gff", "-o", out],
check=True)
finally:
os.unlink(js)
print(f"wrote {out} ({len(tiles.TILES)} tile palette entries)")
if __name__ == "__main__":
main()
-110
View File
@@ -1,110 +0,0 @@
"""Generate the first-pass NWN tileset definition `twu01.set` from tiles.py.
python make_set.py [out.set] # default: twu01.set
Pure Python, no Blender. Emits a valid, DOS/CRLF `.set` (AGENTS.md §4/§9: Unix
line endings crash the toolset). Corners/heights/crossers derive from each tile's
edge grammar; this is a DRAFT to load and hand-tune in the toolset, not a final
set. See CAVEATS below.
CAVEATS (first pass — tune in the toolset):
* PathNode is a placeholder ('A' walkable / 'N' non-walkable). Real pathnode
letters govern creature routing and must be verified against the toolset.
* ImageMap2D references minimaps (twu01_<code>.tga) that are not rendered yet.
* Groups/features Count=0; add multi-tile groups later.
"""
import sys
import tiles
PREFIX = tiles.PREFIX
def tile_block(i, spec):
cor = tiles.nwn_corners(spec["edges"])
n, e, s, w = spec["edges"]
tl, tr, bl, br = cor["TopLeft"], cor["TopRight"], cor["BottomLeft"], cor["BottomRight"]
ref = tiles.resref(spec)
walkable = spec["kind"] != "canal"
# Field order/values mirror the stock BioWare sets (the toolset's .set reader
# is picky); WalkMesh=msb01 is the universal legacy label, not a file.
L = [f"[TILE{i}]"]
L += [
f"Model={ref}",
"WalkMesh=msb01",
f"TopLeft={tl[0]}", f"TopLeftHeight={tl[1]}",
f"TopRight={tr[0]}", f"TopRightHeight={tr[1]}",
f"BottomLeft={bl[0]}", f"BottomLeftHeight={bl[1]}",
f"BottomRight={br[0]}", f"BottomRightHeight={br[1]}",
f"Top={tiles.nwn_crosser(n)}",
f"Right={tiles.nwn_crosser(e)}",
f"Bottom={tiles.nwn_crosser(s)}",
f"Left={tiles.nwn_crosser(w)}",
"MainLight1=1", "MainLight2=1", "SourceLight1=1", "SourceLight2=1",
"AnimLoop1=1", "AnimLoop2=1", "AnimLoop3=1",
"Doors=0",
"Sounds=0",
f"PathNode={'A' if walkable else 'N'}", # placeholder — verify in toolset
"Orientation=0",
"VisibilityNode=A",
"VisibilityOrientation=0",
f"ImageMap2D={ref}", # minimap resref (no .tga yet; blank in toolset)
]
return L
def build():
L = []
L += [
"[GENERAL]",
f"Name={PREFIX}",
"Type=SET",
"Interior=0",
"HasHeightTransition=1",
"Transition=1.5", # metres per height step = Z=1.5 raised tier
"Border=cobble",
"Default=cobble",
"Floor=cobble",
"DisplayName=-1",
"UnlocalizedName=Westgate Urban Foundation",
"Version=V1.0",
"EnvMap=",
"SelectorHeight=3",
"",
"[GRASS]",
"Grass=0",
"GrassTextureName=",
"Density=0.0",
"Height=0.0",
"AmbientRed=0.0", "AmbientGreen=0.0", "AmbientBlue=0.0",
"DiffuseRed=0.0", "DiffuseGreen=0.0", "DiffuseBlue=0.0",
"",
"[TERRAIN TYPES]",
f"Count={len(tiles.TERRAINS)}",
]
for idx, t in enumerate(tiles.TERRAINS):
L += ["", f"[TERRAIN{idx}]", f"Name={t}", "StrRef=-1"]
L += ["", "[CROSSER TYPES]", f"Count={len(tiles.CROSSERS)}"]
for idx, cr in enumerate(tiles.CROSSERS):
L += ["", f"[CROSSER{idx}]", f"Name={cr}", "StrRef=-1"]
L += ["", "[PRIMARY RULES]", "Count=0",
"", "[SECONDARY RULES]", "Count=0",
"", "[TILES]", f"Count={len(tiles.TILES)}"]
for i, spec in enumerate(tiles.TILES):
L.append("")
L += tile_block(i, spec)
L += ["", "[GROUPS]", "Count=0", ""]
return L
def main():
out = sys.argv[1] if len(sys.argv) > 1 else f"{PREFIX}.set"
with open(out, "w", newline="") as fh: # newline='' + explicit \r\n = CRLF
fh.write("\r\n".join(build()))
print(f"wrote {out} ({len(tiles.TILES)} tiles, "
f"{len(tiles.TERRAINS)} terrains, {len(tiles.CROSSERS)} crossers, CRLF)")
if __name__ == "__main__":
main()
Binary file not shown.