DramaticShapeVoxelMod archival mirror · read-only
41f1f073427516364067aef16cbda2475f745f03

grass sway during battles, fix south screen culling

DramaticShape · 2026-08-08 · parent 848c9cb29f

grass sway during battles, fix south screen culling

Changed files

CHANGELOG.md                  |  54 ++++++++++++
 README.md                     |   1 +
 lib/BattleScene.lua           |  18 ++++
 lib/CatchThrow.lua            | 165 ++++++++++++++++++++++++++++--------
 lib/LetsGo.lua                |  48 +++++++++--
 lib/ShadowMap.lua             |  26 +++++-
 lib/Shadows.lua               |  60 +++++++++++++
 lib/ViewBox.lua               |  74 ++++++++++++++++-
 lib/VoxelScene.lua            |   7 +-
 main.lua                      |  16 ++++
 tests/dramatic_shape_test.lua | 133 +++++++++++++++++++++++++++--
 tests/letsgo_empty.lua        | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 833 insertions(+), 51 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb1a20b..63ea377 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,20 @@
   EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the
   shakes the roll answers are the rocks the ball plays on the ground.
 
+  **Running out, and staying out of the way.** Under FULL an empty bag
+  does not hand the fight back to the classic menu -- there is no fight to
+  hand back, since a Let's Go wild has no Pokemon of yours in it and a foe
+  that never takes a turn, so that menu would offer a FIGHT that cannot
+  happen. The encounter keeps its own screen: the seat holds, the Pokemon
+  stands there, the readout says NO BALLS LEFT, and RUN is the way out.
+  Throwing your last ball lands in the same place rather than ending the
+  session. And the scripted catch tutorials -- the VIRIDIAN CITY old man,
+  and Yellow's PROF.OAK catching the PIKACHU -- are left alone at every
+  rung: they are cutscenes wearing a battle's clothes, where the cursor,
+  the bag and the throw are all scripted and nobody keeps the Pokemon, so
+  they play exactly as the original does with no capture screen, no held
+  camera and no experience.
+
   **What it stands on.** The outcome is exactly a Gen 1 ball throw: same
   catch math (status, HP and ball factors intact), same outcome texts,
   same caught flow -- dex page, nickname, box overflow -- and a missed
@@ -50,6 +64,46 @@
   depth-capable driver, no headset); anywhere it cannot stand, balls
   quietly take the engine's classic toss.
 
