DramaticShapeVoxelMod archival mirror · read-only
f0d5ca570a210d7ab4f31a2dcd5cf0e94fd33d1c

lets go

DramaticShape · 2026-08-08 · parent 063ce6e328

lets go

Changed files

CHANGELOG.md                  |   50 +
 lib/BattleScene.lua           |  121 ++-
 lib/CatchThrow.lua            | 1605 +++++++++++++++++++++++++++++++
 lib/LetsGo.lua                |  404 ++++++++
 lib/Mat4.lua                  |    8 +
 lib/OverworldBattle.lua       | 3109 +++++++++++++++++++++++++++++++------------------------------
 lib/Pokeball.lua              |  594 ++++++++++++
 lib/Stadium.lua               |   45 +
 lib/StadiumMon.lua            |   49 +
 lib/StadiumRig.lua            |   30 +
 main.lua                      |   44 +
 tests/dramatic_shape_test.lua |  106 ++-
 tests/letsgo_shots.lua        |  309 ++++++
 tests/letsgo_trainer_exp.lua  |  115 +++
 14 files changed, 5039 insertions(+), 1550 deletions(-)

Diff

Diff truncated at 200.0 KB — fetch the full change with git show f0d5ca570a after cloning.

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18972bb..cb1a20b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
 # Changelog
 
+## 1.8.0
+
+### Added
+
+- **LET'S GO: Pokemon GO-style catching, staged in the 3D battle.** A new
+  three-rung row. CATCH ONLY changes nothing about the game except the
+  throw: picking a Poke/Great/Ultra/Master Ball in a wild battle (or the
+  BALL row of the safari menu) opens capture mode instead of the automatic
+  toss. FULL makes wild encounters the real Let's Go article: the
+  encounter IS the catch -- it opens in throwing mode and stays there,
+  the foe never takes a turn, your own Pokemon is never sent out or
+  shown (no back pic, no model, no HUD), and B runs, which from a catch
+  encounter always works. Poke/Great/Ultra Balls are half price at every
+  mart, and EXPERIENCE works the way that game's does: every healthy
+  party member gains from every catch AND every trainer knockout, each
+  one measured against its OWN level through the Gen VII scaled formula
+  -- which is why Let's Go ships no EXP.ALL, and why a level 5 party
+  member takes several times what a level 45 one does from the very same
+  fight. A catch adds the throw stack on top: grade, first ball of the
+  encounter, new species, and a persistent catch combo. CATCH ONLY
+  leaves experience exactly as the original game had it.
+
+  **The throw.** The camera locks HEAD ON with the wild Pokemon -- its
+  own seat on the arena's axis, no drift, no steer -- and a real 3D Poke
+  Ball (modelled and animated for this: hinged lid, capture beam,
+  squash-click, decaying wobble, caught stars, breakout burst; GREAT
+  blue, ULTRA's yellow band, MASTER purple, SAFARI olive) hangs at the
+  bottom of the frame. The ball rides UNDER the finger -- mouse, touch,
+  or the right stick -- and releasing throws it with the swipe's own
+  velocity: forward from how hard, height from its rise, side from its
+  slant, gravity and collision deciding the rest, with a bearing-and-
+  range assist trimming honest errors. Circling the ball WINDS it -- the
+  spin visibly builds with the gesture to a cap and bleeds off when the
+  hand pauses -- and only a ball at the cap flies with the late-biting
+  curve. Contact is against the creature's own GEOMETRY: a pic foe is
+  its sprite's opaque pixels (a ball through the gap under a wing flies
+  on), a STADIUM foe its model's measured height, girth and hover. The
+  timing ring pulses on the creature, coloured by the Gen 1 odds, and is
+  judged AT the moment of contact: inside earns NICE / GREAT /
+  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.
+
+  **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
+  ball is a spent ball. Outside FULL, a failed throw still costs the
+  turn it always did. Needs the staged 3D battle standing (3D-BTL on, a
+  depth-capable driver, no headset); anywhere it cannot stand, balls
+  quietly take the engine's classic toss.
+
 ## 1.7.1
 
 ### Added
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index dbaf442..ed4b6d4 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -48,6 +48,29 @@ local Map = require("src.world.Map")
 
 local BattleScene = {}
 
+-- ------- LET'S GO capture mode's stake in this scene
+--
+-- One table while a capture session runs, nil otherwise (see
+-- lib/CatchThrow.lua, which owns it):
+--
+--   hidePlayer   the player's side stays out of the shot entirely -- no
+--                card here, no model (Stadium reads this same table), no
+--                pinned back pic (OverworldBattle reads it too)
+--   shrink       the foe's scale while the ball drinks it in, applied
+--                about its chest so it collapses toward the beam
+--   draw(pull)   the Poke Ball, drawn after the Stadium models -- same
+--                depth buffer, same flash window, same camera
+--   cast(sm)     the same ball into the sun's pass
+--   sig()        a term for the cached shadow signature, so a ball in
+--                flight re-casts and a resting scene does not
+--   drawGB(b)    the 2D layer (ring, labels), drawn by OverworldBattle's
+--                BattleState:draw wrap in the GB frame
+--
+-- It lives HERE, not on OverworldBattle, because every consumer below
+-- already requires BattleScene and the one file that writes it requires
+-- both -- this is the spot with no require cycle.
+BattleScene.capture = nil
+
 -- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
 -- is solved against.
 BattleScene.GB_W = 160
@@ -195,14 +218,29 @@ end
 local function monCards(arena, groundY, textures)
   local out = {}
   if not textures then return out end
+  local cap = BattleScene.capture
   for _, side in ipairs({ "enemy", "player" }) do
     local tex = textures[side]
     local cell = (side == "player") and arena.player or arena.enemy
+    -- capture mode: the player's side is out of the shot (the seat looks
+    -- over an empty shoulder), and OverworldBattle.textures already
+    -- skipped rendering it -- this is the belt to that suspender
+    if side == "player" and cap and cap.hidePlayer then tex = nil end
     if tex and tex.canvas and cell then
       local mirror = (side == "player") and not tex.trainer
-      out[#out + 1] = { tex = tex.canvas,
-                        model = monMatrix(tex, cell[1], groundY, cell[2],
-                                          mirror) }
+      local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
+      -- the foe drinking into the ball: scaled about its own chest, in
+      -- world space so the composed card matrix needs no decomposition
+      if side == "enemy" and cap and cap.shrink then
+        local k = cap.shrink
+        local ax, ay, az = cell[1], groundY + 8, cell[2]
+        model = Mat4.mul(
+          Mat4.mul(Mat4.translate(ax, ay, az),
+                   Mat4.mul(Mat4.scale(k, k, k),
+                            Mat4.translate(-ax, -ay, -az))),
+          model)
+      end
+      out[#out + 1] = { tex = tex.canvas, model = model }
     end
   end
   return out
@@ -313,6 +351,14 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
                   -- from somewhere new must be re-cast from there
                   math.floor(ShadowMap.KX * 128),
                   math.floor(ShadowMap.KZ * 128) }
+  -- a capture session's ball moves through the sun's world too; its term
+  -- is quantised inside sig() so the cache re-renders on real movement
+  -- and not on every frame the ball rests
+  local cap = BattleScene.capture
+  if cap and cap.sig then
+    local okSig, sig = pcall(cap.sig)
+    parts[#parts + 1] = okSig and sig or "cap"
+  end
   for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
   return table.concat(parts, ",")
 end
@@ -377,6 +423,10 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
   -- the water. Un-snugged for the same reason: snug is a bias for a card
   -- rooted to the ground plane, and a model has thickness of its own.
   pcall(function() V.require("Stadium").cast(ShadowMap) end)
+  -- the capture session's ball, by the same reasoning: real geometry, its
+  -- shadow is half of what sells the arc
+  local cap = BattleScene.capture
+  if cap and cap.cast then pcall(cap.cast, ShadowMap) end
 
   ShadowMap.finish(sig)
 end
@@ -520,7 +570,18 @@ function BattleScene.render(state, arena, textures, token)
   end
 
   local groundY = BattleScene.groundY(host, arena)
-  local cam, pitch = BattleCam.rig(arena, groundY)
+  -- A capture session brings a camera of its own: the head-on seat, on
+  -- the arena's axis looking straight at the foe, in place of the solved
+  -- over-the-shoulder shot. Everything downstream -- the letterbox fov,
+  -- the pins, the sun, the cards yawing to the eye -- is generic over
+  -- whichever camera this is.
+  local cam, pitch, capFrameH
+  local cap = BattleScene.capture
+  if cap and cap.rig then
+    local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
+    if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
+  end
+  if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
   cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
 
   local cx, cy = arena.mid[1], arena.mid[2]
@@ -529,7 +590,8 @@ function BattleScene.render(state, arena, textures, token)
   -- the player's zoom is part of this: the sun's box is fitted to what the
   -- frame holds, so a shot pulled wide has to light the ground it just
   -- brought into view rather than the ground the rig alone would have
-  local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
+  local vh = (capFrameH or BattleCam.frameH(arena)) * ph
+             / (BattleScene.GB_H * s)
   local vw = vh * pw / ph
 
   -- the cards need the camera's eye to face it, so the rig has to be live
@@ -662,6 +724,11 @@ function BattleScene.render(state, arena, textures, token)
       V.require("Stadium").draw(BattleBillboard.PULL)
     end)
     if not okStadium then V.require("Stadium").report(stadiumErr) end
+    -- the capture session's Poke Ball, still inside the flash window and
+    -- with the mons' own camera-ward pull, so a ball crossing in front of
+    -- a card wins the depth test the way a nearer thing should
+    local cap = BattleScene.capture
+    if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
     if flashing then Voxel3D.flatten(nil) end
     -- grass and flowers ride the same camera-ward pull the free-roam pass
     -- gives them, measured against THIS camera's pitch rather than the
@@ -695,25 +762,47 @@ function BattleScene.render(state, arena, textures, token)
     -- How wide one overworld square is on screen where each mon stands, in
     -- GB pixels. This is what the pics are scaled to: a mon covers its own
     -- square and no more, at whatever the drift has done to the distance.
+    --
+    -- Measured along BOTH map axes and answered as the larger, as a full
+    -- 2D screen distance. One axis alone breaks the moment a camera looks
+    -- ALONG it: the capture seat stands on the arena's own axis, and on a
+    -- quarter-turned arena that axis is world X -- the ±X probe points
+    -- then project to the same pixel and the span reads zero, which
+    -- collapsed the ring and blew up the throw's world-per-pixel mapping.
     local half = BattleScene.CELL / 2
