DramaticShapeVoxelMod archival mirror · read-only
95771403d9ffe44ea698462b052927f0ee151be0

patch holes on flower/grass models

DramaticShape · 2026-08-03 · parent 9542ba94b1

patch holes on flower/grass models

Changed files

CHANGELOG.md        |  83 ++++++++++++++++++++++++++++
 README.md           |   5 ++
 lib/Structures.lua  | 117 ++++++++++++++++++++++++++--------------
 main.lua            |  25 +++++++++
 tests/cam_shots.lua | 211 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 401 insertions(+), 40 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22d4f77..ec33298 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,89 @@
   well-known way to make people ill. The rung still changes the walk and
   the sprites the same way.
 
+- **Every camera zooms, on whatever the machine has.** The mouse wheel,
+  `Q`/`E`, a two-finger pinch and the pad's two stick clicks all reach
+  whichever camera is actually in front of you -- the third-person boom,
+  the staged battle's lens, or the engine's own survey zoom on an orbit
+  rung. One module (`lib/CamControl.lua`) answers "which camera is this
+  aimed at" so the four cameras never race each other for an event, and
+  forwards everything it does not claim. 1ST claims nothing: the eye is in
+  the player's head, and a pinch there would only wind the survey zoom for
+  whenever they stepped back out.
+
+- **The battle camera is yours to steer.** The right stick, a drag across
+  the screen or the mouse walks the staged shot around the arena and raises
+  the seat; the wheel, `Q`/`E`, a pinch or a stick click work the lens.
+
+  Both axes stop where the composition does. LEFT stops at the shot the rig
+  was solved for, because there is nothing to the left of it. RIGHT ends
+  SIDE-ON -- the eye square to the arena's axis, both Pokemon at the same
+  distance instead of one behind the other -- computed from each rig's own
+  stance rather than written down. DOWN stops at the rig's low stance and
+  UP is 45 degrees above it, raised about the focus at a constant radius so
+  climbing never doubles as zooming. Input accumulates into a goal the eye
+  eases after, so a flick reads as the camera being pushed rather than
+  dragged.
+
+  The lens **opens by exactly the amount the pair spreads**: the solved
+  shot looks along the arena's axis at a shallow angle, which foreshortens
+  the gap between the two mons to less than half its length, and swinging
+  round or climbing un-foreshortens it. Left alone that threw both Pokemon
+  off the edges of the frame at the far end of either range, which made the
+  whole far end unusable.
+
+  Where you leave the camera is where the next battle opens. An angle and a
+  lens you chose are how you want to watch battles, not a fact about one
+  encounter.
+
+- **Move animations track the camera.** They already slid to follow the
+  pair's midpoint; now they follow its SEPARATION too. Both mons are
+  geometry standing on the map, so the camera sizes them -- and an effects
+  layer that kept the authored 106-pixel spacing through a zoom and a
+  60-degree swing fired its beams into the air beside the Pokemon they were
+  aimed at.
+
+- **BACK SPRITES locks the battle camera.** That setting pins your own mon
+  to the GB's slot on the menu while the foe stands out on the map, and no
+  angle holds a composition that is half frame and half world. The steer,
+  the climb and the lens all stand down -- in the rig as well as at the
+  inputs, so an angle stored from before the row was switched on cannot
+  leave it steered anyway. The slow drift stays: it was always there under
+  BACK SPRITES and two degrees is not a composition problem.
+
+- **BATTLE BG is pinned to WHITE and its row comes off the menu.** The row
+  picks what fills the screen AROUND the battle, and this mode fills the
+  window with the map the fight is standing on -- there are no voids left
+  for it to be about. WORLD was actively wrong under it: it makes the
+  battle non-opaque so the engine draws a second, dimmed copy of the
+  overworld beneath the arena pass's own. Pinned rather than merely hidden,
+  so a save written before the mod was installed cannot carry a value the
+  menu can no longer reach. Uninstall and the row is back.
+
+### Fixed
+
+- **Grass and flowers are closed off at the sides.** Both stand as
+  per-pixel slabs built from runs of lit pixels, and only the front, the
+  back and a lid were ever emitted -- so from any angle off square you
+  looked straight in through the open end of every run and out the far
+  side. At the low cameras this release adds, that is most of the time.
+  Each run now wears end walls in the colour of the pixel they close off.
+
+  Flowers needed more than that, because a flower SWAYS: the mesh spans the
+  union of every animation frame and each frame is cut back out in texture
+  space, so a pixel that drops out of a frame takes the union's wall with
+  it and leaves an interior boundary that never had one. The first cut of
+  this looked solid on the base frame and still had gaps on every other.
+  Every pixel of a flower now carries a cap on all four of its remaining
+  faces: enclosed and invisible while its neighbour is there, and already
+  in place the moment the animation takes that neighbour away.
+
+- **No more machine-gun bonking in 1ST and 3RD.** The grid walk's collision
+  sound marks a discrete event -- a direction pressed, a step refused. A
+  free walk has no such moment: the body slides along every wall it grazes,
+  continuously, so a corridor taken at a slight angle rang the bonk twice a
+  second from end to end. The wall stopping you is the feedback.
+
 ## 1.5.2
 
 ### Added
