DramaticShapeVoxelMod archival mirror · read-only
5781c28b609b9e8227a711b5249f335859f0c597

added FULL menu option for voxel mod

DramaticShape · 2026-07-28 · parent 6280cdffe7

added FULL menu option for voxel mod

Changed files

CHANGELOG.md                  |  38 ++++++++++++++++
 data/battle_arenas.lua        |   6 ++-
 lib/BattleArena.lua           |   9 +++-
 lib/BattleScene.lua           |   8 ++++
 lib/VoxelGrid.lua             |  12 +++++
 lib/VoxelState.lua            |  53 +++++++++++++++++++++-
 main.lua                      | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 manifest.json                 |   6 ++-
 tests/arena_pick.lua          |  70 ++++++++++++++++++++++++++---
 tests/dramatic_shape_test.lua | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 10 files changed, 478 insertions(+), 26 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 495b14f..a814303 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
 # Changelog
 
+## 1.2.0
+
+### Added
+
+- **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that
+  puts the whole mode in its intended state -- the 50-degree camera, the
+  miniature blur at maximum, the horizon flat, the view fitted, and battles
+  on the map -- rather than making a player assemble it from four rows.
+
+  While it is selected, every row it owns comes OFF the menu: V-GRID,
+  V-CURVE, 3D-BTL and T-SHIFT. A row that no longer decides anything is
+  worse than no row. Stepping onto or off `FULL` rebuilds the open menu in
+  place, so the rows leave and return under the cursor instead of waiting
+  for the menu to be reopened.
+
+  It applies its settings when the row ARRIVES at `FULL`, not every frame:
+  holding them would make the zoom keys and the wheel dead while it was on.
+  Leaving it deliberately undoes nothing -- reverting would discard whatever
+  had been changed since.
+
+### Changed
+
+- **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
+  mid-walk would silently push the blur to maximum and flatten the horizon
+  with nothing on screen saying a keypress had done it. `FULL` stays on the
+  OPTIONS row, where a preset that changes other rows belongs.
+
+  A press FROM `FULL` goes to `75`. `FULL` is already the 50-degree camera,
+  so stepping to the rung of that name would look like the key had done
+  nothing.
+
+- **The mode's four options are one block in the menu.** The engine splices
+  a pipeline row in beside TILT and lands a mod's own rows at the end of the
+  list, which had these four in two places with unrelated engine rows
+  between them. The settings now follow the pipeline rows directly.
+
 ## 1.1.0
 
 ### Added
diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
index f9b1a26..df2d343 100644
--- a/data/battle_arenas.lua
+++ b/data/battle_arenas.lua
@@ -139,8 +139,10 @@ return {
 
   -- ------- the remaining routes
   ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
-  ["ROUTE_20"] = { x = 53, y = 3, shape = "wide" },
-  ["ROUTE_21"] = { x = 16, y = 4, shape = "wide" },
+  -- 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" },
diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua
index e215b8c..20f0e04 100644
--- a/lib/BattleArena.lua
+++ b/lib/BattleArena.lua
@@ -291,7 +291,14 @@ function BattleArena.find(map, fromX, fromY, surfing)
       host = (ok and other) or nil
     end
     if shape and host then
-      local grid, gw = openGrid(host, surfing)
+      -- An authored spot is checked with WATER COUNTING AS GROUND, whatever
+      -- the player is doing. The surfing test exists to stop the automatic
+      -- search staging a walker's fight out at sea; an authored entry was
+      -- chosen and looked at by a person, so if it is on water that is the
+      -- point of it -- the surf routes fight in the middle of their own
+      -- 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)
         arena.map = host
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 8d80615..7cb945f 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -40,6 +40,7 @@ local TerrainAtlas = V.require("TerrainAtlas")
 local VoxelScene = V.require("VoxelScene")
 local BattleCam = V.require("BattleCam")
 local BattleBillboard = V.require("BattleBillboard")
+local VoxelGrid = V.require("VoxelGrid")
 local PaletteFX = require("src.render.PaletteFX")
 
 local BattleScene = {}
