DramaticShapeVoxelMod archival mirror · read-only
9a34ba9c7777aec3450d7fff37db9965156ba00b

force 3d battle ui, initial victory road updates

DramaticShape · 2026-07-29 · parent 097bdcd14b

force 3d battle ui, initial victory road updates

Changed files

CHANGELOG.md                  |  26 ++++++
 README.md                     |  15 ++++
 data/voxel_heights.lua        | 303 +++++++++++++++++++++++++++++++++-----------------------------
 lib/BattleHud.lua             |  69 ++++++++++++--
 lib/OverworldBattle.lua       | 193 ++++++++++++++++++++++++++++++++++++++-
 lib/TileShape.lua             |   7 ++
 main.lua                      |  68 ++++++++++++--
 tests/dramatic_shape_test.lua | 139 ++++++++++++++++++++++++++++
 8 files changed, 664 insertions(+), 156 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5be5443..e72dee0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,32 @@
 
 ### Changed
 
+- **The two HP boxes snap to the window's edges during a staged battle.** The
+  battle screen is 160x144 in the middle of the window and the world is the
+  whole of it, which left both HUD blocks huddled together in the middle of the
+  frame with map showing on either side of them -- a Game Boy screenshot pasted
+  over a diorama rather than the diorama's own furniture. The foe's block now
+  sits against the left edge and the player's against the right, on the same
+  frosted glass, with the same tiles at the same size on the same rows. The
+  pokeball rows and the safari ball count travel with the block whose rows they
+  share. On a window shaped like the GB screen there is nowhere to go and
+  nothing moves.
+
+  The engine draws them into the 160x144 canvas, which clips at its own edges,
+  so the layer is rendered to a texture and composited into the world image --
+  the one surface here that covers the whole window. A driver that cannot do
+  that falls back to the HUD in the frame rather than to no HUD.
+
+- **`BATTLE LAYOUT` is pinned to `OG` while battles are staged on the map**, and
+  the row comes off the OPTIONS menu with the rows `FULL` owns. The staged shot
+  is composed in the GB's own frame -- the arena camera is solved to put a cell
+  under each pic's feet, and the HUD rects and the intercepted background fill
+  are measured there too -- and `WIDE` re-lays that screen out on a 304x144
+  surface, moving every one of them. Set rather than worked around, on every
+  route in: the options row, hotkey `8`, the mod manager's page, `FULL`'s
+  preset, and a save that arrived with `WIDE` already on. Switching `3D-BTL`
+  off hands the row back with `WIDE` selectable again.
+
 - **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is
   a display-mode cycler -- it should change the camera and nothing else --
   and `FULL` reaches in and rewrites four other settings. Landing on it
diff --git a/README.md b/README.md
index b85e313..d379068 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,21 @@ menu.
 **3D-BTL** is on by default and is independent of **VOXEL**: battles draw
 on the world whether or not the free-roam camera is pitched over.
 
+While a battle can be staged on the map — **3D-BTL** on, or **VOXEL** on
+`FULL`, which owns that row — the engine's **BATTLE LAYOUT** row is set to
+`OG` and taken off the OPTIONS menu. The staged shot is composed in the Game
+Boy's own 160x144 frame: the arena camera is solved to put a cell under each
+of the two pics' feet, and `WIDE` re-lays that screen out on a 304x144
+surface, which moves every anchor it is solved against. Switching **3D-BTL**
+off hands the row straight back with `WIDE` selectable again.
+
+The two HP boxes snap to the window's own edges while a battle is staged —
+the foe's to the left, yours to the right — instead of huddling in the middle
+of the frame with map showing either side of them. Same tiles, same size,
+same rows, on the same frosted glass; only the pair's position changes. On a
+window shaped like the GB screen there is nowhere to snap to and nothing
+moves.
+
 ## Where a battle is staged
 
 The mod looks for the nearest clearing shaped like this, where every `x` is
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 106406c..1c372d4 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -102,6 +102,9 @@ return {
     fence = 10,
     sign = 12,
     wall = 16,
+    -- masonry drawn two courses tall (the Indigo Plateau's rim, the
+    -- badge-check gates): as tall as a statue on its plinth
+    cliff = 32,
     tree = 16,
     roof = 28,
     bed = 7,
@@ -2258,6 +2261,165 @@ return {
       -- south-east).  Neither map places them; they belong with the
       -- wall band above if one ever does.
     },