diff --git a/README.md b/README.md
index 2b79a9f..0636994 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,11 @@ through the engine's own machinery.
 | right stick | look |
 | a touch drag off the overlay's controls | look |
 | left stick / touch d-pad / arrow keys | walk, relative to where the camera looks |
+| wheel, `Q` / `E`, pinch, or a stick click | **3RD only** — let the boom out and pull it in (`Q` and left stick click out, `E` and right stick click in) |
+
+On an **orbit rung** the same wheel, `Q`/`E` and pinch drive the engine's own
+survey zoom. On **1ST** they do nothing at all: the eye is in your head, and
+there is no distance to change.
 
 On **3RD** the boom shortens against whatever is behind you, so backing into
 a wall walks the camera in to your shoulders rather than through it — squeeze
diff --git a/lib/Structures.lua b/lib/Structures.lua
index be4aac2..2c99c8d 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -3314,29 +3314,56 @@ end
 -- carries the animation: when a frame keys that pixel out, the wall's own
 -- fragments discard with the faces either side of it, so a swaying tuft
 -- never leaves a wall standing where its blade no longer is.
+-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is
+-- built once, over the UNION of every frame's mask, and each frame is cut
+-- out again in texture space -- so a run that is six pixels wide in the
+-- union may be two pixels wide in the frame on screen, and the four pixels
+-- that dropped out took the union's end walls with them. What is left
+-- exposed is an interior boundary, which had no wall because in the union
+-- it was not a boundary at all. That is the gap that survived closing the
+-- run ends: the first frame looked solid and every other frame did not.
+--
+-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall
+-- between two lit pixels is enclosed by the front and back faces and never
+-- seen; the moment its neighbour is keyed out it becomes the edge, already
+-- in place and already wearing the right colour. Each is inset a hair into
+-- its own pixel so the two that meet at a boundary are not coplanar -- the
+-- voxel pass draws with culling off, and two quads in the same plane would
+-- z-fight rather than politely take turns.
+local SIDE_INSET = 0.03
+
 local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
-                         ax0, ay0, atlasW, atlasH, py, lit)
+                         ax0, ay0, atlasW, atlasH, py, lit, everyPixel)
   local function texel(px)
     return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
   end