-    local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
-                                arena.player[2], lx, ly, s, pw, ph)
-    local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
-                                arena.player[2], lx, ly, s, pw, ph)
-    local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
-                                arena.enemy[2], lx, ly, s, pw, ph)
-    local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
-                                arena.enemy[2], lx, ly, s, pw, ph)
-    if not (pl and pr and el and er) then return end
+    local function cellSpan(wx, wz)
+      local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
+                                      lx, ly, s, pw, ph)
+      local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
+                                      lx, ly, s, pw, ph)
+      local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
+                                      lx, ly, s, pw, ph)
+      local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
+                                      lx, ly, s, pw, ph)
+      if not (x1 and x2 and x3 and x4) then return nil end
+      local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
+      local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
+      return math.max(ew, ns)
+    end
+    local pSpan = cellSpan(arena.player[1], arena.player[2])
+    local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
+    if not (pSpan and eSpan) then return end
     out = {
       canvas = canvas,
       player = { pmx, pmy },
       enemy = { emx, emy },
-      playerSpan = math.abs(pr - pl),
-      enemySpan = math.abs(er - el),
+      playerSpan = pSpan,
+      enemySpan = eSpan,
       -- the letterbox, so the depth-of-field pass can put its sharp band on
       -- the two marks rather than on a fraction of the window
       lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
+      -- the camera and its combined matrix, for anything that reasons
+      -- about this shot from outside the render -- the capture mode's
+      -- throw is solved in these (aim errors along this eye's own right
+      -- and forward, contact judged through this vp)
+      eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
+      focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
+      vp = vp,
       -- and the hour's light, for anything drawn over this shot that is NOT
       -- geometry and so never went past the shader that applied it -- the back
       -- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
new file mode 100644
index 0000000..b573115
--- /dev/null
+++ b/lib/CatchThrow.lua
@@ -0,0 +1,1605 @@
+-- LET'S GO capture mode: the throw itself.
+--
+-- The player sees the wild Pokemon from the staged battle's own
+-- over-the-shoulder seat -- held perfectly still for the duration, with
+-- their own Pokemon nowhere in the shot -- a Poke Ball hanging in the
+-- foreground, and a timing ring pulsing on the foe. A flick of the mouse,
+-- a finger, or the right stick throws the ball along a solved arc; spin
+-- the ball first and it flies with a visible curve. Where the ball crosses
+-- the foe's plane is judged in SCREEN space against the ring -- the player
+-- aimed at pixels, so pixels are what they are graded on -- and a throw
+-- inside the shrinking ring earns NICE / GREAT / EXCELLENT, which
+-- multiplies the Gen 1 catch roll the engine then makes. The ball opens,
+-- drinks the Pokemon in, drops, rocks once per engine shake, and either
+-- clicks with stars or bursts the Pokemon back out.
+--
+-- ------- what this file owns, and what it borrows
+--
+-- It owns the SESSION: input sampling, the flick estimate, the arc, the
+-- ring, the grading, and the choreography of the Pokeball prop. Everything
+-- consequential is borrowed from the engine so the outcome is exactly a
+-- Gen 1 ball throw: the roll is battle:catchAttempt (status, HP and ball
+-- factors intact, our throw grade folded into the rate), the outcome texts
+-- and the enemy's revenge turn are the same queue throwBall builds, and a
+-- catch lands in storeCaughtMon -- dex page, nickname, box overflow and
+-- the pokemon.caught event all included. While the session runs the battle
+-- is FROZEN by parking battle.phase on a value the engine's update has no
+-- branch for; the resolution puts "messages" back and the engine finishes
+-- the fight believing it threw the ball itself.
+--
+-- ------- the input model (design: flick-to-throw)
+--
+-- All pointer samples are kept in GB-frame pixels (160x144), the same
+-- space the ring is drawn in. The flick velocity is a least-squares slope
+-- over the last 110 ms -- never last-minus-previous, which one frame of
+-- jitter destroys. Strength comes from speed, direction from the fit, and
+-- the landing point is "the spot the flick pointed at": release position
+-- plus direction times a reach that grows with strength. Short flicks land
+-- short, wild flicks sail long, and a ±30% speed error still lands inside
+-- the outer ring, which is what keeps the mechanic hard rather than broken.
+-- The right stick plays the same game with a virtual finger standing at
+-- centre + stick * radius, so pad players flick by flicking the stick.
+--
+-- Spin is a circular gesture around the held ball: accumulated signed
+-- angle arms the curve past 1.25 turns, and once armed it latches. A
+-- curved ball hooks late in flight (the ramp is what forces aiming off to
+-- the opposite side) with a 40% assist folded into the solve, so the curve
+-- is a flourish first and a skill test second.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Mat4 = V.require("Mat4")
+local Voxel3D = V.require("Voxel3D")
+local Pokeball = V.require("Pokeball")
+local BattleScene = V.require("BattleScene")
+local BattleCam = V.require("BattleCam")
+
+local CatchThrow = {}
+
+-- the parked phase: any value BattleState:update has no branch for freezes
+-- the fight; namespaced so no future engine phase can collide with it
+CatchThrow.PHASE = "dramaticShapeCapture"
+
+-- ------- tuning
+--
+-- Screen quantities are GB pixels (the battle frame is 160x144); world
+-- quantities are world pixels (a map cell is 16); times are seconds.
+
+local WINDOW = 0.11          -- flick fit window
+local S_MIN = 55             -- below this the release is a hesitation
+-- a throw must point up-screen at least this much. LENIENT on purpose:
+-- the gate only has to reject a plainly downward drag
+local MIN_UP = 0.08
+
+-- ------- the throw is the SWIPE'S OWN VELOCITY
+--
+-- The reference model (and the one that finally felt right): the ball is
+-- dragged under the finger, and releasing LAUNCHES IT with the swipe's
+-- velocity -- forward speed from how hard the swipe was, height from its
+-- vertical, side from its horizontal, each clamped to a sane range like a
+-- rigidbody throw. No aim point is picked and no arc is solved: gravity
+-- and the collision decide what the throw earns. A soft flick drops
+-- short, a wild one sails over, and the assist below is the only mercy.
+--
+-- CALIBRATED against the staging rather than picked: the throw is 48
+-- world pixels (three cells) to a body about ten pixels up, so the
+-- vertical speed that actually ARRIVES is somewhere in the 65-95 range
+-- across every forward speed worth throwing at. The first cut had gains
+-- of 3.0 and a 150/110 ceiling, which meant an ordinary ~190 GB px/s
+-- swipe already saturated both clamps -- every throw left at maximum,
+-- and maximum was well above anything that lands, so the ball sailed
+-- over the Pokemon no matter how it was flicked. These put a
+-- comfortable swipe in the middle of the band and leave the ceilings
+-- where a genuinely hard throw still reaches them.
+local K_FWD, K_UP, K_LAT = 2.4, 1.6, 1.0  -- swipe (world px/s) -> velocity
+local F_MIN, F_MAX = 48, 140 -- forward speed clamp, world px/s
+local U_MIN, U_MAX = 34, 92  -- upward
+local L_MAX = 38             -- sideways
+-- ------- and the ceiling that ends overthrowing
+--
+-- The launch height is never allowed to exceed what THIS throw's own
+-- forward speed needs to arrive, by more than a margin. That is the
+-- direct cure for the failure the gains alone could not fix: on a fixed
+-- camera there is no depth cue for "too high", so a firm flick would
+-- sail the ball clean over the Pokemon every time and the player had no
+-- way to read why. Under the cap a throw can still fall SHORT (too
+-- weak) or go WIDE (bad bearing) -- both of which are legible, because
+-- you watch the ball land -- but it can no longer be lost over the top.
+local OVER_CAP = 1.12
+-- directional assist: the HORIZONTAL launch direction leans this fraction
+-- toward the Pokemon. Speed, height and the short/long answer stay the
+-- player's; the assist only trims the bearing, so an honest throw at the
+-- creature connects instead of clipping past its ear.
+local MAGNET = 0.3
+-- range assist: the launch's HEIGHT leans this fraction toward what its
+-- own forward speed would need to arrive at the foe's body. Pure velocity
+-- throws punish the lob's shape as hard as its speed; this keeps speed as
+-- the skill and stops a good read from sailing a ball-width over. Half,
+-- not a third: the arc's SHAPE is the least legible thing about a throw
+-- on a fixed camera -- there is no depth cue for how high is too high --
+-- so it is the part worth forgiving, while distance and bearing stay the
+-- player's to get right.
+local V_ASSIST = 0.5
+local T_REF = 0.6            -- the tap-throw / Master Ball solve time
+
+local GRAVITY = 300          -- world px/s^2 -- ~2x earth at 16 px/metre,
+                             -- the platformer exaggeration the arc needs
+local H_STEP = 1 / 120       -- fixed integration step
+
+-- ------- spin
+--
+-- The wind-up is a METER now, not a switch: every turn of the gesture adds
+-- to it and it bleeds away when the hand stops, so the ball visibly spins
+-- up with the player -- faster the more they wind -- to a cap. Only a ball
+-- AT the cap curves when thrown; anything less is style.
+local SPIN_FULL = 32         -- radians of gesture from still to the cap
+local ROLL_MAX = 24          -- the cap's visible spin, rad/s
+local SPIN_DECAY = 0.45      -- meter per second, once the hand pauses
+local SPIN_IDLE = 0.2        -- how long a pause before the bleed starts
+local ARM_AT, DISARM_AT = 0.99, 0.80  -- hysteresis on "at the cap"
+
+local CURVE_GAIN = 120       -- lateral pull at full spin, world px/s^2
+local CURVE_RAMP0, CURVE_RAMP1 = 0.25, 0.70   -- the hook bites late
+local ASSIST = 0.4           -- how much of the curve the solve pre-corrects
+local K_RAMP = 0.14          -- integral of the ramp, for the assist
+
+local RING_PERIOD = 2.2      -- shrink cycle
+local RING_HOLD = 0.15       -- pause at the smallest
+local RING_MIN = 0.12        -- the Excellent floor
+local CHEST = 9              -- ring/beam height over the foe's feet, world px
+
+local SUCK_T = 0.6           -- the Pokemon drinking into the ball
+local DROP_T = 0.45          -- fall to the ground
+local SHAKE_GAP = 0.42       -- pause between rocks
+local STICK_R = 34           -- the virtual finger's orbit, GB px
+
+-- stick flick thresholds, in stick units (full deflection = 1) per second
+local STICK_VEL = 5.5
+local STICK_GRAB = 0.30
+
+-- the Let's Go throw-grade multipliers, applied to the Gen 1 catch rate
+-- and to the capture-experience stack (Bulbapedia's Let's Go tables)
+local TIER_MULT = { EXCELLENT = 2.0, GREAT = 1.5, NICE = 1.1 }
+
+-- ------- session state
+
+local S = nil
+
+local function game() return require("src.core.Game") end
+
+local function sound(name)
+  pcall(function()
+    require("src.core.Sound").play(game().data, name)
+  end)
+end
+
+local function emit(name, payload)
+  pcall(function() require("src.mods.Runtime").emit(name, payload) end)
+end
+
+local function OB() return V.require("OverworldBattle") end
+
+function CatchThrow.active()
+  return S ~= nil and S.phase ~= "epilogue"
+end
+
+function CatchThrow.session()
+  return S
+end
+
+-- ------- geometry helpers
+--
+-- The shot BattleScene hands back carries the camera (eye/focus), the
+-- combined matrix (vp) and the letterbox, which together answer every
+-- space question the session has: world -> GB via BattleScene.toGB, and
+-- the camera's flat forward/right for aiming errors.
+
+local function camBasis(shot)
+  local e, f = shot.eye, shot.focus
+  local fx, fz = f[1] - e[1], f[3] - e[3]
+  local l = math.sqrt(fx * fx + fz * fz)
+  if l < 1e-6 then fx, fz = 0, -1 else fx, fz = fx / l, fz / l end
+  -- right-handed about +Y up: right = fwd x up
+  return fx, fz, -fz, fx
+end
+
+local function toGB(shot, wx, wy, wz)
+  return BattleScene.toGB(shot.vp, wx, wy, wz,
+                          shot.lx, shot.ly, shot.scale, shot.pw, shot.ph)
+end
+
+-- the yaw that turns a thing at (x, z) to face this shot's eye
+local function eyeYaw(x, z)
+  local e = S.shot and S.shot.eye
+  if not e then return 0 end
+  return math.atan2(e[1] - x, e[3] - z)
+end
+
+-- ------- the capture seat's measurements
+--
+-- HEAD ON: the eye stands on the arena's own axis, behind the player's
+-- cell, looking straight at the foe -- the GO framing, not the battle's
+-- over-the-shoulder rig. Declared here rather than beside captureRig
+-- below because the ray reconstruction underneath needs the lens.
+local SEAT_BACK = 46         -- eye behind the player's cell, world px
+local SEAT_UP = 13           -- and above the floor
+local SEAT_FOV = math.rad(26)
+local SEAT_FRAME = 44        -- world height the sun's box is fitted to
+
+-- ------- the ray a GB-frame point looks along
+--
+-- The drag's whole job is "the ball is under the finger", and only the
+-- real projection answers that: an offset scaled by some world-per-pixel
+-- estimate is right at one depth and wrong everywhere else, and it has
+-- no way to say what the bottom of the screen even means. This is the
+-- ordinary pinhole reconstruction from the seat's own basis -- the
+-- vertical field covers the GB's 144 rows, and the horizontal falls out
+-- of square pixels (which is why both axes divide by the same half
+-- height).
+--
+-- Keyed to SEAT_FOV rather than to the shot, deliberately: the shot's
+-- fov has been widened to the window (BattleScene.letterboxFov) and what
+-- is wanted here is the lens the GB frame was framed with, which is the
+-- capture rig's own and nobody else's.
+local function rayThrough(gx, gy)
+  local shot = S and S.shot
+  if not shot then return nil end
+  local e, f = shot.eye, shot.focus
+  local dx, dy, dz = f[1] - e[1], f[2] - e[2], f[3] - e[3]
+  local l = math.sqrt(dx * dx + dy * dy + dz * dz)
+  if l < 1e-6 then return nil end
+  dx, dy, dz = dx / l, dy / l, dz / l
+  -- right = forward x worldUp; camUp = right x forward
+  local rx, rz = -dz, dx
+  local rl = math.sqrt(rx * rx + rz * rz)
+  if rl < 1e-6 then return nil end
+  rx, rz = rx / rl, rz / rl
+  local ux, uy, uz = -rz * dy, rz * dx - rx * dz, rx * dy
+  local half = BattleScene.GB_H / 2
+  local t = math.tan(SEAT_FOV / 2)
+  local ax = (gx - BattleScene.GB_W / 2) / half * t
+  local ay = (gy - half) / half * t
+  local vx = dx + rx * ax - ux * ay
+  local vy = dy - uy * ay
+  local vz = dz + rz * ax - uz * ay
+  local vl = math.sqrt(vx * vx + vy * vy + vz * vz)
+  if vl < 1e-6 then return nil end
+  return vx / vl, vy / vl, vz / vl
+end
+
+-- Where the held ball hangs: over the PLAYER'S OWN CELL, which capture
+-- mode has just emptied. Not in front of the camera -- the default battle
+-- rig is a long lens standing five blocks back, and anything a hand's
+-- reach from THAT eye is a blimp filling the frame. The player's cell is
+-- where the trainer stands, it sits bottom-centre of the head-on seat
+-- below, and it makes the throw a real three-cell lob at 16 pixels a
+-- metre instead of an eleven-metre crane shot from the sky.
+local HAND_HOVER = 6.5       -- the held ball's height over the floor
+local function handPos()
+  local p = S.playerPos
+  return { p[1], p[2] + HAND_HOVER, p[3] }
+end
+
+-- The seat itself, from the measurements above: the foe centred, the held
+-- ball at the bottom of the frame, the throw a straight shot up the
+-- middle. Handed to BattleScene.render through the capture table's `rig`;
+-- everything downstream (pins, cards, sun, fov) is camera-generic.
+local function captureRig(arena, groundY)
+  local ex, ez = arena.enemy[1], arena.enemy[2]
+  local px, pz = arena.player[1], arena.player[2]
+  local ax, az = ex - px, ez - pz
+  local l = math.sqrt(ax * ax + az * az)
+  if l < 1e-6 then ax, az = 0, -1 else ax, az = ax / l, az / l end
+  local cam = {
+    eye = { px - ax * SEAT_BACK, groundY + SEAT_UP, pz - az * SEAT_BACK },
+    focus = { ex, groundY + 8, ez },
+    fov = SEAT_FOV,
+  }
+  -- the pitch VoxelScene.pull wants: how far below level the seat looks
+  local pitch = math.atan2(SEAT_UP - 8, SEAT_BACK + l)
+  return cam, math.max(pitch, 0.05), SEAT_FRAME
+end
+
+-- The ring in GB space, centred on the CREATURE. The pinned mark is the
+-- cell's ground point, but a species' art sits wherever the artist put it
+-- in the frame -- a bird hovers half a slot over its own feet row -- so
+-- the session measures the foe's rendered pic once (measureArt below) and
+-- centres on the art's opaque box. The conversion is the card's own
+-- arithmetic: one pic pixel is FULL_W/FULL_PIC world pixels, and a cell
+-- is enemySpan GB pixels, so a pic pixel is span/56 GB pixels on screen.
+-- The heuristic fallback (no pic: a STADIUM model, or a readback that
+-- failed) is a chest height over the mark. The outer clamp is the
+-- design's 0.06..0.18 screen heights, in GB pixels.
+local function ringGeometry(shot)
+  local span = shot.enemySpan or 16
+  local b = S and S.artBox
+  if b then
+    local k = span / 56
+    local cx = shot.enemy[1] + ((b.x0 + b.x1) / 2 - b.ax) * k
+    local cy = shot.enemy[2] - (b.ay - (b.y0 + b.y1) / 2) * k
+    local r = math.max(b.x1 - b.x0, b.y1 - b.y0) * 0.55 * k
+    return cx, cy, math.max(9, math.min(26, r))
+  end
+  -- no pic to measure (a Stadium model, or a readback still pending):
+  -- the collision body doubles as the ring's anchor, in the same
+  -- world-to-screen scale a cell provides
+  local body = (S and S.body) or { r = 8, yOff = 8 }
+  local outer = math.max(9, math.min(26, body.r * 1.25 * span / 16))
+  return shot.enemy[1], shot.enemy[2] - body.yOff * span / 16, outer
+end
+
+-- read the foe's pic back once and box its opaque pixels. A few thousand
+-- getPixel calls on a 160x144 canvas, paid once per session -- the ring
+-- then sits on the Pokemon for every species without a per-species table.
+local function measureArt()
+  local ok, box = pcall(function()
+    local tex = OB().enemyTexture()
+    if not (tex and tex.canvas and tex.canvas.newImageData) then return nil end
+    local data = tex.canvas:newImageData()
+    local w, h = data:getWidth(), data:getHeight()
+    local x0, y0, x1, y1
+    for y = 0, h - 1, 2 do
+      for x = 0, w - 1, 2 do
+        local _, _, _, a = data:getPixel(x, y)
+        if a > 0.1 then
+          if not x0 or x < x0 then x0 = x end
+          if not x1 or x > x1 then x1 = x end
+          if not y0 or y < y0 then y0 = y end
+          if not y1 or y > y1 then y1 = y end
+        end
+      end
+    end
+    if not x0 then
+      if data.release then data:release() end
+      return nil
+    end
+    -- the ImageData is KEPT: it is the foe's collision geometry -- the
+    -- flight samples it per plane crossing -- and it is released when the
+    -- session is swept (endSession / battle.ended)
+    return { ax = tex.ax, ay = tex.ay, x0 = x0, y0 = y0, x1 = x1, y1 = y1,
+             data = data }
+  end)
+  return (ok and box) or nil
+end
+
+-- is there sprite under this pic pixel, within `r` pixels? Nine taps --
+-- centre and a ring -- which at the dilation radii in play reads a hit
+-- anywhere the ball's silhouette overlaps the art's.
+local function maskHit(data, x, y, r)
+  local ok, hit = pcall(function()
+    local w, h = data:getWidth(), data:getHeight()
+    local function tap(px, py)
+      px, py = math.floor(px + 0.5), math.floor(py + 0.5)
+      if px < 0 or py < 0 or px >= w or py >= h then return false end
+      local _, _, _, a = data:getPixel(px, py)
+      return a > 0.1
+    end
+    if tap(x, y) then return true end
+    for i = 0, 7 do
+      local a = i * math.pi / 4
+      if tap(x + math.cos(a) * r, y + math.sin(a) * r) then return true end
+    end
+    return false
+  end)
+  return ok and hit or false
+end
+
+-- The foe's BODY -- where the ball has to physically arrive, and what the
+-- ring is sized on. Measured from whichever the foe actually is: the pic's
+-- opaque box (a hovering bird is hit where the bird is drawn, not where
+-- its shadow falls), or the Stadium model's own height and footprint. A
+-- default torso stands in until one of them answers; measured=false says
+-- keep trying.
+local function measureBody()
+  local box = measureArt()
+  if box then
+    S.artBox = box
+    local k = 16 / 56
+    S.body = {
+      r = math.max(5, math.min(14,
+            math.max(box.x1 - box.x0, box.y1 - box.y0) * k * 0.5)),
+      yOff = math.max(4, (box.ay - (box.y0 + box.y1) / 2) * k),
+      hh = math.max(4, (box.y1 - box.y0) * k * 0.55),
+    }
+    S.bodyMeasured = true
+    return
+  end
+  local ok, body = pcall(function()
+    return V.require("Stadium").captureBody()
+  end)
+  if ok and body then
+    S.body = body
+    S.bodyMeasured = true
+  end
+end
+
+-- named for the drivers: where the ball hangs and where the ring sits, in
+-- GB pixels plus the live letterbox -- everything a scripted flick needs
+-- to aim like a hand would
+function CatchThrow._aimInfo()
+  if not (S and S.shot) then return nil end
+  local cx, cy, outer = ringGeometry(S.shot)
+  return { hand = { S.handGB[1], S.handGB[2] },
+           ring = { cx, cy }, outer = outer,
+           lx = S.shot.lx, ly = S.shot.ly, scale = S.shot.scale,
+           pw = S.shot.pw, ph = S.shot.ph }
+end
+
+-- the ring's inner fraction right now: 1 -> RING_MIN, hold, snap back
+local function ringRho(t)
+  local cycle = RING_PERIOD + RING_HOLD
+  local u = t % cycle
+  if u >= RING_PERIOD then return RING_MIN end
+  return 1 - (1 - RING_MIN) * (u / RING_PERIOD)
+end
+
+-- ------- flick estimation
+
+local function pushSample(x, y)
+  local n = S.samples
+  n[#n + 1] = { x = x, y = y, t = S.clock }
+  if #n > 32 then table.remove(n, 1) end
+end
+
+-- least-squares velocity over the window before `at`; also answers the
+-- window's peak two-sample speed and the mean speed of the last 40 ms,
+-- which the dead-flick gate reads
+local function flickFit(at)
+  local xs, n = S.samples, 0
+  local sx, sy, st = 0, 0, 0
+  local first = at - WINDOW
+  local pts = {}
+  for _, p in ipairs(xs) do
+    if p.t >= first and p.t <= at then
+      pts[#pts + 1] = p
+      sx, sy, st = sx + p.x, sy + p.y, st + p.t
+      n = n + 1
+    end
+  end
+  if n < 3 then return nil, nil, n end
+  local mt, mx, my = st / n, sx / n, sy / n
+  local num_x, num_y, den = 0, 0, 0
+  for _, p in ipairs(pts) do
+    local d = p.t - mt
+    num_x = num_x + d * (p.x - mx)
+    num_y = num_y + d * (p.y - my)
+    den = den + d * d
+  end
+  if den < 1e-9 then return nil, nil, n end
+  local vx, vy = num_x / den, num_y / den
+  local peak, tailSum, tailN = 0, 0, 0
+  for i = 2, #pts do
+    local a, b = pts[i - 1], pts[i]
+    local dt = b.t - a.t
+    if dt > 1e-5 then
+      local sp = math.sqrt((b.x - a.x) ^ 2 + (b.y - a.y) ^ 2) / dt
+      if sp > peak then peak = sp end
+      if b.t > at - 0.04 then tailSum, tailN = tailSum + sp, tailN + 1 end
+    end
+  end
+  local tail = tailN > 0 and tailSum / tailN or 0
+  return vx, vy, n, peak, tail
+end
+
+-- ------- spin gesture
+--
+-- A METER, not a switch. Circling the held ball winds it: every radian of
+-- gesture adds to the meter and the ball's screen-plane roll follows it,
+-- so the ball visibly spins faster the more it is wound, to a cap. Pause
+-- and it bleeds back down (the decay lives in the aim tick). Winding the
+-- OTHER way unwinds first. Only a ball at the cap curves when thrown.
+local function feedSpin(x, y)
+  -- About the CENTROID of the recent gesture, not about any fixed point:
+  -- the ball rides under the finger, so the finger is never orbiting the
+  -- ball -- it is orbiting the middle of the loop it is drawing, and that
+  -- is what the centroid of the last few samples is. It also gives the
+  -- straight-drag rejection for free: on a straight swipe the centroid
+  -- trails along the line, so both spokes point the same way and the
+  -- accumulated angle stays at nothing.
+  local n = #S.samples
+  if n < 4 then return end
+  local sx, sy, count = 0, 0, 0
+  for i = math.max(1, n - 11), n do
+    sx, sy, count = sx + S.samples[i].x, sy + S.samples[i].y, count + 1
+  end
+  local cx, cy = sx / count, sy / count
+  local px, py = S.spinPrev and S.spinPrev[1], S.spinPrev and S.spinPrev[2]
+  S.spinPrev = { x, y }
+  if not px then return end
+  local ax, ay = px - cx, py - cy
+  local bx, by = x - cx, y - cy
+  local ra = math.sqrt(ax * ax + ay * ay)
+  local rb = math.sqrt(bx * bx + by * by)
+  if ra < 4 or rb < 4 then return end        -- thumb jitter, not an orbit
+  local d = math.atan2(ax * by - ay * bx, ax * bx + ay * by)
+  if d == 0 then return end
+  local sign = d > 0 and 1 or -1
+  if sign ~= S.spinSign and S.spinLevel > 0 then
+    -- winding against the charge unwinds it, twice as fast as it built
+    S.spinLevel = math.max(0, S.spinLevel - math.abs(d) * 2 / SPIN_FULL)
+    if S.spinLevel == 0 then S.spinSign = sign end
+  else
+    S.spinSign = sign
+    S.spinLevel = math.min(1, S.spinLevel + math.abs(d) / SPIN_FULL)
+  end
+  S.spinT = S.clock
+  if not S.spinArmed and S.spinLevel >= ARM_AT then
+    S.spinArmed = true
+    sound("Press_AB")
+  end
+end
+
+-- ------- the throw
+
+local function smoothstep(a, b, t)
+  local u = math.max(0, math.min(1, (t - a) / (b - a)))
+  return u * u * (3 - 2 * u)
+end
+
+-- the moment the ball leaves the hand: pay for it, count it, sound it
+local function paidAndAway()
+  if not S.consumed then
+    if S.safari then
+      local st = S.battle.safari
+      if st then st.balls = math.max(0, st.balls - 1) end
+    else
+      pcall(function()
+        require("src.inventory.Bag").remove(game().save, S.ballId, 1)
+      end)
+    end
+    S.consumed = true
+  end
+  S.battle.dramaticShapeThrows = (S.battle.dramaticShapeThrows or 0) + 1
+  S.phase = "flight"
+  S.tFly, S.acc = 0, 0
+  -- a charged ball keeps its screen-plane roll all the way in; a plain
+  -- throw tumbles end over end, harder the less it was wound
+  S.ballInst.tumble = S.spinArmed and 0 or 15 * (1 - S.spinLevel)
+  sound("Ball_Toss")
+end
+
+-- estimate how long the flight will take to reach the foe, for the curve
+-- ramp and the give-up timer -- with a thrown VELOCITY there is no solved
+-- T any more, only this
+local function estimateT()
+  local p, E = S.ballInst.pos, S.enemyPos
+  local dx, dz = E[1] - p[1], E[3] - p[3]
+  local horiz = math.sqrt(dx * dx + dz * dz)
+  local hs = math.sqrt(S.vel[1] ^ 2 + S.vel[3] ^ 2)
+  S.T = math.max(0.35, math.min(1.0, horiz / math.max(20, hs)))
+end
+
+-- launch with the swipe's own velocity (GB px/s in, world velocity out)
+local function launchVelocity(vgx, vgy)
+  local shot = S.shot
+  local fx, fz, rx, rz = camBasis(shot)
+  -- what a GB pixel is worth at the BALL's own depth: the swipe moved the
+  -- ball, so the ball's speed is the swipe's speed in its own plane
+  local wpg = 16 / math.max(6, shot.playerSpan or 16)
+  local speed = math.sqrt(vgx * vgx + vgy * vgy) * wpg
+  local fwd = math.max(F_MIN, math.min(F_MAX, speed * K_FWD))
+  local up = math.max(U_MIN, math.min(U_MAX, (-vgy) * wpg * K_UP))
+  local lat = math.max(-L_MAX, math.min(L_MAX, vgx * wpg * K_LAT))
+  -- the horizontal launch, leaned MAGNET of the way toward the Pokemon
+  local hx, hz = fx * fwd + rx * lat, fz * fwd + rz * lat
+  local hmag = math.sqrt(hx * hx + hz * hz)
+  local p, E = S.ballInst.pos, S.enemyPos
+  local tx, tz = E[1] - p[1], E[3] - p[3]
+  local tl = math.sqrt(tx * tx + tz * tz)
+  if hmag > 1e-6 and tl > 1e-6 then
+    local nx = hx / hmag * (1 - MAGNET) + tx / tl * MAGNET
+    local nz = hz / hmag * (1 - MAGNET) + tz / tl * MAGNET
+    local nl = math.sqrt(nx * nx + nz * nz)
+    hx, hz = nx / nl * hmag, nz / nl * hmag
+  end
+  -- the range assist: what height would land THIS forward speed on the
+  -- body, and lean toward it
+  if hmag > 1 then
+    local body = S.body or { yOff = 8 }
+    local tt = tl / hmag
+    local need = (S.groundY + body.yOff - p[2] + 0.5 * GRAVITY * tt * tt) / tt
+    up = up + (need - up) * V_ASSIST
+    -- and never more than a margin over what this throw needs (OVER_CAP)
+    if need > 0 then up = math.min(up, need * OVER_CAP) end
+    up = math.max(U_MIN * 0.6, math.min(U_MAX, up))
+  end
+  S.vel = { hx, up, hz }
+  estimateT()
+  paidAndAway()
+end
+
+-- the solved arc, kept for exactly two throwers: the Master Ball (which
+-- must not miss) and the pad's A tap (full participation without the
+-- dexterity requirement). Flies to the foe's own body centre.
+local function launchSolve()
+  local body = S.body or { r = 10, yOff = 8 }
+  local E, p = S.enemyPos, S.ballInst.pos
+  local T = T_REF
+  S.vel = { (E[1] - p[1]) / T,
+            (S.groundY + body.yOff - p[2]) / T + 0.5 * GRAVITY * T,
+            (E[3] - p[3]) / T }
+  S.T = T
+  paidAndAway()
+end
+
+local function tapThrow()
+  if S.shot then launchSolve() end
+end
+
+local function pointerRelease(at)
+  if not S.grab then return end
+  S.grab = nil
+  local vx, vy, n, peak, tail = flickFit(at or S.clock)
+  if not vx or n < 3 then return end          -- a tap, not a throw
+  local speed = math.sqrt(vx * vx + vy * vy)
+  -- dead flick: the finger braked before lifting -- the player changed
+  -- their mind, and the ball drifts back to the hand
+  if peak and peak > 0 and tail / peak < 0.10 then return end
+  if speed < S_MIN then return end
+  if vy / speed > -MIN_UP then return end     -- throws point up the screen
+  local def = S.battle:ballDef(S.ballId)
+  if def and def.autoCatch then return launchSolve() end
+  launchVelocity(vx, vy)
+end
+
+-- ------- contact
+
+local function tierFor(rho, inside)
+  if not inside then return nil end
+  if rho <= 0.30 then return "EXCELLENT" end
+  if rho <= 0.70 then return "GREAT" end
+  return "NICE"
+end
+
+local function beginSuck(cross)
+  S.phase = "suck"
+  S.t = 0
+  S.ballInst.pos = cross
+  S.ballInst.tumble, S.ballInst.tumbleAngle = 0, 0
+  S.ballInst.spin, S.ballInst.spinAngle = 0, 0
+  S.ballInst.roll, S.ballInst.rollAngle = 0, 0
+  -- face the foe: the hinge is at the back, so the mouth gapes at it
+  local E = S.enemyPos
+  S.ballInst.yaw = math.atan2(E[1] - cross[1], E[3] - cross[3]) + math.pi
+  S.ballInst:open()
+  sound("Ball_Poof")
+end
+
+-- The ball has TOUCHED the Pokemon -- contact is a real collision in
+-- world space (see the flight step), so any touch opens the ball; the
+-- ring's only job is the GRADE, judged in screen space at this instant:
+-- inside the shrinking ring earns the tier, outside is a plain hit.
+local function resolveContact(cross)
+  local shot = S.shot
+  local cx, cy, outer = ringGeometry(shot)
+  local rho = ringRho(S.ringT)
+  local inside = false
+  local gx, gy = toGB(shot, cross[1], cross[2], cross[3])
+  if gx then
+    local dist = math.sqrt((gx - cx) ^ 2 + (gy - cy) ^ 2)
+    inside = dist <= rho * outer + 1
+  end
+  S.tier = tierFor(rho, inside)
+  S.rho = rho
+  S.hit = true
+  -- hang the ball where it struck
+  beginSuck({ cross[1], math.max(S.groundY + Pokeball.R, cross[2]),
+              cross[3] })
+  return true
+end
+
+-- ------- the engine roll, and the queue that finishes the fight
+
+local function throwMult()
+  return (S.tier and TIER_MULT[S.tier]) or 1.0
+end
+
+local function rollCatch()
+  local b = S.battle
+  local base = S.safari and (b.safariCatchRate or b.enemy.def.catchRate)
+               or b.enemy.def.catchRate
+  local rate = math.min(255, math.floor(base * throwMult()))
+  local caught, shakes = b:catchAttempt(S.ballId, rate)
+  S.caught = caught
+  S.shakes = caught and 3 or (shakes or 0)
+  emit("battle.ball_thrown", { battle = b, ball = S.ballId,
+                               caught = caught, shakes = shakes })
+end
+
+-- the camera hold's previous value, owned by whoever engaged first -- the
+-- battle-long veil under FULL, or a lone session under CATCH ONLY
+local heldStill = nil
+
+local function releaseMask()
+  if S and S.artBox and S.artBox.data then
+    pcall(function() S.artBox.data:release() end)
+    S.artBox.data = nil
+  end
+end
+
+local function endSession(keepScene)
+  if not S then return end
+  if not keepScene then
+    BattleCam.still = heldStill or false
+    heldStill = nil
+    BattleScene.capture = nil
+    releaseMask()
+    S = nil
+  end
+end
+
+-- the catch: the engine's own captured flow, with the fanfare the anim
+-- chain would have supplied. The session stays alive as an epilogue so the
+-- ball and the stars remain in shot under the caught text; battle.ended
+-- sweeps it away.
+local function resolveCaught()
+  local b = S.battle
+  b.lastBall = S.ballId
+  -- the experience context has to exist before storeCaughtMon fires the
+  -- catch-exp hooks; LetsGo owns the arithmetic
+  pcall(function()
+    V.require("LetsGo").noteCatch(b, {
+      tier = S.tier,
+      mult = throwMult(),
+      firstThrow = (b.dramaticShapeThrows or 1) == 1,
+      safari = S.safari,
+    })
+  end)
+  b.phase = "messages"
+  b.afterQueue = "menu"
+  b:act(function() sound("Caught_Mon") end)
+  b:say(require("src.core.Strings")("All right!\n%s was\ncaught!",
+                                    b.enemy.name))
+  b:act(function() b:storeCaughtMon() end)
+  S.phase = "epilogue"
+end
+
+-- a failed throw -- broke out, or never touched the Pokemon at all. The
+-- engine's own miss line for the shake count, then the foe's turn, exactly
+-- as throwBall's failure path builds it.
+--
+-- Except under FULL, where a wild encounter IS the catch: the foe never
+-- takes a turn, and the session stays alive as "await" -- the moment the
+-- miss text clears, the next ball is in the hand. Let's Go wilds do not
+-- fight back, and the player is never dropped out of throwing mode.
+local function resolveFail(shakes)
+  local b = S.battle
+  b.lastBall = S.ballId
+  b.phase = "messages"
+  b.afterQueue = "menu"
+  b:say(b:ballMissMessage(shakes))
+  if S.fullWild then
+    S.phase = "await"
+    S.ballInst.visible = false
+    S.hit, S.tier, S.planeD = nil, nil, nil
+    if BattleScene.capture then BattleScene.capture.shrink = nil end
+    return
+  end
+  if S.safari then
+    b:act(function() b:safariEnemyTurn() end)
+  else
+    b:act(function() b:executeAction(b.enemy, b.player, b:enemyAction()) end)
+    b:act(function() b:endOfTurn() end)
+  end
+  endSession()
+end
+
+-- B during the aim. Under FULL it is the encounter's RUN: a Let's Go wild
+-- is a catch encounter, so leaving it always works -- the safari's own
+-- farewell. Everywhere else it backs out to whatever the session was
+-- opened over, refunding a ball the bag menu already consumed.
+function CatchThrow.cancel(declined)
+  if not (S and S.phase == "aim") then return false end
+  local b = S.battle
+  if S.fullWild then
+    sound("Run")
+    b:say(require("src.core.Strings")("Got away safely!"))
+    b.result = "run"
+    b.phase = "messages"
+    b.afterQueue = "finish"
+    S.phase = "epilogue"      -- the veil holds until battle.ended sweeps
+    return true
+  end
+  if S.consumed and not S.safari then
+    pcall(function()
+      require("src.inventory.Bag").add(game().save, S.ballId, 1, game().data)
+    end)
+  end
+  if declined then b.dramaticShapeDeclined = true end
+  b.phase = "menu"
+  endSession()
+  sound("Press_AB")
+  return true
+end
+
+-- ------- the scene hooks BattleScene consults
+--
+-- One table on BattleScene while a session runs: the ball's draw and
+-- shadow, the foe's shrink, the hidden player side, and the GB overlay.
+
+local function sceneDraw(pull)
+  if not S then return end
+  S.ballInst:draw(pull)
+  if S.phase == "suck" then
+    local E = S.enemyPos
+    local k = 1 - smoothstep(0, 1, S.t / SUCK_T)
+    S.ballInst:drawBeam(E[1], S.groundY + CHEST * 0.8, E[3],
+                        3.2 * k + 0.6, 1, pull)
+  end
+end
+
+local function sceneCast(shadowMap)
+  if S then S.ballInst:cast(shadowMap) end
+end
+
+local function sceneSig()
+  if not S then return "" end
+  return S.ballInst:signature() .. "," .. S.phase
+end
+
+-- ------- the GB overlay (rings, labels, ball readout)
+--
+-- Drawn inside the battle's own 160x144 UI canvas, from the BattleState
+-- draw wrap, so it composites exactly where the engine's HUD does and
+-- comes out with the same chunky letterboxed pixels.
+
+local Font = nil
+local function font()
+  if Font ~= nil then return Font or nil end
+  local ok, F = pcall(require, "src.render.Font")
+  Font = ok and F or false
+  return Font or nil
+end
+
+local function label(str, x, y, align)
+  local F = font()
+  if not F then return end
+  local g = love.graphics
+  local w = F.width(str) + 4
+  local px = align == "right" and (x - w) or
+             align == "center" and math.floor(x - w / 2) or x
+  g.setColor(0.06, 0.05, 0.09, 0.75)
+  g.rectangle("fill", px, y, w, 11)
+  g.setColor(0.93, 0.94, 0.90, 1)
+  g.rectangle("fill", px + 1, y + 1, w - 2, 9)
+  g.setColor(0, 0, 0, 1)
+  F.draw(str, px + 2, y + 2)
+  g.setColor(1, 1, 1, 1)
+end
+
+-- a tiny ball glyph for the readout
+local BALL_TOP = {
+  POKE_BALL = { 0.86, 0.16, 0.16 }, GREAT_BALL = { 0.25, 0.45, 0.88 },
+  ULTRA_BALL = { 0.22, 0.22, 0.26 }, MASTER_BALL = { 0.48, 0.22, 0.66 },
+  SAFARI_BALL = { 0.47, 0.52, 0.26 },
+}
+
+local function ballGlyph(x, y, id)
+  local g = love.graphics
+  local top = BALL_TOP[id] or BALL_TOP.POKE_BALL
+  g.setColor(0.93, 0.94, 0.90, 1)
+  g.circle("fill", x, y, 4)
+  g.setColor(top[1], top[2], top[3], 1)
+  g.arc("fill", x, y, 4, math.pi, 2 * math.pi)
+  g.setColor(0.08, 0.08, 0.09, 1)
+  g.setLineWidth(1)
+  g.line(x - 4, y, x + 4, y)
+  g.circle("line", x, y, 4)
+  g.setColor(1, 1, 1, 1)
+end
+
+-- how sure the ring should look: the Gen 1 odds, ball and status included,
+-- but never the throw -- a colour that jittered with your own inputs would
+-- be unreadable (and is why the reference game excludes it too)
+local function ringColor()
+  local b = S.battle
+  local ok, p = pcall(function()
+    local def = b:ballDef(S.ballId)
+    if def and def.autoCatch then return 1 end
+    local rate = S.safari and (b.safariCatchRate or 0) or b.enemy.def.catchRate
+    local randMax = (def and def.randMax) or 255
+    return math.max(0.05, math.min(1, (rate + 1) / (randMax + 1)))
+  end)
+  if not ok then p = 0.5 end
+  return 1 - p * 0.75, 0.25 + p * 0.7, 0.28, p
+end
+
+local function drawGB(battle)
+  if not S or S.battle ~= battle then return end
+  local shot = OB().shot()
+  if not shot then return end
+  S.shot = shot
+  local g = love.graphics
+  g.push("all")
+
+  if S.phase == "aim" or S.phase == "flight" then
+    local cx, cy, outer = ringGeometry(shot)
+    local rho = ringRho(S.ringT)
+    g.setLineWidth(1)
+    -- outer: fixed, white, the hit area
+    g.setColor(1, 1, 1, 0.85)
+    g.circle("line", cx, cy, outer)
+    -- inner: the timing ring, coloured by the odds
+    local r, gr, bl = ringColor()
+    g.setColor(r, gr, bl, 0.95)
+    g.circle("line", cx, cy, math.max(1.5, rho * outer))
+  end
+
+  if S.phase == "aim" then
+    -- the ball readout: which ball, how many left
+    local count
+    if S.safari then
+      count = battle.safari and battle.safari.balls or 0
+    else
+      count = (game().save.inventory[S.ballId] or 0)
+             + ((S.consumed and not S.safari) and 1 or 0)
+    end
+    local name = (game().data.items[S.ballId]
+                  and game().data.items[S.ballId].name) or S.ballId
+    -- along the bottom edge, out of the throwing lane: with the empty
+    -- text box gone the whole middle of the frame is the wind-up, and
+    -- chrome parked in it would be something to drag around
+    -- " BALL" is dropped from the name: the glyph beside it is already a
+    -- ball, in that tier's own colours, and the full string is wide
+    -- 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")
+    if S.canSwitch then label("L/R:BALL", 15, 119) end
+
+    -- the wind-up readout: sparks orbit the ball as it charges, dim and
+    -- slow at a quarter wind, bright gold and quick at the cap -- the cap
+    -- is the only wind that curves, so it has to be unmistakable
+    if S.spinLevel > 0.12 then
+      local bx, by = S.handGB[1], S.handGB[2]
+      local orbit = math.max(6, (shot.playerSpan or 16) * 0.2)
+      local lvl = S.spinLevel
+      if S.spinArmed then
+        g.setColor(1, 0.9, 0.35, 1)
+      else
+        g.setColor(1, 1, 0.75, 0.25 + lvl * 0.55)
+      end
+      for i = 0, 2 do
+        local a = S.clock * (3 + 8 * lvl) * S.spinSign + i * (2 * math.pi / 3)
+        g.circle("fill", bx + math.cos(a) * orbit,
+                 by + math.sin(a) * orbit * 0.45, S.spinArmed and 1.5 or 1)
+      end
+    end
+  end
+
+  -- the grade, splashed at the ring for a beat after contact
+  if S.tier and S.tierSplash and S.tierSplash > 0 then
+    local cx, cy = ringGeometry(shot)
+    label(S.tier .. "!", cx, math.max(8, cy - 20), "center")
+  end
+
+  g.pop()
+end
+
+-- ------- ball switching (the FULL entry owns its ball choice)
+
+local SWITCHABLE = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" }
+
+local function ownedBalls()
+  local inv = game().save.inventory
+  local out = {}
+  for _, id in ipairs(SWITCHABLE) do
+    if (inv[id] or 0) > 0 then out[#out + 1] = id end
+  end
+  return out
+end
+
+local function switchBall(dir)
+  if not (S and S.canSwitch and S.phase == "aim") then return end
+  local owned = ownedBalls()
+  if #owned == 0 then return end
+  local at = 1
+  for i, id in ipairs(owned) do
+    if id == S.ballId then at = i break end
+  end
+  S.ballId = owned[((at - 1 + dir) % #owned) + 1]
+  S.ballInst.ball = S.ballId
+  sound("Press_AB")
+end
+
+CatchThrow.pickBall = function()
+  local owned = ownedBalls()
+  for _, id in ipairs(owned) do
+    if id == CatchThrow.lastBall then return id end
+  end
+  return owned[1]
+end
+
+-- ------- session lifecycle
+
+-- The capture table BattleScene consults: one shape, installed by the
+-- session OR by the battle-long veil below.
+local function installScene()
+  if not BattleScene.capture then
+    if heldStill == nil then heldStill = BattleCam.still end
+    BattleCam.still = true
+  end
+  BattleScene.capture = {
+    hidePlayer = true,
+    rig = captureRig,
+    draw = sceneDraw,
+    cast = sceneCast,
+    sig = sceneSig,
+    drawGB = drawGB,
+    shrink = nil,
+  }
+end
+
+-- The FULL veil: installed at battle.started for a Let's Go wild, before
+-- any session exists, so the whole encounter -- the wipe, the "Wild X
+-- appeared!" line, every beat between throws -- plays from the head-on
+-- seat with the player's side out of the shot. The player's Pokemon is
+-- NEVER shown; the encounter IS the catch.
+function CatchThrow.veil(battle)
+  installScene()
+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)
+function CatchThrow.begin(battle, ballId, opts)
+  if S then return false end
+  opts = opts or {}
+  local ok = pcall(function()
+    local arena, groundY = OB().arenaInfo()
+    local shot = OB().shot()
+    assert(arena and shot and shot.eye, "no staged shot to capture in")
+    local ball = Pokeball.new(ballId)
+    S = {
+      battle = battle,
+      ballId = ballId,
+      safari = opts.safari or false,
+      fullWild = opts.fullWild or false,
+      consumed = opts.consumed or false,
+      canSwitch = opts.canSwitch or false,
+      declinable = opts.declinable ~= false,
+      phase = "aim",
+      clock = 0, t = 0, ringT = 0,
+      samples = {}, grab = nil,
+      spinLevel = 0, spinArmed = false, spinSign = 1,
+      stickHeld = false, stickSamples = {},
+      ballInst = ball,
+      enemyPos = { arena.enemy[1], groundY, arena.enemy[2] },
+      playerPos = { arena.player[1], groundY, arena.player[2] },
+      groundY = groundY,
+      shot = shot,
+      handGB = { 80, 110 },
+    }
+    ball.pos = handPos()
+    S.body = { r = 10, yOff = 8, hh = 8 }  -- until a measurement lands
+    measureBody()
+  end)
+  if not ok or not S then
+    S = nil
+    return false
+  end
+  battle.phase = CatchThrow.PHASE
+  CatchThrow.lastBall = ballId
+  -- 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
+  -- and lens come back when the hold releases.
+  installScene()
+  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
+local function rearm(ballId)
+  S.ballId = ballId
+  CatchThrow.lastBall = ballId
+  S.ballInst = Pokeball.new(ballId)
+  S.ballInst.pos = handPos()
+  S.battle.phase = CatchThrow.PHASE   -- park the engine's menu again
+  S.phase = "aim"
+  S.clock = 0
+  S.samples, S.grab, S.spinPrev = {}, nil, nil
+  S.spinLevel, S.spinArmed, S.spinSign = 0, false, 1
+  S.stickHeld, S.stickSamples = false, {}
+  S.consumed = false
+  S.hit, S.tier, S.rho, S.caught, S.shakes, S.planeD = nil, nil, nil, nil,
+                                                      nil, nil
+  S.tFly, S.tierSplash = 0, nil
+end
+
+-- battle.ended sweeps the epilogue, the veil, and any half-open session:
+-- a battle torn down by a script must never leave the camera held
+function CatchThrow.onBattleEnded()
+  releaseMask()
+  if heldStill ~= nil or S ~= nil or BattleScene.capture ~= nil then
+    BattleCam.still = heldStill or false
+  end
+  heldStill = nil
+  BattleScene.capture = nil
+  S = nil
+end
+
+-- ------- input entry points (installed once, from LetsGo.install)
+
+local installed = false
+
+-- window coordinates (LOVE units) -> GB frame pixels
+local function winToGB(x, y)
+  local shot = S and S.shot
+  if not shot then return nil end
+  local uw, uh = love.graphics.getDimensions()
+  if not (uw > 0 and uh > 0) then return nil end
+  local px = x * shot.pw / uw
+  local py = y * shot.ph / uh
+  return (px - shot.lx) / shot.scale, (py - shot.ly) / shot.scale
+end
+
+local function aiming()
+  return S ~= nil and S.phase == "aim"
+end
+
+local function pointer(kind, x, y, id)
+  local gx, gy = winToGB(x, y)
+  if not gx then return end
+  if kind == "press" and not S.grab then
+    S.grab = { id = id }
+    S.samples = {}
+    S.spinPrev = nil
+    pushSample(gx, gy)
+  elseif S.grab and S.grab.id == id then
+    pushSample(gx, gy)
+    feedSpin(gx, gy)
+    if kind == "release" then pointerRelease(S.clock) end
+  end
+end
+
+function CatchThrow.installInput()
+  if installed then return end
+  installed = true
+
+  local Game = require("src.core.Game")
+
+  -- ------- mouse
+  --
+  -- Installed after CamControl's wraps (see main.lua's install order), so
+  -- these are the OUTER ones and the aim owns the button first. Bare
+  -- motion is not claimed -- CamControl's battle steer already stands
+  -- down while the capture holds the camera (BattleCam.steerable).
+  do
+    local inner = love.mousepressed
+    love.mousepressed = function(x, y, button, istouch, presses)
+      if aiming() and not istouch and button == 1 then
+        pointer("press", x, y, "mouse")
+        return
+      end
+      if inner then return inner(x, y, button, istouch, presses) end
+    end
+  end
+  do
+    local inner = love.mousemoved
+    love.mousemoved = function(x, y, dx, dy, istouch)
+      if aiming() and not istouch and S.grab and S.grab.id == "mouse" then
+        pointer("move", x, y, "mouse")
+        return
+      end
+      if inner then return inner(x, y, dx, dy, istouch) end
+    end
+  end
+  do
+    local inner = love.mousereleased
+    love.mousereleased = function(x, y, button, istouch, presses)
+      if aiming() and not istouch and button == 1
+         and S.grab and S.grab.id == "mouse" then
+        pointer("release", x, y, "mouse")
+        return
+      end
+      if inner then return inner(x, y, button, istouch, presses) end
+    end
+  end
+
+  -- ------- touch
+  --
+  -- A finger on open screen is the throw; fingers on the overlay's d-pad
+  -- and buttons flow to TouchControls untouched, so B still backs out on
+  -- a phone. One finger owns the ball at a time, the FirstPerson rule.
+  local TouchControls = require("src.core.TouchControls")
+  do
+    local inner = Game.touchpressed
+    function Game:touchpressed(id, x, y)
+      if aiming() then
+        local onControl = nil
+        pcall(function() onControl = TouchControls:hitTest(x, y) end)
+        if not onControl and not S.grab then
+          pointer("press", x, y, id)
+          return
+        end
+      end
+      return inner(self, id, x, y)
+    end
+  end
+  do
+    local inner = Game.touchmoved
+    function Game:touchmoved(id, x, y)
+      if aiming() and S.grab and S.grab.id == id then
+        pointer("move", x, y, id)
+        return
+      end
+      return inner(self, id, x, y)
+    end
+  end
+  do
+    local inner = Game.touchreleased
+    function Game:touchreleased(id, x, y)
+      if aiming() and S.grab and S.grab.id == id then
+        pointer("release", x, y, id)
+        return
+      end
+      return inner(self, id, x, y)
+    end
+  end
+end
+
+-- ------- the stick, polled
+--
+-- FirstPerson's always-on wrap records the right stick whatever the mode,
+-- so the session reads it rather than claiming a seam of its own. The
+-- stick plays "virtual finger": deflection stands a finger at centre +
+-- stick * STICK_R, circling winds the spin, and a hard upward flick of
+-- the stick is the throw -- strength from the flick's own velocity.
+local function stickInput(dt)
+  local FirstPerson = V.require("FirstPerson")
+  local sx = FirstPerson.stickX and FirstPerson.stickX() or 0
+  local sy = FirstPerson.stickY and FirstPerson.stickY() or 0
+  local mag = math.sqrt(sx * sx + sy * sy)
+
+  local st = S.stickSamples
+  st[#st + 1] = { x = sx, y = sy, t = S.clock }
+  if #st > 16 then table.remove(st, 1) end
+
+  if mag > STICK_GRAB then
+    if not S.stickHeld then
+      S.stickHeld = true
+      if not S.grab then
+        S.grab = { id = "stick" }
+        S.samples = {}
+        S.spinPrev = nil
+      end
+    end
+    if S.grab and S.grab.id == "stick" then
+      local gx = S.handGB[1] + sx * STICK_R
+      local gy = S.handGB[2] + sy * STICK_R
+      pushSample(gx, gy)
+      feedSpin(gx, gy)
+      -- the flick: stick velocity over the last few samples, upward and
+      -- fast. Measured in stick units so a worn pad and a fresh one
+      -- answer alike after their deadzones.
+      local n = #st
+      if n >= 3 then
+        local a, b = st[n - 2], st[n]
+        local dt2 = b.t - a.t
+        if dt2 > 1e-4 then
+          local vy = (b.y - a.y) / dt2
+          if vy < -STICK_VEL and sy < -0.45 then
+            -- hand the same release path the pointer takes; the samples
+            -- already trace the gesture in GB pixels
+            pointerRelease(S.clock)
+            S.stickHeld = false
+          end
+        end
+      end
+    end
+  elseif S.stickHeld then
+    S.stickHeld = false
+    if S.grab and S.grab.id == "stick" then
+      -- stick returned to centre without the flick: let the fit decide
+      -- whether the travel back was itself a throw (it usually is not)
+      pointerRelease(S.clock)
+    end
+  end
+end
+
+-- ------- per-frame drive (called from LetsGo.update, before the battle
+-- scene renders, so the ball this frame draws is the ball this frame)
+
+function CatchThrow.update(dt)
+  if not S then return end
+  local b = S.battle
+  S.clock = S.clock + dt
+  S.ringT = S.ringT + dt
+  S.ballInst:update(dt)
+  if S.tierSplash then S.tierSplash = S.tierSplash - dt end
+  -- the box is back by default and taken away only by the aim branch, so
+  -- the first frame with something to say has somewhere to say it
+  if BattleScene.capture then BattleScene.capture.hideTextBox = false end
+
+  -- the battle went away under us (forced finish, a script): stand down
+  if S.phase ~= "epilogue" then
+    local ok, gone = pcall(function()
+      return b.result ~= nil and b.result ~= false
+    end)
+    if ok and gone then
+      CatchThrow.onBattleEnded()
+      return
+    end
+  end
+
+  -- the staged shot for this frame's reasoning: last frame's render.
+  -- Losing it (a lost context, a broken arena) abandons the session
+  -- rather than playing blind.
+  local shot = OB().shot()
+  if shot then
+    S.shot = shot
+    S.lostShot = 0
+  else
+    S.lostShot = (S.lostShot or 0) + dt
+    if S.lostShot > 1.0 and S.phase == "aim" then
+      CatchThrow.cancel(true)
+      return
+    end
+  end
+
+  if S.phase == "aim" then
+    -- 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
+    -- foe that never answers
+    if not S.bodyMeasured and (S.artTries or 0) < 30 then
+      S.artTries = (S.artTries or 0) + 1
+      measureBody()
+    end
+    -- The held ball is DRAGGED, riding directly under the finger with no
+    -- fence around it (the reference model: the ball follows the hand,
+    -- and the throw will be the hand's own velocity). It moves in the
+    -- CAMERA-FACING PLANE through its hover -- screen right is the eye's
+    -- right, screen up is the eye's own tilted up -- so every part of
+    -- the frame maps to somewhere the ball can be: dragging low brings
+    -- it down and toward the seat rather than jamming it into the floor.
+    -- The floor is the one true limit, and it is the world's, not a box.
+    local home = handPos()
+    local bob = math.sin(S.clock * 2.2) * 0.35
+    local target = { home[1], home[2] + bob, home[3] }
+    if S.grab and #S.samples > 0 then
+      -- straight down the finger's own ray, at the hand's reach: the ball
+      -- is wherever the pointer is, over the WHOLE frame, with no plane
+      -- and no box to run into.
+      --
+      -- The one limit is the world's own floor, and it is not a clamp:
+      -- drag low enough that the ray would put the ball underground and
+      -- the ball is pulled IN toward the eye instead, which is how a
+      -- hand holding it low actually looks. So the bottom of the screen
+      -- is reachable everywhere -- the ball simply comes nearer as it
+      -- goes down, rather than stopping dead at the grass.
+      local last = S.samples[#S.samples]
+      local e = S.shot.eye
+      local dx, dy, dz = rayThrough(last.x, last.y)
+      if dx then
+        local reach = math.sqrt((home[1] - e[1]) ^ 2 + (home[2] - e[2]) ^ 2
+                                + (home[3] - e[3]) ^ 2)
+        if dy < -1e-6 then
+          local floor = (e[2] - (S.groundY + Pokeball.R * 1.4)) / -dy
+          reach = math.min(reach, math.max(Pokeball.R * 3, floor))
+        end
+        target = { e[1] + dx * reach, e[2] + dy * reach, e[3] + dz * reach }
+      end
+    end
+    -- direct under the finger, a soft drift when coming home
+    local ease = S.grab and math.min(1, dt * 30) or math.min(1, dt * 9)
+    local bp = S.ballInst.pos
+    bp[1] = bp[1] + (target[1] - bp[1]) * ease
+    bp[2] = bp[2] + (target[2] - bp[2]) * ease
+    bp[3] = bp[3] + (target[3] - bp[3]) * ease
+    -- face the seat, so the wind-up's roll reads as the ball turning
+    -- clockwise/counter-clockwise to the player watching it
+    S.ballInst.yaw = eyeYaw(bp[1], bp[3])
+    local gx, gy = toGB(S.shot, bp[1], bp[2], bp[3])
+    if gx then S.handGB = { gx, gy } end
+
+    -- the wind-up meter: bleeds once the hand pauses, and the ball's
+    -- visible roll IS the meter -- faster the more it is wound
+    if S.spinLevel > 0 and S.clock - (S.spinT or 0) > SPIN_IDLE then
+      S.spinLevel = math.max(0, S.spinLevel - SPIN_DECAY * dt)
+    end
+    if S.spinArmed and S.spinLevel < DISARM_AT then S.spinArmed = false end
+    S.ballInst.roll = -S.spinSign * S.spinLevel * ROLL_MAX
+    -- the empty text box is off the frame while aiming, which is what
+    -- gives the drag the bottom third of the screen to wind up in
+    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
+    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.
+  if S.phase == "await" then
+    if S.battle.phase == "menu" then
+      local ball = CatchThrow.pickBall()
+      if ball then
+        rearm(ball)
+      else
+        endSession()
+      end
+    end
+    return
+  end
+
+  if S.phase == "flight" then
+    S.acc = S.acc + dt
+    local E = S.enemyPos
+    -- The foe's GEOMETRY, not a blob. A pic foe is judged against its own
+    -- art: crossing the card's plane samples the sprite's opaque pixels
+    -- (dilated by the ball's radius and a mercy margin), so a ball through
+    -- the gap under a wing flies on and one that clips the wing connects.
+    -- A model foe is an ellipsoid at its measured height and footprint;
+    -- the default torso covers a foe that never answered.
+    local body = S.body or { r = 10, yOff = 8, hh = 8 }
+    local box = S.artBox
+    local fx, fz, rx, rz = camBasis(S.shot)
+    local kPic = 16 / 56                       -- world px per pic px
+    local dilate = (Pokeball.R * 1.6 + 2) / kPic
+    local sr = body.r * 1.3 + Pokeball.R + 1.5 -- ellipsoid semi-axes
+    local sh = (body.hh or body.r) + Pokeball.R + 1
+    while S.acc >= H_STEP do
+      S.acc = S.acc - H_STEP
+      local p, v = S.ballInst.pos, S.vel
+      local ax, ay, az = 0, -GRAVITY, 0
+      if S.spinArmed then
+        local w = smoothstep(CURVE_RAMP0, CURVE_RAMP1, S.tFly / S.T)
+        ax = ax + rx * CURVE_GAIN * S.spinSign * w
+        az = az + rz * CURVE_GAIN * S.spinSign * w
+      end
+      v[1], v[2], v[3] = v[1] + ax * H_STEP, v[2] + ay * H_STEP,
+                         v[3] + az * H_STEP
+      p[1] = p[1] + v[1] * H_STEP
+      p[2] = p[2] + v[2] * H_STEP
+      p[3] = p[3] + v[3] * H_STEP
+      S.tFly = S.tFly + H_STEP
+
+      if not S.hit then
+        local contact = false
+        if box and box.data then
+          -- the card's plane through the foe's cell, crossed this step?
+          local d1 = (p[1] - E[1]) * fx + (p[3] - E[3]) * fz
+          if (S.planeD or -1) < 0 and d1 >= 0 then
+            -- where the ball stands ON the art, in pic pixels
+            local ux = (p[1] - E[1]) * rx + (p[3] - E[3]) * rz
+            local px = box.ax + ux / kPic
+            local py = box.ay - (p[2] - S.groundY) / kPic
+            contact = maskHit(box.data, px, py, dilate)
+          end
+          S.planeD = d1
+        else
+          local dx = (p[1] - E[1]) / sr
+          local dy = (p[2] - (S.groundY + body.yOff)) / sh
+          local dz = (p[3] - E[3]) / sr
+          contact = dx * dx + dy * dy + dz * dz <= 1
+        end
+        if contact then
+          resolveContact({ p[1], p[2], p[3] })
+          S.tierSplash = S.tier and 1.0 or nil
+          return
+        end
+      end
+
+      -- short or wide: the ground takes it, with a couple of tired hops
+      if p[2] <= S.groundY + Pokeball.R and v[2] < 0 then
+        p[2] = S.groundY + Pokeball.R
+        v[2] = -v[2] * 0.35
+        v[1], v[3] = v[1] * 0.55, v[3] * 0.55
+        S.ballInst.tumble = S.ballInst.tumble * 0.5
+        if math.abs(v[2]) < 12 then v[2] = 0 end
+      end
+    end
+    if S.tFly > S.T + 1.2 then
+      S.ballInst.visible = false
+      resolveFail(0)                          -- "You missed the POKéMON!"
+    end
+    return
+  end
+
+  if S.phase == "suck" then
+    S.t = S.t + dt
+    local k = 1 - smoothstep(0, 1, S.t / SUCK_T)
+    BattleScene.capture.shrink = math.max(0.02, k)
+    if S.t >= SUCK_T then
+      S.ballInst:close()
+      S.phase = "drop"
+      S.t = 0
+      -- the roll happens NOW, so the wobble count is honest
+      rollCatch()
+      local p = S.ballInst.pos
+      S.dropFrom = { p[1], p[2], p[3] }
+      -- land a step toward the camera from the foe's feet
+      local fx, fz = camBasis(S.shot)
+      S.dropTo = { S.enemyPos[1] - fx * 5, S.groundY + Pokeball.R,
+                   S.enemyPos[3] - fz * 5 }
+    end
+    return
+  end
+
+  if S.phase == "drop" then
+    S.t = S.t + dt
+    local u = math.min(1, S.t / DROP_T)
+    local p0, p1 = S.dropFrom, S.dropTo
+    -- a dropped arc: linear across, gravity-shaped down, one bounce
+    local h = (1 - u) * (p0[2] - p1[2])
+    local bounce = 0
+    if u > 0.82 then
+      bounce = math.abs(math.sin((u - 0.82) / 0.18 * math.pi)) * 1.2 * (1 - u)
+    end
+    S.ballInst.pos = { p0[1] + (p1[1] - p0[1]) * u,
+                       p1[2] + h * (1 - u * 0.4) + bounce,
+                       p0[3] + (p1[3] - p0[3]) * u }
+    if u >= 1 then
+      S.ballInst.pos = { p1[1], p1[2], p1[3] }
+      S.phase = "wobble"
+      S.t = -0.35                             -- a beat before the first rock
+      S.shakeIx = 0
+      sound("Tink")
+    end
+    return
+  end
+
+  if S.phase == "wobble" then
+    S.t = S.t + dt
+    if S.ballInst:busy() then return end
+    if S.t < SHAKE_GAP then return end
+    if S.shakeIx < S.shakes then
+      S.shakeIx = S.shakeIx + 1
+      S.t = 0
+      S.ballInst:rock(S.shakeIx % 2 == 0 and 1 or -1)
+      sound("Tink")
+    elseif S.caught then
+      S.phase = "clicked"
+      S.t = 0
+      S.ballInst:catchClick()
+    else
+      -- the breakout: the ball bursts, the foe pours back out
+      S.phase = "burst"
+      S.t = 0
+      S.ballInst:burst()
+      sound("Ball_Poof")
+    end
+    return
+  end
+
+  if S.phase == "clicked" then
+    S.t = S.t + dt
+    if S.t >= 0.9 then resolveCaught() end
+    return
+  end
+
+  if S.phase == "burst" then
+    S.t = S.t + dt
+    local k = smoothstep(0, 1, S.t / 0.3)
+    BattleScene.capture.shrink = k >= 0.98 and nil or math.max(0.02, k)
+    if S.t >= 0.55 then
+      BattleScene.capture.shrink = nil
+      S.ballInst.visible = false
+      resolveFail(S.shakes)
+    end
+    return
+  end
+
+  -- epilogue: the ball rests in shot while the caught text plays; the
+  -- stars fade on their own, and battle.ended clears the table
+end
+
+return CatchThrow
diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua
new file mode 100644
index 0000000..e5965a6
--- /dev/null
+++ b/lib/LetsGo.lua
@@ -0,0 +1,404 @@
+-- LET'S GO: the row, the modes, and every engine seam the capture game
+-- stands on.
+--
+-- Three rungs:
+--
+--   OFF         nothing changes. The default, and what an unrecognised
+--               stored value falls back to.
+--   FULL        the whole Let's Go treatment. A wild encounter opens
+--               STRAIGHT into capture mode (B backs out to the classic
+--               menu for anyone who came to fight), Poke/Great/Ultra
+--               Balls are half price at every mart, and EXPERIENCE works
+--               the way that game's does: every healthy party member
+--               gains from every catch AND every trainer knockout, each
+--               measured against its own level. A catch adds the throw
+--               stack on top -- grade, first throw, new species, combo.
+--   CATCH ONLY  the fights are untouched and the shops are untouched;
+--               the one change is that throwing a ball -- from the bag,
+--               or a SAFARI BALL from the safari menu -- runs the throw
+--               minigame instead of the automatic toss. The minigame's
+--               grade still folds into the Gen 1 catch roll (a good
+--               throw should matter or the ring is a lie), but nothing
+--               outside the throw changes.
+--
+-- The capture game itself lives in lib/CatchThrow.lua and the ball it
+-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
+-- the two BattleState wraps that intercept a ball being thrown, the
+-- auto-entry tick for FULL, the price patch, and the experience hooks.
+--
+-- ------- where the minigame declines to run
+--
+-- The throw is a 3D scene: it needs the staged battle standing (the
+-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
+-- default), a driver with a depth buffer, and a flat screen (the VR seat
+-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
+-- switched off, a headless driver, a headset -- the ball quietly takes
+-- the engine's own toss, which is exactly what the mod's "declines
+-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
+-- old man's demo keep the vanilla path on purpose: those branches ARE
+-- their behaviour.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local Voxel3D = V.require("Voxel3D")
+local CatchThrow = V.require("CatchThrow")
+
+local LetsGo = {}
+
+LetsGo.KEY = "letsgo"
+LetsGo.LABEL = "LET'S GO"
+
+-- `false` first: the default, and the fallback for a stored value from
+-- some other version of this ladder
+LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
+                                { false, "full", "catching" },
+                                { "OFF", "FULL", "CATCH ONLY" })
+
+-- false | "full" | "catching"
+function LetsGo.mode()
+  return LetsGo.setting:get()
+end
+
+local function game() return require("src.core.Game") end
+
+-- ------- half-price balls (FULL)
+--
+-- Prices are live data (game.data.items[id].price) and every reader --
+-- the buy list, the affordability check, the quantity box -- reads them
+-- per use, so patching the table IS the feature. Applied and reverted on
+-- the option's edge, polled from the tick because the row, the manager's
+-- page and a loaded save can all move it and none of them announces to
+-- us. The sell price follows automatically (the mart pays half of list),
+-- which is coherent: cheaper balls are worth less back too.
+local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
+local fullPrices = nil     -- originals while halved, or nil
+
+local function applyPrices()
+  local g = game()
+  local items = g and g.data and g.data.items
+  if not items then return end
+  local wantHalf = LetsGo.mode() == "full"
+  if wantHalf and not fullPrices then
+    fullPrices = {}
+    for _, id in ipairs(PRICED) do
+      local def = items[id]
+      if def and def.price then
+        fullPrices[id] = def.price
+        def.price = math.floor(def.price / 2)
+      end
+    end
+  elseif not wantHalf and fullPrices then
+    for id, price in pairs(fullPrices) do
+      if items[id] then items[id].price = price end
+    end
+    fullPrices = nil
+  end
+end
+
+-- ------- whether a throw can be the minigame
+
+local function vrOn()
+  local ok, vr = pcall(V.require, "VR")
+  return ok and vr and vr.enabled and vr.enabled() 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 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
+  local ok, shot = pcall(function()
+    return V.require("OverworldBattle").shot()
+  end)
+  return (ok and shot) and true or false
+end
+
+-- A Let's Go wild: the encounters FULL owns outright. In these the foe
+-- never takes a turn, the player's Pokemon is never sent out or shown,
+-- B runs (and always escapes), and the encounter lives in throw mode
+-- 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 true or false
+end
+
+-- ------- the experience stack (FULL)
+--
+-- Let's Go pays a catch like a knockout, through the Gen VII scaled
+-- formula -- every party member paid against its OWN level -- times the
+-- catch bonuses. Three engine hooks carry it:
+--
+--   battle.catch_exp    "does a catch pay at all" -- yes, under FULL
+--   battle.exp_award    the distribution: every healthy party member its
+--                       own full share, no participant split
+--   exp.gain            the amount: the scaled formula times the bonus
+--                       stack, in place of floor(b*L/7)
+--
+-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
+-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
+-- combo tier. Traded 1.5 still rides through the engine's own flag.
+local expCtx = nil        -- {battle, mult} while a Let's Go catch pays out
+local granting = nil      -- set across the applyShare loop for exp.gain
+
+local function comboMult(n)
+  if n <= 10 then return 1.1 end
+  if n <= 20 then return 1.5 end
+  if n <= 30 then return 2.0 end
+  if n <= 40 then return 2.5 end
+  return 3.0
+end
+
+-- the catch combo, persisted with the save (mod.save rides save.modData):
+-- catching the same species again extends it, anything else restarts it
+local function bumpCombo(species)
+  local ms = V.mod and V.mod.save
+  local combo = { species = species, count = 1 }
+  if ms then
+    local ok, held = pcall(ms.get, ms, "letsgoCombo")
+    if ok and type(held) == "table" and held.species == species then
+      combo.count = (tonumber(held.count) or 0) + 1
+    end
+    pcall(ms.set, ms, "letsgoCombo", combo)
+  end
+  return combo.count
+end
+
+function LetsGo.combo()
+  local ms = V.mod and V.mod.save
+  if not ms then return nil end
+  local ok, held = pcall(ms.get, ms, "letsgoCombo")
+  return ok and type(held) == "table" and held or nil
+end
+
+-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
+-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
+-- still answerable, and the exp hooks fire inside storeCaughtMon.
+function LetsGo.noteCatch(battle, info)
+  local species = battle.enemy and battle.enemy.mon
+                  and battle.enemy.mon.species
+  local chain = species and bumpCombo(species) or 1
+  if LetsGo.mode() ~= "full" then return end
+  local mult = info.mult or 1
+  if info.firstThrow then mult = mult * 1.5 end
+  local dex = game().save and game().save.pokedex
+  if dex and species and not dex.owned[species] then mult = mult * 1.1 end
+  mult = mult * comboMult(chain)
+  expCtx = { battle = battle, mult = mult }
+end
+
+-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
+-- level, +1, then the traded boost and (for a catch) the bonus stack.
+-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
+-- full share rather than a share of one.
+--
+-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
+-- absent from the catch-side write-ups of this formula for the simple
+-- reason that a caught Pokemon is always wild, so it is always 1 there --
+-- which is also why adding it leaves every catch payout exactly where it
+-- was, verified against the published table in the suite.
+local function scaledGain(c, mult)
+  local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
+  local L = c.level or 1
+  local Lp = (c.mon and c.mon.level) or L
+  local a = c.isTrainer and 1.5 or 1
+  local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
+  local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
+  if c.traded then exp = math.floor(exp * 1.5) end
+  return math.max(1, math.floor(exp * (mult or 1)))
+end
+
+LetsGo._scaledGain = scaledGain   -- named for the suite
+LetsGo._comboMult = comboMult
+
+-- ------- FULL's auto-entry
+--
+-- The moment a wild battle's menu opens under FULL, capture mode opens
+-- over it, with the last ball the player threw (or the first ball in the
+-- bag). B backs out to the classic menu and stays out for that battle --
+-- the bag's own ball route still re-enters the throw.
+local function autoEnter()
+  if LetsGo.mode() ~= "full" then return end
+  if CatchThrow.active() then return end
+  local ok, battle = pcall(function()
+    return V.require("OverworldBattle").battle()
+  end)
+  if not (ok and battle) then return end
+  local g = game()
+  if not (g.stack and g.stack:top() == battle) then return end
+  if battle.phase ~= "menu" then return end
+  if battle.safari then return end      -- the safari menu is already a
+                                        -- catch menu; its BALL row enters
+  if battle.dramaticShapeDeclined then return end
+  if not LetsGo.wantsMinigame(battle) then return end
+  local ball = CatchThrow.pickBall()
+  if not ball then return end
+  CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
+                                   fullWild = LetsGo.fullWild(battle) })
+end
+
+-- ------- per frame, from the voxel pipeline's update hook
+--
+-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
+-- frame computes is the ball the scene render a moment later draws.
+function LetsGo.update(dt)
+  applyPrices()
+  CatchThrow.update(dt)
+  autoEnter()
+end
+
+-- ------- install: the two throw seams, the hooks, the input
+--
+-- Installed from main.lua AFTER every other input seam, so the capture's
+-- pointer wraps sit outside them all while it aims.
+local installed = false
+
+function LetsGo.install()
+  if installed then return end
+  installed = true
+
+  local mod = V.mod
+  local BattleState = require("src.battle.BattleState")
+
+  if not BattleState.dramaticShapeLetsGoHook then
+    -- The bag's ball route: BagMenu has already consumed the ball and
+    -- closed itself when this is called, so a session here owns a paid
+    -- ball (cancel refunds it). Vanilla path untouched whenever the
+    -- minigame cannot or should not run.
+    local innerThrow = BattleState.throwBall
+    function BattleState:throwBall(ball)
+      if LetsGo.wantsMinigame(self) then
+        if CatchThrow.begin(self, ball, {
+             consumed = true, fullWild = LetsGo.fullWild(self),
+           }) then return end
+      end
+      return innerThrow(self, ball)
+    end
+
+    -- The safari menu's BALL row: same interception, safari flavour --
+    -- the ball count and the flee check belong to the safari turn, and
+    -- CatchThrow hands back to safariEnemyTurn on a failure.
+    local innerSafari = BattleState.safariAction
+    function BattleState:safariAction(choice)
+      if choice == "ball" and LetsGo.wantsMinigame(self)
+         and self.safari and self.safari.balls > 0 then
+        if CatchThrow.begin(self, "SAFARI_BALL",
+                            { consumed = false, safari = true }) then
+          return
+        end
+      end
+      return innerSafari(self, choice)
+    end
+
+    BattleState.dramaticShapeLetsGoHook = true
+  end
+
+  -- a catch pays experience under FULL, exactly as a knockout would
+  mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
+    if LetsGo.mode() == "full" and ctx and ctx.battle
+       and ctx.battle.kind == "wild" then
+      return true
+    end
+    return next_(ctx)
+  end)
+
+  -- ------- the Let's Go distribution: every healthy party member, in full
+  --
+  -- The engine's own rule is that only the Pokemon that FOUGHT are paid,
+  -- and they split one award between them; EXP.ALL exists to soften that.
+  -- Let's Go deletes the whole arrangement -- everybody gains from
+  -- everything, which is why that game ships no EXP.ALL at all -- and
+  -- each one is measured against its OWN level, so the low member of a
+  -- party pulls several times what the high one does from the same
+  -- knockout.
+  --
+  -- Two ways in. A CATCH arrives with a bonus stack attached (throw
+  -- grade, first ball, new species, combo) which `expCtx` carries. A
+  -- KNOCKOUT under FULL takes the same distribution with no stack --
+  -- those bonuses are rewards for the throw, and there was no throw.
+  --
+  -- Everything else -- CATCH ONLY, the row switched off, another mod's
+  -- battle -- falls through to the engine's own split untouched.
+  local function payParty(ctx, mult)
+    granting = { mult = mult }
+    local okAward, err = pcall(function()
+      for _, mon in ipairs(ctx.battle.game.save.party) do
+        if mon.hp > 0 then ctx.applyShare(mon, 1, true) end
+      end
+    end)
+    granting = nil
+    if not okAward then error(err, 0) end
+  end
+
+  mod.hooks:wrap("battle.exp_award", function(next_, ctx)
+    local cc = expCtx
+    if cc and ctx and ctx.battle == cc.battle then
+      expCtx = nil
+      return payParty(ctx, cc.mult)
+    end
+    if LetsGo.mode() == "full" and ctx and ctx.battle then
+      return payParty(ctx, 1)
+    end
+    return next_(ctx)
+  end)
+
+  -- and the amount, per receiving mon, while that loop runs
+  mod.hooks:wrap("exp.gain", function(next_, c)
+    if not granting then return next_(c) end
+    return scaledGain(c, granting.mult)
+  end)
+
+  -- ------- FULL owns a wild encounter from its first frame
+  --
+  -- The engine's intro ends by sending the player's Pokemon out -- the
+  -- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
+  -- Go wild has no player Pokemon in it at all. The send-out is exactly
+  -- the LAST SIX rows of the intro queue when this event fires (built in
+  -- BattleState's start, gated `not safari and not demo`), so they are
+  -- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
+  -- if a future engine moves them: the veil still hides the visuals, the
+  -- engine just narrates a send-out that is not shown.
+  --
+  -- Stripping them leaves showPlayerBack TRUE for the whole battle, which
+  -- is the flag the engine's own HUD path reads as "no player HUD" -- the
+  -- player's side vanishes from the readout for free.
+  --
+  -- The veil goes up in the same breath: the capture table, installed
+  -- before any session exists, so the whole encounter -- wipe, "Wild X
+  -- appeared!", every beat between throws -- plays from the held head-on
+  -- seat with the player's side out of the shot.
+  mod.events:on("battle.started", function(payload)
+    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
+    local q = b.queue
+    local n = q and #q or 0
+    if n >= 6 and type(q[n]) == "table" and q[n].fn
+       and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
+       and q[n - 2] and q[n - 2].text
+       and q[n - 3] and q[n - 3].fn
+       and q[n - 4] and q[n - 4].wait
+       and q[n - 5] and q[n - 5].fn then
+      for _ = 1, 6 do table.remove(q) end
+    end
+    pcall(CatchThrow.veil, b)
+  end)
+
+  -- a battle ending sweeps everything: the capture epilogue, the veil, a
+  -- session a script tore down, and the exp context if the payout never
+  -- fired
+  mod.events:on("battle.ended", function()
+    expCtx = nil
+    pcall(CatchThrow.onBattleEnded)
+  end)
+
+  CatchThrow.installInput()
+end
+
+return LetsGo
diff --git a/lib/Mat4.lua b/lib/Mat4.lua
index fe0c72e..d2486d9 100644
--- a/lib/Mat4.lua
+++ b/lib/Mat4.lua
@@ -64,6 +64,14 @@ function Mat4.rotateX(a)
            0, 0, 0, 1 }
 end
 
+function Mat4.rotateZ(a)
+  local c, s = math.cos(a), math.sin(a)
+  return { c, -s, 0, 0,
+           s, c, 0, 0,
+           0, 0, 1, 0,
+           0, 0, 0, 1 }
+end
+
 -- The rotation a unit quaternion describes, row-major. The VR rig is what
 -- needs it: an OpenXR eye pose arrives as position + orientation
 -- quaternion, and both the eye's transform and its inverse (the view) are
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index 7d167df..a496760 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -1,1527 +1,1582 @@
--- Overworld battles: fights that happen on the map you were standing on.

---

--- The engine's battle is a screen: a white field with two pics on it, pushed

--- over a frozen overworld that stops drawing. This turns that white field

--- into the world -- the same terrain the free-roam mode extrudes, shot from

--- a placed over-the-shoulder camera at a clear patch of ground nearby --

--- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move

--- animation, faint slide and text box is the engine's own, drawn in the

--- engine's own order. What changes is what is behind them, and where the two

--- pics stand.

---

--- The sequence, from the moment something picks a fight:

---

---   1. the overworld cast is culled -- every NPC vanishes, so the wipe

---      plays over an empty map and no bystander is left standing in the

---      arena shot

---   2. the engine's own transition wipes the screen (untouched: it is the

---      right wipe, picked by the right three bits)

---   3. the battle draws over a live, window-resolution render of the arena,

---      with each mon PINNED to the cell it is standing on, the camera

---      drifting slowly enough to read as parallax, and a depth-of-field pass

---      holding the slab of world the two of them occupy sharp

---   4. the battle ends, the cast comes back, and the player is exactly

---      where they were standing

---

--- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the

--- player goes: nothing here writes a cell, a facing, a flag or a warp. A

--- real warp would have to survive trainer sight-lines, post-battle

--- dialogue, the blackout path and every script that assumes the player is

--- where it left them -- and it would have to put them back afterwards.

--- Moving the camera buys the whole shot and owes nothing back.

---

--- The feature declines cleanly rather than half-working: no depth support,

--- no open ground on the map, the row switched off, or a mesh still building

--- all end at the same place, which is the battle screen the engine has

--- always drawn.

-

--- the mod namespace (see main.lua): V.require loads a sibling module

-local V = ...

-

-local ModSetting = V.require("ModSetting")

-local BattleArena = V.require("BattleArena")

-local BattleCam = V.require("BattleCam")

-local BattleScene = V.require("BattleScene")

-local BattleDOF = V.require("BattleDOF")

-local BattleHud = V.require("BattleHud")

-local BattlePics = V.require("BattlePics")

-local Voxel3D = V.require("Voxel3D")

-local ChunkMesher = V.require("ChunkMesher")

-

-local OverworldBattle = {}

-

--- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a

--- second, which is how the glyph flip is checked from a shot run. Read

--- through pcall: the loader's sandbox does not hand a mod `os`, and a

--- diagnostic must never be the reason the mod fails to load.

-local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))

-if DEBUG == nil or DEBUG == false then DEBUG = nil end

-

-OverworldBattle.KEY = "battles"

-OverworldBattle.LABEL = "3D-BTL"

-

--- Five rungs. Two independent choices, laid out as one ladder because they

--- are one question to the player -- WHAT is standing there, and WHERE:

---

---              on the MAP              on two DISCS

---   pics       2D-3D A                 2D-3D B

---   models     STADIUM A               STADIUM B

---

---   2D-3D A    the mode this file was written for: the fight is staged on

---              the map and the two Pokemon are the GB's OWN PICS, stood up

---              on their tiles as quads (BattleBillboard).

---   2D-3D B    those same pics on a pair of DISCS against the sky, with no

---              map at all (see lib/StadiumStage.lua). The Game Boy's own

---              framing with the Game Boy's own art, in three dimensions --

---              and, like every B rung, it works everywhere, including the

---              caves and shop floors that have nowhere to stage a fight.

---   STADIUM A  the staged fight with the Pokemon Stadium battle models in

---              place of those quads -- skinned, animated, and playing the

---              animation the move being used actually calls for (see

---              lib/Stadium.lua). The world is still the world: the fight

---              happens on real ground, in the map's own weather and light.

---   STADIUM B  the models on the discs: both halves swapped at once.

---   OFF        the engine's own white battle screen.

---

--- A and B is the STAGE and it is the same stage either way -- the discs do

--- not know what is standing on them and BattleScene draws them off

--- `arena.discs` alone, which is why the second column cost a value in this

--- table and nothing else. The four combinations are all reachable rather

--- than only the diagonal, because a player who cannot use the STADIUM rungs

--- -- no ROM, or a ROM they would rather not go and find -- should still be

--- able to have the disc framing, and because the discs are the answer to

--- "this map has nowhere to fight" whichever art is standing on them.

---

--- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and

--- what an unrecognised stored value falls back to, and the stored value for

--- this row has been `true` since the row existed. Keeping `true` at the head

--- means every save written before the later rungs existed reads back as the

--- 2D-3D it was written for, and a mod whose headline is "the world in 3D"

--- still does not need the player to go and find the switch.

---

--- Every other stored value is likewise the one it has always been --

--- "stadium" from before there was a B, "stadiumB" from before there was a

--- flat one -- so no save loses the mode it chose.

---

--- Both STADIUM rungs are GATED on the models existing: the mod ships no

--- Pokemon Stadium data, and until the player's own ROM has been found and

--- built from (StadiumInstall) the row simply has two fewer stops. See

--- ModSetting.setGate for why they are skipped rather than shown and refused.

--- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are

--- the game's own art, so it needs nothing the base game did not ship.

-OverworldBattle.FLAT_B = "flatB"

-

-OverworldBattle.setting =

-  ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,

-                 { true, "flatB", "stadium", "stadiumB", false },

-                 { "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" })

