wgt01
This commit is contained in:
@@ -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 **2–3 m**.
|
Decimals allowed. BioWare original content used **5 m**; custom sets use **2–3 m**.
|
||||||
- **wgt01 uses a raised tier at Z = 1.5 m**, so its `Transition = 1.5` and the
|
- **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 5–6 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 5–6.
|
||||||
|
|
||||||
|
- [ ] **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 5–6.
|
||||||
|
|
||||||
|
- [ ] **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 2–4.
|
||||||
|
- 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.
|
||||||
@@ -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
Binary file not shown.
@@ -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}/")
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Westgate’s 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
Binary file not shown.
@@ -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()
|
|
||||||
@@ -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.
Reference in New Issue
Block a user