-  if not lit(ix - 1, py) then
-    local u, v = texel(ix)
-    quads[#quads + 1] = {                 -- the run's left wall, facing -X
-      { ix, yBot, zB }, { ix, yBot, zF },
-      { ix, yTop, zF }, { ix, yTop, zB },
+  local function left(px, at)
+    local u, v = texel(px)
+    quads[#quads + 1] = {                 -- facing -X
+      { at, yBot, zB }, { at, yBot, zF },
+      { at, yTop, zF }, { at, yTop, zB },
       uv = { { u, v }, { u, v }, { u, v }, { u, v } },
       shade = OBJ_SHADE.side,
     }
   end
-  if not lit(ix2 + 1, py) then
-    local u, v = texel(ix2)
-    quads[#quads + 1] = {                 -- and its right wall, facing +X
-      { ix2 + 1, yBot, zF }, { ix2 + 1, yBot, zB },
-      { ix2 + 1, yTop, zB }, { ix2 + 1, yTop, zF },
+  local function right(px, at)
+    local u, v = texel(px)
+    quads[#quads + 1] = {                 -- facing +X
+      { at, yBot, zF }, { at, yBot, zB },
+      { at, yTop, zB }, { at, yTop, zF },
       uv = { { u, v }, { u, v }, { u, v }, { u, v } },
       shade = OBJ_SHADE.side,
     }
   end
+  if everyPixel then
+    for px = ix, ix2 do
+      left(px, px + SIDE_INSET)
+      right(px, px + 1 - SIDE_INSET)
+    end
+    return
+  end
+  if not lit(ix - 1, py) then left(ix, ix) end
+  if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) end
 end
 
 -- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
@@ -3590,41 +3617,51 @@ local function flowerTemplate(map, data, tileId)
           uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
           shade = OBJ_SHADE.back,
         }
-        -- petal tips: a top strip where the row above is clear. The
-        -- strip samples its own row's texel, so a tip that is not in
-        -- the current frame discards with the face beneath it
-        if not on(ix, py - 1) then
-          quads[#quads + 1] = {
-            { ix, yTop, zB }, { ix2 + 1, yTop, zB },
-            { ix2 + 1, yTop, zF }, { ix, yTop, zF },
-            uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } },
+        -- ------- the shell, closed on all four remaining faces
+        --
+        -- A flower SWAYS: the geometry spans the union of every animation
+        -- frame's mask and each frame is cut back out of it in texture
+        -- space (see the header). So "is there a pixel next door" has two
+        -- different answers -- one in the union this mesh was built from,
+        -- and one in the frame actually on screen -- and only the second
+        -- decides what is exposed.
+        --
+        -- Closing the union's own edges is therefore not enough, and was
+        -- the bug the first cut of this shipped: the base frame looked
+        -- solid and every other frame still had gaps, because a pixel that
+        -- drops out of a frame takes the union's wall with it and leaves an
+        -- interior boundary that never had one.
+        --
+        -- So every pixel gets a cap on all four of its remaining faces,
+        -- whatever its neighbours do. A cap between two lit pixels sits
+        -- inside the slab, enclosed by the front and back faces, and is
+        -- never seen; the moment its neighbour is keyed out it IS the edge,
+        -- already there and already wearing the right colour. Each samples
+        -- its own pixel's texel, so it appears and vanishes with the pixel
+        -- it belongs to rather than with the one it is closing off.
+        --
+        -- Inset a hair into its own pixel, because the voxel pass draws
+        -- with culling off: the two caps that meet at a boundary would be
+        -- coplanar and z-fight rather than politely take turns.
+        for px = ix, ix2 do
+          local tu = (ax0 + px + 0.5) / atlasW
+          local tv = (ay0 + py + 0.5) / atlasH
+          local xa, xb = px, px + 1
+          local yT = yTop - SIDE_INSET
+          local yB = yBot + SIDE_INSET
+          quads[#quads + 1] = {           -- the pixel's own lid
+            { xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF },
+            uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
             shade = OBJ_SHADE.top,
           }