-  :setGate(function(value)

-    if value ~= "stadium" and value ~= "stadiumB" then return true end

-    local ok, install = pcall(V.require, "StadiumInstall")

-    return ok and install and install.available()

-  end)

-

--- Whether the fight stands on the two carried DISCS rather than on the map

--- -- the B column above, whichever row of it. Asked by stageFor (what to

--- stage on), wantsFront (whether this map needs an arena at all) and, once

--- the arena carries the answer as `arena.discs`, by BattleScene and

--- VoxelScene for what to draw.

---

--- Read straight off the row rather than through Stadium, because it is a

--- question about the STAGE and half the rungs that answer yes have no

--- Stadium models on them at all.

-function OverworldBattle.discs()

-  local value = OverworldBattle.setting:get()

-  return (value == OverworldBattle.FLAT_B or value == "stadiumB")

-end

-

--- Whether the VR row is ON -- read lazily, because VR requires modules

--- that sit above this one. While it is, this mode stops being optional:

--- the headset's battle seat, the pokedex screen and the effects plane

--- all assume a fight standing on the world, and a white-field battle

--- inside a headset is exactly the flat screen VR exists to replace.

-local function vrOn()

-  local ok, vr = pcall(V.require, "VR")

-  return ok and vr and vr.enabled and vr.enabled() or false

