DramaticShapeVoxelMod archival mirror · read-only
c8555e6820b46444ca3e29b25742c6878d6266bd

update CUT tree voxelization and mansion computer desks

DramaticShape · 2026-08-06 · parent 91d9a37e8f

update CUT tree voxelization and mansion computer desks

Changed files

data/voxel_heights.lua       | 162 +++++++++++--
 lib/Structures.lua           |  34 ++-
 lib/TileShape.lua            |   9 +
 tests/cuttree_shots.lua      | 101 ++++++++
 tests/diner_shots.lua        |  95 ++++++++
 tests/mansion_desk_shots.lua | 102 ++++++++
 tests/mansion_shots.lua      |  91 +++++++
 tests/safari_shots.lua       |  96 ++++++++
 tests/table_shots.lua        |  96 ++++++++
 tests/tile_pick.lua          | 816 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/tree_shots.lua         | 101 ++++++++
 tools/building_voxels.py     |  57 +++++
 12 files changed, 1734 insertions(+), 26 deletions(-)

Diff

diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 3b4c904..367b034 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -168,6 +168,10 @@ return {
     -- a round drawing stacked two cells high on one cell of plot (the
     -- Centers' potted plants): 32px of hull standing in its lower cell
     planter = 32,
+    -- the little trees (Celadon Gym's garden, the overworld's cuttable
+    -- tree): the round hull squashed front to back, 16px of drawn
+    -- elevation like every other one-cell tree drawing
+    sapling = 16,
     relief = 3,
     bookcase = 32,
     stair_e = 16,
@@ -199,13 +203,14 @@ return {
       -- outline, round in depth, so tree rows become rows of real canopies
       --
       -- The cuttable tree ($2D/$2E/$3D/$3E, the four tiles Cut deletes)
-      -- joins them: it is Celadon Gym's little tree redrawn pixel-for-
-      -- pixel on this atlas (same 146/256 silhouette, only canopy
-      -- highlight texels differ), and takes the identical hull -- the
-      -- scraggly canopy revolves into a gapped ball, the 2-4px trunk
-      -- into a thin round column, the root flare into a round mound.
-      -- It used to sit in the `prop` pool as a 5-voxel standee; see
-      -- GYM's cylinder entry for the reading.  Scanned: the 2x2 grid
+      -- takes the same hull SQUASHED: it is Celadon Gym's little tree
+      -- redrawn pixel-for-pixel on this atlas (same 146/256 silhouette,
+      -- only canopy highlight texels differ), so the two must stay one
+      -- model -- the scraggly canopy revolves into a gapped ball, the
+      -- 2-4px trunk into a thin round column, the root flare into a
+      -- round mound.  It used to sit in the `prop` pool as a 5-voxel
+      -- standee; see GYM's sapling entry for the reading and for what
+      -- sapling_squash does.  Scanned: the 2x2 grid
       -- occurs 32 times on this atlas across the towns and routes, and
       -- per-tile counts equal the grid count (32 hits for $2D alone),
       -- so no stray occurrence renders as a lone hull.  The same four
@@ -214,8 +219,9 @@ return {
       -- collisions, none of this entry's business.  Cut itself only
       -- swaps the map block to plain grass, so the pin never sees a
       -- cut stump.
-      cylinder = { 42, 43, 58, 59, 64, 65, 80, 81,
-                   45, 46, 61, 62 },
+      cylinder = { 42, 43, 58, 59, 64, 65, 80, 81 },
+      sapling = { 45, 46, 61, 62 },
+      sapling_squash = 50,
       -- the town sign (blockset 8's SE cell): a standing per-pixel slab
       -- 2 voxels thin, transparency respected -- never a solid box
       signpost = { 70, 71, 86, 87 },
@@ -357,25 +363,40 @@ return {
       -- -- PEWTER_GYM's walls and rock maze, and BRUNOS_ROOM's
       -- clusters.  DOJO shares gym.png but places none of them, so
       -- the pin is not copied there.
+      cylinder = { 44, 45, 46, 47,
+                   7, 8, 23, 24 },
       -- Celadon's three little trees ($40/$41 canopy over $50/$51
-      -- trunk) round out the same family: a scraggly canopy over a
-      -- 2-4px trunk flaring into a round root mound.  Every drawn row
-      -- states its own width, which is exactly what the hull revolves
-      -- -- the canopy turns into a ball with its drawn gaps kept, the
-      -- trunk into a thin round column, the mound into a round foot --
-      -- so the tree stands beside the hedge balls as a solid object
-      -- of the same construction.  It used to sit in the `prop` pool
-      -- ("a trunk is not round"), but the standee rendered as the
-      -- whole 16x16 cell extruded, background and all -- the extruded
-      -- picture -- and the trunk IS round; the hull reads it right.
+      -- trunk): a scraggly canopy over a 2-4px trunk flaring into a
+      -- round root mound.  Every drawn row states its own width, which
+      -- is exactly what the hull revolves -- the canopy turns into a
+      -- ball with its drawn gaps kept, the trunk into a thin round
+      -- column, the mound into a round foot.  They used to sit in the
+      -- `prop` pool ("a trunk is not round"), but the standee rendered
+      -- as the whole 16x16 cell extruded, background and all -- the
+      -- extruded picture -- and the trunk IS round; the hull reads it
+      -- right.  The same drawing is the overworld's cuttable tree (see
+      -- OVERWORLD's sapling entry); one drawing, one model.
+      --
+      -- sapling_squash 50 is the one AUTHORED number and the only knob
+      -- taste moves: the percent of its revolved depth every chord
+      -- keeps.  A full revolve (100) assumes the drawing's width is
+      -- also its depth, which is honest for the hedge balls and
+      -- boulders in the `cylinder` pool above but not for a tree --
+      -- the trunk is a stick, the crown is more air than wood, and at
+      -- full width the tree filled a whole cell of depth and read as a
+      -- boulder wearing bark.  50 halves it to an ellipse in plan;
+      -- the model stays round in section and centred on the cell.
+      -- The height is NOT authored: the class's 16 (see the `heights`
+      -- table at the top of this file) is the drawn elevation, which
+      -- is also the model's top plane, so anything riding a tree cell
+      -- lands right.
       -- Scanned: the 2x2 grid occurs 3 times on this atlas and only
       -- in CELADON_GYM -- cells (2,4), (7,5), (5,7) -- and per-tile
       -- counts equal the grid count (3 hits for $40 alone), so no
       -- stray occurrence renders as a lone hull.  DOJO shares gym.png
       -- and places none, so the pin is not copied there.
-      cylinder = { 44, 45, 46, 47,
-                   7, 8, 23, 24,
-                   64, 65, 80, 81 },
+      sapling = { 64, 65, 80, 81 },
+      sapling_squash = 50,
       -- Vermilion Gym's trash cans ($0B/$0C over $1B/$1C), the switch
       -- puzzle's fifteen cans plus the sixteenth beside the leader's
       -- platform.  An open galvanised bin in the 3/4 view, and its plan is
@@ -437,7 +458,7 @@ return {
       can_taper = 4,
       heights = { can = 9 },
       -- The statues.  (Celadon's trees $40/$41/$50/$51 lived here
-      -- too until they moved to the cylinder hull above.)
+      -- too until they moved to the sapling hull above.)
       prop = { 2, 56, 18, 19 },
       -- The Hall of Fame's recording machine, the one piece of real
       -- furniture in the tileset.  It is drawn 32px wide and THREE tile
@@ -1875,6 +1896,18 @@ return {
       -- 18/19) sitting in a WALKABLE cell, so flat floor until pinned.
       -- The 8px standee pool, seat height, as in Red's rooms.
       stool = { 2, 3, 18, 19 },
+      -- ...and the height a figure riding that cell stands at, which is
+      -- the SEAT and not the backrest: the chair's seat is drawn rows
+      -- 26-31, six of them, so 6.  All three Game Freak developers are
+      -- placed ON their chair cell (CELADON_MANSION_3F objects at (0,4),
+      -- (3,4) and (0,7)), and VoxelScene.groundAt reads this pin, not
+      -- the `mansion_computer_desk` model that now draws the chair -- at
+      -- the class default 8 they floated two voxels over the seat.  The
+      -- same reading INTERIOR's `stool = 5` carries for Bill's chair,
+      -- which is this drawing with one white margin column instead of
+      -- two; these four ids are the mansion desks' chairs and nothing
+      -- else on this atlas, so the override reaches only them.
+      heights = { stool = 6 },
       -- the potted palms: two cells of drawing (68/69 crown, 8/9 fronds,
       -- 70/71 stem, 24/25 pot), mostly silhouette, so the THIN standee
       -- pool -- the same numbers the generic HOUSE entry uses, and the
@@ -4237,6 +4270,89 @@ return {
         roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 },
         slab = 3, frontEave = 0, ledge = nil,
       },
+      -- F07c: the Game Freak office's COMPUTER DESK -- the writing desks
+      -- of CELADON_MANSION_2F cell (0,5) and CELADON_MANSION_3F cells
+      -- (0,3), (3,3) and (0,6).  scan.lua on the 4x4 grid returns those
+      -- four and nothing else, and each of the four ids that carry the
+      -- apron and chair rows (2/3/85/86, 18/19) occurs on this atlas ONLY
+      -- inside them -- so no stray cell can pick this model up.  (The
+      -- same ids are furniture on the HOUSE and FACILITY atlases; those
+      -- are different images and none of this entry's business.)
+      --
+      -- Rows 0-15 are BILL'S DESK, byte for byte.  A whole-crop diff of
+      -- these tiles against the interior atlas's 11/12/13/14 over
+      -- 27/28/29/30 comes back empty for all 512 pixels: the same
+      -- tabletop seen from above -- black rim, white highlight course,
+      -- grey field -- with the same terminal, cord and 2:1 isometric
+      -- computer drawn into it.  One drawing is one model, so the four
+      -- parts below are `bills_desk`'s verbatim; that entry carries the
+      -- readings (paper is flat, the keyboard's keys ride its top face,
+      -- `plan` = rx makes the computer a cube turned 45 and not a slab).
+      --
+      -- Only the FRONT is redrawn, and it is 6 rows where Bill's is 7:
+      --   15      the top's own black front edge.  This is the row the
+      --           lid replaces, so it opens the fascia exactly the way
+      --           Bill's row 16 does, and the desk stands 7 voxels to
+      --           his 8 -- measured, not chosen.
+      --   16-17   the #555 edge lip and the black seam under it: the
+      --           desktop's own rim, which is why they are `fascia` --
+      --           that band wraps every side, and a desktop's edge is
+      --           visible from all four.
+      --   18-21   the base: the left leg (the black/#555/black at
+      --           x0-x2), the open apron between, and the DRAWER
+      --           PEDESTAL at x20-x31 -- two #555 drawer fronts (rows
+      --           16-17 and 19-20) inside a black frame.  Each is a
+      --           non-black region sealed behind its own black outline
+      --           and under 24px, so the measured recess pass sinks it
+      --           one voxel and the frame stays proud: the drawer gaps
+      --           come off the pixels, nothing is authored.
+      -- Row 21 is the ground line and 22 is the measured one.  Rows
+      -- 22-23 are the desk's cast SHADOW, dithered into the checker
+      -- floor of the walkable cell in front, so the model builds none of
+      -- them -- they are floor, not furniture.
+      --
+      -- The chair is Bill's chair REDRAWN rather than the same pixels
+      -- (two white margin columns round the backrest panel where his
+      -- has one, and it sits at x4-x15 rather than x2-x13), but the same
+      -- object band for band -- rows 20-21 the backrest top seen from
+      -- above, 22-31 its elevation -- so it takes his part table with x
+      -- and `rise` moved: `rise` is the whole plane back down, because
+      -- the chair stands on the FLOOR and not on the desk.
+      --
+      -- Why the grid runs two tile rows past the desk: the artist drew
+      -- the apron into the WALKABLE cell in front (2/3 + 85/86), and
+      -- 2/3 also carry the chair's back.  A template claims whole TILES,
+      -- so reading the apron takes the chair with it -- which is what
+      -- makes the template owe it, exactly as at Bill's.  `desk.depth`
+      -- stops the desk box at its own two cells; the chair keeps the
+      -- front one.  The `table` pin on the top tiles and the `stool` pin
+      -- on 2/3/18/19 both stay as the degradation path, neutralized
+      -- wherever this stamps.
+      {
+        id = "mansion_computer_desk",
+        tiles = {
+          { 36, 37, 52, 53 },
+          { 64, 65, 66, 67 },
+          {  2,  3, 85, 86 },
+          { 18, 19, 17, 17 },
+        },
+        roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+        slab = 0, frontEave = 0, ledge = nil, depth = 4,
+        -- the desk's own plot is its two cells; the grid runs on because
+        -- its apron and the CHAIR share tiles 2/3
+        desk = { fascia = { 15, 17 }, base = { 18, 21 }, depth = 2 },
+        parts = {
+          { kind = "flat", x = { 2, 15 }, rows = { 3, 7 } },   -- the notes
+          { kind = "upright", x = { 4, 15 }, top = { 8, 12 },
+            facade = { 12, 13 }, z = 8, depth = 6 },           -- keyboard
+          { kind = "upright", x = { 16, 19 }, top = { 8, 8 },
+            facade = { 8, 9 }, z = 8, depth = 2 },             -- the cord
+          { kind = "iso", x = { 19, 30 }, rows = { 1, 13 },
+            plan = 6, z = 9 },                                 -- the computer
+          { kind = "upright", x = { 4, 15 }, top = { 20, 21 },
+            facade = { 22, 31 }, rise = -7, z = 22, depth = 10 }, -- chair
+        },
+      },
     },
 
     HOUSE = {
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 2c99c8d..725dbac 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -601,9 +601,22 @@ local PLANTER_SPRAY = { rows = 24, depth = 5 }
 -- states no profile, the honest reading is the one the thin standee pools
 -- exist for: the foliage stands as a per-pixel slab and keeps the airy
 -- silhouette that makes it read as leaves.
+-- `squash`, when given, is the PERCENT of its revolved depth every chord
+-- keeps -- 100 (or nil) is the identity, 50 halves the hull front to back.
+--
+-- A full revolve assumes the drawing's width is also its depth, which is
+-- true of a thing that really is round in plan (a hedge ball, a boulder,
+-- a trash can). A TREE is round in its canopy and thin at every other
+-- reading: the trunk is a stick, the crown is more air than wood, and the
+-- drawing is scenery seen from one side. Revolved at full width the little
+-- tree eats a whole cell of depth and reads as a boulder wearing bark, so
+-- the plan stays a circle and shrinks toward an ellipse: still round in
+-- section, still stepping pixel by pixel, just shallower. The chord is
+-- re-centred on the mid-plane, so the model neither slides nor detaches
+-- from the cells around it.
 local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
                              NYin, spray, baseRows, bodyRows, wellRows,
-                             taperVox)
+                             taperVox, squash)
   -- The canvas is NX wide and NX DEEP (a hull is round in plan, so its
   -- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell
   -- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing
@@ -864,6 +877,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
                                        + 0.5))
           end
           if spray and iy < spray.rows then n = math.min(n, spray.depth) end
