DramaticShapeVoxelMod archival mirror · read-only
b8b2e0cd33dbfdc740e531a6b6df589e33c4514d

shiny capture

DramaticShape · 2026-08-08 · parent bee22507b2

shiny capture

Changed files

CHANGELOG.md                  | 101 ++++++++++++++++++++++++++
 data/map_atmosphere.lua       |  16 ++++-
 lib/ForestAtmos.lua           | 204 ++++++++++++++++++++++++++++++++++++++++++++++------
 lib/SettingsHelp.lua          | 178 +++++++++++++++++++++++++++++++++++++++++++++
 lib/SettingsMenu.lua          |  88 ++++++++++++++++++-----
 lib/VoxelScene.lua            |   6 +-
 main.lua                      | 121 ++++++-------------------------
 tests/dramatic_shape_test.lua | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 tests/grass_firefly_shots.lua | 187 +++++++++++++++++++++++++++++++++++++++++++++++
 9 files changed, 1000 insertions(+), 144 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2de78e2..39b0256 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,57 @@
 
 ### Added
 
+- **Fireflies over tall grass, on every outdoor map.** Viridian Forest has
+  had them since 1.6.2 -- blinking green motes low in the air, dealt over
+  the forest's whole volume as part of its atmosphere. They now come out
+  over TALL GRASS anywhere the hour reaches: a route at midnight has
+  lights loose over its grass and none over the road beside it.
+
+  The same particle, moved rather than rebuilt. Same mesh format, same
+  blinking shader, same one ramp off the day/night clock -- so they come
+  on through dusk and reach full contrast at deepest night exactly when
+  the forest's do, because it is the same function answering (pulled out
+  of `ForestAtmos.frame` as `fireflyLevel` so the two cannot drift
+  apart). What changed is where they are dealt.
+
+  **Grass is the entry.** `data/map_atmosphere.lua` is still the opt-in
+  for fog and god rays, and still names one map; the grass fireflies need
+  no line in it. Each map is scanned once for the cells the engine's own
+  `isGrassCell` calls tall grass -- the collision tile, the rule that
+  decides where a wild battle starts, and the same test `Structures`
+  passes before it sprouts a tuft. Going by the grass GRAPHIC instead
+  would hang lights over town plazas, where the same art turns up as
+  decorative filler inside ordinary ground blocks. Roughly four fireflies
+  to five grass cells, capped at 200 a map; an atmosphere entry may tune
+  both through a `grassFlies` row. A map with no grass on it deals none
+  and costs one scan.
+
+  They are dealt INTO a cell rather than at its middle, and the shader's
+  existing wander carries each about a cell's width from where it was
+  dealt, in the tufts' own height band -- so a patch reads as a patch
+  with fireflies loose over it rather than as a grid of lights. The deal
+  is seeded off the map id, so a route's swarm is the same arrangement on
+  every visit and no two routes get the same one.
+
+  **The connected neighbours get theirs too.** A neighbour map is drawn
+  in full -- ground, trees, grass and the firefly cards standing in it --
+  so a swarm that stopped at the map seam would draw a line across the
+  picture where the lights ran out. The particle shader takes a map
+  origin now and each neighbour's own cached mesh is drawn where its
+  terrain is. Nothing else in the atmosphere crosses a seam, and nothing
+  else needed to: fog and beams belong to the map you are standing in.
+
+  This is the FULL rung of FOREST FX, like every particle in that pass.
+  The per-tuft firefly cards in the grass mesh are the layer underneath
+  and are unchanged -- static geometry the scene shader already carries,
+  so grass still has something alight after dark on LOW and on Android,
+  where this pass is not drawn at all.
+
+  One fix falls out of it: a machine that cannot hand back a readable
+  depth buffer used to lose the whole atmosphere pass, particles
+  included, rather than just the beams it actually blocks. The refusal
+  now takes the beams alone.
+
 - **SHINY POKEMON.** On by default, with no row to switch it off:
   shininess is a property of a Pokemon, not a display mode, and one that
   differed between two players' saves would be a setting rather than a
@@ -153,6 +204,40 @@
   nor takes it away, on the same reasoning as AA -- what the look costs is
   the player's question, not a preset's.
 
+### Added
+
+- **SELECT on any row of the mod's menus explains what it does.** Every
+  setting here has carried a paragraph of help since it was written -- it is
+  handed to the mod manager with the rest of the schema -- and nothing in the
+  engine has ever drawn one. It could not: a row is a label and a value, and
+  no options row anywhere has room for a third thing. So a row says what it
+  IS on one line and what it is SET TO on the next, and SELECT says what that
+  MEANS, which is the question RENDER DIST or 2D-3D B cannot answer in
+  eighteen characters however the label is worded.
+
+  It opens the game's own dialogue box -- drawn with the ROM's own border
+  glyphs, anchored to the bottom of the screen where this game has always put
+  text, and only as tall as the sentence it holds, so the row being asked
+  about is still visible above it. A, B, START and SELECT all close it, SELECT
+  included: it is the button somebody who just pressed it will reach for. The
+  bottom line of every one of the mod's menus now reads `B BACK  SEL HELP`,
+  because a binding nobody knows about is worth nothing.
+
+  Every description is ONE SENTENCE, and the whole of it is on screen at once.
+  The long paragraphs these grew from were written for a reader that never
+  existed, and they read as documentation rather than as an answer; a box you
+  have to scroll is a worse reply to "what does this do" than a shorter
+  sentence is. Both properties are tested rather than trusted -- a description
+  that gains a second sentence, or that outgrows its box, fails the suite.
+  VOXEL, T-SHIFT and STADIUM ROM got sentences of their own to go with the
+  thirteen settings: the first two are the engine's row descriptors with
+  nowhere to keep one, and the third is an action rather than a setting.
+
+  The suite also checks every character of every description against the ROM's
+  real charmap, because Font.encode answers a glyph it does not have with a
+  SPACE and a one-time console warning -- so a curly quote pasted in from
+  somewhere would blank a word on screen and say nothing about it.
+
 ### Changed
 
 - **The settings live on menus of their own now, behind one red row at the
@@ -259,6 +344,22 @@
   input.step seam, and taken rather than peeked so a press the capture used
   does not also page the message it just queued.
 
+- **No more grass smeared across the top of a LET'S GO throw.** The capture
+  seat handed BattleScene its pitch as the DEPRESSION below level, and the
+  one thing that reads it -- the camera-ward pull the grass and flowers are
+  drawn with -- measures angles off STRAIGHT DOWN, the complement. So a seat
+  looking nearly level read as the top-down end of the ladder, where the pull
+  is longest: 46 world pixels of bias, handed to a camera standing 46 world
+  pixels behind the player. The pull is a shove along each vertex's own eye
+  ray -- a pure depth bias while it is shorter than the range, and past that
+  it carries geometry THROUGH the lens, where the projection turns inside out
+  and a single tuft at the eye lands smeared across the frame. That was the
+  greenery hanging over the top of a capture shot on any route or street with
+  grass rows beside it. The seat now speaks the same convention the battle's
+  own rig does, and the vertex stage clamps the pull to half the range to the
+  eye besides -- so no camera standing this close can be smeared by a bias
+  again, in a capture, a fight, or first person.
+
 - **The grass moves during a staged battle.** The wind is switched on around
   the free-roam pass's grass draws and off again after them, and the battle
   pass -- which draws the same tufts, on the same map, from its own camera --
diff --git a/data/map_atmosphere.lua b/data/map_atmosphere.lua
index 5d6c321..b9ff808 100644
--- a/data/map_atmosphere.lua
+++ b/data/map_atmosphere.lua
@@ -13,7 +13,9 @@
 -- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
 -- and STRENGTH follow the clock: gold spears of sun by day, silver moon
 -- rays after dark, pollen adrift in the day's beams and fireflies once