-end

-

-function OverworldBattle.enabled()

-  if vrOn() then return true end

-  return OverworldBattle.setting:get() and true or false

-end

-

--- Whether the STADIUM rung is the one selected -- read through Stadium so

--- there is one answer to that question and it lives with the mode it

--- describes. Required lazily: Stadium sits above this file and requires it

--- back (for the row), which a load-time require would deadlock.

-function OverworldBattle.stadium()

-  local ok, stadium = pcall(V.require, "Stadium")

-  return (ok and stadium and stadium.enabled()) and true or false

-end

-

--- ------- BACK SPRITES: the player's own mon stays on the menu

---

--- The staged shot stands BOTH mons on the map, which is the mode's whole

--- claim -- but it costs the one piece of framing Gen 1 is most recognisable

--- by: your own Pokemon, seen from behind, sitting on top of the battle menu

--- with its feet on the box. That silhouette is the series' shot.

---

--- So BACK SPRITES is offered as a middle setting rather than a compromise

--- imposed on everyone. With it on the foe is still geometry standing on its

--- tile at the far end of the arena, and the player's side goes back to being

--- the GB's own flat back pic in the GB's own slot: same art, same 2x, same

--- feet on row 96.

--- Nothing else about the shot moves -- the arena, the camera and the drift are