+
+    -- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
+    -- forecourt the League itself stands on) and ROUTE_23 (the long
+    -- climb up to it, with its badge-check gates).  Everything this
+    -- blockset draws is one piece of architecture -- striated rock
+    -- walls, white pillars, and the bird STATUES that line both the
+    -- avenue and the plaza.  29 of its 73 blocks are never placed, so
+    -- every tile named below really is one of these two maps'.
+    --
+    -- A statue is built exactly like the badge gyms' (see GYM above):
+    -- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
+    -- PLINTH ($15/$16 the cap over $30/$31 the plaque).  47 of them --
+    -- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
+    -- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
+    -- stand the same statue on grass are never placed).
+    --
+    -- What the detector made of them is the bug this entry exists for.
+    -- On the avenue the statues stack with NO gap: the plinth's plaque
+    -- row is drawn directly above the next figure's head, so the
+    -- flood-fill joined all six of a column into ONE region 24 tile rows
+    -- tall, and the volume builder's repeat scan read 32px down one half
+    -- of the drawing and 24px down the other.  Each row came out as a
+    -- continuous stepped RIDGE of boxes wearing the statue art folded
+    -- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
+    -- at 32/24 and (22,12)-(23,35) mirrored.  ROUTE_23's plaza statues
+    -- stand alone and fared no better: 24px boxes with the figure's top
+    -- row skipped outright.
+    --
+    -- Pinned the gyms' way the ridge becomes statues.  The plinth is a
+    -- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
+    -- deep (the thin `prop` pool) that rides the plinth's top face
+    -- through the authored-box support rule and collapses to the
+    -- plinth's SINGLE cell of footprint -- Structures' wall-support case,
+    -- so the base never marches backwards.
+    --
+    -- The one thing the gyms did not have to deal with: $28/$29 is
+    -- SHARED.  The same bird is drawn again at the foot of every white
+    -- pillar, framed there by the pillar's black edge ($25/$26 over that
+    -- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
+    -- four on INDIGO_PLATEAU (its two outer corners and the pair
+    -- flanking the League's recess).  So $25/$26 joins the same pool:
+    -- pinning half a cell would have stood a half-height bird under a
+    -- wall.  That in turn is why the pillar itself is pinned -- see the
+    -- last paragraph of `wall`.
+    PLATEAU = {
+      -- ONE 16px course for every piece of masonry here.
+      --
+      -- $03 is the striated rock face, 2436 placements and the bulk of
+      -- both maps.  It already read 16 nearly everywhere, but in the
+      -- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
+      -- the repeat scan came out 24 -- a stagger in the plateau's
+      -- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
+      -- their two mirrors).  Authored, the rim is one course.
+      --
+      -- $0D/$0F/$0E are the League's outer wall -- top band, face and
+      -- base.  The same three tiles draw the Pokemon League's own
+      -- facade, the long walls flanking the avenue, and every
+      -- badge-check gate down ROUTE_23.
+      --
+      -- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
+      -- the plaque base.  $15/$16 over $30/$31 IS the statue's plinth --
+      -- the artist drew the same stone twice -- which is why one pin
+      -- serves the gate corners and the statues alike.
+      --
+      -- $2E/$2F the white pillar shaft and $20/$21 its cap change no
+      -- HEIGHT: they derive 16 already.  What the pin buys is
+      -- `authored`, which is exactly what the prop support rule tests.
+      -- Without it a pillar-foot bird finds no support, drops to ground
+      -- level, and leaves a hole punched clean through the pillar
+      -- (probed, shot, then fixed).
+      -- TWO courses (32px) for the masonry that ENCLOSES both maps -- the
+      -- plateau's rim and every wall around the terraces.  It is drawn two
+      -- cells tall, which is exactly the height of a statue on its plinth
+      -- (a 16px plinth under a 16px standee), and that is the read: you are
+      -- walking in a walled compound whose wall matches the statues lining
+      -- it, not a room with a 16px skirting.  At one course the rim was a
+      -- kerb you appeared to look over.
+      --
+      -- $03 the striated rock face, $0D/$0F/$0E the League's outer wall
+      -- (top band, face, base -- the same three tiles draw the League's
+      -- facade, the walls flanking the avenue and every badge-check gate
+      -- down ROUTE_23), and $2E/$2F the white pillar shaft with $20/$21 its
+      -- cap.  These four groups are the enclosure.
+      cliff = { 3,
+                13, 14, 15,
+                32, 33, 46, 47 },
+      -- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is
+      -- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31
+      -- IS the statue's plinth, the artist having drawn the same stone
+      -- twice.  Raising it would carry every statue standee up to 48px and
+      -- break the very match the cliff height was chosen for.
+      wall = { 21, 22, 48, 49, 5, 6 },
+      -- The statues, and the same bird at the pillar feet.  Black-outline
+      -- segmented: the outline and everything it encloses stay, the sky
+      -- and the paving around them flood away.
+      prop = { 16, 18, 40, 41, 37, 38 },
+      -- Round drawings, one voxel ball per 16x16 cell -- the treatment
+      -- the overworld's canopies and Celadon's hedge take.
+      --
+      -- $07/$08 over $17/$18: the seven canopies planted on pillar tops
+      -- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
+      -- them together is never placed).  Boxed, the canopy art smeared
+      -- down the whole pillar column beneath it.
+      --
+      -- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
+      -- middle terrace (blocks $02/$13/$16), one per cell and NOT
+      -- walkable.  As boxes they sat flat enough to look painted onto
+      -- the path; as balls they read as the obstacles they are.
+      cylinder = { 7, 8, 23, 24,
+                   29, 34, 42, 43 },
+      -- The Route 23 sign, one cell, block $48's only placement.
+      -- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
+      -- with its board skipped.  Same thin plate on a stick every other
+      -- outdoor sign gets.
+      signpost = { 9, 10, 25, 26 },
+      -- The Route 22 gate's roof, drawn from ABOVE and filling
+      -- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
+      -- edges, $40/$41 the corners).  Art on the TOP face -- the way
+      -- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
+      -- It stands past the map's last walkable row (you warp to
+      -- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
+      -- than a fix.
+      roof = { 61, 62, 64, 65, 68 },
+      -- The ground painted where a pinned figure's cell used to be:
+      -- $23, the pale paving both maps are floored with.  It is also the
+      -- white the pillars are drawn in, so the cell a pillar-foot bird
+      -- vacates reads as more pillar -- where the neighbour vote left a
+      -- BLACK hole, having nothing to elect (all four neighbours of that
+      -- cell are wall).  On the avenue and the plaza the statues' own
+      -- cells simply keep the paving they stand on.
+      prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
+                      [37] = 35, [38] = 35 },
+      -- Deliberately NOT pinned:
+      --
+      -- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
+      -- terrace -- and its bank shading $32/$33/$1F.  $14 and $32 are
+      -- two of the three stale-cache water ids the trap is named for,
+      -- but here they are honestly water: TILEANIM_WATER animates $14,
+      -- the pond is real, and $32/$33/$1F are drawn in the TOP half of
+      -- water cells as the waterline itself, so the cell rule dropping
+      -- them to -2 is what the art means.  Left to fall through to the
+      -- engine's water set.  ($48, the third trap id, is not in this
+      -- atlas at all -- it stops at $45.)
+      --
+      -- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
+      -- and Victory Road's two.  They are the tileset's own doorTiles,
+      -- so the door fold already stands them upright in the facade;
+      -- probed 16px identically before and after this entry.
+      --
+      -- $23/$2C/$2D are in the walkable list outright, and $45 is the
+      -- tileset's grassTile, which derives its own standing-tuft pin.
+      --
+      -- The Victory Road entrance is a `buildings` template
+      -- (victory_road_gate, at the bottom of this file): 36x6 tiles at
+      -- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
+      -- and $15/$16/$05/$06.  Buildings claim their tiles before any of
+      -- the above can reach them -- probed `b` class over all 216 of
+      -- them, unchanged by this entry.
+    },
   },
 
   -- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
