This commit is contained in:
2026-07-04 18:03:35 +02:00
parent 605d0e73b4
commit e0fdf2ff16
8 changed files with 488 additions and 52 deletions
+64
View File
@@ -0,0 +1,64 @@
"""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()
+140
View File
@@ -0,0 +1,140 @@
"""Generate loadable NWN tile models (`twu01_<code>.mdl` + `.wok` + textures).
python make_mdl.py [outdir] # default: ./mdl
Pure Python, no Blender. Emits Aurora ASCII models directly from tiles.py so the
result is deterministic and testable without a headless Blender/Neverblender run.
(Neverblender is kept as an alternate path in export_mdl.py, but it flattens our
multi-material tiles to one unloadable material, so this is the shipping path.)
Each tile .mdl contains:
* an AuroraBase dummy node named == resref (classification TILE),
* one trimesh node PER texture used on the tile (NWN allows one bitmap/node) —
faces triangulated, real tverts, and a `bitmap <resref>` the engine can load,
* 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.
"""
import os
import sys
import tiles
import make_wok
VIS_MATID = 65536 # constant last-field on visible faces (matches stock tiles)
def write_tga(path, rgb):
"""Minimal 2x2 uncompressed 24-bit TGA in solid `rgb` (0..1 floats)."""
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
with open(path, "wb") as fh:
fh.write(header + body)
def _trimesh(ref, idx, tex, verts, quads):
"""One trimesh node: local verts + planar tverts + triangulated faces."""
local, lmap, tvs = [], {}, []
def li(gi):
if gi not in lmap:
x, y, z = verts[gi]
lmap[gi] = len(local)
local.append((x, y, z))
tvs.append(((x + tiles.HALF) / (2 * tiles.HALF),
(y + tiles.HALF) / (2 * tiles.HALF)))
return lmap[gi]
tris = []
for a, b, c, d in quads:
la, lb, lc, ld = li(a), li(b), li(c), li(d)
tris.append((la, lb, lc))
tris.append((la, lc, ld))
L = [f"node trimesh {ref}_{idx:02d}",
f" parent {ref}",
" position 0.00000 0.00000 0.00000",
" orientation 0.00000 0.00000 0.00000 0.00000",
" wirecolor 1.0 1.0 1.0",
" ambient 1.0 1.0 1.0",
" diffuse 1.0 1.0 1.0",
" specular 0.0 0.0 0.0",
" shininess 1",
f" bitmap {tex}",
" render 1",
" shadow 0",
" tilefade 0",
" rotatetexture 0",
f" verts {len(local)}"]
L += [f" {v[0]:.5f} {v[1]:.5f} {v[2]:.5f}" for v in local]
L.append(f" tverts {len(tvs)}")
L += [f" {u:.6f} {v:.6f} 0" for u, v in tvs]
L.append(f" faces {len(tris)}")
# face: v1 v2 v3 smoothgroup tv1 tv2 tv3 matid (tvert idx == vert idx here)
L += [f" {a} {b} {c} 1 {a} {b} {c} {VIS_MATID}" for a, b, c in tris]
L.append("endnode")
return L
def emit_mdl(spec):
ref = tiles.resref(spec)
verts, faces = tiles.vis_geometry(spec)
# group quads by texture, preserving first-seen order for stable node numbers
groups, order = {}, []
for a, b, c, d, mat in faces:
tex = tiles.texname(mat)
if tex not in groups:
groups[tex] = []
order.append(tex)
groups[tex].append((a, b, c, d))
L = [f"# {ref} - Westgate Urban Foundation MVP tile (ASCII, generated)",
f"newmodel {ref}",
f"setsupermodel {ref} null",
"classification TILE",
"setanimationscale 1.0",
f"beginmodelgeom {ref}",
f"node dummy {ref}",
" parent null",
"endnode"]
for idx, tex in enumerate(order, start=1):
L += _trimesh(ref, idx, tex, verts, groups[tex])
_, aabb = make_wok.aabb_node(spec)
L += aabb
L += [f"endmodelgeom {ref}", f"donemodel {ref}"]
return ref, "\n".join(L) + "\n", set(order)
def main():
outdir = sys.argv[1] if len(sys.argv) > 1 else "mdl"
os.makedirs(outdir, exist_ok=True)
used = set()
for spec in tiles.TILES:
ref, mdl, texs = emit_mdl(spec)
used |= texs
with open(os.path.join(outdir, ref + ".mdl"), "w") as fh:
fh.write(mdl)
_, wok = make_wok.emit(spec)
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
for mat, tex in tiles.TEXTURES.items():
write_tga(os.path.join(outdir, tex + ".tga"), tiles.COLORS[mat])
print(f"wrote {len(tiles.TILES)} .mdl + .wok and "
f"{len(tiles.TEXTURES)} .tga to {outdir}/")
if __name__ == "__main__":
main()
+11 -8
View File
@@ -27,11 +27,12 @@ def tile_block(i, spec):
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=", # legacy field; per-model .wok is matched by name
f"ImageMap2D={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]}",
@@ -40,13 +41,15 @@ def tile_block(i, spec):
f"Right={tiles.nwn_crosser(e)}",
f"Bottom={tiles.nwn_crosser(s)}",
f"Left={tiles.nwn_crosser(w)}",
"MainLight1=0", "MainLight2=0", "SourceLight1=0", "SourceLight2=0",
"AnimLoop1=0", "AnimLoop2=0", "AnimLoop3=0",
"Orientation=0",
f"PathNode={'A' if walkable else 'N'}", # placeholder — verify in toolset
"VisibilityNode=",
"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
@@ -65,7 +68,7 @@ def build():
"Floor=cobble",
"DisplayName=-1",
"UnlocalizedName=Westgate Urban Foundation",
"Version=1",
"Version=V1.0",
"EnvMap=",
"SelectorHeight=3",
"",
+18 -6
View File
@@ -18,8 +18,8 @@ import sys
import tiles
WALK = 1 # a walkable stone-ish row in surfacemat.2da (verify)
BLOCK = 7 # a non-walkable row (verify)
WALK = 4 # surfacemat.2da row 4 = Stone, walkable (Neverblender wok_Stone)
BLOCK = 7 # surfacemat.2da row 7 = Nonwalk, non-walkable (wok_Nonwalk)
def geometry(spec):
@@ -71,15 +71,21 @@ def aabb_tree(verts, faces):
return nodes
def emit(spec):
def aabb_node(spec):
"""The `node aabb <ref>_wok ... endnode` block (reused by make_mdl embed)."""
verts, faces = geometry(spec)
nodes = aabb_tree(verts, faces)
name = tiles.resref(spec)
L = [f"# {name} walkmesh (ASCII, first-pass) - compile via nwnmdlcomp/cleanmodels",
f"node aabb {name}_wok",
L = [f"node aabb {name}_wg",
f" parent {name}",
" position 0.0 0.0 0.0",
" orientation 0.0 0.0 0.0 0.0",
" bitmap NULL",
" ambient 0.0 0.0 0.0",
" diffuse 0.0 0.0 0.0",
" specular 0.0 0.0 0.0",
" render 0",
" shadow 0",
f" verts {len(verts)}"]
L += [f" {v[0]:.5f} {v[1]:.5f} {v[2]:.5f}" for v in verts]
L.append(f" faces {len(faces)}")
@@ -89,7 +95,13 @@ def emit(spec):
L += [f" {mn[0]:.5f} {mn[1]:.5f} {mn[2]:.5f} {mx[0]:.5f} {mx[1]:.5f} {mx[2]:.5f} {fi}"
for mn, mx, fi in nodes]
L.append("endnode")
return name, "\n".join(L) + "\n"
return name, L
def emit(spec):
name, L = aabb_node(spec)
head = f"# {name} walkmesh (ASCII) - compile via nwnmdlcomp/cleanmodels"
return name, head + "\n" + "\n".join(L) + "\n"
def main():
+48
View File
@@ -32,6 +32,28 @@ COLORS = {
}
CURB = "MAT_curb_dark_stone"
# NWN needs a real texture (bitmap) per trimesh; material name -> texture resref
# (<=16 chars, prefix eats 6). make_mdl.py writes a solid-colour .tga per entry.
TEXTURES = {
"MAT_wet_cobble_dark": "twu01_wcob",
"MAT_cobble_patch": "twu01_cpat",
"MAT_plaza_slab": "twu01_plaz",
"MAT_sidewalk_stone": "twu01_swlk",
"MAT_curb_dark_stone": "twu01_curb",
"MAT_canal_black_water": "twu01_watr",
"MAT_void_black": "twu01_void",
"MAT_ramp_stone": "twu01_ramp",
"MAT_drain_iron": "twu01_iron",
"MAT_foundation_pad": "twu01_fnd",
"MAT_debug_walkmesh": "twu01_dwlk",
"MAT_debug_blocker": "twu01_dblk",
}
def texname(mat):
return TEXTURES.get(mat, "twu01_wcob")
F, R, C, RP, MX = "FLAT", "RAISED", "CANAL", "RAMP", "MIXED"
@@ -281,6 +303,32 @@ def wok_quads(spec):
return [(_rect(-5, 5, -5, 5, 0.0), True)]
# --- visible mesh: subdivided 10x10 plane, faces tagged by material ----------
# Same source as build.py's build_vis so the Blender preview and the exported
# .mdl never drift. Faces are quads here; make_mdl.py triangulates on emit.
def vis_geometry(spec):
"""Return (verts, faces): 100-quad plane. faces = (a, b, c, d, matname)."""
n, step = 10, 1.0
paint = make_paint(spec)
verts, vidx = [], {}
def vi(i, j):
if (i, j) not in vidx:
x, y = -HALF + i * step, -HALF + j * step
vidx[(i, j)] = len(verts)
verts.append((x, y, height(spec, x, y)))
return vidx[(i, j)]
faces = []
for i in range(n):
for j in range(n):
cx, cy = -HALF + (i + 0.5) * step, -HALF + (j + 0.5) * step
faces.append((vi(i, j), vi(i + 1, j), vi(i + 1, j + 1), vi(i, j + 1),
paint(cx, cy)))
return verts, faces
# --- NWN .set mapping (AGENTS.md §4, §7) -------------------------------------
# Minimal, deliberate vocabulary (AGENTS.md §2.1: every terrain pairing multiplies
# tile count). Ground tiles all share 'cobble' so they interconnect freely