diff --git a/AGENTS.md b/AGENTS.md index 378d4a1..35cece4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`** — diff --git a/docs/superpowers/plans/2026-07-05-wgt01-texture-kit.md b/docs/superpowers/plans/2026-07-05-wgt01-texture-kit.md new file mode 100644 index 0000000..fb9750a --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-wgt01-texture-kit.md @@ -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 ` — 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 (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: -> ` 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_.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=_1K-JPG.zip' && nix shell nixpkgs#unzip -c unzip -o plaza.zip -d plaza/ +ls plaza/ # expect _1K_Color.jpg, _NormalGL.jpg, _Displacement.jpg +``` + +- [ ] **Step 2: Convert + regrade into the kit** + +```bash +cd /wgt01 && S=/plaza A= +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_` (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"`. diff --git a/docs/superpowers/specs/2026-07-05-wgt01-texture-kit-design.md b/docs/superpowers/specs/2026-07-05-wgt01-texture-kit-design.md new file mode 100644 index 0000000..755aa5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-wgt01-texture-kit-design.md @@ -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__d/_h/_n.tga` + (+ `_s` where available) and `wgt01_.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_`, 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. diff --git a/wgt01/.gitignore b/twu01/.gitignore similarity index 100% rename from wgt01/.gitignore rename to twu01/.gitignore diff --git a/wgt01/KICKOFF.md b/twu01/KICKOFF.md similarity index 100% rename from wgt01/KICKOFF.md rename to twu01/KICKOFF.md diff --git a/wgt01/STATUS.md b/twu01/STATUS.md similarity index 55% rename from wgt01/STATUS.md rename to twu01/STATUS.md index 834c842..98a7ac2 100644 --- a/wgt01/STATUS.md +++ b/twu01/STATUS.md @@ -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 diff --git a/twu01/concept-art-1.png b/twu01/concept-art-1.png new file mode 100644 index 0000000..b01c607 --- /dev/null +++ b/twu01/concept-art-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3085c7e4896466426289c4ff47e25d3fbf36f6029bb135ee340f3327f3fd9e19 +size 2728593 diff --git a/wgt01/flake.nix b/twu01/flake.nix similarity index 100% rename from wgt01/flake.nix rename to twu01/flake.nix diff --git a/wgt01/generator/.gitignore b/twu01/generator/.gitignore similarity index 100% rename from wgt01/generator/.gitignore rename to twu01/generator/.gitignore diff --git a/wgt01/generator/build.py b/twu01/generator/build.py similarity index 100% rename from wgt01/generator/build.py rename to twu01/generator/build.py diff --git a/wgt01/generator/export_mdl.py b/twu01/generator/export_mdl.py similarity index 100% rename from wgt01/generator/export_mdl.py rename to twu01/generator/export_mdl.py diff --git a/twu01/generator/make_itp.py b/twu01/generator/make_itp.py new file mode 100644 index 0000000..562c934 --- /dev/null +++ b/twu01/generator/make_itp.py @@ -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 `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() diff --git a/wgt01/generator/make_mdl.py b/twu01/generator/make_mdl.py similarity index 63% rename from wgt01/generator/make_mdl.py rename to twu01/generator/make_mdl.py index 4b3aa4a..f463d0b 100644 --- a/wgt01/generator/make_mdl.py +++ b/twu01/generator/make_mdl.py @@ -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}/") diff --git a/twu01/generator/make_set.py b/twu01/generator/make_set.py new file mode 100644 index 0000000..76db77c --- /dev/null +++ b/twu01/generator/make_set.py @@ -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_.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() diff --git a/wgt01/generator/make_wok.py b/twu01/generator/make_wok.py similarity index 100% rename from wgt01/generator/make_wok.py rename to twu01/generator/make_wok.py diff --git a/twu01/generator/test_namesync.py b/twu01/generator/test_namesync.py new file mode 100644 index 0000000..05af6c9 --- /dev/null +++ b/twu01/generator/test_namesync.py @@ -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") diff --git a/wgt01/generator/tiles.py b/twu01/generator/tiles.py similarity index 75% rename from wgt01/generator/tiles.py rename to twu01/generator/tiles.py index b04b0c5..b8e054e 100644 --- a/wgt01/generator/tiles.py +++ b/twu01/generator/tiles.py @@ -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") diff --git a/wgt01/DESIGN.md b/wgt01/DESIGN.md new file mode 100644 index 0000000..5a8b6f3 --- /dev/null +++ b/wgt01/DESIGN.md @@ -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. diff --git a/wgt01/VIBE.md b/wgt01/VIBE.md new file mode 100644 index 0000000..b3805eb --- /dev/null +++ b/wgt01/VIBE.md @@ -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. diff --git a/wgt01/concept-art-2.png b/wgt01/concept-art-2.png new file mode 100644 index 0000000..541f63c --- /dev/null +++ b/wgt01/concept-art-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4665309e501431d66ec5278ab8d6db7f8df2ed1862c2da2276c2fb646c812ff7 +size 3001999 diff --git a/wgt01/generator/make_itp.py b/wgt01/generator/make_itp.py deleted file mode 100644 index eccc172..0000000 --- a/wgt01/generator/make_itp.py +++ /dev/null @@ -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 `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() diff --git a/wgt01/generator/make_set.py b/wgt01/generator/make_set.py deleted file mode 100644 index fe39c1c..0000000 --- a/wgt01/generator/make_set.py +++ /dev/null @@ -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_.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() diff --git a/wgt01/wgt01.blend1 b/wgt01/wgt01.blend1 new file mode 100644 index 0000000..3be4674 Binary files /dev/null and b/wgt01/wgt01.blend1 differ