"""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()