#!/usr/bin/env python3
"""
STITCH a solid block of lookdev cache tiles into one .hblk heightfield.
WHY THIS EXISTS
The lookdev cache is 756 int16 tiles of 256 px + a 1 px apron, written by
tiles.py while someone flew around /explore. They are a verified-single-world
heightfield (8 samples re-fetched from the live fp32 seed-1234 service matched
to <= 1 m, which is the int16 quantisation tiles.py already documents), and
they are the cheapest possible input for proving the engine path: no service,
no PyTorch, no network.
AXES, WHICH ARE THE EASY THING TO GET WRONG
#50 section 2.4 cost a session to this. tiles.py fetches a tile as
fetch(i1 = tx*256, j1 = ty*256) and the API returns [i, j] -- so:
tx indexes the FIRST array axis -> WORLD Z
ty indexes the SECOND array axis -> WORLD X
erode_heightmap.cpp uses the same convention on its raw input: it indexes
h[j*n + i] with px = i*mpp and pz = j*mpp, i.e. column = x, row = z. The .hblk
below is row-major with row = z and column = x, so both agree by construction
rather than by luck.
THE APRON IS KEPT, AND IS THE POINT
Each tile carries 1 px of its neighbour on every side. Stitching interiors
only would leave the block's outer ring without the neighbours the central
difference needs, and the slope error there would look exactly like a seam.
The block therefore spans N*256 + 2 px and the consumer treats the outer ring
as apron: valid heights, not valid slopes.
Usage:
python stitch_block.py --n 4 --out /tmp/block.hblk # auto-pick
python stitch_block.py --tx -3 --ty -1 --n 4 --out ... # explicit
python stitch_block.py --list # what's solid
"""
import argparse, os, re, struct, sys
import numpy as np
# WHERE TILES LIVE, and why it is not ~/.cache.
#
# Generated tiles are GAME STATE, not a cache: each is ~131 s of inference and
# they are the only copy. They used to default under ~/.cache/, which
# rsync-excludes.txt excludes (".cache/" matches any directory of that name at
# any depth) -- so the generator was writing its only copy into the one tree the
# backup deliberately skips. Verified both ways with the real exclude list.
#
# They now live under Godot's user:// for hyper-tps, which IS in the backup set
# and is where every other thing the game generates already goes.
#
# THE SEED IS THE PARTITION AXIS -- and the only one. A room is WHO MAY SEE a
# tile, not what it is: two rooms on one seed have byte-identical terrain, so
# nesting by room would duplicate it (the same reason content_overlay.gd keys
# user://content by hash: "two rooms asking for the same asset share one copy").
# A map SELECTS a seed and generator config; it is not an identity axis.
# Room/map scoping belongs to KeetTileIndex and StateBase, not the filesystem.
#
# LOOKDEV_TILE_CACHE overrides the whole path.
CACHE = os.environ.get("LOOKDEV_TILE_CACHE",
os.path.expanduser(
"~/.local/share/godot/app_userdata/HyperTPS/tile_cache/1234"))
TILE_PX = 256
APRON = 1
MPP = 30.0
MAGIC = b"HBLK"
VERSION = 1
HEADER = 32 # magic4 + version4 + n4 + mpp4 + origin_x4 + origin_z4 + 8 reserved
def cached_tiles(cache=CACHE, tile_px=TILE_PX):
pat = re.compile(rf"^t2_{tile_px}_(-?\d+)_(-?\d+)\.npy$")
out = set()
for f in os.listdir(cache):
m = pat.match(f)
if m:
out.add((int(m.group(1)), int(m.group(2))))
return out
def solid_origins(tiles, n):
"""Tile origins (tx, ty) where the whole n x n block is present."""
return sorted(o for o in tiles
if all((o[0] + i, o[1] + j) in tiles
for i in range(n) for j in range(n)))
def stitch(tx0, ty0, n, cache=CACHE, tile_px=TILE_PX):
"""Return (array, origin_x_m, origin_z_m). Array is (n*tile_px+2)^2 int16,
row = z, column = x, with a 1 px apron ring."""
side = n * tile_px + 2 * APRON
out = np.zeros((side, side), dtype=np.int16)
for i in range(n): # tx -> z
for j in range(n): # ty -> x
p = os.path.join(cache, f"t2_{tile_px}_{tx0+i}_{ty0+j}.npy")
t = np.load(p) # (tile_px+2, tile_px+2)
if t.shape != (tile_px + 2, tile_px + 2):
raise SystemExit(f"{p}: expected {(tile_px+2,)*2}, got {t.shape}")
# Write the tile INCLUDING its apron. Interiors never overlap;
# aprons overwrite each other with identical values (same world
# pixel, same world), so order does not matter -- and the block's
# outer ring ends up filled by the edge tiles' aprons, which is
# the only way to get it at all.
r0, c0 = i * tile_px, j * tile_px
out[r0:r0 + tile_px + 2, c0:c0 + tile_px + 2] = t
origin_x, origin_z = tile_origin(tx0, ty0, tile_px)
return out, origin_x, origin_z
def tile_origin(tx, ty, tile_px=TILE_PX, mpp=MPP, apron=APRON):
"""World-space origin (x, z) in metres of a tile's APRON corner.
ONE definition, because it is the only thing tying a .hblk to a place and
the two axes are NOT symmetric: tx indexes z, ty indexes x. Deriving it a
second time next to the second writer is how the two would drift.
"""
return ((ty * tile_px - apron) * mpp, # origin_x
(tx * tile_px - apron) * mpp) # origin_z
def write_hblk(path, arr, origin_x, origin_z, mpp=MPP):
side = arr.shape[0]
hdr = struct.pack("<4sIIfffII", MAGIC, VERSION, side, mpp,
origin_x, origin_z, 0, 0)
assert len(hdr) == HEADER, len(hdr)
with open(path, "wb") as f:
f.write(hdr)
f.write(np.ascontiguousarray(arr, dtype="<i2").tobytes())
def read_hblk(path):
with open(path, "rb") as f:
hdr = f.read(HEADER)
magic, ver, side, mpp, ox, oz, _, _ = struct.unpack("<4sIIfffII", hdr)
if magic != MAGIC:
raise SystemExit(f"{path}: not an hblk (magic {magic!r})")
a = np.frombuffer(f.read(side * side * 2), dtype="<i2").reshape(side, side)
return a, dict(version=ver, side=side, mpp=mpp, origin_x=ox, origin_z=oz)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=4, help="block side in TILES")
ap.add_argument("--tx", type=int, default=None)
ap.add_argument("--ty", type=int, default=None)
ap.add_argument("--out", default="/tmp/block.hblk")
ap.add_argument("--list", action="store_true")
ap.add_argument("--per-tile", metavar="OUTDIR",
help="write ONE .hblk per cached tile into OUTDIR")
ap.add_argument("--limit", type=int, default=0,
help="with --per-tile, stop after N tiles (0 = all)")
a = ap.parse_args()
tiles = cached_tiles()
if a.list:
for n in (1, 2, 4, 8, 16):
s = solid_origins(tiles, n)
print(f"solid {n:>2}x{n:<2} blocks: {len(s):>4}"
f" span {n*TILE_PX*MPP/1000:>6.1f} km"
+ (f" e.g. --tx {s[len(s)//2][0]} --ty {s[len(s)//2][1]}" if s else ""))
return
if a.per_tile:
# ONE TILE PER FILE, because the tile set is INCOMPLETE and GROWS.
#
# A stitched block is a SNAPSHOT: it fixes which tiles exist at bake
# time, and the 900 km world is 14,161 tiles of which 841 are generated.
# There is no solid 119x119 to stitch and there will not be one for a
# long time, so the engine has to load a LIST of tiles and treat the
# gaps as stubs rather than load one rectangle.
#
# Each file keeps its own 1 px apron and its own world origin, so a tile
# is self-describing and can be added at runtime without touching any
# other file.
os.makedirs(a.per_tile, exist_ok=True)
origins = sorted(tiles)
if a.limit:
origins = origins[:a.limit]
n_ok = 0
for (tx, ty) in origins:
arr, ox, oz = stitch(tx, ty, 1)
write_hblk(os.path.join(a.per_tile, f"tile_{tx}_{ty}.hblk"), arr, ox, oz)
n_ok += 1
print(f"wrote {n_ok} per-tile .hblk to {a.per_tile}")
print(f" each {arr.shape[0]}x{arr.shape[0]} px "
f"({TILE_PX}+2 apron) = {TILE_PX*MPP/1000:.2f} km of payload")
return
if a.tx is None or a.ty is None:
s = solid_origins(tiles, a.n)
if not s:
raise SystemExit(f"no solid {a.n}x{a.n} block in {CACHE}")
a.tx, a.ty = s[len(s) // 2]
print(f"auto-picked block origin tile ({a.tx}, {a.ty})")
arr, ox, oz = stitch(a.tx, a.ty, a.n)
write_hblk(a.out, arr, ox, oz)
side = arr.shape[0]
print(f"wrote {a.out}")
print(f" {side} x {side} px ({a.n}x{a.n} tiles + 1 px apron)"
f" = {(side-2)*MPP/1000:.2f} km span")
print(f" world origin x {ox:,.0f} m z {oz:,.0f} m at {MPP:g} m/px")
print(f" elevation {arr.min()} .. {arr.max()} m")
print(f" {os.path.getsize(a.out)/1024:.0f} KB")
if __name__ == "__main__":
main()