@@ -3003,146 +3165,5 @@ return {
         slab = 4, frontEave = 4, ledge = nil,
       },
     },
-
-    -- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the
-    -- forecourt the League itself stands on) and ROUTE_23 (the long
-    -- climb up to it, with its badge-check gates).  Everything this
-    -- blockset draws is one piece of architecture -- striated rock
-    -- walls, white pillars, and the bird STATUES that line both the
-    -- avenue and the plaza.  29 of its 73 blocks are never placed, so
-    -- every tile named below really is one of these two maps'.
-    --
-    -- A statue is built exactly like the badge gyms' (see GYM above):
-    -- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of
-    -- PLINTH ($15/$16 the cap over $30/$31 the plaque).  47 of them --
-    -- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more
-    -- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that
-    -- stand the same statue on grass are never placed).
-    --
-    -- What the detector made of them is the bug this entry exists for.
-    -- On the avenue the statues stack with NO gap: the plinth's plaque
-    -- row is drawn directly above the next figure's head, so the
-    -- flood-fill joined all six of a column into ONE region 24 tile rows
-    -- tall, and the volume builder's repeat scan read 32px down one half
-    -- of the drawing and 24px down the other.  Each row came out as a
-    -- continuous stepped RIDGE of boxes wearing the statue art folded
-    -- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35)
-    -- at 32/24 and (22,12)-(23,35) mirrored.  ROUTE_23's plaza statues
-    -- stand alone and fared no better: 24px boxes with the figure's top
-    -- row skipped outright.
-    --
-    -- Pinned the gyms' way the ridge becomes statues.  The plinth is a
-    -- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels
-    -- deep (the thin `prop` pool) that rides the plinth's top face
-    -- through the authored-box support rule and collapses to the
-    -- plinth's SINGLE cell of footprint -- Structures' wall-support case,
-    -- so the base never marches backwards.
-    --
-    -- The one thing the gyms did not have to deal with: $28/$29 is
-    -- SHARED.  The same bird is drawn again at the foot of every white
-    -- pillar, framed there by the pillar's black edge ($25/$26 over that
-    -- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and
-    -- four on INDIGO_PLATEAU (its two outer corners and the pair
-    -- flanking the League's recess).  So $25/$26 joins the same pool:
-    -- pinning half a cell would have stood a half-height bird under a
-    -- wall.  That in turn is why the pillar itself is pinned -- see the
-    -- last paragraph of `wall`.
-    PLATEAU = {
-      -- ONE 16px course for every piece of masonry here.
-      --
-      -- $03 is the striated rock face, 2436 placements and the bulk of
-      -- both maps.  It already read 16 nearly everywhere, but in the
-      -- columns of INDIGO_PLATEAU's rim that stand over a corner pillar
-      -- the repeat scan came out 24 -- a stagger in the plateau's
-      -- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and
-      -- their two mirrors).  Authored, the rim is one course.
-      --
-      -- $0D/$0F/$0E are the League's outer wall -- top band, face and
-      -- base.  The same three tiles draw the Pokemon League's own
-      -- facade, the long walls flanking the avenue, and every
-      -- badge-check gate down ROUTE_23.
-      --
-      -- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and
-      -- the plaque base.  $15/$16 over $30/$31 IS the statue's plinth --
-      -- the artist drew the same stone twice -- which is why one pin
-      -- serves the gate corners and the statues alike.
-      --
-      -- $2E/$2F the white pillar shaft and $20/$21 its cap change no
-      -- HEIGHT: they derive 16 already.  What the pin buys is
-      -- `authored`, which is exactly what the prop support rule tests.
-      -- Without it a pillar-foot bird finds no support, drops to ground
-      -- level, and leaves a hole punched clean through the pillar
-      -- (probed, shot, then fixed).
-      wall = { 3,
-               13, 14, 15,
-               21, 22, 48, 49, 5, 6,
-               32, 33, 46, 47 },
-      -- The statues, and the same bird at the pillar feet.  Black-outline
-      -- segmented: the outline and everything it encloses stay, the sky
-      -- and the paving around them flood away.
-      prop = { 16, 18, 40, 41, 37, 38 },
-      -- Round drawings, one voxel ball per 16x16 cell -- the treatment
-      -- the overworld's canopies and Celadon's hedge take.
-      --
-      -- $07/$08 over $17/$18: the seven canopies planted on pillar tops
-      -- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of
-      -- them together is never placed).  Boxed, the canopy art smeared
-      -- down the whole pillar column beneath it.
-      --
-      -- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's
-      -- middle terrace (blocks $02/$13/$16), one per cell and NOT
-      -- walkable.  As boxes they sat flat enough to look painted onto
-      -- the path; as balls they read as the obstacles they are.
-      cylinder = { 7, 8, 23, 24,
-                   29, 34, 42, 43 },
-      -- The Route 23 sign, one cell, block $48's only placement.
-      -- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub
-      -- with its board skipped.  Same thin plate on a stick every other
-      -- outdoor sign gets.
-      signpost = { 9, 10, 25, 26 },
-      -- The Route 22 gate's roof, drawn from ABOVE and filling
-      -- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its
-      -- edges, $40/$41 the corners).  Art on the TOP face -- the way
-      -- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb.
-      -- It stands past the map's last walkable row (you warp to
-      -- ROUTE_22_GATE before you reach it), so this is a tidy-up rather
-      -- than a fix.
-      roof = { 61, 62, 64, 65, 68 },
-      -- The ground painted where a pinned figure's cell used to be:
-      -- $23, the pale paving both maps are floored with.  It is also the
-      -- white the pillars are drawn in, so the cell a pillar-foot bird
-      -- vacates reads as more pillar -- where the neighbour vote left a
-      -- BLACK hole, having nothing to elect (all four neighbours of that
-      -- cell are wall).  On the avenue and the plaza the statues' own
-      -- cells simply keep the paving they stand on.
-      prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35,
-                      [37] = 35, [38] = 35 },
-      -- Deliberately NOT pinned:
-      --
-      -- $14, the water -- 1446 placements, the pond on ROUTE_23's middle
-      -- terrace -- and its bank shading $32/$33/$1F.  $14 and $32 are
-      -- two of the three stale-cache water ids the trap is named for,
-      -- but here they are honestly water: TILEANIM_WATER animates $14,
-      -- the pond is real, and $32/$33/$1F are drawn in the TOP half of
-      -- water cells as the waterline itself, so the cell rule dropping
-      -- them to -2 is what the art means.  Left to fall through to the
-      -- engine's water set.  ($48, the third trap id, is not in this
-      -- atlas at all -- it stops at $45.)
-      --
-      -- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two
-      -- and Victory Road's two.  They are the tileset's own doorTiles,
-      -- so the door fold already stands them upright in the facade;
-      -- probed 16px identically before and after this entry.
-      --
-      -- $23/$2C/$2D are in the walkable list outright, and $45 is the
-      -- tileset's grassTile, which derives its own standing-tuft pin.
-      --
-      -- The Victory Road entrance is a `buildings` template
-      -- (victory_road_gate, at the bottom of this file): 36x6 tiles at
-      -- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29
-      -- and $15/$16/$05/$06.  Buildings claim their tiles before any of
-      -- the above can reach them -- probed `b` class over all 216 of
-      -- them, unchanged by this entry.
-    },
   },
 }
diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua
index f88f264..34432e7 100644
--- a/lib/BattleHud.lua
+++ b/lib/BattleHud.lua
@@ -201,6 +201,23 @@ local function frostRect(rect, box)
   return fx, fy, math.max(1, fw), math.max(1, fh)
 end
 
+-- The same map for a rect that is ALREADY in world-canvas pixels. A HUD
+-- snapped out to the window's edge has left the GB frame, so it has no GB
+-- coordinates to be placed from -- see OverworldBattle.snapRects.
+local function frostRectWorld(rect, box)
+  local kx = frostW / box.pw
+  local ky = frostH / box.ph
+  return rect[1] * kx, rect[2] * ky,
+         math.max(1, rect[3] * kx), math.max(1, rect[4] * ky)
+end
+
+-- Which of the two the caller's rects are in. One frost buffer, one panel
+-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI
+-- canvas) or world pixels (rects land in the window-resolution world image).
+local function mapper(world)
+  return world and frostRectWorld or frostRect
+end
+
 -- ------- the verdict
 --
 -- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
@@ -211,11 +228,12 @@ end
 -- the tint below then commits both panels to that reading.
 local wasDark = false
 
-function BattleHud.verdict(rects, box)
+function BattleHud.verdict(rects, box, world)
   if not (frost and box and box.scale and box.scale > 0) then return false end
+  local toFrost = mapper(world)
   local darkest = nil
   for key, rect in pairs(rects) do
-    local fx, fy, fw, fh = frostRect(rect, box)
+    local fx, fy, fw, fh = toFrost(rect, box)
     local v = sampleLuma(key, fx, fy, fw, fh)
     if v and (not darkest or v < darkest) then darkest = v end
   end
@@ -230,14 +248,16 @@ function BattleHud.verdict(rects, box)
   return wasDark
 end
 
--- Draw one HUD panel into the current target, in GB coordinates.
+-- Draw one HUD panel into the current target, in that target's own
+-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
+-- for a panel laid straight onto the world image.
 --
 -- The tint always pushes AWAY from the glyph colour that is about to be
 -- used, so the contrast is guaranteed rather than hoped for: a dark panel
 -- gets darker under white text, a bright one brighter under black text.
-function BattleHud.panel(rect, box, dark)
+function BattleHud.panel(rect, box, dark, world)
   if not (frost and box and box.scale and box.scale > 0) then return false end
