DramaticShapeVoxelMod archival mirror · read-only
6240b50cec304b3c1d0fd4134fddc06152592947

update all battle locations

DramaticShape · 2026-08-05 · parent 77e0f93315

update all battle locations

Changed files

CHANGELOG.md                  |    2 +-
 data/battle_arenas.lua        |  152 +++---
 lib/BattleArena.lua           |   80 ++-
 lib/BattleCam.lua             |   23 +-
 lib/BattleScene.lua           |    4 +
 tests/arena_config.lua        |  197 ++++++++
 tests/arena_editor.lua        | 1617 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/dramatic_shape_test.lua |  103 ++++
 tests/stadium_all_shots.lua   |  157 ++++++
 9 files changed, 2260 insertions(+), 75 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5e35b6..b6d4873 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 # Changelog
 
-## Unreleased
+## 1.6.1
 
 ### Fixed
 
diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
index 52714a1..33f7164 100644
--- a/data/battle_arenas.lua
+++ b/data/battle_arenas.lua
@@ -23,6 +23,20 @@
 --
 --   SHOT_DIR=.scratchpad/arenas \
 --   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
+--
+-- To change ONE map's spot, or to go over these by eye, run the editor
+-- instead -- it is the same choice made in front of the map rather than in a
+-- batch. It slides the arena around on a plan of the map with the fit, the
+-- clearance and the camera's own sightlines answering live, stages a real
+-- battle on the spot when asked, and writes this file back a map at a time,
+-- replacing only the lines that changed and leaving every comment here alone:
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec .
+--
+-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over;
+-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it
+-- moved describes where that spot USED to be, and it says so by name at
+-- export time -- those are the lines to re-word by hand.
 
 -- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
 -- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