-        end
-        -- and the same strip on the bottom, where the row below is clear:
-        -- a petal that ends mid-air is a solid thing seen from underneath,
-        -- and a low camera (1ST, 3RD, the battle's floor-level seat) is
-        -- looking straight up at it
-        if not on(ix, py + 1) then
-          quads[#quads + 1] = {
-            { ix, yBot, zF }, { ix2 + 1, yBot, zF },
-            { ix2 + 1, yBot, zB }, { ix, yBot, zB },
-            uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
+          quads[#quads + 1] = {           -- and its floor
+            { xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB },
+            uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
             shade = OBJ_SHADE.bottom,
           }
         end
-        -- The ENDS of the run, which close the slab off sideways. Without
-        -- them the flower is two faces and a lid: from anywhere but square
-        -- on you look straight through its open edge and out the far side,
-        -- which is what stopped it reading as a solid thing.
-        --
-        -- Each wall samples ONE texel -- the run's own end pixel, at its
-        -- centre -- so the side wears the colour of the pixel it is closing
-        -- off (the nearest coloured pixel there is) rather than a keyed
-        -- hole, and discards with that pixel when the animation frame it
-        -- belongs to is not the one on screen.
         sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
-                  ax0, ay0, atlasW, atlasH, py, on)
+                  ax0, ay0, atlasW, atlasH, py, on, true)
         ix = ix2 + 1
       else
         ix = ix + 1
diff --git a/main.lua b/main.lua
index df02cee..4f5ebf9 100644
--- a/main.lua
+++ b/main.lua
@@ -646,6 +646,25 @@ end
 --
 -- Everything they did is still reachable: uninstall the mod and both rows are
 -- back, at whatever they were last set to.
+-- BATTLE BG rides the same reasoning, and comes off for a reason of its own.
+-- The row picks what fills the screen AROUND the battle's 160x144 field --
+-- WHITE paper, BLACK bars, or the frozen overworld dimmed behind it -- and
+-- all three were answers to the same question: what to do with the voids,
+-- given the battle is a small picture in the middle of a big window.
+--
+-- This mod answers that question differently and permanently. A staged fight
+-- fills the whole window with the map the fight is standing on, and the
+-- flat battle screen it composites over it is drawn on the mode's own
+-- surface; there are no voids left for the row to fill. WORLD is the worst
+-- of the three under it -- it makes the battle non-opaque so the engine
+-- draws the overworld underneath, which is a SECOND copy of the world drawn
+-- under the one the arena pass already put there, dimmed and at a different
+-- camera. BLACK bars over a diorama read as a letterboxed screenshot.
+--
+-- So the value is pinned at WHITE, which is the one the mode was composed
+-- against, and the row comes off the menu on the same reasoning as TILT and
+-- GBC FX: a row that no longer decides anything is worse than no row.
+-- Uninstall the mod and it is back, at whatever it was last set to.
 local function pinEngineFx(game)
   game = game or require("src.core.Game")
   local opts = game and game.save and game.save.options
@@ -654,7 +673,9 @@ local function pinEngineFx(game)
   local changed = false
   if opts then
     changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
+                or (opts.battleBg or "white") ~= "white"
     opts.tilt, opts.gbcfx = 0, 0
+    opts.battleBg = "white"
   end
   pcall(Tilt.setLevel, 0)
   pcall(GBCFX.setLevel, 0)
@@ -672,6 +693,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
   pinEngineFx(game)
   dropRow(out, "tilt")
   dropRow(out, "gbcfx")
+  -- and BATTLE BG with them: this mode fills the window with the map, so
+  -- the row's whole question -- what to put in the voids around the battle
+  -- -- no longer has voids to be about (see pinEngineFx)
+  dropRow(out, "battleBg")
   -- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
   -- one away. While a fight can be staged on the map, OG is the only layout it
   -- can be composed in (OverworldBattle.forceOG), so the value is pinned there
