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**.
|
||||
- **wgt01 uses a raised tier at Z = 1.5 m**, so its `Transition = 1.5` and the
|
||||
raised tiles sit at corner height `1`.
|
||||
- **"Raised" is a height, NOT a terrain type.** Verified vs `tcn01`: every raised
|
||||
corner is still `cobble` with `…Height=1`; only cobble ever carries a non-zero
|
||||
height (water/building stay at 0). Raise/Lower is the toolbar height tool acting on
|
||||
cobble corners — do **not** add a `raised` entry to `[TERRAIN TYPES]`. Making it
|
||||
paintable requires three things together: `HasHeightTransition=1`, a set of
|
||||
**ungrouped transition tiles** covering the cobble:0 ↔ cobble:1 corner patterns
|
||||
(ramps / retaining edges), and the `[PRIMARY RULES]` that let the brush step a
|
||||
corner's height. Any one missing and Raise/Lower is inert.
|
||||
- Walkable ground can only be *raised* in whole steps, never lowered below Z=0.
|
||||
Visual water/void may dip (e.g. canal at Z = −0.3) **only under a non-walkable
|
||||
walkmesh** — never a playable under/over space (matches `KICKOFF.md`).
|
||||
@@ -114,10 +122,34 @@ It needs, in the `.set`:
|
||||
`Floor` (the terrain the **eraser** paints), `HasHeightTransition=1` (turns on
|
||||
Raise/Lower).
|
||||
`[PRIMARY RULES]` only *refine* auto-picking (cascading corner transitions) and are
|
||||
**optional** — stock custom sets ship `Count=0` (verified: `wut01.set`). **Eraser,
|
||||
**optional even for multi-terrain paint** — verified: stock `wut01.set` paints **6
|
||||
terrains** with `[PRIMARY RULES] Count=0`. What actually makes a terrain paintable is
|
||||
having a `[TILE#]` for **every corner pattern the brush can request** (all rotations),
|
||||
each **ungrouped**. Rules just smooth cascading auto-transitions; stock `tcn01` (City,
|
||||
5 terrains + heights) ships **76** for polish, but you can ship `Count=0` and add them
|
||||
later. **Eraser,
|
||||
Raise/Lower, and fill are built-in toolbar actions driven by those `[GENERAL]`
|
||||
fields — they are not palette entries.**
|
||||
|
||||
**The random-fill pool (verified vs `tcn01`, 2026-07-04).** For a given corner
|
||||
pattern the brush picks a **random tile among the ungrouped tiles that match** —
|
||||
**membership in any `[GROUP#]` removes a tile from that pool.** So the ungrouped
|
||||
same-corner tiles *are* the fill: the cobble the `Default` terrain spawns a new area
|
||||
with, the variants the brush scatters, the tile the eraser resets to. Consequences
|
||||
that bite:
|
||||
- Grouping **every** tile empties the `Default` pool → the New Area wizard
|
||||
null-derefs (`Access violation … Write of 00000040`). Keep ≥1 ungrouped tile per
|
||||
paintable corner pattern.
|
||||
- **Every terrain/transition tile must stay UNGROUPED** to take part in painting.
|
||||
`[GROUP#]` is only for click-place **prefabs** (streets, bridges, statues) that the
|
||||
builder should *not* get at random. A "canal edge" you want the brush to auto-lay is
|
||||
a terrain tile (ungrouped), not a feature.
|
||||
- `tcn01`'s 513 tiles collapse to **58 distinct corner-tuples** — the surplus is art
|
||||
variety. A geometry-only set needs roughly **one tile per corner-tuple**, and one
|
||||
model can serve all four rotations of a tuple as four `[TILE#]` entries differing
|
||||
only in `Orientation` (0/90/180/270) and the correspondingly-rotated corners/edges
|
||||
(the engine rotates model **and** walkmesh together).
|
||||
|
||||
**System 2 — the palette (`.itp`): Features & Groups only.** The right-side
|
||||
"Standard" tree click-places **prefabs**: a *feature* (1×1) or a *group* (N×M). A
|
||||
palette entry is placeable **only if it is backed by a `[GROUP#]` in the `.set`** —
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
# wgt01 Texture Kit Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the 8-slot dark-gothic PBR texture kit for the wgt01 tileset (`wgt01/textures/`), regraded to the concept-art palette, with provenance notes and materials wired into `wgt01.blend`.
|
||||
|
||||
**Architecture:** Copy the best `tcn01` PBR maps out of `/home/vicky/Blender/PBR_Procampur` into a `wgt01_` namespace, run every diffuse through one shared ImageMagick regrade script, pull CC0 sets from ambientCG only for the plaza (and optionally sidewalk) slots, author the water map, then load everything into `wgt01.blend` as `MAT_wgt01_*` materials via headless Blender.
|
||||
|
||||
**Tech Stack:** bash + ImageMagick (`magick`), `curl` (ambientCG API), Blender 4.0.2 headless (via `nix develop ../twu01` → `$BLENDER_BIN`), NWN `.mtr` text files.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All shipped names ≤ 16 chars, lowercase, `wgt01_` prefix (spec: resref limit).
|
||||
- Kit lives in `wgt01/textures/`; never modify originals under `/home/vicky/Blender`.
|
||||
- Every diffuse goes through `wgt01/regrade.sh` — no hand-edited one-off images.
|
||||
- Height/normal maps copy through untouched.
|
||||
- Every shipped file gets a `SOURCES.md` line (origin path or CC0 URL + license).
|
||||
- TGA only in the kit (convert any DDS); NWN also reads TGA directly.
|
||||
- Working branch: `wgt01-tileset-kit`. NixOS: no ad-hoc global installs; use `nix shell nixpkgs#imagemagick -c` / the twu01 dev shell.
|
||||
- This is an asset pipeline, not TDD code: each task's "test" is a runnable visual or scripted check, and the plan's self-checks (`assert`-style bash) stand in for unit tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Kit scaffold + regrade script
|
||||
|
||||
**Files:**
|
||||
- Create: `wgt01/textures/` (dir), `wgt01/regrade.sh`, `wgt01/textures/SOURCES.md` (header only)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `regrade.sh <in> <out.tga>` — darkens ~35%, desaturates, cool-shifts any diffuse. All later tasks call it. Env overrides: `MOD` (modulate triple, default `65,55,100`), `TINT` (default `#232830`), `TINTPCT` (default `12`).
|
||||
|
||||
- [ ] **Step 1: Write the script**
|
||||
|
||||
```bash
|
||||
mkdir -p wgt01/textures
|
||||
cat > wgt01/regrade.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Uniform Westgate regrade: darken, desaturate, cool tint.
|
||||
# Usage: regrade.sh <in> <out.tga> (env: MOD, TINT, TINTPCT to override per-slot)
|
||||
set -euo pipefail
|
||||
MOD="${MOD:-65,55,100}"; TINT="${TINT:-#232830}"; TINTPCT="${TINTPCT:-12}"
|
||||
magick "$1" -modulate "$MOD" -fill "$TINT" -colorize "${TINTPCT}%" -level '0%,95%' "$2"
|
||||
EOF
|
||||
chmod +x wgt01/regrade.sh
|
||||
printf '# wgt01/textures — sources & licenses\n\n| file | origin | license |\n|---|---|---|\n' > wgt01/textures/SOURCES.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Self-check — regraded output is darker and less saturated**
|
||||
|
||||
```bash
|
||||
nix shell nixpkgs#imagemagick -c bash -c '
|
||||
./wgt01/regrade.sh /home/vicky/Blender/PBR_Procampur/tcn01_cobb02_d.tga /tmp/rg_test.tga
|
||||
b_in=$(magick /home/vicky/Blender/PBR_Procampur/tcn01_cobb02_d.tga -colorspace Gray -format "%[fx:mean]" info:)
|
||||
b_out=$(magick /tmp/rg_test.tga -colorspace Gray -format "%[fx:mean]" info:)
|
||||
awk -v a="$b_in" -v b="$b_out" "BEGIN{ if (b < a*0.75) print \"OK darker: \" a \" -> \" b; else { print \"FAIL\"; exit 1 } }"'
|
||||
```
|
||||
Expected: `OK darker: <in> -> <out>` with out ≲ 0.75×in.
|
||||
|
||||
- [ ] **Step 3: Eyeball it** — `magick montage` the original vs regraded cobble side by side, Read the PNG, compare against the concept sheet's wet-cobblestone swatch. Tune `MOD`/`TINT` defaults in the script if it reads too green or too bright; re-run Step 2.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add wgt01/regrade.sh wgt01/textures/SOURCES.md
|
||||
git commit -m "wgt01: texture kit scaffold + shared regrade script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Procampur slot extraction (slots 1,2,4,5,6 + grime decals)
|
||||
|
||||
**Files:**
|
||||
- Create: `wgt01/textures/wgt01_{cobble,worn,sidewlk,wall,bridge}_{d,n,h}.tga`, matching `wgt01_<slot>.mtr`, `wgt01_grime1_d.tga`, `wgt01_wtrline_d.tga`
|
||||
- Modify: `wgt01/textures/SOURCES.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `wgt01/regrade.sh` (Task 1).
|
||||
- Produces: slot basenames `wgt01_cobble`, `wgt01_worn`, `wgt01_sidewlk`, `wgt01_wall`, `wgt01_bridge` (each `_d/_n/_h` + `.mtr`) — Tasks 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
|
||||
`bitmap twu01_wcob`. Walkmesh surfacemats: 4 (`wok_Stone`, walkable) / 7
|
||||
(`wok_Nonwalk`). Matches the stock `trs02` tile structure field-for-field.
|
||||
- **`.itp`**: `twu01palstd.itp` round-trips through `nwn_gff` — `ITP ` type,
|
||||
40 named entries. Names render, but the **structure is wrong** (flat folder of
|
||||
raw tile resrefs → unplaceable, no Terrain/eraser folder). See Diagnosis below;
|
||||
needs a rebuild to the 3-folder Terrain/Features/Groups shape.
|
||||
- **`.set`**: CRLF, 40 tiles, 3 terrains (`cobble`/`raised`/`canal`), 2 crossers
|
||||
(`ramp`/`canal_edge`). `Version=V1.0`, stock field order (`WalkMesh=msb01`,
|
||||
lights=1, `VisibilityNode=A`, `ImageMap2D` last). Corners/heights from each
|
||||
tile's N/E/S/W edge grammar; non-walkable tiles → `PathNode=N`.
|
||||
- **`.itp` (rebuilt 2026-07-04, pass 4)**: `twu01palstd.itp` round-trips through
|
||||
`nwn_gff` with the **real structure** (../AGENTS.md §2.3): a **Features** folder
|
||||
(STRREF 63261) with a leaf per **non-ground** tile (37), and a **Terrain** folder
|
||||
(STRREF 8282) = paint `cobble` + the magic `eraser` leaf (STRREF 63291). Ground
|
||||
tiles (CP1/CP2/CP3) are omitted from Features on purpose — they're the painted
|
||||
fill, not click-placed. Every feature leaf is backed by a 1×1 `[GROUP#]` in the
|
||||
`.set` (Name==leaf NAME, `Tile0`==that tile). Guarded by
|
||||
`generator/test_namesync.py` (passes). **Pending GUI re-test.**
|
||||
- **`.set` (pass 4)**: CRLF, 40 tiles, 3 terrains (`cobble`/`raised`/`canal`), 2
|
||||
crossers (`ramp`/`canal_edge`). `HasHeightTransition=0` (MVP paints flat cobble
|
||||
only; matches stock). **37 `[GROUP#]`, one per non-ground tile — CP1/CP2/CP3 stay
|
||||
ungrouped so they remain the random terrain-fill pool** (see Root cause below).
|
||||
`Version=V1.0`, stock field order. Corners/heights from each tile's edge grammar;
|
||||
non-walkable tiles → `PathNode=N`.
|
||||
- **install path**: `~/Documents/Neverwinter Nights/development` is a symlink to
|
||||
`~/.local/share/Neverwinter Nights/development` (the toolset's real dir); one
|
||||
`nix run .#dev-install` stages all 94 loose files there.
|
||||
@@ -125,17 +131,81 @@ label, LIST}`; leaves `{NAME, RESREF}`. Folder 8282 = **Terrain** (leaf RESREF =
|
||||
terrain/crosser *name*, plus a magic `eraser` leaf STRREF 63291); 63261 =
|
||||
**Features** (1×1, RESREF = first-tile resref); 63262 = **Groups** (N×M).
|
||||
|
||||
## Next passes
|
||||
## Root cause 2026-07-04 — New Area crash (pass 3 regression, fixed pass 4)
|
||||
|
||||
1. **Rebuild `make_itp.py` + `make_set.py` to the real structure** (../AGENTS.md
|
||||
§2.3):
|
||||
- `.itp`: a **Terrain** folder (each `tiles.TERRAINS`/`CROSSERS` by name + an
|
||||
`eraser` leaf) so terrain-painting + eraser exist; a **Features** folder with a
|
||||
1×1 entry per tile we want click-placeable.
|
||||
- `.set`: add `[GROUP#]` (1×1 feature) entries backing those Features leaves
|
||||
(`Name` == palette NAME, `Tile0` == that tile's index).
|
||||
- Then in the toolset lay ground via **Paint Terrain** (cobble/raised/canal), not
|
||||
by clicking tiles.
|
||||
Creating a new area threw `Access violation ... Write of address 00000040` in
|
||||
`nwtoolset.exe`. **Root cause: an empty terrain-fill pool.** A grouped tile is
|
||||
excluded from the toolset's random fill; pass 3 put *all 40* tiles in 1×1 groups,
|
||||
so `Default=cobble` had **no** tile to fill with → null deref. Verified against
|
||||
stock `tcn01` (City Exterior): of its 320 cobble-cornered tiles, 314 are group
|
||||
members and only **6** (non-group `a20_*`/`o01`) form the real fill pool.
|
||||
|
||||
**Fix (pass 4):** ground tiles `CP1/CP2/CP3` (`tiles.GROUND_CODES`) are left
|
||||
ungrouped → they are the interchangeable cobble the area spawns with and the
|
||||
eraser resets to. The other 37 tiles keep their `[GROUP#]` (palette features).
|
||||
Also set `HasHeightTransition=0` (matches stock; we don't paint height
|
||||
transitions this pass). `test_namesync.py::test_ground_tiles_stay_in_fill_pool`
|
||||
is the regression guard. This also fixes the earlier "random different tiles at
|
||||
different elevations" fill — only CP1/CP2/CP3 now auto-fill.
|
||||
|
||||
## Pass 4 result (2026-07-04, confirmed in toolset)
|
||||
|
||||
✅ New Area succeeds, spawns plain cobble. ✅ Features click-place (the `[flat]`
|
||||
prefabs). ✅ Eraser resets to cobble. ❌ **Canal and Raise/Lower are inert** — as
|
||||
predicted by the ponytail note. Root cause (now fully understood, see AGENTS.md
|
||||
§2.2/§2.3):
|
||||
1. Canal & raised tiles are `[GROUP#]` features → excluded from the paint pool, so
|
||||
the brush has nothing to auto-lay.
|
||||
2. Only `cobble` is exposed in the `.itp` Terrain folder → no canal brush.
|
||||
3. `HasHeightTransition=0` → Raise/Lower greyed out.
|
||||
4. `[PRIMARY RULES] Count=0` → the brush can't reconcile a 2nd terrain / a height
|
||||
step against neighbours.
|
||||
5. `raised` is modelled as a *terrain* (`TERRAINS`), but in NWN it's a **height on
|
||||
cobble** — the model is wrong.
|
||||
|
||||
## Pass 5a done (2026-07-04) — CANAL is now paintable
|
||||
|
||||
Staged via `nix run .#dev-install`: 51 tile placements from 41 models, 33 feature
|
||||
groups, matrix complete, `namesync OK`. What changed vs pass 4:
|
||||
- `tiles.py`: split spec→placement (`placements()`, rotate one model into 4
|
||||
orientations); `is_paint`/`is_transition`; added diagonal-pinch tile `CE_D`;
|
||||
fixed `CE_I` corners (its geometry is 1 canal corner, edges wrongly read 3);
|
||||
`canal_paint_matrix()` completeness helper.
|
||||
- `make_set.py`: emits rotated `[TILE#]` placements, groups only NON-paint tiles,
|
||||
blanks crossers on paint tiles (corner-match only), **hard-fails if the 16-pattern
|
||||
cobble/canal matrix is incomplete** (caught the CE_I bug).
|
||||
- `make_itp.py`: Terrain folder = `cobble` + `canal` + eraser; Features = non-paint.
|
||||
- `HasHeightTransition` still 0 (raise/lower = pass 5b).
|
||||
|
||||
**Re-test:** (a) is there a **canal** brush in Terrain, and does painting it lay
|
||||
black-water tiles? (b) do canal/land **edges auto-appear** (straight, both corners,
|
||||
diagonal) without seams or gaps? (c) does the **eraser** still reset to cobble, and
|
||||
New Area still spawn cobble (no regression)? (d) do the click-place features (streets
|
||||
etc.) still place? Report back — then 5b adds raise/lower on the same machinery.
|
||||
|
||||
## Pass 5b plan — raise/lower (next) + original full plan below
|
||||
|
||||
Reference: stock `tcn01` (513 tiles → 58 corner-tuples; 76 primary rules;
|
||||
`HasHeightTransition=1`). Our geometry-only version needs ~1 tile per tuple.
|
||||
|
||||
- **Model change in `tiles.py`:** split *tile spec* (a model) from *placement* (a
|
||||
`[TILE#]` = model + `Orientation` + rotated corners/edges). One transition model
|
||||
→ up to 4 placements (0/90/180/270). Drop `raised` from `TERRAINS` →
|
||||
`TERRAINS=["cobble","canal"]`; raised = `cobble` corner at `Height=1`.
|
||||
- **Ungroup the terrain tiles:** canal fill, canal-edge (straight/outer/inner),
|
||||
ramp, retaining-edge (straight/outer/inner) leave `[GROUPS]` and join the paint
|
||||
pool. Streets/sidewalks/bridges/drains **stay** grouped features.
|
||||
- **Corner matrix:** generate the full cobble/canal patterns and the full
|
||||
cobble:0/cobble:1 patterns (via orientation) so the brush never requests a missing
|
||||
tile. Keep canal and raised in **separate** matrices (a tile is never
|
||||
canal-corner + raised-corner) to bound the count.
|
||||
- **`[PRIMARY RULES]`:** skip (stay `Count=0`). Verified not required — `wut01`
|
||||
paints 6 terrains with `Count=0`. They only polish cascading transitions; add
|
||||
later if seams need it.
|
||||
- **`[GENERAL]`:** `HasHeightTransition=1`.
|
||||
- **`.itp` Terrain folder:** `cobble`, `canal`, + `eraser`.
|
||||
- **Guard:** a completeness self-check that fails generation if any corner pattern
|
||||
the brush can request lacks a tile (so an incomplete matrix can't ship a crash).
|
||||
2. Walk-test → tune PathNodes, crossers, corner terrains; add a tileset TLK if
|
||||
we later want localized palette names (local `NAME` strings suffice for now).
|
||||
3. **Textures (art pass, separate note).** Source assets live in
|
||||
BIN
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 standalone `.wok` (same walkmesh) is written alongside.
|
||||
|
||||
Textures: a solid-colour <=16-char .tga per tiles.TEXTURES (KICKOFF.md: geometry
|
||||
not art; flat colours double as an in-toolset grammar read-out). Real art can
|
||||
replace these files later without touching geometry.
|
||||
Textures: a <=16-char .tga per tiles.TEXTURES. Each is a procedural structural
|
||||
read-out (distinct role colour + dark border + orientation pip) so geometry reads
|
||||
in the toolset (KICKOFF.md: geometry not art); SRC_TEX swaps in a real stock tga
|
||||
where a photographic surface helps (e.g. canal water). Real art can replace any of
|
||||
these files later without touching geometry.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import tiles
|
||||
@@ -27,17 +30,42 @@ import make_wok
|
||||
|
||||
VIS_MATID = 65536 # constant last-field on visible faces (matches stock tiles)
|
||||
|
||||
# Real stock-NWN textures copied verbatim for surfaces where a photographic read
|
||||
# beats a flat colour (user: "pull cobbles/water from refs"). Everything else is
|
||||
# the procedural structural read-out below. Missing source -> procedural fallback.
|
||||
REFS = "/home/vicky/Projects/nwnee-refs"
|
||||
SRC_TEX = {
|
||||
"MAT_canal_black_water": f"{REFS}/aurora_tin.bif/tin01_water01.tga", # animated-look water
|
||||
}
|
||||
|
||||
TEX_N = 64 # texture size (power of 2)
|
||||
_BORDER = 3 # dark inset ring — makes within-tile paint seams read
|
||||
_PIP = 9 # bright corner pip — reveals tile ORIENTATION (debug rotations)
|
||||
|
||||
|
||||
def write_tga(path, rgb):
|
||||
"""Minimal 2x2 uncompressed 24-bit TGA in solid `rgb` (0..1 floats)."""
|
||||
"""64x64 24-bit TGA: base `rgb` (0..1), a dark inset border, and a white
|
||||
corner pip. Not art — a structural read-out: the pip exposes each tile's
|
||||
orientation (so the paint auto-picker's rotations are visible) and the border
|
||||
outlines internal texture edges. TGA origin is lower-left (descriptor 0), so
|
||||
the pip sits at the tile's south-west (x=-5, y=-5) corner."""
|
||||
r, g, b = (max(0, min(255, round(c * 255))) for c in rgb)
|
||||
w = h = 2
|
||||
header = bytes([0, 0, 2, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
w & 0xFF, w >> 8, h & 0xFF, h >> 8, 24, 0])
|
||||
body = bytes([b, g, r]) * (w * h) # TGA stores BGR
|
||||
dr, dg, db = (v * 35 // 100 for v in (r, g, b)) # 0.35x -> border
|
||||
n = TEX_N
|
||||
body = bytearray()
|
||||
for j in range(n):
|
||||
for i in range(n):
|
||||
if i < _PIP and j < _PIP:
|
||||
body += bytes((255, 255, 255)) # pip
|
||||
elif i < _BORDER or i >= n - _BORDER or j < _BORDER or j >= n - _BORDER:
|
||||
body += bytes((db, dg, dr)) # border (BGR)
|
||||
else:
|
||||
body += bytes((b, g, r)) # base (BGR)
|
||||
assert len(body) == n * n * 3
|
||||
header = bytes([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
n & 0xFF, n >> 8, n & 0xFF, n >> 8, 24, 0])
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(header + body)
|
||||
fh.write(header + bytes(body))
|
||||
|
||||
|
||||
def _trimesh(ref, idx, tex, verts, quads):
|
||||
@@ -128,9 +156,15 @@ def main():
|
||||
with open(os.path.join(outdir, ref + ".wok"), "w") as fh:
|
||||
fh.write(wok)
|
||||
|
||||
# write every texture (not just `used`) so nothing dangles if a tile changes
|
||||
# write every texture (not just `used`) so nothing dangles if a tile changes;
|
||||
# copy a real stock tga where SRC_TEX has one, else the procedural read-out.
|
||||
for mat, tex in tiles.TEXTURES.items():
|
||||
write_tga(os.path.join(outdir, tex + ".tga"), tiles.COLORS[mat])
|
||||
dst = os.path.join(outdir, tex + ".tga")
|
||||
src = SRC_TEX.get(mat)
|
||||
if src and os.path.exists(src):
|
||||
shutil.copyfile(src, dst)
|
||||
else:
|
||||
write_tga(dst, tiles.COLORS[mat])
|
||||
|
||||
print(f"wrote {len(tiles.TILES)} .mdl + .wok and "
|
||||
f"{len(tiles.TEXTURES)} .tga to {outdir}/")
|
||||
@@ -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_WATER = -0.3
|
||||
|
||||
# --- prototype materials (flat viewport colours; KICKOFF.md wants geometry, not art)
|
||||
# --- prototype materials. Bright, DISTINCT per role so structure reads at a glance
|
||||
# in the toolset (KICKOFF.md wants geometry not art; the old near-black palette made
|
||||
# every tile invisible). Curb/edge is deliberately loud so canal/height edges pop.
|
||||
COLORS = {
|
||||
"MAT_wet_cobble_dark": (0.05, 0.05, 0.06),
|
||||
"MAT_cobble_patch": (0.12, 0.10, 0.09),
|
||||
"MAT_plaza_slab": (0.30, 0.29, 0.27),
|
||||
"MAT_sidewalk_stone": (0.22, 0.22, 0.23),
|
||||
"MAT_curb_dark_stone": (0.10, 0.10, 0.11),
|
||||
"MAT_canal_black_water": (0.02, 0.03, 0.05),
|
||||
"MAT_void_black": (0.00, 0.00, 0.00),
|
||||
"MAT_ramp_stone": (0.18, 0.16, 0.14),
|
||||
"MAT_drain_iron": (0.08, 0.08, 0.09),
|
||||
"MAT_foundation_pad": (0.25, 0.24, 0.22),
|
||||
"MAT_wet_cobble_dark": (0.42, 0.42, 0.47), # cobble ground — cool mid grey
|
||||
"MAT_cobble_patch": (0.50, 0.34, 0.22), # repair patch — brown
|
||||
"MAT_plaza_slab": (0.74, 0.68, 0.52), # plaza — warm tan
|
||||
"MAT_sidewalk_stone": (0.64, 0.65, 0.70), # sidewalk — light grey
|
||||
"MAT_curb_dark_stone": (0.98, 0.55, 0.08), # EDGES/curbs — loud orange (pop)
|
||||
"MAT_canal_black_water": (0.10, 0.38, 0.80), # canal — strong blue (real tga overrides)
|
||||
"MAT_void_black": (0.03, 0.03, 0.05),
|
||||
"MAT_ramp_stone": (0.80, 0.25, 0.68), # ramps/stairs — magenta (height reads)
|
||||
"MAT_drain_iron": (0.22, 0.24, 0.30), # iron — dark steel
|
||||
"MAT_foundation_pad": (0.58, 0.54, 0.34), # lots — olive
|
||||
"MAT_debug_walkmesh": (0.00, 0.80, 0.20),
|
||||
"MAT_debug_blocker": (0.80, 0.10, 0.10),
|
||||
}
|
||||
@@ -108,7 +110,15 @@ TILES = [
|
||||
T("CN2", "narrow_drainage_cut", "cut", (C, F, C, F), "cut"),
|
||||
T("CE_S", "canal_edge_straight", "canal_edge", (F, C, F, F), "canal_edge", which="E"),
|
||||
T("CE_C", "canal_edge_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_outer"),
|
||||
T("CE_I", "canal_edge_inside_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_inner"),
|
||||
# Inside (concave) corner: geometry (ES_inner) is canal in the SE quadrant only,
|
||||
# i.e. ONE canal corner at BR — the edge tuple would wrongly read as 3, so pin it.
|
||||
T("CE_I", "canal_edge_inside_corner", "canal_edge", (F, C, C, F), "canal_edge",
|
||||
which="ES_inner", corners=[("cobble", 0), ("cobble", 0), ("cobble", 0), ("canal", 0)]),
|
||||
# Diagonal "pinch": canal on TL+BR, land on TR+BL. Edge-derived corners can't
|
||||
# express a diagonal (see nwn_corners), so this one carries explicit corners.
|
||||
# Completes the 16-pattern cobble/canal paint matrix (canal_paint_matrix()).
|
||||
T("CE_D", "canal_edge_diagonal_pinch", "canal_edge", (C, C, C, C), "canal_edge",
|
||||
which="diag", corners=[("canal", 0), ("cobble", 0), ("cobble", 0), ("canal", 0)]),
|
||||
T("BR_1", "narrow_bridge", "bridge", (F, C, F, C), "bridge", hw=1.5),
|
||||
T("BR_2", "broad_bridge", "bridge", (F, C, F, C), "bridge", hw=3.0),
|
||||
T("SEW", "sewer_mouth_culvert", "canal_edge", (F, F, C, F), "sewer", which="S"),
|
||||
@@ -164,6 +174,7 @@ def _land_fn(which):
|
||||
"S": lambda x, y: y >= 0.0, # canal on south
|
||||
"ES_outer": lambda x, y: (x <= 0.0 and y >= 0.0), # land = NW quadrant only
|
||||
"ES_inner": lambda x, y: not (x > 0.0 and y < 0.0), # land = all but SE quadrant
|
||||
"diag": lambda x, y: (x >= 0.0 and y >= 0.0) or (x <= 0.0 and y <= 0.0), # land NE+SW
|
||||
}[which]
|
||||
|
||||
|
||||
@@ -296,6 +307,9 @@ def wok_quads(spec):
|
||||
if w == "ES_inner":
|
||||
return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, 0, 5, 0.0), True),
|
||||
(_rect(0, 5, -5, 0, 0.0), False)]
|
||||
if w == "diag": # NE+SW land, NW+SE canal
|
||||
return [(_rect(0, 5, 0, 5, 0.0), True), (_rect(-5, 0, -5, 0, 0.0), True),
|
||||
(_rect(-5, 0, 0, 5, 0.0), False), (_rect(0, 5, -5, 0, 0.0), False)]
|
||||
if k == "bridge":
|
||||
hw = spec.get("hw", 1.5)
|
||||
return [(_rect(-hw, hw, -5, 5, 0.2), True),
|
||||
@@ -341,6 +355,79 @@ def resref(spec):
|
||||
return f"{PREFIX}_{spec['code'].lower()}" # <=16 chars, valid identifier
|
||||
|
||||
|
||||
def group_name(spec):
|
||||
"""Palette label for a tile — MUST be identical in the .itp leaf NAME and the
|
||||
.set [GROUP#] Name (name-sync gotcha, AGENTS.md §5). e.g. '[flat] cobble_plain'."""
|
||||
return f"[{spec['kind']}] {spec['name']}"
|
||||
|
||||
|
||||
# --- paint pool vs palette features (AGENTS.md §2.3) --------------------------
|
||||
# A tile in ANY [GROUP#] is excluded from the toolset's terrain-paint pool. So the
|
||||
# tiles the brush auto-lays (fills + terrain transitions) must stay UNGROUPED; only
|
||||
# click-place prefabs get grouped. Grouping *every* tile empties the fill pool ->
|
||||
# the New Area wizard null-derefs ("Access violation ... Write of 00000040").
|
||||
GROUND_CODES = {"CP1", "CP2", "CP3"} # cobble fill pool (Default/Floor)
|
||||
CANAL_PAINT_CODES = {"CN1", "CE_S", "CE_C", "CE_I", "CE_D"} # canal fill + edge transitions
|
||||
PAINT_CODES = GROUND_CODES | CANAL_PAINT_CODES # ungrouped, brush-laid terrain
|
||||
TRANSITION_CODES = {"CE_S", "CE_C", "CE_I", "CE_D"} # emit all 4 rotations (matrix)
|
||||
|
||||
|
||||
def is_ground(spec):
|
||||
return spec["code"] in GROUND_CODES
|
||||
|
||||
|
||||
def is_paint(spec):
|
||||
"""True = ungrouped terrain tile the brush auto-picks (never a click-place feature)."""
|
||||
return spec["code"] in PAINT_CODES
|
||||
|
||||
|
||||
def is_transition(spec):
|
||||
return spec["code"] in TRANSITION_CODES
|
||||
|
||||
|
||||
def _rot_corners(c): # one CCW 90° step: (TL,TR,BL,BR) -> (TR,BR,TL,BL)
|
||||
return (c[1], c[3], c[0], c[2])
|
||||
|
||||
|
||||
def _rot_edges(e): # one CCW 90° step: (N,E,S,W) -> (E,S,W,N)
|
||||
return (e[1], e[2], e[3], e[0])
|
||||
|
||||
|
||||
def base_corners(spec):
|
||||
"""(TL,TR,BL,BR) each (terrain, height) — explicit override or edge-derived."""
|
||||
if "corners" in spec:
|
||||
return tuple(tuple(c) for c in spec["corners"])
|
||||
c = nwn_corners(spec["edges"])
|
||||
return (c["TopLeft"], c["TopRight"], c["BottomLeft"], c["BottomRight"])
|
||||
|
||||
|
||||
def placements(spec):
|
||||
"""[(orient_deg, corners4, edges4)] backing this tile's [TILE#] entries. A
|
||||
transition tile emits each DISTINCT rotation (one model covers the whole
|
||||
rotation class via Orientation — the engine rotates model+walkmesh together),
|
||||
so the paint matrix is complete without extra models. Others place upright."""
|
||||
c, e = base_corners(spec), tuple(spec["edges"])
|
||||
if not is_transition(spec):
|
||||
return [(0, c, e)]
|
||||
seen, out = set(), []
|
||||
for k in range(4):
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
out.append((k * 90, c, e))
|
||||
c, e = _rot_corners(c), _rot_edges(e)
|
||||
return out
|
||||
|
||||
|
||||
def canal_paint_matrix():
|
||||
"""(want, covered, missing) sets of 4-corner cobble/canal patterns. `missing`
|
||||
non-empty = a pattern the brush can request has no tile -> paint/New-Area crash.
|
||||
The generator hard-fails on this, so an incomplete matrix can never stage."""
|
||||
opts = [("cobble", 0), ("canal", 0)]
|
||||
want = {(tl, tr, bl, br) for tl in opts for tr in opts for bl in opts for br in opts}
|
||||
have = {c for s in TILES if is_paint(s) for _o, c, _e in placements(s)}
|
||||
return want, have & want, want - have
|
||||
|
||||
|
||||
def _terr(e):
|
||||
return {"RAISED": "raised", "CANAL": "canal"}.get(e, "cobble")
|
||||
|
||||
@@ -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