--- solved exactly as they were, so the foe stands where it always stood and the

--- player's cell is simply empty ground in the foreground.

---

--- OFF by default: what the mode advertises is the pair of them out there.

-OverworldBattle.BACK_KEY = "battleBack"

-OverworldBattle.BACK_LABEL = "BACK SPRITES"

-

-OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,

-                                             OverworldBattle.BACK_LABEL,

-                                             { false, true }, { "OFF", "ON" })

-

--- Gated on 3D-BTL rather than read alone: with staged battles off there is no

--- staged shot for a back pic to be pinned in FRONT of, and the engine's own

--- battle screen already draws exactly this. And held OFF under VR: the

--- headset stands both mons on the world -- a flat back pic pinned to the

--- 2D frame would keep your own mon off the arena the battle seat looks at.

-function OverworldBattle.backPinned()

-  if not OverworldBattle.enabled() then return false end

-  if vrOn() then return false end

-  return OverworldBattle.backSetting:get() and true or false

-end

-

--- Whether a pic is the one drawn in the GB's own slot with its feet on the

--- text box, rather than geometry standing out on the map.

---

--- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back

--- that holds the slot until "Go!" -- because that is the only pic this mod

--- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its

--- tile whichever mode is on, and with the mode off the player's side is one

--- too, so both of those keep the open bottom that lets the arena through a

--- stride. What the answer buys is in BattlePics: a pic on the box has nothing

--- behind its lowest row, so its bottom edge seals.

--- Read by TRUTHINESS rather than against nil, because sideTexture blanks the

--- side it is not rendering by setting the field to FALSE (see OFF) and holds

--- it that way for the whole render -- during which the pic layer runs, and

--- picImage asks this. A nil test passes a `false` straight through to the

--- index below, and the error comes out of sideTexture into the pcall that

--- calls it: the foe's billboard is dropped for the frame and the Pokemon

--- simply is not there.

-function OverworldBattle.pinnedPic(battle, img)

-  if not (battle and img) then return false end

-  if not OverworldBattle.backPinned() then return false end

-  if img == battle.playerBackPic then return true end

-  local player = battle.player

-  return (player and img == player.sprite) and true or false

-end

-

--- ------- both mons face you

---

--- Standing on a map, seen from in front, a Pokemon showing you its BACK is

--- wrong twice over: it is turned away from the camera that is looking at it,

--- and the back pics are a different, smaller drawing made for a slot the

--- player never really sees. So the player's side asks for the FRONT pic too,

--- through the engine's own pokemon.sprite hook -- the seam that exists for

--- exactly this, so no battle code has to be touched to get it.

---

--- Unless BACK SPRITES is on, the setting that asks for the back pic back:

--- that mon is drawn in its own slot on the menu, seen from behind, and the

--- front art would be it turned round to face the player it belongs to.

---

--- Answered BEFORE a battle exists, because the battler is built before the

--- battle is pushed. So it cannot ask whether this fight is staged; it asks

--- whether one on this map WOULD be -- the row is on, the 3D pass is

--- available, and the map has an arena -- which is the same question with the

--- same answer a moment later. Cached per map, because the arena search walks

--- the whole grid and this runs once per battler.

-local staged = { mapId = nil, ok = false }

-

-function OverworldBattle.wantsFront()

-  if not OverworldBattle.enabled() then return false end

-  if OverworldBattle.backPinned() then return false end

-  if not Voxel3D.available() then return false end

-  -- required here rather than through the file's own helper: this runs

-  -- while a battler is being built, which is before that helper is defined

-  local g = require("src.core.Game")

-  local ow = g and g.overworld

-  if not (ow and ow.map and ow.player) then return false end

-  -- a B rung carries its own stage, so the answer is yes on every map and

-  -- there is nothing to search or to cache

-  if OverworldBattle.discs() then return true end

-  if staged.mapId ~= ow.map.id then

-    local ok, arena = pcall(BattleArena.find, ow.map,

-                            ow.player.cellX, ow.player.cellY,

-                            ow.player.surfing)

-    staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }

-  end

-  return staged.ok

-end

-

--- ------- where the engine's own pics stand

---

--- The GB draws the player's back pic with its feet on the text box at row 96

--- and its 7x7-tile slot centred on x=40, and the enemy's front pic

--- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two

--- points are the pics' FEET, they hold for every species at every scale (the

--- engine's placement helpers pin the bottom edge and the centre), and they

--- are what BattleCam is solved to put the two arena cells under.

---

--- Which makes the pin a subtraction: whatever the drift has done to the

--- camera this frame, each pic moves by its own cell's projected position

--- minus its anchor. At the middle of the drift that is zero.

-OverworldBattle.ANCHOR = {

-  player = { 26, 96 },

-  enemy = { 124, 56 },

-}

-

--- ------- how big a mon is

---

--- Not a decision made here. A pic is drawn at its own integer scale -- 1x for

--- a 56px front pic, 2x for a 32px back one -- because that is the only way it

--- keeps every pixel the artist drew, and the CAMERA is solved so that one

--- overworld square is that big on screen (see BattleCam). The mon fits its

--- tile because the tile was sized to the mon, not the other way round.

-OverworldBattle.SLOT_W = { front = 56, back = 32 }

-

--- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and

--- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at

--- the anchors above.

-OverworldBattle.HUD_RECT = {

-  enemy = { 8, 0, 80, 32 },

-  player = { 72, 56, 88, 40 },

-}

-

--- ------- the box at the bottom, on the same glass

---

--- The HUDs got frosted panels because black glyphs on grass are not readable.

--- The battle's text box and its menu had the opposite problem and the same

--- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was

--- the field's own colour when the field was white and is a sheet of paper laid

--- over the bottom third of the diorama now that it is not.

---

--- So the box gets exactly what the HUDs get: the world behind it, blurred to

--- frosted glass and laid back down translucent, with the border and the text

--- drawn over it unchanged, and the same brightness verdict flipping the ink

--- when the ground under it is dark. Only the FILL is taken away -- every glyph

--- the engine draws inside the box is still the engine's own, in its own place.

---

--- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.

--- READ-ONLY duplicates of that function's own branches, the same kind of

--- mirror hudLive is and for the same reason: there is no seam that reports "a

--- move menu is up", and glass has to go down BEFORE the box that sits on it.

--- The worst a future engine change can do is frost a rectangle nothing lands

--- on, or leave a box unfrosted -- never break a battle.

---

--- Each rect stops where the next one starts rather than overlapping it: two

--- panels over the same pixels would frost it twice and leave a visible step

--- along the seam.

-OverworldBattle.TEXT_RECT = {

-  box = { 0, 96, 160, 48 },       -- Font.drawBox(0, 12, 20, 6), always

-  -- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows

-  -- above the box above -- its last tile row sits inside that one

-  moves = { 0, 64, 88, 32 },

-  -- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way

-  mimic = { 0, 56, 128, 40 },

-}

-

--- How far apart the two anchors are: the spacing every move animation was

--- authored against, and so the yardstick the live pair is measured with.

-OverworldBattle.ANCHOR_SPAN = math.sqrt(

-  (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2

-  + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2)

-

--- The effects layer's scale for this shot: how far apart the two mons

--- actually are on screen, over how far apart the slots they were authored

--- for were. Clamped hard at both ends -- an effect is pixel art and a wild

--- factor is worse than a slightly wrong one -- and held at exactly 1 when

--- the marks coincide, which is a projection about to degenerate rather

--- than a pair that has genuinely closed up.

-OverworldBattle.ANIM_SCALE_MIN = 0.5

-OverworldBattle.ANIM_SCALE_MAX = 2.0

-

-function OverworldBattle.animScale(shot, px, py)

-  if not (shot and shot.enemy and px and py) then return 1 end

-  local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py

-  local span = math.sqrt(dx * dx + dy * dy)

-  if not (span > 1) then return 1 end

-  local k = span / OverworldBattle.ANCHOR_SPAN

-  return math.max(OverworldBattle.ANIM_SCALE_MIN,

-                  math.min(OverworldBattle.ANIM_SCALE_MAX, k))

-end

-

-function OverworldBattle.textRects(battle)

-  if not battle or battle.blankForAskName then return {} end

-  local r = OverworldBattle.TEXT_RECT

-  local out = { box = r.box }

-  if battle.phase == "moveSelect" then

-    out.moves = r.moves

-  elseif battle.phase == "mimicSelect" then

-    out.mimic = r.mimic

-  end

-  return out

-end

-

--- ------- the HUDs, out at the window's own edges

---

--- The battle screen is 160x144 in the MIDDLE of the window and the world is the

--- whole of it. That left both HUD blocks huddled together in the middle of the

--- frame with map showing on either side of them, which reads as a Game Boy

--- screenshot pasted over a diorama rather than as the diorama's own furniture.

---

--- So each block is snapped to its own side: the foe's to the left edge of the

--- window, the player's to the right. Nothing about either block changes -- same

--- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar

--- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape

--- of the GB screen there is nowhere to go and the snap is a no-op.

---

--- They cannot simply be MOVED there: the engine draws them into the 160x144 UI

--- canvas and everything outside it is clipped away. So the layer is rendered to

--- a texture and composited into the WORLD image instead, which is the one

--- surface in this mode that covers the whole window.

-

--- The rows each block is cut out of, full width. Generous on purpose:

--- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches

--- further than the panel does, and the pokeball rows and the safari ball count

--- belong to the block whose rows they sit in. Nothing drawHUDs draws lies

--- outside rows 0-96, and the two bands split that between them.

-OverworldBattle.HUD_BAND = {

-  enemy = { 0, 0, 160, 48 },

-  player = { 0, 48, 160, 48 },

-}

-

--- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted

--- glass is cut to, plus the x its band is blitted at.

---

--- The foe's panel starts at the window's left edge and the player's ends at the

--- right one. The vertical is untouched, so both stay on the rows the GB put

--- them on. A band's own origin sits outside the window by the panel's inset --

--- the couple of pixels a HUD shake can push past the edge are clipped there,

--- which is the whole cost of the snap and is invisible.

-function OverworldBattle.snapRects(shot)

-  local s = shot.scale

-  local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player

-  local ex = -e[1] * s                       -- foe: panel's left edge to 0

-  local px = shot.pw - (p[1] + p[3]) * s     -- player: right edge to the far side

-  local rects = {

-    enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },

-    player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },

-  }

-  return rects, { enemy = ex, player = px }

-end

-

--- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox

--- blit will actually put it. The text box has not moved anywhere -- it is drawn

--- where it always was -- but its glass is laid into the world image alongside

--- the HUDs' (see snapHUDs), which is the surface that reaches the screen a

--- pixel to a pixel rather than magnified out of a 160x144 canvas.

-local function toWorld(rect, shot)

-  local s = shot.scale

-  return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,

-           rect[3] * s, rect[4] * s }

-end

-

--- ------- the live battle

---

--- nil when no overworld battle is running. Never more than one: battles do

--- not nest.

-local session = nil

-

-local function isIOS()

-  return love.system and love.system.getOS and love.system.getOS() == "iOS"

-end

-

-local function game()

-  return require("src.core.Game")

-end

-

--- Whether this frame's HUDs went out to the window's edges instead of being

--- drawn in the GB frame. False whenever the composite could not be made, which

--- is what leaves the in-frame HUD as the fallback rather than no HUD at all.

-local function snapped()

-  return (session and session.snapped) and true or false

-end

-

--- Put the map's cast back. Both lists are handed back by identity, so

--- anything that captured one before the battle still sees the same table.

-local function restoreCast()

-  if not (session and session.state) then return end

-  if session.entities then session.state.entities = session.entities end

-  if session.ghosts then session.state.ghosts = session.ghosts end

-  session.entities, session.ghosts = nil, nil

-end

-

--- Cull them. The player stays -- they are not an NPC, they are who the

--- battle belongs to, and Fly/surf animations and the save's own capture read

--- state.player through this list.

---

--- Only the DRAW lists are touched, and only while the overworld is frozen

--- underneath a battle: StateStack updates the top state alone, so nothing

--- walks, wanders, triggers or collides against a list that is short for

--- these frames. The originals go back at battle.ended.

-local function cullCast(state)

-  session.entities = state.entities

-  session.ghosts = state.ghosts

-  state.entities = { state.player }

-  state.ghosts = {}

-end

-

--- ------- one right battle layout

---

--- Everything this file composes is measured in the GB's own 160x144 frame: the

--- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs

--- the frosted panels are cut to, and the full-frame white intercepted to let

--- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a

--- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those

--- -- the mons would stand where no camera was solved for them, and the panels

--- would land beside the HUDs they are supposed to be under.

---

--- So while a fight can be staged on the map there is one right answer, and it

--- is SET rather than worked around. The engine reads the option live

--- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the

--- top state for its surface the same way), so writing it here lands on the

--- battle being pushed as well as every one after it.

---

--- This is the last line rather than the first: the OPTIONS menu takes the row

--- off the list and pins the value while 3D-BTL is on (see main.lua), so a

--- player is never offered a switch that gets reverted under them. What reaches

--- here is a value that arrived some other way -- a save written before the mod

--- was installed, the mod manager's own page, another mod.

-function OverworldBattle.forceOG(g)

-  g = g or game()

-  local opts = g and g.save and g.save.options

-  if not opts or opts.battleLayout ~= "wide" then return false end

-  opts.battleLayout = "og"

-  if g.writeOptions then pcall(g.writeOptions, g) end

-  return true

-end

-

--- Where THIS fight stands, on whichever rung is running: the map's own

--- ground, or the pair of discs a B rung carries with it.

---

--- The one place the two columns actually diverge, and it is worth stating

--- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a

--- map whose authored entry is a refusal -- and the battle then plays exactly

--- as the vanilla game does. A B rung cannot fail: its stage is not something

--- the map has to have room for, so a fight in the tightest cave in Kanto is

--- staged as readily as one on Route 1.

-function OverworldBattle.stageFor(state)

-  if OverworldBattle.discs() and Voxel3D.available() then

-    local okStage, arena = pcall(function()

-      return V.require("StadiumStage").arena(state.map)

-    end)

-    if okStage and arena then return arena end

-    -- the discs could not be built; fall through to the map, which is a

-    -- worse picture but a real one

-  end

-  local okFind, arena = pcall(BattleArena.find, state.map,

-                              state.player.cellX, state.player.cellY,

-                              state.player.surfing)

-  return (okFind and arena) or nil

-end

-

--- Stage a battle triggered from `state`, if this mode can. Returns true when

--- a session started -- which is also the only case where anything visible

--- changes, so a map with no room for an arena plays exactly the vanilla

--- battle it always did, cast and all.

-function OverworldBattle.begin(state, battle)

-  OverworldBattle.finish()

-  if not OverworldBattle.enabled() then return false end

-  if not (state and state.map and state.player) then return false end

-  if not Voxel3D.available() then return false end

-

-  local arena = OverworldBattle.stageFor(state)

-  if not arena then return false end

-

-  -- the fight is staged from here on, so the layout it is composed for is not

-  -- optional any more (see forceOG)

-  OverworldBattle.forceOG()

-

-  session = { state = state, arena = arena, battle = battle, shot = nil,

-              armed = false, token = 0 }

-  cullCast(state)

-  BattleCam.reset()

-  -- and, on the STADIUM rung, the pair of models that will stand on this

-  -- arena's two cells. Declines quietly on any other rung.

-  pcall(function() V.require("Stadium").begin(arena) end)

-  return true

-end

-

--- The fallback entry point: a battle that arrived without going through the

--- overworld's own pushBattle (a link battle, a script pushing a BattleState

--- directly). Nothing visible depends on the cull for those -- the wipe has

--- already been and gone -- but the arena still has to be picked.

-function OverworldBattle.ensure(battle)

-  if session then

-    -- a battle pushed through the overworld reaches begin() before it is

-    -- built far enough to draw; battle.started is where it is finished

-    if battle and not session.battle then session.battle = battle end

-    return

-  end

-  local g = game()

-  local ow = g and g.overworld

-  if ow and ow.map then OverworldBattle.begin(ow, battle) end

-end

-

--- The arena this battle is staged on, or nil. Read by the shot driver so a

--- screenshot can be labelled with the ground it was taken on.

-function OverworldBattle.arena()

-  return session and session.arena or nil

-end

-

-function OverworldBattle.finish()