-  local fx, fy, fw, fh = frostRect(rect, box)
+  local fx, fy, fw, fh = mapper(world)(rect, box)
   local ok = pcall(function()
     local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
     love.graphics.setColor(1, 1, 1, BattleHud.FROST)
@@ -330,6 +350,44 @@ function BattleHud.flipGlyphs(w, h, fn)
   love.graphics.setShader()
 end
 
+-- ------- the whole HUD layer as a texture
+--
+-- The two blocks do not sit in the same place any more: each is snapped to its
+-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws
+-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two
+-- places at once, so the layer is rendered ONCE into a GB-sized canvas and
+-- each block is then blitted out of it as a quad.
+--
+-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked
+-- into the texture rather than composited into the caller's target -- the world
+-- image the quads land on is a colour canvas, and a flip pass over it would
+-- whiten the terrain behind the glyphs along with them.
+local hudLayer = nil
+
+function BattleHud.layerTexture(w, h, dark, fn)
+  if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then
+    hudLayer = canvasOf(w, h, "nearest")
+    if not hudLayer then return nil end
+  end
+  local g = love.graphics
+  local prevCanvas = g.getCanvas()
+  local prevBlend, prevAlpha = g.getBlendMode()
+  local ok, err = pcall(function()
+    g.setCanvas(hudLayer)
+    g.clear(0, 0, 0, 0)
+    g.setBlendMode("alpha")
+    g.setColor(1, 1, 1, 1)
+    -- flipGlyphs renders fn into its own scratch layer and composites the
+    -- whitened result into whatever is bound, which is this canvas
+    if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end
+  end)
+  if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
+  g.setBlendMode(prevBlend or "alpha", prevAlpha)
+  g.setColor(1, 1, 1, 1)
+  if not ok then error(err, 0) end
+  return hudLayer
+end
+
 -- The last luminance measured, for the shot driver's log.
 function BattleHud.lastLuma()
   local best = nil
@@ -344,6 +402,7 @@ function BattleHud.invalidate()
   frostW, frostH = 0, 0
   luma = {}
   wasDark = false
+  layer, hudLayer = nil, nil
 end
 
 return BattleHud
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index f3998f0..83e669d 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -140,6 +140,54 @@ OverworldBattle.HUD_RECT = {
   player = { 72, 56, 88, 40 },
 }
 
+-- ------- the HUDs, out at the window's own edges
+--
+-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
+-- whole of it. That left both HUD blocks huddled together in the middle of the
+-- frame with map showing on either side of them, which reads as a Game Boy
+-- screenshot pasted over a diorama rather than as the diorama's own furniture.
+--
+-- So each block is snapped to its own side: the foe's to the left edge of the
+-- window, the player's to the right. Nothing about either block changes -- same
+-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
+-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
+-- of the GB screen there is nowhere to go and the snap is a no-op.
+--
+-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
+-- canvas and everything outside it is clipped away. So the layer is rendered to
+-- a texture and composited into the WORLD image instead, which is the one
+-- surface in this mode that covers the whole window.
+
+-- The rows each block is cut out of, full width. Generous on purpose:
+-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
+-- further than the panel does, and the pokeball rows and the safari ball count
+-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
+-- outside rows 0-96, and the two bands split that between them.
+OverworldBattle.HUD_BAND = {
+  enemy = { 0, 0, 160, 48 },
+  player = { 0, 48, 160, 48 },
+}
+
+-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
+-- glass is cut to, plus the x its band is blitted at.
+--
+-- The foe's panel starts at the window's left edge and the player's ends at the
+-- right one. The vertical is untouched, so both stay on the rows the GB put
+-- them on. A band's own origin sits outside the window by the panel's inset --
+-- the couple of pixels a HUD shake can push past the edge are clipped there,
+-- which is the whole cost of the snap and is invisible.
+function OverworldBattle.snapRects(shot)
+  local s = shot.scale
+  local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
+  local ex = -e[1] * s                       -- foe: panel's left edge to 0
+  local px = shot.pw - (p[1] + p[3]) * s     -- player: right edge to the far side
+  local rects = {
+    enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
+    player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
+  }
+  return rects, { enemy = ex, player = px }
+end
+
 -- ------- the live battle
 --
 -- nil when no overworld battle is running. Never more than one: battles do
@@ -150,6 +198,13 @@ local function game()
   return require("src.core.Game")
 end
 
+-- Whether this frame's HUDs went out to the window's edges instead of being
+-- drawn in the GB frame. False whenever the composite could not be made, which
+-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
+local function snapped()
+  return (session and session.snapped) and true or false
+end
+
 -- Put the map's cast back. Both lists are handed back by identity, so
 -- anything that captured one before the battle still sees the same table.
 local function restoreCast()
@@ -174,6 +229,36 @@ local function cullCast(state)
   state.ghosts = {}
 end
 
+-- ------- one right battle layout
+--
+-- Everything this file composes is measured in the GB's own 160x144 frame: the
+-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
+-- the frosted panels are cut to, and the full-frame white intercepted to let
+-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
+-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
+-- -- the mons would stand where no camera was solved for them, and the panels
+-- would land beside the HUDs they are supposed to be under.
+--
+-- So while a fight can be staged on the map there is one right answer, and it
+-- is SET rather than worked around. The engine reads the option live
+-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
+-- top state for its surface the same way), so writing it here lands on the
+-- battle being pushed as well as every one after it.
+--
+-- This is the last line rather than the first: the OPTIONS menu takes the row
+-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
+-- player is never offered a switch that gets reverted under them. What reaches
+-- here is a value that arrived some other way -- a save written before the mod
+-- was installed, the mod manager's own page, another mod.
+function OverworldBattle.forceOG(g)
+  g = g or game()
+  local opts = g and g.save and g.save.options
+  if not opts or opts.battleLayout ~= "wide" then return false end
+  opts.battleLayout = "og"
+  if g.writeOptions then pcall(g.writeOptions, g) end
+  return true
+end
+
 -- Stage a battle triggered from `state`, if this mode can. Returns true when
 -- a session started -- which is also the only case where anything visible
 -- changes, so a map with no room for an arena plays exactly the vanilla
@@ -189,6 +274,10 @@ function OverworldBattle.begin(state, battle)
                           state.player.surfing)
   if not (ok and arena) then return false end
 
+  -- the fight is staged from here on, so the layout it is composed for is not
+  -- optional any more (see forceOG)
+  OverworldBattle.forceOG()
+
   session = { state = state, arena = arena, battle = battle, shot = nil,
               armed = false, token = 0 }
   cullCast(state)
@@ -276,11 +365,13 @@ function OverworldBattle.update(dt)
     -- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
     -- guard, which retires a pipeline for the session.
     session.shot = nil
+    session.snapped = false
     session.broken = true
     V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
                    .. "on the plain battle background", tostring(shot))
     return
   end
+  session.snapped = false
   if shot and shot.canvas then
     -- the depth of field is measured off the two marks: the slab in focus is
     -- the one the mons are standing in, at whatever the drift has done to
@@ -295,6 +386,20 @@ function OverworldBattle.update(dt)
     -- so a panel over a blurred far field is frosted from what is actually
     -- behind it
     pcall(BattleHud.build, shot.canvas)
+    -- and then the HUDs go ON that backdrop, snapped out to the window's own
+    -- edges (snapHUDs). Here rather than in the battle's draw for the same
+    -- reason the scene is: it binds a canvas of its own. After the frost, so
+    -- the glass is frosted from the world alone and never from the glyphs
+    -- about to sit on it.
+    local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
+    session.snapped = (okHud and up) and true or false
+    -- once per battle, not once per frame: a driver that cannot do this cannot
+    -- do it sixty times a second either, and the fallback is silent and fine
+    if not okHud and not session.hudWarned then
+      session.hudWarned = true
+      V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
+                     .. "in the battle frame this battle", tostring(up))
+    end
   end
   session.shot = shot
 end
@@ -394,6 +499,7 @@ local texturing = nil
 
 local texCanvas = {}
 local innerPics = nil                   -- captured by install()
+local innerHUDs = nil                   -- likewise, for the snapped HUD layer
 
 local function texCanvasFor(side)
   local c = texCanvas[side]
@@ -695,8 +801,12 @@ function OverworldBattle.install()
   --
   -- The HP bar is untouched: it is drawn in its own greens and reds, and
   -- only an exactly-black set is remapped.
-  local innerHUDs = BattleState.drawHUDs
+  innerHUDs = BattleState.drawHUDs
   function BattleState:drawHUDs(slide)
+    -- Normally the HUDs have already been drawn this frame, snapped out to the
+    -- window's edges and composited into the world image (snapHUDs). Drawing
+    -- them here as well would show each block twice, once in each place.
+    if self.dramaticShapeShot and snapped() then return end
     if not (self.dramaticShapeShot and self.dramaticShapeDark) then
       return innerHUDs(self, slide)
     end
