DramaticShapeVoxelMod archival mirror · read-only
66da6cbc4b17a08afb9714101cd11c701f585c33

fix catch regression + add new menus

DramaticShape · 2026-08-08 · parent 121c87b629

fix catch regression + add new menus

Changed files

CHANGELOG.md                  |  66 +++++++++
 lib/CatchThrow.lua            |  40 +++++-
 lib/ModSetting.lua            |  16 ++-
 lib/SettingsMenu.lua          | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++
 main.lua                      | 323 ++++++++++++++++++++++++++++--------------
 tests/dramatic_shape_test.lua | 475 ++++++++++++++++++++++++++++++++++++++++++++++++++------------
 tests/letsgo_route6.lua       | 104 ++++++++++++++
 7 files changed, 1213 insertions(+), 198 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c468440..73c81ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -77,8 +77,74 @@
   nor takes it away, on the same reasoning as AA -- what the look costs is
   the player's question, not a preset's.
 
+### Changed
+
+- **The settings live on menus of their own now, behind one red row at the
+  top of OPTIONS.** This mod had grown to fourteen rows on the engine's
+  list, spliced in as one block. OPTIONS shows four boxes at a time, so that
+  was four screens of scrolling inside a list that already carried twenty
+  engine rows, and finding SHADOWS meant knowing it was in there somewhere
+  past the wireframe and the horizon bend.
+
+  What is on OPTIONS now is `DRAMATIC SHAPE..`, and it leads the list --
+  a mod that replaces the look of the whole game should not make the player
+  scroll to find out where its settings went, least of all past the engine
+  rows it has quietly taken away. It opens VOXEL and T-SHIFT, which came off
+  the engine's list with it, and four categories: **3D WORLD** (V-GRID,
+  V-CURVE, RENDER DIST, WATER, DAYTIME), **BATTLES** (3D-BTL, BACK SPRITES,
+  LET'S GO, STADIUM ROM), **PERFORMANCE** (FOREST FX, SHADOWS, AA) and
+  **VR** (VR, SMOOTH TURN).
+
+  The split is not a new opinion: it is the `full` flag each row already
+  carried. `full` marks a row the FULL preset does not take away, and the
+  reason written beside each one was always the same -- this is a question
+  about the HARDWARE, or about the GAME, not a knob on the diorama FULL is a
+  preset for. So 3D WORLD is exactly the rows FULL owns, and needs no rule
+  to disappear under it: every child filters itself out and an empty category
+  is not offered. Under FULL the menu is four rows on one screen with no
+  scroll arrow. The same rule retires VR where there is no VR to have.
+
+  **Nothing you had set has moved.** Every setting keeps its stored key, its
+  ladder and its row id, so `options.lua` is byte-identical across the
+  upgrade for a player who changes nothing -- and the hotkeys are untouched,
+  which is what makes the nesting affordable: 3, 5, 6, 7, 8 and 9 still put
+  every buried row one keypress away. The mod manager's own page still lists
+  all thirteen settings flat, now in category order.
+
+  The row is drawn in red, which is a palette zone rather than a color:
+  `setColor` cannot tint this text, because the glyph atlas is black ink and
+  LOVE tints multiplicatively, and because the palette shader keys on the red
+  channel alone and would send a red pixel to the lightest slot. What the
+  zone changes is which color the shade the text was drawn in comes out as.
+  It is MEWMON -- the palette the OPTIONS menu already wears -- copied with
+  only the ink slot replaced, so the paper under the row is the same white as
+  the row above it in all three ROMs, and the band covers the two text lines
+  alone rather than the cursor and the box borders beside them. SGB INV
+  reverses a palette, so there the red starts in the other slot and still
+  lands on the ink; OG, OG INV and CLASSIC substitute their own tables
+  outright, and the row simply draws monochrome, which is what asking for a
+  screen with no colors in it should get.
+
 ### Fixed
 
+- **A setting that pins another one now pins it from wherever it was
+  changed.** 3D-BTL holds BATTLE LAYOUT at OG while a fight can be staged on
+  the map, and FULL holds DAYTIME at SYNC while it owns that row. Both pins
+  used to be a side effect of the options-rows hook, which every step on the
+  OPTIONS menu happened to rerun -- so they fired whether or not the step was
+  the one that mattered, and nothing had to name them. A step made on the
+  mod's own menus reruns no hook, so the pinning is a function now, and the
+  hook, the menus and the mod manager's page all ask for it.
+
+- **An open OPTIONS menu notices a change made on a menu pushed over it.**
+  The rebuild that keeps the row list honest compared the voxel level and the
+  two battle switches across one call of `update`. The stack ticks its top
+  state only, so a step taken on one of the mod's own menus happens while
+  OPTIONS is suspended: both halves of that comparison were read after the
+  fact and always agreed, and OPTIONS came back still showing a BATTLE LAYOUT
+  row that no longer belonged there. The signature is held on the menu and
+  stamped where the rows are built, which is the thing it is a signature of.
+
 - **A building's back no longer wears its own front door.** Every voxelized
   building is its drawing extruded straight through the footprint, so the
   far wall is the facade again -- and read from behind, the facade mirrored:
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
index 287e0f3..777ec75 100644
--- a/lib/CatchThrow.lua
+++ b/lib/CatchThrow.lua
@@ -285,19 +285,55 @@ end
 -- ball at the bottom of the frame, the throw a straight shot up the
 -- middle. Handed to BattleScene.render through the capture table's `rig`;
 -- everything downstream (pins, cards, sun, fov) is camera-generic.
+-- ------- and how far back the WORLD lets it stand
+--
+-- SEAT_BACK is what the shot wants. It is not always available: this seat
+-- is low (SEAT_UP is 13 world pixels, under a single cell) where the
+-- battle's own rig stands at 37.9, and it is planted three cells behind
+-- the player wherever the encounter happened -- which on a hedged route
+-- like ROUTE 6 is inside the hedge. The eye then looks out from within
+-- the foliage and the scenery it is standing in hangs across the top of
+-- the frame, over the horizon and the distant rooftops both.
+--
+-- ThirdPerson's boom answers exactly this question for the free-roam
+-- camera, so it answers it here rather than being restated: march back
+-- from the player's cell and stop at the first thing the eye may not be
+-- inside, keeping its PAD. Its rule is the right one for a seat this low,
+-- too -- terrain height always blocks, and anything BUILT on a cell
+-- blocks below head height, so a kerb is passed over and a hedge is not.
+--
+-- Pulling in brings the foe closer in a fixed field of view, which is a
+-- composition the throw already handles: the ring, the collision and the
+-- drag are all measured from the live shot, so they follow the seat.
+local SEAT_MIN_BACK = 20     -- never nearer than this: the held ball hangs
+                             -- at the player's own cell and has to stay in
+                             -- front of the eye
+
+local function seatBack(px, pz, ax, az, eyeY)
+  local ok, ow = pcall(function() return game().overworld end)
+  if not (ok and ow) then return SEAT_BACK end
+  local ThirdPerson = V.require("ThirdPerson")
+  local okR, reach = pcall(ThirdPerson.reach, ow, { px, eyeY, pz },
+                           -ax, 0, -az, SEAT_BACK)
+  if not (okR and reach) then return SEAT_BACK end
+  return math.max(SEAT_MIN_BACK, math.min(SEAT_BACK, reach))
+end
+
 local function captureRig(arena, groundY)
   local ex, ez = arena.enemy[1], arena.enemy[2]
   local px, pz = arena.player[1], arena.player[2]
   local ax, az = ex - px, ez - pz
   local l = math.sqrt(ax * ax + az * az)
   if l < 1e-6 then ax, az = 0, -1 else ax, az = ax / l, az / l end
+  local eyeY = groundY + SEAT_UP
+  local back = seatBack(px, pz, ax, az, eyeY)
   local cam = {
-    eye = { px - ax * SEAT_BACK, groundY + SEAT_UP, pz - az * SEAT_BACK },
+    eye = { px - ax * back, eyeY, pz - az * back },
     focus = { ex, groundY + 8, ez },
     fov = SEAT_FOV,
   }
   -- the pitch VoxelScene.pull wants: how far below level the seat looks
-  local pitch = math.atan2(SEAT_UP - 8, SEAT_BACK + l)
+  local pitch = math.atan2(SEAT_UP - 8, back + l)
   return cam, math.max(pitch, 0.05), SEAT_FRAME
 end
 
diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua
index 33c44ad..d87112b 100644
--- a/lib/ModSetting.lua
+++ b/lib/ModSetting.lua
@@ -168,6 +168,15 @@ function ModSetting:sync(value)
   self.index = indexOf(self, value)
 end
 
+-- The label of the rung actually in force, which is not the stored one when
+-- that rung has been gated away (see get). Its own entry point because a
+-- caller can want the label without wanting a row: SettingsMenu puts one
+-- setting's rung on the second line of the CATEGORY that contains it.
+function ModSetting:valueLabel()
+  local i = self:read()
+  return self.labels[self:allows(i) and i or 1]
+end
+
 -- The descriptor src/ui/OptionRows.lua renders, in the shape the
 -- ui.options.rows hook appends.
 function ModSetting:row()
@@ -175,12 +184,7 @@ function ModSetting:row()
   return {
     id = "DRAMATIC_SHAPE:" .. self.key,
     label = self.label,
-    -- the label of the rung actually in force, which is not the stored one
-    -- when that rung has been gated away (see get)
-    value = function()
-      local i = self_:read()
-      return self_.labels[self_:allows(i) and i or 1]
-    end,
+    value = function() return self_:valueLabel() end,
     step = function(game, dir)
       self_:cycle(game, dir)
       return true
diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua
new file mode 100644
index 0000000..0b19d67
--- /dev/null
+++ b/lib/SettingsMenu.lua
@@ -0,0 +1,387 @@
+-- This mod's settings, in categories, on menus of their own.
+--
+-- ------- why the flat list had to end
+--
+-- Every setting used to be spliced straight into the engine's OPTIONS list,
+-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
+-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
+-- was four screens of scrolling inside a list that already carried twenty
+-- engine rows -- and a player looking for SHADOWS had to know it was in there
+-- somewhere, past the wireframe and the horizon bend.
+--
+-- The engine has no grouping to borrow: a row descriptor is
+-- { id, label, value, step, activate } and nothing else. No headers, no
+-- sections, no pages. What it DOES have is `activate`, and a state stack that
+-- any state may push onto -- which is how the engine's own MODS and CONTROLS
+-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
+--
+-- ------- how the split was chosen
+--
+-- Not invented here: the mod already sorted its own settings, in the `full`
+-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
+-- take away, and the reasoning written next to each one is always the same
+-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
+-- the diorama FULL is a preset for.
+--
+-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
+-- case to disappear under FULL: every child filters itself out and the
+-- category goes with them (see rows). PERFORMANCE is the three rows marked
+-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
+-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
+-- are the two features that are not about the look at all.
+--
+-- ------- what did NOT change
+--
+-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
+-- row id, so options.lua is byte-identical for a player who upgrades and
+-- changes nothing -- see lib/ModSetting.lua for why the key is the only
+-- identity a setting has. The hotkeys are untouched too, which is what makes
+-- the nesting affordable: a buried row is still one keypress away.
+
+-- the mod namespace (see main.lua)
+local V = ...
+
+local OptionRows = require("src.ui.OptionRows")
+local PaletteFX = require("src.render.PaletteFX")
+
+local SettingsMenu = {}
+SettingsMenu.__index = SettingsMenu
+
+-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
+-- covered, so there is no reason to pay for drawing it.
+SettingsMenu.isOpaque = true
+
+SettingsMenu.ROOT = "root"
+SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
+
+-- Row ids live in a namespace of their own -- "menu." rather than a setting
+-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
+-- settings rows have carried since the beginning.
+function SettingsMenu.id(catId)
+  return "DRAMATIC_SHAPE:menu." .. catId
+end
+
+-- ------- the categories, in menu order
+--
+-- `summary` is the second line of the category's own row, the way MODS reads
+-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
+-- 3D-BTL for the battles, VR for the headset -- it says that setting's
+-- current rung, which is the thing a player actually wants to know without
+-- opening it. Where no single row speaks for the rest, it counts them, which
+-- is honest rather than arbitrary.
+SettingsMenu.CATEGORIES = {
+  { id = "world", label = "3D WORLD.." },
+  { id = "battles", label = "BATTLES..",
+    summary = function() return V.require("OverworldBattle").setting:valueLabel() end },
+  { id = "perf", label = "PERFORMANCE.." },
+  { id = "vr", label = "VR..",
+    summary = function() return V.require("VR").setting:valueLabel() end },
+}
+
+-- ------- what the menus are built from
+--
+-- SETTINGS lives in main.lua, next to the help text that goes with each row
+-- and the comments explaining every `when` and `full`. It is handed here
+-- rather than moved, so this file stays about PRESENTATION and that one stays
+-- the single place the mod's settings are declared.
+local settings = {}
+local pipelineRows = {}
+
+function SettingsMenu.define(list)
+  settings = list or {}
+end
+
+-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
+-- .rows), captured by the options hook on its way past and shown here instead
+-- of at the top level. Reused verbatim, tables and all: they persist in
+-- save.options.pipelines through their own step functions, and rebuilding
+-- them here would be a second implementation of a thing the engine already
+-- got right.
+function SettingsMenu.setPipelineRows(rows)
+  pipelineRows = rows or {}
+end
+
+-- ------- a step here has the same consequences as a step anywhere
+--
+-- Two of these settings PIN something else when they change: 3D-BTL holds
+-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
+-- DAYTIME at SYNC while it owns that row. Both used to happen because every
+-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
+-- pinning on its way past.
+--
+-- Nothing reruns that hook from in here, so the pin is asked for directly.
+-- main.lua supplies it, because WHICH values follow which is a question about
+-- the mod's settings and not about the menu they are on.
+local onChanged = nil
+
+function SettingsMenu.setOnChanged(fn)
+  onChanged = fn
+end
+
+local function isFull()
+  local Pipelines = require("src.render.Pipelines")
+  return V.require("VoxelState").isFull(Pipelines.level("voxel"))
+end
+
+-- The one rule that decides whether a setting is on a menu, lifted unchanged
+-- from the options hook it used to live in.
+--
+-- FULL: a preset that owns the look, so the rows that describe the look go
+-- with it. And a row whose own switch is off the table this frame (BACK
+-- SPRITES, which needs a staged fight to be about) is left off with it. The
+-- mod manager's page carries every one of them either way.
+local function offered(entry, full)
+  return (entry.full or not full) and (not entry.when or entry.when())
+end
+
+-- The rows of one category, or of the root menu. PURE -- no state, no stack,
+-- no side effects -- so a caller that only wants to know what is on a menu
+-- (a test, or the root menu asking whether a category has anything in it)
+-- does not have to push a screen to find out.
+function SettingsMenu.rows(catId, game)
+  local full = isFull()
+  local out = {}
+  if catId == SettingsMenu.ROOT then
+    for _, row in ipairs(pipelineRows) do
+      -- FULL owns the blur exactly as it owns the wireframe and the horizon
+      -- bend, so T-SHIFT comes off with them
+      if not (full and row.id == "pipeline:tiltshift") then
+        out[#out + 1] = row
+      end
+    end
+    for _, cat in ipairs(SettingsMenu.CATEGORIES) do
+      local kids = SettingsMenu.rows(cat.id, game)
+      -- An EMPTY category is not offered. This is the whole of what makes
+      -- 3D WORLD disappear under FULL and VR disappear off Windows: no
+      -- special case, just nothing left inside to open.
+      if kids[1] then
+        out[#out + 1] = {
+          id = SettingsMenu.id(cat.id),
+          label = cat.label,
+          value = cat.summary
+            or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
+          activate = function(g)
+            g.stack:push(SettingsMenu.new(g, cat.id))
+          end,
+        }
+      end
+    end
+    return out
+  end
+  for _, entry in ipairs(settings) do
+    if entry.cat == catId and offered(entry, full) then
+      out[#out + 1] = entry[1]:row()
+    end
+  end
+  -- and the ROM import, which is an ACTION and not a setting: there is no
+  -- rung to store, nothing for the mod manager's page to persist and nothing
+  -- to restore on the next boot, so it is appended here rather than living in
+  -- SETTINGS. It sits under the battles because the STADIUM rungs of 3D-BTL
+  -- are what it unlocks.
+  if catId == "battles" then
+    local ok, importRow = pcall(function()
+      return V.require("StadiumRomPick").row()
+    end)
+    if ok and importRow then out[#out + 1] = importRow end
+  end
+  return out
+end
+
+-- ------- red ink for the mod's row on the OPTIONS menu
+--
+-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
+-- nobody spends an afternoon on it. Twice over:
+--
+--   1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
+--      and Font.drawCode is a plain love.graphics.draw, which LOVE tints
+--      MULTIPLICATIVELY. black x red is black.
+--   2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
+--      RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
+--      red pixel lands in c0, the LIGHTEST slot: white text on white paper.
+--
+-- What actually happens on this screen is that setColor picks a SHADE and the
+-- zone palette picks the COLOR. Black text is c3 and the white box fill is
+-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
+-- The engine does the same thing for the party menu's HP bars
+-- (src/ui/PartyMenu.lua), which is the pattern this follows.
+SettingsMenu.INK = { 255, 0, 0 }
+
+-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
+-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
+-- Blue and Yellow ship different MEWMON tables, and this way the paper under
+-- the row is the same white as the row above it in all three.
+function SettingsMenu.redPalette(data)
+  local base = PaletteFX.pal(data, "MEWMON")
+  if not base then return nil end
+  local out = { base[1], base[2], base[3], base[4] }
+  -- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
+  -- it the ink is the first slot and the paper the last. Put the red where it
+  -- will land on the INK either way: without this the row draws as a solid
+  -- red block with white letters cut out of it.
+  --
+  -- The other modes need nothing. OG, OG INV and CLASSIC discard the table
+  -- outright and substitute their own, so the row simply draws monochrome --
+  -- which is correct: the player asked for a screen with no colors in it.
+  out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
+  return out
+end
+
+-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
+-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
+-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
+-- 19 are the box's own borders and tile 1 is the cursor, and all three are
+-- black glyphs that would turn red along with the text if the band spanned
+-- the whole row.
+function SettingsMenu.rowZone(data, slot)
+  local pal = SettingsMenu.redPalette(data)
+  if not pal then return nil end
+  local top = (slot - 1) * 4 + 1
+  return PaletteFX.zone(pal, 2, top, 18, top + 1)
+end
+
+-- ------- the screen
+--
+-- Deliberately NOT an OptionsMenu instance, though the update loop below is
+-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
+-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
+-- level or the battle rows change -- which would replace a submenu's rows
+-- with the whole top-level OPTIONS list under the player's cursor. A state of
+-- our own cannot be caught by it.
+--
+-- It still renders through OptionRows, so it is the same four boxes, the same
+-- cursor and the same bottom line as every other menu in the game.
+function SettingsMenu.new(game, catId)
+  local self = setmetatable({
+    game = game,
+    cat = catId or SettingsMenu.ROOT,
+    index = 1,
+    scroll = 0,
+  }, SettingsMenu)
+  self.rows = SettingsMenu.rows(self.cat, game)
+  self.sig = SettingsMenu.signature(self.rows)
+  return self
+end
+
+function SettingsMenu.signature(rows)
+  local ids = {}
+  for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
+  return table.concat(ids, "\1")
+end
+
+-- OptionRows has no room for a title: the four boxes fill the screen down to
+-- the bottom line. So the bottom line carries the name as well as the way
+-- out, which is the one place left to say where the player is. "BACK" alone
+-- at the root, where "BACK: DRAMATIC SHAPE" would run past the 18 characters
+-- the line has.
+function SettingsMenu:backLabel()
+  if self.cat == SettingsMenu.ROOT then return "BACK" end
+  for _, cat in ipairs(SettingsMenu.CATEGORIES) do
+    if cat.id == self.cat then
+      return "BACK: " .. (cat.label:gsub("%.%.$", ""))
+    end
+  end
+  return "BACK"
+end
+
+-- A category's contents can change while the player is looking at them: 3D-BTL
+-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
+-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
+-- actually differs, so the common case -- every other rung of every other row
+-- -- costs one string compare.
+function SettingsMenu:refresh()
+  local rows = SettingsMenu.rows(self.cat, self.game)
+  local sig = SettingsMenu.signature(rows)
+  if sig == self.sig then return end
+  -- Follow the row the cursor was ON rather than the slot it was in: a row
+  -- can appear ABOVE the one just used, which would otherwise slide the
+  -- cursor onto its neighbour. The bottom line follows itself.
+  local wasBack = self.index > #self.rows
+  local wasOn = self.rows[self.index] and self.rows[self.index].id
+  self.rows, self.sig = rows, sig
+  self.index, self.scroll = 1, 0
+  if wasBack then
+    self.index = #rows + 1
+  else
+    for i, row in ipairs(rows) do
+      if wasOn and row.id == wasOn then self.index = i break end
+    end
+  end
+end
+
+local function pop(self)
+  local stack = self.game and self.game.stack
+  if self.game and self.game.data then
+    require("src.core.Sound").play(self.game.data, "Press_AB")
+  end
+  if stack and stack:top() == self then stack:pop() end
+end
+
+-- The engine's own options loop (src/ui/OptionsMenu.update), including its
+-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
+-- alone, and the bottom line is a synthetic index past the end of the list
+-- rather than a row, so nothing a category contains can orphan the way out.
+function SettingsMenu:update()
+  local input = self.game and self.game.input
+  if not input then return end
+  local rows = self.rows
+  local back = #rows + 1
+  local changed = false
+  if input:wasPressed("up") then
+    self.index = self.index - 1
+    if self.index < 1 then self.index = back end
+  elseif input:wasPressed("down") then
+    self.index = self.index + 1
+    if self.index > back then self.index = 1 end
+  elseif input:wasPressed("left") or input:wasPressed("right")
+      or input:wasPressed("a") then
+    local dir = input:wasPressed("left") and -1 or 1
+    local row = rows[self.index]
+    if row and row.activate then
+      if input:wasPressed("a") then row.activate(self.game) end
+    elseif row and row.step then
+      changed = row.step(self.game, dir) and true or false
+    elseif input:wasPressed("a") then
+      pop(self)
+      return
+    end
+  elseif input:wasPressed("b") or input:wasPressed("start") then
+    -- B and START both, like every other menu -- and one level only: this
+    -- pops US, leaving the OPTIONS menu underneath exactly as the player
+    -- left it, with its own onCancel still to fire when they leave THAT.
+    pop(self)
+    return
+  end
+  if changed then
+    -- before the rebuild, not after: pinning can itself change which rows are
+    -- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
+    -- it), and refresh has to see the settled answer
+    if onChanged then pcall(onChanged, self.game) end
+    if self.game.writeOptions then
+      pcall(self.game.writeOptions, self.game)
+    end
+  end
+  self:refresh()
+  self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
+                                       #self.rows + 1)
+end
+
+function SettingsMenu:draw()
+  OptionRows.draw(self.game, self.rows, self.index, self.scroll,
+                  self:backLabel(), #self.rows + 1)
+end
+
+-- REQUIRED, even though nothing here is red.
+--
+-- Game:draw walks the stack from the top and stops at the first state that
+-- HAS this method, not the first that answers something. Without one of our
+-- own the walk would fall through to the OPTIONS menu underneath -- whose
+-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
+-- zone is addressed by SLOT, so it would land on whatever this menu happens
+-- to be showing in the same box.
+--
+-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
+function SettingsMenu:sgbPalettes(game)
+  return PaletteFX.wholeNamed(game.data, "MEWMON")
+end
+
+return SettingsMenu
diff --git a/main.lua b/main.lua
index 810c023..9e236bd 100644
--- a/main.lua
+++ b/main.lua
@@ -98,6 +98,9 @@ local FirstPerson = V.require("FirstPerson")
 local FreeMove = V.require("FreeMove")
 local CamControl = V.require("CamControl")
 local VR = V.require("VR")
+-- the mod's settings menus: the categories, the screens they open, and the
+-- red ink that marks this mod's one row on the engine's OPTIONS list
+local SettingsMenu = V.require("SettingsMenu")
 -- HORDE MODE: the konami code's minigame. Horde owns the state machine and
 -- every hook; the other four are the gun, the crowd, the readout and the
 -- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
@@ -449,8 +452,26 @@ local function stagedBattles()
   return OverworldBattle.enabled()
 end
 
+-- ------- this mod's settings, grouped the way the menus present them
+--
+-- One entry per setting: the ModSetting itself, the help text the mod
+-- manager's page carries, and the fields that decide where it is offered.
+--
+--   cat   which of SettingsMenu's categories the row lives on. The table is
+--         kept in category order as well, so the mod manager's own page --
+--         which has no categories to give and lists every row flat -- at
+--         least keeps related settings next to each other.
+--   when  a predicate. The row is off the menu entirely while it answers
+--         false, because a row that decides nothing reads as a broken mod.
+--   full  the row SURVIVES the FULL preset. FULL owns the look, so a row
+--         goes with it by default; `full` marks the ones that were never
+--         about the look. SettingsMenu leans on this and needs no rule of
+--         its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
+--         category empties out under FULL and takes itself off the menu.
 local SETTINGS = {
-  { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
+  -- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
+  { VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
+    cat = "world" },
   { WorldCurve.setting,
     "Bend the world down over the horizon, Animal Crossing style. 1 is a "
     .. "hint of roll at the frame edges and 2 is the classic read; 3 is as "
@@ -458,7 +479,8 @@ local SETTINGS = {
     .. "walk into. 4 and 5 are past that on purpose and they are for a "
     .. "headset's DIORAMA, where the world is a model being looked at "
     .. "rather than walked around in -- 5 curls it into a half sphere, a "
-    .. "town on top of its own little planet." },
+    .. "town on top of its own little planet.",
+    cat = "world" },
   { ViewBox.setting,
     "How much of the map the camera bothers to draw. FIT is exactly the "
     .. "ground on screen and no more -- the shape a tilted camera really "
@@ -472,24 +494,23 @@ local SETTINGS = {
     .. "closest, WIDE through WIDEST push the world's edge further out, "
     .. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are "
     .. "standing in the world there -- and the box opens out and away as "
-    .. "the camera dives in." },
+    .. "the camera dives in.",
+    cat = "world" },
   { Water.setting,
     "Reflections on water. FULL adds screen-space reflections of the "
     .. "shoreline, the trees and the buildings behind it; SKY is the sky, "
     .. "the sun and the moon alone, which is most of the look for a "
-    .. "fraction of the cost." },
-  -- `full` for the AA reason: additive shafts are fill rate, and under 4X
-  -- supersampling that is a question about the hardware, not the look.
-  { ForestAtmos.setting,
-    "The air of the deep woods (Viridian Forest): a ground haze, and "
-    .. "volumetric light let down through the unseen canopy overhead -- "
-    .. "gold spears of sun by day, silver moon rays at night, pollen "
-    .. "drifting through the beams and fireflies once they cool. LOW "
-    .. "keeps the haze, halves the beam march and stands the particles "
-    .. "down. On a phone the row offers LOW alone: the beams need a "
-    .. "depth texture the pass can read back, and no mobile driver here "
-    .. "grants one.",
-    full = true },
+    .. "fraction of the cost.",
+    cat = "world" },
+  { DayNight.setting,
+    "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
+    .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
+    .. "shadows, the sky and the light following -- or SYNC it to the "
+    .. "clock on the wall, so Kanto's evening falls when yours does.",
+    cat = "world" },
+
+  -- ------- BATTLES -- what a fight is drawn over, and how it is played
+  --
   -- `full` marks a row FULL does not take away. FULL owns the diorama's own
   -- knobs; what a battle is drawn over, and how it is framed, are not that.
   -- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
@@ -509,6 +530,7 @@ local SETTINGS = {
     .. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the "
     .. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
     .. "other version works: the reader is keyed to that one cartridge.",
+    cat = "battles",
     when = function() return not VR.enabled() end, full = true },
   -- Only offered while a fight can actually be staged on the map: with 3D-BTL
   -- off the engine draws the classic screen, which is this row's ON already,
@@ -517,6 +539,7 @@ local SETTINGS = {
     "Keep your own Pokemon on the battle menu, seen from behind in its "
     .. "original slot, instead of standing it on the map facing the foe. "
     .. "The foe is still out there on its own tile.",
+    cat = "battles",
     when = function() return stagedBattles() and not VR.enabled() end,
     full = true },
   -- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
@@ -534,12 +557,26 @@ local SETTINGS = {
     .. "throws, new species and your running catch combo. Needs 3D-BTL "
     .. "on; anywhere the staged fight cannot stand, balls quietly throw "
     .. "the classic way.",
-    full = true },
-  { DayNight.setting,
-    "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
-    .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
-    .. "shadows, the sky and the light following -- or SYNC it to the "
-    .. "clock on the wall, so Kanto's evening falls when yours does." },
+    cat = "battles", full = true },
+
+  -- ------- PERFORMANCE -- what the look COSTS, which is a different question
+  --
+  -- All three are `full`, and all three for the same reason: FULL is a preset
+  -- for the diorama, not a licence to spend whatever the machine it happens
+  -- to be running on has got. The player decides what their hardware can
+  -- carry, from inside FULL like anywhere else.
+  -- `full` for the AA reason: additive shafts are fill rate, and under 4X
+  -- supersampling that is a question about the hardware, not the look.
+  { ForestAtmos.setting,
+    "The air of the deep woods (Viridian Forest): a ground haze, and "
+    .. "volumetric light let down through the unseen canopy overhead -- "
+    .. "gold spears of sun by day, silver moon rays at night, pollen "
+    .. "drifting through the beams and fireflies once they cool. LOW "
+    .. "keeps the haze, halves the beam march and stands the particles "
+    .. "down. On a phone the row offers LOW alone: the beams need a "
+    .. "depth texture the pass can read back, and no mobile driver here "
+    .. "grants one.",
+    cat = "perf", full = true },
   -- `full` on AA's reasoning below, and for the same reason: the sun's pass
   -- is the most expensive thing in the frame after the geometry, so this is
   -- a question about the machine rather than a knob on the diorama, and it
@@ -554,13 +591,9 @@ local SETTINGS = {
     .. "phone or an old machine. OFF is no shadow at all, the flat drop "
     .. "shadows under characters included, and the forest's light shafts go "
     .. "with it: the beams are lit by the sun's own map.",
-    full = true },
+    cat = "perf", full = true },
   -- Marked `full` for the opposite reason the battle rows are: this is not a
-  -- knob on the look at all, it is what the look COSTS. FULL is a preset for
-  -- the diorama, not a licence to spend four times the fill rate on the
-  -- machine it happens to be running on, so it neither sets this nor takes
-  -- the row away -- the player decides what their hardware can carry, from
-  -- inside FULL like anywhere else.
+  -- knob on the look at all, it is what the look COSTS.
   { AntiAlias.setting,
     "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
     .. "lips, a tree against the sky -- by rendering the diorama larger than "
@@ -569,7 +602,10 @@ local SETTINGS = {
     .. "reads smoother rather than sharper. 2X costs half again as many "
     .. "pixels in each direction and 4X twice, which makes this the most "
     .. "expensive row in the mod.",
-    full = true },
+    cat = "perf", full = true },
+
+  -- ------- VR -- the headset, and the one comfort knob that is only its
+  --
   -- `full` for the same reason as AA: not a knob on the look, a question
   -- about the hardware on the desk.
   { VR.setting,
@@ -588,6 +624,7 @@ local SETTINGS = {
     .. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
     .. "and the mod running from a real folder; without them the row stays "
     .. "and the game stays flat, with the reason on the console.",
+    cat = "vr",
     -- on Windows the row stays even when a runtime is missing (the console
     -- says why); off Windows -- mobile above all -- there is no VR to have
     -- and the row does not exist
@@ -601,12 +638,15 @@ local SETTINGS = {
     .. "world past a head that did not move, which is the most reliable way "
     .. "to make somebody ill in a headset. Turn it on if you have your sea "
     .. "legs and want the continuity.",
+    cat = "vr",
     -- and only under STANDARD: the stick turns a HEAD, and neither diorama
     -- mode has the player standing in the world to be turned
     when = function() return VR.enabled() and not VR.dioramaMode() end,
     full = true },
 }
 
+SettingsMenu.define(SETTINGS)
+
 local schema = {}
 for _, entry in ipairs(SETTINGS) do
   -- the VR rows are absent from the mod manager's page too where the
@@ -783,29 +823,31 @@ do
   end
 end
 
--- ------- the mode's rows, kept together
+-- ------- the mode's rows, on menus of their own
 --
--- 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.
+-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
+-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
+-- time, so that was four screens of scrolling inside a list that already
+-- carried twenty engine rows, and finding SHADOWS meant knowing it was in
+-- there past the wireframe and the horizon bend.
 --
--- 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
+-- Now there is ONE row, and it leads the list. What it opens -- the
+-- categories, the screens, and why the split falls where it does -- is
+-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
+-- display modes, the engine only spliced them beside TILT because it had
+-- nowhere better, and TILT is not on the menu any more anyway (see below).
+--
+-- Two things it takes to move a pipeline row: the engine's descriptor is
+-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists
+-- through its own step function into save.options.pipelines, and rebuilding
+-- it here would be a second implementation of something the engine already
+-- got right -- and the row is then dropped from the top-level list so it is
+-- not in two places at once.
+local function captureRow(out, id)
+  for _, row in ipairs(out) do
+    if type(row) == "table" and row.id == id then return row end
   end
-  for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
-  return out
+  return nil
 end
 
 -- FULL owns the settings that describe the LOOK, so while it is selected those
@@ -875,6 +917,25 @@ local function pinEngineFx(game)
   if changed and game.writeOptions then pcall(game.writeOptions, game) end
 end
 
+-- ------- the values that follow other values
+--
+-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a
+-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns
+-- that row. Both pins used to be a side effect of the rows hook, which every
+-- step on the OPTIONS menu reran -- so they happened whether or not the step
+-- was the one that mattered, and nothing had to name them.
+--
+-- Now a step can happen on the mod's own menu, where no hook runs, or on the
+-- mod manager's page, where one never did. So the pinning is a function, and
+-- all three routes ask for it.
+local function pinDependents(game)
+  if stagedBattles() then OverworldBattle.forceOG(game) end
+  local Pipelines = require("src.render.Pipelines")
+  if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end
+end
+
+SettingsMenu.setOnChanged(pinDependents)
+
 -- 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)
@@ -901,46 +962,50 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
     OverworldBattle.forceOG(game)
     dropRow(out, "battleLayout")
   end
-  local full = Voxel.isFull(Pipelines.level("voxel"))
-  if full then
+  if Voxel.isFull(Pipelines.level("voxel")) then
     -- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
-    -- horizon bend, the blur, the hour -- so those come off the menu and
-    -- DAYTIME is held at SYNC while its row is unreachable.
+    -- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
+    -- row is unreachable. The rows themselves come off inside SettingsMenu,
+    -- which is where they live now: T-SHIFT with the wireframe and the bend,
+    -- and each of them by the same `full` rule rather than by name.
     DayNight.forceSync(game)
-    dropRow(out, "pipeline:tiltshift")
   end
-  local extra = {}
-  for _, entry in ipairs(SETTINGS) do
-    -- Two things decide whether a row is offered.
-    --
-    -- FULL: a preset that owns the look, so the rows that describe the look go
-    -- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
-    -- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
-    -- the diorama FULL is a preset for. FULL still SETS them on arrival (see
-    -- applyFull); it does not hold them, so leaving them on the menu is the
-    -- difference between a preset and a lock.
-    --
-    -- And a row whose own switch is off the table this frame (BACK SPRITES,
-    -- which needs a staged fight to be about) is left off with it. The mod
-    -- manager's page carries every one of them either way.
-    local offered = (entry.full or not full)
-                    and (not entry.when or entry.when())
-    if offered then extra[#extra + 1] = entry[1]:row() end
+  -- The two pipeline rows move INTO the mod's own root menu: captured as the
+  -- engine built them, then dropped from here so they are not in two places.
+  local captured, voxelRow = {}, nil
+  for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
+    local row = captureRow(out, id)
+    -- a pipeline the registry refused is simply not there, and the menu says
+    -- so by not offering it rather than by offering a hole
+    if row then captured[#captured + 1] = row end
+    if id == "pipeline:voxel" then voxelRow = row end
+    dropRow(out, id)
   end
-  -- and the ROM import, which is an ACTION and not a setting: there is no
-  -- rung to store, nothing for the mod manager's page to persist and nothing
-  -- to restore on the next boot, so it is appended here rather than living in
-  -- SETTINGS. nil on a platform with no file dialog, which takes it off the
-  -- menu rather than offering a button that cannot do anything.
-  -- On EVERY platform. Where there is no file dialog it says WHERE? and
-  -- shows the folder to put the cartridge in, which is the one thing a
-  -- player on a phone could not otherwise find out -- the row used to vanish
-  -- there, which reads as the feature being missing rather than manual.
-  local okPick, importRow = pcall(function()
-    return V.require("StadiumRomPick").row()
-  end)
-  if okPick and importRow then extra[#extra + 1] = importRow end
-  return insertGrouped(out, extra)
+  SettingsMenu.setPipelineRows(captured)
+  -- ------- one row, and it leads the list
+  --
+  -- At the TOP rather than spliced in beside the display modes it used to sit
+  -- with. This is a mod that replaces the whole look of the game, and a player
+  -- who installed it and went looking for its settings should not have to
+  -- scroll to find out where they went -- least of all past the engine rows it
+  -- has quietly taken away.
+  --
+  -- Inserted after next() has run, so it leads every OTHER mod's rows too. The
+  -- second line is VOXEL's own value function, which makes the row say what
+  -- the mode is currently doing without opening it -- and reuses the engine's
+  -- label ladder rather than restating it.
+  table.insert(out, 1, {
+    id = SettingsMenu.id(SettingsMenu.ROOT),
+    label = SettingsMenu.ROOT_LABEL,
+    value = voxelRow and voxelRow.value or nil,
+    -- `activate` and not `step`: the engine fires activate on A alone, and a
+    -- row that OPENS something should not also answer Left and Right
+    -- (src/ui/OptionsMenu.update).
+    activate = function(g)
+      g.stack:push(SettingsMenu.new(g, SettingsMenu.ROOT))
+    end,
+  })
+  return out
 end)
 
 -- The mod manager writes and persists on its own, so the only thing left
@@ -951,14 +1016,11 @@ mod.events:on("mod.options_changed", function(payload)
     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
-  -- and DAYTIME changed from the manager's page while FULL owns it snaps
-  -- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
-  -- not, and FULL's pin must hold against both
-  local Pipelines = require("src.render.Pipelines")
-  if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
+  -- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
+  -- straight back to SYNC -- that row is off the mod's menus under FULL, but
+  -- the manager's page carries every setting unconditionally, and the pin has
+  -- to hold against both.
+  pinDependents()
 end)
 
 -- ------- keeping the geometry in step with the world
@@ -1058,30 +1120,55 @@ end)
 -- 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.
+--
+-- Held on the INSTANCE rather than compared across one call of update, and
+-- that is not a tidying: those three rows live in a SUBMENU now, and the
+-- stack only ticks its top state (src/core/StateStack.update). So the step
+-- that changes them happens while this menu is suspended and a
+-- before/after pair taken around inner() would both be read after the fact
+-- and always agree. A signature that outlives the suspension does not.
 do
   local OptionsMenu = require("src.ui.OptionsMenu")
   if not OptionsMenu.dramaticShapeFullHook then
+    local OptionRows = require("src.ui.OptionRows")
     local Pipelines = require("src.render.Pipelines")
     local inner = OptionsMenu.update
+    local innerPalettes = OptionsMenu.sgbPalettes
 
     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
 
+    -- What the row LIST depends on: whether FULL is selected (it owns the
+    -- rows that describe the look), and the two switches that give and take
+    -- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides
+    -- both battle rows while it is on. Only the FULL-ness of the voxel level
+    -- matters, so stepping 35 to 50 is not a change.
+    local function signature()
+      return string.format("%s|%s|%s",
+        tostring(Voxel.isFull(Pipelines.level("voxel"))),
+        tostring(OverworldBattle.enabled()), tostring(VR.enabled()))
+    end
+
+    -- Stamped where the ROWS are built, which is the thing the signature is a
+    -- signature OF. Read lazily on the first update instead and a menu opened
+    -- before the change and updated after it would compare the new state
+    -- against itself and never rebuild.
+    local innerNew = OptionsMenu.new
+    function OptionsMenu.new(game, opts)
+      local menu = innerNew(game, opts)
+      menu.dramaticShapeSig = signature()
+      return menu
+    end
+
     function OptionsMenu:update(dt)
-      local before = Pipelines.level("voxel")
-      local hadBattles = OverworldBattle.enabled()
-      -- the VR row hides the two battle rows while it is on, so stepping
-      -- it changes the LIST exactly the way 3D-BTL does
-      local hadVR = VR.enabled()
       local wasOn = idAt(self, self.index)
+      local before = self.dramaticShapeSig or signature()
       inner(self, dt)
-      local after = Pipelines.level("voxel")
-      local crossedFull = after ~= before
-                          and (Voxel.isFull(before) or Voxel.isFull(after))
-      if crossedFull or OverworldBattle.enabled() ~= hadBattles
-         or VR.enabled() ~= hadVR then
+      local after = signature()
+      self.dramaticShapeSig = after
+      if before ~= after 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:
@@ -1095,6 +1182,36 @@ do
       end
     end
 
+    -- ------- and the mod's own row is red
+    --
+    -- Why this is a palette zone and not love.graphics.setColor -- twice over
+    -- -- is written out in lib/SettingsMenu.lua, next to the code that builds
+    -- the palette. The short of it: setColor picks a SHADE on this screen and
+    -- the zone picks the COLOR.
+    --
+    -- Addressed by SLOT, because the row scrolls: it leads the list, so it is
+    -- normally the top box, but a player who scrolls past it must not leave a
+    -- red band behind on whatever takes its place. Searched by id rather than
+    -- assumed to be row 1 for the same reason -- another mod's hook running
+    -- after ours could put something above it.
+    function OptionsMenu:sgbPalettes(game)
+      local zones = innerPalettes and innerPalettes(self, game) or nil
+      local scroll = self.scroll or 0
+      for slot = 1, OptionRows.VISIBLE do
+        local row = self.rows and self.rows[scroll + slot]
+        if type(row) == "table"
+            and row.id == SettingsMenu.id(SettingsMenu.ROOT) then
+          local zone = SettingsMenu.rowZone(game and game.data, slot)
+          if zone then
+            zones = zones or {}
+            zones[#zones + 1] = zone
+          end
+          break
+        end
+      end
+      return zones
+    end
+
     OptionsMenu.dramaticShapeFullHook = true
   end
 end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 7cc7a53..d50fc97 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -119,6 +119,46 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
 
 local Runtime = require("src.mods.Runtime")
 local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+-- ------- reaching the rows now that they are on menus of their own
+--
+-- The ui.options.rows hook contributes ONE row to the engine's list, and that
+-- row opens the mod's own screens. So a test asks SettingsMenu.rows -- which
+-- is pure on purpose, precisely so that asking what is on a menu does not mean
+-- pushing one -- rather than scanning the hook's output for a setting.
+--
+-- Keyed by id, because that is the one thing about a row that is a promise.
+-- The old positional lookups (hookedRows[7] is 3D-BTL) pinned an ORDER the
+-- menus are now free to change, and every one of them had to be rewritten to
+-- get here; none of them was testing the order on purpose.
+--
+-- One table rather than three locals: this file is one Lua chunk, a chunk has
+-- 200 local slots, and it is already spending them carefully (see the scoped
+-- sections throughout).
+local Menus = { lib = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsMenu") }
+
+function Menus.rows(catId, game)
+  local ids = {}
+  for _, row in ipairs(Menus.lib.rows(catId, game or { data = Data })) do
+    ids[row.id] = row
+  end
+  return ids
+end
+
+-- every row the mod offers ANYWHERE: the root menu plus all four categories.
+-- What most of this file wants, since "is this setting on a menu at all" is
+-- the question the `when` and `full` gates actually answer.
+function Menus.offered(game)
+  local ids = Menus.rows(Menus.lib.ROOT, game)
+  for _, cat in ipairs(Menus.lib.CATEGORIES) do
+    for id, row in pairs(Menus.rows(cat.id, game)) do ids[id] = row end
+  end
+  return ids
+end
+
+-- the id of a category's own row on the root menu
+function Menus.catId(catId)
+  return Menus.lib.id(catId)
+end
 
 -- ------- FULL is a preset that owns the rows describing the LOOK
 --
@@ -131,36 +171,67 @@ local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
 -- diorama the preset is a preset FOR. FULL sets them on arrival and then lets
 -- go, which is what makes it a preset rather than a lock.
 Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
+-- scoped, like the sections below: this file is one Lua chunk and a chunk has
+-- 200 local slots, so a section that wants four borrows them
+do
 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"],
+-- what the hook leaves on the ENGINE's list: one row, and the pipeline rows
+-- gone from it because they have moved onto the mod's own root menu
+T.check(fullIds[Menus.catId(Menus.lib.ROOT)],
+  "the mod contributes exactly one row to the OPTIONS menu")
+T.check(not fullIds["pipeline:voxel"],
+  "and takes VOXEL off the engine's list -- it lives on the mod's menu now")
+T.check(not fullIds["pipeline:tiltshift"], "and T-SHIFT with it")
+T.eq(fullRows[1].id, Menus.catId(Menus.lib.ROOT),
+  "and it LEADS the list: a mod that replaces the look of the whole game "
+  .. "should not make the player scroll to find out where its settings went")
+
+local fullRoot = Menus.rows(Menus.lib.ROOT)
+local fullOffered = Menus.offered()
+T.check(fullRoot["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
+T.check(not fullRoot["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:viewbox"], "and RENDER DIST")
-T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
+T.check(not fullOffered["DRAMATIC_SHAPE:grid"], "and V-GRID")
+T.check(not fullOffered["DRAMATIC_SHAPE:curve"], "and V-CURVE")
+T.check(not fullOffered["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
+T.check(not fullOffered["DRAMATIC_SHAPE:water"], "and WATER")
+T.check(not fullOffered["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
+-- and with all five of them gone the category has nothing left in it, so it
+-- takes ITSELF off the root menu. No rule anywhere says "hide 3D WORLD under
+-- FULL" -- that category is exactly the rows FULL owns, and an empty category
+-- is not offered.
+T.check(not fullRoot[Menus.catId("world")],
+  "so 3D WORLD is empty, and an empty category is not offered")
 
 -- but the battle rows survive it: they are not knobs on the look, and FULL
 -- sets them once rather than holding them, so a player who wants the classic
 -- back sprite (or no staged fights at all) can still say so from inside FULL
-T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
-T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
+T.check(fullOffered["DRAMATIC_SHAPE:battles"],
+  "3D-BTL is still on the menu under FULL")
+T.check(fullOffered["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
+T.check(fullRoot[Menus.catId("battles")],
+  "and the category that carries them is still offered")
 -- and AA, for the opposite reason: it is not a knob on the look at all, it is
 -- what the look COSTS, and only the player knows what their machine can carry
-T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
+T.check(fullOffered["DRAMATIC_SHAPE:aa"],
+  "and AA, which FULL neither sets nor owns")
 -- SHADOWS on the same reasoning: the sun's pass is what the look costs, and
 -- a preset for the diorama does not get to spend a second draw of the whole
 -- world on a machine that cannot carry one
-T.check(fullIds["DRAMATIC_SHAPE:shadows"],
+T.check(fullOffered["DRAMATIC_SHAPE:shadows"],
   "and SHADOWS, the other row that is a question about the hardware")
+T.check(fullRoot[Menus.catId("perf")],
+  "PERFORMANCE is the category for exactly that question, and it survives too")
 -- VR survives FULL on AA's reasoning: whether a headset is on the desk is
 -- not the diorama's to decide
-T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question")
+T.check(fullOffered["DRAMATIC_SHAPE:vr"],
+  "and VR, likewise the hardware's question")
+end
 
 -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
 -- be reached while FULL owns it, so a value changed underneath (the mod
@@ -290,8 +361,14 @@ local fxIds = {}
 for _, row in ipairs(fxRows) do fxIds[row.id] = true end
 T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu")
 T.check(not fxIds["gbcfx"], "and so is GBC FX")
-T.check(fxIds["colors"] and fxIds["pipeline:voxel"],
+T.check(fxIds["colors"],
   "with every other row the engine offered still on it")
+-- VOXEL is off this list too, but for the opposite reason: not taken away,
+-- MOVED -- onto the mod's own root menu, which the one remaining row opens
+T.check(not fxIds["pipeline:voxel"] and fxIds[Menus.catId(Menus.lib.ROOT)],
+  "and VOXEL moved onto the mod's own menu rather than being dropped")
+T.check(Menus.rows(Menus.lib.ROOT)["pipeline:voxel"],
+  "-- where it is still offered")
 
 T.eq(fxGame.save.options.tilt, 0,
   "a save that had TILT on is pinned back to off, not left on with no row")
@@ -310,32 +387,64 @@ T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"],
 Pipelines.setLevel("voxel", 2)
 end
 
--- ------- and off FULL, the rows come back, grouped with the mode
+-- ------- and off FULL, the rows come back -- on the mod's own menus
 --
--- 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.
+-- What the engine's list carries is ONE row, and it leads. What that row opens
+-- is the mod: the two pipeline rows it took off the engine's list, and the
+-- four categories.
 Pipelines.setLevel("voxel", 2)
+do
 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")
--- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL
-T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6,
-  "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 engine gave it four rows and got three back: three of ours are gone
+-- (tilt dropped, both pipelines moved) and one is added, at the front
+-- the engine offered four and two come back: TILT dropped, both pipeline rows
+-- moved onto the mod's own menu, and one row added at the front
+T.eq(#grouped, 2, "one row for the whole mod, and the engine's own beneath it")
+T.eq(grouped[1].id, Menus.catId(Menus.lib.ROOT), "and it leads the list")
+T.eq(grouped[2].id, "void_fill",
+  "with the engine's own rows following in the order it built them")
+
+local root = Menus.rows(Menus.lib.ROOT)
+T.check(root["pipeline:voxel"] and root["pipeline:tiltshift"],
+  "both pipeline rows are on the mod's root menu")
+for _, cat in ipairs({ "world", "battles", "perf", "vr" }) do
+  T.check(root[Menus.catId(cat)],
+    ("off FULL the %s category is offered"):format(cat))
+end
+
+-- the split itself: each setting on the category that owns it, and nowhere
+-- else. Order inside a category is free to change; membership is the promise.
+local WHERE = {
+  grid = "world", curve = "world", viewbox = "world", water = "world",
+  daytime = "world",
+  battles = "battles", battleBack = "battles", letsgo = "battles",
+  atmos = "perf", shadows = "perf", aa = "perf",
+  vr = "vr", smoothturn = "vr",
+}
+for key, cat in pairs(WHERE) do
+  local id = "DRAMATIC_SHAPE:" .. key
+  -- smoothturn and battleBack need their own switch on first; they are
+  -- checked where those switches are, so only assert the ones offered now
+  if Menus.offered()[id] then
+    T.check(Menus.rows(cat)[id],
+      ("%s is on the %s menu"):format(key, cat))
+  end
+end
+-- the ROM import is an action, not a setting, and it sits with the rungs of
+-- 3D-BTL it unlocks
+T.check(Menus.rows("battles")["DRAMATIC_SHAPE:stadiumRom"],
+  "and STADIUM ROM is under the battles, which is what it unlocks")
+end
 
 -- ------- 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.
+-- A menu reads its row list every frame but builds it once, so without a
+-- rebuild the rows FULL owns stay on screen until it is reopened -- and
+-- stepping OFF FULL never brings them back.
 local OptionsMenu = require("src.ui.OptionsMenu")
 local pressed = {}
 local menuGame = {
@@ -343,17 +452,30 @@ local menuGame = {
   save = { options = { pipelines = {}, modOptions = {} } },
   mods = { modOptions = {} },
   input = { wasPressed = function(_, k) return pressed[k] or false end },
-  stack = { pop = function() end },
+  -- a real stack, because the mod's own menus push and pop on it
+  stack = {
+    states = {},
+    push = function(s, st) s.states[#s.states + 1] = st end,
+    pop = function(s) s.states[#s.states] = nil end,
+    top = function(s) return s.states[#s.states] 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")
+
+Pipelines.setLevel("voxel", 2)
+-- The mod's root menu shows the ENGINE's own pipeline descriptors, captured by
+-- the hook on its way past. The sections above fed the hook stub rows, which
+-- are enough to test what is dropped but carry no step function -- so hand it
+-- the real ones before driving the menu, or a keypress on VOXEL does nothing.
+Runtime.call("ui.options.rows", function(_, r) return r end, menuGame,
+             Pipelines.rows(menuGame))
+local menu = Menus.lib.new(menuGame, Menus.lib.ROOT)
+T.check(rowIndex(menu, Menus.catId("world")),
+  "off FULL the root menu opens with the 3D WORLD category on it")
 
 -- step the VOXEL row from 15 down to FULL, the way the player would
 menu.index = rowIndex(menu, "pipeline:voxel")
@@ -361,10 +483,12 @@ 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(not rowIndex(menu, Menus.catId("world")),
+  "and the category FULL emptied left the OPEN menu at once")
+T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with it")
 T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range")
+T.eq(menu.index, rowIndex(menu, "pipeline:voxel"),
+  "and stayed on the row the player just used")
 
 -- and back off it again
 menu.index = rowIndex(menu, "pipeline:voxel")
@@ -372,16 +496,17 @@ 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, Menus.catId("world")),
+  "and the category 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
+-- ------- 3D-BTL owns BATTLE LAYOUT, from a menu the OPTIONS list cannot see
 --
--- 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.
+-- The switch lives on the mod's BATTLES menu now, and the stack ticks its TOP
+-- state only (src/core/StateStack.update). So the step happens while the
+-- OPTIONS menu underneath is suspended, and a before/after pair taken around
+-- one call of its update would be read entirely after the fact and always
+-- agree. The signature is held on the instance for exactly this.
 do
 local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
 Battles.setting:setValue(false, menuGame)         -- staged battles off
@@ -390,37 +515,214 @@ 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")
+
+local battleMenu = Menus.lib.new(menuGame, "battles")
+T.check(not rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"),
+  "and BACK SPRITES is off the BATTLES menu, having no staged fight to frame")
+battleMenu.index = rowIndex(battleMenu, "DRAMATIC_SHAPE:battles")
 pressed = { right = true }
-layoutMenu:update(0)
+battleMenu: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"),
+T.check(rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"),
+  "and BACK SPRITES arrived on the open submenu with the same keypress")
+-- it arrives BELOW the row that was used, so this one only holds because the
+-- cursor follows the row rather than the slot
+T.eq(battleMenu.index, rowIndex(battleMenu, "DRAMATIC_SHAPE:battles"),
   "with the cursor still on the row the player just used")
+
+layoutMenu:update(0)
+T.check(not rowIndex(layoutMenu, "battleLayout"),
+  "and the OPTIONS menu underneath drops BATTLE LAYOUT on its next update, "
+  .. "though the step it is reacting to happened while it was suspended")
+end
+
+-- ------- the row OPENS the menus, and B comes back one level at a time
+do
+menuGame.stack.states = {}
+Pipelines.setLevel("voxel", 2)
+local hookRow
+for _, row in ipairs(Runtime.call("ui.options.rows", function(_, r) return r end,
+                                  menuGame, Pipelines.rows(menuGame))) do
+  if row.id == Menus.catId(Menus.lib.ROOT) then hookRow = row end
+end
+T.check(hookRow.activate and not hookRow.step,
+  "the row answers A alone -- `activate` SHADOWS `step` in the engine's own "
+  .. "dispatch, so a row carrying both would silently ignore Left and Right")
+T.eq(hookRow.value(menuGame), "15",
+  "and its second line is VOXEL's own, so it says what the mode is doing "
+  .. "without being opened")
+
+hookRow.activate(menuGame)
+local root = menuGame.stack:top()
+T.check(root ~= nil, "pressing A pushed a screen")
+T.eq(#root.rows, #Menus.lib.rows(Menus.lib.ROOT, menuGame),
+  "carrying the root menu's rows")
+T.eq(root:backLabel(), "BACK", "and a way out named for what it does")
+
+-- the second line of a category row
+T.eq(root.rows[rowIndex(root, Menus.catId("battles"))].value(),
+     Menus.rows("battles", menuGame)["DRAMATIC_SHAPE:battles"].value(),
+  "a category one setting speaks for says that setting's rung")
+T.eq(root.rows[rowIndex(root, Menus.catId("world"))].value(), "5 SETTINGS",
+  "and one no single row speaks for counts them instead, which is honest "
+  .. "rather than arbitrary")
+
+-- into a category, and back out of it a level at a time
+root.index = rowIndex(root, Menus.catId("world"))
+pressed = { a = true }
+root:update(0)
+pressed = {}
+T.eq(menuGame.stack:top():backLabel(), "BACK: 3D WORLD",
+  "A on a category opens it, and the bottom line says which one -- "
+  .. "OptionRows has no header, so the way out carries the name")
+T.check(menuGame.stack:top() ~= root, "which is a screen of its own")
+
+pressed = { b = true }
+menuGame.stack:top():update(0)
+pressed = {}
+T.eq(menuGame.stack:top(), root, "B comes back ONE level, to the root menu")
+pressed = { start = true }
+root:update(0)
+pressed = {}
+T.eq(menuGame.stack:top(), nil, "and START leaves it the way B does")
+end
+
+-- ------- the mod's row is RED, which is a palette zone and not a color
+--
+-- setColor cannot do it here twice over: the glyph atlas is black ink and LOVE
+-- tints multiplicatively, and the palette shader keys on the red channel alone
+-- so a red pixel would land in the LIGHTEST slot (see lib/SettingsMenu.lua).
+-- What a zone changes is which color the SHADE the text was drawn in comes out
+-- as, so what there is to assert is where the band sits and which slot carries
+-- the ink.
+do
+local PaletteFX = require("src.render.PaletteFX")
+-- the fixture dataset ships no palettes; stand MEWMON up, since the point is
+-- that the red palette is MEWMON COPIED rather than four invented colors
+Data.palettes = { palettes = {
+  MEWMON = { { 255, 239, 255 }, { 255, 140, 140 }, { 80, 80, 255 }, { 0, 0, 0 } },
+} }
+
+local red = Menus.lib.redPalette(Data)
+T.eq(#red, 4, "the red palette is a palette like any other, four colors")
+T.eq(red[1][2], 239,
+  "and its paper is MEWMON's own -- Red, Blue and Yellow ship different "
+  .. "tables, and the row must not shift the white it sits on")
+
+-- What has to hold is not which slot the red is in but where it comes OUT:
+-- black text is shade 3 and the white box fill shade 0, and the display mode
+-- transforms the table before the shader ever sees it (effectiveColors). So
+-- assert through that, which is the thing the screen actually draws.
+do
+  local eff = PaletteFX.effectiveColors(red)
+  T.check(eff[4][1] == 255 and eff[4][2] == 0 and eff[4][3] == 0,
+    "the ink shade comes out red")
+  T.eq(eff[1][2], 239, "and the paper shade is the white it always was")
+end
+
+-- SGB INV REVERSES the table, so the red has to start in the other slot to
+-- land on the ink. Without the special case the row draws as a solid red
+-- block with the letters cut out of it.
+PaletteFX.mode = "gbc_inv"
+do
+  local eff = PaletteFX.effectiveColors(Menus.lib.redPalette(Data))
+  T.check(eff[4][1] == 255 and eff[4][2] == 0,
+    "under SGB INV the ink shade STILL comes out red, once the mode has had "
+    .. "its turn at the table")
+  T.eq(eff[1][1], 0,
+    "on the same dark paper SGB INV gives every other row -- red ink, not a "
+    .. "red block")
+end
+PaletteFX.mode = "gbc"
+
+-- the band covers the two TEXT lines of the box and nothing else: tile 1 is
+-- the cursor and tiles 0 and 19 are the box borders, all of them black glyphs
+-- that would turn red with the label if the zone spanned the whole row
+local zone = Menus.lib.rowZone(Data, 1)
+T.eq(zone.x, 16, "the band starts at the label column, past the cursor")
+T.eq(zone.y, 8, "on the first box's label line")
+T.eq(zone.w, 17 * 8, "and stops short of the box's right border")
+T.eq(zone.h, 16, "covering the label and the value under it")
+T.eq(Menus.lib.rowZone(Data, 3).y, 8 * ((3 - 1) * 4 + 1),
+  "and it is addressed by SLOT, so it follows the row down the screen")
+
+-- on the menu itself: the whole-screen palette it always had, plus one band
+menuGame.stack.states = {}
+Runtime.call("ui.options.rows", function(_, r) return r end, menuGame,
+             Pipelines.rows(menuGame))
+local palMenu = OptionsMenu.new(menuGame)
+T.eq(#palMenu:sgbPalettes(menuGame), 2,
+  "the OPTIONS menu paints its own palette and then the one band")
+T.eq(palMenu:sgbPalettes(menuGame)[2].y, 8,
+  "on row 1, where the mod's row leads the list")
+T.eq(palMenu:sgbPalettes(menuGame)[2].colors[4][1], 255, "and it is the red one")
+
+-- scrolled past, the band goes with it: a red stripe left behind on whatever
+-- took the slot would be worse than no marker at all
+palMenu.scroll = 4
+T.eq(#palMenu:sgbPalettes(menuGame), 1,
+  "scrolled off screen, the row takes its band with it")
+
+-- and a submenu paints its own. This is the whole reason it has the method:
+-- Game:draw stops at the first state that HAS one, so a submenu without it
+-- would inherit the band above and land it on an unrelated row.
+T.eq(#Menus.lib.new(menuGame, Menus.lib.ROOT):sgbPalettes(menuGame), 1,
+  "a submenu owns its palette rather than inheriting the red band")
+
+Data.palettes = nil
 end
 
 -- 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" } })
+do
+  -- what the engine really hands the hook: its own rows, the pipeline rows
+  -- among them. The mod moves those two onto its root menu, so a call that
+  -- left them out would leave the root menu with nothing but categories.
+  local given = { { id = "text_speed" } }
+  for _, row in ipairs(Pipelines.rows({ data = Data })) do
+    given[#given + 1] = row
+  end
+  Runtime.call("ui.options.rows", function(_, r) return r end,
+               { data = Data }, given)
+end
+-- Every row the mod offers, keyed by id. RENDER DIST, FOREST FX, LET'S GO,
+-- SHADOWS and AA are read straight out of it where they are used rather than
+-- named here, because this chunk is one main function and has 200 local slots
+-- to spend.
+local hookedRows = Menus.offered()
 -- one per setting, plus the STADIUM ROM action row -- which is not a setting
 -- (nothing to store, nothing for the mod manager to persist) and is offered
 -- on every platform, saying WHERE? rather than IMPORT where there is no file
--- dialog to open
-T.eq(#hookedRows, 14, "the options hook added a row per setting, plus the "
-  .. "STADIUM ROM action row")
-local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
-local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
--- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
--- hookedRows[9], SHADOWS hookedRows[11] and AA hookedRows[12]; all five are
--- read where they are used rather than named here, because this chunk is one
--- main function and has 200 local slots to spend
-T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
-T.eq(hookedRows[4].value(), "FIT",
+-- dialog to open -- and the four category rows that carry them
+do
+  -- named rather than counted: a count says "something moved" and this says
+  -- WHAT is meant to be reachable. SMOOTH TURN is absent because VR is off,
+  -- which is its own gate and is tested where VR is turned on.
+  local want = { "grid", "curve", "viewbox", "water", "daytime",
+                 "battles", "battleBack", "letsgo", "stadiumRom",
+                 "atmos", "shadows", "aa", "vr" }
+  local n = 0
+  for _ in pairs(hookedRows) do n = n + 1 end
+  for _, key in ipairs(want) do
+    T.check(hookedRows["DRAMATIC_SHAPE:" .. key],
+      ("%s is reachable from the mod's menus"):format(key))
+  end
+  T.check(hookedRows["pipeline:voxel"] and hookedRows["pipeline:tiltshift"],
+    "and both pipeline rows with them")
+  -- those, the two pipelines and the four categories, and nothing else
+  T.eq(n, #want + 2 + 4, "and nothing else is on them")
+end
+local grid = hookedRows["DRAMATIC_SHAPE:grid"]
+local curve, water = hookedRows["DRAMATIC_SHAPE:curve"], hookedRows["DRAMATIC_SHAPE:water"]
+local battles = hookedRows["DRAMATIC_SHAPE:battles"]
+local backRow, daytime = hookedRows["DRAMATIC_SHAPE:battleBack"],
+                         hookedRows["DRAMATIC_SHAPE:daytime"]
+T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].label, "RENDER DIST",
+  "the viewport row carries its label")
+T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].value(), "FIT",
   "and defaults to FIT -- the cut IS the window the flat game already "
   .. "framed, which is the whole claim the row makes")
 T.eq(water.label, "WATER", "the water row carries its label")
@@ -429,8 +731,8 @@ T.eq(water.value(), "FULL",
 water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
 T.eq(water.value(), "SKY",
   "stepping down drops the screen-space march and keeps the sky, sun and moon")
-T.eq(hookedRows[6].label, "FOREST FX", "the atmosphere row carries its label")
-T.eq(hookedRows[6].value(), "FULL",
+T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].label, "FOREST FX", "the atmosphere row carries its label")
+T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].value(), "FULL",
   "and defaults to FULL -- it only spends anything on a map with an "
   .. "atmosphere entry, which is one forest today")
 T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
@@ -451,8 +753,8 @@ T.eq(backRow.value(), "OFF",
   .. "map, so the classic slot is opt-in")
 T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
   "on its own key, so it persists beside 3D-BTL rather than over it")
-T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label")
-T.eq(hookedRows[9].value(), "OFF",
+T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].label, "LET'S GO", "the capture-mode row carries its label")
+T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].value(), "OFF",
   "and starts OFF -- a gameplay mode is opt-in, whatever the diorama does")
 
 -- stepping writes through to the one place both rows read
@@ -532,7 +834,7 @@ do
 local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
 local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
 local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
-local aa = hookedRows[12]
+local aa = hookedRows["DRAMATIC_SHAPE:aa"]
 T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
 T.eq(aa.value(), "OFF",
   "and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -588,7 +890,7 @@ end
 ;(function()
 local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows")
 local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
-local shadowRow = hookedRows[11]
+local shadowRow = hookedRows["DRAMATIC_SHAPE:shadows"]
 local shGame = { save = { options = {} }, mods = { modOptions = {} } }
 T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label")
 T.eq(shadowRow.value(), "ON",
@@ -3336,19 +3638,16 @@ T.eq(Battles.backSetting:get(), true, "without being rewritten underneath")
 -- ...so the row comes off the menu with it, on the same reasoning the mod's
 -- other absent rows come off: a row that no longer decides anything is worse
 -- than no row
-local offRows = Runtime.call("ui.options.rows", function(_, r) return r end,
-                             backGame, { { id = "tilt" } })
-local offIds = {}
-for _, row in ipairs(offRows) do offIds[row.id] = true end
-T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
-T.check(not offIds["DRAMATIC_SHAPE:battleBack"],
+local offRows = Menus.rows("battles", backGame)
+T.check(offRows["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
+T.check(not offRows["DRAMATIC_SHAPE:battleBack"],
   "but BACK SPRITES is off the menu while there is no staged fight to be about")
 
 Battles.setting:setValue(true, backGame)
-local onRows = Runtime.call("ui.options.rows", function(_, r) return r end,
-                            backGame, { { id = "tilt" } })
+-- switched back on it returns, and to the BATTLES menu -- the one the switch
+-- it depends on is on, so the two are read together
 local onAt = {}
-for i, row in ipairs(onRows) do onAt[row.id] = i end
+for i, row in ipairs(Menus.lib.rows("battles", backGame)) do onAt[row.id] = i end
 T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
 T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
   "directly under the row it belongs to")
@@ -5962,26 +6261,28 @@ end)()
   local VRMod = lib.require("VR")
   T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box")
 
-  local function optionRows()
-    local out = Runtime.call("ui.options.rows", function(_, r) return r end,
-                             { data = Data }, { { id = "tilt" } })
-    local ids = {}
-    for _, row in ipairs(out) do ids[row.id] = row end
-    return ids
-  end
-
   Pipelines.setLevel("voxel", 3)          -- off FULL, which owns other rows
   VRMod.setting:sync(false)
-  T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"],
-    "with VR off the row is not on the OPTIONS menu")
+  T.check(not Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"],
+    "with VR off the row is not on the VR menu")
+  -- and with the headset's own row the only thing left on it, that menu is
+  -- still worth offering: a category is dropped when it is EMPTY, not when it
+  -- is short
+  T.check(Menus.rows(Menus.lib.ROOT)[Menus.catId("vr")],
+    "though the category is, on the strength of the VR row alone")
 
   VRMod.setting:sync(true)
-  local smoothRow = optionRows()["DRAMATIC_SHAPE:smoothturn"]
+  local smoothRow = Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"]
   T.check(smoothRow ~= nil, "and with VR on it is")
   if smoothRow then
     T.eq(smoothRow.label, "SMOOTH TURN", "under its own name")
     T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise")
   end
+  -- VR on takes the two battle rows away, which is the other half of the same
+  -- gate: the headset requires staged battles and forbids back sprites, so
+  -- both rows decide nothing there
+  T.check(not Menus.rows("battles")["DRAMATIC_SHAPE:battles"],
+    "and the battle rows are gone, being decided by the headset instead")
   VRMod.setting:sync(false)
 
   -- ------- the gun's own bookkeeping
diff --git a/tests/letsgo_route6.lua b/tests/letsgo_route6.lua
new file mode 100644
index 0000000..4d668f4
--- /dev/null
+++ b/tests/letsgo_route6.lua
@@ -0,0 +1,104 @@
+-- Probe: the reported grass along the TOP of the frame in GO-style
+-- battles on ROUTE 6.
+--
+-- The capture seat is a different camera from the battle's own: the "tele"
+-- rig stands 145 world px back at a height of 37.9 (well over two cells,
+-- clear of anything that grows on the ground), while the head-on capture
+-- seat stands 46 back at a height of 13 -- BELOW the top of a 16px grass
+-- tuft or hedge. On a route lined with the stuff, the eye is inside it.
+--
+-- So: stage a capture at several spots along Route 6 and record where the
+-- eye actually is relative to the ground, with a shot of each.
+--
+--   SHOT_DIR=.scratchpad/route6 \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_route6.lua \
+--   "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route6"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+  local Bag = require("src.inventory.Bag")
+
+  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")
+    return
+  end
+  local LetsGo = lib.require("LetsGo")
+  local CatchThrow = lib.require("CatchThrow")
+  local ChunkMesher = lib.require("ChunkMesher")
+
+  pcall(os.execute, 'mkdir -p "' .. DIR .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. DIR:gsub("/", "\\") .. '" 2>nul')
+
+  game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
+  game.save.player.name = "RED"
+  Bag.add(game.save, "POKE_BALL", 99, game.data)
+  CatchThrow.lastBall = "POKE_BALL"
+  LetsGo.setting:setValue("full", game)
+
+  local MAP = os.getenv("DS_MAP") or "ROUTE_6"
+  -- a spread down the route: the reported shot is on the path with hedges
+  -- both sides, which is where a low seat has the least room
+  local SPOTS = {}
+  for _, xy in ipairs({ { 5, 6 }, { 5, 14 }, { 9, 20 }, { 4, 26 }, { 10, 32 } }) do
+    SPOTS[#SPOTS + 1] = xy
+  end
+
+  for i, sp in ipairs(SPOTS) do
+    U.teleport(game, MAP, sp[1], sp[2], "up")
+    U.wait(50)
+    -- confirm the teleport actually landed: a first cut reported the SAME
+    -- eye at all five spots, which meant the probe never left the save's
+    -- own position and every "spot" was one place wearing five labels
+    do
+      local ow = game.overworld
+      local m = ow and ow.map
+      U.log(("spot %d: on map %s at cell %s,%s")
+            :format(i, tostring(m and (m.id or m.name) or "?"),
+                    tostring(ow and ow.player and ow.player.cellX),
+                    tostring(ow and ow.player and ow.player.cellY)))
+    end
+    for _ = 1, 600 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+
+    local bt = BattleState.newWild(game, "ODDISH", 13)
+    bt.onFinish = function() end
+    game.overworld:pushBattle(bt)
+    U.wait(70)
+    for _ = 1, 80 do
+      if bt.phase == "menu" or CatchThrow.session() then break end
+      U.tap(game, "a")
+      U.wait(6)
+    end
+    U.wait(40)
+
+    local s = CatchThrow.session()
+    if s and s.shot and s.shot.eye then
+      local e = s.shot.eye
+      local p = s.playerPos
+      local back = math.sqrt((e[1] - p[1]) ^ 2 + (e[3] - p[3]) ^ 2)
+      U.log(("spot %d (%d,%d): eye = %.1f,%.1f,%.1f  height %.1f over ground  "
+             .. "seat %.1f back (wants 46)%s")
+            :format(i, sp[1], sp[2], e[1], e[2], e[3], e[2] - s.groundY, back,
+                    back < 45.5 and "  <-- the world pulled it in" or ""))
+    else
+      U.log(("spot %d (%d,%d): no capture session"):format(i, sp[1], sp[2]))
+    end
+    U.shot(game, ("%s/spot%d.png"):format(DIR, i))
+
+    -- out, and all the way off the stack before the next spot
+    U.tap(game, "b")
+    for _ = 1, 300 do
+      if not CatchThrow.session() and game.stack:top() ~= bt then break end
+      U.tap(game, "a")
+      U.wait(4)
+    end
+    U.wait(30)
+  end
+  U.log("done -- " .. DIR)
+end