-  if not session then return end

-  restoreCast()

-  session = nil

-  Voxel3D.camera = nil

-  pcall(function() V.require("Stadium").finish() end)

-end

-

--- ------- per-frame

---

--- Driven from the voxel pipeline's update hook, which the engine ticks every

--- frame regardless of which state is on top -- including the frames the

--- transition wipe covers, which is what gets the arena's meshes built before

--- the first battle frame needs them.

---

--- The scene is rendered HERE rather than inside the battle's draw, because

--- update runs with no canvas bound: a 3D pass that binds a depth target and

--- unbinds to the screen when it is done cannot do that in the middle of

--- someone else's frame without putting the frame back itself.

-function OverworldBattle.update(dt)

-  if not session then return end

-

-  local g = game()

-  local top = g and g.stack and g.stack:top()

-  local ow = g and g.overworld

-  -- A battle that ended without saying so (a script tearing the state down,

-  -- a path that never emits battle.ended) would otherwise leave the cast

-  -- culled for good. Armed only once something has actually covered the

-  -- overworld, because begin() runs while the overworld is still on top.

-  if top ~= nil and top ~= ow then

-    session.armed = true

-  elseif session.armed then

-    OverworldBattle.finish()

-    return

-  end

-

-  -- Whether the shot is the player's to steer at all. BACK SPRITES pins

-  -- their own mon to the GB's slot on the menu while the foe stands out on

-  -- the map, and there is no angle that half-framed, half-solid

-  -- composition survives -- so under it the camera holds the shot the rig

-  -- was solved for (the slow drift aside, which was always there). Polled

-  -- per frame rather than latched at battle start: the row is reachable

-  -- from the mod manager's page mid-session.

-  BattleCam.steerable = not OverworldBattle.backPinned()

-  -- the right stick, read as a rate before the rig is built from it: the

-  -- wheel, the keys, the mouse and a drag all arrive as events and have

-  -- already landed, but a stick is a HELD position and only a tick can

-  -- turn it into travel (CamControl, which owns every one of those inputs)

-  pcall(V.require("CamControl").tick, dt)

-  BattleCam.update(dt)

-  -- the battle only exists once it has been pushed; a session opened at

-  -- pushBattle time has it, one opened from battle.started was handed it

-  session.battle = session.battle or (top ~= ow and top or nil)

-  -- the world pass is hidden behind the battle, so mesh builds get the wide

-  -- slice: nothing visible can hitch on them

-  ChunkMesher.pump(true)

-

-  -- The STADIUM models, ahead of the pics, because what they decide is

-  -- WHICH pics are needed: a side a model is standing on gets no billboard

-  -- texture rendered for it at all (see Stadium.covers). Posed and skinned

-  -- here too, once for the frame -- the sun pass, the camera and, in a

-  -- headset, both eyes all draw the same skinned meshes.

-  pcall(function()

-    local host = (session.arena and session.arena.map) or session.state.map

-    V.require("Stadium").update(dt, session.battle,

-                                BattleScene.groundY(host, session.arena))

-  end)

-

-  -- The mons' textures are rendered HERE, with no canvas bound, for the same

-  -- reason the scene is: the pics layer binds its own targets, and doing that

-  -- inside somebody else's frame means putting the frame back afterwards.

-  local okTex, textures = pcall(OverworldBattle.textures, session.battle)

-  if not okTex then textures = nil end

-  -- stashed for the VR eye pass, which stands these same pics on the map

-  -- in ITS view of the world (VoxelScene's eyes path). Stashed HERE

-  -- because rendering them binds canvases, which the eye pass -- mid-scene

-  -- when it wants them -- must never do; reading a stashed canvas is free.

-  session.textures = textures

-  -- and the move-animation layer, for the same eyes -- rendered only

-  -- while a headset is actually watching, because only the VR world

-  -- pass draws it (the flat screen has the animations in-frame already)

-  session.animTex = nil

-  local okVR, vrOn = pcall(function()

-    local vr = V.require("VR")

-    return vr.active and vr.active() or false

-  end)

-  if okVR and vrOn and session.battle then

-    local okA, anim = pcall(OverworldBattle.animTexture, session.battle)

-    if okA then session.animTex = anim end

-  end

-  session.token = (session.token or 0) + 1

-  local ok, shot = pcall(BattleScene.render, session.state, session.arena,

-                         textures, session.token)

-  if not ok then

-    -- One failure retires the arena for THIS battle and nothing else: the

-    -- battle screen carries on as the engine's own, the free-roam pipeline

-    -- this runs inside keeps rendering the overworld, and the next battle

-    -- tries again. Rethrowing would hand the whole voxel mode to Pipelines'

-    -- guard, which retires a pipeline for the session.

-    session.shot = nil

-    session.snapped = false

-    session.broken = true

-    V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "

-                   .. "on the plain battle background", tostring(shot))

-    return

-  end

-  session.snapped = false

-  if shot and shot.canvas then

-    -- the depth of field is measured off the two marks: the slab in focus is

-    -- the one the mons are standing in, at whatever the drift has done to

-    -- where that lands

-    local y1 = shot.ly + shot.player[2] * shot.scale

-    local y2 = shot.ly + shot.enemy[2] * shot.scale

-    local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)

-    local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,

-                                 focusY, band, range)

-    if okDof and blurred then shot.canvas = blurred end

-    -- the frosted glass the HUDs sit on is built from the FINISHED backdrop,

-    -- so a panel over a blurred far field is frosted from what is actually

-    -- behind it

-    pcall(BattleHud.build, shot.canvas)

-    -- and then the HUDs go ON that backdrop, snapped out to the window's own

-    -- edges (snapHUDs). Here rather than in the battle's draw for the same

-    -- reason the scene is: it binds a canvas of its own. After the frost, so

-    -- the glass is frosted from the world alone and never from the glyphs

-    -- about to sit on it.

-    local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)

-    session.snapped = (okHud and up) and true or false

-    -- once per battle, not once per frame: a driver that cannot do this cannot

-    -- do it sixty times a second either, and the fallback is silent and fine

-    if not okHud and not session.hudWarned then

-      session.hudWarned = true

-      V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "

-                     .. "in the battle frame this battle", tostring(up))

-    end

-  end

-  session.shot = shot

-end

-

--- The finished shot for this frame, or nil when there is none and the battle

--- should draw the way it always did.

-function OverworldBattle.shot()

-  if not session or session.broken then return nil end

-  local s = session.shot

-  if s and s.canvas then return s end

-  return nil

-end

-

--- The staged fight's WORLD-side pieces, for a pass that stands the mons in

--- its own view of the map rather than in the arena's composed shot -- the

--- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed

--- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets

--- per-eye cards), the live textures table (for the hit-flash flag), and the

--- token the shadow signature keys on. nil while nothing is staged, the

--- arena is broken, or the pics have not been rendered yet.

-function OverworldBattle.worldCards()

-  if not (session and session.arena and not session.broken) then return nil end

-  local tex = session.textures

-  if not tex then return nil end

-  local host = (session.state and session.state.map) or nil

-  if not host then return nil end

-  local groundY = BattleScene.groundY(host, session.arena)

-  return BattleScene.monCards(session.arena, groundY, tex), tex, session.token

-end

-

--- The live session's BATTLE STATE, once the pushed battle has been met

--- (session.battle fills in from the stack in update). The VR quad reads

--- it to tell "the battle screen is on top" from "a menu is over the

--- battle" -- the UI-only panel is right for the first and wrong for the

--- second. nil with no session, a broken one, or a battle not yet pushed.

-function OverworldBattle.battle()

-  if not (session and not session.broken) then return nil end

-  return session.battle

-end

-

--- The move-animation layer as a texture: the engine's own drawAnimLayer,

--- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized

--- transparent canvas of its own. This is what stands the effects up in

--- the VR eyes' world -- see worldAnim below -- the same move the pics

--- made through sideTexture: let the engine draw what it always draws,

--- catch it on a canvas, stand the canvas in the scene.

-local animLayer = nil

--- the engine's own drawAnimLayer, captured by install(). Declared HERE,

--- above the function that reads it: a local declared further down the

--- chunk would leave this function reading a global of the same name --

--- nil forever, and the effects silently absent from the eyes (the bug

--- this comment is the tombstone of).

-local innerAnim = nil

-

-function OverworldBattle.animTexture(battle)

-  if not (innerAnim and battle) then return nil end

-  if not (love.graphics and love.graphics.newCanvas) then return nil end

-  if not animLayer then

-    local ok, c = pcall(love.graphics.newCanvas,

-                        BattleScene.GB_W, BattleScene.GB_H)

-    if not (ok and c) then return nil end

-    pcall(c.setFilter, c, "nearest", "nearest")

-    animLayer = c

-  end

-  local g = love.graphics

-  local prevCanvas = g.getCanvas()

-  local ok = pcall(function()

-    g.push("all")

-    g.origin()

-    g.setCanvas(animLayer)

-    g.clear(0, 0, 0, 0)

-    g.setBlendMode("alpha")

-    g.setColor(1, 1, 1, 1)

-    innerAnim(battle, false)

-    g.pop()

-  end)

-  if not ok then pcall(g.pop, g) end

-  if prevCanvas then pcall(g.setCanvas, g, prevCanvas)

-  else pcall(g.setCanvas, g) end

-  return ok and animLayer or nil

-end

-

--- The staged fight's effects, for the VR eyes: the animation layer plus

--- the plane to stand it on (BattleScene.fxCard -- anchored so a hit

--- authored at a slot lands on the mon standing in for that slot). nil

--- while nothing is staged or no layer was rendered this frame.

-function OverworldBattle.worldAnim()

-  if not (session and session.arena and not session.broken) then return nil end

-  local tex = session.animTex

-  if not tex then return nil end

-  local host = (session.state and session.state.map) or nil

-  if not host then return nil end

-  local groundY = BattleScene.groundY(host, session.arena)

-  local model = BattleScene.fxCard(session.arena, groundY,

-                                   OverworldBattle.ANCHOR)

-  if not model then return nil end

-  return tex, model

-end

-

--- Where the staged fight STANDS -- the arena and its floor height -- for a

--- camera that wants to look at it rather than draw it (the VR battle

--- mount). Answered as soon as the stage exists, textures or not: the

--- camera should be seated behind the fade before the first pic lands.

--- nil whenever no fight is staged on the world.

-function OverworldBattle.stage()

-  if not (session and session.arena and not session.broken) then return nil end

-  local host = (session.state and session.state.map) or nil

-  if not host then return nil end

-  return session.arena, BattleScene.groundY(host, session.arena)

-end

-

-function OverworldBattle.invalidate()

-  BattleDOF.invalidate()

-  BattleHud.invalidate()

-  BattlePics.invalidate()

-  -- the STADIUM models hold meshes and textures of this graphics context

-  -- like everything else here does

-  pcall(function() V.require("Stadium").invalidate() end)

-end

-

--- ------- the battle screen's background

---

--- BattleState opens by filling 160x144 white -- that fill IS the battle's

--- background, and in the colorized pipeline it is also the BG canvas's clear

--- (nothing else clears it, so skipping it outright would ghost last frame).

--- So for the length of one draw, that one call is intercepted: on the two

--- offscreen canvases it becomes a transparent clear, so the shade-remap pass

--- composites the HUD and the text box over the arena and leaves the empty

--- field showing it; on the screen it is simply dropped, because the UI canvas

--- has already been cleared transparent for the world to show through.

---

--- Matched exactly -- fill, the full frame, at the origin, in opaque white --

--- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the

--- move-animation flash (which is white at 0.85) all pass through untouched.

---

--- This is a shim over love.graphics and it is the one invasive thing here,

--- so it is scoped as tightly as it can be: installed around a single call,

--- removed on the way out including on error, and never live outside a battle

--- frame this mode is drawing.

-local function withoutBackgroundFill(battle, fn)

-  local g = love.graphics

-  local rectangle = g.rectangle

-  g.rectangle = function(mode, x, y, w, h, ...)

-    if mode == "fill" and x == 0 and y == 0

-       and w == BattleScene.GB_W and h == BattleScene.GB_H then

-      local r, gr, b, a = g.getColor()

-      if r > 0.99 and gr > 0.99 and b > 0.99 then

-        -- Two different full-frame whites, both replaced rather than drawn.

-        --

-        -- OPAQUE is the battle's background, and on the offscreen canvases it

-        -- doubles as their clear, so there it becomes a transparent one.

-        --

-        -- TRANSLUCENT is the hit flash. Over a white field that reads as a

-        -- flash; over a world it whites out the map, the HUD and the text box

-        -- together. BattleScene puts it back on the mons alone.

-        if a > 0.99 then

-          local target = g.getCanvas()

-          if target ~= nil

-             and (target == battle.bgCanvas or target == battle.waveCanvas) then

-            g.clear(0, 0, 0, 0)

-          end

-        end

-        return

-      end

-    end

-    return rectangle(mode, x, y, w, h, ...)

-  end

-  local ok, err = pcall(fn, battle)

-  g.rectangle = rectangle

-  if not ok then error(err, 0) end

-end

-

--- ------- the box, without its paper

---

--- Font.drawBox is a white fill and then six border glyphs, and the fill is the

--- opaque slab the frosted panel underneath is there to replace. So for the

--- length of one drawTextArea the white fills are dropped and everything else

--- -- the border, the text, the cursor, the down arrow -- draws exactly as it

--- always did, over the glass instead of over paper.

---

--- Every fill drawTextArea issues is one of those: the box's own, and the two

--- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes

--- the border glyphs that hardware would have overwritten. Both are opaque

--- white, both are paper, and both go.

---

--- The same shim shape as withoutBackgroundFill above, and scoped as tightly:

--- installed around a single call, removed on the way out including on error,

--- never live outside a battle frame this mode is drawing.

-local function withoutBoxFill(battle, fn)

-  local g = love.graphics

-  local rectangle = g.rectangle

-  g.rectangle = function(mode, ...)

-    if mode == "fill" then

-      local r, gr, b, a = g.getColor()

-      if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end

-    end

-    return rectangle(mode, ...)

-  end

-  local ok, err = pcall(fn, battle)

-  g.rectangle = rectangle

-  if not ok then error(err, 0) end

-end

-

--- ------- the hour's light, on a pic that is not geometry

---

--- Everything standing in the arena goes through the voxel shader, and that

--- shader multiplies by the hour's tint: at dusk the whole diorama warms, at

--- night it goes blue, and the two mons' cards go with it because they are

--- drawn in the same pass as the ground they stand on.

---

--- A back pic pinned to the menu is not in that pass. It is the engine's own

--- flat blit over the finished shot, so it arrived at noon while the world

--- behind it was at midnight -- a mon lit by nothing in the frame.

---

--- So the tint is applied by hand, to that one draw. Every colour the pics

--- layer sets is multiplied on its way past, which is the whole of it: the

--- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw

--- colour, so tinting the colour tints the pixels -- and the alpha, the faint

--- slide's fade and the blink's own colour all compose with it rather than

--- being overwritten.

---

--- What this does NOT get is the sun: the cards are shadow-mapped, so one

--- standing under a tree is darker than the tint alone, and this pic has no

--- position in the scene to be shadowed at. It carries the hour and not the

--- weather, which is the part the eye reads.

-local function withTint(tint, fn, ...)

-  if not tint then return fn(...) end

-  local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1

-  if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end

-  local gfx = love.graphics

-  local setColor = gfx.setColor

-  gfx.setColor = function(cr, cg, cb, ca, ...)

-    if type(cr) == "table" then

-      return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,

-                        cr[4] }, cg, ...)

-    end

-    if cr == nil then return setColor(cr, cg, cb, ca, ...) end

-    return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)

-  end

-  local ok, err = pcall(fn, ...)

-  gfx.setColor = setColor

-  -- the layer leaves whatever colour it last set, and that one is tinted;

-  -- hand the next caller plain white rather than a dimmed one

-  setColor(1, 1, 1, 1)

-  if not ok then error(err, 0) end

-end

-

--- ------- the mons, as textures for the 3D pass

---

--- The two Pokemon are not composited over the world any more: they are quads

--- standing in it (see BattleBillboard). What that needs from the battle

--- screen is a TEXTURE per side -- and the honest way to get one is to let the

--- engine draw its own pics layer, unchanged, into a canvas.

---

--- So the layer is rendered twice, once per side, with the other side

--- falsified out of existence by nulling exactly the fields its branches

--- test. Everything the engine does to a pic comes along for free that way:

--- the trainer pic before the send-out, the grow-out-of-the-ball scale, the

--- faint slide, the damage blink, the squish, every SE displacement. None of

--- it is reimplemented and none of it can drift.

---

--- Two things are forced during that render. The scale, to 1, so the texture

--- carries the artwork's own pixels and the BILLBOARD does the sizing; and the

--- placement, so the pic lands centred on a known column with its feet on a

--- known row. That known point is what the quad is then hung from.

-local TEX_AX, TEX_AY = 80, 96          -- forced pic centre and baseline

-local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot

-

-OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY

-

--- Which side is being rendered, or nil. The placement wrappers read it.

-local texturing = nil

-

-local texCanvas = {}

-local innerPics = nil                   -- captured by install()

-local innerHUDs = nil                   -- likewise, for the snapped HUD layer

--- (innerAnim, their sibling, is declared up beside animTexture, which

--- sits earlier in the chunk than this group and must see the local)

-

-local function texCanvasFor(side)

-  local c = texCanvas[side]

-  if c then return c end

-  local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,

-                         BattleScene.GB_H, { dpiscale = 1 })

-  if not ok then return nil end

-  made:setFilter("nearest", "nearest")

-  texCanvas[side] = made

-  return made

-end

-

--- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own

--- guards, so an empty canvas is never hung on a quad: a fainted, hidden or

--- not-yet-sent-out mon simply has no billboard this frame.

-local function sideVisible(battle, side)

-  if side == "enemy" then

-    if battle.showEnemyTrainer and battle.trainerPic then return true end

-    return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden

-            and not battle.enemySendingOut

-            and not battle:fxHidden(battle.enemy)) and true or false

-  end

-  if battle.showPlayerBack and battle.playerBackPic then return true end

-  local hide = battle.safari or battle.demo

-  return (battle.player and battle.player.sprite and not hide

-          and not battle.sendingOut

-          and not battle:fxHidden(battle.player)) and true or false

-end

-

-local OFF = {

-  enemy = { player = false, showPlayerBack = false },

-  player = { enemy = false, showEnemyTrainer = false },

-}

-

--- Render one side's pics layer into its canvas and report where the pic's

--- feet ended up, in canvas coordinates.

-function OverworldBattle.sideTexture(battle, side)

-  if not (innerPics and battle) then return nil end

-  -- On the STADIUM rung a side standing a MODEL needs no pic: rendering one

-  -- anyway would hang a second, flat copy of the same Pokemon on the same

-  -- cell. Asked per side, so a species with no pack -- or a substitute

-  -- doll, or the trainer before the send-out -- still comes through here.

-  local okS, covered = pcall(function()

-    return V.require("Stadium").covers(battle, side)

-  end)

-  if okS and covered then return nil end

-  if not sideVisible(battle, side) then return nil end

-  local canvas = texCanvasFor(side)

-  if not canvas then return nil end

-

-  local g = love.graphics

-  local prevCanvas = g.getCanvas()

-  local prevBlend, prevAlpha = g.getBlendMode()

-  -- The pic-window scissors are in the battle screen's fixed coordinates and

-  -- would clip a pic that has been moved to the middle of its own canvas.

-  -- There is nothing here for them to protect -- no HUD, no text box, just

-  -- the one pic -- so they are switched off for the render.

-  local setScissor, intersectScissor = g.setScissor, g.intersectScissor

-  local getScissor = g.getScissor

-  g.setScissor = function() end

-  g.intersectScissor = function() end

-  g.getScissor = function() return nil end

-

-  local saved = {}

-  for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end

-  texturing = side

-

-  local ok, err = pcall(function()

-    g.setCanvas(canvas)

-    g.clear(0, 0, 0, 0)

-    g.setBlendMode("alpha")

-    g.setColor(1, 1, 1, 1)

-    innerPics(battle, 0, 0, 0)

-  end)

-

-  texturing = nil

-  for k in pairs(OFF[side]) do battle[k] = saved[k] end

-  g.setScissor, g.intersectScissor, g.getScissor =

-    setScissor, intersectScissor, getScissor

-  if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end

-  g.setBlendMode(prevBlend or "alpha", prevAlpha)

-  if not ok then error(err, 0) end

-

-  local ax, ay = TEX_AX, TEX_AY

-  local trainer = false

-  -- The intro trainer pic draws itself straight into its own 7x7 slot rather

-  -- than through the placement helpers, so it is hung from that slot instead.

-  if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then

-    ax, ay, trainer = TRAINER_AX, TRAINER_AY, true

-  elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then

-    trainer = true

-  end

-  return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }

-end

-

--- Whether the hit flash is showing this frame.

---

--- Mirrors BattleState:draw's own test, because the flash is a DRAW-time

--- decision there (a counter plus the frame parity that makes it flicker) and

--- there is no seam that reports it. Read-only, so the worst a future engine

--- change can do is flash on a frame the engine would not have.

-function OverworldBattle.flashing(battle)

-  local fx = battle and battle.fx

-  if not (fx and fx.flash and fx.flash > 0) then return false end

-  return (battle.frame or 0) % 4 < 2

-end

-

--- Both sides, or nil when neither has anything to show.

---

--- One side under BACK SPRITES: the player's mon is not standing on the map at all

--- there, it is on the menu, so it has no card to be a texture for -- and

--- nothing downstream has to know that. No billboard, and no shadow on the

--- ground under a mon that is not on it.

-function OverworldBattle.textures(battle)

-  if not battle then return nil end

-  local out = {}

-  local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")

-  local okP, player = true, nil

-  if not OverworldBattle.backPinned() then

-    okP, player = pcall(OverworldBattle.sideTexture, battle, "player")

-  end

-  out.enemy = okE and enemy or nil

-  out.player = okP and player or nil

-  -- On the STADIUM rung both sides can legitimately have no pic -- the pair

-  -- of them are models -- and this table must still come back, because it

-  -- carries the HIT FLASH, and because the VR eye pass uses its presence to

-  -- decide there is a staged fight to draw at all.

-  local okStanding, standing = pcall(function()

-    return V.require("Stadium").standing()

-  end)

-  if not (out.enemy or out.player or (okStanding and standing)) then

-    return nil

-  end

-  out.flash = OverworldBattle.flashing(battle)

-  return out

-end

-

--- ------- engine seams

---

--- Four wraps, each idempotent so a hot reload cannot stack them.

-

-function OverworldBattle.install()

-  local OverworldState = require("src.world.OverworldController")

-  if not OverworldState.dramaticShapeBattleHook then

-    local inner = OverworldState.pushBattle

-    -- The one place the overworld starts a battle, and it runs BEFORE the

-    -- transition is pushed -- which is what lets the cull happen off-screen

-    -- and the wipe play over a map with nobody on it.

-    function OverworldState:pushBattle(battle)

-      pcall(OverworldBattle.begin, self, battle)

-      return inner(self, battle)

-    end

-    OverworldState.dramaticShapeBattleHook = true

-  end

-

-  -- the STADIUM rung's own four wraps, which drive the models' animations

-  -- off the fight (see Stadium.install). Idempotent in the same way, and

-  -- installed whichever rung the row is on: the wraps do nothing at all

-  -- while no stadium session is live.

-  pcall(function() V.require("Stadium").install() end)