+          if squash then n = math.max(1, math.floor(n * squash / 100 + 0.5)) end
           z0[i] = math.floor(N2 - n / 2 + 0.5)
           z1[i] = z0[i] + n
           -- a row the can's body band was repeated into wears the row it
@@ -975,6 +989,9 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
                 n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
                                            + 0.5))
               end
+              if squash then
+                n = math.max(1, math.floor(n * squash / 100 + 0.5))
+              end
               z0[i] = math.floor(N2 - n / 2 + 0.5)
               z1[i] = z0[i] + n
             end
@@ -1298,6 +1315,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
   -- voxels the body band is repeated up to
   local stumpCap, canCap, canBase, canHeight, canWell, canTaper
     = 6, 9, 4, 9, 5, 4
+  -- the sapling class's depth, as a PERCENT of the revolved chord
+  local saplingSquash = 50
   do
     local okP, prof = pcall(V.data, "voxel_heights")
     local entry = okP and type(prof) == "table" and prof.tilesets
@@ -1320,6 +1339,9 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
     if entry and type(entry.can_taper) == "number" then
       canTaper = entry.can_taper
     end
+    if entry and type(entry.sapling_squash) == "number" then
+      saplingSquash = entry.sapling_squash
+    end
   end
 
   -- cells consumed by a 2x2 `canopy` group; the scan runs north to
@@ -1439,13 +1461,19 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
         local tall = s.class == "can" and canHeight or nil
         local well = s.class == "can" and canWell or nil
         local taper = s.class == "can" and canTaper or nil
+        -- 100% is the full revolve, so it is the identity: never signed
+        -- into the cache key, and never passed, by a class that has no
+        -- squash of its own
+        local squash = (s.class == "sapling" and saplingSquash ~= 100)
+                       and saplingSquash or nil
         local ground = false
         if data then
           local sig = tsid .. (cap and ("|c" .. cap) or "")
             .. (base and ("|b" .. base) or "")
             .. (tall and ("|h" .. tall) or "")
             .. (well and ("|w" .. well) or "")
-            .. (taper and ("|t" .. taper) or "") .. "|"
+            .. (taper and ("|t" .. taper) or "")
+            .. (squash and ("|q" .. squash) or "") .. "|"
             .. gsig .. "|" .. table.concat({
             S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)],
             S.tileAt[keyOf(cx * 2, cy * 2 + 1)],
