DramaticShapeVoxelMod archival mirror · read-only
6482325e26e278dad93fd216dfa90cbaeadcae3c

remove fireflies

DramaticShape · 2026-08-08 · parent b8b2e0cd33

remove fireflies

Changed files

CHANGELOG.md                  |  62 ++++---------------
 data/map_atmosphere.lua       |  16 +----
 lib/ForestAtmos.lua           | 204 +++++++-------------------------------------------------------
 lib/SettingsMenu.lua          |  16 +++++
 lib/Shiny.lua                 |  70 +++++++++++++++++++--
 lib/VoxelScene.lua            |   6 +-
 main.lua                      |  20 +++++-
 tests/dramatic_shape_test.lua |  78 ------------------------
 tests/grass_firefly_shots.lua | 187 --------------------------------------------------------
 tests/shiny_test.lua          |  27 +++++++++
 10 files changed, 162 insertions(+), 524 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39b0256..fc3e552 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,57 +4,6 @@
 
 ### 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
@@ -127,6 +76,17 @@
   pixel rather than floating over the finished frame. Page 1 only: page 2
   clears that block itself.
 
+  **SHINY ODDS**, on the DRAMATIC SHAPE menu itself rather than in one of
+  its four categories -- those are the diorama, the fights, what the look
+  costs and the headset, and an encounter rate is none of them. The row
+  reads `1:8192` and halves down to `1:1`, so every rung is exactly twice
+  as often as the one above it, and `1:8192` is both the default and the
+  fallback for an unreadable options file: the mod's default is the games'
+  own rate, not a buff. The number is the truth rather than an
+  approximation of it, because a missed roll also clears a mon that landed
+  on the pattern by luck -- without that, every setting would be itself
+  and 1/8192 in parallel, and no setting could ever be rarer than 8192.
+
 ## 1.8.0
 
 ### Added
diff --git a/data/map_atmosphere.lua b/data/map_atmosphere.lua
index b9ff808..5d6c321 100644
--- a/data/map_atmosphere.lua
+++ b/data/map_atmosphere.lua
@@ -13,9 +13,7 @@
 -- 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. 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).
+-- they cool.
 --
 -- 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
@@ -38,18 +36,8 @@
 --   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, dealt over the map's whole volume
+--   fireflies count of the night shift
 --   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 cd22560..884f671 100644
--- a/lib/ForestAtmos.lua
+++ b/lib/ForestAtmos.lua
@@ -137,7 +137,6 @@ 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
@@ -157,10 +156,9 @@ end
 function ForestAtmos.invalidate(mapId)
   if mapId then
     layoutCache[mapId] = nil
-    grassCache[mapId] = nil
     meshCache[mapId] = nil
   else
-    layoutCache, grassCache, meshCache = {}, {}, {}
+    layoutCache, meshCache = {}, {}
     shaders = {}
     leafTex = nil
     rayMesh = nil
@@ -196,18 +194,6 @@ 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.
@@ -217,8 +203,7 @@ 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 = 0, 0, 0
-  local flies = ForestAtmos.fireflyLevel(t)
+  local alpha, dens, motes, flies = 0, 0, 0, 0
   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
@@ -226,6 +211,7 @@ 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
@@ -388,111 +374,6 @@ 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)
@@ -756,9 +637,6 @@ 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;
@@ -766,7 +644,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 + origin + vec3(
+    vec3 base = vertex_position.xyz + 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);
@@ -855,19 +733,12 @@ local function buildPartMesh(points)
   return mesh
 end
 
--- 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 function meshesFor(map, L)
   local hit = meshCache[map.id]
   if hit then return hit end
-  local L = layoutFor(map)
   local M = {
-    motes = L and buildPartMesh(L.motes) or nil,
-    flies = L and buildPartMesh(L.flies) or nil,
-    grass = buildPartMesh(grassFliesFor(map) or {}),
+    motes = buildPartMesh(L.motes),
+    flies = buildPartMesh(L.flies),
   }
   meshCache[map.id] = M
   return M
@@ -875,7 +746,6 @@ 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
@@ -904,29 +774,19 @@ end
 -- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
 -- refused shader, no readable depth, no shadow map -- subtracts only
 -- itself.
