DramaticShapeVoxelMod archival mirror · read-only
aa49898fd690b8ce7a9f8b0b40700e9793632760

updated tests

DramaticShape · 2026-07-26 · parent 702d8049f0

updated tests

Changed files

CHANGELOG.md                   |  30 ++++-----
 tests/voxel_anim_probe.lua     | 133 ++++++++++++++++++++++++++++++++++++++++
 tests/voxel_door_probe.lua     |  43 +++++++++++++
 tests/voxel_mem_probe.lua      |  67 ++++++++++++++++++++
 tests/voxel_perf_probe.lua     | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/voxel_pipeline_probe.lua |  67 ++++++++++++++++++++
 tests/voxel_seam_shots.lua     |  34 +++++++++++
 tests/voxel_shadow_probe.lua   | 128 ++++++++++++++++++++++++++++++++++++++
 tests/voxel_survey.lua         |  93 ++++++++++++++++++++++++++++
 tests/voxel_void_probe.lua     |  68 +++++++++++++++++++++
 tools/voxel-survey.md          | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++
 11 files changed, 1020 insertions(+), 14 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f91f08d..afc8558 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -95,11 +95,11 @@ long as the thing throwing them is tall.
   branch inside it, because it needs shader derivatives (`fwidth`) -- the
   one part of the mode a driver can refuse. A refusal costs the grid and
   nothing else.
-- `tests/drivers/voxel_shadow_probe.lua`: reports the fitted frustum and
-  the resolution rung, dumps the map itself, and shoots a stand point at
-  every pitch. `SHADOW_SUN="kx,kz"` retunes the bearing for one run,
-  `SHADOW_GRID=1` forces the wireframe on, and `SHADOW_ZOOM` pins the
-  zoom, without which two runs are not comparable -- a driver inherits
+- `mods/DRAMATIC_SHAPE/tests/voxel_shadow_probe.lua`: reports the fitted
+  frustum and the resolution rung, dumps the map itself, and shoots a
+  stand point at every pitch. `SHADOW_SUN="kx,kz"` retunes the bearing for
+  one run, `SHADOW_GRID=1` forces the wireframe on, and `SHADOW_ZOOM` pins
+  the zoom, without which two runs are not comparable -- a driver inherits
   whatever the player left in `options.lua`, and the world view size (which
   the light frustum is fitted to) swings 3x across that range.
 
@@ -128,10 +128,10 @@ long as the thing throwing them is tall.
   announces it, and checked ahead of the active() gate so switching it
   while voxel mode is off still drops what is cached.
 
-  `tests/drivers/voxel_void_probe.lua` walks the three modes and reports
-  the border block, whether the mesh built and whether the scene took the
-  3D path. It deliberately does NOT invalidate the cache itself, since
-  doing so would hide the second half of this.
+  `mods/DRAMATIC_SHAPE/tests/voxel_void_probe.lua` walks the three modes
+  and reports the border block, whether the mesh built and whether the
+  scene took the 3D path. It deliberately does NOT invalidate the cache
+  itself, since doing so would hide the second half of this.
 
 - Water and flowers did not animate. The 2D path animates them by
   OVERDRAWING the animated cells on top of the static tile layer each
@@ -448,8 +448,9 @@ written up in `assets/docs/buidling_to_voxel/`.
 
 ## 1.1.0
 
-Interior furniture gets the shapes it depicts (docs/voxel-survey.md is the
-procedure that found and verified these).
+Interior furniture gets the shapes it depicts
+(mods/DRAMATIC_SHAPE/tools/voxel-survey.md is the procedure that found and
+verified these).
 
 ### Added
 
@@ -487,9 +488,10 @@ procedure that found and verified these).
   standing monitor, bookcases, TV standing on the floor behind the game
   console's relief, potted plant, flower pot, both staircases, and the
   wall/window band.
-- `tests/drivers/voxel_survey.lua`: screenshot-survey driver behind the
-  repeatable inspection procedure (SURVEY_MAP / SURVEY_SPOTS /
-  SURVEY_LEVELS / SHOT_DIR), documented in docs/voxel-survey.md.
+- `mods/DRAMATIC_SHAPE/tests/voxel_survey.lua`: screenshot-survey driver
+  behind the repeatable inspection procedure (SURVEY_MAP / SURVEY_SPOTS /
+  SURVEY_LEVELS / SHOT_DIR), documented in
+  mods/DRAMATIC_SHAPE/tools/voxel-survey.md.
 
 ### Changed
 