-

-  local BattleState = require("src.battle.BattleState")

-  if BattleState.dramaticShapeBattleHook then return end

-

-  -- Integer scales only. The camera is solved to make one overworld square

-  -- exactly big enough for a pic at its own integer scale (see BattleCam), so

-  -- the fit never has to come out of the pixels -- and a species override or

-  -- a battle_sprite_scales entry that asks for 1.7x would undo that and

-  -- resample the sprite into mush. Rounded rather than refused, so such a mod

-  -- still gets the bigger or smaller mon it asked for, on the pixel grid.

-  local innerScale = BattleState.resolveBattleScale

-  function BattleState.resolveBattleScale(data, side, path, species)

-    local base = innerScale(data, side, path, species)

-    -- 1:1 into the billboard texture: the artwork's own pixels, with the

-    -- quad's world size doing every bit of the scaling. Anything else would

-    -- resample the sprite twice -- once into the texture and again on the way

-    -- to the screen -- and a twice-resampled Gen 1 pic is mush.

-    if texturing then return 1 end

-    if not OverworldBattle.shot() then return base end

-    return math.max(1, math.floor((tonumber(base) or 1) + 0.5))

-  end

-

-  -- Keyed-out whites inside a pic used to be filled by the white field

-  -- behind it. There is a world back there now, so they are filled here

-  -- instead -- see BattlePics, which puts the paper back without touching

-  -- the silhouette.

-  --

-  -- The pinned pic is told that its feet are on the box, which is what lets

-  -- the pale-bodied back sprites be filled at all: their bellies leak out

-  -- through an opening too wide to read as a drain, and only the box under

-  -- them settles that it is not a hole. Passed the pre-bake image, because

-  -- that is the one the battle holds a reference to.

-  local innerPic = BattleState.picImage

-  function BattleState:picImage(img)

-    local out = innerPic(self, img)

-    if not OverworldBattle.shot() then return out end

-    return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))

-  end

-

-  -- While a billboard texture is being rendered both pics are put in the same

-  -- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad

-  -- has one anchor to hang from whichever side and whichever species it is

-  -- carrying. Outside that render both helpers answer exactly as they always

-  -- did.

-  local innerBack = BattleState.backPlacement

-  function BattleState.backPlacement(w, h, pad, padL, scale)

-    local x, y, s = innerBack(w, h, pad, padL, scale)

-    if not texturing then return x, y, s end

-    return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s

-  end

-

-  local innerFront = BattleState.frontPlacement

-  function BattleState.frontPlacement(ex, ey, w, h, scale)

-    local x, y, s = innerFront(ex, ey, w, h, scale)

-    if not texturing then return x, y, s end

-    return TEX_AX - w * scale / 2, TEX_AY - h * scale, s

-  end

-

-  local innerDraw = BattleState.draw

-  function BattleState:draw()

-    local shot = OverworldBattle.shot()

-    -- AskName blanks the field on purpose (the nickname prompt is meant to

-    -- sit on nothing); leave that one alone.

-    if not shot or self.blankForAskName then

-      -- nil, not false: the class default is inherited again, so a battle

-      -- that loses its arena mid-fight goes back to white voids

-      self.letterboxWhite = nil

-      self.dramaticShapeShot = nil

-      return innerDraw(self)

-    end

-    self.dramaticShapeShot = shot

-    -- The world reaches the screen through the seam a render pipeline's

-    -- finished world image already uses: one window-resolution canvas,

-    -- blitted a pixel to a pixel, with the 160x144 UI canvas composited over

-    -- it in the classic letterbox afterwards. That is what makes the backdrop

-    -- as crisp as the free-roam diorama while the pics and text stay GB art.

-    local renderer = game().renderer

-    if renderer and renderer.setWorldOverride then

-      renderer:setWorldOverride(shot.canvas)

-    end

-    -- beginFrame clears the UI canvas white for an opaque state; the world is

-    -- under it now, so clear it back to nothing and let it through. Safe to

-    -- do here: an opaque battle is the lowest state drawn, so nothing has

-    -- drawn into this canvas yet.

-    love.graphics.clear(0, 0, 0, 0)

-    -- the white letterbox exists so the window matches the white battle

-    -- canvas; there is a world out to the window edges now

-    self.letterboxWhite = false

-    OverworldBattle.drawHudPanels(self)

-    withoutBackgroundFill(self, innerDraw)

-  end

-

-  -- The mons are geometry standing on the map now, drawn in the 3D pass

-  -- before this screen is composited at all, so the flat pics layer has

-  -- nothing left to do here. Skipped rather than left to draw underneath, or

-  -- every Pokemon would appear twice: once on its tile and once in its slot.

-  --

-  -- Except under BACK SPRITES, where the player's side never became geometry and this

-  -- layer is the only thing that draws it. The engine's own onlySide argument

-  -- does the whole job: one call, the player's branches alone, in the slot and

-  -- at the scale the GB always put them -- feet on the box, 2x, back view.

-  innerPics = BattleState.drawPicsLayer

-  function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)

-    local shot = self.dramaticShapeShot

-    if not shot then

-      return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)

-    end

-    if OverworldBattle.backPinned() and onlySide ~= "enemy" then

-      -- under the hour's own light, like everything else in the frame -- see

-      -- withTint, and the tint BattleScene hands over with the shot.

-      --

-      -- Except on the wavy path, where the pic is baked into the GRAYSCALE bg

-      -- canvas for the zone pass to colour by region. That pass keys off the

-      -- red channel, and a night tint pulls red down -- it would not darken

-      -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a

-      -- second and the hour survives it fine.

-      local tint = not self.grayPics and shot.tint or nil

-      return withTint(tint, innerPics, self, slide, sx, sy, "player",

-                      skipMenuClip)

-    end

-  end

-

-  -- The battle's text box and its menus, over the frosted glass laid down for

-  -- them rather than over their own white paper. The INK is Gen 1's own black

-  -- and stays that way whatever is behind the glass -- the panel's tint is

-  -- what earns it its contrast (see BattleHud).

-  local innerText = BattleState.drawTextArea

-  function BattleState:drawTextArea()

-    if not self.dramaticShapeShot then return innerText(self) end

-    return withoutBoxFill(self, innerText)

-  end

-

-  -- Move animations are authored against the pics' fixed slots, and a single

-  -- animation reaches across both sides, so there is no per-side offset to

-  -- give them. They ride the average, which is where the pair's centre went

-  -- -- a few pixels at most, and it keeps a hit landing on the mon it is

-  -- aimed at instead of drifting off it.

-  innerAnim = BattleState.drawAnimLayer

-  function BattleState:drawAnimLayer(colorized)

-    local shot = self.dramaticShapeShot

-    if not shot then return innerAnim(self, colorized) end

-    -- Move animations are authored against the pics' old fixed slots, and one

-    -- animation reaches across both sides, so there is no per-side offset to

-    -- give them. They ride to where the PAIR went: the midpoint of the two

-    -- mons' projected positions, less the midpoint of the slots they used to

-    -- sit in. A hit still lands on the mon it is aimed at.

-    --

-    -- And they ride the pair's SEPARATION as well, because the mons

-    -- themselves do. Both are geometry standing on the map, so the camera

-    -- sizes them: zoom in and they grow, swing round to side-on and the two

-    -- marks close up as the axis foreshortens. A layer that only slid would

-    -- have held the authored 106-pixel spacing through all of it -- a beam

-    -- fired between two mons that are no longer that far apart, ending in

-    -- the air beside the one it was aimed at. Scaling about the same

-    -- midpoint keeps every authored offset the same fraction of the gap it

-    -- was authored as.

-    local a = OverworldBattle.ANCHOR

-    -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side

-    -- contributes no movement at all and the pair's centre has gone half as

-    -- far as the foe's mark did.

-    local px, py = shot.player[1], shot.player[2]

-    if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end

-    local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2

-    local ax = (a.enemy[1] + a.player[1]) / 2

-    local ay = (a.enemy[2] + a.player[2]) / 2

-    love.graphics.push()

-    love.graphics.translate(cx - ax, cy - ay)

-    -- Clamped, and skipped outright if the marks ever coincide: a

-    -- degenerate projection must leave the effects the size they were

-    -- rather than collapse them to nothing or blow them across the screen.

-    local k = OverworldBattle.animScale(shot, px, py)

-    if k ~= 1 then

-      love.graphics.translate(ax, ay)

-      love.graphics.scale(k, k)

-      love.graphics.translate(-ax, -ay)

-    end

-    local ok, err = pcall(innerAnim, self, colorized)

-    love.graphics.pop()

-    if not ok then error(err, 0) end

-  end

-

-  -- The engine's flash has a SECOND half, and it is the one that reaches the

-  -- menu. Beside the white rectangle (dropped above) the flash moves are

-  -- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the

-  -- colorized pipeline applies in drawZonePass to the WHOLE background

-  -- canvas. That canvas carries the HUD glyphs and the text box, so a fade

-  -- meant for the two mons washed the menu out with them.

-  --

-  -- The fade is left switched on for the pics, which read it through

-  -- picImage, and switched off for the zone pass alone. So the mons flash

-  -- and the furniture around them does not.

-  --

-  -- The zone pass has a SECOND thing it paints, and this is the one that

-  -- reads as the menu box flashing. A screen shake makes it fill every zone

-  -- with the zone's own color 0 before it draws the offset copy -- the

-  -- hardware showing empty BG in the strip the shake vacated. On a white

-  -- battle field that fill is invisible; over a world it is an opaque white

-  -- sheet across the whole frame, and since a shake program alternates

-  -- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it

-  -- switches on and off a few times a second. It is dropped: the background

-  -- here is the map, so what the shake vacates should show the map.

-  local innerZone = BattleState.drawZonePass

-  function BattleState:drawZonePass(src, sx, sy)

-    if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end

-    -- shadow the method on the instance for this call only; putting the

-    -- field back to whatever it was (normally nil) lets the class method be

-    -- found again

-    local had = rawget(self, "activeBgp")

-    self.activeBgp = function() return nil end

-    local g = love.graphics

-    local rectangle = g.rectangle

-    g.rectangle = function(mode, ...)

-      -- the pass draws no other rectangle; the shake still shifts the copy

-      if mode == "fill" then return end

-      return rectangle(mode, ...)

-    end

-    local ok, err = pcall(innerZone, self, src, sx, sy)

-    g.rectangle = rectangle

-    self.activeBgp = had

-    if not ok then error(err, 0) end

-  end

-

-  innerHUDs = BattleState.drawHUDs

-  function BattleState:drawHUDs(slide)

-    -- Normally the HUDs have already been drawn this frame, snapped out to the

-    -- window's edges and composited into the world image (snapHUDs). Drawing

-    -- them here as well would show each block twice, once in each place.

-    if self.dramaticShapeShot and snapped() then return end

-    return innerHUDs(self, slide)

-  end

-

-  BattleState.dramaticShapeBattleHook = true

-end

-

--- Whether each HUD block is on screen this frame.

---

--- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam

--- that reports "the enemy HUD is up". A panel under a HUD that is not there

--- would be a frosted slab floating in the arena, so it is worth mirroring;

--- the worst a future engine change can do is show an empty one for a frame,

--- never break a battle.

-function OverworldBattle.hudLive(battle, slide)

-  local enemy = battle.enemy and not battle.showEnemyTrainer

-                and not battle.enemySendingOut

-                and not battle:growInScale(battle.enemy) and slide == 0

-                and not battle.enemy.fainted

-  local player = battle.player and not (battle.safari or battle.demo)

-                 and not battle.showPlayerBack and slide == 0

-  return enemy and true or false, player and true or false

-end

-

--- ------- the snapped composite

---

--- The engine's own HUD layer, rendered into a texture.

---

--- One thing is falsified for the render, and it is falsified because this layer

--- never reaches the battle's zone pass -- it is composited into the world image,

--- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves

--- the HP bar's fill as DMG gray for the zone pass to colour by region (#229);

--- answered false, it tints its own greens and reds instead, exactly as it does

--- on the flat path.

---

--- Shadowed on the instance for this call only, the way drawZonePass shadows

--- activeBgp: putting the field back to whatever it was (normally nil) lets the

--- class method be found again.

-function OverworldBattle.hudTexture(battle, slide)

-  if not (innerHUDs and battle) then return nil end

-  local had = rawget(battle, "colorMode")

-  battle.colorMode = function() return false end

-  local ok, layer = pcall(BattleHud.layerTexture,

-                          BattleScene.GB_W, BattleScene.GB_H,

-                          function() innerHUDs(battle, slide) end)

-  battle.colorMode = had

-  return ok and layer or nil

-end

-

--- Draw both HUD blocks into the world image at the window's edges, each on its

--- own frosted panel. Returns true when the frame's HUDs are up there and the

--- in-frame draw must be skipped; false leaves the battle screen's own HUD

--- exactly as it was before any of this existed.

---

--- Both bands are blitted whether or not that side's HUD is LIVE, because a band

--- carries more than the HUD: the pokeball rows of the intro and of an enemy

--- faint, and the safari ball count, all draw in these rows and belong at the

--- same edge as the block they share it with. The panels are the ones that

--- follow hudLive -- frosted glass under nothing is a slab floating in the arena.

-function OverworldBattle.snapHUDs(battle, shot)

-  if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then

-    return false

-  end

-  -- With a headset live the HUDs stay IN the GB frame -- the classic

-  -- slots, on the glass drawHudPanels lays for the unsnapped path. Both

-  -- of VR's battle screens (the floating panel and the pokedex's) crop

-  -- to the letterbox, and a block snapped out to the window's edge would

-  -- be cropped away with the window around it.

-  local okV, vr = pcall(V.require, "VR")

-  if okV and vr and vr.active and vr.active() then return false end

-  local slide = (battle.introSlide or 0) * 4

-  local rects, bandX = OverworldBattle.snapRects(shot)

-  local enemy, player = OverworldBattle.hudLive(battle, slide)

-  local live = {}


-  if not isIOS() then


-    if enemy then live.enemy = rects.enemy end


-    if player then live.player = rects.player end


-  end

-  -- The text box's frost panel normally goes into this same world-canvas pass.

-  -- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating

-  -- the large ghost rectangle behind the Pokemon. Keep the box border/text but

-  -- skip only this frosted backing on iOS.

-  if not isIOS() then

-    for key, rect in pairs(OverworldBattle.textRects(battle)) do

-      live[key] = toWorld(rect, shot)

-    end

-  end

-  local layer = OverworldBattle.hudTexture(battle, slide)

-  if not layer then return false end

-

-  local g = love.graphics

-  local prevCanvas = g.getCanvas()

-  local prevBlend, prevAlpha = g.getBlendMode()

-  local ok, err = pcall(function()

-    g.setCanvas(shot.canvas)

-    g.setBlendMode("alpha")

-    for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end

-    g.setColor(1, 1, 1, 1)

-    for side, band in pairs(OverworldBattle.HUD_BAND) do

-      local quad = g.newQuad(band[1], band[2], band[3], band[4],

-                             BattleScene.GB_W, BattleScene.GB_H)

-      local x = bandX[side] + band[1] * shot.scale


-      local targetY = shot.ly + band[2] * shot.scale


-


-      if isIOS() then


-        -- Keep the player's HUD exactly where it currently appears on the


-        -- right. Only the enemy band needs its mirrored destination corrected.


-        local y = targetY


-        if side == "enemy" then


-          y = shot.ph - targetY - band[4] * shot.scale


-        end


-


-        -- iOS presents this Canvas-to-Canvas HUD texture upside down.


-        g.draw(layer, quad, x, y, 0,


-               shot.scale, -shot.scale, 0, band[4])


-      else


-        g.draw(layer, quad, x, targetY, 0,


-               shot.scale, shot.scale)


-      end

-    end

-  end)

-  if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end

-  g.setBlendMode(prevBlend or "alpha", prevAlpha)

-  g.setColor(1, 1, 1, 1)

-  if not ok then error(err, 0) end

-  return true

-end

-

--- Lay the frosted glass down under whichever HUD and box are about to draw,

--- and record which way the glyphs have to flip.

---

--- The panels are the fallback path only: normally the HUDs are snapped out to

--- the window's edges and their glass, and the box's, went into the world image

--- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is

--- drawn here, in the GB frame, whichever path laid the glass under it.

-function OverworldBattle.drawHudPanels(battle)

-  local shot = battle.dramaticShapeShot

-  if not shot then return end

-  if snapped() then

-    return

-  end

-  local slide = (battle.introSlide or 0) * 4

-  local enemy, player = OverworldBattle.hudLive(battle, slide)

-  local rect = OverworldBattle.HUD_RECT

-  local live = {}

-  if enemy then live.enemy = rect.enemy end

-  if player then live.player = rect.player end

-  for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end

-  if not next(live) then return end

-  for _, r in pairs(live) do BattleHud.panel(r, shot) end

-end

-

-return OverworldBattle