@@ -726,12 +836,91 @@ function OverworldBattle.hudLive(battle, slide)
   return enemy and true or false, player and true or false
 end
 
+-- ------- the snapped composite
+--
+-- The engine's own HUD layer, rendered into a texture.
+--
+-- One thing is falsified for the render, and it is falsified because this layer
+-- never reaches the battle's zone pass -- it is composited into the world image,
+-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves
+-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);
+-- answered false, it tints its own greens and reds instead, exactly as it does
+-- on the flat path. The glyphs are pure black either way, which is what the
+-- flip in BattleHud.layerTexture is measured against.
+--
+-- Shadowed on the instance for this call only, the way drawZonePass shadows
+-- activeBgp: putting the field back to whatever it was (normally nil) lets the
+-- class method be found again.
+function OverworldBattle.hudTexture(battle, slide, dark)
+  if not (innerHUDs and battle) then return nil end
+  local had = rawget(battle, "colorMode")
+  battle.colorMode = function() return false end
+  local ok, layer = pcall(BattleHud.layerTexture,
+                          BattleScene.GB_W, BattleScene.GB_H, dark,
+                          function() innerHUDs(battle, slide) end)
+  battle.colorMode = had
+  return ok and layer or nil
+end
+
+-- Draw both HUD blocks into the world image at the window's edges, each on its
+-- own frosted panel. Returns true when the frame's HUDs are up there and the
+-- in-frame draw must be skipped; false leaves the battle screen's own HUD
+-- exactly as it was before any of this existed.
+--
+-- Both bands are blitted whether or not that side's HUD is LIVE, because a band
+-- carries more than the HUD: the pokeball rows of the intro and of an enemy
+-- faint, and the safari ball count, all draw in these rows and belong at the
+-- same edge as the block they share it with. The panels are the ones that
+-- follow hudLive -- frosted glass under nothing is a slab floating in the arena.
+function OverworldBattle.snapHUDs(battle, shot)
+  if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then
+    return false
+  end
+  local slide = (battle.introSlide or 0) * 4
+  local rects, bandX = OverworldBattle.snapRects(shot)
+  local enemy, player = OverworldBattle.hudLive(battle, slide)
+  local live = {}
+  if enemy then live.enemy = rects.enemy end
+  if player then live.player = rects.player end
+  -- measured under the SNAPPED rects: the panels are over whatever the world
+  -- shows at the window's edges now, which is not what was behind them in the
+  -- middle of the frame
+  local dark = BattleHud.verdict(live, shot, true)
+  local layer = OverworldBattle.hudTexture(battle, slide, dark)
+  if not layer then return false end
+
+  local g = love.graphics
+  local prevCanvas = g.getCanvas()
+  local prevBlend, prevAlpha = g.getBlendMode()
+  local ok, err = pcall(function()
+    g.setCanvas(shot.canvas)
+    g.setBlendMode("alpha")
+    for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end
+    g.setColor(1, 1, 1, 1)
+    for side, band in pairs(OverworldBattle.HUD_BAND) do
+      local quad = g.newQuad(band[1], band[2], band[3], band[4],
+                             BattleScene.GB_W, BattleScene.GB_H)
+      g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
+             shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
+    end
+  end)
+  if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
+  g.setBlendMode(prevBlend or "alpha", prevAlpha)
+  g.setColor(1, 1, 1, 1)
+  if not ok then error(err, 0) end
+  return true
+end
+
 -- Lay the frosted glass down under whichever HUD is about to draw, and
 -- record which way the glyphs have to flip.
+--
+-- The fallback path only: with the HUDs snapped out to the window's edges their
+-- panels went with them, and there is nothing left inside the GB frame to lay
+-- glass under.
 function OverworldBattle.drawHudPanels(battle)
   local shot = battle.dramaticShapeShot
   battle.dramaticShapeDark = nil
