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**. 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 - **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 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. - 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 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`). 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 `Floor` (the terrain the **eraser** paints), `HasHeightTransition=1` (turns on
Raise/Lower). Raise/Lower).
`[PRIMARY RULES]` only *refine* auto-picking (cascading corner transitions) and are `[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]` Raise/Lower, and fill are built-in toolbar actions driven by those `[GENERAL]`
fields — they are not palette entries.** 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 **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 "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`** 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 walkmesh. E.g. `st_s` → 3 trimesh nodes (swlk/curb/wcob); `cp1` → 1 with
`bitmap twu01_wcob`. Walkmesh surfacemats: 4 (`wok_Stone`, walkable) / 7 `bitmap twu01_wcob`. Walkmesh surfacemats: 4 (`wok_Stone`, walkable) / 7
(`wok_Nonwalk`). Matches the stock `trs02` tile structure field-for-field. (`wok_Nonwalk`). Matches the stock `trs02` tile structure field-for-field.
- **`.itp`**: `twu01palstd.itp` round-trips through `nwn_gff``ITP ` type, - **`.itp` (rebuilt 2026-07-04, pass 4)**: `twu01palstd.itp` round-trips through
40 named entries. Names render, but the **structure is wrong** (flat folder of `nwn_gff` with the **real structure** (../AGENTS.md §2.3): a **Features** folder
raw tile resrefs → unplaceable, no Terrain/eraser folder). See Diagnosis below; (STRREF 63261) with a leaf per **non-ground** tile (37), and a **Terrain** folder
needs a rebuild to the 3-folder Terrain/Features/Groups shape. (STRREF 8282) = paint `cobble` + the magic `eraser` leaf (STRREF 63291). Ground
- **`.set`**: CRLF, 40 tiles, 3 terrains (`cobble`/`raised`/`canal`), 2 crossers tiles (CP1/CP2/CP3) are omitted from Features on purpose — they're the painted
(`ramp`/`canal_edge`). `Version=V1.0`, stock field order (`WalkMesh=msb01`, fill, not click-placed. Every feature leaf is backed by a 1×1 `[GROUP#]` in the
lights=1, `VisibilityNode=A`, `ImageMap2D` last). Corners/heights from each `.set` (Name==leaf NAME, `Tile0`==that tile). Guarded by
tile's N/E/S/W edge grammar; non-walkable tiles → `PathNode=N`. `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 - **install path**: `~/Documents/Neverwinter Nights/development` is a symlink to
`~/.local/share/Neverwinter Nights/development` (the toolset's real dir); one `~/.local/share/Neverwinter Nights/development` (the toolset's real dir); one
`nix run .#dev-install` stages all 94 loose files there. `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 = terrain/crosser *name*, plus a magic `eraser` leaf STRREF 63291); 63261 =
**Features** (1×1, RESREF = first-tile resref); 63262 = **Groups** (N×M). **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 Creating a new area threw `Access violation ... Write of address 00000040` in
§2.3): `nwtoolset.exe`. **Root cause: an empty terrain-fill pool.** A grouped tile is
- `.itp`: a **Terrain** folder (each `tiles.TERRAINS`/`CROSSERS` by name + an excluded from the toolset's random fill; pass 3 put *all 40* tiles in 1×1 groups,
`eraser` leaf) so terrain-painting + eraser exist; a **Features** folder with a so `Default=cobble` had **no** tile to fill with → null deref. Verified against
1×1 entry per tile we want click-placeable. stock `tcn01` (City Exterior): of its 320 cobble-cornered tiles, 314 are group
- `.set`: add `[GROUP#]` (1×1 feature) entries backing those Features leaves members and only **6** (non-group `a20_*`/`o01`) form the real fill pool.
(`Name` == palette NAME, `Tile0` == that tile's index).
- Then in the toolset lay ground via **Paint Terrain** (cobble/raised/canal), not **Fix (pass 4):** ground tiles `CP1/CP2/CP3` (`tiles.GROUND_CODES`) are left
by clicking tiles. 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 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). we later want localized palette names (local `NAME` strings suffice for now).
3. **Textures (art pass, separate note).** Source assets live in 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 aabb walkmesh node (shared with make_wok, embedded per NWN tile rules).
The standalone `.wok` (same walkmesh) is written alongside. The standalone `.wok` (same walkmesh) is written alongside.
Textures: a solid-colour <=16-char .tga per tiles.TEXTURES (KICKOFF.md: geometry Textures: a <=16-char .tga per tiles.TEXTURES. Each is a procedural structural
not art; flat colours double as an in-toolset grammar read-out). Real art can read-out (distinct role colour + dark border + orientation pip) so geometry reads
replace these files later without touching geometry. 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 os
import shutil
import sys import sys
import tiles import tiles
@@ -27,17 +30,42 @@ import make_wok
VIS_MATID = 65536 # constant last-field on visible faces (matches stock tiles) 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): 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) r, g, b = (max(0, min(255, round(c * 255))) for c in rgb)
w = h = 2 dr, dg, db = (v * 35 // 100 for v in (r, g, b)) # 0.35x -> border
header = bytes([0, 0, 2, 0, 0, 0, 0, 0, n = TEX_N
0, 0, 0, 0, body = bytearray()
w & 0xFF, w >> 8, h & 0xFF, h >> 8, 24, 0]) for j in range(n):
body = bytes([b, g, r]) * (w * h) # TGA stores BGR 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: with open(path, "wb") as fh:
fh.write(header + body) fh.write(header + bytes(body))
def _trimesh(ref, idx, tex, verts, quads): def _trimesh(ref, idx, tex, verts, quads):
@@ -128,9 +156,15 @@ def main():
with open(os.path.join(outdir, ref + ".wok"), "w") as fh: with open(os.path.join(outdir, ref + ".wok"), "w") as fh:
fh.write(wok) 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(): 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 " print(f"wrote {len(tiles.TILES)} .mdl + .wok and "
f"{len(tiles.TEXTURES)} .tga to {outdir}/") 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_RAISED = 1.5
Z_WATER = -0.3 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 = { COLORS = {
"MAT_wet_cobble_dark": (0.05, 0.05, 0.06), "MAT_wet_cobble_dark": (0.42, 0.42, 0.47), # cobble ground — cool mid grey
"MAT_cobble_patch": (0.12, 0.10, 0.09), "MAT_cobble_patch": (0.50, 0.34, 0.22), # repair patch — brown
"MAT_plaza_slab": (0.30, 0.29, 0.27), "MAT_plaza_slab": (0.74, 0.68, 0.52), # plaza — warm tan
"MAT_sidewalk_stone": (0.22, 0.22, 0.23), "MAT_sidewalk_stone": (0.64, 0.65, 0.70), # sidewalk — light grey
"MAT_curb_dark_stone": (0.10, 0.10, 0.11), "MAT_curb_dark_stone": (0.98, 0.55, 0.08), # EDGES/curbs — loud orange (pop)
"MAT_canal_black_water": (0.02, 0.03, 0.05), "MAT_canal_black_water": (0.10, 0.38, 0.80), # canal — strong blue (real tga overrides)
"MAT_void_black": (0.00, 0.00, 0.00), "MAT_void_black": (0.03, 0.03, 0.05),
"MAT_ramp_stone": (0.18, 0.16, 0.14), "MAT_ramp_stone": (0.80, 0.25, 0.68), # ramps/stairs — magenta (height reads)
"MAT_drain_iron": (0.08, 0.08, 0.09), "MAT_drain_iron": (0.22, 0.24, 0.30), # iron — dark steel
"MAT_foundation_pad": (0.25, 0.24, 0.22), "MAT_foundation_pad": (0.58, 0.54, 0.34), # lots — olive
"MAT_debug_walkmesh": (0.00, 0.80, 0.20), "MAT_debug_walkmesh": (0.00, 0.80, 0.20),
"MAT_debug_blocker": (0.80, 0.10, 0.10), "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("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_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_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_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("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"), 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 "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_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 "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] }[which]
@@ -296,6 +307,9 @@ def wok_quads(spec):
if w == "ES_inner": if w == "ES_inner":
return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, 0, 5, 0.0), True), 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)] (_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": if k == "bridge":
hw = spec.get("hw", 1.5) hw = spec.get("hw", 1.5)
return [(_rect(-hw, hw, -5, 5, 0.2), True), 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 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): def _terr(e):
return {"RAISED": "raised", "CANAL": "canal"}.get(e, "cobble") 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.