diff --git a/tests/voxel_anim_probe.lua b/tests/voxel_anim_probe.lua
new file mode 100644
index 0000000..5b6c5ff
--- /dev/null
+++ b/tests/voxel_anim_probe.lua
@@ -0,0 +1,133 @@
+-- Driver: does the water/flower tile animation reach voxel mode?
+--
+-- Shoots a burst of frames spaced about one animation period apart, in
+-- voxel mode and (for reference) flat.  The evidence is the difference
+-- between consecutive shots: water tile $14 rolls sideways a pixel a step
+-- and flower tile $03 cycles three frames, so the burst should NOT be
+-- eight copies of the same picture.
+--
+--   POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_anim_probe.lua lovec .
+--
+-- knobs (env):
+--   ANIM_MAP    map id                        (default PALLET_TOWN)
+--   ANIM_SPOT   "x,y[,facing]"                (default 5,6,down)
+--   ANIM_SHOTS  frames in the burst           (default 6)
+--   ANIM_LEVEL  voxel rung, 0 = flat          (default 3)
+--   SHOT_DIR    output directory, must exist  (default "shots")
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+  local TileRenderer = require("src.render.TileRenderer")
+
+  local SPEED = math.max(1,
+    math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
+  local function wait(n) U.wait(n * SPEED) end
+
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local mapId = os.getenv("ANIM_MAP") or "PALLET_TOWN"
+  local shots = math.floor(tonumber(os.getenv("ANIM_SHOTS")) or 6)
+  local level = math.floor(tonumber(os.getenv("ANIM_LEVEL")) or 3)
+  local sx, sy, facing = (os.getenv("ANIM_SPOT") or "5,6,down")
+    :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
+  facing = (facing ~= "" and facing) or "down"
+
+  local tileset = game.data.maps[mapId] and game.data.maps[mapId].tileset
+  local specs = tileset and game.data.tilesets
+                and TileRenderer.defaultAnimatedTiles(game.data.tilesets[tileset])
+  print(("[anim] %s tileset=%s animated entries=%d")
+    :format(mapId, tostring(tileset), specs and #specs or 0))
+  for _, s in ipairs(specs or {}) do
+    print(("[anim]   tile $%02X kind=%s period=%s")
+      :format(s.tile or -1, tostring(s.kind), tostring(s.period)))
+  end
+
+  local Zoom = require("src.render.Zoom")
+  Zoom.reset()
+  Pipelines.setLevel("tiltshift", 0)
+  U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
+  wait(20)
+
+  Pipelines.setLevel("voxel", level)
+  wait(30)                          -- outlast the camera tween
+
+  -- the atlas the terrain mesh actually samples, dumped per step: if these
+  -- differ but the frames do not, the animated tiles are not reaching the
+  -- geometry (baked into prop prisms, say) rather than not animating
+  local V = game.mods.exports["DRAMATIC_SHAPE"]
+  V = V and V.lib
+  local TerrainAtlas = V and V.require("TerrainAtlas")
+  local PaletteFX = require("src.render.PaletteFX")
+  local function dumpAtlas(tag)
+    if not TerrainAtlas then return end
+    local ow = game.overworld
+    local map = ow and ow.map
+    if not map then return end
+    local colors = nil
+    if not PaletteFX.usesGbcPack() and ow.paletteFor then
+      colors = ow:paletteFor(map)
+    end
+    local img = TerrainAtlas.forMap(map, colors)
+    if not img then return end
+    print(("[anim]   atlas %s: animated copy=%s")
+      :format(tag, tostring(img ~= map.renderer.image)))
+    local ok = pcall(function()
+      local w, h = img:getDimensions()
+      local c = love.graphics.newCanvas(w, h)
+      love.graphics.setCanvas(c)
+      love.graphics.clear(0, 0, 0, 0)
+      love.graphics.setColor(1, 1, 1, 1)
+      love.graphics.draw(img)
+      love.graphics.setCanvas()
+      c:newImageData():encode("png", "atlas_" .. tag .. ".png")
+    end)
+    if not ok then print("[anim]   atlas dump failed") end
+  end
+
+  -- where the animated tiles ended up in the geometry: a flat top quad
+  -- samples its tile through uvRect and picks the atlas patch up, but a
+  -- cell Structures turned into a prop prism has its pixels baked as
+  -- point-sampled voxels and would not
+  if V and specs then
+    local Structures = V.require("Structures")
+    local ow = game.overworld
+    local S = ow and ow.map and Structures.forMap(ow.map)
+    if S then
+      local def = ow.map.def
+      for _, spec in ipairs(specs) do
+        local flat, prop, run, first = 0, 0, 0, nil
+        for ty = 0, def.height * 4 - 1 do
+          for tx = 0, def.width * 4 - 1 do
+            local k = (ty + 64) * 4096 + (tx + 64)
+            if S.tileAt[k] == spec.tile then
+              if S.skip[k] then prop = prop + 1
+              elseif S.runs[k] then run = run + 1
+              else
+                flat = flat + 1
+                first = first or { tx, ty }
+              end
+            end
+          end
+        end
+        print(("[anim]   tile $%02X on this map: %d flat, %d in a volume run,"
+               .. " %d inside a prop%s"):format(spec.tile, flat, run, prop,
+          first and (" -- first at cell " .. math.floor(first[1] / 2)
+                     .. "," .. math.floor(first[2] / 2)) or ""))
+      end
+    end
+  end
+
+  -- one animation period is 20 logic frames at 60Hz, and TileRenderer.tick
+  -- runs on wall-clock steps, so ~22 rendered frames lands the next step
+  for i = 1, shots do
+    game.capturePath = ("%s/anim_%s_L%d_%02d.png"):format(DIR, mapId, level, i)
+    wait(3)
+    print(("[anim] shot %d at animFrame %d step %d"):format(
+      i, TileRenderer.animFrame(), math.floor(TileRenderer.animFrame() / 20) % 8))
+    dumpAtlas(tostring(i))
+    wait(22)
+  end
+
+  Pipelines.setLevel("voxel", 0)
+  wait(5)
+  print("[anim] done")
+end
diff --git a/tests/voxel_door_probe.lua b/tests/voxel_door_probe.lua
new file mode 100644
index 0000000..c59f37e
--- /dev/null
+++ b/tests/voxel_door_probe.lua
@@ -0,0 +1,43 @@
+-- Driver: the reported "2D flash when entering a building".
+-- Switches voxel mode on, walks through Red's front door, and records for
+-- EVERY frame of the warp whether a pipeline owned the world pass.  Any
+-- frame with the mode on but no pipeline is a frame the player saw flat.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local Pipelines = require("src.render.Pipelines")
+
+  U.teleport(game, "PALLET_TOWN", 5, 6, "up")
+  U.wait(30)
+  Pipelines.setLevel("voxel", 2)
+  Pipelines.setLevel("tiltshift", 0)
+  U.wait(30)
+  print("[door] outside -- level:", Pipelines.level("voxel"),
+        "pipeline:", tostring(Pipelines.worldPipeline()))
+  U.shot(game, DIR .. "/door_0_outside.png")
+
+  local flat, total = 0, 0
+  local firstFlatAt = nil
+  -- walk onto the doormat and through the warp, sampling every frame
+  for i = 1, 90 do
+    game.input.state = game.input.state or {}
+    U.tap(game, "up")
+    U.wait(1)
+    total = total + 1
+    if Pipelines.level("voxel") > 0 and Pipelines.worldPipeline() == nil then
+      flat = flat + 1
+      firstFlatAt = firstFlatAt or i
+      if flat <= 3 then
+        U.shot(game, DIR .. ("/door_flat_frame%d.png"):format(i))
+      end
+    end
+    if i == 45 then U.shot(game, DIR .. "/door_1_mid.png") end
+  end
+
+  U.wait(20)
+  print("[door] inside map:", game.overworld.map and game.overworld.map.id)
+  print("[door] pipeline:", tostring(Pipelines.worldPipeline()))
+  print(("[door] RESULT flat frames %d / %d  (first at %s)")
+    :format(flat, total, tostring(firstFlatAt)))
+  U.shot(game, DIR .. "/door_2_inside.png")
+end
diff --git a/tests/voxel_mem_probe.lua b/tests/voxel_mem_probe.lua
new file mode 100644
index 0000000..a6d8a21
--- /dev/null
+++ b/tests/voxel_mem_probe.lua
@@ -0,0 +1,67 @@
+-- Driver: measure voxel-mode memory growth over the Pallet -> Mt Moon
+-- trek. Teleports along the chain with voxel engaged, and after each map
+-- prints the Lua heap (post-collect), LOVE texture memory, and the size
+-- of the voxel mod's retained caches -- so the 4.9GB growth decomposes
+-- into named pools.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local clock = (love.timer and love.timer.getTime) or os.clock
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[mem] DRAMATIC_SHAPE mod not loaded")
+    return
+  end
+  local V = handle.lib
+
+  local CHAIN = {
+    { "PALLET_TOWN", 10, 8 },
+    { "ROUTE_1", 10, 18 },
+    { "VIRIDIAN_CITY", 20, 20 },
+    { "ROUTE_2", 8, 30 },
+    { "VIRIDIAN_FOREST", 17, 24 },
+    { "ROUTE_2", 8, 10 },
+    { "PEWTER_CITY", 18, 20 },
+    { "ROUTE_3", 20, 5 },
+    { "ROUTE_4", 10, 5 },
+    { "MT_MOON_1F", 15, 20 },
+  }
+
+  game:keypressed("6")   -- voxel 15
+  U.wait(20)
+
+  local lastLua = 0
+  for i, step in ipairs(CHAIN) do
+    local t0 = clock()
+    U.teleport(game, step[1], step[2], step[3], "down")
+    U.wait(30)   -- let the voxel frame build meshes for map + neighbors
+    local buildT = clock() - t0
+    collectgarbage("collect")
+    collectgarbage("collect")
+    local luaKB = collectgarbage("count")
+    local stats = love.graphics.getStats()
+    print(("[mem] %-18s lua=%7.1fMB (+%6.1f) tex=%7.1fMB images=%4d canvases=%3d fonts=%d  t=%5.0fms")
+      :format(step[1], luaKB / 1024, (luaKB - lastLua) / 1024,
+              (stats.texturememory or 0) / 1048576,
+              stats.images or -1, stats.canvases or -1, stats.fonts or -1,
+              buildT * 1000))
+    lastLua = luaKB
+  end
+
+  -- decompose the retained Lua heap: drop the mod's caches one at a time
+  -- and re-collect, so each pool's share is named
+  local ChunkMesher = V.require("ChunkMesher")
+  local Structures = V.require("Structures")
+  collectgarbage("collect")
+  local before = collectgarbage("count")
+  ChunkMesher.invalidate()       -- drops mesh cache AND Structures cache
+  collectgarbage("collect")
+  collectgarbage("collect")
+  local after = collectgarbage("count")
+  print(("[mem] voxel caches held %.1fMB of Lua heap (%.1f -> %.1f)")
+    :format((before - after) / 1024, before / 1024, after / 1024))
+  local stats = love.graphics.getStats()
+  print(("[mem] texture memory after invalidate: %.1fMB (meshes are not textures; GPU mesh memory is untracked)")
+    :format((stats.texturememory or 0) / 1048576))
+  print("[mem] done")
+end
diff --git a/tests/voxel_perf_probe.lua b/tests/voxel_perf_probe.lua
new file mode 100644
index 0000000..49e2677
--- /dev/null
+++ b/tests/voxel_perf_probe.lua
@@ -0,0 +1,204 @@
+-- Driver: diagnose the voxel mode-switch hitch and map-seam artifacts.
+--
+-- Three passes, all printed:
+--   1. Teleport to Route 1, engage voxel, and time every build-side
+--      function (Structures, ChunkMesher, meshes, atlases) plus per-frame
+--      wall time, so the mode-switch hitch decomposes into named costs.
+--   2. Rebuild the seam-band geometry CPU-side and list every quad that
+--      rises above the ground plane near the Route 1 / Pallet seam, to
+--      name the source of the stray pixels.
+--   3. Walk across the seam both ways logging the ground height the
+--      voxel pass would stand the player on each frame (the "hop").
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local clock = (love.timer and love.timer.getTime) or os.clock
+
+  U.teleport(game, "ROUTE_1", 10, 30, "down")
+  U.wait(10)
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[perf] DRAMATIC_SHAPE mod not loaded")
+    return
+  end
+  local V = handle.lib
+  local Structures = V.require("Structures")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel3D = V.require("Voxel3D")
+  local TerrainAtlas = V.require("TerrainAtlas")
+  local TileShape = V.require("TileShape")
+
+  -- ---- pass 1: instrument the build ----
+  local stats, order = {}, {}
+  local function wrap(tbl, name, label, argLabel)
+    local orig = tbl[name]
+    if not orig then print("[perf] missing " .. label); return end
+    tbl[name] = function(...)
+      local t0 = clock()
+      local r1, r2, r3 = orig(...)
+      local dt = clock() - t0
+      local lbl = label
+      if argLabel then
+        local extra = argLabel(...)
+        if extra then lbl = label .. "[" .. tostring(extra) .. "]" end
+      end
+      local s = stats[lbl]
+      if not s then
+        s = { n = 0, total = 0, max = 0 }
+        stats[lbl] = s
+        order[#order + 1] = lbl
+      end
+      s.n = s.n + 1
+      s.total = s.total + dt
+      if dt > s.max then s.max = dt end
+      return r1, r2, r3
+    end
+  end
+  local mapId = function(map) return map.id end
+  wrap(Structures, "forMap", "Structures.forMap", mapId)
+  wrap(Structures, "extractObjects", "Structures.extractObjects")
+  wrap(Structures, "buildVolume", "Structures.buildVolume")
+  wrap(Structures, "buildObject", "Structures.buildObject")
+  wrap(Structures, "buildGrass", "Structures.buildGrass")
+  wrap(Structures, "buildCylinders", "Structures.buildCylinders")
+  wrap(Structures, "buildStairs", "Structures.buildStairs")
+  wrap(Structures, "buildBookcases", "Structures.buildBookcases")
+  wrap(ChunkMesher, "geometry", "ChunkMesher.geometry", mapId)
+  wrap(ChunkMesher, "build", "ChunkMesher.build", mapId)
+  wrap(Voxel3D, "newMesh", "Voxel3D.newMesh")
+  wrap(TerrainAtlas, "forMap", "TerrainAtlas.forMap", mapId)
+
+  -- engage voxel level 2 (35 degrees) like the player: 6, then 6 again
+  local frameTimes = {}
+  game:keypressed("6")
+  for i = 1, 30 do
+    local f0 = clock()
+    coroutine.yield()
+    frameTimes[i] = clock() - f0
+  end
+  game:keypressed("6")
+  for i = 31, 60 do
+    local f0 = clock()
+    coroutine.yield()
+    frameTimes[i] = clock() - f0
+  end
+  U.shot(game, DIR .. "/perf_route1_voxel.png")
+
+  local sorted = {}
+  for _, lbl in ipairs(order) do sorted[#sorted + 1] = lbl end
+  table.sort(sorted, function(a, b) return stats[a].total > stats[b].total end)
+  print("[perf] ---- build stats (sorted by total) ----")
+  for _, lbl in ipairs(sorted) do
+    local s = stats[lbl]
+    print(("[perf] %-50s n=%5d total=%8.1fms max=%7.1fms")
+      :format(lbl, s.n, s.total * 1000, s.max * 1000))
+  end
+  print("[perf] ---- frame spikes over 25ms ----")
+  for i, ft in ipairs(frameTimes) do
+    if ft > 0.025 then
+      print(("[perf] frame %02d: %.1fms"):format(i, ft * 1000))
+    end
+  end
+
+  -- ---- pass 2: seam-band geometry scan ----
+  local function scanQuads(tag, verts, ox, oy, zlo, zhi, xlo, xhi)
+    local n = 0
+    for i = 1, #verts, 4 do
+      local minX, minY, minZ = math.huge, math.huge, math.huge
+      local maxX, maxY, maxZ = -math.huge, -math.huge, -math.huge
+      for j = i, i + 3 do
+        local v = verts[j]
+        local x, y, z = v[1] + ox, v[2], v[3] + oy
+        if x < minX then minX = x end
+        if x > maxX then maxX = x end
+        if v[2] < minY then minY = y end
+        if y > maxY then maxY = y end
+        if z < minZ then minZ = z end
+        if z > maxZ then maxZ = z end
+      end
+      if maxY > 0.01 and minZ < zhi and maxZ > zlo
+         and (not xlo or (minX < xhi and maxX > xlo)) then
+        n = n + 1
+        if n <= 60 then
+          print(("[seam] %s x[%6.1f..%6.1f] y[%5.1f..%5.1f] z[%6.1f..%6.1f]")
+            :format(tag, minX, maxX, minY, maxY, minZ, maxZ))
+        end
+      end
+    end
+    print(("[seam] %s: %d raised quads in band"):format(tag, n))
+  end
+  local function flatQuads(list)
+    -- Structures object/grass quad lists ({v1..v4, uv=..} entries) -> verts
+    local verts = {}
+    for _, q in ipairs(list) do
+      for j = 1, 4 do verts[#verts + 1] = q[j] end
+    end
+    return verts
+  end
+
+  local ow = game.overworld
+  print("[seam] current map: " .. tostring(ow.map.id))
+  local masks = {}
+  for _, nb in ipairs(ow.neighbors or {}) do
+    masks[#masks + 1] = { nb.ox, nb.oy, nb.ox + nb.map.def.width * 32,
+                          nb.oy + nb.map.def.height * 32 }
+    print(("[seam] neighbor %-16s ox=%d oy=%d"):format(nb.map.id, nb.ox, nb.oy))
+  end
+  local seamZ = ow.map.def.height * 32
+  local zlo, zhi = seamZ - 24, seamZ + 24
+  print(("[seam] scanning band z=[%d..%d] (Route 1 south edge at %d)")
+    :format(zlo, zhi, seamZ))
+  do
+    local verts = ChunkMesher.geometry(ow.map, false, masks)
+    scanQuads("cur.full ", verts, 0, 0, zlo, zhi)
+    local S = Structures.forMap(ow.map)
+    scanQuads("cur.grass", flatQuads(S.grassQuads), 0, 0, zlo, zhi)
+    scanQuads("cur.objs ", flatQuads(S.objectQuads), 0, 0, zlo, zhi)
+  end
+  for _, nb in ipairs(ow.neighbors or {}) do
+    local verts = ChunkMesher.geometry(nb.map, true)
+    scanQuads("nb." .. nb.map.id, verts, nb.ox, nb.oy, zlo, zhi)
+    local S = Structures.forMap(nb.map)
+    scanQuads("nb." .. nb.map.id .. ".grass", flatQuads(S.grassQuads),
+              nb.ox, nb.oy, zlo, zhi)
+  end
+
+  -- ---- pass 3: cross the seam both ways, logging ground height ----
+  local function crossLog(fromMap, x, y, dir, steps, tag)
+    U.teleport(game, fromMap, x, y, dir)
+    U.wait(20)
+    print(("[hop] ---- %s: %s (%d,%d) heading %s ----")
+      :format(tag, fromMap, x, y, dir))
+    for i = 1, steps do
+      table.insert(game.input.pressQueue, dir)
+      game.input.state[dir] = true
+      local f0 = clock()
+      coroutine.yield()
+      local ft = clock() - f0
+      local o = game.overworld
+      local p = o.player
+      local m = o.map
+      local shapes = TileShape.forMap(m)
+      local s = shapes[m:cellTile(p.cellX, p.cellY)]
+      local g = 0
+      if s and s.art ~= "stair" and s.h > 0 then g = s.h end
+      if not m:inBounds(p.cellX, p.cellY) or g ~= 0 or ft > 0.025 then
+        print(("[hop] f%02d map=%-14s cell=(%d,%d) py=%.0f inB=%s gh=%d cls=%s ft=%.0fms")
+          :format(i, m.id, p.cellX, p.cellY, p.py,
+                  tostring(m:inBounds(p.cellX, p.cellY)), g,
+                  s and s.class or "?", ft * 1000))
+      end
+      game.input.state[dir] = false
+    end
+    local o = game.overworld
+    print(("[hop] end map=%s cell=(%d,%d)")
+      :format(o.map.id, o.player.cellX, o.player.cellY))
+  end
+  crossLog("ROUTE_1", 10, 34, "down", 60, "route1 -> pallet")
+  U.shot(game, DIR .. "/perf_pallet_after_cross.png")
+  crossLog("PALLET_TOWN", 10, 1, "up", 60, "pallet -> route1")
+  U.shot(game, DIR .. "/perf_route1_after_cross.png")
+
+  print("[perf] done")
+end
diff --git a/tests/voxel_pipeline_probe.lua b/tests/voxel_pipeline_probe.lua
new file mode 100644
index 0000000..eb0c442
--- /dev/null
+++ b/tests/voxel_pipeline_probe.lua
@@ -0,0 +1,67 @@
+-- Driver: prove the render-pipeline seam end to end.
+--
+-- Teleports to Pallet Town, screenshots the flat world, engages the
+-- DRAMATIC_SHAPE mod's pipeline exactly the way the player does (hotkey 6),
+-- screenshots the diorama, then walks the T-SHIFT ladder with hotkey 9.
+-- Every gate on the path is printed, so a run that comes back flat says
+-- which check refused rather than just looking wrong.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local Pipelines = require("src.render.Pipelines")
+
+  U.teleport(game, "PALLET_TOWN", 10, 8, "down")
+  U.wait(30)
+  U.shot(game, DIR .. "/pipeline_0_flat.png")
+
+  print("[probe] pipelines registered:")
+  for _, entry in ipairs(Pipelines.list()) do
+    print(("  %-10s label=%-8s world=%s worldPresent=%s present=%s hotkey=%s")
+      :format(entry.id, tostring(entry.def.label),
+              entry.def.drawWorld ~= nil, entry.def.worldPresent ~= nil,
+              entry.def.present ~= nil, tostring(entry.def.hotkey)))
+  end
+
+  local defs = game.data.render_pipelines or {}
+  print("[probe] voxel available:",
+        defs.voxel and defs.voxel.available and defs.voxel.available())
+
+  -- press 6 exactly like the player
+  game:keypressed("6")
+  print("[probe] after key6 level:", Pipelines.level("voxel"),
+        "saved:", game.save.options.pipelines
+                  and game.save.options.pipelines.voxel,
+        "tilt:", game.save.options.tilt)
+  U.wait(30)
+  print("[probe] world pipeline:",
+        tostring(Pipelines.worldPipeline(game.stack:top(), game.overworld)))
+  print("[probe] renderer override:",
+        tostring(game.renderer.worldOverride))
+  U.shot(game, DIR .. "/pipeline_1_voxel15.png")
+
+  for _, level in ipairs({ 2, 3 }) do
+    game:keypressed("6")
+    U.wait(25)
+    print(("[probe] voxel level %d -> override %s")
+      :format(Pipelines.level("voxel"), tostring(game.renderer.worldOverride)))
+    U.shot(game, DIR .. ("/pipeline_1_voxel%d.png"):format(level))
+  end
+
+  -- tilt-shift ladder over the diorama
+  for _, level in ipairs({ 1, 2, 3 }) do
+    game:keypressed("9")
+    U.wait(20)
+    print(("[probe] t-shift level %d"):format(Pipelines.level("tiltshift")))
+    U.shot(game, DIR .. ("/pipeline_2_tshift%d.png"):format(level))
+  end
+
+  -- and back off: the world must return to the flat draw, not stay stuck
+  game:keypressed("9")
+  game:keypressed("6")
+  U.wait(30)
+  print("[probe] back off -- voxel:", Pipelines.level("voxel"),
+        "override:", tostring(game.renderer.worldOverride))
+  U.shot(game, DIR .. "/pipeline_3_backflat.png")
+
+  print("[probe] done")
+end
diff --git a/tests/voxel_seam_shots.lua b/tests/voxel_seam_shots.lua
new file mode 100644
index 0000000..36bf12b
--- /dev/null
+++ b/tests/voxel_seam_shots.lua
@@ -0,0 +1,34 @@
+-- Driver: screenshot a voxel-mode connection crossing frame by frame.
+--
+-- Stands at the Viridian -> Route 1 seam (script-free, unlike Pallet's
+-- north edge where Oak interrupts a fresh save), engages voxel 35, waits
+-- for the neighbourhood to finish building, then walks south across the
+-- seam capturing every other frame -- the burst shows whether the seam
+-- line carries stray pixels and whether the walker's height stays glued
+-- to the ground through the crossing.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+
+  U.teleport(game, "VIRIDIAN_CITY", 20, 33, "down")
+  game:keypressed("6")
+  U.wait(5)
+  game:keypressed("6")   -- 35 degrees
+  U.wait(150)            -- let every neighbour mesh land
+  U.shot(game, DIR .. "/seam_0_viridian.png")
+
+  local shot = 0
+  for i = 1, 56 do
+    table.insert(game.input.pressQueue, "down")
+    game.input.state.down = true
+    coroutine.yield()
+    game.input.state.down = false
+    if i % 2 == 0 and i >= 24 then
+      shot = shot + 1
+      U.shot(game, DIR .. ("/seam_cross_%02d.png"):format(shot))
+    end
+  end
+  print("[seam-shots] end map " .. game.overworld.map.id
+        .. " cell " .. game.overworld.player.cellX
+        .. "," .. game.overworld.player.cellY)
+end
diff --git a/tests/voxel_shadow_probe.lua b/tests/voxel_shadow_probe.lua
new file mode 100644
index 0000000..5cf9178
--- /dev/null
+++ b/tests/voxel_shadow_probe.lua
@@ -0,0 +1,128 @@
+-- Driver: prove out the voxel sun pass.
+--
+-- Reports whether the shadow map can run, dumps the map itself as a PNG
+-- (the packed depth reads as a red/green gradient -- what matters is that
+-- it is populated and the right way up), and screenshots a stand point at
+-- every camera pitch so the cast shadows can be eyeballed against the flat
+-- ground truth.
+--
+--   POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_shadow_probe.lua lovec .
+--
+-- knobs (env):
+--   SHADOW_MAP    map id                        (default PALLET_TOWN)
+--   SHADOW_SPOT   "x,y[,facing]"                (default 5,6,down)
+--   SHOT_DIR      output directory, must exist  (default "shots")
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local SPEED = math.max(1,
+    math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
+  local function wait(n) U.wait(n * SPEED) end
+
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local mapId = os.getenv("SHADOW_MAP") or "PALLET_TOWN"
+  local sx, sy, facing = (os.getenv("SHADOW_SPOT") or "5,6,down")
+    :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
+  facing = (facing ~= "" and facing) or "down"
+
+  -- reach the mod's lib namespace the way a companion mod would
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  local V = handle and handle.lib
+  assert(V, "DRAMATIC_SHAPE exports not reachable")
+  local ShadowMap = V.require("ShadowMap")
+  local Voxel3D = V.require("Voxel3D")
+  local VoxelGrid = V.require("VoxelGrid")
+
+  -- SHADOW_GRID=1 forces the voxel wireframe on for the run, and
+  -- SHADOW_CURVE=n the curved-world rung -- neither touches the player's
+  -- persisted setting
+  if os.getenv("SHADOW_GRID") == "1" then
+    VoxelGrid.sync(true)
+    print("[shadow] voxel grid forced on; shader built="
+          .. tostring(Voxel3D.shader(true) ~= nil))
+  end
+  local WorldCurve = V.require("WorldCurve")
+  local curve = math.floor(tonumber(os.getenv("SHADOW_CURVE")) or 0)
+  if curve > 0 then
+    WorldCurve.sync(curve)
+    print(("[shadow] world curve rung %d (amount %.2f)")
+      :format(curve, WorldCurve.AMOUNTS[curve + 1] or 0))
+  end
+
+  -- SHADOW_SUN="kx,kz" retunes the bearing for one run, so a sun can be
+  -- compared against another without an edit-rebuild cycle
+  local skx, skz = (os.getenv("SHADOW_SUN") or ""):match("^(-?[%d.]+),(-?[%d.]+)$")
+  if skx then
+    ShadowMap.KX, ShadowMap.KZ = tonumber(skx), tonumber(skz)
+    Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = ShadowMap.KX, ShadowMap.KZ
+  end
+
+  print(("[shadow] sun shear kx=%.2f kz=%.2f  alpha=%.2f  res=%d")
+    :format(ShadowMap.KX, ShadowMap.KZ, Voxel3D.SHADOW_ALPHA, ShadowMap.res))
+
+  -- pin the zoom: the world view size drives the light frustum, and a run
+  -- that inherits whatever the player left in options.lua is not
+  -- comparable with the one before it
+  local Zoom = require("src.render.Zoom")
+  Zoom.reset()
+  local steps = math.floor(tonumber(os.getenv("SHADOW_ZOOM")) or 0)
+  for _ = 1, math.abs(steps) do
+    Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale())
+  end
+
+  Pipelines.setLevel("tiltshift", 0)
+  U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
+  wait(20)
+  do
+    local vw, vh = game.renderer:worldViewSize()
+    print(("[shadow] world view %dx%d px, zoom offset %d")
+      :format(vw, vh, Zoom.offset))
+  end
+
+  Pipelines.setLevel("voxel", 0)
+  wait(25)
+  game.capturePath = ("%s/shadow_%s_flat.png"):format(DIR, mapId)
+  wait(3)
+
+  print("[shadow] available=" .. tostring(ShadowMap.available()))
+
+  for level = 1, Pipelines.maxLevel("voxel") do
+    Pipelines.setLevel("voxel", level)
+    wait(30)                      -- outlast the 0.25s camera tween
+    local label = Pipelines.levelLabel("voxel", level) or level
+    game.capturePath = ("%s/shadow_%s_v%s.png"):format(DIR, mapId, label)
+    wait(3)
+    local e = ShadowMap.extent or { 0, 0, 0 }
+    print(("[shadow] level %s active=%s bias=%.6f frustum=%.0fx%.0f deep %.0f"
+           .. " (%.2f world px/texel)")
+      :format(label, tostring(ShadowMap.active()), ShadowMap.bias,
+              e[1], e[2], e[3], e[1] / ShadowMap.res))
+  end
+
+  -- the map itself: packed depth, so red is the high byte of "how far the
+  -- sun got". A blank (all-white) dump means nothing was drawn into it.
+  local canvas = ShadowMap.texture()
+  if canvas and canvas.newImageData then
+    local ok, err = pcall(function()
+      local data = canvas:newImageData()
+      local w, h = data:getDimensions()
+      local hit = 0
+      for y = 0, h - 1, 8 do
+        for x = 0, w - 1, 8 do
+          local r = data:getPixel(x, y)
+          if r < 0.999 then hit = hit + 1 end
+        end
+      end
+      print(("[shadow] map %dx%d, %d/%d sampled texels written")
+        :format(w, h, hit, (w / 8) * (h / 8)))
+      data:encode("png", "shadowmap.png")
+    end)
+    print("[shadow] dump " .. (ok and "ok (save dir/shadowmap.png)"
+                               or tostring(err)))
+  end
+
+  Pipelines.setLevel("voxel", 0)
+  wait(5)
+  print("[shadow] done")
+end
diff --git a/tests/voxel_survey.lua b/tests/voxel_survey.lua
new file mode 100644
index 0000000..9f9f405
--- /dev/null
+++ b/tests/voxel_survey.lua
@@ -0,0 +1,93 @@
+-- Driver: voxel-accuracy survey of one map.
+--
+-- Teleports to a map, walks a list of stand points, and screenshots each
+-- one flat (2D ground truth) and at every voxel camera pitch, tilt-shift
+-- forced off.  The shots are the evidence an agent (or a human) reads to
+-- find sprites whose 3D shape does not match what the object is -- a bed
+-- extruded into a wall, a flat stool, stairs drawn as a box.
+--
+--   POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_survey.lua love .
+--
+-- knobs (env):
+--   SURVEY_MAP     map id                          (default REDS_HOUSE_2F)
+--   SURVEY_SPOTS   "x,y[,facing][@label]; ..."     (default centre of map)
+--                  cell coordinates, facing up/down/left/right
+--   SURVEY_LEVELS  comma list of voxel levels 1..3 (default "1,2,3";
+--                  15/35/50 degrees)
+--   SHOT_DIR       output directory, must exist    (default "shots")
+--
+-- Levels are set through Pipelines.setLevel, not the player hotkey, so the
+-- run never writes the player's options.lua and cannot leave tilt-shift on.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  -- Under POKEPORT_SPEED=N the driver is resumed N times per RENDERED
+  -- frame, so anything that must span a draw (a capture flush, a camera
+  -- tween) needs its yield count scaled by N or the screenshot lands on a
+  -- later state than the one it names.
+  local SPEED = math.max(1, math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
+  local function wait(frames) U.wait(frames * SPEED) end
+
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local mapId = os.getenv("SURVEY_MAP") or "REDS_HOUSE_2F"
+
+  local mapDef = game.data.maps and game.data.maps[mapId]
+  assert(mapDef, "unknown map: " .. tostring(mapId))
+
+  local spots = {}
+  local spec = os.getenv("SURVEY_SPOTS")
+  if spec and spec ~= "" then
+    for entry in spec:gmatch("[^;]+") do
+      local body, label = entry:match("^%s*(.-)%s*@%s*(%S+)%s*$")
+      body = body or entry
+      local x, y, facing = body:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
+      assert(x, "bad SURVEY_SPOTS entry: " .. entry)
+      spots[#spots + 1] = {
+        x = tonumber(x), y = tonumber(y),
+        facing = (facing ~= "" and facing) or "up",
+        label = label or (x .. "x" .. y),
+      }
+    end
+  else
+    spots[1] = { x = math.floor(mapDef.width * 2 / 2),
+                 y = math.floor(mapDef.height * 2 / 2),
+                 facing = "up", label = "centre" }
+  end
+
+  local levels = {}
+  for n in (os.getenv("SURVEY_LEVELS") or "1,2,3"):gmatch("%d") do
+    levels[#levels + 1] = tonumber(n)
+  end
+
+  local function shot(name)
+    local path = ("%s/%s_%s.png"):format(DIR, mapId, name)
+    game.capturePath = path
+    wait(3) -- let the capture flush through a real draw
+    print("[survey] shot " .. path)
+  end
+
+  -- the survey's whole point is reading the voxel geometry: the miniature
+  -- blur would soften exactly the edges under inspection
+  Pipelines.setLevel("tiltshift", 0)
+
+  for i, s in ipairs(spots) do
+    U.teleport(game, mapId, s.x, s.y, s.facing)
+    wait(20)
+
+    Pipelines.setLevel("voxel", 0)
+    wait(25)
+    if i == 1 then shot(s.label .. "_flat") end
+
+    for _, level in ipairs(levels) do
+      Pipelines.setLevel("voxel", level)
+      wait(25) -- outlast the 0.25s camera tween
+      shot(("%s_v%s"):format(s.label,
+        Pipelines.levelLabel("voxel", level) or level))
+    end
+  end
+
+  Pipelines.setLevel("voxel", 0)
+  wait(5)
+  print("[survey] done: " .. #spots .. " spots")
+end
diff --git a/tests/voxel_void_probe.lua b/tests/voxel_void_probe.lua
new file mode 100644
index 0000000..554a69c
--- /dev/null
+++ b/tests/voxel_void_probe.lua
@@ -0,0 +1,68 @@
+-- Driver: does voxel mode survive every VOID FILL mode?
+--
+-- The ring around a map's body is baked into the terrain mesh, and which
+-- block it is made of comes from the same TileRenderer.borderBlockFor the
+-- 2D path uses -- including the BLACK mode, which is not a block at all.
+-- This walks the three modes on an outdoor map, reports whether the mesh
+-- built and whether the scene actually took the 3D path, and shoots each.
+--
+--   POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_void_probe.lua lovec .
+--
+-- knobs (env):
+--   VOID_MAP    map id                        (default PALLET_TOWN)
+--   VOID_SPOT   "x,y[,facing]"                (default 5,6,down)
+--   VOID_LEVEL  voxel rung                    (default 3)
+--   SHOT_DIR    output directory, must exist  (default "shots")
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+  local TileRenderer = require("src.render.TileRenderer")
+
+  local SPEED = math.max(1,
+    math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
+  local function wait(n) U.wait(n * SPEED) end
+
+  local DIR = os.getenv("SHOT_DIR") or "shots"
+  local mapId = os.getenv("VOID_MAP") or "PALLET_TOWN"
+  local level = math.floor(tonumber(os.getenv("VOID_LEVEL")) or 3)
+  local sx, sy, facing = (os.getenv("VOID_SPOT") or "5,6,down")
+    :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
+  facing = (facing ~= "" and facing) or "down"
+
+  local V = game.mods.exports["DRAMATIC_SHAPE"]
+  V = V and V.lib
+  assert(V, "DRAMATIC_SHAPE exports not reachable")
+  local Voxel = V.require("VoxelState")
+  local ChunkMesher = V.require("ChunkMesher")
+
+  local Zoom = require("src.render.Zoom")
+  Zoom.reset()
+  Pipelines.setLevel("tiltshift", 0)
+  U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
+  wait(20)
+  Pipelines.setLevel("voxel", level)
+  wait(30)
+
+  for _, mode in ipairs(TileRenderer.VOID_FILLS) do
+    -- Set it the way the options row does and NOTHING else: the ring is
+    -- baked into the mesh, so the mod has to notice the change and drop
+    -- the cache itself. Invalidating here would hide exactly the bug this
+    -- driver exists to catch.
+    TileRenderer.setVoidFill(mode)
+    wait(40)
+    local ow = game.overworld
+    local border = TileRenderer.borderBlockFor(ow.map)
+    local ok, mesh = pcall(ChunkMesher.peek, ow.map, false)
+    print(("[void] %-6s border=%-5s mesh=%s ready=%s pending=%d")
+      :format(mode, tostring(border),
+              tostring(ok and mesh ~= nil), tostring(Voxel.ready),
+              ChunkMesher.pending()))
+    game.capturePath = ("%s/void_%s_%s.png"):format(DIR, mapId, mode)
+    wait(4)
+  end
+
+  TileRenderer.setVoidFill("trees")
+  Pipelines.setLevel("voxel", 0)
+  wait(5)
+  print("[void] done")
+end
diff --git a/tools/voxel-survey.md b/tools/voxel-survey.md
new file mode 100644
index 0000000..9709873
--- /dev/null
+++ b/tools/voxel-survey.md
@@ -0,0 +1,167 @@
+# Voxel accuracy survey
+
+A repeatable procedure -- runnable by an agent or a human -- for finding
+map objects the DRAMATIC_SHAPE mod voxelizes into the wrong shape (a bed
+extruded to wall height, a table merged into the wall, stairs lying flat)
+and for fixing them so each object gets the 3D shape it depicts.
+
+The loop is: **survey → diagnose → pin → re-survey → spot-check**.
+
+## 1. Survey a location
+
+`mods/DRAMATIC_SHAPE/tests/voxel_survey.lua` teleports to a map, walks a
+list of stand points and screenshots each one flat (the 2D ground truth)
+and at every voxel camera pitch, with tilt-shift forced off (it would blur
+exactly the edges under inspection).  Levels are set through
+`Pipelines.setLevel`, so the run never writes the player's options.
+
+```powershell
+$env:POKEPORT_DRIVER = "mods/DRAMATIC_SHAPE/tests/voxel_survey.lua"
+$env:SHOT_DIR   = "<absolute scratch dir, must exist>"
+$env:SURVEY_MAP = "REDS_HOUSE_2F"
+$env:SURVEY_SPOTS = "3,4,up@centre; 2,6,left@bed; 5,2,right@stairs"
+$env:POKEPORT_SPEED = "4"          # optional; the driver scales its waits
+& lovec.exe .
+```
+
+- `SURVEY_SPOTS` is `x,y[,facing][@label]` in cell coordinates, `;`
+  separated. Pick the room centre plus one spot near each object of
+  interest, so every object is seen close up and from more than one
+  parallax.
+- `SURVEY_LEVELS` defaults to `1,2,3` (15/35/50 degrees). All three
+  matter: tall-object errors scream at 35/50, thin-object errors (a prop
+  with no body) only show near top-down at 15.
+- The run quits by itself and names shots `<map>_<label>_<flat|v15|v35|v50>.png`.
+
+## 2. Diagnose against the flat shot
+
+The `_flat` shot is the authority for what each object IS. Compare every
+voxel shot against it and record, per object: cells, what it looks like,
+what it should look like. The recurring failure modes:
+
+| symptom | cause | fix class |
+| --- | --- | --- |
+| furniture as a wall-height box | detector defaults solid tiles to `wall` | `bed` / `table` / `desk` |
+| furniture towering 3-6 blocks | flood-fill merged it into the wall region, region consensus adopted the tall height | pin it; pinned tiles leave the region |
+| walkable art lying flat (stairs, mats) | walkable cells resolve to `ground` | `stair_*`, or leave (mats ARE flat) |
+| prop as a solid box wrapped in its art | background flood could not reach around it | `billboard` (forced per-pixel prop) |
+| top-down drawing standing upright | art depicts a surface, class folds it | any `top`-art class (`bed`, `ledge`) |
+| a house as a cube wearing its own elevation | one drawing packs roof, facade and slopes, and the volume path folds all three upright | a `buildings` template (see below) |
+
+To identify tiles: the map's `blocks` list in `data/generated/maps.lua`
+indexes `tileset.blocks` (0-based; each block is 4x4 tile ids over 2x2
+cells), and the atlas is the tileset's `image` PNG, 16 tiles per row.
+Zoom it with a grid to read ids:
+
+```python
+from PIL import Image, ImageDraw
+im = Image.open("assets/generated/tilesets/<atlas>.png").convert("RGB")
+z = 8; big = im.resize((im.width*z, im.height*z), Image.NEAREST)
+d = ImageDraw.Draw(big)
+for t in range(im.width//8 * im.height//8):
+    x, y = (t % 16)*8*z, (t//16)*8*z
+    d.rectangle([x, y, x+8*z-1, y+8*z-1], outline=(255,0,0))
+    d.text((x+3, y+2), str(t), fill=(255,0,255))
+big.save("<scratch>/atlas_ids.png")
+```
+
+## 3. Pin shapes in the profile
+
+`mods/DRAMATIC_SHAPE/data/voxel_heights.lua` maps tile ids to classes per
+tileset id; a pinned tile bypasses detection entirely. The classes:
+
+- `bed` (h7, art on top) -- anything drawn from above that lies low.
+  Also the fallback for a drawing that depicts furniture WITH someone on
+  it: a standee would shred it (see the shade note below) and an upright
+  fold duplicates them onto the box's top and front, but a slab draws the
+  whole thing exactly once.
+- `counter` (h8) -- a service counter: half a cell, one 8px band, so the
+  drawn front panel stands up and the counter top stays on top. Shorter
+  than `table` on purpose; a 12px counter reads as a wall stub.
+- `table` (h12) / `desk` (h24) -- boxes; the south face folds the drawing
+  upright, flanks wear the front stack darkened, and the top keeps the
+  drawn surface (a meal drawn on the tabletop stays on the tabletop).
+- `billboard` (10px) / `prop` (5px) / `stool` (5px) / `cutout` (1px) --
+  standing per-pixel cutouts: TVs, plants, monitors, stools, vases.
+  Segmented by the art's BLACK OUTLINE: background is the shades
+  touching the cluster's edge, flooded in from around it; the outline
+  and everything it encloses survives, paint whites included -- so a
+  white vase cuts cleanly out of a grey tabletop. Solid pixels then
+  split into connected components, each standing on its own feet in the
+  row it is drawn in (two stacked stools stay two stools; nothing
+  floats), and a prop drawn directly above a pinned box stands ON that
+  box (the monitor on its desk, the vase on the table). Touching
+  drawings that must stay separate objects go in different pools.
+  `stool` additionally seats characters: standing on its walkable cell
+  lifts them to its 8px class height.
+  **Check the drawing has a floor margin before reaching for these.**
+  The background is read off the shades touching the cluster's rim, so a
+  drawing that runs edge to edge sees its own body colours flood: the
+  Pokemon Center bench loses 307 of its 420 interior pixels that way and
+  comes out a bare outline. Histogram the rim against the interior first
+  -- if all three non-black shades appear on the rim, no standee pool
+  will work and the object wants a box or a slab instead.
+- `relief` (h3) -- a prop drawn from above (a game console on the
+  floor): the drawing stays flat and only the pixels inside its outline
+  extrude, art on the top face.
+- `bookcase` (h32) -- a free-standing shelf drawn tall, not deep: each
+  rank collapses onto a one-cell-deep box at its full drawn height,
+  back rows become hidden floor, and a trim row above that cannot be
+  pinned (shared with other furniture) is adopted as the cap. Pin the
+  book rows and base; leave the shared trim unpinned.
+- `stair_e` / `stair_w` -- a rising flight of four steps climbing toward
+  the named side, for stairs leading UP.
+- `stair_down_e` / `stair_down_w` -- a sunken stairwell descending toward
+  the named side, for stairs leading DOWN. Read the drawn railing to pick
+  the side: its high end is where the player enters at floor level.
+- `wall` -- pin the wall band (and its windows) when de-merging furniture
+  would otherwise leave the auto-detected wall patchy.
+
+A whole building is not a tile pin. Its drawing packs several 3D facings
+at once -- roof from above, facade face-on, ends as diagonal silhouettes --
+and no single class covers that, so buildings go in the profile's
+`buildings` list instead, as a BAND TABLE over the drawing's rows
+(`mods/DRAMATIC_SHAPE/lib/Buildings.lua`; the pipeline is
+`mods/DRAMATIC_SHAPE/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md`).
+A template is matched by its exact tile grid, which
+`mods/DRAMATIC_SHAPE/assets/docs/buildings/` catalogues per building along
+with every map that places it, so one entry covers all of them. Author only what needs a human to read the drawing -- which rows are
+roof, how the roof's depth maps onto them, the slab, the eave, an awning
+band -- because the silhouette, the taper rate (the slope), the eave
+height and every window are measured off the pixels.
+
+Verify a new template against `mods/DRAMATIC_SHAPE/tools/building_voxels.py`,
+which builds the same model offline and renders isometric previews: the
+voxel and shell counts it prints must match the runtime's (the mod's
+`Buildings.stats()`), and it asserts the intent -- symmetric profile,
+constant taper rate, nothing poking through the roof, every wall column
+covered.
+
+Heights live in the same file; a cell is 16x16 px, a "block" of height
+is 8. The voxelization must be pixel perfect: every standee and relief
+voxel carries exactly its source texel, and a segmentation that eats or
+keeps the wrong pixels (a cut-off monitor, a slab of tabletop in the
+cutout) is a bug -- fix the pin set or the segmentation, don't accept it.
+
+## 4. Re-survey and compare
+
+Re-run step 1 into a fresh `SHOT_DIR` and put before/after side by side.
+Check every object at every pitch, not just the one you fixed -- pins
+change region shapes, so neighbours can shift.
+
+## 5. Spot-check the blast radius
+
+- Every map sharing the tileset id inherits the pins (grep
+  `tileset = "<id>"` in `data/generated/maps.lua`) -- survey at least one.
+- A change to the shared analysis (anything in `lib/Structures.lua`
+  rather than the data file) affects every map of that kind; survey one
+  unrelated busy interior (e.g. `OAKS_LAB`) to prove nothing regressed.
+- `luajit mods/DRAMATIC_SHAPE/tests/dramatic_shape_test.lua` for the headless
+  invariants.
+
+## Gameplay is out of bounds
+
+Shape pins are purely presentational. Collision, warps and triggers read
+the same data they always did -- a stairwell cell is still the walkable
+warp cell it was when it was flat. If a fix seems to need a collision
+change, it is the wrong fix.