--- they cool.
+-- they cool. The fireflies are the one part of this that reaches past the
+-- table: they also come out over TALL GRASS on every outdoor map, entry
+-- or no entry (see grassFlies below).
 --
 -- A map with no entry here has no atmosphere at all: no fog uniform is
 -- raised, no march runs, nothing is spent. That is the contract a new
@@ -36,8 +38,18 @@
 --   rays      strength  overall in-scatter gain on the march
 --             reach     how far out the march walks, in world px
 --   motes     count of pollen/dust flecks adrift in the daylight beams
---   fireflies count of the night shift
+--   fireflies count of the night shift, dealt over the map's whole volume
 --   seed      the xorshift seed the particle deal runs on
+--   grassFlies  per  fireflies dealt per TALL GRASS cell (default 0.8)
+--               cap  and the most one map may have of them (default 200)
+--
+-- That last row is the odd one out, and the only knob here a map does not
+-- need an entry to get. The GRASS fireflies are dealt over a map's tall
+-- grass rather than over its air, and every outdoor map has them -- grass
+-- is the entry, so a route gets fireflies after dark without a line here
+-- and a map with no grass on it deals none. What an entry buys is TUNING:
+-- a `grassFlies` row moves the two numbers off their defaults, and that
+-- is the whole of it. See ForestAtmos, under "the grass fireflies".
 --
 -- Two caveats for maps opting in later: the water pass has no fog term,
 -- so a lake under heavy haze stays clear-day sharp in its reflections;
diff --git a/lib/ForestAtmos.lua b/lib/ForestAtmos.lua
index 884f671..cd22560 100644
--- a/lib/ForestAtmos.lua
+++ b/lib/ForestAtmos.lua
@@ -137,6 +137,7 @@ ForestAtmos.configFor = configFor
 -- shader and texture sentinels, which is what a lost GL context needs.
 
 local layoutCache = {}
+local grassCache = {}         -- the grass deal, keyed by map; false = no grass
 local meshCache = {}
 local shaders = {}            -- keyed by variant; nil untried, false refused
 local leafTex = nil           -- the tiling leaf-dapple field
@@ -156,9 +157,10 @@ end
 function ForestAtmos.invalidate(mapId)
   if mapId then
     layoutCache[mapId] = nil
+    grassCache[mapId] = nil
     meshCache[mapId] = nil
   else
-    layoutCache, meshCache = {}, {}
+    layoutCache, grassCache, meshCache = {}, {}, {}
     shaders = {}
     leafTex = nil
     rayMesh = nil
@@ -194,6 +196,18 @@ ForestAtmos.RAMP = {
              alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
 }
 
+-- How far out the night shift is, 0..1, at clock `t`. Pulled out of the
+-- frame's own loop because the GRASS fireflies (below) need it on maps
+-- that have no atmosphere at all -- and pulled out rather than copied so
+-- the two can never disagree about when a firefly comes on.
+function ForestAtmos.fireflyLevel(t)
+  local flies = 0
+  for name, w in pairs(DayNight.mix(t or DayNight.time())) do
+    flies = flies + (ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day).flies * w
+  end
+  return flies
+end
+
 -- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
 -- nil -- no entry, or the row is OFF -- in which case nothing is drawn
 -- and Voxel3D.fog should be left nil.
@@ -203,7 +217,8 @@ function ForestAtmos.frame(map, t)
   if not cfg then return nil end
   local mix = DayNight.mix(t or DayNight.time())
   local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
-  local alpha, dens, motes, flies = 0, 0, 0, 0
+  local alpha, dens, motes = 0, 0, 0
+  local flies = ForestAtmos.fireflyLevel(t)
   for name, w in pairs(mix) do
     local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
     fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
@@ -211,7 +226,6 @@ function ForestAtmos.frame(map, t)
     alpha = alpha + p.alpha * w
     dens = dens + p.density * w
     motes = motes + p.motes * w
-    flies = flies + p.flies * w
   end
   -- the haze takes on a little of the light standing in it
   local LEAN = 0.15
@@ -374,6 +388,111 @@ function ForestAtmos.layout(cfg, w, h)
   return { motes = motes, flies = flies }
 end
 