@@ -1454,7 +1482,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
           if not tpl then
             local tq, tbg = roundTemplate(S, map, data, cx, cy,
                                           groundTiles, 16, cap, nil, nil,
-                                          base, tall, well, taper)
+                                          base, tall, well, taper, squash)
             tpl = { quads = tq, bg = tbg }
             roundCache[sig] = tpl
           end
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index 0ea0ada..f9e041e 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -80,6 +80,14 @@ local FALLBACK_HEIGHTS = {
   -- the drawing's own straight run is only a couple of rows, because a GB
   -- cell spends most of itself on the opening
   can = 9,
+  -- the same hull SQUASHED front to back (the profile's sapling_squash,
+  -- a percent of the revolved depth): the little trees drawn one cell
+  -- wide -- Celadon Gym's garden trees and the overworld's cuttable
+  -- tree, which are the same drawing on two atlases. A tree is round in
+  -- its canopy but is not a boulder: revolved at full width it fills a
+  -- whole cell of depth, so the plan keeps its circle and shrinks toward
+  -- an ellipse
+  sapling = 16,
   -- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
   -- cell of plot: the Pokemon Centers' potted plants. Carved as one
   -- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
@@ -147,6 +155,7 @@ local ART = {
   canopy = "canopy",
   stump = "cylinder",
   can = "cylinder",
+  sapling = "cylinder",
   planter = "planter",
   billboard = "billboard",
   -- signposts share the billboard treatment but as their own pool at a
diff --git a/tests/cuttree_shots.lua b/tests/cuttree_shots.lua
new file mode 100644
index 0000000..0d6f1a0
--- /dev/null
+++ b/tests/cuttree_shots.lua
@@ -0,0 +1,101 @@
+-- Scratch driver: the overworld cuttable tree ($2D/$2E/$3D/$3E, one
+-- cell) at PEWTER_CITY (26,4) -- it sits in a gap of the border tree
+-- wall, so shoot from the open grass east/west and the path south.
+-- Same spots BEFORE and AFTER the pin change.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cuttree_shots.lua \
+--   SHOT_DIR=.scratchpad/cuttree AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or "shots/cuttree")
+    .. "/" .. (os.getenv("AB_TAG") or "after")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[cuttree] DRAMATIC_SHAPE is not loaded")
+    love.event.quit()
+    return
+  end
+  local V = handle.lib
+  do -- prove the RUNNING mod sees the pin we think it does
+    local TS = V.require("TileShape")
+    local shapes = TS.forMap({ tileset = { id = "OVERWORLD",
+                                           imageWidth = 128,
+                                           imageHeight = 48 } })
+    for _, t in ipairs({ 45, 61 }) do
+      local s = shapes[t]
+      print("[cuttree] running-mod OVERWORLD tile " .. t .. ": "
+        .. (s and (tostring(s.class) .. "/" .. tostring(s.art)
+                   .. " h=" .. tostring(s.h)) or "nil"))
+    end
+  end
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- open grass west of the tree, edge-on
+    { map = "PEWTER_CITY", x = 25, y = 4, face = "right", label = "cut_west" },
+    -- open grass east of it
+    { map = "PEWTER_CITY", x = 27, y = 4, face = "left", label = "cut_east" },
+    -- the path south, seeing its front over the tree wall gap
+    { map = "PEWTER_CITY", x = 26, y = 6, face = "up", label = "cut_front" },
+    -- one step closer on the gap's south side
+    { map = "PEWTER_CITY", x = 26, y = 5, face = "up", label = "cut_near" },
+    -- Cerulean's lone cut tree at (19,28): open grass to its south
+    { map = "CERULEAN_CITY", x = 19, y = 30, face = "up", label = "cer_front" },
+    { map = "CERULEAN_CITY", x = 19, y = 29, face = "up", label = "cer_near" },
+    { map = "CERULEAN_CITY", x = 20, y = 29, face = "up", label = "cer_diag" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[cuttree] capture missed: " .. path) end
+      end
+    else
+      print("[cuttree] teleport failed: " .. s.map)
+    end
+  end
+  print(("[cuttree] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end
diff --git a/tests/diner_shots.lua b/tests/diner_shots.lua
new file mode 100644
index 0000000..0d8f3a1
--- /dev/null
+++ b/tests/diner_shots.lua
@@ -0,0 +1,95 @@
+-- Scratch driver: the Celadon Diner's stools ($07/$08/$23/$24 on LOBBY,
+-- one cell each; the pinned `stool` standee pool). Shot at the voxel
+-- rung (5) and the flat rung (3) for orientation, front/back/side of
+-- the stool at cell (0,4).
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diner_shots.lua \
+--   SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
+--   "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR")
+    or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[diner] DRAMATIC_SHAPE is not loaded")
+    return
+  end
+  local V = handle.lib
+  do -- prove the RUNNING mod resolves the stool tiles as pinned
+    local TS = V.require("TileShape")
+    local shapes = TS.forMap({ tileset = { id = "LOBBY",
+                                           imageWidth = 128,
+                                           imageHeight = 48 } })
+    for _, t in ipairs({ 7, 8, 23, 24 }) do
+      local s = shapes[t]
+      print(("[diner] running-mod tile %d: %s"):format(t,
+        s and (tostring(s.class) .. "/" .. tostring(s.art)
+               .. " h=" .. tostring(s.height)) or "nil"))
+    end
+  end
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- the stool at (0,4), seen from the south = its front (legs row)
+    { map = "CELADON_DINER", x = 0, y = 6, face = "up", label = "stool_front" },
+    -- the same stool from the north = its back
+    { map = "CELADON_DINER", x = 0, y = 2, face = "down", label = "stool_back" },
+    -- edge-on from the east, with the table beside it in frame
+    { map = "CELADON_DINER", x = 2, y = 4, face = "left", label = "stool_side" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[diner] capture missed: " .. path) end
+      end
+    else
+      print("[diner] teleport failed: " .. s.map)
+    end
+  end
+  print(("[diner] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end
diff --git a/tests/mansion_desk_shots.lua b/tests/mansion_desk_shots.lua
new file mode 100644
index 0000000..181b9e4
--- /dev/null
+++ b/tests/mansion_desk_shots.lua
@@ -0,0 +1,102 @@
+-- Scratch driver: the Game Freak office computer desks (buildings
+-- template mansion_computer_desk).  CELADON_MANSION_2F cell (0,5) and
+-- CELADON_MANSION_3F cells (0,3)/(3,3)/(0,6).  Shot at the voxel rung
+-- (5), the mid rung (4) and the flat rung (3) for orientation.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_desk_shots.lua \
+--   SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
+--   AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR")
+    or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
+  local TAG = os.getenv("AB_TAG") or "shot"
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[deskshot] DRAMATIC_SHAPE is not loaded")
+    return
+  end
+  local V = handle.lib
+  do -- prove the RUNNING mod's profile carries the new template
+    local ok, s = pcall(V.data, "voxel_heights")
+    local found = "NO"
+    if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then
+      for _, t in ipairs(s.buildings.MANSION) do
+        if t.id == "mansion_computer_desk" then
+          found = ("YES fascia=%d-%d base=%d-%d parts=%d")
+            :format(t.desk.fascia[1], t.desk.fascia[2],
+                    t.desk.base[1], t.desk.base[2], #t.parts)
+        end
+      end
+    end
+    print("[deskshot] running-mod mansion_computer_desk: " .. found)
+  end
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- 2F: the lone desk at (0,5):(1,6).  Stand just south of it, facing
+    -- north, so the camera looks along the desk's front face.
+    { map = "CELADON_MANSION_2F", x = 1, y = 7, face = "up", label = "desk2f" },
+    -- and from the east, to see the drawer pedestal and the chair in
+    -- profile against the checker floor
+    { map = "CELADON_MANSION_2F", x = 2, y = 6, face = "left", label = "desk2f_side" },
+    -- 3F: the pair at (0,3) and (3,3), both in frame from between them
+    { map = "CELADON_MANSION_3F", x = 2, y = 5, face = "up", label = "desk3f_pair" },
+    -- 3F: the south desk at (0,6), close in
+    { map = "CELADON_MANSION_3F", x = 1, y = 8, face = "up", label = "desk3f" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 4, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[deskshot] capture missed: " .. path) end
+      end
+    else
+      print("[deskshot] teleport failed: " .. s.map)
+    end
+  end
+  print(("[deskshot] %d shots into %s (tag %s)"):format(shots, ROOT, TAG))
+  love.event.quit()
+end
diff --git a/tests/mansion_shots.lua b/tests/mansion_shots.lua
new file mode 100644
index 0000000..17637d0
--- /dev/null
+++ b/tests/mansion_shots.lua
@@ -0,0 +1,91 @@
+-- Scratch driver: the CELADON_MANSION_1F square table (buildings
+-- template mansion_square_table, cells (0,6):(1,7)). Shot at the voxel
+-- rung (5) and the flat rung (3) for orientation, front/back/side.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_shots.lua \
+--   SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
+--   "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR")
+    or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[mansion] DRAMATIC_SHAPE is not loaded")
+    return
+  end
+  local V = handle.lib
+  do -- prove the RUNNING mod's profile carries the new template
+    local ok, s = pcall(V.data, "voxel_heights")
+    local found = false
+    if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then
+      for _, t in ipairs(s.buildings.MANSION) do
+        if t.id == "mansion_square_table" then found = true end
+      end
+    end
+    print("[mansion] running-mod mansion_square_table: " .. tostring(found))
+  end
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- the rung-5 camera looks north from ~4 cells south of the player, so
+    -- the room's 16-voxel south wall hides the 6-voxel table at the low
+    -- rung; rungs 4 and 3 pitch over it.  Stand just south of the table.
+    { map = "CELADON_MANSION_1F", x = 1, y = 8, face = "up", label = "sqtable" },
+    { map = "CELADON_MANSION_1F", x = 2, y = 6, face = "left", label = "sqtable_beside" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 4, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[mansion] capture missed: " .. path) end
+      end
+    else
+      print("[mansion] teleport failed: " .. s.map)
+    end
+  end
+  print(("[mansion] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end
diff --git a/tests/safari_shots.lua b/tests/safari_shots.lua
new file mode 100644
index 0000000..97a760c
--- /dev/null
+++ b/tests/safari_shots.lua
@@ -0,0 +1,96 @@
+-- Scratch driver: the Safari Zone's decorations — the "cut"-style round
+-- trees ($54/$55/$56/$57, one cell) and the stump/bush rows ($02/$03/
+-- $12/$13) — front and back, at the voxel rung (5) and the flat rung (3)
+-- for orientation. Same spots BEFORE and AFTER the pin change.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/safari_shots.lua \
+--   SHOT_DIR=.scratchpad/safari AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or "shots/safari")
+    .. "/" .. (os.getenv("AB_TAG") or "after")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[safari] DRAMATIC_SHAPE is not loaded")
+    return
+  end
+  local V = handle.lib
+  do -- what does the RUNNING mod think tile 84 on FOREST is?
+    local TS = V.require("TileShape")
+    local shapes = TS.forMap({ tileset = { id = "FOREST",
+                                           imageWidth = 128,
+                                           imageHeight = 48 } })
+    local s = shapes[84]
+    print("[safari] running-mod tile 84: "
+      .. (s and (tostring(s.class) .. "/" .. tostring(s.art)
+                 .. " authored=" .. tostring(s.authored)) or "nil"))
+  end
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- tree row at (2..5, 3), seen from the south = its front
+    { map = "SAFARI_ZONE_EAST", x = 4, y = 5, face = "up", label = "trees_front" },
+    -- the same row from the north = its back
+    { map = "SAFARI_ZONE_EAST", x = 4, y = 1, face = "down", label = "trees_back" },
+    -- the long tree line at (2..9, 6)
+    { map = "SAFARI_ZONE_EAST", x = 6, y = 8, face = "up", label = "treeline" },
+    -- the stump/bush row along the top edge (8..19, 0)
+    { map = "SAFARI_ZONE_EAST", x = 12, y = 2, face = "up", label = "stumps_front" },
+    -- Safari Center's west tree column, edge-on
+    { map = "SAFARI_ZONE_CENTER", x = 2, y = 4, face = "left", label = "center_trees" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[safari] capture missed: " .. path) end
+      end
+    else
+      print("[safari] teleport failed: " .. s.map)
+    end
+  end
+  print(("[safari] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end
diff --git a/tests/table_shots.lua b/tests/table_shots.lua
new file mode 100644
index 0000000..f72a574
--- /dev/null
+++ b/tests/table_shots.lua
@@ -0,0 +1,96 @@
+-- Scratch driver: the round tables of the Celadon diner and the Mart
+-- roof terrace (the diner_round_table template's four placements).
+-- Shot at the voxel rung (5) and the flat rung (3), front/back/side of
+-- the diner table at cells (0,5):(1,6) plus the terrace pair.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/table_shots.lua \
+--   SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \
+--   AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR")
+    or "mods/DramaticShapeVoxelMod/.claude/voxelizations")
+  local TAG = os.getenv("AB_TAG") or "shot"
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[table] DRAMATIC_SHAPE is not loaded")
+    return
+  end
+  local V = handle.lib
+  local Buildings = V.require("Buildings")
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- the diner table at (0,5):(1,6), seen from the south = its front
+    { map = "CELADON_DINER", x = 1, y = 7, face = "up", label = "diner_front" },
+    -- the same table from the north = its back (standing between the two)
+    { map = "CELADON_DINER", x = 0, y = 4, face = "down", label = "diner_back" },
+    -- edge-on from the east
+    { map = "CELADON_DINER", x = 3, y = 5, face = "left", label = "diner_side" },
+    -- the roof terrace's table at (4,2):(5,3), from the south
+    { map = "CELADON_MART_ROOF", x = 4, y = 4, face = "up", label = "roof_front" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        -- prove which models the RUNNING mod built for this map
+        local st = Buildings.stats()
+        local keys = {}
+        for k, v in pairs(st) do
+          keys[#keys + 1] = ("%s(v=%d)"):format(k, v.voxels)
+        end
+        table.sort(keys)
+        print(("[table] %s r%d models: %s"):format(s.label, rung,
+          #keys > 0 and table.concat(keys, " ") or "none"))
+        local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[table] capture missed: " .. path) end
+      end
+    else
+      print("[table] teleport failed: " .. s.map)
+    end
+  end
+  print(("[table] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end
diff --git a/tests/tile_pick.lua b/tests/tile_pick.lua
new file mode 100644
index 0000000..2bd95c8
--- /dev/null
+++ b/tests/tile_pick.lua
@@ -0,0 +1,816 @@
+-- Driver: the tile picker -- point at a tile, get the voxelize prompt.
+--
+-- The voxelize-tiles skill is driven per TILE, and its first argument is
+-- always the same thing: which map, and which cell on it. Finding that pair
+-- by hand means walking the game to the object, counting cells off a corner
+-- and hoping the count is right. This is the same window the arena editor is
+-- -- the map drawn from its OWN art, the tiles the cartridge holds, through
+-- the game's own palette -- with a cursor on it instead of an arena, and one
+-- button: COPY PROMPT.
+--
+-- What lands on the clipboard is exactly:
+--
+--   run voxelize-tile on the tile found in PALLET_TOWN- (5,4). Return
+--   screenshots of the voxelization in .claude/voxelizations/
+--
+-- on one line, ready to paste into Claude. Every copy is also appended to
+-- SHOT_DIR/prompts.txt and printed to the console, because a session of this
+-- is a LIST of jobs -- you walk a map picking out every object worth shape
+-- and hand the list over at the end, rather than pasting one and waiting.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tile_pick.lua \
+--     "/c/Program Files/LOVE/lovec.exe" .
+--
+-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so
+-- the console is attached and the transcript alone is the list.
+--
+-- ------- the keys
+--
+--   arrows      move the cursor (hold shift for 5 cells)
+--   click       put the cursor on that tile
+--   [  ]        previous / next map (shift: 10 at a time)
+--   c / enter   COPY PROMPT for the tile under the cursor
+--   z           zoom the inset in / out (shift: out)
+--   g           what the plan shows: the map's own tiles, the walk grid over
+--               them, or the walk grid alone
+--   m           HIDE the plan, so the window is the game -- the panel keeps
+--               the coordinates, the inset and the button, so a tile is
+--               still copied from here while looking at it standing up in 3D
+--   t           stand the player on this tile, so the 3D view behind the
+--               panel is looking at the thing you are about to voxelize
+--   y           screenshot (shift: with this panel in it)
+--   l           re-print every prompt copied so far
+--   F1          the key legend on screen
+--   escape      quit
+--
+-- ------- environment
+--
+--   TILE_MAPS=ID,ID    work this list instead of every map in the game
+--   TILE_START=ID      open on this map
+--   TILE_OUT=path      where prompts.txt is written
+--   TILE_RUNG=N        the voxel angle rung to sit on (default 3)
+--   SHOT_DIR=path      screenshots, and the default prompt-list directory
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+
+  local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end
+
+  local DIR = os.getenv("SHOT_DIR")
+  if not DIR or DIR == "" then DIR = ".scratchpad/tile_pick" end
+  local OUT = os.getenv("TILE_OUT")
+  if not OUT or OUT == "" then OUT = DIR .. "/prompts.txt" end
+  local RUNG = tonumber(os.getenv("TILE_RUNG") or "") or 3
+
+  -- U.shot's own mkdir is Unix-flavoured and does nothing on Windows, which
+  -- is where this tool is driven from -- so the shell is asked in its own
+  -- language, or the screenshots and the prompt list never reach disk.
+  local WINDOWS = love.system and love.system.getOS() == "Windows"
+  local function mkdirp(dir)
+    if not dir or dir == "" then return end
+    if WINDOWS then
+      local d = dir:gsub("/", "\\")
+      os.execute('if not exist "' .. d .. '" mkdir "' .. d .. '"')
+    else
+      os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
+    end
+  end
+  mkdirp(DIR)
+  mkdirp(OUT:match("^(.*)[/\\][^/\\]+$"))
+
+  -- ------- the mod
+  --
+  -- Only for the camera rung: the picking itself is engine-only, but the
+  -- view behind the panel is what `t` is for, and a flat 2D world behind a
+  -- 3D-tile picker would be looking at the wrong thing.
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  local Pipelines = require("src.render.Pipelines")
+  local PaletteFX = require("src.render.PaletteFX")
+  local Pokemon = require("src.pokemon.Pokemon")
+
+  local wasRung
+  if lib then
+    local DayNight = lib.require("DayNight")
+    DayNight.setting:setValue("day")     -- one light to look at them all in
+    wasRung = Pipelines.level("voxel")
+    Pipelines.setLevel("voxel", RUNG)
+  else
+    U.log("DRAMATIC_SHAPE is not loaded -- the picker still works, but the "
+          .. "view behind it will be the flat 2D map")
+  end
+
+  game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
+  game.save.player.name = "RED"
+
+  -- ------- which maps
+  --
+  -- EVERY map, unlike the arena tools: a tile worth voxelizing is as likely
+  -- to be a shop counter or a bedroom desk as anything on a route, and those
+  -- are exactly the maps a "where can a fight happen" filter throws away.
+  local ids = {}
+  local explicit = os.getenv("TILE_MAPS")
+  if explicit and explicit ~= "" then
+    for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
+  else
+    for id, def in pairs(game.data.maps) do
+      if type(id) == "string" and type(def) == "table" and def.width then
+        ids[#ids + 1] = id
+      end
+    end
+    table.sort(ids)
+  end
+  if #ids == 0 then
+    U.log("no maps to work on")
+    return
+  end
+
+  local S = {
+    i = 1,
+    map = nil,
+    cx = 0, cy = 0,        -- the cursor, in cells
+    plan = nil,
+    tiles = nil,           -- the map baked from its own tileset
+    planMode = 1,
+    showPlan = true,       -- is the plan drawn at all
+    zoom = 5,              -- how many cells across the inset shows
+    msg = "",
+    legend = true,
+    copied = {},           -- every prompt this session, in order
+    shooting = false,
+    quit = false,
+    planRect = nil,        -- where the plan landed, for the mouse
+    buttonRect = nil,      -- and the button
+  }
+
+  local queue = {}
+  local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end
+  local function say(fmt, ...)
+    S.msg = select("#", ...) > 0 and fmt:format(...) or fmt
+  end
+  local function mapId() return ids[S.i] end
+
+  -- ------- the prompt
+  --
+  -- One line, and the ONLY thing this tool exists to produce. Kept in one
+  -- function so the text that reaches the clipboard, the console and the
+  -- file is provably the same text.
+  local function promptFor(id, cx, cy)
+    return ("run voxelize-tile on the tile found in %s- (%d,%d). "
+            .. "Return screenshots of the voxelization in "
+            .. ".claude/voxelizations/"):format(id, cx, cy)
+  end
+
+  local function copyPrompt()
+    local text = promptFor(mapId(), S.cx, S.cy)
+    local ok = false
+    if love.system and love.system.setClipboardText then
+      ok = pcall(love.system.setClipboardText, text)
+    end
+    S.copied[#S.copied + 1] = text
+    -- The clipboard holds ONE of these and the session produces many, so the
+    -- file is not a convenience -- it is where the list actually lives. Open
+    -- per copy and closed again: a tool driving a game can be killed at any
+    -- moment, and a buffered list is a lost afternoon.
+    local f = io.open(OUT, "a")
+    if f then
+      f:write(text, "\n")
+      f:close()
+    end
+    U.log("PROMPT " .. text)
+    say("%s (%d,%d) copied%s -- %d so far", mapId(), S.cx, S.cy,
+        ok and "" or " to the list only (no clipboard here)", #S.copied)
+  end
+
+  -- ------- what the map looks like from above
+  --
+  -- Lifted from the arena editor, and for its reason: the plan is the
+  -- surface a tile is chosen on, and a grid of coloured squares is a map of
+  -- the walk grid rather than of the place. You are picking out a bookshelf.
+  -- Baked once per map into a canvas, during UPDATE rather than inside a
+  -- draw, because it binds a canvas of its own.
+  local PLAN_MAX = 2048
+
+  local function paletteFor(m)
+    local ow = game.overworld
+    if ow and ow.paletteNameFor then
+      local okName, name = pcall(ow.paletteNameFor, ow, m)
+      if okName and name then
+        local okPal, colours = pcall(PaletteFX.pal, game.data, name)
+        if okPal and colours then return colours end
+      end
+    end
+    local okOg, og = pcall(PaletteFX.ogBg)
+    return okOg and og or nil
+  end
+
+  local function bakeTiles()
+    if S.tiles then pcall(S.tiles.release, S.tiles) end
+    S.tiles = nil
+    local m = S.map
+    if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then
+      return
+    end
+    local w, h = m.widthCells * 16, m.heightCells * 16
+    local scale = math.min(1, PLAN_MAX / math.max(w, h))
+    local okNew, canvas = pcall(love.graphics.newCanvas,
+                                math.max(1, math.floor(w * scale)),
+                                math.max(1, math.floor(h * scale)))
+    if not (okNew and canvas) then return end
+    -- Nearest, unlike the arena editor's plan: that one is a whole route
+    -- minified to a panel, where nearest keeps a moire. This one gets
+    -- magnified in the inset, where a tile has to be READABLE -- linear
+    -- there is a smear of a bookshelf, and the drawing is the whole point.
+    pcall(canvas.setFilter, canvas, "nearest", "nearest")
+
+    local g = love.graphics
+    local prev = g.getCanvas()
+    local wasTrue = m.renderer.trueColor
+    m.renderer.trueColor = false
+    local ok, err = pcall(function()
+      g.push("all")
+      g.origin()
+      g.setCanvas(canvas)
+      g.clear(0, 0, 0, 0)
+      g.setBlendMode("alpha")
+      g.setColor(1, 1, 1, 1)
+      g.scale(scale, scale)
+      local shader = PaletteFX.shader()
+      local colours = paletteFor(m)
+      if shader and colours then
+        g.setShader(shader)
+        PaletteFX.sendColors(shader, colours)
+      end
+      m.renderer:drawMapOnly(0, 0, w, h)
+      g.pop()
+    end)
+    m.renderer.trueColor = wasTrue
+    if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end
+    if not ok then
+      pcall(canvas.release, canvas)
+      U.log("could not bake the map's tiles: " .. tostring(err))
+      return
+    end
+    S.tiles = canvas
+  end
+
+  local PLAN_MODES = { "both", "tiles", "grid" }
+
+  local CELL_COLOUR = {
+    open  = { 0.78, 0.74, 0.63 },
+    grass = { 0.33, 0.60, 0.30 },
+    water = { 0.24, 0.44, 0.78 },
+    warp  = { 0.85, 0.68, 0.20 },
+    solid = { 0.17, 0.17, 0.20 },
+  }
+
+  local function buildPlan()
+    local m = S.map
+    if not m then S.plan = nil return end
+    local w, h = m.widthCells, m.heightCells
+    local plan = { w = w, h = h }
+    for cy = 0, h - 1 do
+      for cx = 0, w - 1 do
+        local kind
+        if not m:isWalkableCell(cx, cy) then
+          kind = m:isWaterCell(cx, cy) and "water" or "solid"
+        elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then
+          kind = "warp"
+        elseif m.isGrassCell and m:isGrassCell(cx, cy) then
+          kind = "grass"
+        else
+          kind = "open"
+        end
+        plan[cy * w + cx] = kind
+      end
+    end
+    S.plan = plan
+  end
+
+  -- ------- moving between maps
+
+  local function enterMap(index)
+    S.i = ((index - 1) % #ids) + 1
+    local id = mapId()
+    local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end)
+    if not ok then
+      S.map, S.plan = nil, nil
+      say("could not enter %s (%s)", id, tostring(err))
+      U.log(("SKIP   %s -- could not enter (%s)"):format(id, tostring(err)))
+      return
+    end
+    S.map = game.overworld.map
+    buildPlan()
+    bakeTiles()
+    -- the middle of the map, which is where the furniture tends to be
+    S.cx = math.floor(S.map.widthCells / 2)
+    S.cy = math.floor(S.map.heightCells / 2)
+    say("%s -- %dx%d cells", id, S.map.widthCells, S.map.heightCells)
+    U.log(("MAP    %s (%d/%d) %dx%d cells"):format(id, S.i, #ids,
+          S.map.widthCells, S.map.heightCells))
+  end
+
+  -- ------- the frame
+  --
+  -- The driver harness turns the frame cap off (a scripted run is not
+  -- paced), so each step is held to a 60th of a second by hand.
+  local lastT = love.timer.getTime()
+  local function step(n)
+    for _ = 1, (n or 1) do
+      local rest = (1 / 60) - (love.timer.getTime() - lastT)
+      if rest > 0 then love.timer.sleep(rest) end
+      lastT = love.timer.getTime()
+      U.wait(1)
+    end
+  end
+
+  local function shoot(withHud)
+    -- the plan being away is part of what a panel shot IS, or the two write
+    -- one file twice and whichever came last is the only one kept
+    local tag = ""
+    if withHud then tag = S.showPlan and "_panel" or "_panel_noplan" end
+    local name = ("%s_x%d_y%d%s"):format(mapId():lower(), S.cx, S.cy, tag)
+    local path = ("%s/%s.png"):format(DIR, name)
+    S.shooting = not withHud
+    game.capturePath = path
+    for _ = 1, 120 do
+      if not game.capturePath then break end
+      step(1)
+    end
+    step(2)
+    S.shooting = false
+    local f = io.open(path, "rb")
+    if f then
+      f:close()
+      say("shot %s.png", name)
+      U.log("SHOT   " .. path)
+    else
+      say("screenshot did not reach disk: %s", path)
+    end
+  end
+
+  -- Stand the player on the tile, so the world behind the panel is looking
+  -- at the thing about to be voxelized. The tile itself is usually SOLID --
+  -- a counter, a shelf, a house -- so the walk of shame outwards: the
+  -- nearest walkable cell to it, searched in rings, which is where a player
+  -- would have to stand to see it anyway.
+  local function standHere()
+    local m = S.map
+    if not m then return end
+    for r = 0, 6 do
+      for dy = -r, r do
+        for dx = -r, r do
+          if math.max(math.abs(dx), math.abs(dy)) == r then
+            local x, y = S.cx + dx, S.cy + dy
+            if m:inBounds(x, y) and m:isWalkableCell(x, y) then
+              game.overworld.player.cellX = x
+              game.overworld.player.cellY = y
+              say("standing at %d,%d", x, y)
+              return
+            end
+          end
+        end
+      end
+    end
+    say("nowhere walkable within 6 cells of %d,%d", S.cx, S.cy)
+  end
+
+  -- ------- the keys
+
+  local function shifted()
+    return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")
+  end
+
+  local function moveCursor(dx, dy)
+    local m = S.map
+    if not m then return end
+    local n = shifted() and 5 or 1
+    S.cx = math.max(0, math.min(m.widthCells - 1, S.cx + dx * n))
+    S.cy = math.max(0, math.min(m.heightCells - 1, S.cy + dy * n))
+  end
+
+  local function handle(key)
+    if key == "left" then moveCursor(-1, 0) return true end
+    if key == "right" then moveCursor(1, 0) return true end
+    if key == "up" then moveCursor(0, -1) return true end
+    if key == "down" then moveCursor(0, 1) return true end
+    if key == "[" then post("map", shifted() and -10 or -1) return true end
+    if key == "]" then post("map", shifted() and 10 or 1) return true end
+    if key == "c" or key == "return" or key == "kpenter" then
+      copyPrompt()
+      return true
+    end
+    if key == "z" then
+      S.zoom = math.max(3, math.min(21, S.zoom + (shifted() and 2 or -2)))
+      return true
+    end
+    if key == "g" then
+      S.planMode = (S.planMode % #PLAN_MODES) + 1
+      say("plan: %s", PLAN_MODES[S.planMode])
+      return true
+    end
+    -- The plan covers most of the window, and the thing UNDER it is the game
+    -- -- the tile standing up in 3D, which is what the voxelizing is for.
+    -- With the plan away, the panel still carries the coordinates, the inset
+    -- and the button, so a tile can still be copied while looking at it.
+    if key == "m" then
+      S.showPlan = not S.showPlan
+      say("plan %s", S.showPlan and "shown" or "hidden -- the game is under it")
+      return true
+    end
+    if key == "t" then standHere() return true end
+    if key == "y" then post("shot", shifted() and "hud" or nil) return true end
+    if key == "l" then
+      if #S.copied == 0 then
+        U.log("nothing copied yet")
+      else
+        for i, t in ipairs(S.copied) do U.log(("%3d  %s"):format(i, t)) end
+      end
+      say("%d prompt%s printed to the console", #S.copied,
+          #S.copied == 1 and "" or "s")
+      return true
+    end
+    if key == "f1" then S.legend = not S.legend return true end
+    if key == "escape" then S.quit = true return true end
+    return false
+  end
+
+  local innerKey = love.keypressed
+  function love.keypressed(key, scancode, isrepeat)
+    local ok, claimed = pcall(handle, key)
+    if ok and claimed then return end
+    if not ok then U.log("key error: " .. tostring(claimed)) end
+    if innerKey then return innerKey(key, scancode, isrepeat) end
+  end
+
+  -- The mouse is the reason this tool is faster than counting cells: click
+  -- the bookshelf, read the coordinates back off the panel, copy. Both hit
+  -- tests read rectangles the draw left behind, so what is clickable is
+  -- exactly what was drawn -- no second copy of the layout to drift.
+  local innerMouse = love.mousepressed
+  function love.mousepressed(x, y, button, istouch, presses)
+    if button == 1 then
+      local b = S.buttonRect
+      if b and x >= b[1] and y >= b[2] and x <= b[1] + b[3]
+         and y <= b[2] + b[4] then
+        copyPrompt()
+        return
+      end
+      local r = S.planRect
+      if r and S.map and x >= r.x and y >= r.y and x <= r.x + r.w
+         and y <= r.y + r.h then
+        S.cx = math.max(0, math.min(S.map.widthCells - 1,
+                                    math.floor((x - r.x) / r.cell)))
+        S.cy = math.max(0, math.min(S.map.heightCells - 1,
+                                    math.floor((y - r.y) / r.cell)))
+        return
+      end
+    end
+    if innerMouse then
+      return innerMouse(x, y, button, istouch, presses)
+    end
+  end
+
+  -- ------- the overlay
+
+  local font = love.graphics.newFont(13)
+  local smallFont = love.graphics.newFont(11)
+  local bigFont = love.graphics.newFont(16)
+
+  local function drawPlan(x, y, w, h)
+    local plan = S.plan
+    S.planRect = nil
+    if not plan then return end
+    local g = love.graphics
+    local cell = math.min(w / plan.w, h / plan.h)
+    if cell < 1 then cell = 1 end
+    local pw, ph = plan.w * cell, plan.h * cell
+    local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2
+    S.planRect = { x = ox, y = oy, w = pw, h = ph, cell = cell }
+
+    g.setColor(0, 0, 0, 0.55)
+    g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8)
+
+    local mode = PLAN_MODES[S.planMode or 1]
+    if S.tiles and mode ~= "grid" then
+      g.setColor(1, 1, 1, 1)
+      g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(),
+             ph / S.tiles:getHeight())
+    end
+    if mode ~= "tiles" then
+      local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22
+      for cy = 0, plan.h - 1 do
+        for cx = 0, plan.w - 1 do
+          local kind = plan[cy * plan.w + cx]
+          local c = CELL_COLOUR[kind]
+          if c and not (alpha < 1 and kind == "open") then
+            g.setColor(c[1], c[2], c[3], alpha)
+            g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell)
+          end
+        end
+      end
+    end
+
+    local ow = game.overworld
+    if ow and ow.player then
+      g.setColor(1, 1, 1, 0.8)
+      g.circle("fill", ox + (ow.player.cellX + 0.5) * cell,
+               oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4))
+    end
+
+    -- The cursor, with crosshair arms out to the edges of the plan. On a
+    -- route the cell is three pixels across and a box round it is invisible;
+    -- the arms are what actually says WHICH one is selected.
+    local cx, cy = ox + S.cx * cell, oy + S.cy * cell
+    g.setColor(1, 0.85, 0.2, 0.35)
+    g.setLineWidth(1)
+    g.line(ox, cy + cell / 2, ox + pw, cy + cell / 2)
+    g.line(cx + cell / 2, oy, cx + cell / 2, oy + ph)
+    g.setColor(1, 0.85, 0.2, 1)
+    g.setLineWidth(2)
+    g.rectangle("line", cx - 1, cy - 1, cell + 2, cell + 2)
+    g.setLineWidth(1)
+    g.setColor(1, 1, 1, 1)
+  end
+
+  -- The tile itself, magnified. The whole judgement this tool supports is
+  -- "is THAT the drawing I mean to voxelize", and at plan scale a cell is a
+  -- few pixels. Drawn as a scissored blow-up of the same baked canvas rather
+  -- than a second render of the map: one bake, one truth.
+  local function drawInset(x, y, size)
+    local g = love.graphics
+    g.setColor(0, 0, 0, 0.7)
+    g.rectangle("fill", x - 3, y - 3, size + 6, size + 6)
+    if not (S.tiles and S.map) then return end
+    local span = S.zoom                       -- cells across the inset
+    local pxPerCell = S.tiles:getWidth() / S.map.widthCells
+    local mag = size / (span * pxPerCell)
+    -- the top-left canvas pixel the inset starts at, so the cursor's cell
+    -- sits in the middle of it
+    local sx = (S.cx + 0.5 - span / 2) * pxPerCell
+    local sy = (S.cy + 0.5 - span / 2) * pxPerCell
+    g.setScissor(x, y, size, size)
+    g.setColor(1, 1, 1, 1)
+    g.draw(S.tiles, x - sx * mag, y - sy * mag, 0, mag, mag)
+    g.setScissor()
+    local cell = size / span
+    local mx = x + size / 2 - cell / 2
+    local my = y + size / 2 - cell / 2
+    g.setColor(1, 0.85, 0.2, 1)
+    g.setLineWidth(2)
+    g.rectangle("line", mx, my, cell, cell)
+    g.setLineWidth(1)
+    g.setColor(1, 1, 1, 1)
+  end
+
+  local LEGEND = {
+    "arrows  move the cursor (shift x5)",
+    "click   put the cursor on that tile",
+    "[  ]    previous / next map (shift x10)",
+    "c       COPY PROMPT  (also enter, also the button)",
+    "z       zoom the inset in (shift: out)",
+    "g       plan: tiles / walk grid / both",
+    "m       hide the plan (the game is under it)",
+    "t       stand the player on this tile",
+    "y       screenshot (shift: with this panel)",
+    "l       list every prompt copied so far",
+    "F1      this list      escape  quit",
+  }
+
+  local function drawOverlay()
+    local g = love.graphics
+    local W, H = g.getDimensions()
+    local panel = math.min(320, math.floor(W * 0.34))
+
+    g.setColor(0, 0, 0, 0.72)
+    g.rectangle("fill", 0, 0, panel, H)
+
+    local y = 10
+    g.setFont(bigFont)
+    g.setColor(1, 1, 1, 1)
+    g.print(mapId(), 10, y)
+    y = y + 22
+    g.setFont(font)
+    g.setColor(0.72, 0.78, 0.9, 1)
+    g.print(("map %d / %d"):format(S.i, #ids), 10, y)
+    y = y + 20
+
+    -- the coordinates, big, because they are what the prompt is made of and
+    -- what gets read back against the game
+    g.setFont(bigFont)
+    g.setColor(1, 0.85, 0.2, 1)
+    g.print(("tile  (%d,%d)"):format(S.cx, S.cy), 10, y)
+    y = y + 24
+    g.setFont(smallFont)
+    g.setColor(0.8, 0.8, 0.85, 1)
+    local m = S.map
+    if m then
+      local okTile, tile = pcall(m.cellTile, m, S.cx, S.cy)
+      local kind = S.plan and S.plan[S.cy * S.plan.w + S.cx] or "?"
+      -- tileset and tile id are not in the prompt, and are here anyway: they
+      -- are what the skill's data files are keyed by, so this is the line
+      -- that tells you two different-looking cells are the same drawing
+      g.print(("tileset %s   tile %s   %s")
+              :format(tostring(m.def and m.def.tileset),
+                      okTile and tostring(tile) or "?", kind), 10, y)
+      y = y + 16
+      g.print(("block (%d,%d)"):format(math.floor(S.cx / 2),
+                                       math.floor(S.cy / 2)), 10, y)
+      y = y + 18
+    end
+
+    -- the inset
+    local inset = math.min(panel - 20, 180)
+    drawInset(10, y, inset)
+    y = y + inset + 14
+
+    -- THE BUTTON. A real rectangle with a real hit test, not a hint that a
+    -- key exists: the tool is used with one hand on the mouse walking a map,
+    -- and reaching for the keyboard per tile is the whole friction.
+    local bw, bh = panel - 20, 34
+    S.buttonRect = { 10, y, bw, bh }
+    local mx, my = love.mouse.getPosition()
+    local hot = mx >= 10 and my >= y and mx <= 10 + bw and my <= y + bh
+    g.setColor(hot and 0.30 or 0.18, hot and 0.62 or 0.42, hot and 0.34 or 0.22,
+               1)
+    g.rectangle("fill", 10, y, bw, bh, 5, 5)
+    g.setColor(0.45, 0.9, 0.55, 1)
+    g.rectangle("line", 10, y, bw, bh, 5, 5)
+    g.setFont(font)
+    g.setColor(1, 1, 1, 1)
+    local label = "COPY PROMPT"
+    g.print(label, 10 + (bw - font:getWidth(label)) / 2, y + 9)
+    y = y + bh + 12
+
+    -- what it will copy, shown in full and wrapped: the prompt is the
+    -- product, so it is never hidden behind the button that makes it
+    g.setFont(smallFont)
+    g.setColor(0.65, 0.75, 0.85, 1)
+    local preview = promptFor(mapId(), S.cx, S.cy)
+    local _, lines = smallFont:getWrap(preview, panel - 20)
+    for _, l in ipairs(lines) do
+      g.print(l, 10, y)
+      y = y + 13
+    end
+    y = y + 8
+
+    g.setColor(0.55, 0.9, 0.6, 1)
+    local _, outLines = smallFont:getWrap(
+      ("%d copied  ->  %s"):format(#S.copied, OUT), panel - 20)
+    for _, l in ipairs(outLines) do
+      g.print(l, 10, y)
+      y = y + 13
+    end
+    y = y + 5
+
+    if S.msg ~= "" then
+      g.setColor(1, 0.9, 0.6, 1)
+      local _, msgLines = smallFont:getWrap(S.msg, panel - 20)
+      for _, l in ipairs(msgLines) do
+        g.print(l, 10, y)
+        y = y + 13
+      end
+      y = y + 6
+    end
+
+    if S.legend then
+      y = y + 6
+      g.setColor(0.75, 0.8, 0.9, 1)
+      for _, l in ipairs(LEGEND) do
+        g.print(l, 10, y)
+        y = y + 14
+      end
+    end
+
+    if S.showPlan then
+      local px = panel + 12
+      drawPlan(px, 12, W - px - 12, H - 24)
+    else
+      -- and with nothing drawn there, nothing there is clickable: the mouse
+      -- reads the rectangle the draw leaves behind, so clearing it is what
+      -- stops a click on the game moving the cursor
+      S.planRect = nil
+    end
+    g.setColor(1, 1, 1, 1)
+  end
+
+  local innerDraw = love.draw
+  function love.draw()
+    if innerDraw then innerDraw() end
+    if S.shooting then return end
+    local g = love.graphics
+    g.push("all")
+    g.origin()
+    local ok, err = pcall(drawOverlay)
+    g.pop()
+    if not ok and not S.drawWarned then
+      S.drawWarned = true
+      U.log("overlay draw failed: " .. tostring(err))
+    end
+  end
+
+  -- ------- the session
+
+  U.log("tile picker -- " .. #ids .. " maps")
+  for _, l in ipairs(LEGEND) do U.log("  " .. l) end
+  U.log("prompts append to " .. OUT)
+
+  local start = os.getenv("TILE_START")
+  local at = 1
+  if start and start ~= "" then
+    for i, id in ipairs(ids) do
+      if id == start then at = i break end
+    end
+  end
+  enterMap(at)
+
+  local function dispatch(job)
+    if job.cmd == "map" then
+      enterMap(S.i + job.arg)
+    elseif job.cmd == "shot" then
+      shoot(job.arg == "hud")
+    end
+  end
+
+  -- One frame of the session: the next thing a key asked for, or a frame of
+  -- standing still while the game runs underneath. Map changes and captures
+  -- go through the queue rather than happening inside the key handler --
+  -- both of them bind canvases, and doing that from inside love.keypressed
+  -- is doing it inside the engine's own frame.
+  local function pump(frames)
+    for _ = 1, (frames or 1) do
+      local job = table.remove(queue, 1)
+      if job then dispatch(job) else step(1) end
+      if S.quit then return end
+    end
+  end
+
+  -- ------- TILE_SELFTEST=1: the same session, with the keys pressed for you
+  --
+  -- Not a substitute for sitting in front of it -- the thing this tool is
+  -- judged on is whether a bookshelf is recognisable in the inset -- but it
+  -- proves the session starts, both wraps hold, every key path runs, a map
+  -- change re-bakes, and a prompt reaches the clipboard and the file. Which
+  -- is the whole of what can fail without a person noticing.
+  if truthy(os.getenv("TILE_SELFTEST")) then
+    OUT = DIR .. "/selftest_prompts.txt"
+    local function press(key, frames)
+      local ok, err = pcall(handle, key)
+      if not ok then U.log("SELFTEST key " .. key .. " failed: " .. tostring(err)) end
+      pump(frames or 20)
+    end
+    press("right")
+    press("down")
+    press("left")
+    press("up")
+    press("c")                -- a prompt, copied
+    press("z")                -- the inset both ways
+    press("z")
+    press("g")                -- each way of drawing the plan
+    press("g")
+    press("g")
+    press("t")                -- stand on it
+    press("m")                -- the plan away, and back -- with a shot of
+    post("shot", "hud")       -- the window without it, which is the only
+    pump(240)                 -- thing that proves the panel stands alone
+    press("m")
+    press("]", 90)            -- the next map, which re-bakes
+    press("c")                -- and a prompt from there
+    press("[", 90)            -- back
+    press("y", 240)           -- a screenshot of the world
+    post("shot", "hud")       -- and one WITH the panel, which is the only
+    pump(240)                 -- thing that proves the overlay draws
+    press("l")                -- the list
+    press("f1")
+    -- the click paths, which the key presses above never touch
+    local b = S.buttonRect
+    if b then
+      love.mousepressed(b[1] + 4, b[2] + 4, 1)
+      pump(20)
+    end
+    local r = S.planRect
+    if r then
+      love.mousepressed(r.x + r.w * 0.5, r.y + r.h * 0.5, 1)
+      pump(20)
+    end
+    U.log(("SELFTEST %d prompts, cursor %d,%d on %s -- %s")
+          :format(#S.copied, S.cx, S.cy, mapId(), tostring(S.msg)))
+    press("escape", 5)
+    S.quit = true
+  end
+
+  while not S.quit do pump(1) end
+
+  -- Hand the install back the way it was found.
+  if S.tiles then pcall(S.tiles.release, S.tiles) end
+  love.draw = innerDraw
+  love.keypressed = innerKey
+  love.mousepressed = innerMouse
+  if wasRung then pcall(Pipelines.setLevel, "voxel", wasRung) end
+
+  if #S.copied > 0 then
+    U.log(("-- %d prompt%s this session"):format(#S.copied,
+          #S.copied == 1 and "" or "s"))
+    for i, t in ipairs(S.copied) do U.log(("%3d  %s"):format(i, t)) end
+  end
+  U.log("done -- " .. OUT)
+end
diff --git a/tests/tree_shots.lua b/tests/tree_shots.lua
new file mode 100644
index 0000000..8dd320a
--- /dev/null
+++ b/tests/tree_shots.lua
@@ -0,0 +1,101 @@
+-- Scratch driver: Celadon Gym's little tree ($40/$41 over $50/$51,
+-- one cell) at CELADON_GYM (5,7), flanked by the hedge cylinders.
+-- Front and back, voxel rung (5) and flat rung (3). Same spots BEFORE
+-- and AFTER the pin change.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tree_shots.lua \
+--   SHOT_DIR=.scratchpad/celtree AB_TAG=before "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or "shots/celtree")
+    .. "/" .. (os.getenv("AB_TAG") or "after")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[celtree] DRAMATIC_SHAPE is not loaded")
+    love.event.quit()
+    return
+  end
+  local V = handle.lib
+  do -- prove the RUNNING mod sees the pin we think it does
+    local TS = V.require("TileShape")
+    local shapes = TS.forMap({ tileset = { id = "GYM",
+                                           imageWidth = 128,
+                                           imageHeight = 48 } })
+    for _, t in ipairs({ 64, 80 }) do
+      local s = shapes[t]
+      print("[celtree] running-mod GYM tile " .. t .. ": "
+        .. (s and (tostring(s.class) .. "/" .. tostring(s.art)
+                   .. " h=" .. tostring(s.h)) or "nil"))
+    end
+  end
+  local DayNight = V.require("DayNight")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  DayNight.setting:sync("day")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local SCENES = {
+    -- the tree at (5,7) seen from the south = its front
+    { map = "CELADON_GYM", x = 5, y = 8, face = "up", label = "tree_front" },
+    -- the same tree from the north = its back
+    { map = "CELADON_GYM", x = 5, y = 6, face = "down", label = "tree_back" },
+    -- a step further back so the whole tree fits over the hedges
+    { map = "CELADON_GYM", x = 5, y = 9, face = "up", label = "tree_far" },
+    -- from the open floor west of it, edge-on
+    { map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "tree_west" },
+    -- the second placement at (7,5), unobstructed in the east garden
+    { map = "CELADON_GYM", x = 7, y = 7, face = "up", label = "tree2_front" },
+    { map = "CELADON_GYM", x = 8, y = 5, face = "left", label = "tree2_east" },
+    { map = "CELADON_GYM", x = 7, y = 3, face = "down", label = "tree2_back" },
+  }
+
+  local shots = 0
+  for _, s in ipairs(SCENES) do
+    local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
+    if ok then
+      for _, rung in ipairs({ 5, 3 }) do
+        Pipelines.setLevel("voxel", rung)
+        Pipelines.setLevel("tiltshift", 0)
+        settle()
+        local path = ("%s/%s_r%d.png"):format(ROOT, s.label, rung)
+        game.capturePath = path
+        U.wait(6)
+        local f = io.open(path, "rb")
+        if f then f:close() shots = shots + 1
+        else print("[celtree] capture missed: " .. path) end
+      end
+    else
+      print("[celtree] teleport failed: " .. s.map)
+    end
+  end
+  print(("[celtree] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end
diff --git a/tools/building_voxels.py b/tools/building_voxels.py
index 0dd1002..02714e4 100644
--- a/tools/building_voxels.py
+++ b/tools/building_voxels.py
@@ -697,6 +697,63 @@ TEMPLATES = {
         roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23),
         slab=3, front_eave=0, ledge=None, tileset="mansion",
     ),
+    # F07c: the Game Freak office's computer desk -- CELADON_MANSION_2F
+    # cell (0,5) and CELADON_MANSION_3F cells (0,3), (3,3) and (0,6)
+    # (4 placements, scan.lua, and the four ids of its apron and chair
+    # row occur nowhere else on this atlas).
+    #
+    # Rows 0-15 are BILL'S DESK, byte for byte: the same tabletop seen
+    # from above with the same terminal, cord and isometric computer
+    # drawn into it (a whole-crop diff against the interior atlas's
+    # 11/12/13/14 + 27/28/29/30 comes back empty for every one of the
+    # 512 pixels). One drawing = one model, so those four parts are
+    # bills_desk's verbatim -- see that entry for the readings.
+    #
+    # Only the FRONT differs, and it is drawn 6 rows where Bill's is 7:
+    #   15      the top's own black front edge -- the row the lid
+    #           replaces, so it opens the fascia the way Bill's row 16
+    #           does and the desk stands 7 voxels rather than 8
+    #   16-17   the #555 edge lip and the black seam under it: the
+    #           desktop's own rim, so `fascia` (it wraps every side)
+    #   18-21   the base -- the left leg (the @#@ at x0-x2), the open
+    #           apron between, and the DRAWER PEDESTAL at x20-x31: two
+    #           #555 drawer fronts (rows 16-17 and 19-20) in a black
+    #           frame, which the measured recess pass sinks a voxel
+    #           each because they are non-black regions sealed behind
+    #           their own black outline. Row 21 is the ground line, and
+    #           the measured 22 is where the drawing puts the cast
+    #           shadow on the floor -- 22-23 are that shadow, dithered
+    #           floor in the walkable cell, and the model builds none
+    #           of it.
+    # The chair is Bill's chair REDRAWN, not the same pixels (a 2px
+    # white margin round the backrest panel where Bill's has 1px, and
+    # it sits at x4-x15 rather than x2-x13), but the same object band
+    # for band, so it takes that part table with x and rise adjusted:
+    # rows 20-21 the backrest top seen from above, 22-31 its elevation,
+    # rise = -7 putting it back on the floor, z 22 depth 10.
+    "mansion_computer_desk": dict(
+        tiles=[
+            [36, 37, 52, 53],
+            [64, 65, 66, 67],
+            [2, 3, 85, 86],
+            [18, 19, 17, 17],
+        ],
+        roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
+        slab=0, front_eave=0, ledge=None, tileset="mansion", depth=4,
+        # the desk's own plot is its two cells; the grid runs on because
+        # its apron and the CHAIR share tiles 2/3
+        desk=dict(fascia=(15, 17), base=(18, 21), depth=2),
+        parts=[
+            dict(kind="flat", x=(2, 15), rows=(3, 7)),       # the notes
+            dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13),
+                 z=8, depth=6),                              # the keyboard
+            dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9),
+                 z=8, depth=2),                              # the cord
+            dict(kind="iso", x=(19, 30), rows=(1, 13), plan=6, z=9),
+            dict(kind="upright", x=(4, 15), top=(20, 21), facade=(22, 31),
+                 rise=-7, z=22, depth=10),                   # the chair
+        ],
+    ),
     # F08: the dining table of the generic town house -- 18 placements,
     # every home's cells (3,3):(4,4) -- the chief's long table (F07) at
     # two cells wide. The same read to the row: 0-23 the rounded