G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+583
View File
@@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""Blockset explorer: what is in a tileset's blockset, what is free, and what
new 32x32 blocks can be built out of the 8x8 tiles it already owns.
Developer-only, like tools/build_data.py. Reads the generated cache
(data/generated/tilesets.lua, data/generated/maps.lua and the tileset PNGs
under assets/generated/tilesets/); writes nothing back into it.
A block is 4x4 = 16 tiles of 8x8, matching pokered's blockdata (16 bytes per
block, see data/tilesets/*_blockset.bst). Map blockdata stores a block index
in one byte, so a tileset can never hold more than 256 blocks: the interesting
budget is the free slots between the blocks a tileset ships and that ceiling,
not the raw combination count. Exhaustive enumeration is not a thing you can
run -- OVERWORLD has 93 distinct tiles, so 93^16 is about 3.3e31 blocks -- so
the generator recombines under constraints learned from the real blocks
instead. --mode exhaustive exists to show the wall rather than to hit it.
Usage:
python3 tools/blockgen.py --list
python3 tools/blockgen.py --tileset OVERWORLD
python3 tools/blockgen.py --tileset OVERWORLD --sheet /tmp/blocks.png
python3 tools/blockgen.py --tileset CAVERN --mode exhaustive
"""
import argparse
import collections
import hashlib
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TILESETS_LUA = os.path.join(ROOT, "data/generated/tilesets.lua")
MAPS_LUA = os.path.join(ROOT, "data/generated/maps.lua")
BLOCK_TILES = 4 # a block is 4x4 tiles
TILE_PX = 8
# The engine has no block ceiling: src/world/Map.lua indexes tilesetDef.blocks
# as a plain Lua array and never masks the id, and mods register tilesets with
# arbitrary block lists (src/mods/Schemas.lua). 256 is the ROM's limit, not
# ours -- map blockdata on the cart is one byte per block, so only blocks
# IMPORTED from a ROM are bound by it. Kept here to report parity headroom.
ROM_BLOCK_LIMIT = 256
BLOCK_LIMIT = ROM_BLOCK_LIMIT
# ----------------------------------------------------------------- lua parsing
# The generated caches are plain nested table literals (numbers, strings,
# booleans, `key = value`), so a small recursive-descent reader beats shelling
# out to luajit and keeps this runnable with no interpreter installed.
_TOKEN = re.compile(r"""
(?P<ws>\s+|--[^\n]*)
| (?P<str>"(?:[^"\\]|\\.)*")
| (?P<num>-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)
| (?P<name>[A-Za-z_][A-Za-z_0-9]*)
| (?P<punct>[{}\[\]=,;])
""", re.X)
def _tokenize(text):
pos, n, out = 0, len(text), []
while pos < n:
m = _TOKEN.match(text, pos)
if not m:
raise ValueError("lua parse: stuck at %r" % text[pos:pos + 40])
pos = m.end()
if m.lastgroup != "ws":
out.append((m.lastgroup, m.group()))
return out
def parse_lua(text):
"""Parse `return { ... }`. Array parts come back as lists, keyed parts as
dicts; a table with both is returned as a dict with integer keys."""
toks = _tokenize(text)
i = 0
if toks and toks[0] == ("name", "return"):
i = 1
def value(i):
kind, tok = toks[i]
if kind == "num":
return (float(tok) if ("." in tok or "e" in tok.lower()) else int(tok)), i + 1
if kind == "str":
return tok[1:-1].encode().decode("unicode_escape"), i + 1
if kind == "name":
if tok == "true":
return True, i + 1
if tok == "false":
return False, i + 1
if tok == "nil":
return None, i + 1
raise ValueError("lua parse: bare name %r" % tok)
if tok == "{":
return table(i + 1)
raise ValueError("lua parse: unexpected %r" % tok)
def table(i):
arr, dct = [], {}
while True:
kind, tok = toks[i]
if tok == "}":
return (dct if dct else arr) if not (dct and arr) else _merge(arr, dct), i + 1
if tok in (",", ";"):
i += 1
continue
# `key = v` or `["key"] = v`
if kind == "name" and toks[i + 1][1] == "=":
k = tok
v, i = value(i + 2)
dct[k] = v
continue
if tok == "[":
k, i = value(i + 1)
assert toks[i][1] == "]" and toks[i + 1][1] == "="
v, i = value(i + 2)
dct[k] = v
continue
v, i = value(i)
arr.append(v)
def _merge(arr, dct):
out = dict(dct)
for n, v in enumerate(arr, 1):
out[n] = v
return out
v, _ = value(i)
return v
# ------------------------------------------------------------------ tile art
def load_tiles(record):
"""Slice a tileset PNG into 8x8 tiles, indexed the way the block data
indexes them (row-major, tilesPerRow per row). Returns None when Pillow is
missing, so the structural half of this script still runs."""
try:
from PIL import Image
except ImportError:
return None
path = os.path.join(ROOT, record["image"])
if not os.path.exists(path):
return None
img = Image.open(path).convert("RGB")
per_row = record["tilesPerRow"]
rows = img.height // TILE_PX
tiles = {}
for idx in range(rows * per_row):
tx, ty = (idx % per_row) * TILE_PX, (idx // per_row) * TILE_PX
tiles[idx] = img.crop((tx, ty, tx + TILE_PX, ty + TILE_PX))
return tiles
def tile_hash(tile):
return hashlib.blake2b(tile.tobytes(), digest_size=8).hexdigest()
def edges(tile):
"""(top, bottom, left, right) pixel-row/column signatures. Two tiles butt
together seamlessly when the facing edges match, which is what makes a
recombined block read as deliberate rather than as noise."""
px = tile.load()
w, h = tile.size
top = bytes(v for x in range(w) for v in px[x, 0])
bot = bytes(v for x in range(w) for v in px[x, h - 1])
left = bytes(v for y in range(h) for v in px[0, y])
right = bytes(v for y in range(h) for v in px[w - 1, y])
return top, bot, left, right
# ------------------------------------------------------------- categorisation
def categorise(record, tiles):
"""Tag every tile id the blockset actually uses. Roles come from the
tileset metadata the extractor already carries (walkable / grass / door /
warp / counter); everything else is scenery. Visual duplicates are folded
into one class so 'make unique' means unique art, not unique index."""
used = sorted({t for blk in record["blocks"] for t in blk})
walkable = set(record.get("walkable") or [])
doors = set(record.get("doorTiles") or [])
warps = set(record.get("warpTiles") or [])
counters = set(record.get("counterTiles") or [])
grass = record.get("grassTile")
role = {}
for t in used:
if t == grass:
role[t] = "grass"
elif t in doors:
role[t] = "door"
elif t in warps:
role[t] = "warp"
elif t in counters:
role[t] = "counter"
elif t in walkable:
role[t] = "walkable"
else:
role[t] = "scenery"
# visual identity classes: distinct ids whose art is byte-identical
art_class, alias = {}, {}
if tiles:
for t in used:
if t in tiles:
art_class.setdefault(tile_hash(tiles[t]), []).append(t)
for ids in art_class.values():
for t in ids:
alias[t] = ids[0]
else:
alias = {t: t for t in used}
return used, role, alias, art_class
# ------------------------------------------------------------------- analysis
def blocks_in_use(maps, tileset_name):
"""Block indices any map on this tileset actually places. A block that no
map references is already a free slot in everything but name."""
seen = set()
maps_on = 0
for rec in maps.values():
if not isinstance(rec, dict) or rec.get("tileset") != tileset_name:
continue
maps_on += 1
for b in rec.get("blocks") or []:
seen.add(b)
return seen, maps_on
def canon(block, alias):
"""Identity of a block by art, not by tile index."""
return tuple(alias.get(t, t) for t in block)
def learn_constraints(blocks, alias):
"""What the real blocks permit: which tiles appear at each of the 16
positions, and which tiles ever sit directly right of / below which."""
pos_vocab = [set() for _ in range(16)]
right_of = collections.defaultdict(set)
below = collections.defaultdict(set)
for blk in blocks:
a = [alias.get(t, t) for t in blk]
for i, t in enumerate(a):
pos_vocab[i].add(t)
r, c = divmod(i, BLOCK_TILES)
if c + 1 < BLOCK_TILES:
right_of[t].add(a[i + 1])
if r + 1 < BLOCK_TILES:
below[t].add(a[i + BLOCK_TILES])
return pos_vocab, right_of, below
def fill_tiles(blocks, alias, share=0.04):
"""The tiles a blockset uses as background. Anything holding more than
`share` of all tile slots across the real blocks is fill: grass, path,
interior floor. Used to score structure, since a block made only of these
is a texture swatch, not a block worth a slot."""
counts = collections.Counter(alias.get(t, t) for blk in blocks for t in blk)
total = sum(counts.values())
return {t for t, n in counts.items() if n / total > share}
def render_block(block, tiles):
"""A block's 32x32 pixels, for comparing what a block LOOKS like rather
than which indices it happens to name. Two blocks built from different
ids whose art is identical render identical here, which index-space
distance cannot see."""
from PIL import Image
px = BLOCK_TILES * TILE_PX
img = Image.new("RGB", (px, px))
for i, t in enumerate(block):
art = tiles.get(t)
if art is None:
continue
r, c = divmod(i, BLOCK_TILES)
img.paste(art, (c * TILE_PX, r * TILE_PX))
return img
def score_block(block, alias, fill):
"""How much structure a block carries. Distinct art plus non-fill tiles,
with a hard floor: a block that is almost entirely one tile is a swatch."""
a = [alias.get(t, t) for t in block]
distinct = len(set(a))
non_fill = sum(1 for t in a if t not in fill)
dominant = collections.Counter(a).most_common(1)[0][1]
if distinct < 3 or non_fill == 0 or dominant >= 14:
return 0
return non_fill * 2 + distinct
def tile_diff_table(tiles, ids):
"""Pixel differences between every pair of tiles, once.
Two blocks differ by exactly the sum of their per-slot tile differences, so
this turns a 1024-pixel comparison per block pair into 16 lookups. That is
the difference between this finishing and not: 400 blocks needs millions of
pair comparisons, and pure Python cannot walk 1024 pixels that many times."""
raw = {t: tiles[t].tobytes() for t in ids if t in tiles}
table = {}
for a in raw:
ra = raw[a]
row = table.setdefault(a, {})
for b in raw:
if b in row:
continue
rb = raw[b]
d = sum(1 for i in range(0, len(ra), 3) if ra[i:i + 3] != rb[i:i + 3])
row[b] = d
table.setdefault(b, {})[a] = d
return table
def too_close(cand, kept, table, budget):
"""True when `cand` is within `budget` differing pixels of anything kept.
Accumulates per slot and bails the moment a block is provably far enough,
so the common case costs a handful of lookups rather than 16."""
for other in kept:
total = 0
for x, y in zip(cand, other):
if x != y:
total += table.get(x, {}).get(y, TILE_PX * TILE_PX)
if total >= budget:
break
if total < budget:
return True
return False
def generate(blocks, alias, limit, tiles=None, seed=0, min_distance=6,
attempts_per=200, oversample=8, min_pixel_diff=0.12):
"""Sample blocks the real blockset's own rules permit: a tile may only sit
at a position it is observed at, and only next to / below tiles it is
observed next to / below.
Sampling is randomised rather than depth-first on purpose. A sorted DFS
walks the lexicographically first prefix to exhaustion, so its first
hundred results are one corner with the last tile wiggling -- technically
unique, useless as art. Random restarts spread over the space, and
min_distance (tiles differing from every block already kept, the blockset's
included) is what stops near-duplicates coming back under a new index."""
import random
rng = random.Random(seed)
pos_vocab, right_of, below = learn_constraints(blocks, alias)
fill = fill_tiles(blocks, alias)
existing = [canon(b, alias) for b in blocks]
existing_set = set(existing)
out, seen = [], set()
kept = list(existing)
def far_enough(cand):
for other in kept:
if sum(1 for a, b in zip(cand, other) if a != b) < min_distance:
return False
return True
def sample():
"""One randomised walk with backtracking; None if it paints itself in.
`tried` mirrors `cur` so a position never re-picks a tile that already
led to a dead end on this walk."""
cur, tried = [], []
while len(cur) < 16:
i = len(cur)
r, c = divmod(i, BLOCK_TILES)
cand = set(pos_vocab[i])
if c > 0:
cand &= right_of.get(cur[i - 1], set())
if r > 0:
cand &= below.get(cur[i - BLOCK_TILES], set())
if i == len(tried):
tried.append(set())
cand -= tried[i]
if not cand:
if i == 0:
return None
tried.pop()
cur.pop()
continue
pick = rng.choice(sorted(cand))
tried[i].add(pick)
cur.append(pick)
return tuple(cur)
# Oversample into a pool, rank by structure, then take the best that are
# visually far enough apart. Filling greedily in sample order instead is
# what produced a sheet of grass swatches: every one of them cleared a
# 6-tile index distance by swapping interchangeable background, which is
# not a difference anybody can see.
pool, tries = [], 0
want = limit * oversample
while len(pool) < want and tries < want * attempts_per:
tries += 1
cand = sample()
if cand is None or cand in existing_set or cand in seen:
continue
seen.add(cand)
pool.append(cand)
scored = [(score_block(c, alias, fill), c) for c in pool]
scored = [(s, c) for s, c in scored if s > 0]
scored.sort(key=lambda sc: -sc[0])
table = tile_diff_table(tiles, {t for b in pool for t in b} |
{t for b in existing for t in b}) if tiles else None
budget = int(min_pixel_diff * (BLOCK_TILES * TILE_PX) ** 2)
stats = {"pool": len(pool), "swatches": len(pool) - len(scored),
"too_similar": 0, "budget_px": budget, "asked": limit}
for _score, cand in scored:
if len(out) >= limit:
break
if table is not None:
if too_close(cand, kept, table, budget):
stats["too_similar"] += 1
continue
elif not far_enough(cand):
stats["too_similar"] += 1
continue
kept.append(cand)
out.append(list(cand))
stats["short_by"] = max(0, limit - len(out))
return out, stats
def exhaustive_estimate(used_by_role):
"""The user's original plan, priced. Not run: printed."""
rows = []
for role, ids in sorted(used_by_role.items()):
k = len(ids)
rows.append((role, k, k ** 16))
return rows
# --------------------------------------------------------------------- output
def write_sheet(path, blocks, tiles, cols=8, scale=3, number=True):
"""Contact sheet, numbered the way the blockset viewer numbers the real
blocks so a block can be named in conversation. Indices are into the
generated run, not into the tileset: they only mean anything for the same
--seed."""
from PIL import Image, ImageDraw
if not blocks:
return False
px = BLOCK_TILES * TILE_PX
rows = (len(blocks) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (px + 2), rows * (px + 2)), (24, 24, 24))
for n, blk in enumerate(blocks):
bx, by = (n % cols) * (px + 2), (n // cols) * (px + 2)
for i, t in enumerate(blk):
art = tiles.get(t)
if art is None:
continue
r, c = divmod(i, BLOCK_TILES)
sheet.paste(art, (bx + c * TILE_PX, by + r * TILE_PX))
if scale > 1:
sheet = sheet.resize((sheet.width * scale, sheet.height * scale), Image.NEAREST)
if number:
draw = ImageDraw.Draw(sheet)
for n in range(len(blocks)):
bx = (n % cols) * (px + 2) * scale
by = (n // cols) * (px + 2) * scale
label = str(n)
w = 6 * len(label) + 2
draw.rectangle([bx, by, bx + w, by + 10], fill=(20, 20, 20))
draw.text((bx + 2, by + 1), label, fill=(255, 255, 255))
sheet.save(path)
return True
def report(name, record, maps, args):
blocks = record["blocks"]
tiles = load_tiles(record)
used, role, alias, art_class = categorise(record, tiles)
placed, maps_on = blocks_in_use(maps, name)
by_role = collections.defaultdict(list)
for t in used:
by_role[role[t]].append(t)
dup_ids = sum(len(v) - 1 for v in art_class.values()) if tiles else 0
distinct_art = len(art_class) if tiles else len(used)
print("%s (%s)" % (name, record.get("image", "no image")))
print(" blocks shipped %d (%d under the ROM's 256-block addressing"
% (len(blocks), ROM_BLOCK_LIMIT - len(blocks)))
print(" limit; the engine itself has no ceiling)")
print(" 8x8 tiles referenced %d (%d distinct by art, %d are duplicate ids)"
% (len(used), distinct_art, dup_ids))
print(" categories " + ", ".join(
"%s=%d" % (r, len(v)) for r, v in sorted(by_role.items())))
print(" maps on this tileset %d, placing %d distinct blocks"
% (maps_on, len(placed)))
never = [i for i in range(len(blocks)) if i not in placed]
print(" blocks no map places %d%s" % (
len(never), (" -> " + ", ".join(map(str, never[:16])) + ("..." if len(never) > 16 else ""))
if never else ""))
if args.mode == "exhaustive":
print("\n exhaustive enumeration, per category (this is the wall):")
for r, k, total in exhaustive_estimate(by_role):
print(" %-9s %2d tiles -> %d^16 = %.3g blocks" % (r, k, k, total))
print(" at 1e9 blocks/sec the smallest of those still outlives the sun.")
return
budget_n = args.limit if args.limit else (BLOCK_LIMIT - len(blocks))
made, gstats = generate(blocks, alias, budget_n, tiles=tiles, seed=args.seed,
min_distance=args.min_distance,
min_pixel_diff=args.min_pixel_diff,
oversample=args.oversample)
print("\n generated %d of the %d asked for, from a pool of %d"
% (len(made), gstats["asked"], gstats["pool"]))
print(" %d rejected as texture swatches (no structure)" % gstats["swatches"])
print(" %d rejected as within %d differing pixels of a block already kept"
% (gstats["too_similar"], gstats["budget_px"]))
if gstats["short_by"]:
print(" SHORT BY %d: the pool ran out before the quota filled. Raise"
% gstats["short_by"])
print(" --oversample, or lower --min-pixel-diff to accept closer blocks.")
# Reproducibility: same cache + same flags must give the same blocks, so
# print a digest of exactly that rather than asking anyone to trust it.
digest = hashlib.sha256(
("%s|%d|%d|%.4f|%d|" % (name, args.seed, budget_n, args.min_pixel_diff,
args.oversample)
+ ";".join(",".join(map(str, b)) for b in made)).encode()).hexdigest()
print(" digest %s (seed %d, oversample %d, min-pixel-diff %.2f)"
% (digest[:16], args.seed, args.oversample, args.min_pixel_diff))
if made:
print(" first three, as tile indices:")
for blk in made[:3]:
for r in range(BLOCK_TILES):
print(" " + " ".join("%3d" % t for t in blk[r * 4:r * 4 + 4]))
print()
if args.sheet and tiles and made:
if write_sheet(args.sheet, made[:args.sheet_count], tiles, cols=args.cols):
print(" contact sheet: %s (%d blocks)" % (args.sheet, min(len(made), args.sheet_count)))
elif args.sheet and not tiles:
print(" (no contact sheet: Pillow or the tileset PNG is missing)")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--tileset", help="tileset id, e.g. OVERWORLD")
ap.add_argument("--list", action="store_true", help="list tilesets and exit")
ap.add_argument("--mode", choices=["adjacency", "exhaustive"], default="adjacency")
ap.add_argument("--limit", type=int, default=0,
help="cap generated blocks (default: the free slots up to 256)")
ap.add_argument("--sheet", help="write a PNG contact sheet of generated blocks")
ap.add_argument("--sheet-count", type=int, default=64)
ap.add_argument("--cols", type=int, default=8)
ap.add_argument("--seed", type=int, default=0,
help="fixed by default: the same cache and flags always give the same blocks")
ap.add_argument("--oversample", type=int, default=8,
help="candidates sampled per block kept")
ap.add_argument("--min-pixel-diff", type=float, default=0.12,
help="fraction of the 32x32 pixels that must differ from every kept block")
ap.add_argument("--min-distance", type=int, default=6,
help="tiles that must differ from every block already kept")
args = ap.parse_args()
for p in (TILESETS_LUA, MAPS_LUA):
if not os.path.exists(p):
sys.exit("missing %s -- import a ROM first (scripts/setup.sh --rom ...)" % p)
tilesets = parse_lua(open(TILESETS_LUA).read())
maps = parse_lua(open(MAPS_LUA).read())
if args.list or not args.tileset:
print("%-14s %7s %7s %7s" % ("tileset", "blocks", "free", "tiles"))
for name in sorted(tilesets):
rec = tilesets[name]
used = {t for blk in rec["blocks"] for t in blk}
print("%-14s %7d %7d %7d"
% (name, len(rec["blocks"]), BLOCK_LIMIT - len(rec["blocks"]), len(used)))
return
name = args.tileset.upper()
if name not in tilesets:
sys.exit("unknown tileset %r (try --list)" % args.tileset)
report(name, tilesets[name], maps, args)
if __name__ == "__main__":
main()