+-- ------- the grass fireflies
+--
+-- The same particle -- same mesh format, same blinking shader, same hour's
+-- ramp -- dealt over a map's TALL GRASS instead of over its whole volume,
+-- and on any outdoor map rather than only the ones with an atmosphere
+-- entry. Grass IS the entry: a route with tall grass on it gets fireflies
+-- after dark without anybody authoring a line, and a map with no grass
+-- cell on it deals nothing and costs one scan.
+--
+-- Placement asks the engine's own question, `map:isGrassCell` -- the
+-- cell's collision tile, the rule that decides where a wild battle can
+-- start -- which is the same test Structures runs before it sprouts a
+-- tuft. The grass GRAPHIC also turns up as decorative filler inside plain
+-- ground blocks, and going by the tile would hang fireflies over town
+-- plazas (the trap that note in Structures.buildGrass records).
+--
+-- They are dealt into the cell rather than at its middle, and the shader's
+-- own wander (sway 10, 4) carries each one about a cell's width from where
+-- it was dealt -- so a patch reads as a patch with fireflies loose over it,
+-- not as a grid of lights. The height band is the tufts' own: 3..15, which
+-- is blade height and a little air (Structures stands its blades to y = 16).
+--
+-- This is the FULL rung, like every other particle here. The per-tuft
+-- firefly cards in Structures.buildGrass are the layer underneath -- they
+-- are static geometry the scene shader already carries, so grass still has
+-- something alight after dark on LOW and on Android, where this pass is
+-- not drawn at all.
+local GRASS_PER_CELL = 0.8    -- fireflies per grass cell...
+local GRASS_CAP = 200         -- ...up to this many on one map
+local CELL = 16               -- world px, the grid isGrassCell speaks
+
+-- A map id deals its own swarm: stable across visits and saves (nothing
+-- here rides the clock), different between maps, so two routes with the
+-- same amount of grass do not get the same arrangement of lights.
+local function seedOf(id)
+  local s = 0x51D
+  for i = 1, #id do s = (s * 31 + id:byte(i)) % 0x100000000 end
+  return s
+end
+
+-- `cells` is a flat list of { cx, cy } pairs; count is worked out from how
+-- many there are. Deterministic, and separated from the map so a test can
+-- hand it a meadow.
+function ForestAtmos.grassLayout(cells, seed, per, cap)
+  local n = #cells
+  if n == 0 then return {} end
+  local want = min(floor(n * (per or GRASS_PER_CELL) + 0.5), cap or GRASS_CAP)
+  local rng = newRng(seed or 0x51D)
+  local flies = {}
+  for _ = 1, want do
+    local c = cells[1 + min(floor(rng:unit() * n), n - 1)]
+    flies[#flies + 1] = {
+      x = c[1] * CELL + rng:unit() * CELL,
+      y = 3 + rng:unit() * 12,
+      z = c[2] * CELL + rng:unit() * CELL,
+      phase = rng:unit() * 6.2832,
+      rate = 0.5 + rng:unit(),
+    }
+  end
+  return flies
+end
+
+-- Only where the clock reaches: an outdoor map, or a CANOPY one (Viridian
+-- Forest is not outdoor -- no sky, no sun -- but night still falls in it,
+-- and it is the map these fireflies were drawn for). A cave stays a cave.
+local function litByTheHour(map)
+  if DayNight.isCanopy(map) then return true end
+  local ok, Map = pcall(require, "src.world.Map")
+  if not (ok and Map and Map.isOutdoor) then return false end
+  local got, outdoor = pcall(Map.isOutdoor, map.def or {})
+  return got and outdoor or false
+end
+
+-- One scan of the map's cells, cached with everything else that goes stale
+-- with it. `false` records "scanned, no grass" so a grassless map is never
+-- walked twice.
+local function grassFliesFor(map)
+  local hit = grassCache[map.id]
+  if hit ~= nil then return hit or nil end
+  if not (map.isGrassCell and litByTheHour(map)) then
+    grassCache[map.id] = false
+    return nil
+  end
+  local cells = {}
+  local w = map.widthCells or ((map.def and map.def.width or 0) * 2)
+  local h = map.heightCells or ((map.def and map.def.height or 0) * 2)
+  -- one pcall around the whole scan, not one per cell: a route is a couple
+  -- of thousand cells and this runs on the frame that first draws the map
+  pcall(function()
+    for cy = 0, h - 1 do
+      for cx = 0, w - 1 do
+        if map:isGrassCell(cx, cy) then cells[#cells + 1] = { cx, cy } end
+      end
+    end
+  end)
+  local cfg = configFor(map.id) or {}
+  local knob = cfg.grassFlies or {}
+  local flies = ForestAtmos.grassLayout(cells, seedOf(map.id),
+                                        knob.per, knob.cap)
+  grassCache[map.id] = (#flies > 0) and flies or false
+  return grassCache[map.id] or nil
+end
+
+ForestAtmos.grassFliesFor = grassFliesFor
+
 -- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
 -- for world pixels (the same arithmetic Structures runs in tiles).
 local function layoutFor(map)
@@ -637,6 +756,9 @@ local PART_SHADER = [[
   uniform float size;
   uniform vec2 sway;       // wander amplitude: horizontal, vertical
   uniform float blinky;    // 0 = steady motes, 1 = blinking fireflies
+  uniform vec3 origin;     // the map's corner: zero for the one being
+                           // stood on, the connection offset for a
+                           // neighbour's swarm (one mesh, drawn per map)
   attribute vec4 AtmosData;    // corner x, corner y, phase, rate
   vec4 position(mat4 transform_projection, vec4 vertex_position) {
     float ph = AtmosData.z;
@@ -644,7 +766,7 @@ local PART_SHADER = [[
     float t = time * (0.5 + rt);
     // bounded wander only -- three incommensurate sines, so nothing ever
     // walks off the map or needs a CPU tick to bring it home
-    vec3 base = vertex_position.xyz + vec3(
+    vec3 base = vertex_position.xyz + origin + vec3(
       sin(t * 0.23 + ph) * sway.x,
       sin(t * 0.17 + ph * 2.7) * sway.y,
       cos(t * 0.19 + ph * 1.3) * sway.x);
@@ -733,12 +855,19 @@ local function buildPartMesh(points)
   return mesh
 end
 
-local function meshesFor(map, L)
+-- The map's three swarms in one cache entry: the atmosphere's pollen and
+-- fireflies, which exist only for a map with an entry, and the grass
+-- fireflies, which exist for any outdoor map with tall grass on it. Each
+-- is nil on its own -- a route builds one mesh, Viridian Forest builds
+-- three, and a cave builds none.
+local function meshesFor(map)
   local hit = meshCache[map.id]
   if hit then return hit end
+  local L = layoutFor(map)
   local M = {
-    motes = buildPartMesh(L.motes),
-    flies = buildPartMesh(L.flies),
+    motes = L and buildPartMesh(L.motes) or nil,
+    flies = L and buildPartMesh(L.flies) or nil,
+    grass = buildPartMesh(grassFliesFor(map) or {}),
   }
   meshCache[map.id] = M
   return M
@@ -746,6 +875,7 @@ end
 
 local MOTE_COLOR = { 1.0, 0.96, 0.78 }
 local FLY_COLOR = { 0.72, 1.0, 0.45 }
+local HOME = { 0, 0, 0 }      -- the origin of the map being stood on
 
 -- The billboard frame: the camera's own right and up, from the same
 -- fields every pass sets (per VR eye too -- drawScene runs per eye and
@@ -774,19 +904,29 @@ end
 -- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
 -- refused shader, no readable depth, no shadow map -- subtracts only
 -- itself.
-function ForestAtmos.draw(map)
+--
+-- `f` is nil for every map without an atmosphere entry, and the pass does
+-- NOT stop there any more: the grass fireflies belong to the grass, not to
+-- an authored line, so the fog and the beams sit that map out and the
+-- particle block below still runs. Every read of `f` past here is guarded.
+function ForestAtmos.draw(map, neighbors)
   local rung = ForestAtmos.setting:get()
   if rung == "off" then return end
+  if not (map and map.id) then return end
   local f = ForestAtmos.frame(map)
-  if not f then return end
   local Voxel3D = V.require("Voxel3D")
   local ShadowMap = V.require("ShadowMap")
 
-  if f.rayAlpha > 0.01 then
-    if not Voxel3D.depthReadable() then
-      say("depth", "no readable depth this frame -- beams off, fog stays")
-      return
-    end
+  -- A refusal here takes the BEAMS, not the frame: this one used to
+  -- `return` out of the function, which took the particles with it. They
+  -- fall through now, so a machine that cannot hand back a readable depth
+  -- buffer still gets fireflies over its grass.
+  local beams = f and f.rayAlpha > 0.01 or false
+  if beams and not Voxel3D.depthReadable() then
+    say("depth", "no readable depth this frame -- beams off, fog stays")
+    beams = false
+  end
+  if beams then
     -- no beams without the sun's own pass: uvVP is only the world -> map
     -- transform while a shadow map is actually standing
     local sunTex = ShadowMap.active() and ShadowMap.texture()
@@ -843,11 +983,13 @@ function ForestAtmos.draw(map)
   end
 
   if rung == "full" then
-    local L = layoutFor(map)
-    local M = L and meshesFor(map, L)
+    local M = meshesFor(map)
+    -- the night shift, for the grass swarm: the atmosphere's own answer
+    -- where there is one, and the bare ramp where there is not
+    local flyLevel = f and f.fireflyLevel or ForestAtmos.fireflyLevel()
     local psh = partShader()
     local axisR, axisU = billboardAxes(Voxel3D)
-    if M and psh and axisR then
+    if (M.motes or M.flies or M.grass) and psh and axisR then
       Voxel3D.blend("add")
       if Voxel3D.beginEffect(psh) then
         pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
@@ -860,7 +1002,8 @@ function ForestAtmos.draw(map)
         pcall(psh.send, psh, "axisR", axisR)
         pcall(psh.send, psh, "axisU", axisU)
         pcall(psh.send, psh, "time", ForestAtmos.time)
-        if M.motes and f.moteLevel > 0.02 then
+        pcall(psh.send, psh, "origin", HOME)
+        if M.motes and f and f.moteLevel > 0.02 then
           pcall(psh.send, psh, "size", 1.4)
           pcall(psh.send, psh, "sway", { 5, 2.5 })
           pcall(psh.send, psh, "blinky", 0)
@@ -868,13 +1011,32 @@ function ForestAtmos.draw(map)
           pcall(psh.send, psh, "level", f.moteLevel * 0.5)
           pcall(love.graphics.draw, M.motes)
         end
-        if M.flies and f.fireflyLevel > 0.02 then
+        -- every swarm of fireflies shares every uniform but the mesh and
+        -- the map corner it stands on: the grass ones ARE the forest's,
+        -- moved onto the grass.
+        --
+        -- The NEIGHBOURS matter here in a way they never did for the fog
+        -- or the beams. A connected route is drawn in full -- its ground,
+        -- its trees, its grass and the firefly cards standing in it -- so
+        -- a swarm that stopped at the seam would draw a line across the
+        -- map where the lights ran out. Each neighbour's own deal is a
+        -- mesh already cached against its id; it costs a uniform and a
+        -- draw call to put it where the terrain under it is.
+        if flyLevel > 0.02 then
           pcall(psh.send, psh, "size", 1.6)
           pcall(psh.send, psh, "sway", { 10, 4 })
           pcall(psh.send, psh, "blinky", 1)
           pcall(psh.send, psh, "dotColor", FLY_COLOR)
-          pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
-          pcall(love.graphics.draw, M.flies)
+          pcall(psh.send, psh, "level", flyLevel * 0.85)
+          if M.flies then pcall(love.graphics.draw, M.flies) end
+          if M.grass then pcall(love.graphics.draw, M.grass) end
+          for _, nb in ipairs(neighbors or {}) do
+            local NM = nb.map and nb.map.id and meshesFor(nb.map)
+            if NM and NM.grass then
+              pcall(psh.send, psh, "origin", { nb.ox or 0, 0, nb.oy or 0 })
+              pcall(love.graphics.draw, NM.grass)
+            end
+          end
         end
         Voxel3D.endEffect()
       end
diff --git a/lib/SettingsHelp.lua b/lib/SettingsHelp.lua
new file mode 100644
index 0000000..59fa617
--- /dev/null
+++ b/lib/SettingsHelp.lua
@@ -0,0 +1,178 @@
+-- SELECT on a row explains what it does.
+--
+-- ------- why this exists at all
+--
+-- Every setting in this mod has ALWAYS carried a paragraph of help. It goes
+-- into the schema handed to the mod manager (ModSetting:schema takes it), it
+-- has been written and kept up to date beside every row in main.lua's
+-- SETTINGS -- and nothing in the engine has ever drawn it. Not the OPTIONS
+-- menu, whose row is a label and a value and has no room for a third thing;
+-- not the mod manager's own page, which renders the same two lines. It was
+-- authored, structured, accurate prose sitting in a field with no reader.
+--
+-- So it gets one. A row on this mod's menus says what it IS on one line and
+-- what it is SET TO on the next, and SELECT says what that means -- which is
+-- the question a row like RENDER DIST or 2D-3D B cannot answer in eighteen
+-- characters however the label is worded.
+--
+-- SELECT rather than a button that already does something: A steps a setting,
+-- B leaves, and the d-pad moves. SELECT is free on a menu -- the mod's own
+-- SELECT hotkey is installed on OverworldController:handleInput, which only
+-- runs while the overworld is the top state, so a menu can have the button
+-- without taking anything from the map.
+--
+-- ------- the shape of it
+--
+-- The game's own dialogue box: drawn with Font.drawBox, so the border is the
+-- ROM's own glyphs and a mod-supplied font theme retextures this along with
+-- everything else (Font.BORDER) -- and anchored to the BOTTOM of the screen
+-- with the menu still visible above it, which is where this game has put
+-- every line of text anybody has ever read in it.
+--
+-- Sized to what it holds rather than to the screen. Each description is one
+-- sentence, so most of these are five or six tiles tall and the row being
+-- asked about is still on screen over the top of the box. A sentence long
+-- enough to overflow scrolls instead of growing past MAX_LINES, a line at a
+-- time on the d-pad -- which is a fallback, not the design: the answer to a
+-- description that needs scrolling is a shorter description.
+
+-- the mod namespace (see main.lua)
+local V = ...
+
+local Font = require("src.render.Font")
+local Theme = require("src.ui.Theme")
+local PaletteFX = require("src.render.PaletteFX")
+
+local SettingsHelp = {}
+SettingsHelp.__index = SettingsHelp
+
+-- NOT opaque: the menu stays drawn underneath, so the row being asked about
+-- is still on screen above the box. That is most of why the box is only as
+-- tall as it needs to be.
+SettingsHelp.isOpaque = false
+
+-- The box spans the screen's twenty tiles and its border owns the outer ring,
+-- so text runs from tile 1. Seventeen columns rather than eighteen: tile 18
+-- is kept clear for the more-arrow, which would otherwise land on top of the
+-- last character of any line that filled the width.
+local COLS = 17
+local PEN_X = 8
+local SCREEN_ROWS = 18
+-- title, plus the body, plus the two border rows
+local CHROME_ROWS = 3
+-- A sentence needing more than this scrolls. Eight lines of seventeen is 136
+-- characters, which is a long sentence and a box two thirds up the screen.
+local MAX_LINES = 8
+
+-- Break a string into lines that fit, on word boundaries. Unbounded, unlike
+-- StadiumScreen's -- that one is capping a save path to what a fixed plate can
+-- show, and this one is the whole point of the screen.
+local function wrapped(str, cols)
+  cols = cols or COLS
+  local lines, line = {}, nil
+  for word in tostring(str or ""):gmatch("%S+") do
+    local try = line and (line .. " " .. word) or word
+    if #try <= cols then
+      line = try
+    else
+      if line then lines[#lines + 1] = line end
+      -- a word longer than the line is broken across lines rather than cut;
+      -- nothing in the help text is that long today, but losing the end of a
+      -- sentence silently is not a failure mode worth leaving open
+      while #word > cols do
+        lines[#lines + 1] = word:sub(1, cols)
+        word = word:sub(cols + 1)
+      end
+      line = word
+    end
+  end
+  if line then lines[#lines + 1] = line end
+  return lines
+end
+
+SettingsHelp.wrapped = wrapped
+
+function SettingsHelp.new(game, title, body)
+  return setmetatable({
+    game = game,
+    title = tostring(title or ""):gsub("%.%.$", ""),
+    lines = wrapped(body),
+    top = 0,
+  }, SettingsHelp)
+end
+
+-- How many body lines this box shows: all of them, unless there are more than
+-- a box is allowed to be tall.
+function SettingsHelp:bodyRows()
+  return math.min(#self.lines, MAX_LINES)
+end
+
+function SettingsHelp:maxTop()
+  return math.max(0, #self.lines - self:bodyRows())
+end
+
+-- Every button that could mean "done" closes it, including SELECT itself --
+-- the press that opened the box is the one a player is most likely to reach
+-- for to get rid of it. A is in there too: it steps a setting everywhere else
+-- on these menus, and stepping one you cannot see would be worse than an
+-- extra way out.
+local DISMISS = { "a", "b", "start", "select" }
+
+function SettingsHelp:update()
+  local input = self.game and self.game.input
+  if not input then return end
+  local maxTop = self:maxTop()
+  -- the d-pad only does anything when there is something below the fold; a
+  -- box showing its whole sentence has nowhere to go and says so by not
+  -- moving
+  if input:wasPressed("down") then
+    self.top = math.min(maxTop, self.top + 1)
+    return
+  elseif input:wasPressed("up") then
+    self.top = math.max(0, self.top - 1)
+    return
+  end
+  for _, btn in ipairs(DISMISS) do
+    if input:wasPressed(btn) then
+      local stack = self.game.stack
+      if 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
+      return
+    end
+  end
+end
+
+function SettingsHelp:draw()
+  local body = self:bodyRows()
+  local th = body + CHROME_ROWS
+  local ty = SCREEN_ROWS - th          -- anchored to the bottom of the screen
+  Font.drawBox(0, ty, 20, th)
+  love.graphics.setColor(0, 0, 0, 1)
+  -- the row's own name, so the box says what it is about even where it covers
+  -- the row that was asked
+  Font.draw(self.title, PEN_X, (ty + 1) * 8)
+  for i = 1, body do
+    local line = self.lines[self.top + i]
+    if not line then break end
+    Font.draw(line, PEN_X, (ty + 1 + i) * 8)
+  end
+  -- the same marker the options list uses for "there is more below this", so
+  -- it means here what it means there
+  if self.top < self:maxTop() then
+    Font.drawCode(Theme.moreArrow, 144, (ty + th - 2) * 8)
+  end
+  love.graphics.setColor(1, 1, 1, 1)
+end
+
+-- Game:draw stops at the first state that HAS this method, so without one the
+-- box would inherit whatever is underneath -- which is a menu of ours, whose
+-- answer happens to be right. Stated anyway: the reason that answer is right
+-- is not a property of this screen, and a future menu that paints something
+-- of its own would silently repaint this box with it.
+function SettingsHelp:sgbPalettes(game)
+  return PaletteFX.wholeNamed(game.data, "MEWMON")
+end
+
+return SettingsHelp
diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua
index 9984da8..0c66c1c 100644
--- a/lib/SettingsMenu.lua
+++ b/lib/SettingsMenu.lua
@@ -70,12 +70,36 @@ end
 -- 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 = "world", label = "3D WORLD..",
+    help = "The diorama itself: how far the world bends, how much of it is "
+      .. "drawn, what the water does and what hour it is outdoors." },
   { id = "battles", label = "BATTLES..",
-    summary = function() return V.require("OverworldBattle").setting:valueLabel() end },
-  { id = "perf", label = "PERFORMANCE.." },
+    summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
+    help = "What a fight is drawn over, how it is framed, and how a ball is "
+      .. "thrown." },
+  { id = "perf", label = "PERFORMANCE..",
+    help = "What the look costs -- the three most expensive things in the "
+      .. "frame after the geometry itself." },
   { id = "vr", label = "VR..",
-    summary = function() return V.require("VR").setting:valueLabel() end },
+    summary = function() return V.require("VR").setting:valueLabel() end,
+    help = "PCVR through OpenXR, and the one comfort setting that belongs to "
+      .. "the headset alone." },
+}
+
+-- ------- help for the rows that are not settings
+--
+-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
+-- next to the row it explains. What is left is the two pipeline rows -- whose
+-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
+-- and the ROM import, which is an action rather than a setting and has no
+-- SETTINGS entry to live in.
+local ROW_HELP = {
+  ["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
+    .. "by a 3D camera, with the numbered rungs its angle in degrees.",
+  ["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
+    .. "look, sharp across the middle and softening above and below it.",
+  ["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
+    .. "cartridge that 3D-BTL's STADIUM rungs need.",
 }
 
 -- ------- what the menus are built from
@@ -91,6 +115,25 @@ function SettingsMenu.define(list)
   settings = list or {}
 end
 
+-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
+-- the category's out of CATEGORIES, or one of the three above for the rows
+-- that have nowhere else to keep it.
+--
+-- Looked up BY ID rather than hung on the row as a field, because two of
+-- these rows are the engine's own tables reused verbatim -- and annotating
+-- somebody else's table is how a mod ends up owning a field it never meant
+-- to. nil for a row with nothing to say, which SELECT reads as "no box".
+function SettingsMenu.helpFor(id)
+  if ROW_HELP[id] then return ROW_HELP[id] end
+  for _, cat in ipairs(SettingsMenu.CATEGORIES) do
+    if SettingsMenu.id(cat.id) == id then return cat.help end
+  end
+  for _, entry in ipairs(settings) do
+    if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
+  end
+  return nil
+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
@@ -273,19 +316,19 @@ function SettingsMenu.signature(rows)
   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.
+-- The bottom line is the only place on this screen to say anything that is
+-- not a row: OptionRows' four boxes fill everything above it and there is no
+-- header slot. It spends that line on the two buttons that are not obvious.
+--
+-- It used to carry the category's NAME instead, for orientation. The hint
+-- won: a binding nobody knows about is worth nothing, and where the player is
+-- was just answered by the row they pressed A on. Sixteen characters of the
+-- eighteen the line has, which is also why the name could not stay -- "BACK:
+-- PERFORMANCE" is seventeen on its own.
+SettingsMenu.BACK_LABEL = "B BACK  SEL HELP"
+
 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"
+  return SettingsMenu.BACK_LABEL
 end
 
 -- A category's contents can change while the player is looking at them: 3D-BTL
@@ -349,6 +392,19 @@ function SettingsMenu:update()
       pop(self)
       return
     end
+  elseif input:wasPressed("select") then
+    -- SELECT explains the row the cursor is on. Every row on these menus has
+    -- something to say -- the settings have carried a paragraph each since
+    -- they were written, and nothing has ever drawn it (see SettingsHelp) --
+    -- but a row that does not is simply left alone rather than opening an
+    -- empty box.
+    local row = rows[self.index]
+    local help = row and SettingsMenu.helpFor(row.id)
+    if help and self.game.stack then
+      self.game.stack:push(
+        V.require("SettingsHelp").new(self.game, row.label, help))
+    end
+    return
   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
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 6239deb..179fc8f 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -1246,8 +1246,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
   -- finished depth buffer, so the trees occlude the light and the light
   -- writes nothing; here in the prop slot, after everything the beams
   -- should fall across and inside drawScene so VR gets them per eye. On
-  -- the one map that has any, today.
-  ForestAtmos.draw(state.map)
+  -- the one map that has any, today -- but the FIREFLIES over tall grass
+  -- need no entry and reach every outdoor map, which is why the connected
+  -- neighbours go too: their grass is drawn, so their lights are.
+  ForestAtmos.draw(state.map, state.neighbors)
 
   -- The VR pokedex in the player's left hand, last of all: a prop over
   -- the world drawn with real depth, so leaning it into a wall still
diff --git a/main.lua b/main.lua
index 39baf40..055e63e 100644
--- a/main.lua
+++ b/main.lua
@@ -475,40 +475,20 @@ local SETTINGS = {
   { 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 "
-    .. "far as it goes before the horizon closes over ground you can still "
-    .. "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.",
+    "Bends the world down over the horizon, until a town sits 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 "
-    .. "frames, which reaches well north of you and flares wide out there, "
-    .. "not the square the flat game shows. So a connected map that falls "
-    .. "entirely outside it is skipped before it is drawn, terrain, water, "
-    .. "grass and shadows together, which is most of the frame's geometry "
-    .. "at the high rungs. Below about 63 degrees that is all the row does "
-    .. "and the picture is untouched. At 75 the camera can see all the way "
-    .. "to the horizon, so something has to name a distance: FIT is the "
-    .. "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.",
+    "How far out the camera bothers to draw, which only changes the picture "
+    .. "above about 63 degrees where the horizon comes into view.",
     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.",
+    "Reflections on water: SKY is the sun, moon and sky alone, and FULL "
+    .. "adds the shoreline and trees behind it.",
     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.",
+    "What time it is outdoors -- pinned to an hour, running on a ten-minute "
+    .. "cycle, or synced to the clock on your wall.",
     cat = "world" },
 
   -- ------- BATTLES -- what a fight is drawn over, and how it is played
@@ -520,45 +500,24 @@ local SETTINGS = {
   -- and forbids back sprites (backPinned answers false), so both rows
   -- decide nothing there and a dead switch on the menu reads as broken.
   { OverworldBattle.setting,
-    "Fight in three dimensions, shot over the shoulder with a slow parallax "
-    .. "drift. 2D-3D stands the game's own battle pics up as cards; STADIUM "
-    .. "replaces them with the Pokemon Stadium battle models, animated, "
-    .. "playing the animation the move being used actually calls for. A "
-    .. "stages the fight on the MAP -- the nearest clear ground, in that "
-    .. "place's own weather and light; B stands it on two discs against the "
-    .. "sky instead, which works everywhere, including the caves and shop "
-    .. "floors that have nowhere to stage a fight. The STADIUM rungs only "
-    .. "appear once the models have been built, and building them needs a "
-    .. "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.",
+    "Fights staged in 3D over your shoulder, on the map or on discs against "
+    .. "the sky, as cards or Stadium's animated models.",
     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,
   -- and a row that no longer decides anything is worse than no row.
   { OverworldBattle.backSetting,
-    "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.",
+    "Keeps your own Pokemon on the battle menu, seen from behind, instead "
+    .. "of standing it on the map facing the foe.",
     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
   -- the diorama, so the FULL preset neither sets it nor takes it away.
   { LetsGo.setting,
-    "Pokemon GO-style catching, staged in the 3D battle. Flick the mouse, "
-    .. "a finger or the right stick to throw the ball at the wild Pokemon "
-    .. "-- spin it first for a curve -- and land inside the shrinking "
-    .. "ring for a NICE, GREAT or EXCELLENT that raises the catch odds. "
-    .. "CATCH ONLY changes nothing else: picking a ball in battle simply "
-    .. "plays the throw. FULL is the whole Let's Go treatment: wild "
-    .. "encounters open straight in throwing mode (B backs out to the "
-    .. "classic menu), Poke/Great/Ultra Balls are half price, and a catch "
-    .. "pays the whole party experience -- scaled by throw quality, first "
-    .. "throws, new species and your running catch combo. Needs 3D-BTL "
-    .. "on; anywhere the staged fight cannot stand, balls quietly throw "
-    .. "the classic way.",
+    "Pokemon GO-style catching -- flick to throw the ball, with FULL adding "
+    .. "half-price balls and party experience (needs 3D-BTL).",
     cat = "battles", full = true },
 
   -- ------- PERFORMANCE -- what the look COSTS, which is a different question
@@ -570,39 +529,21 @@ local SETTINGS = {
   -- `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.",
+    "Haze and light shafts in the deep woods, with pollen in the beams by "
+    .. "day and fireflies over tall grass at night.",
     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
   -- has to stay reachable from inside FULL -- which never sets it either.
   { Shadows.setting,
-    "Real cast shadows: the scene rendered a second time from the sun, so "
-    .. "buildings, trees, ledges and people throw shadows that climb walls, "
-    .. "drape over roofs and slide across each other, following the hour on "
-    .. "the DAYTIME row. It is the most expensive pass in the mode after the "
-    .. "geometry itself -- a whole extra draw of the world every time the "
-    .. "view or anybody in it moves -- so OFF is the first thing to try on a "
-    .. "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.",
+    "Real cast shadows from the sun, and the first thing to switch off on a "
+    .. "phone or an old machine.",
     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.
   { 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 "
-    .. "the window and folding it back down. Every edge in the picture "
-    .. "softens with them, the tileset's own texels included, so the diorama "
-    .. "reads smoother rather than sharper. 2X costs half again as many "
-    .. "pixels in each direction and 4X twice, which makes this the most "
+    "Smooths the stair-stepped edges of the 3D world, and the most "
     .. "expensive row in the mod.",
     cat = "perf", full = true },
 
@@ -611,21 +552,8 @@ local SETTINGS = {
   -- `full` for the same reason as AA: not a knob on the look, a question
   -- about the hardware on the desk.
   { VR.setting,
-    "PCVR through OpenXR (SteamVR, Oculus, WMR). STANDARD follows the VOXEL "
-    .. "ladder: the orbit rungs become a tabletop model your head moves "
-    .. "around, and the 1ST rung stands you inside the world at life size, "
-    .. "looking where the headset looks. DIORAMA is one presentation "
-    .. "instead -- the world always a model, cut to a square viewport you "
-    .. "grab with the grips to carry, turn and open out, with a "
-    .. "fight arriving as a floating disc of the map. There is no 2D and "
-    .. "no first person in it, and the left stick's click throws V-CURVE "
-    .. "to its top rung and back -- which turns the square cut into a ball "
-    .. "with a dissolved rim, because a bent world has no straight sides. "
-    .. "DIORAMA-MR is the same with the background keyed green, for a "
-    .. "mixed-reality capture. "
-    .. "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.",
+    "PCVR through OpenXR on Windows, either following the VOXEL ladder or "
+    .. "as a DIORAMA you carry and turn with the grips.",
     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
@@ -635,11 +563,8 @@ local SETTINGS = {
   -- device that is not plugged in decides nothing, and this one is read
   -- exclusively by the headset's right stick.
   { VR.smoothTurn,
-    "Turn smoothly with the right stick instead of snapping 45 degrees a "
-    .. "flick. OFF by default, and deliberately: a software turn moves the "
-    .. "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.",
+    "Turns smoothly with the right stick instead of snapping 45 degrees, "
+    .. "if you have your sea legs for it.",
     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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 7a5a3f0..c93b9f3 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -567,7 +567,10 @@ 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")
+T.eq(root:backLabel(), "B BACK  SEL HELP",
+  "and a bottom line naming the two buttons that are not obvious")
+T.check(#root:backLabel() <= 18,
+  "which fits the eighteen characters the line has")
 
 -- the second line of a category row
 T.eq(root.rows[rowIndex(root, Menus.catId("battles"))].value(),
@@ -582,10 +585,9 @@ 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")
+T.check(menuGame.stack:top() ~= root,
+  "A on a category opens it, as a screen of its own")
+T.eq(menuGame.stack:top().cat, "world", "the one the cursor was on")
 
 pressed = { b = true }
 menuGame.stack:top():update(0)
@@ -597,6 +599,159 @@ pressed = {}
 T.eq(menuGame.stack:top(), nil, "and START leaves it the way B does")
 end
 
+-- ------- SELECT explains the row the cursor is on
+--
+-- Every setting has carried a paragraph of help since it was written -- it
+-- goes into the schema the mod manager is handed -- and nothing in the engine
+-- has ever drawn one. A row says what it IS and what it is SET TO, and no
+-- engine row anywhere has room for a third thing.
+do
+menuGame.stack.states = {}
+Pipelines.setLevel("voxel", 2)
+local Help = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsHelp")
+local menu2 = Menus.lib.new(menuGame, "world")
+
+-- every row on every menu has something to say, the two engine-owned pipeline
+-- rows and the ROM import included -- those three keep theirs in SettingsMenu,
+-- having no SETTINGS entry of their own to keep it in
+for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
+  for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
+    T.check(Menus.lib.helpFor(row.id),
+      ("%s has help behind SELECT"):format(row.id))
+  end
+end
+T.check(not Menus.lib.helpFor("nothing:here"),
+  "and a row with nothing to say gets nil rather than an empty box")
+
+-- every character of every one of them has a glyph in the ROM's font.
+--
+-- Read from the REAL charmap rather than the fixture's, which is a stub with
+-- seven entries in it. This is the one place in the mod where prose reaches
+-- the screen verbatim, and Font.encode answers a character it does not know
+-- with a SPACE and a one-time console warning that nobody is reading -- so a
+-- curly quote or a `%` pasted in from somewhere would blank a word and say
+-- nothing about it.
+do
+  local ok, font = pcall(dofile, "data/generated/font.lua")
+  if ok and font and font.charmap then
+    local glyph = { [" "] = true }
+    for _, e in ipairs(font.charmap) do
+      if #e.seq == 1 then glyph[e.seq] = true end
+    end
+    local missing = {}
+    for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
+      for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
+        for ch in (Menus.lib.helpFor(row.id) or ""):gmatch(".") do
+          if not glyph[ch] then missing[ch] = true end
+        end
+      end
+    end
+    local names = {}
+    for ch in pairs(missing) do names[#names + 1] = ("%q"):format(ch) end
+    table.sort(names)
+    T.eq(#names, 0, "every character of every help string has a glyph in the "
+      .. "ROM font -- these do not: " .. table.concat(names, " "))
+  end
+end
+
+menu2.index = rowIndex(menu2, "DRAMATIC_SHAPE:viewbox")
+pressed = { select = true }
+menu2:update(0)
+pressed = {}
+local box = menuGame.stack:top()
+T.check(box ~= nil and box ~= menu2, "SELECT opens a box over the menu")
+T.eq(box.title, "RENDER DIST", "titled with the row it explains")
+T.check(#box.lines > 1, "carrying the row's own description, wrapped")
+for _, line in ipairs(box.lines) do
+  T.check(#line <= 17,
+    "every line fits between the border and the more-arrow's column")
+end
+
+-- ------- every description is ONE SENTENCE, and the whole of it is on screen
+--
+-- Both halves matter and the second follows from the first. The box is sized
+-- to what it holds and anchored to the bottom, so a description that fits is
+-- read at a glance with the row it describes still visible above it -- and one
+-- that does not turns a glance into a scroll, which is a worse answer to
+-- "what does this do" than a shorter sentence would have been.
+--
+-- One sentence is: ends with a full stop, and has none inside it. The version
+-- numbers that would break that naive test ("US 1.0") have no space after the
+-- point, which is why the check is for ". " rather than for ".".
+do
+  local long, many = {}, {}
+  for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
+    for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
+      local help = Menus.lib.helpFor(row.id)
+      if help then
+        if help:find(". ", 1, true) or not help:find("%.$") then
+          many[#many + 1] = row.id
+        end
+        local probe = Help.new(menuGame, row.label, help)
+        if probe:maxTop() > 0 then
+          long[#long + 1] = ("%s (%d lines)"):format(row.id, #probe.lines)
+        end
+      end
+    end
+  end
+  T.eq(#many, 0,
+    "every description is one sentence -- these are not: "
+    .. table.concat(many, ", "))
+  T.eq(#long, 0,
+    "and every one of them fits its box without scrolling: "
+    .. table.concat(long, ", "))
+end
+
+-- the scroll is still there, for a description that outgrows the box despite
+-- the check above -- a mod-supplied font with a wider glyph, or a sentence
+-- somebody lengthens later. It stops at both ends rather than wrapping.
+do
+  local tall = Help.new(menuGame, "TALL", ("word "):rep(80))
+  T.check(tall:maxTop() > 0, "a description too long for the box scrolls")
+  T.eq(tall.top, 0, "opening at the top")
+  pressed = { up = true }
+  tall:update()
+  pressed = {}
+  T.eq(tall.top, 0, "up at the top stays there")
+  pressed = { down = true }
+  tall:update()
+  pressed = {}
+  T.eq(tall.top, 1, "down moves one line")
+  for _ = 1, 200 do
+    pressed = { down = true }
+    tall:update()
+    pressed = {}
+  end
+  T.eq(tall.top, tall:maxTop(), "and the end of the text ends the scroll")
+  T.eq(tall:maxTop(), #tall.lines - tall:bodyRows(),
+    "with the last boxful still full, not one line stranded at the bottom")
+end
+
+-- and every button that could mean "done" closes it -- SELECT included, which
+-- is the one a player who just pressed it will reach for
+for _, btn in ipairs({ "a", "b", "start", "select" }) do
+  menuGame.stack.states = {}          -- the scrolled box above is still open
+  menu2.index = rowIndex(menu2, "DRAMATIC_SHAPE:viewbox")
+  pressed = { select = true }
+  menu2:update(0)
+  pressed = {}
+  T.check(menuGame.stack:top() ~= nil, "the box is open")
+  pressed = { [btn] = true }
+  menuGame.stack:top():update()
+  pressed = {}
+  T.eq(menuGame.stack:top(), nil, ("%s closes it"):format(btn:upper()))
+end
+
+-- a title strips the "..'" a category row wears to say it opens something:
+-- inside the box it has already opened, the dots are a promise about a press
+-- that has been made
+T.eq(Help.new(menuGame, "3D WORLD..", "x").title, "3D WORLD",
+  "a category's box is titled without the dots that meant `opens something'")
+-- and the wrap never strands a word it cannot fit
+T.eq(#Help.wrapped(("y"):rep(40))[1], 17,
+  "a word longer than the line is broken across lines rather than cut")
+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
@@ -4531,6 +4686,84 @@ ForestAtmos.setting:sync("off")
 T.check(ForestAtmos.frame(fmap, DayNight.T.day) == nil,
   "OFF answers no frame at all: no fog uniform, no draw, no spend")
 ForestAtmos.setting:sync("full")
+
+-- ------- and the fireflies get out of the forest
+--
+-- The same particle -- same mesh, same blinking shader, same hour's ramp --
+-- dealt over a map's TALL GRASS rather than over its whole volume, and on
+-- every outdoor map rather than the one with an entry. Grass is the entry.
+
+-- the hour reads the same whether a map has an atmosphere or not, which is
+-- the point of pulling it out of frame(): the two cannot drift apart
+T.check(ForestAtmos.fireflyLevel(DayNight.T.night) > 0.5,
+  "the night shift is out at midnight on a map with no entry either")
+T.check(ForestAtmos.fireflyLevel(DayNight.T.day) < 0.05,
+  "and in by day")
+T.eq(ForestAtmos.fireflyLevel(DayNight.T.night),
+     ForestAtmos.frame(fmap, DayNight.T.night).fireflyLevel,
+  "the forest's own answer IS that answer -- one ramp, read twice")
+
+-- the deal: over the cells handed in, one seed, one arrangement
+local meadow = {}
+for cx = 4, 9 do
+  for cy = 4, 9 do meadow[#meadow + 1] = { cx, cy } end
+end
+local g1 = ForestAtmos.grassLayout(meadow, 4242)
+local g2 = ForestAtmos.grassLayout(meadow, 4242)
+T.eq(#g1, 29, "a 36-cell patch musters fireflies at the default density")
+local steady = true
+for i = 1, #g1 do
+  steady = steady and g1[i].x == g2[i].x and g1[i].z == g2[i].z
+end
+T.check(steady, "and deals them the same way on every visit")
+T.check(#ForestAtmos.grassLayout(meadow, 99) > 0
+        and ForestAtmos.grassLayout(meadow, 99)[1].x ~= g1[1].x,
+  "a different map's seed deals a different swarm")
+
+-- every one of them stands OVER the grass it was dealt for, in the tufts'
+-- own height band -- Structures stands a blade to y = 16
+local overGrass, lowDown = true, true
+for _, fly in ipairs(g1) do
+  local cx, cy = math.floor(fly.x / 16), math.floor(fly.z / 16)
+  if cx < 4 or cx > 9 or cy < 4 or cy > 9 then overGrass = false end
+  if fly.y < 0 or fly.y > 16 then lowDown = false end
+end
+T.check(overGrass, "every firefly is dealt into a cell that HAS grass in it")
+T.check(lowDown, "and low over the blades rather than up in the air")
+
+-- the knobs, and the cap that keeps a big route from dealing thousands
+T.eq(#ForestAtmos.grassLayout(meadow, 1, 2.0), 72,
+  "the density knob musters more of them per cell")
+T.eq(#ForestAtmos.grassLayout(meadow, 1, 2.0, 10), 10,
+  "and the cap is the ceiling over it")
+T.eq(#ForestAtmos.grassLayout({}, 1), 0,
+  "a map with no grass on it deals nothing at all")
+
+-- and the map-level gate: outdoors or under a canopy, never in a cave.
+-- A 4x4-block map is 8x8 cells; grass down the middle two columns.
+local function meadowMap(id, def)
+  return {
+    id = id, def = def, widthCells = 8, heightCells = 8,
+    isGrassCell = function(_, cx, cy)
+      return cx >= 3 and cx <= 4 and cy >= 1 and cy <= 6
+    end,
+  }
+end
+local route = meadowMap("ROUTE_1", { width = 4, height = 4,
+                                     tileset = "OVERWORLD" })
+T.check(#ForestAtmos.grassFliesFor(route) > 0,
+  "a route with tall grass on it has fireflies without an entry anywhere")
+local cave = meadowMap("MT_MOON_1F", { width = 4, height = 4,
+                                       tileset = "CAVERN" })
+T.check(ForestAtmos.grassFliesFor(cave) == nil,
+  "and a cave has none, whatever its collision tiles say -- no hour "
+  .. "reaches it, so no night ever falls in it")
+local wood = meadowMap("VIRIDIAN_FOREST", { width = 4, height = 4,
+                                            tileset = "FOREST" })
+T.check(#ForestAtmos.grassFliesFor(wood) > 0,
+  "the canopy map is not outdoor and gets them anyway: night still FALLS "
+  .. "in a forest, and it is the map they were drawn for")
+ForestAtmos.invalidate()
 end
 
 -- ------- a shadow keeps hold of the feet that throw it
diff --git a/tests/grass_firefly_shots.lua b/tests/grass_firefly_shots.lua
new file mode 100644
index 0000000..211baec
--- /dev/null
+++ b/tests/grass_firefly_shots.lua
@@ -0,0 +1,187 @@
+-- Driver: screenshot the fireflies over tall grass.
+--
+-- The forest's own particle, dealt over a map's grass instead of over its
+-- air, on every outdoor map rather than the one with an atmosphere entry
+-- (see ForestAtmos, "the grass fireflies"). What this has to show:
+--
+--   * a route with tall grass on it, at night, with lights over the grass
+--     and none over the road beside it -- the whole point of placing them
+--     by isGrassCell rather than by the grass GRAPHIC;
+--   * the same vantage by DAY, which must have none at all;
+--   * a MAP SEAM at night: the swarm has to carry onto the connected
+--     neighbour, whose grass is drawn in full;
+--   * Viridian Forest, where the grass swarm stands alongside the
+--     authored map-wide one and neither should read as double;
+--   * and the row OFF, which must be pixel-identical to a run from before
+--     the feature existed.
+--
+-- Deterministic on the same recipe forest_fog_shots runs: encounters
+-- killed, tile animation frozen, the atmosphere's clock pinned -- so an
+-- AB_TAG=before/after pair diffs clean. The grass deal itself is seeded
+-- off the map id and never moves.
+--
+--   SHOT_DIR=.scratchpad/grassfly AB_TAG=after \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/grass_firefly_shots.lua \
+--   lovec.exe .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or ".scratchpad/grassfly")
+    .. "/" .. (os.getenv("AB_TAG") or "after")
+  -- U.shot's own mkdir is Unix-flavoured and fails silently on Windows
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  local exports = game.mods and game.mods.exports
+  local handle = exports and exports.DRAMATIC_SHAPE
+  if not (handle and handle.lib) then
+    U.log("DRAMATIC_SHAPE is not loaded -- check the ds_fp_ceiling conflict")
+    return
+  end
+  local lib = handle.lib
+  local DayNight = lib.require("DayNight")
+  local ChunkMesher = lib.require("ChunkMesher")
+  local Voxel = lib.require("VoxelState")
+  local ForestAtmos = lib.require("ForestAtmos")
+
+  -- Prove the running game is reading THIS copy of the file before any
+  -- shot is trusted: an install in the save dir shadows the repo, and a
+  -- stale one makes an unchanged AFTER look like a broken feature.
+  if not ForestAtmos.grassFliesFor then
+    U.log("the loaded ForestAtmos has no grassFliesFor -- a STALE copy is "
+          .. "being read, not this repo. Shots would be meaningless.")
+    return
+  end
+  U.log("grass fireflies present in the loaded mod -- shooting")
+
+  -- ------- the determinism recipe (see forest_fog_shots)
+  require("src.world.OverworldController").rollEncounter =
+    function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+  ForestAtmos.frozen = true
+  ForestAtmos.time = 5
+
+  local function setTime(value)
+    DayNight.setting:sync(value)
+    DayNight.update(0)
+  end
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
+        break
+      end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local function go(mapId, x, y, face, rung)
+    U.teleport(game, mapId, x, y, face or "up")
+    Pipelines.setLevel("voxel", rung or 5)
+    Pipelines.setLevel("tiltshift", 0)   -- judge the lights, not the blur
+    settle()
+  end
+
+  ForestAtmos.setting:sync("full")
+
+  -- ------- find a route the deal actually put fireflies on
+  --
+  -- Rather than guessing a cell with grass in it, ask the layout: it is
+  -- the same list the mesh is built from, so a vantage picked out of it
+  -- is guaranteed to have lights in frame.
+  local ROUTES = { "ROUTE_1", "ROUTE_2", "ROUTE_22", "ROUTE_3" }
+  local routeId, spotX, spotY
+  for _, id in ipairs(ROUTES) do
+    if game.data.maps and game.data.maps[id] then
+      go(id, 5, 5)
+      local map = game.stack:top() and game.stack:top().map
+      local flies = map and ForestAtmos.grassFliesFor(map)
+      if flies and #flies > 0 then
+        -- the middle of the swarm, so the frame holds many rather than one
+        local sx, sy, n = 0, 0, 0
+        for _, fly in ipairs(flies) do
+          sx, sy, n = sx + fly.x, sy + fly.z, n + 1
+        end
+        routeId = id
+        spotX = math.floor(sx / n / 16)
+        spotY = math.floor(sy / n / 16)
+        U.log(("%s: %d fireflies over its grass, centre cell %d,%d")
+              :format(id, #flies, spotX, spotY))
+        break
+      end
+      U.log(id .. ": no grass fireflies dealt")
+    end
+  end
+
+  if not routeId then
+    U.log("no route in this dataset got a grass swarm -- nothing to shoot")
+    return
+  end
+
+  -- ------- the route: night, then the same frame by day
+  setTime("night")
+  go(routeId, spotX, spotY + 3, "up")
+  U.shot(game, ROOT .. "/10_route_night.png")
+  go(routeId, spotX, spotY + 3, "up", 6)      -- 1ST: eye level in the grass
+  U.shot(game, ROOT .. "/11_route_night_fp.png")
+  setTime("day")
+  go(routeId, spotX, spotY + 3, "up")
+  U.shot(game, ROOT .. "/12_route_day.png")   -- must have none at all
+  setTime("dusk")
+  go(routeId, spotX, spotY + 3, "up")
+  U.shot(game, ROOT .. "/13_route_dusk.png")  -- coming on, not yet full
+
+  -- ------- the seam: the swarm has to carry onto the connected neighbour
+  --
+  -- Stand at the map's own edge looking out of it. The neighbour's ground,
+  -- trees and grass are all drawn; if its fireflies were not, the picture
+  -- would have a line across it where the lights stopped.
+  setTime("night")
+  do
+    local map = game.stack:top() and game.stack:top().map
+    local h = map and map.heightCells or 20
+    go(routeId, spotX, math.max(h - 2, 0), "down")
+    U.shot(game, ROOT .. "/20_seam_south_night.png")
+    go(routeId, spotX, 1, "up")
+    U.shot(game, ROOT .. "/21_seam_north_night.png")
+  end
+
+  -- ------- Viridian Forest: the authored swarm and the grass one together
+  if game.data.maps and game.data.maps.VIRIDIAN_FOREST then
+    setTime("night")
+    go("VIRIDIAN_FOREST", 17, 20)
+    U.shot(game, ROOT .. "/30_forest_night.png")
+    go("VIRIDIAN_FOREST", 16, 24)
+    U.shot(game, ROOT .. "/31_forest_night_b.png")
+    setTime("day")
+    go("VIRIDIAN_FOREST", 17, 20)
+    U.shot(game, ROOT .. "/32_forest_day.png")  -- pollen, no fireflies
+  end
+
+  -- ------- the controls
+  setTime("night")
+  ForestAtmos.setting:sync("low")
+  go(routeId, spotX, spotY + 3, "up")
+  U.shot(game, ROOT .. "/40_route_night_low.png")   -- the tuft cards alone
+  ForestAtmos.setting:sync("off")
+  go(routeId, spotX, spotY + 3, "up")
+  U.shot(game, ROOT .. "/41_route_night_off.png")   -- a pre-feature run
+
+  ForestAtmos.setting:sync("full")
+  ForestAtmos.frozen = false
+  setTime("day")
+  U.log("done -- " .. ROOT)
+end