+- **SHADOWS: a row that stands the sun's pass down.** Cast shadows are the
+  most expensive thing the mode draws after the geometry -- the whole world
+  rendered a second time from the light, every time the view or anybody in
+  it moves -- and on a phone or an old laptop that is the difference between
+  the diorama running and the diorama stuttering. ON by default, because a
+  world where a building throws nothing reads as flat however many voxels it
+  is made of. OFF means off rather than "fall back": the flat decal drop
+  shadows are the stand-in for a machine that WANTED shadows and could not
+  have them, so they stay down too, and the forest's light shafts go with
+  them (the beams are lit by the sun's own map). FULL neither sets the row
+  nor takes it away, on the same reasoning as AA -- what the look costs is
+  the player's question, not a preset's.
+
+### Fixed
+
+- **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 --
+  never switched it on: the uniform sat at the per-frame default, which means
+  no wind, so a field that was moving one frame before the encounter went dead
+  still for the whole fight and started again when it ended. A fight is staged
+  on the MAP, in that place's own weather and light; a frozen field was the one
+  thing reading as a photograph of it rather than the place. No walker-contact
+  push comes with it -- that is somebody stepping through the grass, and the
+  two mons stand still on their own tiles.
+
+- **The bottom of the frame no longer bites a row out of the scenery.**
+  RENDER DIST cut the world to where the frame's rays land on the GROUND,
+  and the ground is not what the picture is made of: a tree at the bottom of
+  the screen has its feet south of the row its top is seen on, because the
+  bottom edge's ray is still coming down as it passes them. The cut is by
+  column -- deliberately, so it never takes the tops off trees -- so a tree
+  whose base fell one pixel outside lost its whole height at once, and the
+  last row of forest along the bottom of the frame was cut through with the
+  ground behind it showing. The south edge is now walked back down that same
+  ray by the tallest thing that can stand on it (about a tile and a half at
+  35 degrees, four tiles at 50, eleven at 75), plus a tile of slack so a hard
+  edge is never decided by a rounding. FIT carries it too: it is a correction
+  to the honest answer, not margin around it.
+
 ## 1.7.1
 
 ### Added
diff --git a/README.md b/README.md
index 5acd476..359cd04 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ menu.
 | `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models |
 | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
 | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
+| the **SHADOWS** options row | ON / OFF — real cast shadows, thrown by rendering the whole scene a second time from the sun, so buildings, trees, ledges and people shadow whatever they land on: walls, roofs, each other. The most expensive pass in the mode after the geometry itself, and the first thing to turn off 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, since the beams are lit by the sun's own map. **FULL** leaves it alone |
 | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
 | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
 
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index ed4b6d4..a6ad975 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -736,11 +736,29 @@ function BattleScene.render(state, arena, textures, token)
     -- pull is also what keeps a tuft from z-fighting the floor it stands on
     local pull = VoxelScene.pull(math.max(pitch, 0.05))
     if not discs then
+      -- and the WIND blowing through it, exactly as the free-roam pass
+      -- switches on around its own grass draws (VoxelScene). Without this
+      -- the uniform sits at the per-frame default beginScene sends -- zero,
+      -- meaning "no wind" -- and the tall grass a fight is standing in goes
+      -- dead still for the length of the battle while the same tufts one
+      -- frame earlier, and one frame after, were moving. A staged fight is
+      -- shot on the MAP, in that place's own weather and light; a frozen
+      -- field is the one thing that reads as a photograph of it rather than
+      -- the place itself.
+      --
+      -- No contact point goes with it (grassWind's px/pz are left nil, which
+      -- sends the far-away sentinel): that push is a WALKER parting the grass
+      -- they are stepping through, and there is nobody walking here -- the
+      -- two mons stand still on their own tiles for the whole shot.
+      Voxel3D.grassWind(true)
       Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
       for _, nb in ipairs(neighbors) do
         Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
                      Mat4.translate(nb.ox, 0, nb.oy), pull)
       end
+      -- off again before the flowers, which are not grass and have no sway
+      -- of their own -- the same order the free-roam pass draws them in
+      Voxel3D.grassWind(false)
       local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
       Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
                    ShadowMap.snug(nil))
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
index b573115..47bc7c5 100644
--- a/lib/CatchThrow.lua
+++ b/lib/CatchThrow.lua
@@ -913,6 +913,19 @@ local function drawGB(battle)
   local g = love.graphics
   g.push("all")
 
+  -- The empty hand: no ring (nothing will be graded) and no ball readout
+  -- (there is no ball) -- just what happened and the way out. On the two
+  -- rows the aim HUD already uses, NOT side by side on one: at the GB font
+  -- these two strings are ~110 and ~56 pixels wide in a 160 pixel frame,
+  -- so a single row draws the second plate straight over the end of the
+  -- first ("OUT OF BALL|A/B:RUN").
+  if S.phase == "aim" and S.empty then
+    label("NO BALLS LEFT", 8, 119)
+    label("A/B:RUN", 158, 131, "right")
+    g.pop()
+    return
+  end
+
   if S.phase == "aim" or S.phase == "flight" then
     local cx, cy, outer = ringGeometry(shot)
     local rho = ringRho(S.ringT)
@@ -945,7 +958,10 @@ local function drawGB(battle)
     -- enough at the GB font to run under the B:BACK plate opposite
     ballGlyph(8, 137, S.ballId)
     label(("%s x%d"):format(name:gsub("%s*BALL%s*", ""), count), 15, 131)
-    label("B:BACK", 158, 131, "right")
+    -- and what B does, which is not the same thing in both modes: under
+    -- FULL there is no classic menu behind the throw to back out TO, so B
+    -- is the encounter's RUN and the label has to say so
+    label(S.fullWild and "B:RUN" or "B:BACK", 158, 131, "right")
     if S.canSwitch then label("L/R:BALL", 15, 119) end
 
     -- the wind-up readout: sparks orbit the ball as it charges, dim and
@@ -1042,10 +1058,12 @@ end
 
 -- opts: consumed (the bag already took the ball), safari (safari flow),
 -- canSwitch (aim-time ball cycling), declinable (B backs out / runs),
--- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees)
+-- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees),
+-- empty (ballId is nil -- the out-of-balls hand, see rearm below)
 function CatchThrow.begin(battle, ballId, opts)
   if S then return false end
   opts = opts or {}
+  local empty = opts.empty or ballId == nil
   local ok = pcall(function()
     local arena, groundY = OB().arenaInfo()
     local shot = OB().shot()
@@ -1056,8 +1074,9 @@ function CatchThrow.begin(battle, ballId, opts)
       ballId = ballId,
       safari = opts.safari or false,
       fullWild = opts.fullWild or false,
+      empty = empty,
       consumed = opts.consumed or false,
-      canSwitch = opts.canSwitch or false,
+      canSwitch = (opts.canSwitch and not empty) or false,
       declinable = opts.declinable ~= false,
       phase = "aim",
       clock = 0, t = 0, ringT = 0,
@@ -1072,6 +1091,7 @@ function CatchThrow.begin(battle, ballId, opts)
       handGB = { 80, 110 },
     }
     ball.pos = handPos()
+    ball.visible = not empty
     S.body = { r = 10, yOff = 8, hh = 8 }  -- until a measurement lands
     measureBody()
   end)
@@ -1080,7 +1100,7 @@ function CatchThrow.begin(battle, ballId, opts)
     return false
   end
   battle.phase = CatchThrow.PHASE
-  CatchThrow.lastBall = ballId
+  if ballId then CatchThrow.lastBall = ballId end
   -- the capture seat, HELD: still drops the drift, the steer and the lift
   -- inside the rig itself, and OverworldBattle's per-frame steerable gate
   -- closes every input that could move it. The player's own steered angle
@@ -1089,13 +1109,27 @@ function CatchThrow.begin(battle, ballId, opts)
   return true
 end
 
--- the next ball into the hand, without tearing the seat down: FULL's
--- "always in throw mode" between one throw and the next
+-- The next ball into the hand, without tearing the seat down: FULL's
+-- "always in throw mode" between one throw and the next.
+--
+-- A NIL ball is not a failure to rearm -- it is the empty hand, and it is
+-- still capture mode. Running out mid-encounter does not hand the fight
+-- back to the classic menu (there is no fight: a Let's Go wild has no
+-- player Pokemon in it and the foe never takes a turn, so the menu would
+-- offer a FIGHT that cannot happen against a foe that cannot answer).
+-- The seat holds, the Pokemon stands there, and the readout says so with
+-- the one move that is left.
 local function rearm(ballId)
   S.ballId = ballId
-  CatchThrow.lastBall = ballId
-  S.ballInst = Pokeball.new(ballId)
-  S.ballInst.pos = handPos()
+  S.empty = ballId == nil
+  if ballId then
+    CatchThrow.lastBall = ballId
+    S.ballInst = Pokeball.new(ballId)
+    S.ballInst.pos = handPos()
+  else
+    S.ballInst.visible = false
+    S.canSwitch = false
+  end
   S.battle.phase = CatchThrow.PHASE   -- park the engine's menu again
   S.phase = "aim"
   S.clock = 0
@@ -1135,8 +1169,12 @@ local function winToGB(x, y)
   return (px - shot.lx) / shot.scale, (py - shot.ly) / shot.scale
 end
 
+-- An EMPTY hand is not aimable: with no ball there is nothing to drag, so
+-- the pointer seams stand down entirely and the mouse/finger goes back to
+-- whatever owned it -- rather than the player wrestling an invisible ball
+-- around a screen that can never throw it.
 local function aiming()
-  return S ~= nil and S.phase == "aim"
+  return S ~= nil and S.phase == "aim" and not S.empty
 end
 
 local function pointer(kind, x, y, id)
@@ -1154,12 +1192,78 @@ local function pointer(kind, x, y, id)
   end
 end
 
+-- ------- the BUTTONS, read on the LOGIC STEP rather than the frame
+--
+-- Everything else in this file is presentational and rides the render
+-- frame (Pipelines.update). Button EDGES cannot: Input:step rebuilds
+-- `pressed` from scratch once per FIXED step, and Game:update runs the
+-- fixed steps for the frame FIRST and Pipelines.update after -- so a
+-- frame that runs two steps has already thrown the first step's edges
+-- away by the time anything on the render clock looks at them.
+--
+-- Which is not a rare race. It is every frame below 60fps: the key is
+-- queued between frames, the frame's FIRST step promotes it, the SECOND
+-- wipes it, and the poll never sees it at all. A 3D battle is exactly
+-- where the frame rate goes under, so B-to-run, A-to-throw and the L/R
+-- ball switch were all reliably dead on the machines that most needed
+-- them and fine on a 144Hz one (where most frames run no step at all and
+-- the edge lingers). Read from the pressQueue on the engine's own
+-- input.step seam instead: it fires once per logic step, never skipped,
+-- with this step's presses still in the queue.
+--
+-- Taken rather than peeked, so a press the capture consumed does not also
+-- page the message it just queued.
+local function take(g, btn)
+  local q = g and g.input and g.input.pressQueue
+  if not q then return false end
+  local hit = false
+  for i = #q, 1, -1 do
+    if q[i] == btn then
+      table.remove(q, i)
+      hit = true
+    end
+  end
+  return hit
+end
+
+function CatchThrow.buttons(g)
+  if not (S and S.phase == "aim") then return end
+  -- the same beat of deafness the drag has: the A that picked the ball out
+  -- of the bag menu is still this step's edge, and must not become a throw
+  if S.clock < 0.25 then return end
+  if S.empty then
+    -- A as well as B: nothing here can be confirmed, so the confirm button
+    -- should get the player out rather than do nothing at all
+    local b, a = take(g, "b"), take(g, "a")
+    if b or a then CatchThrow.cancel(true) end
+    return
+  end
+  if S.declinable and take(g, "b") then
+    CatchThrow.cancel(true)
+    return
+  end
+  if take(g, "a") then tapThrow() end
+  if take(g, "left") then switchBall(-1) end
+  if take(g, "right") then switchBall(1) end
+end
+
 function CatchThrow.installInput()
   if installed then return end
   installed = true
 
   local Game = require("src.core.Game")
 
+  -- the logic-step seam for the buttons (see above). next_ first, so a
+  -- tool mod injecting presses on this hook is read like a controller.
+  local mod = V.mod
+  if mod and mod.hooks then
+    mod.hooks:wrap("input.step", function(next_, g, dt)
+      local r = next_(g, dt)
+      pcall(CatchThrow.buttons, g)
+      return r
+    end)
+  end
+
   -- ------- mouse
   --
   -- Installed after CamControl's wraps (see main.lua's install order), so
@@ -1340,6 +1444,18 @@ function CatchThrow.update(dt)
   end
 
   if S.phase == "aim" then
+    -- ------- the empty hand
+    --
+    -- No ball, so no drag, no wind-up, no throw and no ring: the seat and
+    -- the Pokemon are all that is left, and the only input that means
+    -- anything is leaving. A runs as well as B on purpose -- there is no
+    -- second choice for it to be confused with, and a player mashing the
+    -- confirm button at a screen that cannot confirm anything should get
+    -- out rather than get stuck.
+    if S.empty then
+      if BattleScene.capture then BattleScene.capture.hideTextBox = true end
+      return                      -- A/B are read on the logic step, below
+    end
     -- the foe's body, if no measurement landed at begin (the texture pass
     -- and the session race on the entry frame; a model needs its pack
     -- warm): retried while aiming, briefly -- the default torso covers a
@@ -1407,36 +1523,17 @@ function CatchThrow.update(dt)
     if BattleScene.capture then BattleScene.capture.hideTextBox = true end
 
     stickInput(dt)
-
-    -- a beat of deafness on entry: the A that picked the ball in the bag
-    -- menu is still this frame's edge, and it must not become the throw
-    if S.clock < 0.25 then return end
-    local input = game().input
-    if input and input.wasPressed then
-      if S.declinable and input:wasPressed("b") then
-        CatchThrow.cancel(true)
-        return
-      end
-      if input:wasPressed("a") then tapThrow() end
-      if input:wasPressed("left") then switchBall(-1) end
-      if input:wasPressed("right") then switchBall(1) end
-    end
+    -- B, A and the L/R ball switch are NOT read here: button edges do not
+    -- survive the render clock (CatchThrow.buttons, on the logic step)
     return
   end
 
   -- FULL, between throws: the miss text is playing, the seat is held, and
   -- the moment the engine offers the menu the next ball is in the hand
-  -- instead. Out of balls, the hold releases and the classic menu takes
-  -- over -- there is nothing left to be in throw mode WITH.
+  -- instead -- or, when that was the last ball, the empty hand is (rearm
+  -- takes nil for exactly this). Either way the capture screen stays up.
   if S.phase == "await" then
-    if S.battle.phase == "menu" then
-      local ball = CatchThrow.pickBall()
-      if ball then
-        rearm(ball)
-      else
-        endSession()
-      end
-    end
+    if S.battle.phase == "menu" then rearm(CatchThrow.pickBall()) end
     return
   end
 
diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua
index b62ad2f..e2075ef 100644
--- a/lib/LetsGo.lua
+++ b/lib/LetsGo.lua
@@ -104,10 +104,25 @@ local function vrOn()
   return ok and vr and vr.enabled and vr.enabled() or false
 end
 
+-- ------- the battles that are cutscenes wearing a battle's clothes
+--
+-- The catch tutorials -- the VIRIDIAN CITY old man, and Yellow's PROF.OAK
+-- catching the PIKACHU (both BattleState:makeOldManDemo, which is why one
+-- flag covers both) -- are scripted from the first frame: the cursor moves
+-- itself, the bag opens itself, the ball is thrown by someone who is not
+-- the player, and the throw always catches a Pokemon nobody keeps. There
+-- is no decision in them to hand a minigame, and the story beat is the
+-- point, so LET'S GO stays out of them entirely at whatever rung: no
+-- capture screen, no FULL treatment, no experience.
+function LetsGo.scripted(battle)
+  return battle and (battle.demo or battle.oakDemo) and true or false
+end
+
 function LetsGo.wantsMinigame(battle)
   if not LetsGo.mode() then return false end
   if not battle or battle.kind ~= "wild" then return false end
-  if battle.demo or battle.ghost or battle.noCatch then return false end
+  if LetsGo.scripted(battle) then return false end
+  if battle.ghost or battle.noCatch then return false end
   if not Voxel3D.available() or vrOn() then return false end
   -- the staged shot must actually be standing: this is "there is a 3D
   -- battle on screen right now", which the throw is aimed into
@@ -123,8 +138,8 @@ end
 -- from the wipe to the last message.
 function LetsGo.fullWild(battle)
   return LetsGo.mode() == "full" and battle and battle.kind == "wild"
-         and not (battle.safari or battle.demo or battle.ghost
-                  or battle.noCatch)
+         and not LetsGo.scripted(battle)
+         and not (battle.safari or battle.ghost or battle.noCatch)
          and true or false
 end
 
@@ -237,9 +252,18 @@ local function autoEnter()
   if battle.dramaticShapeDeclined then return end
   if not LetsGo.wantsMinigame(battle) then return end
   local ball = CatchThrow.pickBall()
-  if not ball then return end
+  local full = LetsGo.fullWild(battle)
+  -- An empty bag does NOT fall back to the classic menu under FULL. A
+  -- Let's Go wild has no player Pokemon in it and a foe that never takes a
+  -- turn, so the menu it would fall back to offers a FIGHT that cannot
+  -- happen -- the encounter has to keep its own screen and its own exit.
+  -- The capture screen opens empty-handed instead: the foe stands there,
+  -- the readout says there is nothing to throw, and RUN is the way out.
+  -- At CATCH ONLY there is no auto-entry to speak of and the bag is the
+  -- only route in, so no balls simply means no throw, as it always did.
+  if not (ball or full) then return end
   CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
-                                   fullWild = LetsGo.fullWild(battle) })
+                                   fullWild = full })
 end
 
 -- ------- per frame, from the voxel pipeline's update hook
@@ -299,9 +323,17 @@ function LetsGo.install()
   end
 
   -- a catch pays experience under FULL, exactly as a knockout would
+  -- Never for a scripted demo: the old man's catch is a cutscene, nobody
+  -- keeps the Pokemon, and the party it would pay may not exist yet
+  -- (Yellow's Pallet intro runs before the lab gift). The engine's own
+  -- flow does not reach either hook for a demo today -- oldManThrow ends
+  -- the battle without storeCaughtMon or awardExp -- so this guards the
+  -- INVARIANT rather than a live bug: a demo pays nothing, whatever route
+  -- some later engine takes to get there.
   mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
     if LetsGo.mode() == "full" and ctx and ctx.battle
-       and ctx.battle.kind == "wild" then
+       and ctx.battle.kind == "wild"
+       and not LetsGo.scripted(ctx.battle) then
       return true
     end
     return next_(ctx)
@@ -358,6 +390,7 @@ function LetsGo.install()
   end
 
   mod.hooks:wrap("battle.exp_award", function(next_, ctx)
+    if ctx and LetsGo.scripted(ctx.battle) then return next_(ctx) end
     local cc = expCtx
     if cc and ctx and ctx.battle == cc.battle then
       expCtx = nil
@@ -398,7 +431,8 @@ function LetsGo.install()
     local b = payload and payload.battle
     if not (b and LetsGo.fullWild(b)) then return end
     if not (Voxel3D.available() and not vrOn()) then return end
-    if not CatchThrow.pickBall() then return end
+    -- deliberately NOT gated on owning a ball: an empty bag still gets the
+    -- Let's Go encounter (see autoEnter), so the send-out still has to go
     local q = b.queue
     local n = q and #q or 0
     if n >= 6 and type(q[n]) == "table" and q[n].fn
diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua
index 8997a49..6d914fd 100644
--- a/lib/ShadowMap.lua
+++ b/lib/ShadowMap.lua
@@ -217,10 +217,27 @@ local function getBlank()
   return blank or nil
 end
 
+-- Whether the player asked for shadows at all (the SHADOWS row, see
+-- lib/Shadows). Asked through a pcall because this module is loaded by
+-- probes and by the suite with no mod namespace around it, where the answer
+-- is simply yes.
+--
+-- ONE gate for both halves of the module -- can the pass run, and is there
+-- a map to read -- because they must never disagree: available() alone
+-- would leave the LAST map standing (`ready` is still true), and every
+-- surface would go on wearing shadows frozen in the pose the row was
+-- switched off in.
+function ShadowMap.wanted()
+  local ok, on = pcall(function() return V.require("Shadows").enabled() end)
+  return (not ok) or on
+end
+
 -- Whether the sun pass can run at all. False headless, without shaders, or
 -- where the canvas cannot be made -- VoxelScene then keeps the flat decal
--- shadows, which need nothing but a quad.
+-- shadows, which need nothing but a quad -- and false with the row off,
+-- where nothing stands in (see lib/Shadows).
 function ShadowMap.available()
+  if not ShadowMap.wanted() then return false end
   if love.system and love.system.getOS and love.system.getOS() == "iOS" then
     return false
   end
@@ -241,9 +258,12 @@ function ShadowMap.texture()
   return getBlank()
 end
 
--- True while the map holds a frame the main pass can read.
+-- True while the map holds a frame the main pass can read. The row's OFF
+-- lands here as well as on available(): a map drawn a frame ago is still in
+-- the canvas, and every reader (the scene shader's sunDark, the water's,
+-- the forest's beams) hangs off this one answer.
 function ShadowMap.active()
-  return ready and canvas ~= nil and canvas ~= false
+  return ready and canvas ~= nil and canvas ~= false and ShadowMap.wanted()
 end
 
 -- The direction the light TRAVELS, normalized. The shear is the shadow a
diff --git a/lib/Shadows.lua b/lib/Shadows.lua
new file mode 100644
index 0000000..e9eb5ac
--- /dev/null
+++ b/lib/Shadows.lua
@@ -0,0 +1,60 @@
+-- Voxel world mode: whether the sun casts at all.
+--
+-- lib/ShadowMap renders the whole scene a second time from the light every
+-- frame the view or a pose changes, at up to 2048 squared, and every
+-- surface in the main pass then takes four taps at it. That is the single
+-- most expensive thing this mode does after the geometry itself -- and on a
+-- phone, or an old laptop, it is the difference between the diorama running
+-- and the diorama stuttering. So it gets a row.
+--
+-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a
+-- driver that cannot make the map (see Voxel3D.beginShadows), and those are
+-- a stand-in for a machine that wanted shadows and could not have them.
+-- A player who has just switched them off wants no shadow under anybody,
+-- which is what this row gives -- see ShadowMap.wanted, the one gate both
+-- halves hang off.
+--
+-- This file owns the toggle rather than the drawing: the value, where it
+-- persists, and the row the player finds it on -- exactly as VoxelGrid does
+-- for the wireframe.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+
+local Shadows = {}
+
+-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
+-- OPTIONS and the mod manager's own settings page for this mod
+Shadows.KEY = "shadows"
+Shadows.LABEL = "SHADOWS"
+
+-- ON is values[1] and so the default: cast shadows are what the mode is
+-- for as much as the geometry is -- a world where a building throws
+-- nothing reads as flat however many voxels it is made of. The row is for
+-- the machine that cannot carry them, not a look anybody is choosing.
+Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL,
+                                 { true, false }, { "ON", "OFF" })
+
+function Shadows.enabled()
+  return Shadows.setting:get() and true or false
+end
+
+function Shadows.set(enabled, game)
+  return Shadows.setting:setIndex(enabled and 1 or 2, game)
+end
+
+function Shadows.toggle(game)
+  return Shadows.setting:cycle(game)
+end
+
+function Shadows.sync(value)
+  Shadows.setting:sync(value and true or false)
+end
+
+function Shadows.row()
+  return Shadows.setting:row()
+end
+
+return Shadows
diff --git a/lib/ViewBox.lua b/lib/ViewBox.lua
index f745071..200ef02 100644
--- a/lib/ViewBox.lua
+++ b/lib/ViewBox.lua
@@ -26,6 +26,15 @@
 -- in closed form -- see footprint() for the derivation, which is three
 -- lines of algebra and no tuning at all.
 --
+-- AND THE GROUND IS NOT THE PICTURE. The trapezoid is where the frame's
+-- rays LAND; what is drawn is what stands on it, and a tree at the bottom
+-- of the screen has its feet south of the row its top is seen on. Cut to
+-- the trapezoid alone, the box takes that tree away whole -- the cut is by
+-- column, so a base one pixel outside loses the whole height -- and the
+-- bottom of the frame reads as a bite taken out of the scenery. So the
+-- south edge is walked back down the bottom ray by the tallest thing that
+-- can stand there; see lift().
+--
 -- The CUT is still a rectangle (the shader's box kind), so what is stored
 -- is the trapezoid's bounding rect: never narrower than the picture, so it
 -- can never take a bite out of it. It is off-centre in z, because the
@@ -105,6 +114,63 @@ ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
 -- the row, so a player who can see the seam can push it away.
 ViewBox.MAX_REACH = 6
 
+-- ------- the geometry standing on the ground it frames
+--
+-- The footprint is where the frame's rays hit the GROUND, and the ground is
+-- not what the picture is made of. A tree is most of a hundred world pixels
+-- tall, and a point that high up on the BOTTOM edge's own ray sits south of
+-- where that ray lands -- nearer the eye, because the ray is coming down. So
+-- the bottom of the screen is full of things whose feet are outside the
+-- ground trapezoid, and a box cut to the trapezoid alone takes them away
+-- whole: the shader cuts a fragment by the column it stands in (Voxel3D's
+-- dioramaCull is unbounded upward, deliberately, so a cut never takes the
+-- tops off trees), so a tree one pixel south of the edge loses its whole
+-- height at once. That is a bite along the bottom of the picture -- a row of
+-- trees cut through by the frame's own edge, with the ground behind them
+-- showing.
+--
+-- HEIGHT is the tallest thing standing on that ground, and it is the sun
+-- pass's own figure for the same reason it needs one: how far outside the
+-- ground it fits can something still reach the picture? Kept here rather
+-- than read across so this file's cut does not move when the light's
+-- frustum is retuned; they answer to the same world either way.
+ViewBox.HEIGHT = 160
+
+-- And a tile of slack on top, at every pitch. The cut's south edge would
+-- otherwise land on the frame's own bottom row at the rungs where the term
+-- below is zero, which is a hard edge (see FADE_FRAC) balanced on the
+-- pixel it is drawn at -- a supersampled frame (lib/AntiAlias) resolves
+-- half of it. One tile is cheap and no cut this file makes should be
+-- decided by a rounding.
+ViewBox.SOUTH_PAD = 16
+
+-- How much further south than the ground it lands on the bottom edge of the
+-- frame can still show, in world pixels.
+--
+-- At sy = -1 the ray direction (see footprint) is
+--
+--     d = (0, -(cos a + tanY sin a), -(sin a - tanY cos a))
+--
+-- in (x, y, z) with y up and -z north, so climbing it costs
+--
+--     (sin a - tanY cos a) / (cos a + tanY sin a)
+--
+-- of south per world pixel of height. Zero at and below atan(tanY) -- about
+-- 26 degrees with FOCAL 1, where the ray is shallower than the frame's own
+-- half-angle and a RAISED point lands north of the ground hit, which no cut
+-- can lose -- a tile and a half at 35, four at 50, and a good eleven at 75,
+-- where the eye is nearly level and a tree is nearly all of what is under
+-- the bottom of the frame.
+function ViewBox.lift(a)
+  local Voxel = V.require("VoxelState")
+  local tanY = 1 / (2 * (Voxel.FOCAL or 1))
+  local ca = math.max(math.cos(a or 0), 1e-3)
+  local sa = math.max(math.sin(a or 0), 0)
+  local rise = (sa - tanY * ca) / (ca + tanY * sa)
+  if rise <= 0 then return 0 end
+  return ViewBox.HEIGHT * rise
+end
+
 -- The rim under V-CURVE, as a fraction of the shorter half-extent, and for
 -- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides,
 -- so a hard edge across one is a lie about what is being looked at. Flat,
@@ -247,8 +313,14 @@ function ViewBox.frame(cx, cy, vw, vh, level)
   if left <= 0.001 then return nil end
   frac = frac / left
   local Voxel = V.require("VoxelState")
+  local angle = Voxel.angle or 0
   local north, south, side = ViewBox.footprint(
-    Voxel.angle or 0, vw, vh, ViewBox.MAX_REACH * (vh or 288))
+    angle, vw, vh, ViewBox.MAX_REACH * (vh or 288))
+  -- the ground the bottom edge lands on is not the southernmost thing under
+  -- it: what STANDS there reaches into the frame from further south (see
+  -- lift). Added before the row's multiplier, so FIT carries it too -- it is
+  -- a correction to the honest answer, not margin around it.
+  south = south + ViewBox.lift(angle) + ViewBox.SOUTH_PAD
   north, south, side = north * frac, south * frac, side * frac
   -- The rectangle around it. Off-centre in z, because the trapezoid is:
   -- the camera looks NORTH from south of its focus, so there is far more
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index e90c45a..6239deb 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -15,6 +15,7 @@ local V = ...
 local Mat4 = V.require("Mat4")
 local Voxel3D = V.require("Voxel3D")
 local ShadowMap = V.require("ShadowMap")
+local Shadows = V.require("Shadows")
 local ChunkMesher = V.require("ChunkMesher")
 local SpriteBillboards = V.require("SpriteBillboards")
 local TileShape = V.require("TileShape")
@@ -1050,7 +1051,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
   -- against the terrain just drawn (a shadow behind a building stays
   -- hidden) but never depth-writing, so the grass pass at the end of the
   -- frame still wins its feet-overdraw fights.
-  if not Voxel3D.shadowsActive() then
+  --
+  -- Not with the SHADOWS row off, though: that is a player saying no
+  -- shadows, and standing the fallback in would answer a machine that
+  -- cannot have them (see lib/Shadows).
+  if Shadows.enabled() and not Voxel3D.shadowsActive() then
     Voxel3D.beginShadows()
     for _, p in ipairs(posed) do
       drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
diff --git a/main.lua b/main.lua
index 04b6ba0..810c023 100644
--- a/main.lua
+++ b/main.lua
@@ -92,6 +92,7 @@ local DayNight = V.require("DayNight")
 local DayTint = V.require("DayTint")
 local Water = V.require("Water")
 local ForestAtmos = V.require("ForestAtmos")
+local Shadows = V.require("Shadows")
 local AntiAlias = V.require("AntiAlias")
 local FirstPerson = V.require("FirstPerson")
 local FreeMove = V.require("FreeMove")
@@ -539,6 +540,21 @@ local SETTINGS = {
     .. "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." },
+  -- `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.",
+    full = true },
   -- Marked `full` for the opposite reason the battle rows are: this is not a
   -- knob on the look at all, it is what the look COSTS. FULL is a preset for
   -- the diorama, not a licence to spend four times the fill rate on the
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 9cb33c4..1a3524d 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -153,6 +153,11 @@ T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
 -- and AA, for the opposite reason: it is not a knob on the look at all, it is
 -- what the look COSTS, and only the player knows what their machine can carry
 T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
