DramaticShapeVoxelMod archival mirror · read-only
4da8e5dc3e00c582c938df4fa51b8b92df8978e8

add day/night filter to 2d

DramaticShape · 2026-07-30 · parent 26d1d96d52

add day/night filter to 2d

Changed files

CHANGELOG.md                  | 101 +++++++++++++++---
 README.md                     |   2 +-
 lib/BattlePics.lua            | 207 ++++++++++++++++++------------------
 lib/DayTint.lua               | 169 ++++++++++++++++++++++++++++++
 main.lua                      |  94 ++++++++++++-----
 mod.card                      |   1 +
 tests/dramatic_shape_test.lua | 351 ++++++++++++++++++++++++++++++++++++++++++++++----------------
 7 files changed, 696 insertions(+), 229 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 331bf61..e340500 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,20 +46,44 @@
   gap between its legs. Read across all 305 of the game's battle pics, that
   rule finds an enclosed hole in exactly none of them.
 
-  So a second rule: paper is what the figure is drawn OVER. A transparent
-  pixel with ink somewhere to its left AND to its right AND above it is under
-  the drawing and gets filled; the sky between a pair of ears has nothing over
-  it and stays sky, the ground beside a foot has nothing on one side of it and
-  stays ground. The silhouette is untouched either way, so the mon still cuts
-  cleanly against the world. Three running scans, cached per pic.
-
-  Asking for ink BELOW as well was the obvious first cut and it is wrong,
-  which took a second pass to find: a battle pic is cropped flush at the feet,
-  bottom-aligned in its slot with all the margin at the top, so half of any
-  mon's belly has bare frame edge under it. Requiring it left a clean vertical
-  channel of world showing straight down the middle of a Clefairy. Below
-  cannot be accepted on its own either, or the whole sky over a mon's head
-  fills in -- it has the mon under it. The asymmetry is the rule.
+  The fix is to start the flood somewhere else: at the edges of the ARTWORK'S
+  OWN BOUNDING BOX, and at three of them -- left, right and top. The bottom is
+  closed, because it is not a side the background is behind, it is where the
+  drawing was CUT. A pic is bottom-aligned in its slot with all the margin at
+  the top, so a mon's lowest row is the last row it was given and everything
+  below the belly simply stops. Treat that cut as open and the background
+  pours up inside the figure, which is the channel of world that used to show
+  through a Clefairy.
+
+  That is exact rather than a heuristic: nothing is filled because of what
+  surrounds it, only because the background provably cannot reach it. Which is
+  why it needs no idea whether it is holding a front pic or a back one -- the
+  sky between a pair of ears reaches the top edge and stays sky, the gap
+  between a body and a raised tail reaches the side and stays gap, the belly
+  reaches neither and is paper. The silhouette is untouched, so the mon still
+  cuts cleanly against the world.
+
+  It replaces the border flood outright rather than sitting beside it, since
+  anything the border could not reach the box edges cannot reach either.
+
+  The bottom edge needs one more distinction, because two different things
+  meet the underside of a figure. A DRAIN is where the drawing ran out -- a
+  belly whose white carries on down until the artist stopped, leaking out
+  through the inch between a body and a leg -- and is sealed. A MOUTH is the
+  space between two legs, background that happens to be enclosed on three
+  sides, and is left open so the world shows through a trainer's stride.
+
+  Width tells them apart, and on this game's art it is not a close call.
+  Measured along the bottom of every battle pic, the drains run 3 and 4 pixels
+  (Clefairy's back, Wartortle's back, Red's back) and the mouths run 10, 12, 14
+  and 17 (a Rattata's underbelly, Blue's stride, Brock's, a Pikachu's back).
+  Nothing lands between 4 and 10, so the cut is taken at 6 with room either
+  side rather than tuned to one sprite. Apart from that number the rule stays
+  exact.
+
+  Front pics come back untouched, and not by being special-cased: they are
+  near-solid silhouettes with almost nothing inside them to fill, so their own
+  shape is what says so.
 
   Both mons were affected -- the cards in the arena as much as anything -- so
   this lands wherever a battle pic is drawn over the world, not just under
@@ -80,8 +104,57 @@
   the menu has no position in the scene to be shadowed at, so it carries the
   hour and not the weather.
 
+### Added
+
+- **The hour reaches the FLAT world too, not just the diorama.** DAYTIME drove
+  the 3D pass through the voxel shader's own tint uniform -- a uniform the 2D
+  tile path never runs -- so with VOXEL off, the same evening that fell on the
+  diorama left the flat world at permanent noon. One clock, two worlds, one of
+  them ignoring it. Outdoor maps now get the same multiply, painted as one
+  rectangle over the composited world.
+
+  The whole difficulty is WHERE, and it is worth writing down. Not on the world
+  canvas: in a colorized mode that canvas is grayscale art and the blit that
+  puts it on screen runs it through the palette shader, which classifies each
+  pixel into a shade BY ITS RED CHANNEL -- multiply a night blue over it first
+  and every pixel lands in the wrong bucket, so the world does not darken, it
+  changes colour. Not over the finished frame either, or the dialog boxes and
+  menus darken along with the world they are held up in front of, which is the
+  same reason the tilt-shift blur is a `worldPresent` and not a `present`.
+
+  Which leaves the instant between the world blit and the UI blit, and the
+  engine has no seam there -- `worldPresent` only runs when a PIPELINE produced
+  the world, which in flat mode is precisely what did not happen. So
+  `Renderer:endFrame` is wrapped and the UI canvas's own draw is watched for:
+  `blit` passes the canvas it is compositing as the first argument, so the
+  first draw of `Renderer.canvas` IS the boundary, by identity rather than by
+  counting. The shader and scissor that call arrives under belong to the UI
+  blit already in progress, so both are put aside for the rectangle and handed
+  straight back.
+
+  Skipped entirely when a pipeline drew the frame (it tinted itself, and twice
+  is wrong), indoors (a room has no sky to take its light from), and at midday
+  (a multiply by white) -- so a game with the clock at DAY issues not one extra
+  call.
+
 ### Changed
 
+- **FULL no longer takes the two battle rows off the menu.** It still owns the
+  rows that describe the LOOK -- the wireframe, the horizon bend, the blur, the
+  hour -- because it is a preset for the diorama and a row that no longer
+  decides anything is worse than no row. 3D-BTL and BACK SPRITES are not that:
+  one decides what a fight is drawn OVER and the other how it is framed.
+  FULL still SETS both on arrival; it does not hold them, and leaving them
+  reachable is the difference between a preset and a lock.
+
+  This makes `stagedBattles()` honest as a side effect. It used to answer yes
+  under FULL as well, on the grounds that FULL owned the 3D-BTL row and
+  switched it on -- safe only while the row was hidden. With the row reachable
+  from inside FULL, that clause would have claimed staged battles for a preset
+  the player had just switched them off inside, pinning BATTLE LAYOUT to OG for
+  a fight that never gets staged. The row is the only thing that decides now,
+  which is what `OverworldBattle.begin` and `wantsFront` already believed.
+
 - **TILT and GBC FX are off the OPTIONS menu entirely while this mod is
   installed.** Both fight the diorama and both were already half-taken: the
   mode's own key forces them off on every press, and the registry switches
diff --git a/README.md b/README.md
index b40d6cf..6886926 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ menu.
 | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
 | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
 | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
-| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors; held at SYNC (and off the menu) while VOXEL is FULL |
+| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
 
 **3D-BTL** is on by default and is independent of **VOXEL**: battles draw
 on the world whether or not the free-roam camera is pitched over.
diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua
index e2983c7..f40d816 100644
--- a/lib/BattlePics.lua
+++ b/lib/BattlePics.lua
@@ -16,41 +16,49 @@
 -- image says which was which. There is no distinction to recover; there is one
 -- to draw.
 --
--- Two rules, and a pixel filled by either is paper:
---
---   ENCLOSED   the transparent region outside the figure is flood-filled from
---              the border, and anything transparent the flood cannot reach is
---              a hole the artwork closes on all sides.
---
---   UNDER THE  anything transparent with ink somewhere to its left AND to its
---   DRAWING    right AND above it.
---
--- The first is exact and catches almost nothing: Gen 1 pics are open figures,
--- and a belly reaches the border through the gap between two legs. Read across
--- all 305 of this game's battle pics, it fills an enclosed hole in none of them
--- -- so on its own it left every mon a stencil, which is the bug this file
--- exists to fix and did not.
---
--- The second is the one that does the work: paper is what the figure is drawn
--- OVER, and a pixel with the drawing to either side of it and over it is under
--- the drawing. The sky above a pair of ears has nothing over it and stays sky;
--- the ground beside a foot has nothing on one side of it and stays ground.
---
--- The asymmetry is the whole trick and it is not arbitrary. Look BELOW a pixel
--- and the question has no answer, because a battle pic is CROPPED FLUSH AT THE
--- FEET -- bottom-aligned in its slot, with the margin all at the top. Half of
--- any mon's belly has bare frame edge under it and nothing else, which is why
--- requiring ink below left a clean vertical channel of world showing through
--- the middle of a Clefairy. And below cannot be accepted on its own either, or
--- the entire sky over a mon's head fills in: it has the mon under it.
---
--- It is a heuristic and it is allowed to be one. What it fills that hardware
--- would not have distinguished is a deep notch -- the gap between two legs,
--- with a belly over it -- and the hardware drew white there too, so that is
--- the pixel the artist saw.
---
--- The silhouette is untouched either way, so the mon still cuts cleanly
--- against the world; only its insides stop being see-through.
+-- The rule is a flood fill from OUTSIDE the figure: whatever the background
+-- can reach is background, and whatever it cannot is paper. What makes that
+-- work is where the flood is allowed to start.
+--
+-- Start it at the image border and it fills everything and answers nothing.
+-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
+-- out between two legs and off the bottom of the frame. Run over all 352 of
+-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
+-- it left every mon a stencil, which is the bug this file exists to fix and
+-- for a long time did not.
+--
+-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
+-- the left, the right and the top are seeded whole. The sky between a pair of
+-- ears reaches the top edge and stays sky; the gap between a body and a raised
+-- tail reaches the side and stays gap.
+--
+-- The BOTTOM is the interesting one, because two completely different things
+-- meet the underside of a figure and they have to be told apart.
+--
+--   A DRAIN is where the drawing simply ran out -- a belly whose white carries
+--   on down until the artist stopped, leaking to the outside through the inch
+--   between a body and a leg. Seal it: what is above it is the mon.
+--
+--   A MOUTH is the space BETWEEN two legs, or under an arch. It is background
+--   that happens to be enclosed on three sides. Leave it open: the world
+--   should show through the gap in a trainer's stride.
+--
+-- What separates them is how WIDE the opening is, and on this game's art that
+-- is not a close call. Measured along the bottom of every battle pic: the
+-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
+-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
+-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
+-- taken at 6 with room either side rather than tuned to a single sprite.
+--
+-- Apart from that one number the rule is exact: no pixel is filled for what
+-- surrounds it, only because the background provably cannot get to it. And it
+-- needs no idea whether it is holding a front pic, a back one or a trainer --
+-- fronts are near-solid silhouettes with almost nothing inside them to fill,
+-- and they come back untouched because that is what their own shape says, not
+-- because they were special-cased.
+--
+-- The silhouette is untouched, so the mon still cuts cleanly against the
+-- world; only its insides stop being see-through.
 --
 -- Read back off the GPU rather than off the asset, deliberately. What comes
 -- back is the pic the engine actually decided to draw -- species palette,
@@ -108,32 +116,78 @@ local function readBack(img)
   return ok and data or nil
 end
 
--- Mark every transparent pixel reachable from the border. That set is the
--- OUTSIDE; everything transparent it does not reach is an enclosed hole.
+-- The box the artwork actually occupies, or nil for a pic with no ink in it.
+--
+-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
+-- whole rows and columns of nothing around itself. The bottom of THIS box is
+-- the cut the rule below turns on, and the bottom of the image is just empty
+-- frame some distance under it.
+local function inkBounds(data, w, h)
+  local x0, y0, x1, y1 = w, h, -1, -1
+  for y = 0, h - 1 do
+    for x = 0, w - 1 do
+      local _, _, _, a = data:getPixel(x, y)
+      if a > CUT then
+        if x < x0 then x0 = x end
+        if x > x1 then x1 = x end
+        if y < y0 then y0 = y end
+        if y > y1 then y1 = y end
+      end
+    end
+  end
+  if x1 < x0 then return nil end
+  return x0, y0, x1, y1
+end
+
+-- The widest opening along the bottom of a figure that still counts as a drain
+-- rather than a mouth. See the header for the measurements either side of it.
+BattlePics.DRAIN = 6
+
+-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
+-- the edges of the artwork's box: the left, the right and the top whole, and
+-- along the bottom only those openings wide enough to be background rather
+-- than the underside of a figure the drawing ran out of.
+--
+-- Confined to the box as well as seeded from it, so the empty frame under a
+-- short pic cannot walk around a sealed drain and come back up through it.
 --
 -- An explicit stack rather than recursion: a 56x56 pic is three thousand
 -- pixels and a keyed-out background is most of them, which is a deeper call
 -- chain than is worth risking for no gain.
-local function markOutside(data, w, h)
+local function markOutside(data, w, h, x0, y0, x1, y1)
   local outside = {}
   local stack, top = {}, 0
+  local function clear(x, y)
+    local _, _, _, a = data:getPixel(x, y)
+    return a <= CUT
+  end
   local function push(x, y)
-    if x < 0 or y < 0 or x >= w or y >= h then return end
+    if x < x0 or y < y0 or x > x1 or y > y1 then return end
     local key = y * w + x
     if outside[key] then return end
-    local _, _, _, a = data:getPixel(x, y)
-    if a > CUT then return end
+    if not clear(x, y) then return end
     outside[key] = true
     top = top + 1
     stack[top] = key
   end
-  for x = 0, w - 1 do
-    push(x, 0)
-    push(x, h - 1)
+  for x = x0, x1 do push(x, y0) end
+  for y = y0, y1 do
+    push(x0, y)
+    push(x1, y)
   end
-  for y = 0, h - 1 do
-    push(0, y)
-    push(w - 1, y)
+  -- the bottom, run by run: a wide one is the gap between two legs and lets
+  -- the world through, a narrow one is where a belly ran out and is sealed
+  local x = x0
+  while x <= x1 do
+    if clear(x, y1) then
+      local from = x
+      while x <= x1 and clear(x, y1) do x = x + 1 end
+      if (x - from) > BattlePics.DRAIN then
+        for k = from, x - 1 do push(k, y1) end
+      end
+    else
+      x = x + 1
+    end
   end
   while top > 0 do
     local key = stack[top]
@@ -147,52 +201,6 @@ local function markOutside(data, w, h)
   return outside
 end
 
--- Mark every transparent pixel with ink to its left, to its right and above.
---
--- Three running scans rather than three searches per pixel: sweeping each row
--- left to right carries "has there been ink yet" along with it, the same sweep
--- the other way, and one down the columns. Three passes over the image however
--- big the figure is.
-local function markUnderDrawing(data, w, h)
-  local ink = {}
-  for y = 0, h - 1 do
-    local row = y * w
-    for x = 0, w - 1 do
-      local _, _, _, a = data:getPixel(x, y)
-      if a > CUT then ink[row + x] = true end
-    end
-  end
-
-  local left, right, above = {}, {}, {}
-  for y = 0, h - 1 do
-    local row, seen = y * w, false
-    for x = 0, w - 1 do
-      left[row + x] = seen
-      seen = seen or ink[row + x] or false
-    end
-    seen = false
-    for x = w - 1, 0, -1 do
-      right[row + x] = seen
-      seen = seen or ink[row + x] or false
-    end
-  end
-  for x = 0, w - 1 do
-    local seen = false
-    for y = 0, h - 1 do
-      above[y * w + x] = seen
-      seen = seen or ink[y * w + x] or false
-    end
-  end
-
-  local under = {}
-  for key = 0, w * h - 1 do
-    if not ink[key] and left[key] and right[key] and above[key] then
-      under[key] = true
-    end
-  end
-  return under
-end
-
 -- The pic with its enclosed holes filled, or the pic itself when that could
 -- not be done (no pixel access, a driver that refused the readback). Never
 -- nil for a non-nil argument: a caller must always have something to draw.
@@ -206,14 +214,17 @@ function BattlePics.filled(img)
     local data = readBack(img)
     if not data then return end
     local w, h = data:getDimensions()
-    local outside = markOutside(data, w, h)
-    local under = markUnderDrawing(data, w, h)
+    local x0, y0, x1, y1 = inkBounds(data, w, h)
+    if not x0 then return end          -- a pic with nothing drawn in it
+    local outside = markOutside(data, w, h, x0, y0, x1, y1)
     local fill = BattlePics.FILL
     local changed = false
-    for y = 0, h - 1 do
+    -- only inside the box: everything beyond it is frame the artist never
+    -- reached, and filling that would put the mon in a white rectangle
+    for y = y0, y1 do
       local row = y * w
-      for x = 0, w - 1 do
-        if not outside[row + x] or under[row + x] then
+      for x = x0, x1 do
+        if not outside[row + x] then
           local _, _, _, a = data:getPixel(x, y)
           if a <= CUT then
             data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
diff --git a/lib/DayTint.lua b/lib/DayTint.lua
new file mode 100644
index 0000000..ac976f5
--- /dev/null
+++ b/lib/DayTint.lua
@@ -0,0 +1,169 @@
+-- The hour's light on the FLAT world.
+--
+-- The clock already reaches everything the 3D pass draws: VoxelScene and
+-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
+-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
+-- mode off and none of that happens -- the tint is a uniform in a shader the
+-- flat tile path never runs -- so the same evening that fell on the diorama
+-- left the 2D world at permanent noon. One clock, two worlds, one of them
+-- ignoring it.
+--
+-- So the flat composite gets the same multiply, painted as one rectangle.
+--
+-- ------- WHERE, which is the only difficult part
+--
+-- Not on the world canvas. In a colorized mode that canvas is grayscale art
+-- and the blit that puts it on screen runs it through the palette shader,
+-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
+-- night blue over it first and every shade lands in the wrong bucket -- the
+-- world would not darken, it would change colour into whatever the palette
+-- said the wrong bucket was.
+--
+-- So it goes on AFTER that pass, on the composited world. And not after the
+-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
+-- the HUD are paper held up in front of the world rather than part of it --
+-- the same reason the tilt-shift blur is a worldPresent and not a present.
+--
+-- Which leaves one instant: between the world blit and the UI blit, inside
+-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
+-- hook for exactly this, only runs when a PIPELINE produced the world, which
+-- in flat mode is the one thing that did not happen. So endFrame is wrapped
+-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
+-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
+-- by identity rather than by counting or guessing.
+--
+-- The shader and scissor that call arrives under belong to the UI blit already
+-- in progress, so both are put aside for the rectangle and handed straight
+-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
+--
+-- ------- WHEN
+--
+-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
+-- load-bearing:
+--
+--   the flat path   a pipeline that rendered the world already applied the
+--                   tint inside its own shader; painting it again would apply
+--                   the hour twice. worldOverride is exactly "a pipeline drew
+--                   this frame".
+--   outdoors        a room has no sky to take its light from, which is the
+--                   same answer DayNight.tint gives on its own and the same
+--                   one applyRig gives the sun.
+--   not neutral     midday is a multiply by white. Skipped rather than drawn,
+--                   so a game with the clock at DAY issues not one extra call.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local DayNight = V.require("DayNight")
+
+local DayTint = {}
+
+-- Below this the tint is close enough to white that the rectangle would not
+-- change a pixel, and the frame is left exactly as it was.
+DayTint.NEUTRAL = 0.999
+
+local function outdoorNow()
+  local ok, Game = pcall(require, "src.core.Game")
+  if not ok then return false end
+  local ow = Game and Game.overworld
+  local map = ow and ow.map
+  if not map then return false end
+  local okMap, Map = pcall(require, "src.world.Map")
+  if not okMap then return false end
+  local outdoor = map.def and Map.isOutdoor(map.def) or false
+  -- a canopy floor takes the hour's colour and nothing else of it, exactly as
+  -- it does in the 3D pass (BattleScene, VoxelScene)
+  return outdoor or DayNight.isCanopy(map)
+end
+
+-- The colour this frame's world should be multiplied by, or nil to leave the
+-- frame alone.
+function DayTint.forFrame(renderer)
+  if not renderer then return nil end
+  if renderer.worldOverride then return nil end   -- a pipeline drew, and tinted
+  if not renderer.worldActive then return nil end -- no world on screen at all
+  if not outdoorNow() then return nil end
+  local tint = DayNight.tint(true)
+  if not tint then return nil end
+  local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
+  if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
+    return nil
+  end
+  return r, g, b
+end
+
+-- One rectangle over the window, multiplied into whatever is under it.
+--
+-- The whole window rather than the world's own rect, which is what the
+-- engine's warp fade does from the same place and for the same reason: the
+-- border fill, the letterbox bars and the world are all "the world" as far as
+-- the hour is concerned, and black multiplied by anything is still black.
+-- Every read of the graphics state is optional, because a headless driver
+-- ships some of these and not others -- the same reason TerrainAtlas reads the
+-- engine's seams guarded. What cannot be read cannot be put back either, and a
+-- missing accessor must cost the tint rather than the frame.
+local function saved(name, ...)
+  local fn = love.graphics[name]
+  if not fn then return nil end
+  local ok, a, b, c, d = pcall(fn, ...)
+  if not ok then return nil end
+  return a, b, c, d
+end
+
+function DayTint.paint(r, g, b)
+  local gfx = love.graphics
+  local shader = saved("getShader")
+  local sx, sy, sw, sh = saved("getScissor")
+  local blend, alpha = saved("getBlendMode")
+  local pr, pg, pb, pa = saved("getColor")
+  local w, h = gfx.getDimensions()
+
+  if gfx.setShader then gfx.setShader() end
+  if gfx.setScissor then gfx.setScissor() end
+  gfx.setBlendMode("multiply", "premultiplied")
+  gfx.setColor(r, g, b, 1)
+  gfx.rectangle("fill", 0, 0, w, h)
+
+  gfx.setBlendMode(blend or "alpha", alpha)
+  gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
+  if gfx.setScissor then
+    if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
+  end
+  if shader and gfx.setShader then gfx.setShader(shader) end
+end
+
+function DayTint.install()
+  local Renderer = require("src.render.Renderer")
+  if Renderer.dramaticShapeTintHook then return end
+  local inner = Renderer.endFrame
+
+  function Renderer:endFrame(zones, worldZones)
+    local r, g, b = DayTint.forFrame(self)
+    if not r then return inner(self, zones, worldZones) end
+
+    local gfx = love.graphics
+    local draw = gfx.draw
+    local ui = self.canvas
+    local painted = false
+    gfx.draw = function(tex, ...)
+      -- the UI canvas reaching the screen: the world is finished, the paper
+      -- in front of it has not started. Restored FIRST so the rectangle's own
+      -- drawing cannot re-enter this, and so a UI blit that draws one quad per
+      -- SGB zone only triggers it once.
+      if not painted and tex == ui then
+        painted = true
+        gfx.draw = draw
+        DayTint.paint(r, g, b)
+      end
+      return draw(tex, ...)
+    end
+
+    local ok, err = pcall(inner, self, zones, worldZones)
+    gfx.draw = draw
+    if not ok then error(err, 0) end
+  end
+
+  Renderer.dramaticShapeTintHook = true
+end
+
+return DayTint
diff --git a/main.lua b/main.lua
index 26c78e6..0ba9fb0 100644
--- a/main.lua
+++ b/main.lua
@@ -80,6 +80,7 @@ local WorldCurve = V.require("WorldCurve")
 local OverworldBattle = V.require("OverworldBattle")
 local BattleExit = V.require("BattleExit")
 local DayNight = V.require("DayNight")
+local DayTint = V.require("DayTint")
 
 -- Forward declaration: the voxel pipeline's update hook (registered below)
 -- calls this, and it is defined further down with the settings it drives.
@@ -285,13 +286,13 @@ applyFull = function(level)
   opts.zoom = 0
   Zoom.applyOptions(opts)
   -- battles on the map too: FULL means the whole mode, and a fight is where
-  -- half of it is spent. Set rather than forced -- the row is gone from the
-  -- menu while FULL is on, but a save that already had it off gets it on.
+  -- half of it is spent. Set and then LET GO of -- unlike the rows above, both
+  -- battle rows stay on the menu under FULL (see the rows hook), so this is
+  -- where the preset puts them and not where they are held.
   OverworldBattle.setting:setIndex(1, Game)
-  -- with both mons out there on it: BACK SPRITES keeps the player's own on the menu,
-  -- which is the one part of the old screen FULL is least about. Set rather
-  -- than held, like every other line here -- a player who wants their back pic
-  -- back can say so again on the row, or from the mod manager's page.
+  -- with both mons out there on it: BACK SPRITES keeps the player's own on the
+  -- menu, which is the one part of the old screen FULL is least about. Set the
+  -- same way, and changed back on the same row a keypress later.
   OverworldBattle.backSetting:setIndex(1, Game)
   -- and the battle screen the staged fight is composed for. WIDE re-lays that
   -- screen out on a 304x144 surface, which moves every anchor the arena camera
@@ -307,24 +308,36 @@ applyFull = function(level)
 end
 
 -- Whether a fight can be staged on the map, as far as the OPTIONS menu is
--- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and
--- switches it on. Deliberately NOT gated on Voxel3D.available(): the engine
--- offers a pipeline's row whether or not the hardware can run it
--- (Pipelines.rows), so this mode's rows say ON on a machine without a depth
--- buffer too, and a menu that claims 3D battles are on must not also offer the
--- layout they cannot be drawn in.
+-- concerned: the 3D-BTL row, and nothing else.
+--
+-- It used to answer yes under FULL as well, on the grounds that FULL owned
+-- that row and switched it on. FULL no longer owns it -- the row stays on the
+-- menu under FULL and can be switched off there (see the rows hook) -- so that
+-- clause would now claim staged battles for a preset the player had just
+-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
+-- never staged. The row is the only thing that decides, which is what every
+-- other reader of this setting already believed: OverworldBattle.begin and
+-- wantsFront both gate on enabled() alone.
+--
+-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
+-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
+-- this mode's rows say ON on a machine without a depth buffer too, and a menu
+-- that claims 3D battles are on must not also offer the layout they cannot be
+-- drawn in.
 local function stagedBattles()
-  local Pipelines = require("src.render.Pipelines")
-  return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
+  return OverworldBattle.enabled()
 end
 
 local SETTINGS = {
   { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
   { WorldCurve.setting,
     "Bend the world down over the horizon, Animal Crossing style." },
+  -- `full` marks a row FULL does not take away. FULL owns the diorama's own
+  -- knobs; what a battle is drawn over, and how it is framed, are not that.
   { OverworldBattle.setting,
     "Fight on the map: the battle draws over the nearest clear ground, "
-    .. "shot over the shoulder with a slow parallax drift." },
+    .. "shot over the shoulder with a slow parallax drift.",
+    full = true },
   -- Only offered while a fight can actually be staged on the map: with 3D-BTL
   -- off the engine draws the classic screen, which is this row's ON already,
   -- and a row that no longer decides anything is worse than no row.
@@ -332,7 +345,7 @@ local SETTINGS = {
     "Keep your own Pokemon on the battle menu, seen from behind in its "
     .. "original slot, instead of standing it on the map facing the foe. "
     .. "The foe is still out there on its own tile.",
-    when = function() return stagedBattles() end },
+    when = function() return stagedBattles() end, full = true },
   { DayNight.setting,
     "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
     .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
@@ -480,10 +493,12 @@ local function insertGrouped(out, extra)
   return out
 end
 
--- FULL owns every one of those settings, so while it is selected they are
--- taken off the menu rather than left to be changed under it -- including
--- T-SHIFT, which is a pipeline row the engine put there. A row that no
--- longer decides anything is worse than no row.
+-- FULL owns the settings that describe the LOOK, so while it is selected those
+-- are taken off the menu rather than left to be changed under it -- including
+-- T-SHIFT, which is a pipeline row the engine put there. A row that no longer
+-- decides anything is worse than no row.
+--
+-- The battle rows are the exception and they stay; see the rows hook.
 local function dropRow(out, id)
   for i = #out, 1, -1 do
     if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
@@ -546,18 +561,31 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
     OverworldBattle.forceOG(game)
     dropRow(out, "battleLayout")
   end
-  if Voxel.isFull(Pipelines.level("voxel")) then
-    -- FULL keeps every mod row off the menu (the early return skips the
-    -- insert below), and holds DAYTIME at SYNC while the row is unreachable
+  local full = Voxel.isFull(Pipelines.level("voxel"))
+  if full then
+    -- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
+    -- horizon bend, the blur, the hour -- so those come off the menu and
+    -- DAYTIME is held at SYNC while its row is unreachable.
     DayNight.forceSync(game)
-    return dropRow(out, "pipeline:tiltshift")
+    dropRow(out, "pipeline:tiltshift")
   end
   local extra = {}
   for _, entry in ipairs(SETTINGS) do
-    -- a row whose own switch is off the table this frame (BACK SPRITES, which
-    -- needs a staged fight to be about) is left off with it; the mod manager's
-    -- page still carries every one of them
-    if not entry.when or entry.when() then extra[#extra + 1] = entry[1]:row() end
+    -- Two things decide whether a row is offered.
+    --
+    -- FULL: a preset that owns the look, so the rows that describe the look go
+    -- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
+    -- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
+    -- the diorama FULL is a preset for. FULL still SETS them on arrival (see
+    -- applyFull); it does not hold them, so leaving them on the menu is the
+    -- difference between a preset and a lock.
+    --
+    -- And a row whose own switch is off the table this frame (BACK SPRITES,
+    -- which needs a staged fight to be about) is left off with it. The mod
+    -- manager's page carries every one of them either way.
+    local offered = (entry.full or not full)
+                    and (not entry.when or entry.when())
+    if offered then extra[#extra + 1] = entry[1]:row() end
   end
   return insertGrouped(out, extra)
 end)
@@ -773,6 +801,16 @@ mod.content.transitions:register(BattleExit.ID, {
 
 BattleExit.install()
 
+-- ------- and the hour on the flat world
+--
+-- The clock reaches the diorama through the voxel shader's own tint uniform,
+-- which the 2D tile path never runs -- so with the mode off, the same evening
+-- that fell on the diorama left the flat world at permanent noon. One clock,
+-- two worlds, one of them ignoring it. DayTint paints the same multiply over
+-- the composited flat world, between the world blit and the UI blit; the
+-- reasoning for that exact instant is in the file.
+DayTint.install()
+
 -- ------- what time it is
 --
 -- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
diff --git a/mod.card b/mod.card
index 9f72264..d7f9644 100644
--- a/mod.card
+++ b/mod.card
@@ -23,6 +23,7 @@ return {
       "V-GRID on hotkey 5 and V-CURVE on hotkey 7",
       "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
       "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
+      "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
       "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
       "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
       "a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index a637c31..0249247 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -116,11 +116,16 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
 local Runtime = require("src.mods.Runtime")
 local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
 
--- ------- FULL is a preset that owns the other rows
+-- ------- FULL is a preset that owns the rows describing the LOOK
 --
 -- While it is selected the settings it drives come OFF the menu -- including
 -- T-SHIFT, which is a pipeline row the engine spliced in. A row that no
 -- longer decides anything is worse than no row.
+--
+-- The two BATTLE rows are the exception and stay. 3D-BTL decides what a fight
+-- is drawn over and BACK SPRITES how it is framed; neither is a knob on the
+-- diorama the preset is a preset FOR. FULL sets them on arrival and then lets
+-- go, which is what makes it a preset rather than a lock.
 Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
 local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
                               { data = Data },
@@ -133,9 +138,14 @@ T.check(not fullIds["pipeline:tiltshift"],
   "FULL takes T-SHIFT off the menu -- it owns the blur")
 T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
 T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
-T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
 T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
 
+-- but the battle rows survive it: they are not knobs on the look, and FULL
+-- sets them once rather than holding them, so a player who wants the classic
+-- back sprite (or no staged fights at all) can still say so from inside FULL
+T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
+T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
+
 -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
 -- be reached while FULL owns it, so a value changed underneath (the mod
 -- manager's page, an edited options file) snaps back when the menu asks
@@ -200,14 +210,26 @@ Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
 T.eq(layoutGame.save.options.battleLayout, "wide",
   "and WIDE is left alone once no battle can be staged on the map")
 
--- FULL owns the 3D-BTL row, so it pins the layout even with that row switched
--- off underneath it
+-- and FULL does not override that. It used to: the preset owned the 3D-BTL row
+-- and hid it, so "FULL is selected" was a safe stand-in for "battles are
+-- staged". The row is on the menu under FULL now and can be switched off
+-- there, so the stand-in would pin BATTLE LAYOUT to OG for a fight that is
+-- never staged. The ROW decides, which is what every other reader of this
+-- setting already believed.
 Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
 Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
              { { id = "battleLayout" } })
-T.eq(layoutGame.save.options.battleLayout, "og",
-  "FULL pins the layout on its own, because it owns the row that would")
+T.eq(layoutGame.save.options.battleLayout, "wide",
+  "with 3D-BTL off, FULL leaves the layout alone -- it no longer owns that row")
+
+-- switch the row back on and the pin comes back with it, FULL or no FULL.
+-- (Arriving at FULL for real runs applyFull, which switches the row on -- so
+-- in the game the pin still follows the preset, by way of the row.)
 Battles.setting:setIndex(1, layoutGame)
+Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
+             { { id = "battleLayout" } })
+T.eq(layoutGame.save.options.battleLayout, "og",
+  "and the row switched back on pins it again, from inside FULL")
 end
 
 -- ------- TILT and GBC FX are off the menu entirely
@@ -249,11 +271,15 @@ T.eq(fxGame.save.options.tilt, 0,
 T.eq(fxGame.save.options.gbcfx, 0, "and GBC FX with it")
 T.eq(Tilt.level, 0, "the live level follows, so the frame is not still tilted")
 
--- and FULL, which returns early from the rows hook, must not be a way back in
+-- and FULL, which takes its own branch through the rows hook, must not be a
+-- way back in
 Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
 local fullFx = Runtime.call("ui.options.rows", function(_, r) return r end,
                             fxGame, { { id = "tilt" }, { id = "gbcfx" } })
-T.eq(#fullFx, 0, "under FULL they are gone too -- its early return is below them")
+local fullFxIds = {}
+for _, row in ipairs(fullFx) do fullFxIds[row.id] = true end
+T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"],
+  "under FULL they are gone too -- the drop is above every branch")
 Pipelines.setLevel("voxel", 2)
 end
 
@@ -2013,106 +2039,255 @@ T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
 Battles.backSetting:setIndex(1, backGame)          -- and off for the rows below
 end
 
+-- ------- the hour reaches the FLAT world too
+--
+-- The clock reaches the diorama through the voxel shader's tint uniform, which
+-- the 2D tile path never runs. With the mode off the same evening left the flat
+-- world at permanent noon.
+--
+-- The fix is one multiplied rectangle, and the whole difficulty is WHERE. Not
+-- on the world canvas -- in a colorized mode that is grayscale art the palette
+-- shader classifies by RED CHANNEL, so tinting first would move every pixel
+-- into the wrong shade bucket rather than darkening it. Not over the finished
+-- frame either, or the dialog boxes darken with the world they are held up in
+-- front of. Between the two, which is the one instant with no engine seam in
+-- it -- worldPresent only runs when a pipeline drew the world, which in flat
+-- mode is exactly what did not happen.
+--
+-- So the boundary is found by identity: `blit` passes the canvas it is
+-- compositing as the first argument, so the first draw of the renderer's UI
+-- canvas IS the moment the world is finished and the paper has not started.
+-- That is what this drives -- the gates, and the ordering.
+do
+local DayTint = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayTint")
+local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
+
+-- the map the hour is asked about is the one the player is standing on, read
+-- off the live game rather than passed in -- so there has to be one
+local Game = require("src.core.Game")
+local owWas = Game.overworld
+Game.overworld = { map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } } }
+
+-- ------- the gates
+--
+-- A frame with a pipeline's world image in it was tinted inside that
+-- pipeline's own shader; painting again would apply the hour twice.
+DayNight.setting:sync("night")
+T.check(DayTint.forFrame({ worldActive = true, worldOverride = {} }) == nil,
+  "a frame a render pipeline drew is left alone -- it tinted itself")
+T.check(DayTint.forFrame({ worldActive = false }) == nil,
+  "and so is a frame with no world in it at all, like a menu over nothing")
+T.check(DayTint.forFrame(nil) == nil, "and no renderer, no tint")
+
+-- midday is a multiply by white, so it is skipped rather than drawn: a game
+-- with the clock at DAY issues not one extra call
+DayNight.setting:sync("day")
+T.check(DayTint.forFrame({ worldActive = true }) == nil,
+  "at midday the tint is white, so nothing is painted")
+
+-- and a room has no sky to take its light from, which is the same answer
+-- DayNight.tint gives on its own and the same one applyRig gives the sun
+DayNight.setting:sync("night")
+T.check(DayTint.forFrame({ worldActive = true }) ~= nil,
+  "at night, outdoors, there is a tint to paint")
+Game.overworld = { map = { id = "OAKS_LAB", def = { tileset = "HOUSE" } } }
+T.check(DayTint.forFrame({ worldActive = true }) == nil,
+  "but indoors the hour does not reach the floor")
+Game.overworld = { map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } } }
+
+-- ------- and the ordering, driven through the real wrap
+--
+-- A stand-in renderer whose endFrame issues the two draws the real one does,
+-- in the real order: the world canvas, then the UI canvas.
+DayNight.setting:sync("night")
+local Renderer = require("src.render.Renderer")
+local realEnd, realHook = Renderer.endFrame, Renderer.dramaticShapeTintHook
+local log = {}
+local uiCanvas, worldPixels = { "the UI canvas" }, { "the world canvas" }
+Renderer.dramaticShapeTintHook = nil
+Renderer.endFrame = function(self)
+  log[#log + 1] = "world"
+  love.graphics.draw(worldPixels, 0, 0)
+  log[#log + 1] = "ui"
+  love.graphics.draw(self.canvas, 0, 0)
+  love.graphics.draw(self.canvas, 0, 0)   -- a second SGB zone's quad
+end
+DayTint.install()
+
+local realRect = love.graphics.rectangle
+love.graphics.rectangle = function(...)
+  log[#log + 1] = "tint"
+  return realRect(...)
+end
+Renderer.endFrame({ canvas = uiCanvas, worldActive = true, map = true })
+love.graphics.rectangle = realRect
+
+T.eq(table.concat(log, ","), "world,ui,tint",
+  "the tint lands after the world is composited and before the UI blit draws")
+local painted = 0
+for _, step in ipairs(log) do if step == "tint" then painted = painted + 1 end end
+T.eq(painted, 1,
+  "once, not once per SGB zone quad the UI blit issues")
+
+-- a frame the gates decline must not leave the shim installed on love.graphics
+local drawWas = love.graphics.draw
+Renderer.endFrame({ canvas = uiCanvas, worldActive = true, worldOverride = {} })
+T.eq(love.graphics.draw, drawWas,
+  "a declined frame does not leave a wrapper on love.graphics.draw")
+
+Renderer.endFrame, Renderer.dramaticShapeTintHook = realEnd, realHook
+Game.overworld = owWas
+DayNight.setting:sync("sync")
+end
+
 -- ------- and a pinned back pic is not a stencil
 --
 -- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
 -- decoded PNGs key that shade to alpha 0 -- free on a white field, a hole with
 -- the world showing through over a route. BattlePics puts the paper back.
 --
--- It used to do that by flood-filling the outside and filling what the flood
--- could not reach, which is exact and, on this game's art, fills NOTHING: a
--- Gen 1 figure is an open drawing and its belly reaches the border through the
--- gap between two legs. Every mon was a stencil, most obviously the back pic
--- pinned to the menu, which is drawn flat over the tiles rather than shaded
--- like a card. So paper is also anything with ink to its left AND right AND
--- ABOVE it -- what the figure is drawn over.
---
--- The asymmetry is the load-bearing part, so it is what this drives at. A pic
--- is cropped flush at the FEET, so half a mon's belly has bare frame edge
--- under it: ask for ink below as well and a clean channel of world shows
--- through the middle of the figure, which is the bug that got reported.
---
--- Driven against a hand-drawn figure with exactly that shape -- ears with sky
--- between them, a belly, legs, and a bottom edge flush with the frame.
--- NOTHING in it is enclosed: every transparent pixel inside walks out between
--- the legs and off that bottom edge, so the flood-fill rule alone leaves this
--- figure exactly as it found it.
+-- It does that by flooding the background INWARD and filling whatever the
+-- background cannot reach. Started at the image border that finds nothing at
+-- all -- a Gen 1 figure is an open drawing, and its belly walks out between
+-- two legs and off the bottom of the frame -- so every mon was a stencil.
+--
+-- So the flood starts at the edges of the ARTWORK'S OWN BOX, and at three of
+-- them: left, right and top. The bottom is closed, because it is not a side
+-- the background is behind -- it is where the drawing was CUT. A pic is
+-- bottom-aligned in its slot with the margin all at the top, so a mon's lowest
+-- row is the last row it was given. Seed that cut and the background pours up
+-- inside the figure, which was the channel of world showing through the middle
+-- of a Clefairy.
+--
+-- Both halves are driven here, because getting one right at the other's
+-- expense is exactly what went wrong twice: an earlier rule that filled
+-- anything with ink to its left, right and above closed the channel and then
+-- filled the notch between a Rattata's ears and the gap between its body and
+-- its tail, which are background and have the drawing over them.
 do
 local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
 
-local FIGURE = {
-  "..#..#..",   -- two ears, with sky between them
-  "..#..#..",
-  "..####..",   -- and the head closing under them
-  ".#....#.",   -- belly: sides beside it, head over it, bare frame under it
-  ".#....#.",
-  ".#....#.",
-  ".#.##.#.",   -- legs, with the gap between them open to the bottom edge
-  ".#.##.#.",   -- which the frame cuts flush, exactly as a battle pic is cut
-}
-local W, H = #FIGURE[1], #FIGURE
-
-local function fakeData(rows)
-  local px = {}
-  for y = 0, H - 1 do
-    for x = 0, W - 1 do
-      px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0
+-- Run one hand-drawn figure through the real BattlePics and hand back a
+-- reader over what came out. The pic is faked at the readback seam, which is
+-- the only thing between this and the pixels the engine would have blitted.
+local function fill(rows)
+  local W, H = #rows[1], #rows
+  local built = nil
+  local function fakeData()
+    local px = {}
+    for y = 0, H - 1 do
+      for x = 0, W - 1 do
+        px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0
+      end
     end
+    return {
+      px = px,
+      getDimensions = function() return W, H end,
+      getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end,
+      setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end,
+    }
   end
-  return {
-    px = px,
-    getDimensions = function() return W, H end,
-    getPixel = function(self, x, y)
-      local a = self.px[y * W + x]
-      return 0, 0, 0, a
-    end,
-    setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end,
-  }
-end
 
-local built = nil
-local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage
-love.graphics.newCanvas = function()
-  return { setFilter = function() end, release = function() end,
-           newImageData = function() return fakeData(FIGURE) end }
-end
-love.graphics.newImage = function(data)
-  built = data
-  return { setFilter = function() end, dramaticShapeFilled = true }
+  local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage
+  love.graphics.newCanvas = function()
+    return { setFilter = function() end, release = function() end,
+             newImageData = fakeData }
+  end
+  love.graphics.newImage = function(data)
+    built = data
+    return { setFilter = function() end }
+  end
+  local pic = { getDimensions = function() return W, H end }
+  local out = BattlePics.filled(pic)
+  love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage
+  -- deliberately NOT invalidated: each figure brings its own pic, and the
+  -- cache check at the bottom needs one of them still in there
+  return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end
 end
 
-local pic = { getDimensions = function() return W, H end }
-local out = BattlePics.filled(pic)
-love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage
-
-T.check(out ~= pic and built ~= nil,
-  "the pic comes back rebuilt: there was paper to put back")
-local function opaque(x, y) return built.px[y * W + x] > 0.5 end
+-- ------- the cut at the feet, which is what the closed bottom edge is for
+local out, pic, opaque = fill({
+  "..#..#..",   -- two ears, with sky between them
+  "..#..#..",
+  "..####..",   -- and the head closing under them
+  ".#....#.",   -- belly: nothing under it but the edge the drawing stops at
+  ".#....#.",
+  ".#....#.",
+  ".#.##.#.",   -- legs, with the gap between them running down to that edge
+  ".#.##.#.",
+})
+T.check(out ~= pic and opaque, "the pic comes back rebuilt: there was paper to put back")
 
--- the belly, filled edge to edge. Every one of these has bare frame under it
--- rather than ink, which is the case that used to leave a channel of world
--- showing straight down the middle of the mon
 T.check(opaque(2, 3) and opaque(3, 4) and opaque(5, 5),
-  "the figure's white insides are filled back in, so it is not see-through")
+  "the belly is filled edge to edge -- no channel of world down the middle")
+T.check(opaque(2, 7),
+  "and so is the notch between its legs, which the same cut runs through")
 
--- the sky between two ears is NOT paper, and this is what the third ray is
--- for: nothing is drawn over it
+-- the sky between two ears reaches the top of the box, so it is background
 T.check(not opaque(3, 0) and not opaque(4, 1),
-  "the sky between its ears stays sky -- there is no drawing over it")
-
--- the outside is untouched, which is what keeps the silhouette cutting
--- cleanly against the world instead of standing in a white box
-T.check(not opaque(0, 0) and not opaque(7, 0),
-  "the corners it never covered stay transparent")
-T.check(not opaque(0, 4) and not opaque(7, 4),
-  "and so do the columns beside it -- ink on one side is not being inside")
+  "the sky between its ears stays sky")
+-- and everything outside the artwork's own box is never touched, which is what
+-- keeps the silhouette cutting cleanly instead of standing in a white rectangle
+T.check(not opaque(0, 0) and not opaque(7, 0), "the corners stay transparent")
+T.check(not opaque(0, 4) and not opaque(7, 4), "and the columns beside it")
+
+-- ------- and a pocket that drains out to the SIDE is background, however
+-- much of the drawing is over it
+--
+-- This is the regression the ray rule caused: ink to the left, ink to the
+-- right, ink above, and still plainly the gap between a body and a tail.
+-- Every transparent pixel in this one drains out through the notch at (2,3)
+-- and away to the left, so NONE of it is paper -- and a pic with no paper to
+-- put back is handed straight back, unrebuilt. That identity IS the assertion:
+-- under the ray rule this figure came back rebuilt with the pocket filled in.
+local gapOut, gapPic = fill({
+  "..#####.",   -- a brow, with the drawing over the pocket
+  "..#...#.",
+  "..#...#.",
+  "....###.",   -- which opens at the left, and drains out that way
+  "..#####.",
+  "..#####.",
+})
+T.eq(gapOut, gapPic,
+  "a pocket the background can walk into from the side is not paper")
 
--- the notch between the legs, on the other hand, has a belly over it and legs
--- either side, so it is paper. The hardware drew white there too
-T.check(opaque(2, 7), "the notch between its legs is under the drawing")
+-- ------- and neither is a wide MOUTH along the bottom
+--
+-- Two things meet the underside of a figure. A DRAIN is where the drawing ran
+-- out -- a belly leaking through the inch between a body and a leg -- and is
+-- sealed. A MOUTH is the space between two legs, background that happens to be
+-- enclosed on three sides, and is left open so the world shows through a
+-- trainer's stride. Width tells them apart, and on this game's art the drains
+-- run 3-4 pixels and the mouths 10-17, so BattlePics.DRAIN sits at 6.
+--
+-- This figure's stride is eight wide, so nothing in it is paper and it comes
+-- back unrebuilt -- the identity again.
+local strideOut, stridePic = fill({
+  "..##############..",
+  "..##############..",
+  "..##############..",
+  "..###........###..",   -- a stride eight wide, past the drain cut
+  "..###........###..",
+  "..###........###..",
+})
+T.eq(strideOut, stridePic,
+  "the world shows through the gap between a trainer's legs")
+
+-- the same figure with a two-pixel gap IS a drain, and fills
+local drainOut, drainPic, drain = fill({
+  "..##############..",
+  "..##############..",
+  "..##############..",
+  "..######..######..",   -- a belly running out, not a stride
+  "..######..######..",
+  "..######..######..",
+})
+T.check(drainOut ~= drainPic and drain and drain(8, 4),
+  "a narrow one is where the drawing ran out, and is paper")
 
--- and the answer is cached on the image, so a pic costs one readback a
--- session rather than one a frame
+-- the answer is cached on the image, so a pic costs one readback a session
+-- rather than one a frame -- checked on the first figure, which is still in
+-- there because fill() does not clear it
 T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original")
 BattlePics.invalidate()
 end