-  if not shot then return end
+  if not shot or snapped() then return end
   local slide = (battle.introSlide or 0) * 4
   local enemy, player = OverworldBattle.hudLive(battle, slide)
   if not (enemy or player) then return end
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index 199bed3..31ac92b 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = {
   sign = 12,
   wall = 16,
   tree = 16,
+  -- masonry drawn TWO courses tall: the Indigo Plateau's rim and the
+  -- badge-check gates down Route 23 are drawn 32px, the same height as a
+  -- statue on its plinth, and read as a step in the terrain rather than a
+  -- room's wall.  Same fold as `wall`, twice the height -- and its own
+  -- class because `wall` is 16px for every interior in the game.
+  cliff = 32,
   roof = 28,
   cylinder = 16,
   -- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
@@ -110,6 +116,7 @@ local ART = {
   ledge = "top",
   roof = "top",
   wall = "upright",
+  cliff = "upright",
   tree = "upright",
   fence = "upright",
   sign = "upright",
diff --git a/main.lua b/main.lua
index faf2595..e5e35d9 100644
--- a/main.lua
+++ b/main.lua
@@ -281,9 +281,26 @@ applyFull = function(level)
   -- half of it is spent. Set rather than forced -- the row is gone from the
   -- menu while FULL is on, but a save that already had it off gets it on.
   OverworldBattle.setting:setIndex(1, Game)
+  -- and the battle screen the staged fight is composed for. WIDE re-lays that
+  -- screen out on a 304x144 surface, which moves every anchor the arena camera
+  -- is solved against (OverworldBattle.forceOG); FULL has just switched staged
+  -- fights on, so the layout follows them.
+  OverworldBattle.forceOG(Game)
   if Game.writeOptions then pcall(Game.writeOptions, Game) end
 end
 
+-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
+-- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and
+-- switches it on. Deliberately NOT gated on Voxel3D.available(): the engine
+-- offers a pipeline's row whether or not the hardware can run it
+-- (Pipelines.rows), so this mode's rows say ON on a machine without a depth
+-- buffer too, and a menu that claims 3D battles are on must not also offer the
+-- layout they cannot be drawn in.
+local function stagedBattles()
+  local Pipelines = require("src.render.Pipelines")
+  return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
+end
+
 local SETTINGS = {
   { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
   { WorldCurve.setting,
@@ -393,6 +410,11 @@ do
         -- when the fight starts, so flipping it from inside one would be a
         -- switch that appeared to do nothing.
         claim:cycle(self)
+        -- 8 is one of the two ways staged battles get switched on, and they
+        -- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys
+        -- parameterise the pass and leave the layout alone; the guard answers
+        -- for all three, so nothing here has to know which key it was.
+        if stagedBattles() then OverworldBattle.forceOG(self) end
         return
       end
     end
@@ -442,6 +464,17 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
   local out = next(game, rows)
   if type(out) ~= "table" then return out end
   local Pipelines = require("src.render.Pipelines")
+  -- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
+  -- one away. While a fight can be staged on the map, OG is the only layout it
+  -- can be composed in (OverworldBattle.forceOG), so the value is pinned there
+  -- and the row comes off the list on the same reasoning as the rows FULL owns:
+  -- a row that no longer decides anything is worse than no row. Nothing is
+  -- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same
+  -- keypress.
+  if stagedBattles() then
+    OverworldBattle.forceOG(game)
+    dropRow(out, "battleLayout")
+  end
   if Voxel.isFull(Pipelines.level("voxel")) then
     return dropRow(out, "pipeline:tiltshift")
   end
@@ -457,6 +490,10 @@ mod.events:on("mod.options_changed", function(payload)
   for _, entry in ipairs(SETTINGS) do
     if payload.key == entry[1].key then entry[1]:sync(payload.value) end
   end
+  -- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
+  -- the OPTIONS row does. The manager persists its own value; this is the one
+  -- that has to follow it.
+  if stagedBattles() then OverworldBattle.forceOG() end
 end)
 
 -- ------- keeping the geometry in step with the world
@@ -539,7 +576,7 @@ mod.events:on("map.reloaded", function(payload)
   if mapId then ChunkMesher.invalidate(mapId) end
 end)
 
--- ------- FULL takes rows off the menu, so the menu has to notice
+-- ------- rows come and go, so the menu has to notice
 --
 -- OptionsMenu builds its row list ONCE, when it is opened, and then reads
 -- that list every frame. So stepping the VOXEL row onto or off FULL changed
@@ -547,25 +584,40 @@ end)
 -- settings FULL owns stayed visible until the menu was closed and reopened,
 -- and a player who stepped off FULL could not see the rows come back.
 --
--- Rebuilt in place, and only on a step that crosses FULL: every other rung
--- returns the same list, and rebuilding on all of them would rerun every
--- mod's ui.options.rows hook once per keypress. The cursor is clamped rather
--- than reset, so it stays on the VOXEL row it was just used on instead of
--- jumping to the top when the list below it shortens.
+-- Rebuilt in place, and only on a step that changes the LIST: crossing FULL,
+-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT).
+-- Every other rung returns the same list, and rebuilding on all of them would
+-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
+-- clamped rather than reset, so it stays on the row it was just used on
+-- instead of jumping to the top when the list below it shortens.
 do
   local OptionsMenu = require("src.ui.OptionsMenu")
   if not OptionsMenu.dramaticShapeFullHook then
     local Pipelines = require("src.render.Pipelines")
     local inner = OptionsMenu.update
 
+    local function idAt(menu, index)
+      local row = menu.rows and menu.rows[index or 1]
+      return type(row) == "table" and row.id or nil
+    end
+
     function OptionsMenu:update(dt)
       local before = Pipelines.level("voxel")
+      local hadBattles = OverworldBattle.enabled()
+      local wasOn = idAt(self, self.index)
       inner(self, dt)
       local after = Pipelines.level("voxel")
-      if after ~= before
-         and (Voxel.isFull(before) or Voxel.isFull(after)) then
+      local crossedFull = after ~= before
+                          and (Voxel.isFull(before) or Voxel.isFull(after))
+      if crossedFull or OverworldBattle.enabled() ~= hadBattles then
         local rebuilt = OptionsMenu.new(self.game)
         self.rows = rebuilt.rows
+        -- Follow the row the cursor was ON rather than the slot it was in:
+        -- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would
+        -- otherwise slide the cursor onto the row under the one just used.
+        for i = 1, #self.rows do
+          if wasOn and idAt(self, i) == wasOn then self.index = i; break end
+        end
         local cancel = #self.rows + 1
         if (self.index or 1) > cancel then self.index = cancel end
       end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 582a4d8..97fbe18 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -135,6 +135,62 @@ T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
 T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
 T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
 
+-- ------- BATTLE LAYOUT is pinned to OG while a fight can be staged on the map
+--
+-- The staged battle is composed in the GB's own 160x144 frame: the anchors the
+-- arena camera is solved to put a cell under, the HUD rects the frosted panels
+-- are cut to, the full-frame white intercepted to let the world through. WIDE
+-- lays the same battle out on a 304x144 surface and moves every one of them. So
+-- the value is set and the ENGINE's row comes off the menu -- the one row this
+-- mod takes away that is not its own.
+local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
+T.eq(Battles.enabled(), true, "3D-BTL is on by default, which is what pins it")
+
+-- off FULL first: the row on its own has to be enough, and FULL is checked
+-- separately below
+Pipelines.setLevel("voxel", 2)
+local layoutGame = {
+  data = Data,
+  save = { options = { battleLayout = "wide", pipelines = {}, modOptions = {} } },
+  mods = { modOptions = {} },
+  writeOptions = function() end,
+}
+local pinned = Runtime.call("ui.options.rows", function(_, r) return r end,
+                            layoutGame,
+                            { { id = "battleLayout" }, { id = "tilt" },
+                              { id = "pipeline:voxel" } })
+local pinnedIds = {}
+for _, row in ipairs(pinned) do pinnedIds[row.id] = true end
+T.check(not pinnedIds["battleLayout"],
+  "with staged battles on, BATTLE LAYOUT is off the menu")
+T.eq(layoutGame.save.options.battleLayout, "og",
+  "and a save that had WIDE is set to OG -- the only layout the shot composes in")
+
+-- switching 3D-BTL off hands the row straight back, WIDE and all
+Battles.setting:setIndex(2, layoutGame)
+T.eq(Battles.enabled(), false, "3D-BTL off")
+local handedBack = Runtime.call("ui.options.rows", function(_, r) return r end,
+                                layoutGame,
+                                { { id = "battleLayout" }, { id = "tilt" },
+                                  { id = "pipeline:voxel" } })
+local backIds = {}
+for _, row in ipairs(handedBack) do backIds[row.id] = true end
+T.check(backIds["battleLayout"], "the engine's row is back on the menu")
+layoutGame.save.options.battleLayout = "wide"
+Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
+             { { id = "battleLayout" } })
+T.eq(layoutGame.save.options.battleLayout, "wide",
+  "and WIDE is left alone once no battle can be staged on the map")
+
+-- FULL owns the 3D-BTL row, so it pins the layout even with that row switched
+-- off underneath it
+Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
+Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
+             { { id = "battleLayout" } })
+T.eq(layoutGame.save.options.battleLayout, "og",
+  "FULL pins the layout on its own, because it owns the row that would")
+Battles.setting:setIndex(1, layoutGame)
+
 -- ------- and off FULL, the rows come back, grouped with the mode
 --
 -- The engine splices a pipeline row in beside TILT and lands a mod's own
@@ -200,6 +256,29 @@ T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
   "and the rows came straight back without reopening the menu")
 T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
 