---
--- `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)
+function ForestAtmos.draw(map)
   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")
 
-  -- 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
+  if f.rayAlpha > 0.01 then
+    if not Voxel3D.depthReadable() then
+      say("depth", "no readable depth this frame -- beams off, fog stays")
+      return
+    end
     -- 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()
@@ -983,13 +843,11 @@ function ForestAtmos.draw(map, neighbors)
   end
 
   if rung == "full" then
-    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 L = layoutFor(map)
+    local M = L and meshesFor(map, L)
     local psh = partShader()
     local axisR, axisU = billboardAxes(Voxel3D)
-    if (M.motes or M.flies or M.grass) and psh and axisR then
+    if M and psh and axisR then
       Voxel3D.blend("add")
       if Voxel3D.beginEffect(psh) then
         pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
@@ -1002,8 +860,7 @@ function ForestAtmos.draw(map, neighbors)
         pcall(psh.send, psh, "axisR", axisR)
         pcall(psh.send, psh, "axisU", axisU)
         pcall(psh.send, psh, "time", ForestAtmos.time)
-        pcall(psh.send, psh, "origin", HOME)
-        if M.motes and f and f.moteLevel > 0.02 then
+        if M.motes 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)
@@ -1011,32 +868,13 @@ function ForestAtmos.draw(map, neighbors)
           pcall(psh.send, psh, "level", f.moteLevel * 0.5)
           pcall(love.graphics.draw, M.motes)
         end
-        -- 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
+        if M.flies and f.fireflyLevel > 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", 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
+          pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
+          pcall(love.graphics.draw, M.flies)
         end
         Voxel3D.endEffect()
       end
diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua
index 0c66c1c..e5ad7fd 100644
--- a/lib/SettingsMenu.lua
+++ b/lib/SettingsMenu.lua
@@ -192,6 +192,22 @@ function SettingsMenu.rows(catId, game)
         out[#out + 1] = row
       end
     end
+    -- ------- settings that belong to no category
+    --
+    -- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
+    -- menu next to the pipeline rows. For a setting that is about the GAME
+    -- rather than about one of the four things the categories are for --
+    -- SHINY ODDS is the first -- burying it under a heading it does not
+    -- belong to is worse than the flat list this menu was built to end.
+    --
+    -- Above the categories, because these are rows you CHANGE and those are
+    -- rows you OPEN: everything with a value on it stays together at the top
+    -- of the screen, and the "..." rows read as the way further in.
+    for _, entry in ipairs(settings) do
+      if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
+        out[#out + 1] = entry[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
diff --git a/lib/Shiny.lua b/lib/Shiny.lua
index 3a191d9..bcf32c9 100644
--- a/lib/Shiny.lua
+++ b/lib/Shiny.lua
@@ -42,6 +42,8 @@ local V = ...
 -- src.pokemon.Stats precisely so an indicator mod can call isShiny
 local Stats = require("src.pokemon.Stats")
 
+local ModSetting = V.require("ModSetting")
+
 local Shiny = {}
 
 -- ------- the odds
@@ -59,17 +61,75 @@ local Shiny = {}
 -- the number says.
 Shiny.ODDS_DENOM = 8192
 
--- Set the denominator. Guards the degenerate values because a 0 or a
--- negative here would divide-by-zero or make every encounter shiny by
--- accident rather than by choice; 1 (always shiny) stays reachable because
--- it is genuinely useful for walking the whole model set.
+-- ------- the row the player cycles
+--
+-- A ladder that HALVES, so every step is exactly "twice as often as the one
+-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
+-- rate is what the number says, not an approximation of it, because the
+-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
+-- 1:2 really is every other encounter.
+--
+-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
+-- and the fallback for an unreadable or unrecognised stored value, so the
+-- canonical rate is what a player who never opens the menu gets and what a
+-- corrupted options.lua comes back to.
+--
+-- No rung RARER than 8192. The mod's promise is that its default is not a
+-- change to the game; making the game harder than it ships is a different
+-- promise and nobody asked for it.
+local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
+
+local ODDS_LABELS = {}
+for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
+
+Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
+
+-- ------- the setting is PULLED, not pushed
+--
+-- decide() asks this every roll rather than the menu telling us when it
+-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
+-- settings page -- and only the first has a change hook to hang on; the
+-- manager writes through mod.options and calls ModSetting:sync, which
+-- notifies nothing. Pulling is the only way both are seen, and the cost is
+-- a table read on an event that happens once per encounter.
+--
+-- ODDS_DENOM stays the live value and is written through on every ask, so
+-- anything already reading that field keeps reading the truth.
+local pinned = false
+
+function Shiny.odds()
+  if not pinned then
+    local ok, value = pcall(Shiny.setting.get, Shiny.setting)
+    local n = ok and tonumber(value)
+    if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
+  end
+  return Shiny.ODDS_DENOM
+end
+
+-- Set the denominator BY HAND, which also PINS it: a driver or a test that
+-- has asked for 1:1 means it, and must not have the next roll quietly put
+-- back to whatever the player left on the menu. Nothing in the game calls
+-- this -- the row is how a player changes the rate.
+--
+-- Guards the degenerate values because a 0 or a negative here would
+-- divide-by-zero or make every encounter shiny by accident rather than by
+-- choice; 1 (always shiny) stays reachable because it is genuinely useful
+-- for walking the whole model set.
 function Shiny.setOdds(denom)
   denom = tonumber(denom)
   if not denom or denom < 1 then return Shiny.ODDS_DENOM end
   Shiny.ODDS_DENOM = math.floor(denom)
+  pinned = true
   return Shiny.ODDS_DENOM
 end
 
+-- Hand the row back control, for a test that pinned the odds and wants the
+-- setting to mean something again afterwards.
+function Shiny.unpinOdds()
+  pinned = false
+  return Shiny.odds()
+end
+
 -- ------- reading it
 
 -- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
@@ -205,7 +265,7 @@ function Shiny.decide(mon, rng)
   -- same shape as love.math.random(lo, hi), so a caller can pass that or a
   -- stub and the call below reads identically either way
   rng = rng or function(_lo, hi) return roll(hi) end
-  local hit = rng(1, Shiny.ODDS_DENOM) == 1
+  local hit = rng(1, Shiny.odds()) == 1
 
   if hit then
     forceShiny(mon.dvs)
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 179fc8f..6239deb 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -1246,10 +1246,8 @@ 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 -- 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 one map that has any, today.
+  ForestAtmos.draw(state.map)
 
   -- 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 055e63e..33d1842 100644
--- a/main.lua
+++ b/main.lua
@@ -88,6 +88,7 @@ local WorldCurve = V.require("WorldCurve")
 local ViewBox = V.require("ViewBox")
 local OverworldBattle = V.require("OverworldBattle")
 local BattleExit = V.require("BattleExit")
+local Shiny = V.require("Shiny")
 local ShinyBattle = V.require("ShinyBattle")
 local ShinyUI = V.require("ShinyUI")
 local DayNight = V.require("DayNight")
@@ -471,6 +472,21 @@ end
 --         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 = {
+  -- ------- the top-level menu -- settings that are about the GAME
+  --
+  -- SettingsMenu.ROOT as a `cat` puts a row on the DRAMATIC SHAPE screen
+  -- itself rather than inside one of the four categories, which is right
+  -- here: the categories are the diorama, the fights, what the look costs
+  -- and the headset, and how often a shiny appears is none of those.
+  --
+  -- `full` for the battle rows' reason: FULL is a preset for the LOOK, and
+  -- an encounter rate is a rule of the game. A player inside FULL must be
+  -- able to reach it, and FULL must never set it.
+  { Shiny.setting,
+    "How often a wild Pokemon turns up shiny. 1:8192 is the games' own "
+    .. "rate, and every rung below it is twice as often as the one above.",
+    cat = SettingsMenu.ROOT, full = true },
+
   -- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
   { VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
     cat = "world" },
@@ -529,8 +545,8 @@ 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,
-    "Haze and light shafts in the deep woods, with pollen in the beams by "
-    .. "day and fireflies over tall grass at night.",
+    "Haze and volumetric light shafts in the deep woods, with pollen in the "
+    .. "beams by day and fireflies 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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index c93b9f3..e121062 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -4686,84 +4686,6 @@ 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
deleted file mode 100644
index 211baec..0000000
--- a/tests/grass_firefly_shots.lua
+++ /dev/null
@@ -1,187 +0,0 @@
--- 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
diff --git a/tests/shiny_test.lua b/tests/shiny_test.lua
index c2b97bf..32d670a 100644
--- a/tests/shiny_test.lua
+++ b/tests/shiny_test.lua
@@ -133,6 +133,33 @@ do
   eq(Shiny.ODDS_DENOM, saved, "and a sane one is accepted")
 end
 
+do
+  -- ------- the row on the menu
+  --
+  -- The ladder's first rung is both the default and the fallback, so the
+  -- canonical 1:8192 has to be it: a player who never opens the menu, and a
+  -- corrupted options.lua, must both land on the games' own rate.
+  local s = Shiny.setting
+  eq(s.values[1], 8192, "the ladder starts at the canonical rate")
+  eq(s.labels[1], "1:8192", "and says so in the 1:# the row shows")
+  eq(s.labels[#s.labels], "1:1", "the last rung is every encounter")
+  eq(#s.values, #s.labels, "every rung has a label")
+  for i = 2, #s.values do
+    eq(s.values[i] * 2, s.values[i - 1],
+       ("rung %d is twice as often as the one above"):format(i))
+  end
+
+  -- and the roll READS it. Pulled rather than pushed, so a value written by
+  -- the mod manager's page -- which notifies nobody -- is seen too.
+  local before = s:read()
+  s:setValue(512)
+  Shiny.unpinOdds()
+  eq(Shiny.odds(), 512, "the roll follows the row without being told")
+  eq(Shiny.ODDS_DENOM, 512, "and the live field is written through")
+  s:setIndex(before)
+  Shiny.setOdds(8192)          -- and pinned again, for the blocks below
+end
+
 do
   -- stats must follow the DVs, and a full-health mon must stay full: a wild
   -- mon that appears at less than full HP is visible in the first frame