@@ -333,6 +334,12 @@ function BattleScene.render(state, arena, textures, token)
   -- free-roam world it shares this module with keeps its own weight
   local sunWas = Voxel3D.SHADOW_ALPHA
   Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
+  -- and the wireframe is ON for a battle whatever the V-GRID row says. The
+  -- arena is a staged shot rather than the world being walked through, and
+  -- the seams are what make it read as built rather than photographed. Forced
+  -- through the override so the player's own row is never written to.
+  local gridWas = VoxelGrid.override
+  VoxelGrid.override = true
   local out = nil
   local ok, err = pcall(function()
     -- its own canvas slot: this renders at the window's pixel size and the
@@ -408,6 +415,7 @@ function BattleScene.render(state, arena, textures, token)
   -- renders (the free-roam pipeline, next frame) must find the orbit back
   Voxel3D.camera = nil
   Voxel3D.SHADOW_ALPHA = sunWas
+  VoxelGrid.override = gridWas
   if not ok then
     -- endScene never ran, so the canvas is still bound and the shader still
     -- set; put the frame back the way it was found before rethrowing
diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua
index 1f68bab..1fb7ce7 100644
--- a/lib/VoxelGrid.lua
+++ b/lib/VoxelGrid.lua
@@ -48,7 +48,19 @@ VoxelGrid.WIDTH = 1.0
 VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
                                    { false, true }, { "OFF", "ON" })
 
+-- A pass that needs the wireframe whatever the player left the row on sets
+-- this for the length of its own draw and puts it back after. nil means
+-- "follow the setting", which is every frame outside such a pass.
+--
+-- The overworld battle is the one user: a fight is a STAGED shot, not the
+-- world being walked around in, and the seams are what make it read as
+-- constructed rather than as a photograph of somewhere. The row still owns
+-- what free-roam looks like, and is not written to -- switching the mode off
+-- mid-battle would silently rewrite the player's own setting.
+VoxelGrid.override = nil
+
 function VoxelGrid.enabled()
+  if VoxelGrid.override ~= nil then return VoxelGrid.override end
   return VoxelGrid.setting:get() and true or false
 end
 
diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua
index 38a920b..ca85fac 100644
--- a/lib/VoxelState.lua
+++ b/lib/VoxelState.lua
@@ -23,10 +23,59 @@
 
 local Voxel = {}
 
-Voxel.ANGLES_DEG = { 0, 15, 35, 50, 75 }
-Voxel.ANGLE_LABELS = { "OFF", "15", "35", "50", "75" }
+-- FULL is a PRESET, not another angle: one rung that puts the whole mode in
+-- its intended state at once -- this camera, the miniature blur at full, the
+-- horizon flat, the view fitted -- so a player who wants "the diorama" picks
+-- it rather than assembling it from four rows. It sits directly after OFF
+-- because that is the order those two get used in.
+--
+-- Its ANGLE is 50 degrees, the same as the rung of that name. The duplicate
+-- in the table is deliberate: the ladder is a list of what each rung LOOKS
+-- like, and two rungs may look the same while meaning different things.
+Voxel.ANGLES_DEG = { 0, 50, 15, 35, 50, 75 }
+Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
 Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
 