+-- Overworld battles: fights that happen on the map you were standing on.
+--
+-- The engine's battle is a screen: a white field with two pics on it, pushed
+-- over a frozen overworld that stops drawing. This turns that white field
+-- into the world -- the same terrain the free-roam mode extrudes, shot from
+-- a placed over-the-shoulder camera at a clear patch of ground nearby --
+-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
+-- animation, faint slide and text box is the engine's own, drawn in the
+-- engine's own order. What changes is what is behind them, and where the two
+-- pics stand.
+--
+-- The sequence, from the moment something picks a fight:
+--
+--   1. the overworld cast is culled -- every NPC vanishes, so the wipe
+--      plays over an empty map and no bystander is left standing in the
+--      arena shot
+--   2. the engine's own transition wipes the screen (untouched: it is the
+--      right wipe, picked by the right three bits)
+--   3. the battle draws over a live, window-resolution render of the arena,
+--      with each mon PINNED to the cell it is standing on, the camera
+--      drifting slowly enough to read as parallax, and a depth-of-field pass
+--      holding the slab of world the two of them occupy sharp
+--   4. the battle ends, the cast comes back, and the player is exactly
+--      where they were standing
+--
+-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
+-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
+-- real warp would have to survive trainer sight-lines, post-battle
+-- dialogue, the blackout path and every script that assumes the player is
+-- where it left them -- and it would have to put them back afterwards.
+-- Moving the camera buys the whole shot and owes nothing back.
+--
+-- The feature declines cleanly rather than half-working: no depth support,
+-- no open ground on the map, the row switched off, or a mesh still building
+-- all end at the same place, which is the battle screen the engine has
+-- always drawn.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local BattleArena = V.require("BattleArena")
+local BattleCam = V.require("BattleCam")
+local BattleScene = V.require("BattleScene")
+local BattleDOF = V.require("BattleDOF")
+local BattleHud = V.require("BattleHud")
+local BattlePics = V.require("BattlePics")
+local Voxel3D = V.require("Voxel3D")
+local ChunkMesher = V.require("ChunkMesher")
+
+local OverworldBattle = {}
+
+-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
+-- second, which is how the glyph flip is checked from a shot run. Read
+-- through pcall: the loader's sandbox does not hand a mod `os`, and a
+-- diagnostic must never be the reason the mod fails to load.
+local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
+if DEBUG == nil or DEBUG == false then DEBUG = nil end
+
+OverworldBattle.KEY = "battles"
+OverworldBattle.LABEL = "3D-BTL"
+
+-- Five rungs. Two independent choices, laid out as one ladder because they
+-- are one question to the player -- WHAT is standing there, and WHERE:
+--
+--              on the MAP              on two DISCS
+--   pics       2D-3D A                 2D-3D B
+--   models     STADIUM A               STADIUM B
+--
+--   2D-3D A    the mode this file was written for: the fight is staged on
+--              the map and the two Pokemon are the GB's OWN PICS, stood up
+--              on their tiles as quads (BattleBillboard).
+--   2D-3D B    those same pics on a pair of DISCS against the sky, with no
+--              map at all (see lib/StadiumStage.lua). The Game Boy's own
+--              framing with the Game Boy's own art, in three dimensions --
+--              and, like every B rung, it works everywhere, including the
+--              caves and shop floors that have nowhere to stage a fight.
+--   STADIUM A  the staged fight with the Pokemon Stadium battle models in
+--              place of those quads -- skinned, animated, and playing the
+--              animation the move being used actually calls for (see
+--              lib/Stadium.lua). The world is still the world: the fight
+--              happens on real ground, in the map's own weather and light.
+--   STADIUM B  the models on the discs: both halves swapped at once.
+--   OFF        the engine's own white battle screen.
+--
+-- A and B is the STAGE and it is the same stage either way -- the discs do
+-- not know what is standing on them and BattleScene draws them off
+-- `arena.discs` alone, which is why the second column cost a value in this
+-- table and nothing else. The four combinations are all reachable rather
+-- than only the diagonal, because a player who cannot use the STADIUM rungs
+-- -- no ROM, or a ROM they would rather not go and find -- should still be
+-- able to have the disc framing, and because the discs are the answer to
+-- "this map has nowhere to fight" whichever art is standing on them.
+--
+-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and
+-- what an unrecognised stored value falls back to, and the stored value for
+-- this row has been `true` since the row existed. Keeping `true` at the head
+-- means every save written before the later rungs existed reads back as the
+-- 2D-3D it was written for, and a mod whose headline is "the world in 3D"
+-- still does not need the player to go and find the switch.
+--
+-- Every other stored value is likewise the one it has always been --
+-- "stadium" from before there was a B, "stadiumB" from before there was a
+-- flat one -- so no save loses the mode it chose.
+--
+-- Both STADIUM rungs are GATED on the models existing: the mod ships no
+-- Pokemon Stadium data, and until the player's own ROM has been found and
+-- built from (StadiumInstall) the row simply has two fewer stops. See
+-- ModSetting.setGate for why they are skipped rather than shown and refused.
+-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are
+-- the game's own art, so it needs nothing the base game did not ship.
+OverworldBattle.FLAT_B = "flatB"
+
+OverworldBattle.setting =
+  ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,
+                 { true, "flatB", "stadium", "stadiumB", false },
+                 { "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" })
+  :setGate(function(value)
+    if value ~= "stadium" and value ~= "stadiumB" then return true end
+    local ok, install = pcall(V.require, "StadiumInstall")
+    return ok and install and install.available()
+  end)
+
+-- Whether the fight stands on the two carried DISCS rather than on the map
+-- -- the B column above, whichever row of it. Asked by stageFor (what to
+-- stage on), wantsFront (whether this map needs an arena at all) and, once
+-- the arena carries the answer as `arena.discs`, by BattleScene and
+-- VoxelScene for what to draw.
+--
+-- Read straight off the row rather than through Stadium, because it is a
+-- question about the STAGE and half the rungs that answer yes have no
+-- Stadium models on them at all.
+function OverworldBattle.discs()
+  local value = OverworldBattle.setting:get()
+  return (value == OverworldBattle.FLAT_B or value == "stadiumB")
+end
+
+-- Whether the VR row is ON -- read lazily, because VR requires modules
+-- that sit above this one. While it is, this mode stops being optional:
+-- the headset's battle seat, the pokedex screen and the effects plane
+-- all assume a fight standing on the world, and a white-field battle
+-- inside a headset is exactly the flat screen VR exists to replace.
+local function vrOn()
+  local ok, vr = pcall(V.require, "VR")
+  return ok and vr and vr.enabled and vr.enabled() or false
+end
+
+function OverworldBattle.enabled()
+  if vrOn() then return true end
+  return OverworldBattle.setting:get() and true or false
+end
+
+-- Whether the STADIUM rung is the one selected -- read through Stadium so
+-- there is one answer to that question and it lives with the mode it
+-- describes. Required lazily: Stadium sits above this file and requires it
+-- back (for the row), which a load-time require would deadlock.
+function OverworldBattle.stadium()
+  local ok, stadium = pcall(V.require, "Stadium")
+  return (ok and stadium and stadium.enabled()) and true or false
+end
+
+-- ------- BACK SPRITES: the player's own mon stays on the menu
+--
+-- The staged shot stands BOTH mons on the map, which is the mode's whole
+-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
+-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
+-- with its feet on the box. That silhouette is the series' shot.
+--
+-- So BACK SPRITES is offered as a middle setting rather than a compromise
+-- imposed on everyone. With it on the foe is still geometry standing on its
+-- tile at the far end of the arena, and the player's side goes back to being
+-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same
+-- feet on row 96.
+-- Nothing else about the shot moves -- the arena, the camera and the drift are
+-- solved exactly as they were, so the foe stands where it always stood and the
+-- player's cell is simply empty ground in the foreground.
+--
+-- OFF by default: what the mode advertises is the pair of them out there.
+OverworldBattle.BACK_KEY = "battleBack"
+OverworldBattle.BACK_LABEL = "BACK SPRITES"
+
+OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
+                                             OverworldBattle.BACK_LABEL,
+                                             { false, true }, { "OFF", "ON" })
+
+-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
+-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
+-- battle screen already draws exactly this. And held OFF under VR: the
+-- headset stands both mons on the world -- a flat back pic pinned to the
+-- 2D frame would keep your own mon off the arena the battle seat looks at.
+function OverworldBattle.backPinned()
+  if not OverworldBattle.enabled() then return false end
+  if vrOn() then return false end
+  return OverworldBattle.backSetting:get() and true or false
+end
+
+-- Whether a pic is the one drawn in the GB's own slot with its feet on the
+-- text box, rather than geometry standing out on the map.
+--
+-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
+-- that holds the slot until "Go!" -- because that is the only pic this mod
+-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
+-- tile whichever mode is on, and with the mode off the player's side is one
+-- too, so both of those keep the open bottom that lets the arena through a
+-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
+-- behind its lowest row, so its bottom edge seals.
+-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
+-- side it is not rendering by setting the field to FALSE (see OFF) and holds
+-- it that way for the whole render -- during which the pic layer runs, and
+-- picImage asks this. A nil test passes a `false` straight through to the
+-- index below, and the error comes out of sideTexture into the pcall that
+-- calls it: the foe's billboard is dropped for the frame and the Pokemon
+-- simply is not there.
+function OverworldBattle.pinnedPic(battle, img)
+  if not (battle and img) then return false end
+  if not OverworldBattle.backPinned() then return false end
+  if img == battle.playerBackPic then return true end
+  local player = battle.player
+  return (player and img == player.sprite) and true or false
+end
+
+-- ------- both mons face you
+--
+-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
+-- wrong twice over: it is turned away from the camera that is looking at it,
+-- and the back pics are a different, smaller drawing made for a slot the
+-- player never really sees. So the player's side asks for the FRONT pic too,
+-- through the engine's own pokemon.sprite hook -- the seam that exists for
+-- exactly this, so no battle code has to be touched to get it.
+--
+-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
+-- that mon is drawn in its own slot on the menu, seen from behind, and the
+-- front art would be it turned round to face the player it belongs to.
+--
+-- Answered BEFORE a battle exists, because the battler is built before the
+-- battle is pushed. So it cannot ask whether this fight is staged; it asks
+-- whether one on this map WOULD be -- the row is on, the 3D pass is
+-- available, and the map has an arena -- which is the same question with the
+-- same answer a moment later. Cached per map, because the arena search walks
+-- the whole grid and this runs once per battler.
+local staged = { mapId = nil, ok = false }
+
+function OverworldBattle.wantsFront()
+  if not OverworldBattle.enabled() then return false end
+  if OverworldBattle.backPinned() then return false end
+  if not Voxel3D.available() then return false end
+  -- required here rather than through the file's own helper: this runs
+  -- while a battler is being built, which is before that helper is defined
+  local g = require("src.core.Game")
+  local ow = g and g.overworld
+  if not (ow and ow.map and ow.player) then return false end
+  -- a B rung carries its own stage, so the answer is yes on every map and
+  -- there is nothing to search or to cache
+  if OverworldBattle.discs() then return true end
+  if staged.mapId ~= ow.map.id then
+    local ok, arena = pcall(BattleArena.find, ow.map,
+                            ow.player.cellX, ow.player.cellY,
+                            ow.player.surfing)
+    staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
+  end
+  return staged.ok
+end
+
+-- ------- where the engine's own pics stand
+--
+-- The GB draws the player's back pic with its feet on the text box at row 96
+-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
+-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
+-- points are the pics' FEET, they hold for every species at every scale (the
+-- engine's placement helpers pin the bottom edge and the centre), and they
+-- are what BattleCam is solved to put the two arena cells under.
+--
+-- Which makes the pin a subtraction: whatever the drift has done to the
+-- camera this frame, each pic moves by its own cell's projected position
+-- minus its anchor. At the middle of the drift that is zero.
+OverworldBattle.ANCHOR = {
+  player = { 26, 96 },
+  enemy = { 124, 56 },
+}
+
+-- ------- how big a mon is
+--
+-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
+-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
+-- keeps every pixel the artist drew, and the CAMERA is solved so that one
+-- overworld square is that big on screen (see BattleCam). The mon fits its
+-- tile because the tile was sized to the mon, not the other way round.
+OverworldBattle.SLOT_W = { front = 56, back = 32 }
+
+-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
+-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
+-- the anchors above.
+OverworldBattle.HUD_RECT = {
+  enemy = { 8, 0, 80, 32 },
+  player = { 72, 56, 88, 40 },
+}
+
+-- ------- the box at the bottom, on the same glass
+--
+-- The HUDs got frosted panels because black glyphs on grass are not readable.
+-- The battle's text box and its menu had the opposite problem and the same
+-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
+-- the field's own colour when the field was white and is a sheet of paper laid
+-- over the bottom third of the diorama now that it is not.
+--
+-- So the box gets exactly what the HUDs get: the world behind it, blurred to
+-- frosted glass and laid back down translucent, with the border and the text
+-- drawn over it unchanged, and the same brightness verdict flipping the ink
+-- when the ground under it is dark. Only the FILL is taken away -- every glyph
+-- the engine draws inside the box is still the engine's own, in its own place.
+--
+-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
+-- READ-ONLY duplicates of that function's own branches, the same kind of
+-- mirror hudLive is and for the same reason: there is no seam that reports "a
+-- move menu is up", and glass has to go down BEFORE the box that sits on it.
+-- The worst a future engine change can do is frost a rectangle nothing lands
+-- on, or leave a box unfrosted -- never break a battle.
+--
+-- Each rect stops where the next one starts rather than overlapping it: two
+-- panels over the same pixels would frost it twice and leave a visible step
+-- along the seam.
+OverworldBattle.TEXT_RECT = {
+  box = { 0, 96, 160, 48 },       -- Font.drawBox(0, 12, 20, 6), always
+  -- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
+  -- above the box above -- its last tile row sits inside that one
+  moves = { 0, 64, 88, 32 },
+  -- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
+  mimic = { 0, 56, 128, 40 },
+}
+
+-- How far apart the two anchors are: the spacing every move animation was
+-- authored against, and so the yardstick the live pair is measured with.
+OverworldBattle.ANCHOR_SPAN = math.sqrt(
+  (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2
+  + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2)
+
+-- The effects layer's scale for this shot: how far apart the two mons
+-- actually are on screen, over how far apart the slots they were authored
+-- for were. Clamped hard at both ends -- an effect is pixel art and a wild
+-- factor is worse than a slightly wrong one -- and held at exactly 1 when
+-- the marks coincide, which is a projection about to degenerate rather
+-- than a pair that has genuinely closed up.
+OverworldBattle.ANIM_SCALE_MIN = 0.5
+OverworldBattle.ANIM_SCALE_MAX = 2.0
+
+function OverworldBattle.animScale(shot, px, py)
+  if not (shot and shot.enemy and px and py) then return 1 end
+  local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py
+  local span = math.sqrt(dx * dx + dy * dy)
+  if not (span > 1) then return 1 end
+  local k = span / OverworldBattle.ANCHOR_SPAN
+  return math.max(OverworldBattle.ANIM_SCALE_MIN,
+                  math.min(OverworldBattle.ANIM_SCALE_MAX, k))
+end
+
+function OverworldBattle.textRects(battle)
+  if not battle or battle.blankForAskName then return {} end
+  -- a capture session aiming has no text to put in the box and takes it
+  -- off the frame (see the drawTextArea wrap): no box, no glass under it
+  local cap = BattleScene.capture
+  if cap and cap.hideTextBox then return {} end
+  local r = OverworldBattle.TEXT_RECT
+  local out = { box = r.box }
+  if battle.phase == "moveSelect" then
+    out.moves = r.moves
+  elseif battle.phase == "mimicSelect" then
+    out.mimic = r.mimic
+  end
+  return out
+end
+
+-- ------- the HUDs, out at the window's own edges
+--
+-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
+-- whole of it. That left both HUD blocks huddled together in the middle of the
+-- frame with map showing on either side of them, which reads as a Game Boy
+-- screenshot pasted over a diorama rather than as the diorama's own furniture.
+--
+-- So each block is snapped to its own side: the foe's to the left edge of the
+-- window, the player's to the right. Nothing about either block changes -- same
+-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar
+-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape
+-- of the GB screen there is nowhere to go and the snap is a no-op.
+--
+-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI
+-- canvas and everything outside it is clipped away. So the layer is rendered to
+-- a texture and composited into the WORLD image instead, which is the one
+-- surface in this mode that covers the whole window.
+
+-- The rows each block is cut out of, full width. Generous on purpose:
+-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches
+-- further than the panel does, and the pokeball rows and the safari ball count
+-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies
+-- outside rows 0-96, and the two bands split that between them.
+OverworldBattle.HUD_BAND = {
+  enemy = { 0, 0, 160, 48 },
+  player = { 0, 48, 160, 48 },
+}
+
+-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted
+-- glass is cut to, plus the x its band is blitted at.
+--
+-- The foe's panel starts at the window's left edge and the player's ends at the
+-- right one. The vertical is untouched, so both stay on the rows the GB put
+-- them on. A band's own origin sits outside the window by the panel's inset --
+-- the couple of pixels a HUD shake can push past the edge are clipped there,
+-- which is the whole cost of the snap and is invisible.
+function OverworldBattle.snapRects(shot)
+  local s = shot.scale
+  local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player
+  local ex = -e[1] * s                       -- foe: panel's left edge to 0
+  local px = shot.pw - (p[1] + p[3]) * s     -- player: right edge to the far side
+  local rects = {
+    enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s },
+    player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s },
+  }
+  return rects, { enemy = ex, player = px }
+end
+
+-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
+-- blit will actually put it. The text box has not moved anywhere -- it is drawn
+-- where it always was -- but its glass is laid into the world image alongside
+-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
+-- pixel to a pixel rather than magnified out of a 160x144 canvas.
+local function toWorld(rect, shot)
+  local s = shot.scale
+  return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
+           rect[3] * s, rect[4] * s }
+end
+
+-- ------- the live battle
+--
+-- nil when no overworld battle is running. Never more than one: battles do
+-- not nest.
+local session = nil
+
+local function isIOS()
+  return love.system and love.system.getOS and love.system.getOS() == "iOS"
+end
+
+local function game()
+  return require("src.core.Game")
+end
+
+-- Whether this frame's HUDs went out to the window's edges instead of being
+-- drawn in the GB frame. False whenever the composite could not be made, which
+-- is what leaves the in-frame HUD as the fallback rather than no HUD at all.
+local function snapped()
+  return (session and session.snapped) and true or false
+end
+
+-- Put the map's cast back. Both lists are handed back by identity, so
+-- anything that captured one before the battle still sees the same table.
+local function restoreCast()
+  if not (session and session.state) then return end
+  if session.entities then session.state.entities = session.entities end
+  if session.ghosts then session.state.ghosts = session.ghosts end
+  session.entities, session.ghosts = nil, nil
+end
+
+-- Cull them. The player stays -- they are not an NPC, they are who the
+-- battle belongs to, and Fly/surf animations and the save's own capture read
+-- state.player through this list.
+--
+-- Only the DRAW lists are touched, and only while the overworld is frozen
+-- underneath a battle: StateStack updates the top state alone, so nothing
+-- walks, wanders, triggers or collides against a list that is short for
+-- these frames. The originals go back at battle.ended.
+local function cullCast(state)
+  session.entities = state.entities
+  session.ghosts = state.ghosts
+  state.entities = { state.player }
+  state.ghosts = {}
+end
+
+-- ------- one right battle layout
+--
+-- Everything this file composes is measured in the GB's own 160x144 frame: the
+-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs
+-- the frosted panels are cut to, and the full-frame white intercepted to let
+-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a
+-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those
+-- -- the mons would stand where no camera was solved for them, and the panels
+-- would land beside the HUDs they are supposed to be under.
+--
+-- So while a fight can be staged on the map there is one right answer, and it
+-- is SET rather than worked around. The engine reads the option live
+-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the
+-- top state for its surface the same way), so writing it here lands on the
+-- battle being pushed as well as every one after it.
+--
+-- This is the last line rather than the first: the OPTIONS menu takes the row
+-- off the list and pins the value while 3D-BTL is on (see main.lua), so a
+-- player is never offered a switch that gets reverted under them. What reaches
+-- here is a value that arrived some other way -- a save written before the mod
+-- was installed, the mod manager's own page, another mod.
+function OverworldBattle.forceOG(g)
+  g = g or game()
+  local opts = g and g.save and g.save.options
+  if not opts or opts.battleLayout ~= "wide" then return false end
+  opts.battleLayout = "og"
+  if g.writeOptions then pcall(g.writeOptions, g) end
+  return true
+end
+
+-- Where THIS fight stands, on whichever rung is running: the map's own
+-- ground, or the pair of discs a B rung carries with it.
+--
+-- The one place the two columns actually diverge, and it is worth stating
+-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a
+-- map whose authored entry is a refusal -- and the battle then plays exactly
+-- as the vanilla game does. A B rung cannot fail: its stage is not something
+-- the map has to have room for, so a fight in the tightest cave in Kanto is
+-- staged as readily as one on Route 1.
+function OverworldBattle.stageFor(state)
+  if OverworldBattle.discs() and Voxel3D.available() then
+    local okStage, arena = pcall(function()
+      return V.require("StadiumStage").arena(state.map)
+    end)
+    if okStage and arena then return arena end
+    -- the discs could not be built; fall through to the map, which is a
+    -- worse picture but a real one
+  end
+  local okFind, arena = pcall(BattleArena.find, state.map,
+                              state.player.cellX, state.player.cellY,
+                              state.player.surfing)
+  return (okFind and arena) or nil
+end
+
+-- Stage a battle triggered from `state`, if this mode can. Returns true when
+-- a session started -- which is also the only case where anything visible
+-- changes, so a map with no room for an arena plays exactly the vanilla
+-- battle it always did, cast and all.
+function OverworldBattle.begin(state, battle)
+  OverworldBattle.finish()
+  if not OverworldBattle.enabled() then return false end
+  if not (state and state.map and state.player) then return false end
+  if not Voxel3D.available() then return false end
+
+  local arena = OverworldBattle.stageFor(state)
+  if not arena then return false end
+
+  -- the fight is staged from here on, so the layout it is composed for is not
+  -- optional any more (see forceOG)
+  OverworldBattle.forceOG()
+
+  session = { state = state, arena = arena, battle = battle, shot = nil,
+              armed = false, token = 0 }
+  cullCast(state)
+  BattleCam.reset()
+  -- and, on the STADIUM rung, the pair of models that will stand on this
+  -- arena's two cells. Declines quietly on any other rung.
+  pcall(function() V.require("Stadium").begin(arena) end)
+  return true
+end
+
+-- The fallback entry point: a battle that arrived without going through the
+-- overworld's own pushBattle (a link battle, a script pushing a BattleState
+-- directly). Nothing visible depends on the cull for those -- the wipe has
+-- already been and gone -- but the arena still has to be picked.
+function OverworldBattle.ensure(battle)
+  if session then
+    -- a battle pushed through the overworld reaches begin() before it is
+    -- built far enough to draw; battle.started is where it is finished
+    if battle and not session.battle then session.battle = battle end
+    return
+  end
+  local g = game()
+  local ow = g and g.overworld
+  if ow and ow.map then OverworldBattle.begin(ow, battle) end
+end
+
+-- The arena this battle is staged on, or nil. Read by the shot driver so a
+-- screenshot can be labelled with the ground it was taken on.
+function OverworldBattle.arena()
+  return session and session.arena or nil
+end
+
+function OverworldBattle.finish()
+  if not session then return end
+  restoreCast()
+  session = nil
+  Voxel3D.camera = nil
+  pcall(function() V.require("Stadium").finish() end)
+end
+
+-- ------- per-frame
+--
+-- Driven from the voxel pipeline's update hook, which the engine ticks every
+-- frame regardless of which state is on top -- including the frames the
+-- transition wipe covers, which is what gets the arena's meshes built before
+-- the first battle frame needs them.
+--
+-- The scene is rendered HERE rather than inside the battle's draw, because
+-- update runs with no canvas bound: a 3D pass that binds a depth target and
+-- unbinds to the screen when it is done cannot do that in the middle of
+-- someone else's frame without putting the frame back itself.
+function OverworldBattle.update(dt)
+  if not session then return end
+
+  local g = game()
+  local top = g and g.stack and g.stack:top()
+  local ow = g and g.overworld
+  -- A battle that ended without saying so (a script tearing the state down,
+  -- a path that never emits battle.ended) would otherwise leave the cast
+  -- culled for good. Armed only once something has actually covered the
+  -- overworld, because begin() runs while the overworld is still on top.
+  if top ~= nil and top ~= ow then
+    session.armed = true
+  elseif session.armed then
+    OverworldBattle.finish()
+    return
+  end
+
+  -- Whether the shot is the player's to steer at all. BACK SPRITES pins
+  -- their own mon to the GB's slot on the menu while the foe stands out on
+  -- the map, and there is no angle that half-framed, half-solid
+  -- composition survives -- so under it the camera holds the shot the rig
+  -- was solved for (the slow drift aside, which was always there). Polled
+  -- per frame rather than latched at battle start: the row is reachable
+  -- from the mod manager's page mid-session.
+  -- A LET'S GO capture session holds it too: the throw is aimed in this
+  -- exact framing, and a camera that moved under a ball in flight would
+  -- bend where the flick was pointed after the fact. (The session also
+  -- sets BattleCam.still, which is what stops the drift -- see
+  -- CatchThrow.begin.)
+  BattleCam.steerable = not OverworldBattle.backPinned()
+                        and not BattleScene.capture
+  -- the right stick, read as a rate before the rig is built from it: the
+  -- wheel, the keys, the mouse and a drag all arrive as events and have
+  -- already landed, but a stick is a HELD position and only a tick can
+  -- turn it into travel (CamControl, which owns every one of those inputs)
+  pcall(V.require("CamControl").tick, dt)
+  BattleCam.update(dt)
+  -- the battle only exists once it has been pushed; a session opened at
+  -- pushBattle time has it, one opened from battle.started was handed it
+  session.battle = session.battle or (top ~= ow and top or nil)
+  -- the world pass is hidden behind the battle, so mesh builds get the wide
+  -- slice: nothing visible can hitch on them
+  ChunkMesher.pump(true)
+
+  -- The STADIUM models, ahead of the pics, because what they decide is
+  -- WHICH pics are needed: a side a model is standing on gets no billboard
+  -- texture rendered for it at all (see Stadium.covers). Posed and skinned
+  -- here too, once for the frame -- the sun pass, the camera and, in a
+  -- headset, both eyes all draw the same skinned meshes.
+  pcall(function()
+    local host = (session.arena and session.arena.map) or session.state.map
+    V.require("Stadium").update(dt, session.battle,
+                                BattleScene.groundY(host, session.arena))
+  end)
+
+  -- The mons' textures are rendered HERE, with no canvas bound, for the same
+  -- reason the scene is: the pics layer binds its own targets, and doing that
+  -- inside somebody else's frame means putting the frame back afterwards.
+  local okTex, textures = pcall(OverworldBattle.textures, session.battle)
+  if not okTex then textures = nil end
+  -- stashed for the VR eye pass, which stands these same pics on the map
+  -- in ITS view of the world (VoxelScene's eyes path). Stashed HERE
+  -- because rendering them binds canvases, which the eye pass -- mid-scene
+  -- when it wants them -- must never do; reading a stashed canvas is free.
+  session.textures = textures
+  -- and the move-animation layer, for the same eyes -- rendered only
+  -- while a headset is actually watching, because only the VR world
+  -- pass draws it (the flat screen has the animations in-frame already)
+  session.animTex = nil
+  local okVR, vrOn = pcall(function()
+    local vr = V.require("VR")
+    return vr.active and vr.active() or false
+  end)
+  if okVR and vrOn and session.battle then
+    local okA, anim = pcall(OverworldBattle.animTexture, session.battle)
+    if okA then session.animTex = anim end
+  end
+  session.token = (session.token or 0) + 1
+  local ok, shot = pcall(BattleScene.render, session.state, session.arena,
+                         textures, session.token)
+  if not ok then
+    -- One failure retires the arena for THIS battle and nothing else: the
+    -- battle screen carries on as the engine's own, the free-roam pipeline
+    -- this runs inside keeps rendering the overworld, and the next battle
+    -- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
+    -- guard, which retires a pipeline for the session.
+    session.shot = nil
+    session.snapped = false
+    session.broken = true
+    V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
+                   .. "on the plain battle background", tostring(shot))
+    return
+  end
+  session.snapped = false
+  if shot and shot.canvas then
+    -- the depth of field is measured off the two marks: the slab in focus is
+    -- the one the mons are standing in, at whatever the drift has done to
+    -- where that lands
+    local y1 = shot.ly + shot.player[2] * shot.scale
+    local y2 = shot.ly + shot.enemy[2] * shot.scale
+    local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
+    local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
+                                 focusY, band, range)
+    if okDof and blurred then shot.canvas = blurred end
+    -- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
+    -- so a panel over a blurred far field is frosted from what is actually
+    -- behind it
+    pcall(BattleHud.build, shot.canvas)
+    -- and then the HUDs go ON that backdrop, snapped out to the window's own
+    -- edges (snapHUDs). Here rather than in the battle's draw for the same
+    -- reason the scene is: it binds a canvas of its own. After the frost, so
+    -- the glass is frosted from the world alone and never from the glyphs
+    -- about to sit on it.
+    local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
+    session.snapped = (okHud and up) and true or false
+    -- once per battle, not once per frame: a driver that cannot do this cannot
+    -- do it sixty times a second either, and the fallback is silent and fine
+    if not okHud and not session.hudWarned then
+      session.hudWarned = true
+      V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
+                     .. "in the battle frame this battle", tostring(up))
+    end
+  end
+  session.shot = shot
+end
+
+-- The finished shot for this frame, or nil when there is none and the battle
+-- should draw the way it always did.
+function OverworldBattle.shot()
+  if not session or session.broken then return nil end
+  local s = session.shot
+  if s and s.canvas then return s end
+  return nil
+end
+
+-- The stag