+-- SHADOWS on the same reasoning: the sun's pass is what the look costs, and
+-- a preset for the diorama does not get to spend a second draw of the whole
+-- world on a machine that cannot carry one
+T.check(fullIds["DRAMATIC_SHAPE:shadows"],
+  "and SHADOWS, the other row that is a question about the hardware")
 -- VR survives FULL on AA's reasoning: whether a headset is on the desk is
 -- not the diorama's to decide
 T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question")
@@ -406,14 +411,14 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
 -- (nothing to store, nothing for the mod manager to persist) and is offered
 -- on every platform, saying WHERE? rather than IMPORT where there is no file
 -- dialog to open
-T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the "
+T.eq(#hookedRows, 14, "the options hook added a row per setting, plus the "
   .. "STADIUM ROM action row")
 local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
 local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
 -- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
--- hookedRows[9] and AA hookedRows[11]; all four are read where they are
--- used rather than named here, because this chunk is one main function and
--- has 200 local slots to spend
+-- hookedRows[9], SHADOWS hookedRows[11] and AA hookedRows[12]; all five are
+-- read where they are used rather than named here, because this chunk is one
+-- main function and has 200 local slots to spend
 T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
 T.eq(hookedRows[4].value(), "FIT",
   "and defaults to FIT -- the cut IS the window the flat game already "
@@ -527,7 +532,7 @@ do
 local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
 local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
 local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
-local aa = hookedRows[11]
+local aa = hookedRows[12]
 T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
 T.eq(aa.value(), "OFF",
   "and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -567,6 +572,49 @@ AntiAlias.expand(320, 200)
 T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass")
 end
 
+-- ------- SHADOWS: the sun's own pass, and the row that stands it down
+--
+-- The most expensive thing in the frame after the geometry -- the whole world
+-- drawn a second time from the light, every time anything in it moves -- so
+-- the row exists for the machine that cannot carry it. What it must do is
+-- switch shadows OFF rather than down: both halves of ShadowMap (can the pass
+-- run, and is there a map to read) hang off the one gate, because the map
+-- from the last frame is still in the canvas and a row that only stopped the
+-- pass would leave every shadow frozen in the pose it was switched off in.
+--
+-- In an anonymous scope, like every section that wants more than a name or
+-- two: this file is one chunk and a chunk has 200 local slots, which the
+-- main function is already sitting on.
+;(function()
+local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows")
+local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
+local shadowRow = hookedRows[11]
+local shGame = { save = { options = {} }, mods = { modOptions = {} } }
+T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label")
+T.eq(shadowRow.value(), "ON",
+  "and starts ON -- cast shadows are as much of the mode as the geometry, "
+  .. "and the row is for the machine that cannot have them")
+T.eq(Shadows.enabled(), true, "which is what the pass reads")
+T.eq(ShadowMap.wanted(), true, "and the gate both halves of the sun pass ask")
+
+shadowRow.step(shGame, 1)
+T.eq(shadowRow.value(), "OFF", "stepping the row stands the sun pass down")
+T.eq(shGame.save.options.modOptions.DRAMATIC_SHAPE.shadows, false,
+  "the choice persists beside the other settings, not over them")
+T.eq(Shadows.enabled(), false, "the pass sees it")
+T.eq(ShadowMap.wanted(), false, "through the one gate")
+T.eq(ShadowMap.available(), false,
+  "so the sun pass never opens -- the frame's second draw of the world is "
+  .. "the whole thing being saved")
+T.eq(ShadowMap.active(), false,
+  "and nothing reads the map either: a pass stopped WITHOUT this would leave "
+  .. "the last frame's shadows standing forever")
+
+shadowRow.step(shGame, 1)
+T.eq(shadowRow.value(), "ON", "and the row is a toggle -- one step back on")
+T.eq(ShadowMap.wanted(), true, "with the sun asked for again")
+end)()
+
 -- ------- the animated terrain atlas survives an engine without its seams
 --
 -- Regression: cycling palette modes with voxel mode on eventually killed
@@ -6187,6 +6235,10 @@ T.check(box.z < 200,
   .. "the view centre is the bug that cut the top off")
 do
   local n, s, x = VB.footprint(math.rad(35), 320, 288, REACH)
+  -- the south edge is the ground the bottom ray lands on PLUS what stands
+  -- there: a tree's feet are south of the row its top is seen on, and the
+  -- cut is by column (see lift)
+  s = s + VB.lift(math.rad(35)) + VB.SOUTH_PAD
   T.check(math.abs(box.rz - (n + s) * 0.5) < 1e-6,
     "the half-depth spans the footprint from its south edge to its north")
   T.check(math.abs(box.z - (200 - (n - s) * 0.5)) < 1e-6,
@@ -6196,6 +6248,49 @@ do
     "both bigger than the window at every tilted rung, so the cut can "
     .. "never take a bite out of the picture")
 end
+
+-- ------- and the geometry standing on the ground it frames
+--
+-- The bug: the box was cut to where the frame's rays hit the GROUND, and
+-- the ground is not what the picture is made of. At every rung past the
+-- frame's own half-angle the bottom of the screen shows things whose FEET
+-- are south of that line, and the shader cuts by column -- so a tree one
+-- pixel outside lost its whole height and the bottom of the frame wore a
+-- bite out of the scenery, the ground behind showing through.
+do
+  local tanY = 1 / (2 * VS.FOCAL)
+  T.eq(VB.lift(0), 0,
+    "straight down nothing is lost: a raised point lands NORTH of where its "
+    .. "own ray hits the ground, which no cut can take")
+  T.eq(VB.lift(math.atan(tanY)), 0,
+    "and up to atan(tanY) -- the frame's own half-angle -- it still is")
+  T.check(VB.lift(math.rad(35)) > 0 and VB.lift(math.rad(50)) > VB.lift(math.rad(35))
+          and VB.lift(math.rad(75)) > VB.lift(math.rad(50)),
+    "past it the lift opens with the tilt: the flatter the eye, the more of "
+    .. "a tree is what sits under the bottom of the frame")
+  -- against the ray it is derived from: a point HEIGHT up on the bottom
+  -- edge's own ray, dropped to the ground, is exactly this far south of
+  -- where that ray lands
+  for _, deg in ipairs({ 35, 50, 75 }) do
+    local a = math.rad(deg)
+    local dy = -(math.cos(a) + tanY * math.sin(a))
+    local dz = -(math.sin(a) - tanY * math.cos(a))
+    T.check(math.abs(VB.lift(a) - VB.HEIGHT * (-dz / -dy)) < 1e-9,
+      ("%d degrees: the lift is that ray walked back up by HEIGHT")
+        :format(deg))
+  end
+  -- and it is in the CUT, at FIT, which is the rung the artefact was seen on
+  VS.angle = math.rad(35)
+  local fit = VB.frame(0, 0, 320, 288, 3)
+  local n, s = VB.footprint(math.rad(35), 320, 288, REACH)
+  T.check(fit.z + fit.rz >= s + VB.lift(math.rad(35)) - 1e-6,
+    "so the box's south edge clears the feet of everything the bottom of "
+    .. "the frame can show, tree tops included")
+  T.check(fit.z + fit.rz > s and fit.z - fit.rz < -n + 1e-6,
+    "without moving the north edge, which the reach already decides")
+end
+VS.angle = math.rad(35)
+box = VB.frame(100, 200, 320, 288, 3)
 T.check(box.rx ~= box.rz,
   "which is the whole reason the box kind carries two half-extents: a "
   .. "square cut on a wide frame either loses the sides or overshoots")
@@ -6337,6 +6432,34 @@ end)()
   -- the row answers the mode, and OFF answers false
   T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF")
 
+  -- ------- the scripted catch tutorials are none of LET'S GO's business
+  --
+  -- The VIRIDIAN CITY old man and Yellow's PROF.OAK / PIKACHU intro are
+  -- the same makeOldManDemo cutscene: the cursor, the bag and the throw
+  -- are all scripted, and nobody keeps the Pokemon. Every rung has to
+  -- leave them exactly as the engine plays them.
+  T.eq(LetsGo.scripted({ demo = true }), true,
+    "the old man's tutorial is a scripted battle")
+  T.eq(LetsGo.scripted({ demo = true, oakDemo = true }), true,
+    "and so is PROF.OAK catching the PIKACHU")
+  T.eq(LetsGo.scripted({ kind = "wild" }), false,
+    "an ordinary wild encounter is not")
+  T.eq(LetsGo.scripted(nil), false, "and no battle at all is not either")
+  do
+    -- the predicates that gate every FULL behaviour, read at FULL
+    local realMode = LetsGo.mode
+    LetsGo.mode = function() return "full" end
+    T.eq(LetsGo.fullWild({ kind = "wild" }), true,
+      "FULL owns an ordinary wild encounter")
+    T.eq(LetsGo.fullWild({ kind = "wild", demo = true }), false,
+      "but never the old man's demo, whatever the row says")
+    T.eq(LetsGo.fullWild({ kind = "wild", oakDemo = true }), false,
+      "nor PROF.OAK's")
+    T.eq(LetsGo.wantsMinigame({ kind = "wild", demo = true }), false,
+      "and a scripted throw is never handed the minigame")
+    LetsGo.mode = realMode
+  end
+
   -- the ball: a full open-drop-rock-click life without a GPU
   T.eq(Mat4.rotateZ ~= nil, true, "Mat4 grew the roll the wobble rocks on")
   do
diff --git a/tests/letsgo_empty.lua b/tests/letsgo_empty.lua
new file mode 100644
index 0000000..a1c3342
--- /dev/null
+++ b/tests/letsgo_empty.lua
@@ -0,0 +1,282 @@
+-- Driver: the two edges the capture mode has to hold on to.
+--
+--   A  FULL with an EMPTY BAG. The encounter must still be a Let's Go
+--      encounter -- head-on seat, no player Pokemon, no classic menu --
+--      with the readout saying there is nothing to throw and RUN as the
+--      way out. The failure this catches is the old behaviour: falling
+--      back to the battle menu, which under FULL offers a FIGHT against a
+--      foe that never takes a turn.
+--
+--   B  FULL, running DRY MID-ENCOUNTER. One ball, thrown weakly enough to
+--      fall short: the miss must land in the empty hand rather than
+--      tearing the session down.
+--
+--   C  The SCRIPTED catch tutorial (the VIRIDIAN CITY old man; PROF.OAK
+--      and the PIKACHU are the same makeOldManDemo). LET'S GO must not
+--      touch it at any rung -- no session, no held camera, no veil -- and
+--      it must play its scripted throw through to its own ending.
+--
+--   SHOT_DIR=.scratchpad/letsgo \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_empty.lua \
+--   "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+  local Bag = require("src.inventory.Bag")
+
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if not lib then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+    return
+  end
+  local LetsGo = lib.require("LetsGo")
+  local CatchThrow = lib.require("CatchThrow")
+  local BattleScene = lib.require("BattleScene")
+
+  game.save.party = {
+    Pokemon.new(game.data, "CHARIZARD", 45),
+    Pokemon.new(game.data, "PIKACHU", 10),
+  }
+  game.save.player.name = "RED"
+  LetsGo.setting:setValue("full", game)
+  if os.getenv("DS_RUNG") == "cards" then
+    lib.require("OverworldBattle").setting:setValue(true, game)
+    U.log("3D-BTL forced to 2D-3D A")
+  end
+  U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
+
+  -- every ball out of the bag, whatever the save arrived with
+  local BALLS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
+  local function emptyBag()
+    for _, id in ipairs(BALLS) do
+      local n = game.save.inventory[id] or 0
+      if n > 0 then pcall(Bag.remove, game.save, id, n) end
+      game.save.inventory[id] = nil
+    end
+  end
+  local function ballCount()
+    local n = 0
+    for _, id in ipairs(BALLS) do n = n + (game.save.inventory[id] or 0) end
+    return n
+  end
+
+  -- A REAL key event, not U.tap's synthetic pressQueue inject. The bug
+  -- this driver has to be able to see (button edges read on the render
+  -- clock instead of the logic step) lives between love.keypressed and
+  -- whoever polls the edge, so a driver that writes the queue itself
+  -- jumps straight over it. "b" is the default binding for the B button.
+  local function key(name)
+    love.keypressed(name, name, false)
+    U.wait(2)
+    love.keyreleased(name, name)
+    U.wait(2)
+  end
+
+  U.teleport(game, "ROUTE_1", 5, 8, "down")
+  U.wait(90)
+
+  -- drive the intro chatter until the menu would open (which under FULL is
+  -- when capture mode takes over instead)
+  local function toCapture(battle)
+    U.wait(70)
+    for _ = 1, 80 do
+      if battle.phase == "menu" or CatchThrow.session() then break end
+      U.tap(game, "a")
+      U.wait(6)
+    end
+    U.wait(30)
+  end
+
+  local function describe(tag, battle)
+    local s = CatchThrow.session()
+    U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s")
+          :format(tag, s and "yes" or "NO", s and tostring(s.empty) or "-",
+                  s and s.phase or "-", tostring(battle.phase),
+                  BattleScene.capture and "up" or "down",
+                  tostring(BattleScene.capture
+                           and BattleScene.capture.hidePlayer)))
+    return s
+  end
+
+  -- ------- A: an empty bag still gets the capture screen
+
+  emptyBag()
+  U.log("== A: FULL with " .. ballCount() .. " balls")
+  local a = BattleState.newWild(game, "PIDGEY", 5)
+  a.onFinish = function() end
+  game.overworld:pushBattle(a)
+  toCapture(a)
+
+  local s = describe("A", a)
+  U.log(("A: hand is empty -> %s (want yes)"):format(
+        (s and s.empty) and "yes" or "NO"))
+  U.shot(game, DIR .. "/empty_1_aim.png")
+
+  -- and B is the way out: a Let's Go wild always escapes
+  key("b")
+  for _ = 1, 120 do
+    U.wait(2)
+    if a.result then break end
+    U.tap(game, "a")
+  end
+  U.log(("A: after B -- result=%s (want run)"):format(tostring(a.result)))
+  -- the session and the held camera are swept by battle.ended, which is the
+  -- teardown BELOW this, not the moment `result` is written -- so the sweep
+  -- is only worth asserting once the battle has actually left the stack
+  for _ = 1, 40 do U.tap(game, "a"); U.wait(4) end
+  U.wait(60)
+  U.log(("A: after teardown -- session=%s veil=%s (want gone/down)")
+        :format(CatchThrow.session() and "still up" or "gone",
+                BattleScene.capture and "still up" or "down"))
+
+  -- ------- B: the LAST ball, thrown short
+
+  Bag.add(game.save, "POKE_BALL", 1, game.data)
+  CatchThrow.lastBall = "POKE_BALL"
+  U.log("== B: FULL with " .. ballCount() .. " ball")
+  local b = BattleState.newWild(game, "PIDGEY", 5)
+  b.onFinish = function() end
+  game.overworld:pushBattle(b)
+  toCapture(b)
+  describe("B", b)
+
+  -- a deliberately feeble swipe: it must fall short of the Pokemon, so
+  -- the miss is the outcome under test rather than a lucky catch
+  local aim = CatchThrow._aimInfo()
+  if aim then
+    local uw, uh = love.graphics.getDimensions()
+    local function toWin(gx, gy)
+      return (aim.lx + gx * aim.scale) * uw / aim.pw,
+             (aim.ly + gy * aim.scale) * uh / aim.ph
+    end
+    local hx, hy = aim.hand[1], aim.hand[2]
+    local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
+    local d = math.sqrt(dx * dx + dy * dy)
+    dx, dy = dx / d, dy / d
+    local step = 120 / 60
+    local px, py = toWin(hx, hy)
+    love.mousepressed(px, py, 1, false, 1)
+    for i = 1, 8 do
+      local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
+      love.mousemoved(wx, wy, 0, 0, false)
+      U.wait(1)
+    end
+    local wx, wy = toWin(hx + dx * step * 8, hy + dy * step * 8)
+    love.mousereleased(wx, wy, 1, false, 1)
+  else
+    U.log("B: NO AIM INFO -- capture mode did not open")
+  end
+
+  -- ride the throw out, then keep tapping through the miss text until the
+  -- hand is refilled -- which, with the bag now empty, means the EMPTY hand
+  for _ = 1, 400 do
+    U.wait(2)
+    local q = CatchThrow.session()
+    if q and q.phase == "aim" and q.empty then break end
+    if not q then break end
+    if q.phase ~= "aim" and q.phase ~= "flight" then U.tap(game, "a") end
+  end
+  local sb = describe("B", b)
+  U.log(("B: last ball thrown, balls=%d -> %s (want an empty hand)")
+        :format(ballCount(),
+                (sb and sb.empty) and "empty hand" or
+                (sb and ("still holding " .. tostring(sb.ballId)))
+                or "SESSION GONE"))
+  U.shot(game, DIR .. "/empty_2_ranout.png")
+  key("b")
+  for _ = 1, 120 do
+    U.wait(2)
+    if b.result then break end
+    U.tap(game, "a")
+  end
+  U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)")
+  for _ = 1, 40 do U.tap(game, "a"); U.wait(4) end
+  U.wait(60)
+
+  -- ------- C: the scripted tutorial, untouched
+
+  Bag.add(game.save, "POKE_BALL", 10, game.data)
+  U.log("== C: the old man's demo, at FULL, with " .. ballCount() .. " balls")
+  local om = game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
+  local c = BattleState.newWild(game, om.species, om.level)
+  c:makeOldManDemo()
+  c.onFinish = function() end
+  U.log("C: LetsGo.scripted -> " .. tostring(LetsGo.scripted(c))
+        .. "  fullWild -> " .. tostring(LetsGo.fullWild(c))
+        .. "  wantsMinigame -> " .. tostring(LetsGo.wantsMinigame(c)))
+  game.overworld:pushBattle(c)
+
+  -- the demo drives ITSELF: the cursor, the bag and the throw are all
+  -- scripted, so this only watches. Any session or veil appearing here is
+  -- the failure.
+  local sawSession, sawVeil = false, false
+  local shotDemo = false
+  for i = 1, 900 do
+    if CatchThrow.session() then sawSession = true end
+    if BattleScene.capture then sawVeil = true end
+    if not shotDemo and i > 200 then
+      shotDemo = true
+      U.shot(game, DIR .. "/empty_3_oldman.png")
+    end
+    if c.result then break end
+    U.wait(2)
+    -- the scripted beats want A only to page the text along
+    if i % 3 == 0 then U.tap(game, "a") end
+  end
+  U.log(("C: session ever opened=%s (want no)  veil ever up=%s (want no)")
+        :format(tostring(sawSession), tostring(sawVeil)))
+  U.log(("C: result=%s  balls=%d (want 10 -- the demo consumes none)")
+        :format(tostring(c.result), ballCount()))
+  U.log(("C: party still %d, first is %s")
+        :format(#game.save.party,
+                game.data.pokemon[game.save.party[1].species].name))
+
+  -- ------- D: B RUNS, with a ball in hand, on a frame that runs several
+  -- logic steps
+  --
+  -- The reported failure, reproduced rather than reasoned about. Input:step
+  -- rebuilds the edge table once per FIXED step; Game:update runs the
+  -- frame's steps first and the render-clock hooks after, so any frame
+  -- carrying more than one step has already discarded the earlier steps'
+  -- edges. Below 60fps -- which is where a 3D battle lives -- that is
+  -- every press. speedOverride multiplies the logic clock, so it packs
+  -- several steps into each frame on demand and turns "sometimes, on a
+  -- slow machine" into "every time, here".
+  for _, speed in ipairs({ 1, 4 }) do
+    Bag.add(game.save, "POKE_BALL", 5, game.data)
+    CatchThrow.lastBall = "POKE_BALL"
+    game.speedOverride = speed > 1 and speed or nil
+    U.log(("== D: FULL, ball in hand, B to run at %dX logic speed"):format(speed))
+    local d = BattleState.newWild(game, "PIDGEY", 5)
+    d.onFinish = function() end
+    game.overworld:pushBattle(d)
+    toCapture(d)
+    local sd = describe("D" .. speed, d)
+    if sd and not sd.empty then
+      key("b")
+      local ran = false
+      for _ = 1, 60 do
+        U.wait(2)
+        if d.result then ran = true break end
+      end
+      U.log(("D%dX: after a REAL B -- result=%s (want run)  %s")
+            :format(speed, tostring(d.result),
+                    ran and "RAN" or "*** B DID NOTHING ***"))
+    else
+      U.log("D" .. speed .. ": no armed session to test")
+    end
+    -- escape hatch: a B that did nothing leaves the battle parked in the
+    -- capture phase forever, and the rest of the run must still report
+    if not d.result then
+      pcall(CatchThrow.onBattleEnded)
+      d.result, d.phase, d.afterQueue = "run", "messages", "finish"
+    end
+    for _ = 1, 80 do U.tap(game, "a"); U.wait(4) end
+    U.wait(60)
+    game.speedOverride = nil
+  end
+  U.log("done -- " .. DIR)
+end