+-- the rung FULL sits on, so nothing has to hunt for it by label
+Voxel.FULL_LEVEL = 1
+
+function Voxel.isFull(level)
+  return (level or Voxel.level) == Voxel.FULL_LEVEL
+end
+
+-- ------- what the hotkey walks
+--
+-- The ANGLE rungs only, with FULL left out. The key is a display-mode
+-- cycler: pressing it should change the camera and nothing else, and FULL
+-- reaches in and rewrites four other settings. Landing on it by accident,
+-- mid-walk, would silently turn the blur to maximum and flatten the horizon
+-- with no indication that a keypress had done so. FULL stays on the OPTIONS
+-- row, which is where a preset that changes other rows belongs.
+Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 }   -- OFF, 15, 35, 50, 75
+
+-- The rung a press moves to from `level`.
+--
+-- A level that is not on the key's path -- FULL, reached from the menu --
+-- steps on from whichever rung shows the SAME camera it does. FULL is 50
+-- degrees, so a press from it goes to 75 rather than back to 50, and the key
+-- never appears to do nothing.
+function Voxel.nextHotkeyLevel(level)
+  level = level or Voxel.level
+  local order = Voxel.HOTKEY_ORDER
+  local at = nil
+  for i, rung in ipairs(order) do
+    if rung == level then at = i break end
+  end
+  if not at then
+    local deg = Voxel.ANGLES_DEG[level + 1]
+    for i, rung in ipairs(order) do
+      if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
+    end
+  end
+  if not at then return order[1] end
+  return order[at % #order + 1]
+end
+
 Voxel.level = 0
 Voxel.angle = 0
 Voxel.from = 0
diff --git a/main.lua b/main.lua
index b77e555..d07292b 100644
--- a/main.lua
+++ b/main.lua
@@ -79,6 +79,12 @@ local VoxelGrid = V.require("VoxelGrid")
 local WorldCurve = V.require("WorldCurve")
 local OverworldBattle = V.require("OverworldBattle")
 
+-- Forward declaration: the voxel pipeline's update hook (registered below)
+-- calls this, and it is defined further down with the settings it drives.
+-- Declared rather than left global -- a mod writing to _G would leak into
+-- every other mod's namespace.
+local applyFull
+
 -- The last VOID FILL the terrain was meshed under; see the update hook.
 -- The scene canvas's size, in FRAMEBUFFER PIXELS.
 --
@@ -145,6 +151,11 @@ mod.content.render_pipelines:register("voxel", {
   -- pump slice -- so stepping out of a door lands on terrain that is
   -- already there instead of a flat flash.
   update = function(dt, level)
+    -- FULL is a preset, so it is applied ON THE PRESS rather than held every
+    -- frame: it SETS the other rows and then leaves them alone. Holding them
+    -- would make the zoom keys and the wheel dead while the mode was on, and
+    -- would fight anyone who changed one deliberately.
+    applyFull(level)
     Voxel.update(dt, level)
     -- The overworld battle rides this hook rather than owning a pipeline of
     -- its own, because it owns no pass of the FRAME: it draws under a battle
@@ -233,6 +244,46 @@ mod.content.render_pipelines:register("tiltshift", {
 -- instead -- see ModSetting for where they persist and how the two rows
 -- each ends up on stay in step.
 
+-- ------- the FULL preset
+--
+-- Everything the mode wants switched to at once. Applied when the VOXEL row
+-- ARRIVES at FULL and not again, so the player can still move the camera or
+-- the zoom afterwards -- it is a starting point, not a lock.
+--
+-- Leaving FULL deliberately does NOT undo any of it. A preset that reverted
+-- would throw away whatever the player had changed since, and "put it back
+-- how it was" is not a thing this can know.
+local fullWas = nil
+
+applyFull = function(level)
+  local isFull = Voxel.isFull(level)
+  local was = fullWas
+  fullWas = isFull
+  if not isFull or was == true or was == nil then return end
+
+  local Game = require("src.core.Game")
+  local Pipelines = require("src.render.Pipelines")
+  local Zoom = require("src.render.Zoom")
+  local opts = Game.save and Game.save.options
+  if not opts then return end
+
+  -- the miniature blur at its strongest: FULL is the diorama look, and the
+  -- tilt-shift is most of what makes it read as a model
+  Pipelines.setLevel("tiltshift", Pipelines.maxLevel("tiltshift"))
+  Pipelines.syncOptions(opts)
+  -- the horizon flat. The curve bends the world away from a walking player,
+  -- which fights a fixed diorama framing
+  WorldCurve.setting:setIndex(1, Game)
+  -- and the view fitted to the window
+  opts.zoom = 0
+  Zoom.applyOptions(opts)
+  -- battles on the map too: FULL means the whole mode, and a fight is where
+  -- 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)
+  if Game.writeOptions then pcall(Game.writeOptions, Game) end
+end
+
 local SETTINGS = {
   { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
   { WorldCurve.setting,
@@ -250,7 +301,7 @@ mod.options:define(schema)
 
 -- ------- this mod's hotkeys
 --
---   3  VOXEL    cycle the camera ladder      (was 6)
+--   3  VOXEL    cycle the camera ladder      (was 6; skips FULL)
 --   5  V-GRID   toggle the wireframe         (new)
 --   6  T-SHIFT  cycle the blur ladder        (was 9)
 --   7  V-CURVE  cycle the horizon bend       (new)
@@ -299,7 +350,20 @@ do
     -- render mode. Only free-roam presses are ours to take.
     if claim and not (top and top.onKeyPressed) then
       if claim == "pipeline" then
-        if Pipelines.hotkey(key, top, self.overworld) then
+        -- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
+        -- so the registry's plain "advance one and wrap" is not what it
+        -- wants; 6 still is. The gate is the registry's own either way.
+        local stepped = false
+        if key == "3" then
+          if Pipelines.canToggle("voxel", top, self.overworld) then
+            Pipelines.setLevel("voxel",
+              Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
+            stepped = true
+          end
+        else
+          stepped = Pipelines.hotkey(key, top, self.overworld) and true
+        end
+        if stepped then
           Pipelines.syncOptions(self.save.options)
           -- 3 is the key that used to turn TILT on and sits next to the one
           -- that used to turn GBC FX on, and this mod has taken both away.
@@ -336,15 +400,54 @@ do
   end
 end
 
+-- ------- the mode's rows, kept together
+--
+-- The engine splices a pipeline's row in beside TILT, because a display mode
+-- belongs with the other display modes; a mod's own ui.options.rows
+-- additions land at the END of the list. That left this mod's four rows in
+-- two places with unrelated engine rows between them, which reads as two
+-- unrelated features rather than one mode with settings.
+--
+-- So the plain settings are inserted directly after the last of this mod's
+-- PIPELINE rows instead of appended. Nothing else moves: the block lands
+-- where the engine already decided display modes go.
+local function insertGrouped(out, extra)
+  local anchor = nil
+  for i, row in ipairs(out) do
+    local id = type(row) == "table" and row.id
+    if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
+  end
+  if not anchor then
+    for _, row in ipairs(extra) do out[#out + 1] = row end
+    return out
+  end
+  for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
+  return out
+end
+
+-- FULL owns every one of those settings, so while it is selected they are
+-- taken off the menu rather than left to be changed under it -- including
+-- T-SHIFT, which is a pipeline row the engine put there. A row that no
+-- longer decides anything is worse than no row.
+local function dropRow(out, id)
+  for i = #out, 1, -1 do
+    if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
+  end
+  return out
+end
+
 -- call next() first and decorate what comes back, so every other mod's
 -- rows survive this one
 mod.hooks:wrap("ui.options.rows", function(next, game, rows)
   local out = next(game, rows)
   if type(out) ~= "table" then return out end
-  for _, entry in ipairs(SETTINGS) do
-    out[#out + 1] = entry[1]:row()
+  local Pipelines = require("src.render.Pipelines")
+  if Voxel.isFull(Pipelines.level("voxel")) then
+    return dropRow(out, "pipeline:tiltshift")
   end
-  return out
+  local extra = {}
+  for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
+  return insertGrouped(out, extra)
 end)
 
 -- The mod manager writes and persists on its own, so the only thing left
@@ -436,6 +539,42 @@ 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
+--
+-- 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
+-- which rows the hook would return but not which rows were on screen -- the
+-- 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.
+do
+  local OptionsMenu = require("src.ui.OptionsMenu")
+  if not OptionsMenu.dramaticShapeFullHook then
+    local Pipelines = require("src.render.Pipelines")
+    local inner = OptionsMenu.update
+
+    function OptionsMenu:update(dt)
+      local before = Pipelines.level("voxel")
+      inner(self, dt)
+      local after = Pipelines.level("voxel")
+      if after ~= before
+         and (Voxel.isFull(before) or Voxel.isFull(after)) then
+        local rebuilt = OptionsMenu.new(self.game)
+        self.rows = rebuilt.rows
+        local cancel = #self.rows + 1
+        if (self.index or 1) > cancel then self.index = cancel end
+      end
+    end
+
+    OptionsMenu.dramaticShapeFullHook = true
+  end
+end
+
 -- ------- battles on the map
 --
 -- The wraps this needs -- OverworldState:pushBattle, BattleState:draw and
@@ -481,7 +620,7 @@ mod.events:on("battle.ended", function()
   OverworldBattle.finish()
 end)
 
-mod.exports.version = "1.1.0"
+mod.exports.version = "1.2.0"
 -- exposed so a companion mod can pin its own tiles' shapes or read the
 -- camera without reaching into this mod's file layout
 mod.exports.lib = V
diff --git a/manifest.json b/manifest.json
index 3ac3b2e..e53591d 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
@@ -11,7 +11,9 @@
   "dependencies": [],
   "optional_dependencies": [],
   "conflicts": [],
-  "permissions": ["engine_internals"],
+  "permissions": [
+    "engine_internals"
+  ],
   "affects_link": false,
   "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands."
 }
diff --git a/tests/arena_pick.lua b/tests/arena_pick.lua
index d8af2d8..06bd92a 100644
--- a/tests/arena_pick.lua
+++ b/tests/arena_pick.lua
@@ -16,9 +16,11 @@
 --   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
 --
 -- ARENA_FROM / ARENA_COUNT slice the map list so several runs can share the
--- work; ARENA_MAPS=ID,ID,... does an explicit set instead. SHOT_DIR must
--- already exist -- the capture writes with io.open, which does not create
--- directories.
+-- work; ARENA_MAPS=ID,ID,... does an explicit set instead. ARENA_COUNT=0
+-- lists the maps and stops. ARENA_SURF=1 stages the fight out on the water,
+-- centred in the map's biggest body of it, which is what the surf routes
+-- want. SHOT_DIR must already exist -- the capture writes with io.open,
+-- which does not create directories.
 return function(game)
   local U = dofile("tests/drivers/util.lua")
   local DIR = os.getenv("SHOT_DIR") or ".scratchpad/arenas"
@@ -37,6 +39,51 @@ return function(game)
   local Arena = lib.require("BattleArena")
   local Battles = lib.require("OverworldBattle")
 
+  -- ------- staging out on the water
+  --
+  -- A surf route's land is a rim of beach round the edge of the map, so the
+  -- ordinary search always picks the rim and the fight happens on sand at
+  -- the corner of a sea. ARENA_SURF=1 says stage this map afloat: water
+  -- counts as ground, and the search starts from the middle of the map's
+  -- BIGGEST body of water rather than the middle of the map, so the arena
+  -- lands out in the open sea instead of against the first shoreline it
+  -- finds.
+  --
+  -- Biggest body, not all water at once: a map with a lake and an ocean has
+  -- a centroid between them that is on neither, and the arena would be
+  -- pinned to whichever shore that landed nearest.
+  local function waterCentre(map)
+    local w, h = map.widthCells, map.heightCells
+    local seen, best = {}, nil
+    for y0 = 0, h - 1 do
+      for x0 = 0, w - 1 do
+        if not seen[y0 * w + x0] and map:isWaterCell(x0, y0) then
+          -- one connected body, flooded from this cell
+          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 map: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
+    return best
+  end
+
   -- Which maps a battle can actually happen on: anything with a wild
   -- encounter table or an object that fights. Everything else -- a shop
   -- floor, a stairwell, a bedroom -- would be authoring a spot for a fight
@@ -134,9 +181,22 @@ return function(game)
           clear = any and Arena.clearance(any.map or map, any) or false
         end
       end
+      local surf = os.getenv("ARENA_SURF")
+      surf = surf ~= nil and surf ~= "" and surf ~= "0"
       if not any then
-        clear = Arena.search(map, cx, cy, false, true)
-        any = clear or Arena.search(map, cx, cy, false)
+        local ox, oy = cx, cy
+        if surf then
+          local sea = waterCentre(map)
+          if sea then
+            ox, oy = sea.x, sea.y
+            U.log(("SEA    %s: biggest body is %d cells, centre %.1f,%.1f")
+                  :format(id, sea.n, sea.x, sea.y))
+          else
+            U.log("SEA    " .. id .. ": no water on this map")
+          end
+        end
+        clear = Arena.search(map, ox, oy, surf, true)
+        any = clear or Arena.search(map, ox, oy, surf)
       end
       if not any then
         U.log(("NONE %s -- no arena of either shape"):format(id))
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 006a23e..a685495 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -49,11 +49,13 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE",
 
 -- ------- the ladders the engine drives
 
-T.eq(#defs.voxel.levels, 5, "voxel exposes a five-rung ladder")
+T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder")
 T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF")
-T.eq(defs.voxel.levels[5], "75", "the top rung is the 75-degree camera")
-T.eq(Pipelines.maxLevel("voxel"), 4, "the engine reads the ladder height")
-T.eq(Pipelines.levelLabel("voxel", 2), "35", "the engine reads the rung labels")
+T.eq(defs.voxel.levels[2], "FULL",
+  "FULL is the first rung after OFF -- the order those two get used in")
+T.eq(defs.voxel.levels[6], "75", "the top rung is the 75-degree camera")
+T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height")
+T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels")
 
 -- ------- gating: inert until switched on, and inert without a GPU
 
@@ -101,7 +103,7 @@ local byLabel = {}
 for _, row in ipairs(rows) do byLabel[row.label] = row end
 T.check(byLabel.VOXEL ~= nil, "the VOXEL row is offered")
 T.check(byLabel["T-SHIFT"] ~= nil, "the T-SHIFT row is offered")
-T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label")
+T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
 
 -- ------- this mod's own settings
 --
@@ -112,6 +114,95 @@ T.eq(byLabel.VOXEL.value(), "15", "the row renders the current rung's label")
 -- settings page looks.
 
 local Runtime = require("src.mods.Runtime")
+local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+
+-- ------- FULL is a preset that owns the other rows
+--
+-- While it is selected the settings it drives come OFF the menu -- including
+-- T-SHIFT, which is a pipeline row the engine spliced in. A row that no
+-- longer decides anything is worse than no row.
+Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
+local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
+                              { data = Data },
+                              { { id = "tilt" }, { id = "pipeline:voxel" },
+                                { id = "pipeline:tiltshift" } })
+local fullIds = {}
+for _, row in ipairs(fullRows) do fullIds[row.id] = true end
+T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
+T.check(not fullIds["pipeline:tiltshift"],
+  "FULL takes T-SHIFT off the menu -- it owns the blur")
+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")
+
+-- ------- 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
+-- additions at the END of the list, which would leave this mode's four rows
+-- in two places with unrelated rows between them.
+Pipelines.setLevel("voxel", 2)
+local grouped = Runtime.call("ui.options.rows", function(_, r) return r end,
+                             { data = Data },
+                             { { id = "tilt" }, { id = "pipeline:voxel" },
+                               { id = "pipeline:tiltshift" },
+                               { id = "void_fill" } })
+local order = {}
+for i, row in ipairs(grouped) do order[row.id] = i end
+T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
+  "the mode's settings follow its pipeline rows")
+T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3,
+  "and sit in one unbroken block, not scattered to the end of the list")
+T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
+  "with the engine's own later rows still after them")
+
+-- ------- the open menu notices when FULL is stepped onto or off
+--
+-- OptionsMenu reads its row list every frame but builds it once, so without
+-- a rebuild the rows FULL owns stay on screen until the menu is reopened --
+-- and stepping OFF FULL never brings them back.
+local OptionsMenu = require("src.ui.OptionsMenu")
+local pressed = {}
+local menuGame = {
+  data = Data,
+  save = { options = { pipelines = {}, modOptions = {} } },
+  mods = { modOptions = {} },
+  input = { wasPressed = function(_, k) return pressed[k] or false end },
+  stack = { pop = function() end },
+  writeOptions = function() end,
+}
+
+Pipelines.setLevel("voxel", 2)
+local menu = OptionsMenu.new(menuGame)
+local function rowIndex(m, id)
+  for i, row in ipairs(m.rows) do if row.id == id then return i end end
+end
+T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
+  "off FULL the menu opens with the mode's settings on it")
+
+-- step the VOXEL row from 15 down to FULL, the way the player would
+menu.index = rowIndex(menu, "pipeline:voxel")
+pressed = { left = true }
+menu:update(0)
+pressed = {}
+T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL")
+T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"),
+  "and the rows FULL owns left the OPEN menu at once")
+T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them")
+T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range")
+
+-- and back off it again
+menu.index = rowIndex(menu, "pipeline:voxel")
+pressed = { right = true }
+menu:update(0)
+pressed = {}
+T.eq(Pipelines.level("voxel"), 2, "the step left FULL")
+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")
+
+-- 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)
 local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
                                { data = Data }, { { id = "text_speed" } })
 T.eq(#hookedRows, 4, "the options hook added a row per setting")
@@ -734,10 +825,33 @@ keyGame = {
 local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
 local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve")
 
+-- ------- 3 walks the ANGLE rungs and steps over FULL
+--
+-- The key is a display-mode cycler: it should change the camera and nothing
+-- else. FULL reaches in and rewrites four other settings, so landing on it
+-- mid-walk would silently turn the blur to maximum and flatten the horizon
+-- with nothing on screen saying a keypress had done it.
+Pipelines.setLevel("voxel", 0)
+local walk = {}
+for _ = 1, 6 do
+  Game.keypressed(keyGame, "3")
+  walk[#walk + 1] = Pipelines.levelLabel("voxel")
+end
+T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15",
+  "3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL")
+
+-- FULL is 50 degrees, so a press from it goes ON to 75 rather than back to
+-- the rung that shows the same camera -- the key never appears to do nothing
+Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
 Game.keypressed(keyGame, "3")
-T.eq(Pipelines.level("voxel"), 1, "3 cycles the voxel camera ladder")
+T.eq(Pipelines.levelLabel("voxel"), "75",
+  "a press from FULL goes to 75, since FULL already IS the 50 camera")
+
+Pipelines.setLevel("voxel", 0)
+Game.keypressed(keyGame, "3")
+T.eq(Pipelines.level("voxel"), 2, "3 cycles the voxel camera ladder")
 Game.keypressed(keyGame, "3")
-T.eq(Pipelines.level("voxel"), 2, "and keeps climbing it")
+T.eq(Pipelines.level("voxel"), 3, "and keeps climbing it")
 
 Game.keypressed(keyGame, "6")
 T.eq(Pipelines.level("tiltshift"), 1, "6 cycles the tilt-shift blur")
@@ -780,7 +894,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer")
 -- TILT with or without us. Park the ladder on its top rung and turn both
 -- back on, so the single press under test is the one that wraps to OFF --
 -- where nothing else is going to clear them.
-Pipelines.setLevel("voxel", Pipelines.maxLevel("voxel"))
+-- 5 is the "75" rung, the last one the key walks before it wraps to OFF
+Pipelines.setLevel("voxel", 5)
 Tilt.setLevel(3)
 GBCFX.setLevel(4)
 keyGame.save.options.tilt = 3
@@ -1256,6 +1371,26 @@ T.check(math.abs(sx - px) < 0.2,
 -- arena floor out from under the two mons pinned to it
 T.eq(rig.curve, 0, "the battle camera switches the world curve off")
 
+-- ------- the wireframe is forced on in a battle
+--
+-- A fight is a staged shot rather than the world being walked through, so it
+-- always wears the seams. The player's own V-GRID row must not be touched by
+-- that -- an override, not a write, or switching the mode off mid-battle
+-- would quietly rewrite a setting they chose.
+local Grid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
+Grid.override = nil
+local rowWas = Grid.setting:get()
+T.eq(Grid.enabled(), rowWas and true or false,
+  "with no override the wireframe follows the row")
+Grid.override = true
+T.eq(Grid.enabled(), true, "an override forces it on")
+T.eq(Grid.setting:get(), rowWas, "and leaves the player's row alone")
+Grid.override = false
+T.eq(Grid.enabled(), false, "an override can force it off too")
+Grid.override = nil
+T.eq(Grid.enabled(), rowWas and true or false,
+  "and clearing it hands the answer back to the row")
+
 -- ------- the depth of field is measured off the two marks
 --
 -- The slab held sharp is the one the mons are standing in, so the band has