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.