+-- ------- 3D-BTL owns BATTLE LAYOUT, and takes it off the OPEN menu too
+--
+-- The row this one takes away sits ABOVE it in the list, so the cursor has to
+-- follow the row it was ON rather than the slot it was in -- otherwise the very
+-- press that switched staged battles on would leave the cursor a row further
+-- down than the player left it.
+Battles.setting:setIndex(2, menuGame)             -- staged battles off
+menuGame.save.options.battleLayout = "wide"
+Pipelines.setLevel("voxel", 2)
+local layoutMenu = OptionsMenu.new(menuGame)
+T.check(rowIndex(layoutMenu, "battleLayout"),
+  "with staged battles off, the engine's BATTLE LAYOUT row is on the menu")
+layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles")
+pressed = { right = true }
+layoutMenu:update(0)
+pressed = {}
+T.eq(Battles.setting:get(), true, "the step switched staged battles on")
+T.check(not rowIndex(layoutMenu, "battleLayout"),
+  "and BATTLE LAYOUT left the open menu with the same keypress")
+T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out")
+T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"),
+  "with the cursor still on the row the player just used")
+
 -- level 2 is the "15" rung: any rung that is not FULL, so the settings the
 -- preset owns are back on the menu
 Pipelines.setLevel("voxel", 2)
@@ -1428,6 +1507,66 @@ T.check(range > 0, "with a ramp out of it, so the band edge has no seam")
 local wide = select(2, BattleDOF.bandFor(120, 30, 144))
 T.check(wide > band, "marks further apart hold a deeper slab in focus")
 
+-- ------- the HUDs are snapped to the window's own edges
+--
+-- The battle screen is 160x144 in the middle of the window and the world is the
+-- whole of it, which left both HUD blocks huddled in the middle of the frame
+-- with map on either side of them. Each is snapped to its own side instead: the
+-- foe's to the left edge, the player's to the right. Measured in world-canvas
+-- pixels, because that is the surface they are composited into -- the GB canvas
+-- they are drawn in cannot reach past its own 160 columns.
+local hudShot = { lx = 100, ly = 12, scale = 3, pw = 1000, ph = 500 }
+local hudRects, bandX = Battles.snapRects(hudShot)
+local hudRect = Battles.HUD_RECT
+
+T.eq(hudRects.enemy[1], 0, "the foe's panel starts at the window's left edge")
+T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw,
+  "and the player's ends at the right one")
+T.check(hudRects.enemy[1] < hudShot.lx,
+  "so the foe's block has left the letterbox it used to sit in")
+T.check(hudRects.player[1] > hudShot.lx + hudRect.player[1] * hudShot.scale,
+  "and the player's has gone the other way")
+
+-- the vertical is untouched: both blocks stay on the rows the GB put them on
+T.eq(hudRects.enemy[2], hudShot.ly + hudRect.enemy[2] * hudShot.scale,
+  "the foe's block keeps its own rows")
+T.eq(hudRects.player[2], hudShot.ly + hudRect.player[2] * hudShot.scale,
+  "and so does the player's")
+
+-- and their size: a block is the same GB tiles at the same scale as the rest of
+-- the art, moved and not stretched
+T.eq(hudRects.enemy[3], hudRect.enemy[3] * hudShot.scale,
+  "a block is its own width at the frame's scale")
+T.eq(hudRects.player[4], hudRect.player[4] * hudShot.scale,
+  "and its own height")
+
+-- the band each block is cut out of is placed so the block lands on the rect
+-- above; that is what the panel and the glyphs agreeing depends on
+T.eq(bandX.enemy + hudRect.enemy[1] * hudShot.scale, hudRects.enemy[1],
+  "the foe's band is offset so its block lands on its panel")
+T.eq(bandX.player + hudRect.player[1] * hudShot.scale, hudRects.player[1],
+  "and the player's likewise")
+
+-- a window the shape of the GB screen has nowhere to snap TO, and the player's
+-- block already ends at column 160, so it must not move at all
+local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 }
+local snugRects = Battles.snapRects(snug)
+T.eq(snugRects.player[1], hudRect.player[1] * snug.scale,
+  "on a GB-shaped window the player's block stays exactly where it was")
+T.eq(snugRects.enemy[1], 0, "and the foe's is flush with a left edge it already met")
+
+-- the bands together cover every row drawHUDs draws into (0-96: the two HUDs,
+-- the pokeball rows and the safari ball count) and never overlap, so nothing it
+-- draws is dropped or shown twice
+local e, p = Battles.HUD_BAND.enemy, Battles.HUD_BAND.player
+T.eq(e[2], 0, "the foe's band starts at the top of the frame")
+T.eq(e[2] + e[4], p[2], "the player's picks up exactly where it ends")
+T.check(p[2] + p[4] >= 96, "and together they reach the bottom of the HUD rows")
+T.check(e[2] + e[4] <= hudRect.player[2],
+  "the split falls between the two blocks, so neither is cut in half")
+T.eq(e[1], 0, "the bands are full width")
+T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block")
+
 Pipelines.reset()
 run.release()