OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / stitch_block.py

211 lines · python ref:
#!/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()