@@ -49,14 +63,14 @@ return {
   -- height along the sightline and a hedge in the apron row is not terrain.
   -- So the choice is where in the clearing, and this is its west end: tree
   -- line square behind the pair, nothing crossing either of them.
-  ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
+  ["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
   ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
-  ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
-  ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
-  ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
-  ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
-  ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
-  ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
+  ["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
+  ["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
+  ["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
+  ["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
+  ["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
+  ["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
   -- the whole route admits six bare wide arenas, all in the west cliff
   -- corridor; this is the best of them. A flower cluster crosses the far
   -- mon's hind legs, which the brief allows -- every alternative put a
@@ -64,13 +78,13 @@ return {
   ["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
   ["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
   ["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
-  ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
-  ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
-  ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
-  ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
-  ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
+  ["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
+  ["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
+  ["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
+  ["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
+  ["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
   ["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
-  ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
+  ["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
 
   -- ------- buildings and caves
   --
@@ -78,10 +92,10 @@ return {
   -- or gravestones in it rarely holds a 3x6 clearing whose whole width is
   -- also SEEN, and giving up the apron is usually the difference between a
   -- fight in the open and one behind a console.
-  ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
-  ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
-  ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
-  ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
+  ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
+  ["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
+  ["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
+  ["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
   ["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
   ["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
   -- every one of 4F's thirty candidate spots puts a gravestone through a
@@ -89,73 +103,79 @@ return {
   -- is shot there
   ["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
                            shape = "wide" },
-  ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
-  ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
+  ["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
+  ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
   ["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
   ["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
   ["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
   ["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
-  ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
-  ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
-  ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
-  ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
+  ["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
+                             shape = "narrow", turn = 90, cam = "wide" },
+  ["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
   ["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
-  ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
-  ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
-  ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
+  ["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
+                           shape = "wide", turn = 90, cam = "wide" },
+  ["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
+  ["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
   ["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
+  ["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
+                              shape = "wide", turn = 90, cam = "wide" },
+  ["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
+  ["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
+  ["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
   -- Silph Co is office floors partitioned into small rooms, so the long lens
   -- often lands outside the walls it is meant to be looking between; the
   -- floors that could not be framed any other way ask for the wide one.
-  ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
-  ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
+  ["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
+  ["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
   ["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
-  ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
+  ["SILPH_CO_6F"] = { x = 19, y = 2,
+                      shape = "narrow", turn = 90, cam = "wide" },
   ["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
   ["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
   ["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
-  ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
+  ["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
   -- the ship is all two-cell corridors, so the wide arena shape fits nowhere
   -- aboard and the long lens always lands outside the hull
   ["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
   -- these two decks are byte-identical geometry, so they take the same spot
-  ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
-  ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
+  ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
+  ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
   ["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
-  ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
-  ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
+  ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
+  ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
 
   -- ------- towns and the last interiors
   ["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
-  ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
+  ["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
   -- the lab is ten cells by twelve, so the long lens is always off-map, and
   -- on it a desk clipped one mon and a pillar the other
-  ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
+  ["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
   -- Saffron's gym is a grid of small walled cells: no wide shape exists
   -- anywhere in it, and the long lens sits inside a divider
   ["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
   ["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
-  ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
-  ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
+  ["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
+  ["SILPH_CO_11F"] = { x = 10, y = 6,
+                       shape = "wide", turn = 180, cam = "wide" },
   -- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
   -- gym's barrier, so arenas there silently fail the fit test
-  ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
-  ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
+  ["VERMILION_GYM"] = { x = 3, y = 2,
+                        shape = "narrow", turn = 90, cam = "wide" },
+  ["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
   ["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
 
   -- ------- the remaining routes
-  ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
+  ["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
   -- the two surf routes fight AFLOAT, in the middle of their own sea rather
   -- than on the rim of beach the land search would otherwise find
   ["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
   ["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
-  ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
-  ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
-  ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
+  ["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
+  ["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
+  ["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
   ["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
 
   -- ------- caves, gyms and the Elite Four
@@ -163,25 +183,29 @@ return {
   -- None of these tilesets has a grass tile at all, so the no-grass rule
   -- constrained nothing here; what constrains them is furniture, rock
   -- pillars and how small the rooms are.
-  ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
-  ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
-  ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
-  ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
+  ["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
+  ["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
+  ["CELADON_GYM"] = { x = 3, y = 4,
+                      shape = "narrow", turn = 90, cam = "wide" },
+  ["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
   -- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
   -- hide a mon, so it borrows the floor below -- the same cave
   ["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
                            shape = "wide" },
-  ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
-  ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
+  ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
+  ["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
   ["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
-  ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
-  ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
-  ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
-  ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
-  ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
-  ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
-  ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
-  ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
+  ["CINNABAR_GYM"] = { x = 9, y = 16,
+                       shape = "narrow", turn = 90, cam = "wide" },
+  ["DIGLETTS_CAVE"] = { x = 14, y = 26,
+                        shape = "wide", turn = 90, cam = "wide" },
+  ["FIGHTING_DOJO"] = { x = 3, y = 5,
+                        shape = "narrow", turn = 90, cam = "wide" },
+  ["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
+  ["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
+  ["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
+  ["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
+  ["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
 
   -- The three gyms the default rig cannot stand back from. Five blocks is
   -- further than these rooms are wide, so the eye landed outside the map and
@@ -190,7 +214,7 @@ return {
   -- BattleCam), which fits inside the room; the mons come out smaller and all
   -- three became stageable. It is asked for HERE, per map, so every area that
   -- does not ask keeps the long lens it was framed for.
-  ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
-  ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
+  ["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
+  ["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
   ["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
 }
diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua
index 025fe8f..ccc124b 100644
--- a/lib/BattleArena.lua
+++ b/lib/BattleArena.lua
@@ -109,6 +109,57 @@ BattleArena.SHAPES = {
   { id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
 }
 
+-- ------- which way round the fight stands
+--
+-- Both shapes above are drawn north-south, with the foe at the top and the
+-- player below it, and the camera is solved for that: it sits off the
+-- player's shoulder, low and back down the arena's own axis. `turn` swings
+-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and
+-- the camera with them -- so the composition on screen is identical and only
+-- the ground under it is different.
+--
+-- It buys two things.
+--
+-- A footprint that FITS. The wide shape is three cells by six; an east-west
+-- corridor two cells deep has no room for it standing up and all the room in
+-- the world for it lying down. Half the maps in Kanto run the other way from
+-- the one shape this mode was drawn in.
+--
+-- And a BACKDROP. A quarter turn moves the camera to a different side of the
+-- same patch of ground, so the wall behind the pair becomes the window
+-- behind them, or the cliff becomes the valley. Nothing about the shot's
+-- geometry changes -- the mons land on the same two screen anchors at the
+-- same size -- so this is purely a choice about what is behind them, made
+-- per map by somebody looking at it.
+--
+-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is
+-- what it is; handled as quarter turns everywhere below.
+local function quarters(turn)
+  local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5)
+  return ((q % 4) + 4) % 4
+end
+
+BattleArena.quarters = quarters
+
+-- The footprint a shape covers once turned: a quarter or three of a turn
+-- swaps how far it reaches in each direction, which is the whole reason a
+-- corridor takes one and not the other.
+function BattleArena.extent(shape, turn)
+  if quarters(turn) % 2 == 1 then return shape.h, shape.w end
+  return shape.w, shape.h
+end
+
+-- Where a cell offset inside the shape ends up under the same turn, measured
+-- from the turned footprint's own north-west corner -- so the corner an entry
+-- names stays the corner, whichever way the fight faces from it.
+local function spin(shape, turn, ox, oy)
+  local q = quarters(turn)
+  if q == 1 then return shape.h - 1 - oy, ox end
+  if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end
+  if q == 3 then return oy, shape.w - 1 - ox end
+  return ox, oy
+end
+
 -- Whether a cell is open ground for the purpose above.
 --
 -- "Open" is the walk test the player themselves answer to, so an arena can
@@ -174,12 +225,19 @@ end
 
 -- Build the record the renderer reads: the two mons' cells and, in world
 -- pixels, the centre of each and of the pair.
-local function place(shape, x, y)
-  local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
-  local px, py = x + shape.player[1], y + shape.player[2]
+local function place(shape, x, y, turn)
+  local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2])
+  local pox, poy = spin(shape, turn, shape.player[1], shape.player[2])
+  local ex, ey = x + eox, y + eoy
+  local px, py = x + pox, y + poy
+  local w, h = BattleArena.extent(shape, turn)
   local arena = {
     shape = shape.id,
-    x = x, y = y, w = shape.w, h = shape.h,
+    -- carried in degrees, so everything downstream that reasons about the
+    -- shot -- the camera's base yaw above all -- reads the same number the
+    -- data file was written with
+    turn = quarters(turn) * 90,
+    x = x, y = y, w = w, h = h,
     enemyCell = { ex, ey },
     playerCell = { px, py },
     -- world-pixel centres of the two cells a mon stands on
@@ -302,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing)
       -- ocean rather than on a scrap of beach at the edge of the map. Land
       -- entries are unaffected: land passes the test either way.
       local grid, gw = openGrid(host, true)
-      if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
-        local arena = place(shape, pick.x, pick.y)
+      -- measured against the TURNED footprint: an entry that lies the arena
+      -- down an east-west corridor covers different ground from the one that
+      -- stands it up, and the fit test is the thing that has to know
+      local fw, fh = BattleArena.extent(shape, pick.turn)
+      if fits(grid, gw, pick.x, pick.y, fw, fh) then
+        local arena = place(shape, pick.x, pick.y, pick.turn)
         arena.map = host
         -- which camera rig this spot is framed for; nil is the default long
         -- lens, "close" the short one small rooms need (see BattleCam)
@@ -321,9 +383,11 @@ end
 -- The arena at a given north-west corner, whatever the map says about it.
 -- The authoring tool's manual override: a spot chosen by eye rather than by
 -- the search, so it can be photographed and judged before it is written down.
-function BattleArena.at(x, y, shapeId)
+function BattleArena.at(x, y, shapeId, turn)
   for _, shape in ipairs(BattleArena.SHAPES) do
-    if shape.id == (shapeId or "wide") then return place(shape, x, y) end
+    if shape.id == (shapeId or "wide") then
+      return place(shape, x, y, turn)
+    end
   end
   return nil
 end
diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua
index 3bd3170..160f4fd 100644
--- a/lib/BattleCam.lua
+++ b/lib/BattleCam.lua
@@ -431,7 +431,22 @@ function BattleCam.rig(arena, groundY, canonical)
   -- arena's own axis, and the room the player has is all on the far side of
   -- that -- out toward square-on. (orbitRange measures exactly that room.)
   local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
-  local yaw = steer + (fixed and 0
+  -- ------- and the quarter turn the arena itself is standing at
+  --
+  -- An arena may be laid down any of the four ways (BattleArena's `turn`),
+  -- and the rig is solved for ONE of them: eye off the player's shoulder,
+  -- back down an axis that runs north-south. So the whole offset is turned
+  -- with the ground under it, which leaves the camera in exactly the same
+  -- place RELATIVE to the two mons -- same distance, same height, same
+  -- angle -- and therefore lands them on the same two screen anchors at the
+  -- same size. A turn is a fact about the map, never about the shot.
+  --
+  -- It goes in with the drift and the steer rather than beside them because
+  -- it is the same rotation about the same point; the player's own orbit is
+  -- then measured from wherever the arena starts, so both stops travel with
+  -- it and side-on stays side-on.
+  local base = math.rad(arena.turn or 0)
+  local yaw = base + steer + (fixed and 0
               or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
   local c, s = math.cos(yaw), math.sin(yaw)
   -- the breath scales the whole offset, height included, so the eye moves
@@ -443,7 +458,11 @@ function BattleCam.rig(arena, groundY, canonical)
   local dz = (R.side * s + R.back * c) * k
 
   local eye = { mx + dx, groundY + R.height * k, mz + dz }
-  local focus = { mx + R.lookX, groundY + R.lookY, mz }
+  -- the aim's own offset turns with the arena too, and with the BASE alone --
+  -- the drift and the steer swing the eye about the focus, so a focus that
+  -- followed them would take the thing being orbited around with it
+  local bc, bs = math.cos(base), math.sin(base)
+  local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs }
 
   -- and the climb: the eye swung UP about the focus, at a constant radius.
   -- About the focus so the aim stays nailed to the two mons and only the
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 008d84b..52ad143 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -303,7 +303,11 @@ end
 -- first drawn in.
 local function shadowSignature(state, arena, terrain, nbMesh, token)
   local host = arena.map or state.map
+  -- `turn` is in the signature with the corner and the shape: the same corner
+  -- turned a quarter is a different footprint standing on different ground,
+  -- and a cast kept from the other one freezes the shadows across it
   local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
+                  tostring(arena.turn or 0),
                   tostring(terrain), tostring(token or 0),
                   -- the cycle keeps running through a fight, and an arena lit
                   -- from somewhere new must be re-cast from there
diff --git a/tests/arena_config.lua b/tests/arena_config.lua
new file mode 100644
index 0000000..c72a258
--- /dev/null
+++ b/tests/arena_config.lua
@@ -0,0 +1,197 @@
+-- data/battle_arenas.lua, edited in place by line.
+--
+-- The arena editor (tests/arena_editor.lua) writes this file back every time
+-- a map is committed, and most of what is in it is PROSE: which alternatives
+-- were rejected for each area and what was wrong with them. That argument is
+-- the part a tool cannot reconstruct, so nothing here regenerates the file --
+-- an entry that changed has its own lines replaced, a new one is appended,
+-- and every other byte comes through exactly as it was.
+--
+-- Kept out of the driver so it can be tested without a game: a bug in here
+-- writes a config file, and the failure mode of a bad one is silent -- every
+-- map loses its authored spot and battles quietly start happening somewhere
+-- else. `apply` therefore never decides on its own that its output is good;
+-- it hands back the text and the caller parses it back and checks it says
+-- what was asked for before anything is overwritten.
+--
+--   local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")
+--   local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14,
+--                                                            shape = "narrow" } })
+--
+-- An edit's value is an entry table, `false` for an authored refusal, or
+-- Config.REMOVE to take the entry out altogether.
+local Config = {}
+
+Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end })
+
+-- The heading new maps are collected under, so a file worked over several
+-- sessions grows one list rather than one heading per session.
+Config.SECTION = "  -- ------- added by tests/arena_editor"
+
+-- Lossless both ways: a trailing newline becomes a trailing empty line, and
+-- concat puts it back.
+function Config.splitLines(text)
+  local out, pos = {}, 1
+  while true do
+    local a, b = text:find("\n", pos, true)
+    if not a then
+      out[#out + 1] = text:sub(pos)
+      break
+    end
+    out[#out + 1] = text:sub(pos, a - 1)
+    pos = b + 1
+  end
+  return out
+end
+
+-- One entry, laid out the way the file already lays them out: on one line
+-- where it fits inside the file's own margin, and wrapped under the brace
+-- where it does not (which is how the cross-floor entries are written).
+function Config.entryLines(id, entry)
+  if entry == false then
+    return { ("  [%q] = false,"):format(id) }
+  end
+  local head = ("  [%q] = { "):format(id)
+  local parts = {}
+  if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end
+  parts[#parts + 1] = ("x = %d"):format(entry.x)
+  parts[#parts + 1] = ("y = %d"):format(entry.y)
+  parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide")
+  if (entry.turn or 0) ~= 0 then
+    parts[#parts + 1] = ("turn = %d"):format(entry.turn)
+  end
+  if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end
+  local one = head .. table.concat(parts, ", ") .. " },"
+  if #one <= 79 then return { one } end
+  local first = entry.map and 3 or 2
+  return {
+    head .. table.concat(parts, ", ", 1, first) .. ",",
+    (" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },",
+  }
+end
+
+-- Where an id's entry starts and ends, plus how many comment lines sit
+-- directly above it. The end is the first line that closes the table, so a
+-- wrapped entry is found whole rather than half-replaced.
+function Config.findEntry(lines, id)
+  local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*='
+  for i = 1, #lines do
+    if lines[i]:find(pat) then
+      local note = 0
+      while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do
+        note = note + 1
+      end
+      if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end
+      for j = i, #lines do
+        if lines[j]:find("}%s*,?%s*$") then return i, j, note end
+      end
+      return i, i, note
+    end
+  end
+  return nil
+end
+
+-- The `}` that closes the returned table: new entries go in above it.
+function Config.closingLine(lines)
+  for i = #lines, 1, -1 do
+    if lines[i]:find("^}") then return i end
+  end
+  return #lines + 1
+end
+
+-- Apply `edits` (map id -> entry | false | REMOVE) to `text`.
+--
+-- Returns the new text and a report: `changed`, `added`, `removed` and
+-- `stale` as lists of map ids. `stale` is the one worth acting on -- those
+-- entries had comment lines directly above them, and a comment above an
+-- entry that has just moved describes where it USED to be.
+function Config.apply(text, edits)
+  local lines = Config.splitLines(text)
+  local report = { changed = {}, added = {}, removed = {}, stale = {} }
+
+  local ordered = {}
+  for id in pairs(edits) do ordered[#ordered + 1] = id end
+  table.sort(ordered)
+
+  local appended = {}
+  for _, id in ipairs(ordered) do
+    local entry = edits[id]
+    -- found again per edit rather than indexed once up front: each
+    -- replacement changes the line count under every index after it
+    local from, to, note = Config.findEntry(lines, id)
+    if entry == Config.REMOVE then
+      if from then
+        for _ = from, to do table.remove(lines, from) end
+        report.removed[#report.removed + 1] = id
+        if note > 0 then report.stale[#report.stale + 1] = id end
+      end
+    elseif from then
+      local new = Config.entryLines(id, entry)
+      for _ = from, to do table.remove(lines, from) end
+      for k = #new, 1, -1 do table.insert(lines, from, new[k]) end
+      report.changed[#report.changed + 1] = id
+      if note > 0 then report.stale[#report.stale + 1] = id end
+    else
+      appended[#appended + 1] = id
+      report.added[#report.added + 1] = id
+    end
+  end
+
+  if #appended > 0 then
+    local at, block = nil, {}
+    for i = 1, #lines do
+      if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then
+        at = i + 1
+      end
+    end
+    if not at then
+      at = Config.closingLine(lines)
+      block[#block + 1] = ""
+      block[#block + 1] = Config.SECTION
+    end
+    for _, id in ipairs(appended) do
+      for _, l in ipairs(Config.entryLines(id, edits[id])) do
+        block[#block + 1] = l
+      end
+    end
+    for k = #block, 1, -1 do table.insert(lines, at, block[k]) end
+  end
+
+  return table.concat(lines, "\n"), report
+end
+
+-- Whether two entries say the same thing. `false` and nil are values here --
+-- a refusal is not the absence of one -- so this is an equality test and not
+-- a truthiness one.
+function Config.same(a, b)
+  if a == b then return true end
+  if type(a) ~= "table" or type(b) ~= "table" then return false end
+  return a.x == b.x and a.y == b.y
+     and (a.shape or "wide") == (b.shape or "wide")
+     and (a.turn or 0) == (b.turn or 0)
+     and a.cam == b.cam and a.map == b.map
+end
+
+-- Parse a config back and check it says what was committed. This is what
+-- stands between a bug in the surgery above and a data file that no longer
+-- loads, so it is run on the TEXT before that text replaces anything.
+-- Returns the parsed table, or nil and why not.
+function Config.verify(text, edits, chunkName)
+  local chunk, err = load(text, "@" .. (chunkName or "battle_arenas"))
+  if not chunk then return nil, "would not compile: " .. tostring(err) end
+  local ok, list = pcall(chunk)
+  if not (ok and type(list) == "table") then
+    return nil, "did not return a table: " .. tostring(list)
+  end
+  for id, want in pairs(edits) do
+    local got = list[id]
+    if want == Config.REMOVE then
+      if got ~= nil then return nil, id .. " is still in the file" end
+    elseif not Config.same(want, got) then
+      return nil, id .. " did not read back as it was committed"
+    end
+  end
+  return list
+end
+
+return Config
diff --git a/tests/arena_editor.lua b/tests/arena_editor.lua
new file mode 100644
index 0000000..b540b11
--- /dev/null
+++ b/tests/arena_editor.lua
@@ -0,0 +1,1617 @@
+-- Driver: the interactive battle-arena editor.
+--
+-- data/battle_arenas.lua holds one authored spot per area -- where that map's
+-- 3D battles are staged. tests/arena_pick.lua AUTHORS that file in a batch:
+-- it takes the search's answer for every map, photographs each one, and
+-- leaves a pile of PNGs and PICK lines to be read afterwards. This is the
+-- other way round. It is one window you sit in front of: pick a map, slide
+-- the arena around on a plan of it, watch the fit and the sightlines answer
+-- as you move, stage a REAL battle on the spot to see what the shot actually
+-- looks like, and commit. Committing rewrites the config.
+--
+-- The plan is the MAP'S OWN ART -- the tiles extracted from the cartridge,
+-- drawn from the game's own atlas through the game's own palette -- with the
+-- walk grid washed over it, because "where may an arena stand" and "what is
+-- it standing in" are two different questions and a spot has to answer both.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua \
+--     "/c/Program Files/LOVE/lovec.exe" .
+--
+-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so
+-- the console is attached: every commit prints its line there too, and the
+-- console transcript alone is enough to redo a session by hand.
+--
+-- ------- the keys
+--
+--   arrows      move the arena's north-west corner (hold shift for 5 cells)
+--   [  ]        previous / next map
+--   s           cycle the SHAPE -- wide (3x6, with the apron) or narrow (1x4)
+--   r           TURN the whole staging a quarter (shift: the other way) --
+--               footprint, both mons and the camera together, so the picture
+--               is unchanged and only the ground under it moves. What it is
+--               for: a long arena down a corridor that runs east-west, and
+--               swapping what stands behind the pair
+--   c           cycle the LENS -- the long default, or cam = "wide"
+--   h           cycle the HOST map: stage this map's fight on a sibling floor
+--   w           water counts as ground for the search (the surf routes)
+--   g           what the plan shows: the map's own tiles, the walk grid over
+--               them, or the walk grid alone
+--   f           scan this map for every arena that fits, clear ones first
+--   n  p        step through that scan
+--   a           jump to the automatic pick (what arena_pick would have said)
+--   u           back to whatever the config file holds for this map
+--   t           TEST: stage a real battle here and look at it
+--   y           screenshot
+--   enter       COMMIT this map (shift+enter: commit and go to the next map)
+--   backspace   commit a REFUSAL: nowhere on this map a fight can be seen
+--   delete      commit a REMOVAL: no entry, so battle time falls back to the
+--               nearest-clear search
+--   e           export now (a commit exports on its own; this is for after a
+--               revert, or just to be sure)
+--   F1          the key legend on screen
+--   escape      quit (in TEST, back to the editor)
+--
+-- In TEST every key except t / escape / y goes to the GAME, so the fight is
+-- driven with the game's own buttons and the camera steered with the mod's
+-- own Q/E and mouse -- what you are judging is the shot a player gets.
+--
+-- ------- what it writes
+--
+-- The export is data/battle_arenas.lua REWRITTEN LINE BY LINE rather than
+-- regenerated: an entry that changed has its lines replaced in place, a new
+-- one is appended, and every other line of that file -- which is mostly the
+-- prose explaining why each spot is the spot -- comes through untouched. A
+-- comment sitting directly above a replaced entry describes the OLD spot, so
+-- each of those is reported by id at export time and is yours to re-word.
+--
+-- It lands in .scratchpad/arena_editor/battle_arenas.lua by default, so a
+-- session is a file to diff and copy over rather than an edit to the mod
+-- under a running game. ARENA_WRITE=1 writes to data/battle_arenas.lua
+-- itself, keeping a .bak of what was there. Either way the written text is
+-- parsed back and checked against what was committed BEFORE it replaces
+-- anything -- a tool that writes a config file must never be the reason one
+-- stops loading.
+--
+-- Re-run and the session resumes: the export is a complete config, so it is
+-- read back as the base when it exists (ARENA_FRESH=1 starts from the mod's
+-- own file instead).
+--
+-- ------- environment
+--
+--   ARENA_MAPS=ID,ID   work this list instead of every map a fight happens on
+--   ARENA_OUT=path     where the config is written
+--   ARENA_WRITE=1      write to data/battle_arenas.lua (keeps a .bak)
+--   ARENA_FRESH=1      ignore an existing export and start from the mod's file
+--   ARENA_MON=SPECIES  what to fight in TEST (default NIDORINO)
+--   ARENA_LEVEL=N      its level (default 20)
+--   ARENA_RUNG=N       the voxel angle rung to sit on (default 3)
+--   ARENA_MARGIN=N     keep the scan this far off the map's edge (default: 2
+--                      outdoors, where the outermost cells are the connection
+--                      border, and 0 indoors)
+--   SHOT_DIR=path      screenshots and the default export directory
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  -- the config's line surgery, kept in its own file so it can be tested
+  -- without a game (see the header there)
+  local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.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/arena_editor" end
+  local DATA = "mods/DramaticShapeVoxelMod/data/battle_arenas.lua"
+  local IN_PLACE = truthy(os.getenv("ARENA_WRITE"))
+  local OUT = os.getenv("ARENA_OUT")
+  if not OUT or OUT == "" then
+    OUT = IN_PLACE and DATA or (DIR .. "/battle_arenas.lua")
+  end
+  local FRESH = truthy(os.getenv("ARENA_FRESH"))
+  local SELFTEST = truthy(os.getenv("ARENA_SELFTEST"))
+  if SELFTEST then
+    -- never the mod's own file, whatever the environment says: the check runs
+    -- unattended and its whole point is that it can be thrown away
+    IN_PLACE = false
+    OUT = DIR .. "/selftest_battle_arenas.lua"
+    FRESH = true          -- and always from the mod's own file, so a rerun
+  end                     -- checks the same surgery on the same text
+  local SPECIES = os.getenv("ARENA_MON")
+  if not SPECIES or SPECIES == "" then SPECIES = "NIDORINO" end
+  local LEVEL = tonumber(os.getenv("ARENA_LEVEL") or "") or 20
+  local RUNG = tonumber(os.getenv("ARENA_RUNG") or "") or 3
+  local MARGIN = tonumber(os.getenv("ARENA_MARGIN") or "")
+
+  -- SHOT_DIR is created with both spellings: U.shot's own mkdir is
+  -- Unix-flavoured and silently does nothing on Windows, which is where this
+  -- tool is driven from, and a screenshot that never reaches disk is the one
+  -- failure a screenshot tool must not have.
+  -- U.shot's own mkdir is Unix-flavoured, and this tool is driven from
+  -- Windows, where `2>/dev/null` is cmd.exe being asked to redirect into a
+  -- directory that does not exist -- which prints "the system cannot find the
+  -- path specified" for every call and creates nothing. So the shell is asked
+  -- in its own language.
+  local WINDOWS = love.system and love.system.getOS() == "Windows"
+
+  local function mkdirp(dir)
+    if not dir or dir == "" then return end
+    if WINDOWS then
+      os.execute('if not exist "' .. dir:gsub("/", "\\") .. '" mkdir "'
+                 .. dir:gsub("/", "\\") .. '"')
+    else
+      os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
+    end
+  end
+  mkdirp(DIR)
+
+  local function readFile(path)
+    local f = io.open(path, "rb")
+    if not f then return nil end
+    local text = f:read("*a")
+    f:close()
+    return text
+  end
+
+  local function writeFile(path, text)
+    mkdirp(path:match("^(.*)[/\\][^/\\]+$"))
+    local f = io.open(path, "wb")
+    if not f then return false end
+    f:write(text)
+    f:close()
+    return true
+  end
+
+  -- ------- the mod, and the modes this tool needs it in
+
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if not lib then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+    return
+  end
+  local Arena = lib.require("BattleArena")
+  local Battles = lib.require("OverworldBattle")
+  local BattleCam = lib.require("BattleCam")
+  local DayNight = lib.require("DayNight")
+  local ChunkMesher = lib.require("ChunkMesher")
+  local Voxel3D = lib.require("Voxel3D")
+  local Pipelines = require("src.render.Pipelines")
+  local PaletteFX = require("src.render.PaletteFX")
+  local BattleState = require("src.battle.BattleState")
+  local Pokemon = require("src.pokemon.Pokemon")
+  local MapLoader = require("src.world.MapLoader")
+  local Map = require("src.world.Map")
+
+  game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
+  game.save.player.name = "RED"
+
+  -- Every mod setting is set WITHOUT a game argument, deliberately: passing
+  -- one persists the value into the player's own options file, and a tool
+  -- that is borrowing their install for an hour should not come back out
+  -- having changed what they play with. ModSetting caches the index it is
+  -- given, which is what every reader asks, so the modes are live for this
+  -- process and stored nowhere.
+  Battles.setting:setValue(true)             -- 2D-3D A: the fight on the map
+  Battles.backSetting:setValue(false)        -- both mons out there on it
+  DayNight.setting:setValue("day")           -- one light to judge them all in
+  -- the rung IS persisted by the engine's own registry, so it is noted here
+  -- and put back on the way out
+  local wasRung = Pipelines.level("voxel")
+  Pipelines.setLevel("voxel", RUNG)
+  if not Voxel3D.available() then
+    U.log("no 3D pass available here -- the editor still picks and exports, "
+          .. "but TEST will draw the flat battle screen")
+  end
+
+  -- ------- which maps get a spot
+  --
+  -- The same filter arena_pick uses, and for the same reason: authoring a
+  -- spot for a shop floor is authoring a spot for a fight that never happens
+  -- there. The encounter tables are their own registry keyed by map id, NOT a
+  -- field on the map record -- reading def.encounters answers nil for every
+  -- map in the game and silently narrows this to trainer maps alone.
+  local function fights(id, def)
+    local enc = game.data.encounters and game.data.encounters[id]
+    if enc then
+      for _, kind in ipairs({ "grass", "water" }) do
+        local t = enc[kind]
+        if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then
+          return true
+        end
+      end
+    end
+    for _, obj in ipairs(def.objects or {}) do
+      if obj.trainer or obj.trainerClass or obj.species then return true end
+    end
+    return false
+  end
+
+  local ids = {}
+  local explicit = os.getenv("ARENA_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 fights(id, def) then
+        ids[#ids + 1] = id
+      end
+    end
+    table.sort(ids)
+  end
+  if #ids == 0 then
+    U.log("no maps to work on")
+    return
+  end
+
+  -- ------- the config this session starts from
+  --
+  -- The export is a complete config file, so when one is already there it is
+  -- what we read back: a session that ran out of evening resumes exactly
+  -- where it stopped, and the file being rewritten is the same one being
+  -- read, which keeps the prose in it accumulating rather than reverting.
+  local baseFrom = OUT
+  local baseText = (not FRESH) and readFile(OUT) or nil
+  if not baseText then
+    baseFrom = DATA
+    baseText = readFile(DATA)
+  end
+  local base = {}
+  if baseText then
+    local chunk = load(baseText, "@" .. baseFrom)
+    local ok, list = pcall(chunk)
+    if ok and type(list) == "table" then
+      base = list
+    else
+      U.log(("could not read %s (%s) -- starting from an empty config")
+            :format(baseFrom, tostring(list)))
+      baseText = "return {\n}\n"
+      baseFrom = "(empty)"
+    end
+  else
+    U.log("no config file found -- starting from an empty one")
+    baseText = "return {\n}\n"
+    baseFrom = "(empty)"
+  end
+  U.log(("base config: %s (%d maps)"):format(baseFrom, (function()
+    local n = 0
+    for _ in pairs(base) do n = n + 1 end
+    return n
+  end)()))
+
+  -- What this session has decided, per map id: an entry table, `false` for a
+  -- refusal, or REMOVE for "take the entry out and let the search answer".
+  -- Kept apart from `base` so the export knows which lines it may touch.
+  local REMOVE = Config.REMOVE
+  local edits = {}
+  local sameEntry = Config.same
+
+  local backedUp = false
+
+  -- Apply every committed edit to the base text and write it out. Returns
+  -- ok, message.
+  local function export()
+    local n = 0
+    for _ in pairs(edits) do n = n + 1 end
+    local text, report = Config.apply(baseText, edits)
+
+    -- Parsed back and checked against what was committed BEFORE it replaces
+    -- anything. A config file this tool has made unloadable would take the
+    -- authored spot away from every map in the game, and the failure would
+    -- turn up as battles quietly happening somewhere else.
+    local list, why = Config.verify(text, edits, OUT)
+    if not list then return false, why end
+
+    if IN_PLACE and not backedUp then
+      if writeFile(DATA .. ".bak", baseText) then
+        U.log("kept the original at " .. DATA .. ".bak")
+      end
+      backedUp = true
+    end
+    if not writeFile(OUT, text) then
+      return false, "could not open " .. OUT .. " for writing"
+    end
+
+    -- the written file becomes the base, so the next export edits the text it
+    -- just produced and every edit is applied exactly once
+    baseText, base, edits = text, list, {}
+
+    if #report.added > 0 then
+      U.log("ADDED  " .. table.concat(report.added, ", "))
+    end
+    if #report.removed > 0 then
+      U.log("REMOVED  " .. table.concat(report.removed, ", "))
+    end
+    if #report.stale > 0 then
+      U.log("NOTE   these entries had comments above them that describe the "
+            .. "spot they USED to be: " .. table.concat(report.stale, ", "))
+    end
+    return true, ("wrote %s (%d %s)"):format(OUT, n, n == 1 and "map" or "maps")
+  end
+
+  -- ------- the editor's state
+
+  local S = {
+    i = 1,                  -- which map, into ids
+    map = nil,              -- the live Map for it
+    pick = nil,             -- the working spot: a table, false, or nil
+    fits = false,           -- does its footprint lie on open ground
+    clear = false,          -- would both mons be seen from the battle camera
+    arena = nil,            -- the arena record the two above were judged on
+    host = nil,             -- the map that arena stands on (a sibling floor)
+    cands = nil,            -- the last scan
+    ci = 0,
+    surf = false,           -- water counts as ground for the search
+    mode = "edit",
+    msg = "",
+    legend = true,
+    planMode = 1,           -- the map's own tiles, with the walk grid over
+    tiles = nil,            -- that map, baked from its own tileset
+    shooting = false,       -- the overlay stands aside for a screenshot
+    quit = false,
+    done = {},              -- ids committed at some point this session
+    doneN = 0,
+    overrideOn = nil,       -- the map whose entry we have forced, if any
+  }
+
+  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
+
+  local hosts = {}
+  local function hostMap(id)
+    if id == nil or (S.map and id == S.map.id) then return S.map end
+    if hosts[id] ~= nil then return hosts[id] or nil end
+    local ok, m = pcall(MapLoader.load, game.data, id)
+    hosts[id] = (ok and m) or false
+    return hosts[id] or nil
+  end
+
+  local function shapeFor(id)
+    for _, s in ipairs(Arena.SHAPES) do
+      if s.id == (id or "wide") then return s end
+    end
+    return Arena.SHAPES[1]
+  end
+
+  -- Does this spot's whole footprint lie on ground an arena may be laid on?
+  --
+  -- Water counts, whatever the player is doing, because that is what
+  -- BattleArena.find does for an AUTHORED entry: the surfing test exists to
+  -- stop the automatic search staging a walker's fight out at sea, and an
+  -- entry chosen by a person who is looking at it is not that.
+  -- the footprint a shape covers at this turn: a quarter turn swaps how far
+  -- it reaches each way, which is the whole reason a corridor takes one
+  -- orientation and refuses the other
+  local function extentOf(shapeId, turn)
+    return Arena.extent(shapeFor(shapeId), turn)
+  end
+
+  local function footprintFits(host, x, y, shapeId, turn)
+    local w, h = extentOf(shapeId, turn)
+    for cy = y, y + h - 1 do
+      for cx = x, x + w - 1 do
+        if not Arena.openCell(host, cx, cy, true) then return false end
+      end
+    end
+    return true
+  end
+
+  -- Re-judge the working pick and hand the same answer to the mod, so a TEST
+  -- stages exactly what is on screen rather than whatever the search would
+  -- have found from the player's cell.
+  local function evaluate()
+    S.fits, S.clear, S.arena, S.host = false, false, nil, nil
+    -- The working spot is handed to the mod as this map's authored entry, so
+    -- a TEST stages what is on screen. Any map we forced earlier is let go
+    -- first: an override outlives the map it was set on, and one left behind
+    -- would quietly stage some other area's fight on a spot that was never
+    -- committed.
+    if S.overrideOn and S.overrideOn ~= mapId() then
+      Arena.setOverride(S.overrideOn, nil)
+    end
+    S.overrideOn = mapId()
+    Arena.setOverride(mapId(), S.pick)
+    local p = S.pick
+    if type(p) ~= "table" or not S.map then return end
+    local host = hostMap(p.map)
+    if not host then
+      say("host map %s could not be loaded", tostring(p.map))
+      return
+    end
+    S.host = host
+    if not footprintFits(host, p.x, p.y, p.shape, p.turn) then return end
+    S.fits = true
+    local arena = Arena.at(p.x, p.y, p.shape, p.turn)
+    if not arena then return end
+    arena.map, arena.cam = host, p.cam
+    S.arena = arena
+    local ok, clear = pcall(Arena.clearance, host, arena)
+    S.clear = (ok and clear) and true or false
+  end
+
+  local function setPick(entry, why)
+    S.pick = entry
+    evaluate()
+    if why then say(why) end
+  end
+
+  -- Put the player on the near mon's cell, so the free-roam camera is looking
+  -- at the spot being edited and the meshes around it are the ones built.
+  -- Written the way OverworldController's own placement writes it -- cell,
+  -- pixel and the half-finished step alike -- because setting the cell alone
+  -- leaves the body mid-stride toward wherever it was walking.
+  local function standOnPick()
+    local p, ow = S.pick, game.overworld
+    if type(p) ~= "table" or not (ow and ow.player) then return end
+    if S.host and S.map and S.host.id ~= S.map.id then return end
+    -- through the placed record rather than the shape's own offsets, so the
+    -- near mon's cell is the turned one
+    local arena = Arena.at(p.x, p.y, p.shape, p.turn)
+    if not arena then return end
+    local cx, cy = arena.playerCell[1], arena.playerCell[2]
+    if not S.map:inBounds(cx, cy) then return end
+    local player = ow.player
+    player.cellX, player.cellY = cx, cy
+    player.px, player.py = cx * 16, cy * 16
+    player.moving = false
+    player.targetX, player.targetY = nil, nil
+    player.facing = "up"
+  end
+
+  -- ------- what the map looks like from above
+  --
+  -- THE MAP'S OWN ART, first: the plan is the surface a spot is chosen on,
+  -- and a grid of coloured squares is a map of the walk grid rather than of
+  -- the place. What tells you the fight will be staged in the clearing north
+  -- of the ledge, or on the jetty rather than the sand, is the tiles -- the
+  -- game's own, extracted from the cartridge, drawn from the same atlas and
+  -- through the same shade-remap shader the flat 2D world uses.
+  --
+  -- Baked ONCE per map into a canvas rather than drawn per frame: the tile
+  -- layer is a static SpriteBatch over the whole body, and asking the engine
+  -- for it sixty times a second would refill that batch against a window the
+  -- game is also using. Baked during UPDATE, never inside a draw, because it
+  -- binds a canvas of its own -- the same reason the battle's scene pass
+  -- lives on the update hook.
+  local PLAN_MAX = 2048     -- the largest bake, in pixels on the long side
+
+  -- The four colours this map's ground is drawn in: its own SGB palette,
+  -- looked up exactly as the overworld looks it up, so a cave is a cave's
+  -- grey and Viridian Forest is its own green. Falls back to the boot ROM's
+  -- background palette, which is what the engine falls back to.
+  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
+    -- linear, deliberately, and the one place in this mod where that is
+    -- right: the plan is this art MINIFIED to a panel, where nearest
+    -- sampling throws away most of the tiles and keeps a moire of whichever
+    -- texels the ratio happens to land on
+    pcall(canvas.setFilter, canvas, "linear", "linear")
+
+    local g = love.graphics
+    local prev = g.getCanvas()
+    -- the true-colour marks a RED++ atlas registers are for the frame the
+    -- engine is composing, not for a canvas baked outside one
+    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)
+      -- the atlas is the Game Boy's four greys; this is the pass that turns
+      -- them into the map's colours, and it is the engine's own
+      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
+
+  -- And the walk grid over it, one class per cell, built once with it. The
+  -- art says what the place looks like; this says where an arena may legally
+  -- go, which is not visible in the art -- a ledge lip and the path beside it
+  -- are the same three pixels of grass.
+  --
+  -- How much of it is laid over the art is the `g` key. Declared up here
+  -- rather than beside the drawing that reads it: a local declared further
+  -- down the chunk would leave the key handler above reading a GLOBAL of the
+  -- same name, which is nil forever and takes the key silently with it.
+  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 autoPick(quiet)
+    if not S.map then return nil end
+    local def = game.data.maps[mapId()]
+    -- def.width/height are in BLOCKS and a block is two cells, so the map's
+    -- middle in cells is the width itself
+    local ox, oy = math.floor(def.width), math.floor(def.height)
+    if S.surf then
+      -- A surf route's land is a rim of beach around the edge of the map, so
+      -- a search from the middle always lands on the rim and the fight is
+      -- staged on sand at the corner of a sea. Starting from the middle of
+      -- the BIGGEST body of water puts it out in the open instead -- biggest,
+      -- not all of it at once, because a map with a lake and an ocean has a
+      -- centroid between them that is on neither.
+      local m = S.map
+      local w, h = m.widthCells, m.heightCells
+      local seen, best = {}, nil
+      for y0 = 0, h - 1 do
+        for x0 = 0, w - 1 do
+          if not seen[y0 * w + x0] and m:isWaterCell(x0, y0) then
+            local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0
+            seen[y0 * w + x0] = true
+            while #stack > 0 do
+              local cell = table.remove(stack)
+              local cx, cy = cell[1], cell[2]
+              n, sx, sy = n + 1, sx + cx, sy + cy
+              for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
+                local nx, ny = cx + d[1], cy + d[2]
+                local key = ny * w + nx
+                if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key]
+                   and m:isWaterCell(nx, ny) then
+                  seen[key] = true
+                  stack[#stack + 1] = { nx, ny }
+                end
+              end
+            end
+            if not best or n > best.n then
+              best = { n = n, x = sx / n, y = sy / n }
+            end
+          end
+        end
+      end
+      if best then ox, oy = best.x, best.y end
+    end
+    local found = Arena.search(S.map, ox, oy, S.surf, true)
+    local why = "clear"
+    if not found then
+      found = Arena.search(S.map, ox, oy, S.surf)
+      -- The shipped search asks about the DEFAULT lens only, which is what
+      -- makes it say this about rooms it cannot stand back from. `f` asks
+      -- about both, so it is the thing to reach for next rather than a
+      -- conclusion that the map has nowhere to fight.
+      why = "OBSTRUCTED on the long lens -- try f, which also tries the wide one"
+    end
+    if not found then
+      if not quiet then say("no arena of either shape fits on this map") end
+      return nil
+    end
+    if not quiet then
+      say("automatic pick, %s", why)
+    end
+    return { x = found.x, y = found.y, shape = found.shape }
+  end
+
+  local function enterMap(index)
+    S.i = ((index - 1) % #ids) + 1
+    local id = mapId()
+    S.cands, S.ci = nil, 0
+    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 entry this map already has wins; a map with none gets the search's
+    -- answer as a starting point, uncommitted
+    local entry = edits[id]
+    if entry == REMOVE then entry = nil end
+    if entry == nil then entry = base[id] end
+    if entry == nil then
+      setPick(autoPick(true), nil)
+      say("%s: no entry yet -- this is the automatic pick", id)
+    else
+      if entry == false then
+        setPick(false, nil)
+        say("%s: the config refuses this map", id)
+      else
+        setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide",
+                  turn = entry.turn, cam = entry.cam, map = entry.map }, nil)
+        say("%s: from the config", id)
+      end
+    end
+    standOnPick()
+  end
+
+  -- ------- the frame
+  --
+  -- The driver harness turns the frame cap off (main.lua: a scripted run is
+  -- not paced), so each step is held to a 60th of a second by hand. Without
+  -- it the game's logic runs at the refresh rate of whatever monitor this is
+  -- on, and a battle intro on a 165Hz panel is over before it can be read.
+  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
+
+  -- `withHud` keeps the overlay in the shot, which is how a spot is filed
+  -- with its own plan and verdict beside it; the plain shot is the picture a
+  -- player would get and is the one to judge by.
+  local function shoot(name, withHud)
+    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()
+      -- the panel gets the file name and the console the whole path: one is
+      -- read at a glance and the other is pasted into something
+      say("shot %s.png", name)
+      U.log("SHOT   " .. path)
+    else
+      say("screenshot did not reach disk: %s", path)
+    end
+  end
+
+  -- The spot IS the filename, so a directory of these is a contact sheet of
+  -- decisions rather than a pile of numbered frames -- and the mode is in it
+  -- too, or the shot of the staged fight lands on top of the shot of the
+  -- ground it was staged on.
+  local function shotName(kind)
+    local p = S.pick
+    local where = mapId():lower()
+    if type(p) == "table" then
+      where = ("%s_x%d_y%d_%s"):format(where, p.x, p.y, p.shape or "wide")
+      -- the turn is part of the spot's identity, or the same corner shot two
+      -- ways round writes one file twice and the comparison is impossible
+      if (p.turn or 0) ~= 0 then where = ("%s_t%d"):format(where, p.turn) end
+    end
+    return where .. "_" .. (kind or (S.mode == "test" and "battle" or "plan"))
+  end
+
+  -- ------- TEST: a real fight on the spot
+  --
+  -- Staged through the mod's own path -- the override is already installed by
+  -- evaluate(), the overworld's own pushBattle is what starts it -- so what
+  -- appears is a battle, not a mock of one. Judging a spot against anything
+  -- less is how a spot that reads on a plan and hides a Pokemon in the game
+  -- gets committed.
+  local function beginTest()
+    if type(S.pick) ~= "table" then
+      say("nothing to stage here")
+      return
+    end
+    if not S.fits then
+      say("that footprint is not on open ground -- nothing would stage")
+      return
+    end
+    standOnPick()
+    say("staging...")
+    -- let the terrain around the arena finish building before the shot needs
+    -- it, so the first battle frame is not the flat fallback
+    for _ = 1, 120 do
+      if (ChunkMesher.pending and ChunkMesher.pending() or 0) == 0 then break end
+      step(1)
+    end
+    step(20)
+    local battle = BattleState.newWild(game, SPECIES, LEVEL)
+    -- the fight is a fixture, so it must not run the real one's aftermath
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+    S.mode = "test"
+    -- Drive it to the MENU. Anything earlier is the intro, where the player's
+    -- side legitimately shows the trainer's back and there is no Pokemon on
+    -- the field at all -- which looks exactly like a broken arena and is not.
+    for _ = 1, 200 do
+      if battle.phase == "menu" then break end
+      U.tap(game, "a")
+      step(6)
+    end
+    local staged = Battles.arena()
+    if staged then
+      say("TEST at %d,%d %s -- t or escape to come back", staged.x, staged.y,
+          staged.shape)
+    else
+      say("TEST: nothing staged, this is the flat battle screen")
+    end
+    U.log(("TEST   %s phase=%s staged=%s"):format(mapId(),
+          tostring(battle.phase), staged and "yes" or "no"))
+  end
+
+  local function endTest()
+    U.log("TEST   left")
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    step(8)
+    S.mode = "edit"
+    standOnPick()
+    say("back in the editor")
+  end
+
+  -- ------- the scan
+  --
+  -- Every arena that fits anywhere on this map, clear ones first. The
+  -- clearance test is what makes the list worth stepping through: an
+  -- obstructed spot is not a candidate however good the ground under it
+  -- looks, and half the ground on a route is obstructed from a camera this
+  -- low.
+  local function scan()
+    local m = S.map
+    if not m then return end
+    local margin = MARGIN
+    if not margin then
+      -- outdoors the outermost cells are the CONNECTION BORDER, the strip the
+      -- neighbouring map is drawn into: ground there passes every test and is
+      -- still the wrong answer, because a fight staged on it happens at the
+      -- edge of the world. Indoors there is no such strip and the walls are
+      -- already off the table, so nothing is kept back.
+      margin = Map.isOutdoor(m.def) and 2 or 0
+    end
+    local list = {}
+    local mx = (m.widthCells - 1) / 2
+    local my = (m.heightCells - 1) / 2
+    -- Both footprints, because a quarter turn is what lets the 3x6 shape into
+    -- an east-west corridor at all: turns 0 and 2 cover the same ground and
+    -- so do 1 and 3, so the GROUND is walked twice and the four ways round
+    -- are asked about each patch that takes one.
+    for _, shape in ipairs(Arena.SHAPES) do
+      for _, lie in ipairs({ 0, 90 }) do
+        local fw, fh = Arena.extent(shape, lie)
+        -- the square footprint the narrow shape never has, and the wide one
+        -- never has either -- so nothing is scanned twice
+        local twice = (fw == shape.w and fh == shape.h and lie ~= 0)
+        for y = margin, m.heightCells - fh - margin do
+          for x = margin, m.widthCells - fw - margin do
+            local fits = not twice
+            if fits then
+              for cy = y, y + fh - 1 do
+                for cx = x, x + fw - 1 do
+                  if not Arena.openCell(m, cx, cy, S.surf) then
+                    fits = false
+                    break
+                  end
+                end
+                if not fits then break end
+              end
+            end
+            if fits then
+              -- Judged on both TURNS that share this footprint and on both
+              -- LENSES, because which way round a fight stands and what it is
+              -- shot on are part of the answer, not separate questions asked
+              -- afterwards.
+              --
+              -- The lens matters most indoors: the long default cannot stand
+              -- back from a small room -- its eye lands outside the walls, and
+              -- on an indoor map that is inside the border ring this mode
+              -- extrudes into a cliff -- so a gym where nothing is clear on
+              -- the tele lens can be entirely clear on the 44-degree one. That
+              -- is what `cam = "wide"` is for on the three gyms in the shipped
+              -- file, and an editor that only asked about the default lens
+              -- would report those maps as having nowhere to fight.
+              --
+              -- The turn matters most against a wall: the two ends of the same
+              -- axis put the camera on opposite sides of the pair, and one of
+              -- them is often looking into the rock the other has behind it.
+              --
+              -- First combination that reads wins, in preference order, so the
+              -- common case -- clear, as it lies, on the default lens -- costs
+              -- exactly one clearance test.
+              local clear, cam, turn = false, nil, lie
+              for _, t in ipairs({ lie, lie + 180 }) do
+                for _, lens in ipairs({ false, "wide" }) do
+                  local a = Arena.at(x, y, shape.id, t)
+                  a.cam = lens or nil
+                  local ok, got = pcall(Arena.clearance, m, a)
+                  if ok and got then
+                    clear, cam, turn = true, lens or nil, t
+                    break
+                  end
+                end
+                if clear then break end
+              end
+              local dx = x + (fw - 1) / 2 - mx
+              local dy = y + (fh - 1) / 2 - my
+              list[#list + 1] = { x = x, y = y, shape = shape.id, turn = turn,
+                                  clear = clear, cam = cam,
+                                  d = dx * dx + dy * dy }
+            end
+          end
+          say("scanning %s%s... %d found", shape.id,
+              lie ~= 0 and " (turned)" or "", #list)
+          step(1)
+        end
+      end
+    end
+    -- Clear before obstructed; among the clear, the default lens before the
+    -- one that had to be widened to get there; then the wide arena shape
+    -- before the narrow one, since that is the shot this mode is framed for;
+    -- and within all that, nearest to the middle of the map -- which is the
+    -- order arena_pick's own answer comes first in, so `f` then `n` walks
+    -- outward from the spot the batch tool would have chosen.
+    table.sort(list, function(a, b)
+      if a.clear ~= b.clear then return a.clear end
+      if (a.cam ~= nil) ~= (b.cam ~= nil) then return a.cam == nil end
+      if a.shape ~= b.shape then return a.shape == "wide" end
+      -- and the way round the mode was drawn for before the three that had
+      -- to be turned to get there
+      if (a.turn or 0) ~= (b.turn or 0) then
+        return (a.turn or 0) < (b.turn or 0)
+      end
+      return a.d < b.d
+    end)
+    local clearN, wideN = 0, 0
+    for _, c in ipairs(list) do
+      if c.clear then
+        clearN = clearN + 1
+        if c.cam then wideN = wideN + 1 end
+      end
+    end
+    S.cands, S.ci = list, 0
+    S.candClear, S.candWide = clearN, wideN
+    if #list == 0 then
+      say("nothing fits anywhere on this map")
+    else
+      say("%d spots fit, %d of them clear%s -- n and p step through them",
+          #list, clearN,
+          wideN > 0 and (" (%d only on the wide lens)"):format(wideN) or "")
+    end
+  end
+
+  local function stepCandidate(dir)
+    if not S.cands or #S.cands == 0 then
+      say("scan the map first (f)")
+      return
+    end
+    S.ci = ((S.ci + dir - 1) % #S.cands + #S.cands) % #S.cands + 1
+    local c = S.cands[S.ci]
+    local keep = type(S.pick) == "table" and S.pick or {}
+    -- the turn and the lens come WITH the candidate: the scan reached its
+    -- verdict about one particular way round on one particular lens, and
+    -- landing on the spot with either of the others selected would show a
+    -- verdict that was never about this shot
+    setPick({ x = c.x, y = c.y, shape = c.shape, turn = c.turn,
+              cam = c.clear and c.cam or keep.cam, map = keep.map })
+    standOnPick()
+    say("candidate %d/%d  %s%s%s", S.ci, #S.cands,
+        c.clear and "clear" or "OBSTRUCTED",
+        (c.turn or 0) ~= 0 and (" turned %d"):format(c.turn) or "",
+        c.cam and " on the wide lens" or "")
+  end
+
+  -- ------- committing
+
+  local function commit(entry, advance)
+    local id = mapId()
+    local was = base[id]
+    if entry == nil then
+      -- there is no spot to commit. Saying so is the honest answer; a map
+      -- with nowhere a fight can be seen is committed with backspace, which
+      -- writes a refusal the mod reads rather than silence it has to guess at
+      say("no spot here to commit -- backspace commits a refusal instead")
+      return
+    end
+    if not S.done[id] then
+      S.done[id] = true
+      S.doneN = S.doneN + 1
+    end
+    if entry == REMOVE then
+      if was == nil then
+        edits[id] = nil
+        say("%s has no entry to remove", id)
+        return
+      end
+      edits[id] = REMOVE
+      U.log(("REMOVE %s -- battle time falls back to the search"):format(id))
+    elseif entry == false then
+      edits[id] = false
+      U.log(("PICK   [%q] = false,"):format(id))
+    else
+      if sameEntry(entry, was) then
+        edits[id] = nil
+        say("%s already says exactly that", id)
+        if advance then post("map", 1) end
+        return
+      end
+      edits[id] = { x = entry.x, y = entry.y, shape = entry.shape or "wide",
+                    turn = ((entry.turn or 0) ~= 0) and entry.turn or nil,
+                    cam = entry.cam, map = entry.map }
+      U.log(("PICK   [%q] = { %sx = %d, y = %d, shape = %q%s%s },%s")
+            :format(id, entry.map and ("map = %q, "):format(entry.map) or "",
+                    entry.x, entry.y, entry.shape or "wide",
+                    ((entry.turn or 0) ~= 0)
+                      and (", turn = %d"):format(entry.turn) or "",
+                    entry.cam and (", cam = %q"):format(entry.cam) or "",
+                    S.clear and "" or "   -- OBSTRUCTED"))
+    end
+    local ok, msg = export()
+    if ok then
+      say("committed %s -- %s", id, msg)
+    else
+      say("COMMITTED BUT NOT WRITTEN: %s", msg)
+      U.log("EXPORT FAILED: " .. msg)
+    end
+    if advance then post("map", 1) end
+  end
+
+  -- ------- the keys
+  --
+  -- Wrapped around love.keypressed rather than polled, so a press is taken
+  -- INSTEAD of reaching the game rather than as well as -- an arrow key that
+  -- moved the arena and walked the player would fight itself. Only the keys
+  -- below are claimed; everything else falls through, which is what leaves
+  -- the mod's own hotkeys (the rung, the water, the wireframe) working while
+  -- the editor is up, and the whole keyboard working inside a TEST.
+  local function shifted()
+    return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")
+  end
+
+  -- Keep the corner on the map, measured against the footprint as it
+  -- currently lies -- a shape that has just been turned or swapped reaches a
+  -- different distance each way from the same corner.
+  local function clampPick()
+    local p = S.pick
+    if type(p) ~= "table" then return end
+    local fw, fh = extentOf(p.shape, p.turn)
+    local w = S.host and S.host.widthCells or (S.map and S.map.widthCells) or 0
+    local h = S.host and S.host.heightCells or (S.map and S.map.heightCells) or 0
+    p.x = math.max(0, math.min(w - fw, p.x))
+    p.y = math.max(0, math.min(h - fh, p.y))
+  end
+
+  local function nudge(dx, dy)
+    local p = S.pick
+    if type(p) ~= "table" then
+      setPick(autoPick(true), "no spot here yet -- took the automatic one")
+      return
+    end
+    local n = shifted() and 5 or 1
+    p.x, p.y = p.x + dx * n, p.y + dy * n
+    clampPick()
+    evaluate()
+    standOnPick()
+    say("%d,%d %s", p.x, p.y, p.shape or "wide")
+  end
+
+  -- A quarter turn of the whole staging: the footprint, both mons and the
+  -- camera together (see BattleArena's `turn`). The picture does not change --
+  -- the pair land on the same screen anchors at the same size -- so what this
+  -- is for is the GROUND: fitting a long arena down a corridor that runs the
+  -- other way, and swapping what is standing behind the pair.
+  local function rotate(dir)
+    local p = S.pick
+    if type(p) ~= "table" then return end
+    p.turn = (((p.turn or 0) + dir * 90) % 360 + 360) % 360
+    clampPick()
+    evaluate()
+    standOnPick()
+    local fw, fh = extentOf(p.shape, p.turn)
+    say("turned %d degrees -- %dx%d cells, foe to the %s", p.turn, fw, fh,
+        ({ [0] = "north", [90] = "east", [180] = "south",
+           [270] = "west" })[p.turn])
+  end
+
+  local function cycleShape()
+    local p = S.pick
+    if type(p) ~= "table" then return end
+    p.shape = (p.shape == "narrow") and "wide" or "narrow"
+    -- the wide shape is 3x6 and the narrow one 1x4, so a corner that was
+    -- legal for the smaller one can hang off the map with the bigger
+    clampPick()
+    evaluate()
+    standOnPick()
+    say("shape %s", p.shape)
+  end
+
+  local function cycleCam()
+    local p = S.pick
+    if type(p) ~= "table" then return end
+    p.cam = (p.cam == nil) and "wide" or nil
+    evaluate()
+    say("lens %s", p.cam or "tele (the long default)")
+  end
+
+  -- The floors of the same cave or building, for an entry that stages its
+  -- fight on one of them. Family is the id with its floor suffix taken off,
+  -- which is how these are actually named: POKEMON_TOWER_4F borrows
+  -- POKEMON_TOWER_3F, CERULEAN_CAVE_2F borrows CERULEAN_CAVE_B1F.
+  local function family(id)
+    return (id:gsub("_B?%d+F", ""))
+  end
+
+  local function cycleHost()
+    local p = S.pick
+    if type(p) ~= "table" then return end
+    local id = mapId()
+    local kin = { id }
+    for other in pairs(game.data.maps) do
+      if type(other) == "string" and other ~= id
+         and family(other) == family(id) then
+        kin[#kin + 1] = other
+      end
+    end
+    table.sort(kin)
+    if #kin == 1 then
+      say("%s has no sibling floors to stage on", id)
+      return
+    end
+    local at = 1
+    for k, other in ipairs(kin) do
+      if other == (p.map or id) then at = k end
+    end
+    local nextId = kin[(at % #kin) + 1]
+    p.map = (nextId ~= id) and nextId or nil
+    -- the corner is a coordinate on the HOST, so a floor change is a fresh
+    -- question about where on it -- clamp it in, then let the fit test speak
+    evaluate()
+    if S.host then
+      clampPick()
+      evaluate()
+    end
+    standOnPick()
+    say("staging on %s", p.map or "this map")
+  end
+
+  local function handle(key)
+    if S.mode == "test" then
+      if key == "t" or key == "escape" then post("endtest") return true end
+      if key == "y" then post("shot", shifted() and "hud" or nil) return true end
+      if key == "f1" then S.legend = not S.legend return true end
+      return false
+    end
+
+    if key == "up" then nudge(0, -1) return true end
+    if key == "down" then nudge(0, 1) return true end
+    if key == "left" then nudge(-1, 0) return true end
+    if key == "right" then nudge(1, 0) return true end
+    if key == "[" then post("map", -1) return true end
+    if key == "]" then post("map", 1) return true end
+    if key == "s" then cycleShape() return true end
+    if key == "r" then rotate(shifted() and -1 or 1) return true end
+    if key == "c" then cycleCam() return true end
+    if key == "h" then cycleHost() return true end
+    if key == "w" then
+      S.surf = not S.surf
+      say("the search %s water as ground",
+          S.surf and "counts" or "does not count")
+      return true
+    end
+    if key == "g" then
+      S.planMode = (S.planMode % #PLAN_MODES) + 1
+      say("plan: %s", PLAN_MODES[S.planMode])
+      return true
+    end
+    if key == "f" then post("scan") return true end
+    if key == "n" then stepCandidate(1) return true end
+    if key == "p" then stepCandidate(-1) return true end
+    if key == "a" then
+      local found = autoPick()
+      if found then
+        setPick(found)
+        standOnPick()
+      end
+      return true
+    end
+    -- `u` for undo, because `r` is rotate: the strongest convention on each
+    -- wins, and this one is only ever wanted after a change you regret
+    if key == "u" then
+      local id = mapId()
+      local entry = base[id]
+      edits[id] = nil
+      if entry == nil then
+        setPick(autoPick(true), "no entry in the config -- automatic pick")
+      elseif entry == false then
+        setPick(false, "the config refuses this map")
+      else
+        setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide",
+                  turn = entry.turn, cam = entry.cam, map = entry.map },
+                "back to the config")
+      end
+      standOnPick()
+      return true
+    end
+    if key == "t" then post("test") return true end
+    if key == "y" then post("shot", shifted() and "hud" or nil) return true end
+    if key == "return" or key == "kpenter" then
+      post("commit", shifted() and "advance" or "stay")
+      return true
+    end
+    if key == "backspace" then post("refuse") return true end
+    if key == "delete" then post("drop") return true end
+    if key == "e" then post("export") 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)
+    -- the unattended check drives handle() directly, so anything arriving
+    -- HERE during one is a real key from the window and worth naming
+    if SELFTEST then U.log("KEY    " .. tostring(key)) end
+    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 overlay
+  --
+  -- Drawn after the game's own frame, over it. The plan on the right is what
+  -- the spot is CHOSEN on -- open ground, grass, water, the walls -- and the
+  -- two lines drawn across it from the camera's eye are why a spot that looks
+  -- open gets rejected: they are the sightlines the clearance test walks, and
+  -- seeing them cross a building is the whole explanation.
+  local font = love.graphics.newFont(13)
+  local smallFont = love.graphics.newFont(11)
+
+  local function drawPlan(x, y, w, h)
+    local plan = S.plan
+    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
+    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]
+    -- the map itself, from its own tiles
+    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
+    -- and the walk grid over it: opaque with no art under it, and a wash
+    -- when there is -- enough to read where an arena may go without hiding
+    -- what it would be standing on. Open ground is never washed: it is what
+    -- the art is being read for.
+    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
+
+    -- The candidates, so a scan is a picture of where the map will take a
+    -- fight at all rather than a number to press n against.
+    --
+    -- One mark at each one's middle rather than its footprint filled in: on a
+    -- route most open ground is a candidate, and painting all of them lays a
+    -- sheet of colour over the very art the spot is being chosen from. A dot
+    -- says "here, and here" without hiding what is there.
+    if S.cands then
+      local r = math.max(1.5, cell * 0.35)
+      for _, c in ipairs(S.cands) do
+        local fw, fh = extentOf(c.shape, c.turn)
+        if c.clear then
+          -- green for the default lens, amber for one that only reads on the
+          -- wide one -- the difference is a decision, not a detail
+          if c.cam then g.setColor(1, 0.8, 0.2, 0.85)
+          else g.setColor(0.3, 1, 0.4, 0.85) end
+        else
+          g.setColor(1, 0.35, 0.2, 0.35)
+        end
+        g.circle("fill", ox + (c.x + fw / 2) * cell,
+                 oy + (c.y + fh / 2) * cell, r)
+      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 spot itself, on whichever map it is measured against
+    local p = S.pick
+    local onHost = not (S.host and S.map and S.host.id ~= S.map.id)
+    -- laid out through the placed record rather than from the shape's own
+    -- offsets, so a turned arena is drawn the way it will actually stand.
+    -- Built even when it does not fit -- a footprint half off the map is
+    -- exactly the thing you need to see to move it back on.
+    local shot = type(p) == "table" and Arena.at(p.x, p.y, p.shape, p.turn)
+    if shot and onHost then
+      g.setColor(S.fits and (S.clear and 0.2 or 1) or 1,
+                 S.fits and 1 or 0.3, S.fits and (S.clear and 1 or 0.2) or 0.3,
+                 0.9)
+      g.setLineWidth(2)
+      g.rectangle("line", ox + shot.x * cell, oy + shot.y * cell,
+                  shot.w * cell, shot.h * cell)
+      g.setLineWidth(1)
+      local ex, ey = shot.enemyCell[1], shot.enemyCell[2]
+      local px, py = shot.playerCell[1], shot.playerCell[2]
+      g.setColor(0.95, 0.35, 0.35, 1)
+      g.rectangle("fill", ox + ex * cell, oy + ey * cell, cell, cell)
+      g.setColor(0.4, 0.65, 1, 1)
+      g.rectangle("fill", ox + px * cell, oy + py * cell, cell, cell)
+
+      -- the camera, and what it is looking through
+      if S.arena then
+        local ok, rig = pcall(BattleCam.rig, S.arena, 0, true)
+        if ok and rig and rig.eye then
+          local eyeX = ox + (rig.eye[1] / 16) * cell
+          local eyeY = oy + (rig.eye[3] / 16) * cell
+          if S.clear then g.setColor(0.3, 1, 0.5, 0.85)
+          else g.setColor(1, 0.35, 0.3, 0.85) end
+          g.line(eyeX, eyeY, ox + (ex + 0.5) * cell, oy + (ey + 0.5) * cell)
+          g.line(eyeX, eyeY, ox + (px + 0.5) * cell, oy + (py + 0.5) * cell)
+          g.circle("fill", eyeX, eyeY, 4)
+        end
+      end
+    end
+    g.setColor(1, 1, 1, 1)
+  end
+
+  local LEGEND = {
+    "arrows  move the corner (shift x5)",
+    "[  ]    previous / next map",
+    "s       shape: wide / narrow",
+    "r       turn it 90 (shift: the other way)",
+    "c       lens: tele / wide",
+    "h       stage on a sibling floor",
+    "w       search over water",
+    "g       plan: tiles / walk grid / both",
+    "f       scan the map     n p  step",
+    "a       automatic pick   u    revert",
+    "t       TEST a real battle here",
+    "y       screenshot (shift: with this panel)",
+    "enter   commit   (shift: and next map)",
+    "bksp    commit a refusal",
+    "del     commit a removal",
+    "e       export      F1  this list",
+    "escape  quit",
+  }
+
+  local function drawOverlay()
+    local g = love.graphics
+    local W, H = g.getDimensions()
+
+    if S.mode == "test" then
+      g.setFont(font)
+      g.setColor(0, 0, 0, 0.65)
+      g.rectangle("fill", 0, 0, W, 26)
+      g.setColor(1, 0.85, 0.3, 1)
+      g.print(("TEST  %s  %s   [t] back   [y] shot")
+              :format(mapId(), S.msg), 8, 5)
+      g.setColor(1, 1, 1, 1)
+      return
+    end
+
+    local panel = math.min(340, W * 0.34)
+    g.setFont(font)
+    g.setColor(0, 0, 0, 0.68)
+    g.rectangle("fill", 0, 0, panel, H)
+
+    local p = S.pick
+    local id = mapId()
+    local mark = S.done[id] and " *" or ""
+    local lines = {}
+    local function add(fmt, ...)
+      lines[#lines + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
+    end
+    add("ARENA EDITOR   %d/%d%s", S.i, #ids, mark)
+    add("%s", id)
+    if S.map then
+      add("%d x %d cells", S.map.widthCells, S.map.heightCells)
+    end
+    add("")
+    if p == false then
+      add("REFUSAL -- no fight is staged here")
+    elseif type(p) ~= "table" then
+      add("no spot")
+    else
+      local fw, fh = extentOf(p.shape, p.turn)
+      add("x %d   y %d   %s  (%dx%d)", p.x, p.y, p.shape or "wide", fw, fh)
+      add("turn %d   foe to the %s", p.turn or 0,
+          ({ [0] = "north", [90] = "east", [180] = "south",
+             [270] = "west" })[p.turn or 0] or "?")
+      add("lens %s", p.cam or "tele")
+      add("host %s", p.map or "this map")
+      add("")
+      add("fits    %s", S.fits and "YES" or "NO -- not open ground")
+      add("clear   %s", S.clear and "YES"
+                        or (S.fits and "NO -- a mon is hidden" or "-"))
+    end
+    add("")
+    -- what the CONFIG says right now, which after a commit is what was just
+    -- written to it -- an export rebases on its own output, so "pending" is
+    -- never a state this can be left in
+    local held = edits[id]
+    if held == nil then held = base[id] end
+    if held == REMOVE or held == nil then
+      add("config  no entry (the search answers)")
+    elseif held == false then
+      add("config  refuses this map")
+    else
+      add("config  %d,%d %s%s%s%s", held.x, held.y, held.shape or "wide",
+          ((held.turn or 0) ~= 0) and (" turn=" .. held.turn) or "",
+          held.cam and (" cam=" .. held.cam) or "",
+          held.map and (" on " .. held.map) or "")
+    end
+    if not sameEntry(p, held) then
+      add("NOT COMMITTED -- enter writes it")
+    elseif S.done[id] then
+      add("committed this session")
+    else
+      add("unchanged")
+    end
+    if S.cands then
+      add("scan  %d fit, %d clear (%d need the wide lens)%s",
+          #S.cands, S.candClear or 0, S.candWide or 0,
+          S.ci > 0 and ("   at %d"):format(S.ci) or "")
+    end
+    add("water %s", S.surf and "counts as ground" or "is not searched")
+    add("done  %d of %d maps this session", S.doneN, #ids)
+    add("out   %s", OUT)
+
+    g.setColor(1, 1, 1, 1)
+    local y = 10
+    for _, l in ipairs(lines) do
+      g.print(l, 10, y)
+      y = y + 16
+    end
+
+    y = y + 6
+    g.setColor(1, 0.85, 0.35, 1)
+    g.setFont(smallFont)
+    local msg = S.msg or ""
+    local _, wrapped = smallFont:getWrap(msg, panel - 20)
+    g.printf(msg, 10, y, panel - 20)
+    y = y + 13 * math.max(1, #wrapped)
+
+    -- what the marks on the plan mean, in the marks themselves
+    if S.cands then
+      y = y + 8
+      local key = { { { 0.3, 1, 0.4 }, "clear" },
+                    { { 1, 0.8, 0.2 }, "wide lens only" },
+                    { { 1, 0.35, 0.2 }, "obstructed" } }
+      local kx = 14
+      for _, entry in ipairs(key) do
+        g.setColor(entry[1][1], entry[1][2], entry[1][3], 0.9)
+        g.circle("fill", kx, y + 5, 3.5)
+        g.setColor(0.8, 0.8, 0.85, 1)
+        g.print(entry[2], kx + 9, y - 1)
+        kx = kx + 12 + smallFont:getWidth(entry[2]) + 10
+      end
+      y = y + 16
+    end
+
+    if S.legend then
+      y = y + 10
+      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
+
+    -- the plan, in whatever is left of the window
+    local px = panel + 12
+    drawPlan(px, 12, W - px - 12, H - 24)
+    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("arena editor -- " .. #ids .. " maps")
+  for _, l in ipairs(LEGEND) do U.log("  " .. l) end
+  U.log("exports to " .. OUT)
+
+  enterMap(1)
+
+  local function dispatch(job)
+    if job.cmd == "map" then
+      enterMap(S.i + job.arg)
+    elseif job.cmd == "scan" then
+      scan()
+    elseif job.cmd == "test" then
+      beginTest()
+    elseif job.cmd == "endtest" then
+      endTest()
+    elseif job.cmd == "shot" then
+      local hud = job.arg == "hud"
+      shoot(shotName(hud and "sheet" or nil), hud)
+    elseif job.cmd == "commit" then
+      commit(S.pick, job.arg == "advance")
+    elseif job.cmd == "refuse" then
+      commit(false, false)
+    elseif job.cmd == "drop" then
+      commit(REMOVE, false)
+    elseif job.cmd == "export" then
+      local ok, msg = export()
+      say("%s", msg)
+      U.log((ok and "EXPORT " or "EXPORT FAILED ") .. msg)
+    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.
+  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
+
+  -- ------- ARENA_SELFTEST=1: the same session, with the keys pressed for you
+  --
+  -- Every command this tool has, driven through the SAME entry point the
+  -- keyboard drives -- so it exercises the real handler, the real staging and
+  -- the real export rather than a parallel script that can drift from them.
+  -- It is how the tool is checked after a change without a person sitting in
+  -- front of it, and it writes to its own file, never to the mod's.
+  if SELFTEST then
+    local function press(key, frames)
+      local ok, err = pcall(handle, key)
+      if not ok then U.log("SELFTEST key failed: " .. tostring(err)) end
+      pump(frames or 20)
+      U.log(("SELFTEST %-9s %s"):format(key, S.msg or ""))
+    end
+    press("f", 900)         -- scan this map
+    press("n")              -- and step through what it found
+    press("n")
+    press("s")              -- both shapes
+    press("s")
+    press("r")              -- all four ways round, and back to where it was
+    press("r")
+    press("r")
+    press("r")
+    press("right")          -- nudge, with and without shift
+    press("down")
+    press("c")              -- both lenses
+    press("c")
+    press("h")              -- a sibling floor, and back off it
+    press("h", 40)
+    press("a", 60)          -- the automatic pick
+    press("n", 60)          -- back onto the scan's best answer
+    -- and the rest of the run happens TURNED, so what gets photographed and
+    -- committed is a turned staging: the projection maths is proved in the
+    -- suite, but whether the whole pipeline behind it -- the shadow cast, the
+    -- billboards, the pinned pics -- still composes at a turn is the kind of
+    -- thing only a picture settles.
+    --
+    -- HALF a turn, twice, rather than a quarter: the candidate under us fits
+    -- the ground it was found on, and 180 keeps that footprint exactly while
+    -- moving the camera to the other end of it. A quarter turn swaps the
+    -- footprint's reach, so on a lane that is six cells one way and three the
+    -- other it lands on a wall -- which the tool rightly refuses to stage, and
+    -- which would leave this check photographing nothing.
+    press("r")
+    press("r", 60)
+    post("shot", "hud")     -- and one with the panel in it, which is the
+    pump(240)               -- only thing that proves the overlay draws
+    press("y", 240)         -- a screenshot of the free-roam view
+    press("g")              -- each way of drawing the plan
+    press("g")
+    press("g")
+    press("t", 1200)        -- a real battle on the spot
+    press("y", 240)         -- and one of that
+    press("t", 90)          -- out of the test, by the key that is not also
+                            -- the quit key -- escape is checked at the end
+    press("return", 120)    -- commit it, which exports
+    press("]", 200)         -- the next map
+    press("backspace", 120) -- commit a refusal there
+    press("delete", 120)    -- and take the entry out again
+    press("e", 60)          -- an explicit export
+    press("escape", 5)      -- and the way out
+    U.log("SELFTEST done -- " .. tostring(S.msg))
+    S.quit = true
+  end
+
+  while not S.quit do pump(1) end
+
+  -- Hand the install back the way it was found: the forced entry let go, the
+  -- baked plan released, the two wraps unwound and the camera rung put back.
+  -- The mod settings this tool moved were never persisted (see the top), so
+  -- there is nothing to undo there.
+  if S.overrideOn then Arena.setOverride(S.overrideOn, nil) end
+  if S.tiles then pcall(S.tiles.release, S.tiles) end
+  love.draw = innerDraw
+  love.keypressed = innerKey
+  pcall(Pipelines.setLevel, "voxel", wasRung)
+  U.log("done -- " .. OUT)
+end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index a1d46e7..ee96136 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -2909,6 +2909,109 @@ T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1,
 T.check(span(closeRig, shot.player) < span(rig, shot.player),
   "the mons render smaller on it, which is what it trades for fitting")
 
+-- ------- the quarter turns
+--
+-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
+-- the whole claim of the feature is that this is a fact about the GROUND and
+-- never about the shot: the footprint, the two mons and the camera turn
+-- together, so the pair land on the same two anchors at the same size and
+-- only what is behind them changes.
+--
+-- Asserted by REPROJECTING each turn through the real rig, exactly the way
+-- the unturned shot is checked above -- so a rig retune, or a sign error in
+-- the rotation, says so here rather than in a screenshot nobody takes.
+--
+-- In its own scope: the suite's main chunk sits at LuaJIT's 200-active-local
+-- ceiling, and every local below would be one more of them. The leading
+-- semicolon is the file's own convention -- without it the previous
+-- statement's `)` and this `(` parse as one call.
+;(function()
+  local corner = { shot.x, shot.y }
+  -- How wide a mon's own square comes out, measured ACROSS the arena's axis.
+  -- `span` above offsets along world X, which is across the axis only while
+  -- the arena is standing the way the mode was drawn; on the odd quarters the
+  -- axis IS X, so offsetting along it would measure the square's depth --
+  -- foreshortened by a camera that is looking almost straight down it -- and
+  -- report a shrinking mon that is nothing of the kind.
+  local function acrossSpan(cam, point, turn)
+    local d = (turn % 180 == 0) and { 8, 0 } or { 0, 8 }
+    local a = project(cam, { point[1] - d[1], point[2] - d[2] })
+    local b = project(cam, { point[1] + d[1], point[2] + d[2] })
+    return math.abs(b - a)
+  end
+  for _, turn in ipairs({ 0, 90, 180, 270 }) do
+    BattleCam.reset()
+    local a = BattleArena.at(corner[1], corner[2], "wide", turn)
+    T.check(a ~= nil, ("the wide arena places at turn %d"):format(turn))
+    T.eq(a.turn, turn, ("and carries the turn it was placed at (%d)"):format(turn))
+
+    -- the footprint swaps its reach on the odd quarters, which is the whole
+    -- reason a corridor takes one way round and refuses the other
+    local wantW = (turn % 180 == 0) and 3 or 6
+    local wantH = (turn % 180 == 0) and 6 or 3
+    T.eq(a.w, wantW, ("turn %d is %d cells across"):format(turn, wantW))
+    T.eq(a.h, wantH, ("and %d deep"):format(wantH))
+
+    -- both mons stay inside the footprint they were turned within, and stay
+    -- three cells apart -- the gap the move animations are scaled against
+    T.check(a.enemyCell[1] >= a.x and a.enemyCell[1] < a.x + a.w
+            and a.enemyCell[2] >= a.y and a.enemyCell[2] < a.y + a.h,
+      ("turn %d keeps the foe inside the footprint"):format(turn))
+    T.check(a.playerCell[1] >= a.x and a.playerCell[1] < a.x + a.w
+            and a.playerCell[2] >= a.y and a.playerCell[2] < a.y + a.h,
+      ("turn %d keeps the player inside it too"):format(turn))
+    local gap = math.abs(a.enemyCell[1] - a.playerCell[1])
+                + math.abs(a.enemyCell[2] - a.playerCell[2])
+    T.eq(gap, 3, ("turn %d still stands them three cells apart"):format(turn))
+
+    -- and which way the foe lies from the player, which is what `turn` NAMES
+    local dx = a.enemyCell[1] - a.playerCell[1]
+    local dy = a.enemyCell[2] - a.playerCell[2]
+    local want = ({ [0] = { 0, -3 }, [90] = { 3, 0 },
+                    [180] = { 0, 3 }, [270] = { -3, 0 } })[turn]
+    T.check(dx == want[1] and dy == want[2],
+      ("turn %d puts the foe %d,%d from the player: got %d,%d")
+      :format(turn, want[1], want[2], dx, dy))
+
+    -- THE INVARIANT: the same composition, whichever way round it stands
+    local r = BattleCam.rig(a, 0)
+    local ppx, ppy = project(r, a.player)
+    local eex, eey = project(r, a.enemy)
+    T.check(ppx ~= nil and eex ~= nil,
+      ("turn %d keeps both marks in front of the camera"):format(turn))
+    T.check(math.abs(ppx - 26) < 1 and math.abs(ppy - 96) < 1,
+      ("turn %d lands the player's mark on its anchor: (%.2f, %.2f)")
+      :format(turn, ppx, ppy))
+    T.check(math.abs(eex - 124) < 1 and math.abs(eey - 56) < 1,
+      ("turn %d lands the enemy's on its own: (%.2f, %.2f)")
+      :format(turn, eex, eey))
+    T.check(math.abs(acrossSpan(r, a.player, turn) - 64) < 4,
+      ("turn %d still makes the player's square a back pic wide (64px): got "
+       .. "%.2f"):format(turn, acrossSpan(r, a.player, turn)))
+    T.check(math.abs(acrossSpan(r, a.enemy, turn) - 56) < 4,
+      ("turn %d still makes the foe's square a front pic wide (56px): got "
+       .. "%.2f"):format(turn, acrossSpan(r, a.enemy, turn)))
+
+    -- the eye really did move: a turn that left the camera where it was would
+    -- pass every anchor test above by looking at the pair from the side
+    if turn ~= 0 then
+      local base = BattleArena.at(corner[1], corner[2], "wide", 0)
+      BattleCam.reset()
+      local r0 = BattleCam.rig(base, 0)
+      local moved = math.abs(r.eye[1] - r0.eye[1])
+                    + math.abs(r.eye[3] - r0.eye[3])
+      T.check(moved > 16,
+        ("turn %d actually moves the camera (%.1f world px)"):format(turn, moved))
+    end
+  end
+
+  -- degrees in, degrees out, and anything else folded onto the four
+  T.eq(BattleArena.quarters(360), 0, "a full turn is no turn")
+  T.eq(BattleArena.quarters(-90), 3, "and a negative one comes round the back")
+  T.eq(BattleArena.at(1, 1, "wide").turn, 0,
+    "an arena placed without a turn is the way the mode was drawn")
+end)()
+
 -- an arena picks its rig by name, and anything unnamed gets the default
 T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide,
   "an arena that asks for the wide lens gets it")
diff --git a/tests/stadium_all_shots.lua b/tests/stadium_all_shots.lua
new file mode 100644
index 0000000..1c09cc9
--- /dev/null
+++ b/tests/stadium_all_shots.lua
@@ -0,0 +1,157 @@
+-- Driver: one STADIUM screenshot per species, every one of the 151 standing
+-- on the ENEMY mark in its standby loop.
+--
+-- stadium_shots.lua photographs a handful of deliberately awkward cases; this
+-- is the exhaustive sweep behind it. What it exists to catch is the class of
+-- fault that is per-MODEL and invisible to the headless QA pass -- a texture
+-- that comes out magenta, a mon standing in the floor or floating over it, a
+-- silhouette that is subtly the wrong shape -- none of which is a number
+-- stadium_anim_qa.lua can measure.
+--
+--   SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/stadium_verify \
+--   POKEPORT_SPEED=4 \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_all_shots.lua \
+--   lovec.exe .
+--
+-- Files land as NNN_species.png in dex order. Run from the PROJECT ROOT.
+--
+-- ------- everything but the foe is nailed down
+--
+-- The whole value of 151 shots is that they are comparable, so the staging is
+-- identical in every one: same map, same cells, same hour (the day/night
+-- cycle would otherwise relight every shot), same rung, and the same small
+-- Pokemon on the player's mark -- DIGLETT, chosen because it is the shortest
+-- model in the set and so hides the least of the arena behind it. Whatever
+-- differs between two of these shots is the foe.
+--
+-- ------- the model/sprite verdict is the other half
+--
+-- A species whose pack will not load, or whose animation data the packer
+-- declined, does not error: StadiumMon.setSpecies answers false and the mode
+-- quietly stands the Game Boy's flat pic on the tile instead. That is the
+-- correct behaviour and it is nearly invisible in a screenshot at a glance --
+-- which is exactly why it needs counting rather than looking. Stadium.showing
+-- is asked for every species and the ones that decline are listed at the end.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".claude/stadium_verify"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if not lib then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+    return
+  end
+  local Battles = lib.require("OverworldBattle")
+  local Stadium = lib.require("Stadium")
+  local Pack = lib.require("StadiumPack")
+  local Install = lib.require("StadiumInstall")
+
+  -- every real species the merged data carries, in dex order
+  local species = {}
+  for id, def in pairs(game.data.pokemon) do
+    if type(id) == "string" and type(def) == "table"
+       and def.dex and def.dex >= 1 and def.dex <= 151 then
+      species[#species + 1] = { id = id, dex = def.dex }
+    end
+  end
+  table.sort(species, function(a, b) return a.dex < b.dex end)
+
+  game.save.player.name = "RED"
+
+  -- ONTO THE MAP FIRST: the model build (if one is owed) is pushed from the
+  -- mod's update hook on the first frame the overworld is the top state, so
+  -- waiting for it before teleporting waits forever.
+  U.teleport(game, "ROUTE_1", 5, 8, "down")
+
+  local waited = 0
+  while (Install.pending() or Install.status.state == "building")
+        and waited < 5400 do
+    U.wait(10)
+    waited = waited + 10
+  end
+  if waited > 0 then
+    U.log(("waited %.1fs for the model build -- %s")
+          :format(waited / 60, tostring(Install.status.state)))
+  end
+
+  Battles.setting:setValue("stadium", game)
+  Battles.backSetting:setValue(false, game)
+  lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
+
+  local have = 0
+  for dex = 1, 151 do
+    if Pack.available(dex) then have = have + 1 end
+  end
+  U.log(("%d species, %d/151 packs on disk, rung = %s")
+        :format(#species, have, tostring(Battles.setting:get())))
+
+  -- let the neighbourhood's meshes land, so the first fight opens on the
+  -- real arena rather than the flat fallback
+  U.wait(90)
+
+  local asPic, stuck, missed = {}, {}, {}
+
+  for _, s in ipairs(species) do
+    -- the player's side is a constant (see the header), so the only thing
+    -- that changes between two shots is the Pokemon being verified
+    game.save.party = { Pokemon.new(game.data, "DIGLETT", 40) }
+
+    local battle = BattleState.newWild(game, s.id, 30)
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+
+    -- the wipe and the intro chatter, then far enough past it that the
+    -- player's own Pokemon has been sent out -- before that the player's
+    -- side is legitimately the trainer's back sprite and the foe is still
+    -- sliding in
+    U.wait(70)
+    for _ = 1, 40 do
+      if battle.phase == "menu" then break end
+      U.tap(game, "a")
+      U.wait(10)
+    end
+    if battle.phase ~= "menu" then
+      stuck[#stuck + 1] = s.id
+      U.log(("STUCK before menu: %s (phase %s)")
+            :format(s.id, tostring(battle.phase)))
+    end
+    -- and let the send-out beat finish so the mon is standing still in its
+    -- standby loop rather than mid-arrival
+    U.wait(45)
+
+    -- model or flat pic? asked here, with the fight actually on screen
+    if not Stadium.showing("enemy") then
+      asPic[#asPic + 1] = ("%03d %s"):format(s.dex, s.id)
+    end
+
+    local path = ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())
+    if not U.shot(game, path) then
+      missed[#missed + 1] = s.id
+    end
+
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    U.wait(10)
+  end
+
+  U.log(("---- %d shots -> %s"):format(#species - #missed, DIR))
+  if #asPic > 0 then
+    U.log(("%d species declined to a model and were shot as FLAT PICS: %s")
+          :format(#asPic, table.concat(asPic, ", ")))
+  else
+    U.log("all species stood as MODELS -- none fell back to a flat pic")
+  end
+  if #stuck > 0 then
+    U.log(("%d never reached the battle menu: %s")
+          :format(#stuck, table.concat(stuck, ", ")))
+  end
+  if #missed > 0 then
+    U.log(("%d screenshots did not reach disk: %s")
+          :format(#missed, table.concat(missed, ", ")))
+  end
+  U.log("done -- " .. DIR)
+end