diff --git a/tests/cam_shots.lua b/tests/cam_shots.lua
new file mode 100644
index 0000000..3bcbba3
--- /dev/null
+++ b/tests/cam_shots.lua
@@ -0,0 +1,211 @@
+-- Scratch driver: the cameras the player steers -- the third-person boom's
+-- zoom, and the battle shot's orbit, climb and lens, including the far
+-- stops (side-on, 45 degrees up) and what BACK SPRITES takes away.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cam_shots.lua \
+--   SHOT_DIR=.scratchpad/camshots lovec.exe .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or "shots/cam")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[cam] DRAMATIC_SHAPE mod not loaded")
+    return love.event.quit()
+  end
+  local V = handle.lib
+  local FirstPerson = V.require("FirstPerson")
+  local ThirdPerson = V.require("ThirdPerson")
+  local BattleCam = V.require("BattleCam")
+  local OverworldBattle = V.require("OverworldBattle")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+  local DayNight = V.require("DayNight")
+
+  pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
+  pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  -- The flower's animation frame, pinned per shot rather than left to the
+  -- clock: the mesh spans the union of every frame and each one is cut back
+  -- out in texture space, so a gap that only opens on frame 2 is invisible
+  -- to a driver that always photographs frame 0 -- which is exactly how the
+  -- first cut of the closed sides shipped looking correct.
+  local ANIM = { frame = 0 }
+  TileRenderer.animFrame = function() return ANIM.frame end
+  DayNight.setting:sync("day")
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local shots = 0
+  local function shot(name)
+    if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
+      shots = shots + 1
+    end
+  end
+
+  -- ------- the boom's zoom
+  U.teleport(game, "PALLET_TOWN", 13, 14, "down")
+  Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
+  Pipelines.setLevel("tiltshift", 0)
+  settle()
+  for _ = 1, 200 do
+    if FirstPerson.blend >= 1 then break end
+    U.wait(1)
+  end
+  FirstPerson.yaw = math.pi
+  U.wait(20)
+  for _, z in ipairs({ "min", "default", "max" }) do
+    ThirdPerson.zoomGoal = (z == "min" and ThirdPerson.ZOOM_MIN)
+                           or (z == "max" and ThirdPerson.ZOOM_MAX) or 1
+    for _ = 1, 90 do U.wait(1) end
+    print(("[cam] boom %-8s zoom %.2f  len %.1f"):format(z, ThirdPerson.zoom,
+                                                        ThirdPerson.len))
+    shot("boom_" .. z)
+  end
+  ThirdPerson.zoomGoal = 1
+  U.wait(60)
+
+  -- ------- the standees' closed sides
+  --
+  -- Pallet's flower beds and Route 1's tall grass, from a low camera close
+  -- in -- the angle that showed the slabs were open at the ends of every
+  -- run and let you see straight through them.
+  ThirdPerson.zoomGoal = ThirdPerson.ZOOM_MIN
+  for _, s in ipairs({
+    { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
+      pitch = math.rad(6), label = "flowers_low" },
+    { map = "PALLET_TOWN", x = 13, y = 14, yaw = 3 * math.pi / 4,
+      pitch = math.rad(30), label = "flowers_angled" },
+    { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi,
+      pitch = math.rad(8), label = "grass_low" },
+  }) do
+    U.teleport(game, s.map, s.x, s.y, "down")
+    settle()
+    FirstPerson.yaw, FirstPerson.pitch = s.yaw, s.pitch
+    U.wait(60)
+    -- EVERY animation frame, because a gap the union closed and a frame
+    -- reopens is only visible on that frame
+    for f = 0, 3 do
+      ANIM.frame = f
+      U.wait(6)
+      shot(("%s_f%d"):format(s.label, f))
+    end
+    ANIM.frame = 0
+  end
+  ThirdPerson.zoomGoal = 1
+  U.wait(60)
+
+  -- ------- the battle shot
+  --
+  -- Staged the way the game stages one, then steered to each stop with the
+  -- module's own entry points -- the same ones the wheel, the stick and a
+  -- drag reach.
+  Pipelines.setLevel("voxel", 4)
+  U.wait(60)
+  do
+    -- Somebody to fight WITH: a fresh driver save has an empty party, and
+    -- a trainer battle with nobody to send out ends on the frame it starts
+    -- -- which is what made every steered shot below sample a dead session.
+    local Pokemon = require("src.pokemon.Pokemon")
+    game.save.party = {
+      Pokemon.new(game.data, "CHARIZARD", 45),
+      Pokemon.new(game.data, "PIKACHU", 40),
+    }
+    game.save.player.name = "RED"
+    local BattleState = require("src.battle.BattleState")
+    local class = next(game.data.trainers)
+    local battle = BattleState.newTrainer(game, class, 1)
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+  end
+  -- The wipe, then just enough of the send-out chatter to get both mons
+  -- standing on the arena -- and NOT one tap more. A is also FIGHT and then
+  -- the first move, so tapping past the intro starts an exchange and the
+  -- fight can be over before the camera has been steered anywhere.
+  U.wait(70)
+  for _ = 1, 40 do
+    if OverworldBattle.shot() then break end
+    U.tap(game, "a")
+    U.wait(10)
+  end
+  U.wait(60)
+  print(("[cam] staged: arena %s  shot %s  top %s")
+        :format(tostring(OverworldBattle.arena() ~= nil),
+                tostring(OverworldBattle.shot() ~= nil),
+                tostring(game.stack:top() ~= game.overworld)))
+  if not OverworldBattle.shot() then
+    print("[cam] no staged battle -- skipping the battle shots")
+    print(("[cam] %d shots into %s"):format(shots, ROOT))
+    return love.event.quit()
+  end
+
+  local function settleCam(label)
+    for _ = 1, 120 do U.wait(1) end
+    print(("[cam] battle %-20s orbit %.2f/%.2f  pitch %.2f/%.2f  "
+           .. "zoom %.2f/%.2f  steerable %s  live %s")
+          :format(label, BattleCam.orbit, BattleCam.orbitGoal,
+                  BattleCam.pitch, BattleCam.pitchGoal,
+                  BattleCam.zoom, BattleCam.zoomGoal,
+                  tostring(BattleCam.steerable),
+                  tostring(OverworldBattle.shot() ~= nil)))
+    shot("battle_" .. label)
+  end
+
+  BattleCam.recentre()
+  settleCam("home")
+
+  -- right to the stop: this must land SQUARE to the arena's axis
+  BattleCam.dragOrbit(10)
+  settleCam("side_on")
+
+  -- and there is nothing to the left of home
+  BattleCam.recentre()
+  BattleCam.dragOrbit(-10)
+  settleCam("left_stop")
+
+  -- up to the stop, and refusing to go below home
+  BattleCam.recentre()
+  BattleCam.dragPitch(10)
+  settleCam("high")
+  BattleCam.recentre()
+  BattleCam.dragPitch(-10)
+  settleCam("low_stop")
+
+  -- the lens at both ends
+  BattleCam.recentre()
+  for _ = 1, 40 do BattleCam.stepZoom(-1) end
+  settleCam("zoom_in")
+  BattleCam.recentre()
+  for _ = 1, 40 do BattleCam.stepZoom(1) end
+  settleCam("zoom_out")
+
+  -- everything at once, which is the shot a player would actually build
+  BattleCam.recentre()
+  BattleCam.dragOrbit(0.55)
+  BattleCam.dragPitch(0.5)
+  for _ = 1, 4 do BattleCam.stepZoom(-1) end
+  settleCam("steered")
+
+  -- What BACK SPRITES takes away is NOT shot here: the flag it works
+  -- through is re-derived from the row every frame
+  -- (OverworldBattle.update), so a driver cannot hold it down for the
+  -- hundred-odd frames a settled shot needs, and a picture that claimed to
+  -- show a locked camera while the camera was in fact free would be worse
+  -- than no picture. The suite asserts it instead, on both axes and the
+  -- lens, and on the RIG as well as on the inputs.
+  BattleCam.recentre()
+
+  print(("[cam] %d shots into %s"):format(shots, ROOT))
+  love.event.quit()
+end