DramaticShapeVoxelMod archival mirror · read-only
8f38aeb36e9bbd7da585b01bd1af08123be45866

water updates

DramaticShape · 2026-07-31 · parent 9a9441899a

water updates

Changed files

lib/BattleScene.lua           |   32 ++
 lib/ShadowMap.lua             |   32 +-
 lib/VoxelScene.lua            |   45 ++-
 lib/Water.lua                 | 1201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/dramatic_shape_test.lua |   42 ++-
 tests/mon_shots.lua           |   71 ++++
 tests/pic_dump.lua            |   63 ++++
 tests/water_reflect_probe.lua |  127 +++++++
 8 files changed, 1589 insertions(+), 24 deletions(-)

Diff

diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index d483ed5..2fc3cce 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -259,10 +259,15 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
   -- the mons themselves, as the same cards the camera will see. Their alpha
   -- is the silhouette, so what lands on the ground is the shape of the
   -- Pokemon rather than a blob standing in for one.
+  -- marked as the CAST, so a fight staged at the water's edge does not lay a
+  -- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's
+  -- own floor still takes them, which is the shadow that matters here
+  ShadowMap.sprites(true)
   for _, card in ipairs(cards or {}) do
     ShadowMap.draw(BattleBillboard.mesh(), card.tex,
                    ShadowMap.snug(card.model))
   end
+  ShadowMap.sprites(false)
 
   ShadowMap.finish(sig)
 end
@@ -309,9 +314,36 @@ end
 BattleScene.FLASH_COLOR = { 1, 1, 1 }
 BattleScene.FLASH_STRENGTH = 0.5
 
+-- ------- the tile clock, while the overworld is not the one drawing
+--
+-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE
+-- only advances it from OverworldState:drawWorld -- which runs under dialogs
+-- and menus, but not under a battle, because a battle draws instead of the
+-- overworld rather than over it. So for the length of a staged fight the
+-- counter stood still: the water tiles stopped rotating their pixels and the
+-- wave field, which is driven off the same number so the two cannot drift
+-- (see Water), stopped with them. A lake in the background of a battle was a
+-- photograph.
+--
+-- Ticked HERE rather than from the mod's update hook, because here is the
+-- one place that means "a staged battle is drawing this frame, and the
+-- overworld is not". From the update hook the condition would have to be
+-- guessed at, and a frame where both ran would double the rate.
+local function tickTiles()
+  local Game = require("src.core.Game")
+  local ow = Game and Game.overworld
+  local top = Game and Game.stack and Game.stack:top()
+  -- during the wipe INTO a battle the overworld can still be the one
+  -- drawing, and it is ticking the clock itself; two ticks in a frame would
+  -- run the water at double speed
+  if top and ow and top == ow then return end
+  pcall(require("src.render.TileRenderer").tick)
+end
+
 function BattleScene.render(state, arena, textures, token)
   if not (state and state.map and arena) then return nil end
   if not Voxel3D.available() then return nil end
+  tickTiles()
 
   -- the floor the fight is staged on: normally the player's own, sometimes
   -- another floor of the same cave or building (see BattleArena)
diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua
index 9798462..18ceffd 100644
--- a/lib/ShadowMap.lua
+++ b/lib/ShadowMap.lua
@@ -130,17 +130,23 @@ local SHADER = [[
   }
 #endif
 #ifdef PIXEL
+  uniform float sprite;   // 1 while the CAST is being drawn; see ShadowMap.sprites
   vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
     // the same alpha discard the main pass uses: a sprite card casts its
     // silhouette, not its 16x16 bounding box
     if (Texel(tex, tc).a < 0.5) discard;
-    // pack into two channels: the high byte in red, the low in green
+    // pack into two channels: the high byte in red, the low in green.
+    // Blue says WHAT cast this, which costs a channel that was zero anyway
+    // and lets a surface decline one kind of caster -- water does, for the
+    // people (see Water's sunLit).
     float d = clamp(vDepth, 0.0, 1.0) * 255.0;
-    return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0);
+    return vec4(floor(d) / 255.0, fract(d), sprite, 1.0);
   }
 #endif
 ]]
 
+ShadowMap._source = function() return SHADER end   -- named for the suite
+
 local shader = nil            -- nil = untried, false = unavailable
 local canvas = nil            -- nil = untried, false = unavailable
 local canvasRes = 0           -- the edge `canvas` was made at
@@ -440,6 +446,9 @@ function ShadowMap.begin(cx, cy, vw, vh)
   love.graphics.setShader(sh)
   love.graphics.setColor(1, 1, 1, 1)
   pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP)
+  -- the world until a cast pass says otherwise, reset per pass so one that
+  -- forgot to put it back cannot leak into the next map's terrain
+  pcall(sh.send, sh, "sprite", 0)
   drawing = true
   ready = false
   return true
@@ -448,6 +457,25 @@ end
 -- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward
 -- pull, which is a trick for the VIEW's depth buffer and would drag a
 -- shadow off whatever throws it.
+-- Whether what is drawn next is one of the CAST -- a walker, an authored
+-- figure, a battle's Pokemon -- rather than part of the world. false for the
+-- length of such a pass, true to put it back.
+--
+-- The map records it per texel (the shader's blue channel) so a surface can
+-- decline that kind of caster, and exactly one does: water. A character
+-- standing at a lake's edge threw a hard cut-out of its own sprite across
+-- the surface, which on something showing the sky and the shoreline reads as
+-- a sticker rather than as a shadow in the water. Everything else -- ground,
+-- roofs, ledges, the characters themselves -- still takes them.
+--
+-- Sent rather than branched, so a caller that forgets to put it back only
+-- mislabels casters rather than losing them; begin() resets it per pass.
+function ShadowMap.sprites(on)
+  if not drawing then return end
+  local sh = getShader()
+  if sh then pcall(sh.send, sh, "sprite", on and 1 or 0) end
+end
+
 function ShadowMap.draw(mesh, texture, model)
   if not (drawing and mesh) then return end
   local sh = getShader()
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 96bd4b8..b9244fb 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -699,6 +699,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
     ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
                    ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
   end
+  -- From here down it is the CAST, marked as such in the map (see
+  -- ShadowMap.sprites) so water can decline them: everything the world casts
+  -- still shades a lake, a silhouette of somebody standing beside it does
+  -- not. Ground, roofs and the characters themselves take them as before.
+  ShadowMap.sprites(true)
   -- authored figures cast too, for the same reason the flowers do: a
   -- handful of cards per map, and a person with no shadow reads as pasted on
   eachFigure(state.map, 0, 0, function(mesh, _, caster)
@@ -720,6 +725,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
                                             mirror)))
     end
   end
+  ShadowMap.sprites(false)
 
   ShadowMap.finish(sig)
 end
@@ -779,8 +785,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
                  Mat4.translate(nb.ox, 0, nb.oy))
   end
 
+  -- Without a shadow map (headless, or a driver that could not make the
+  -- canvas) the old flat decals stand in: ground-only, characters only,
+  -- but better than a world with nothing under anybody. They go down
+  -- first, as decals the characters then stand over -- depth-tested
+  -- against the terrain just drawn (a shadow behind a building stays
+  -- hidden) but never depth-writing, so the grass pass at the end of the
+  -- frame still wins its feet-overdraw fights.
+  if not Voxel3D.shadowsActive() then
+    Voxel3D.beginShadows()
+    for _, p in ipairs(posed) do
+      drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
+                 p.lift)
+    end
+    Voxel3D.endShadows()
+  end
+
   -- and the water over the top of it, reflecting everything just drawn plus
-  -- the sky the frame opened with (see drawWater)
+  -- the sky the frame opened with (see drawWater).
+  --
+  -- After the fallback decals deliberately: those are the stand-in drop
+  -- shadows for a frame with no shadow map, they write no depth, and a
+  -- lake would otherwise wear one as a black smear. Water covers them,
+  -- which is the same answer the shadow map's own pass gives (see
+  -- ShadowMap.sprites) -- people do not shadow water either way.
   local waterDraws = {}
   if water then
     waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
@@ -800,21 +828,6 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
     end)
   end
 
-  -- Without a shadow map (headless, or a driver that could not make the
-  -- canvas) the old flat decals stand in: ground-only, characters only,
-  -- but better than a world with nothing under anybody. They go down
-  -- first, as decals the characters then stand over -- depth-tested
-  -- against the terrain just drawn (a shadow behind a building stays
-  -- hidden) but never depth-writing, so the grass pass at the end of the
-  -- frame still wins its feet-overdraw fights.
-  if not Voxel3D.shadowsActive() then
-    Voxel3D.beginShadows()
-    for _, p in ipairs(posed) do
-      drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
-                 p.lift)
-    end
-    Voxel3D.endShadows()
-  end
 
   -- Sprite sheets from here to the figure pass: their texture coordinates
   -- mean nothing to the tileset-shaped glass mask, so the glass is off or
diff --git a/lib/Water.lua b/lib/Water.lua
new file mode 100644
index 0000000..96f5368
--- /dev/null
+++ b/lib/Water.lua
@@ -0,0 +1,1201 @@
+-- Voxel world mode: water, and what it reflects.
+--
+-- Every other surface in this mode is opaque and is drawn once, inside the
+-- terrain mesh, by the scene shader. Water is neither: it is a MIRROR, and
+-- a mirror cannot be drawn until the thing it reflects already exists. So
+-- the water surface is lifted out of the terrain mesh at build time
+-- (ChunkMesher's water sink) and drawn as its own pass, after the world and
+-- before the characters, by the shader below.
+--
+-- WHAT IT REFLECTS, in the order the shader resolves them:
+--
+--   the sky      the reflected direction is put through the SAME matrix the
+--                frame is drawn with, as a point at infinity, and the canvas
+--                row that lands on is looked up on Sky's own band ramp --
+--                the identical texture, dither and display-mode transform
+--                the painted sky uses. So the sky in the lake is the sky
+--                over it: blue at noon, gold at dusk, navy under the moon,
+--                and it meets the painted sky at the waterline with no seam.
+--
+--   the sun,     hung by ANGLE rather than by screen position, because a
+--   the moon     reflected body is usually off the top of the frame and a
+--                projected point is meaningless out there. The angular
+--                radius is Sky.discRadius converted through the camera's own
+--                field of view, so the disc on the water is exactly as big
+--                as the disc in the sky -- craters, dithered rim, the
+--                sunset's loom and all. This is also the specular: a low sun
+--                lays a broken gold path across the water on its own, out of
+--                the reflection rather than out of a highlight term.
+--
+--   the world    SCREEN SPACE. The reflected ray is walked forward in world
+--                space, each step projected through the same matrix, looking
+--                for where it passes behind what the depth buffer holds --
+--                then binary-refined onto the contact and read out of a copy
+--                of the frame as it stood before the water went down. Shore
+--                trees, buildings, ledges and cliffs land in the water
+--                because they are on screen; where the ray leaves the frame
+--                or finds nothing, the sky above answers instead, which is
+--                what makes the far half of a lake sky and the near half
+--                scenery without a seam between them.
+--
+--   the cast     the walkers, the NPCs, the authored figures and a staged
+--                battle's two Pokemon. Awkward, and settled by drawing them
+--                twice: Gen 1 draws people OVER the world and water is
+--                world, so a surfing player has to composite after the
+--                water, and a reflection can only hold what came before it.
+--                So they are painted into the reflection copy alone
+--                (Voxel3D.beginWater), in the picture the water reflects and
+--                not yet in the picture it is drawn into.
+--
+-- WHAT IT CANNOT REFLECT is what no screen-space reflection can: anything
+-- that is not in the frame. A tree just off the top edge is not in the water
+-- below it, and a ray that runs off the side of the screen fades into the
+-- sky rather than ending on a line.
+--
+-- THE SURFACE ITSELF is not flat. It is a heightfield of one-world-pixel
+-- columns, each standing a whole number of pixels tall and rising and
+-- falling as waves, walked by the view ray in the pixel shader -- so the
+-- bars occlude each other and show their sides without a single extra
+-- vertex. See WAVE_HEIGHT and relief().
+--
+-- THE PASS ITSELF, and why it is shaped this way. The scene canvas carries a
+-- READABLE depth canvas (Voxel3D), and a texture cannot be sampled while it
+-- is bound as a render target -- so for the length of this pass the depth
+-- buffer is DETACHED and the shader does the depth test itself, comparing
+-- its own fragment depth against the texture it just stopped writing to.
+-- That is the same test the hardware would have run, so a tree in front of a
+-- pond still hides it; what it costs is depth WRITES, which water has no use
+-- for anyway (it is flat, it never overlaps itself, and everything drawn
+-- after it stands on top of it by construction).
+--
+-- Falls back all the way down. No readable depth canvas, a driver that will
+-- not compile this, or the row set to OFF and the water mesh is simply drawn
+-- by the ordinary scene shader -- flat animated water, exactly what the mode
+-- drew before any of this existed.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local Sky = V.require("Sky")
+local DayNight = V.require("DayNight")
+local ShadowMap = V.require("ShadowMap")
+local Mat4 = V.require("Mat4")
+
+local Water = {}
+
+-- ------- the row
+--
+-- Three rungs rather than a toggle, because the two halves of this cost
+-- very different things. SKY is a handful of instructions per water pixel
+-- and no extra buffers read; FULL adds the screen-space march, which is the
+-- part that samples a depth texture twenty-odd times. A phone that wants the
+-- sunset on the lake but not the ray march has somewhere to stand.
+Water.KEY = "water"
+Water.LABEL = "WATER"
+
+Water.setting = ModSetting.new(Water.KEY, Water.LABEL,
+                               { "full", "sky", "off" },
+                               { "FULL", "SKY", "OFF" })
+
+function Water.level()
+  local v = Water.setting:get()
+  if v == "off" then return 0 end
+  if v == "sky" then return 1 end
+  return 2
+end
+
+-- Whether the reflective pass should run at all (either rung above OFF).
+function Water.enabled()
+  return Water.level() > 0
+end
+
+-- ------- the look, in constants
+--
+-- FRESNEL. Water reflects almost nothing looked straight down at and almost
+-- everything looked along, which is Schlick's curve -- and taken literally
+-- it hands the top rung a mirror and the other four nothing at all. This
+-- mode's rungs are named for the camera's tilt off VERTICAL, so 15 is a
+-- near-overhead camera meeting the water at 15 degrees off its normal:
+-- honest Schlick gives that about 2%, and even a generous floor of 0.14 was
+-- invisible.
+--
+-- So the floor is lifted a long way above water's true 0.04 and the exponent
+-- softened from 5 to 2: the SHAPE is still the honest one -- a low camera
+-- still gets much more of it than a high one -- but the bottom of the curve
+-- is a pond rather than a painted tile.
+Water.FRESNEL_FLOOR = 0.34
+Water.FRESNEL_CEIL = 0.92
+Water.FRESNEL_POWER = 2.0
+
+-- THE HORIZON LEAN, which is the other half of why the steeper rungs showed
+-- nothing -- and the bigger half.
+--
+-- A reflection off flat water points as far ABOVE the horizon as the eye is
+-- above the water. At the top rung that is 15 degrees: the reflected ray
+-- grazes the sky's pale end, sweeps the sun's own path and travels far
+-- enough across the screen for the march to find the shoreline. At the 15
+-- rung it is 75 degrees -- straight up. Up there the sky's bands are at
+-- their DARKEST (deep blue over blue water, which is no picture at all), the
+-- sun and moon sit at about 6 degrees of squashed elevation and are nowhere
+-- near it, and the screen-space ray leaves the top of the frame in two
+-- steps. All three of those are correct, and together they are a lake with
+-- nothing in it.
+--
+-- So the reflected direction LEANS toward the way this camera is looking, by
+-- however far the camera is from having a horizon in frame. That is a
+-- deliberate stylisation and it is worth being exact about what it costs and
+-- what it does not:
+--
+--   at the rung where the horizon IS in frame the lean is ZERO, so the one
+--   place the join can actually be seen -- the waterline, where the lake
+--   meets the painted sky -- is still the exact reflection it was.
+--
+--   at the rungs where the horizon is above the top edge there is no join to
+--   break, and what the lean buys is the whole of the effect: the pale bands,
+--   the sunset, the moon's path, and a screen-space ray that travels ACROSS
+--   the diorama instead of straight out of it.
+--
+-- It leans toward an ELEVATION rather than by a weight, and that matters.
+-- Mixing the ray a fixed fraction of the way toward horizontal sounds like
+-- the same thing and is not: the ray it starts from is different at every
+-- rung, so a fixed fraction lands them all somewhere different, and the
+-- middle rungs came out worst of all -- further from the sun than the
+-- steepest one. Aimed at an elevation, every rung below the top one puts its
+-- reflection where the TOP rung puts its own, which is the one place the
+-- effect is known to work.
+--
+-- Measured off Voxel3D.descent -- the sine of how far below horizontal the
+-- view runs -- so it answers for the battle's placed camera too, which has
+-- no rung to be asked about.
+Water.LEAN_FROM = 0.30         -- descent where the lean starts: the top rung's
+Water.LEAN_FULL = 0.55         -- and where it is complete
+-- the elevation it aims at: the one the top rung's own reflection sits at,
+-- stated as that same descent so the two cannot drift apart
+Water.LEAN_ELEV = math.asin(Water.LEAN_FROM)
+
+function Water.lean(descent)
+  local span = Water.LEAN_FULL - Water.LEAN_FROM
+  local t = ((descent or 0) - Water.LEAN_FROM) / span
+  if t <= 0 then return 0 end
+  return t < 1 and t or 1
+end
+
+-- ------- the waves
+--
+-- Not a normal map. The surface is a HEIGHTFIELD of one-world-pixel columns
+-- -- the same unit every other voxel in this mode is built from, and exactly
+-- one texel of the water tile (a tile is 8 texels across 8 world pixels) --
+-- and every column stands at a whole number of pixels. So the water is a
+-- field of little square bars rising and falling on their own, which is what
+-- water made of pixels should look like from a camera that can see it in 3D.
+--
+-- It is drawn without any extra geometry. The mesh is still one flat quad
+-- per tile; the columns are found by walking the view ray down through the
+-- slab in the pixel shader (relief mapping) and taking the first one it
+-- meets. That is what makes them read as SOLID rather than as shading: a
+-- tall bar hides the shorter ones behind it, you see the SIDE of the ones
+-- facing you, and the whole field parallaxes against the plane as the camera
+-- moves. The side faces wear the mesh's own direction shading
+-- (Voxel3D.FACE_SHADE, sent in rather than restated) so a wave crest is lit
+-- like every other voxel in the world.
+--
+-- HEIGHT is in world pixels: the tallest a column may stand above the plane
+-- the quad is drawn on, and so both the amplitude and the number of rungs a
+-- crest can climb through (five gives six).
+--
+-- It is well past the 2px recess TileShape sinks water into, which is a
+-- deliberate look rather than an oversight: the crests are RELIEF, drawn
+-- inside the water quad's own screen footprint, so a bar that reaches above
+-- the shoreline cannot actually spill over the bank -- it is clipped at the
+-- water's edge like everything else this pass draws. What it buys is a
+-- surface with real swell in it instead of a two-rung terrace.
+Water.WAVE_HEIGHT = 5
+
+-- ------- the trains
+--
+-- Each is { fx, fz, speed, weight }. The vector is the train's DIRECTION and
+-- its frequency in one -- the crest runs across it, and two pi over its
+-- length is the wavelength in world pixels -- and `speed` is what walks it.
+--
+-- The first one dominates, and that weighting is the whole difference
+-- between water and soup: a wave has a direction, and its crest is a line
+-- running across it for as far as the surface goes. Three trains of equal
+-- weight cancel and reinforce in patches instead, and the field comes out as
+-- round islands of raised pixels with no travel to them.
+--
+-- Long, too: the dominant wavelength is about forty world pixels, five
+-- tiles, so a crest is a run of hundreds of columns at one height with a
+-- step down either side. Pitched anywhere near a pixel they stop being waves
+-- and become static -- every column its own island.
+--
+-- Read into the shader source rather than sent as uniforms, so the rate
+-- below can be derived from the same numbers the field is built out of.
+Water.WAVE_TRAINS = {
+  { 0.150, 0.062, 1.60, 0.60 },
+  { 0.058, 0.132, -1.05, 0.29 },
+  { -0.041, 0.033, 0.55, 0.11 },
+}
+
+-- ------- and the beat they move on
+--
+-- The surface does not slide, it advances in STEPS -- 15 a second, off the
+-- engine's own frame counter, which is the cadence hand-drawn pixel art is
+-- animated at and the reason this reads as art rather than as a simulation
+-- someone forgot to stylise. A surface built out of whole pixels that crawls
+-- between them smoothly gives away that the quantisation is only skin deep.
+Water.WAVE_FPS = 15
+
+-- How far the dominant train advances each of those steps, in WORLD PIXELS.
+-- One is the honest choice for a stepped surface: the whole field shifts by
+-- exactly one pixel per frame, so nothing ever lands half-way between two.
+-- The rate below is derived from it rather than tuned beside it, so changing
+-- a wavelength moves the speed with it instead of quietly desynchronising.
+Water.WAVE_PIXELS_PER_STEP = 1
+
+-- Radians of wave phase per second. A train travels `speed / frequency`
+-- world pixels per radian of phase, so the phase that moves the dominant one
+-- a pixel is its frequency over its speed -- times the step rate.
+function Water.waveRate()
+  local t = Water.WAVE_TRAINS[1]
+  local freq = math.sqrt(t[1] * t[1] + t[2] * t[2])
+  local speed = math.abs(t[3])
+  if not (freq > 0 and speed > 0) then return 0 end
+  return Water.WAVE_PIXELS_PER_STEP * (freq / speed) * Water.WAVE_FPS
+end
+-- Relief samples down through the slab. With the stride pinned at one world
+-- pixel (see WAVE_STRIDE) this is also how FAR the march can see: sixteen
+-- samples, sixteen pixels of parallax, which covers the slab at every rung
+-- but the very lowest and leaves the rest to fade out honestly.
+--
+-- The pass early-outs entirely (see relief) whenever the camera is steep
+-- enough that the whole slab projects to under a pixel across, which is most
+-- of the ladder -- so the cost of this only lands where it buys something.
+Water.WAVE_STEPS = 16
+
+-- The furthest one relief sample may travel ACROSS the surface, in world
+-- pixels -- which is what bounds how far the march runs in total.
+--
+-- The march's reach is the slab's depth over the ray's descent, so it grows
+-- without limit as the camera flattens: at the top rung, fragments near the
+-- horizon look along the water at a few degrees and the reach runs to
+-- hundreds of world pixels. Spread over a fixed number of samples that steps
+-- clean over whole crests, and the surface comes apart into streaks running
+-- away from the eye. Capping the span is what keeps a sample worth taking;
+-- what it costs is parallax on the far water, where the columns are under a
+-- pixel across and there was nothing left to see anyway.
+--
+-- This is the FLOOR on it. The stride the march actually takes is a SCREEN
+-- pixel's worth of surface, which is the only rate that makes sense:
+--
+--   up close, a screen pixel is a fraction of a world pixel, so the stride
+--   sits on this floor of one world pixel and the march visits every column
+--   on its path. It has to: a column is one world pixel wide, a longer
+--   stride steps over columns, and which ones it misses changes from
+--   fragment to fragment -- neighbouring pixels landing on different columns
+--   at different heights wearing different faces. That is peppery noise.
+--
+--   far away, a screen pixel already spans several world pixels, so a stride
+--   that matches it skips columns the screen could not have resolved anyway.
+--   Holding it at one world pixel out there does not buy detail, it just
+--   runs out of samples -- and a march that runs out stops part-way down the
+--   slab and reports the surface as flat, which is why the lowest rung lost
+--   its waves entirely across the whole middle distance.
+Water.WAVE_STRIDE = 1
+
+-- How far the wave field's own gradient tilts the REFLECTION. A multiplier
+-- on the SMOOTH surface's slope, not on the stepped one -- see waveNormal
+-- for why that distinction is the whole difference between a moon on the
+-- water and confetti. The field's gradient peaks around 0.06 per world
+-- pixel, so this lands the steepest faces about twelve degrees off vertical:
+-- enough to sweep a low sun or moon into a broken glitter path down the
+-- lake, and not so much that the sky's own bands come apart.
+Water.WAVE_SLOPE = 3.5
+-- and how far the horizon lean is allowed to open that up, since it squashes
+-- the same tilt on its way past (see LEAN_FROM)
+Water.WAVE_SLOPE_LEAN = 1.5
+
+-- THE MARCH. Steps are in world pixels and lengthen as they go: near the
+-- surface the reflection needs precision (a shoreline is a few pixels), far
+-- from it reach matters more than accuracy, and a geometric ramp gets both
+-- out of one loop. RAY_STEPS is compiled in -- GLSL wants a constant bound.
+Water.RAY_STEPS = 24
+Water.RAY_REFINE = 5           -- halvings once a crossing is found
+Water.RAY_STEP = 3.0           -- world pixels in the first step
+-- and the ratio each step after it. 3 x (1.18^24 - 1) / 0.18 is about 930
+-- world pixels of reach -- three view-heights, past which a reflection is
+-- faded out anyway (see the tail fade in march) and the sky is the honest
+-- answer: distant water reflects haze, which is what the bands already are.
+Water.RAY_GROW = 1.18
+-- How far behind the depth buffer a crossing may land and still count, as a
+-- multiple of the depth the step itself covered. A ray that dives far past
+-- what it crossed went BEHIND a thin thing rather than hitting it -- the
+-- classic screen-space smear, where a tree between the camera and the pond
+-- paints itself across the water -- and this is the test that drops it.
+Water.RAY_THICK = 1.6
+Water.EDGE_FADE = 0.14         -- reflection eased off over this much of the frame
+
+-- ------- the shader
+--
+-- The scene shader's own vertex path, plus the world position the geometry
+-- was actually DRAWN at -- after the world curve, because that is the space
+-- the depth buffer holds and therefore the space the march has to walk in.
+-- (The curve only ever moves Y, so a fragment's world XZ is the same on both
+-- sides of it and the ripple can be measured off this one too.)
+local SHADER_SRC = [[
+varying float vShade;
+varying vec3 vSun;
+varying vec3 vBent;          // world position, as drawn
+
+#ifdef VERTEX
+uniform mat4 vp;
+uniform mat4 model;
+uniform mat4 sunVP;
+uniform vec3 curve;          // xy = the focus in world XZ, z = k; 0 = off
+attribute float VertexShade;
+
+vec4 position(mat4 transform_projection, vec4 vertex_position) {
+  vShade = VertexShade;
+  vec4 w = model * vertex_position;
+  vSun = (sunVP * w).xyz;
+  if (curve.z > 0.0) {
+    vec2 cd = w.xz - curve.xy;
+    w.y -= dot(cd, cd) * curve.z;
+  }
+  vBent = w.xyz;
+  return vp * w;
+}
+#endif
+
+#ifdef PIXEL
+uniform mat4 vp;
+uniform vec3 eye;
+uniform vec2 screen;         // the canvas, in pixels
+uniform float cell;          // one diorama pixel, in canvas pixels
+uniform float pxAngle;       // radians of view one screen pixel subtends
+
+// the sun's own pass, exactly as the scene shader reads it
+uniform Image sunMap;
+uniform float sunDark;
+uniform float sunBias;
+uniform vec2 sunTexel;
+uniform vec3 dayTint;
+
+// the frame as it stood before the water went down, and its depth
+uniform Image reflectTex;
+uniform Image depthTex;
+
+uniform float rays;          // 0 = sky only, 1 = march the screen too
+uniform vec3 lookFlat;       // the way the horizon lies from this camera
+uniform float lean;          // and how far the reflection tilts toward it
+uniform float leanElev;      // the elevation it aims at, in radians
+uniform float waveHeight;    // the tallest column, in whole world pixels
+uniform float waveSlope;     // how far a column's neighbours tilt its normal
+uniform float waveSlopeLean; // and how far the horizon lean may open that up
+uniform float waveT;
+uniform vec4 faceShade;      // the mesh's own direction shading: E, W, S, N
+uniform vec2 atlasSize;      // the tileset atlas, in texels
+uniform float fresnelFloor;
+uniform float fresnelCeil;
+uniform float fresnelPower;
+uniform float rayStep;
+uniform float rayGrow;
+uniform float rayThick;
+uniform float edgeFade;
+
+// the sky, as Sky paints it
+uniform Image skyRamp;
+uniform float skyCount;
+uniform float skyEdge;       // the sky's bottom, in canvas pixels
+uniform float skyStart;      // where the checker begins inside a band
+uniform float skyOn;         // 0 indoors: there is no sky to reflect
+
+// and what hangs in it
+uniform vec3 bodyDir;
+uniform float bodyOn;
+uniform float bodyMoon;
+uniform float bodyAng;       // the disc's angular radius, in radians
+uniform vec3 bodyCore;
+uniform vec3 bodyMain;
+uniform vec3 bodyDark;
+uniform float glowAmt;
+uniform float glowReach;     // in radians, like bodyAng
+uniform vec3 glowColor;
+
+#ifdef VOXEL_GRID
+uniform float gridDark;
+uniform float gridWidth;
+#endif
+
+// ------- the sun's pass (the scene shader's, verbatim)
+
+// One shadow tap: 1 where the sun reaches, 0 where something blocks it --
+// EXCEPT that water declines one kind of blocker.
+//
+// The sun pass marks the cast in the blue channel (ShadowMap.sprites), and
+// water ignores those. A character standing at a lake's edge laid a hard
+// cut-out of its own sprite across the surface, and on something that is
+// already showing the sky, the shoreline and the trees behind it, a
+// silhouette of somebody reads as a sticker on the water rather than as a
+// shadow in it. Everything the WORLD casts -- trees, buildings, cliffs,
+// ledges -- still shades it, which is the half that was worth having.
+float sunLit(vec2 uv, float z) {
+  vec4 c = Texel(sunMap, uv);
+  return max(step(z, c.r + c.g * (1.0 / 255.0)), c.b);
+}
+
+float sunlight(vec3 p) {
+  if (sunDark <= 0.0) return 1.0;
+  if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) {
+    return 1.0;
+  }
+  vec2 e = min(p.xy, 1.0 - p.xy);
+  float edge = smoothstep(0.0, 0.06, min(e.x, e.y));
+  if (edge <= 0.0) return 1.0;
+  float z = p.z - sunBias;
+  float lit = sunLit(p.xy + sunTexel * vec2(-0.5, -0.5), z)
+            + sunLit(p.xy + sunTexel * vec2( 0.5, -0.5), z)
+            + sunLit(p.xy + sunTexel * vec2(-0.5,  0.5), z)
+            + sunLit(p.xy + sunTexel * vec2( 0.5,  0.5), z);
+  return 1.0 - sunDark * edge * (1.0 - lit * 0.25);
+}
+
+#ifdef VOXEL_GRID
+// The wireframe, ruled on the COLUMNS rather than on the flat sheet they
+// stand on.
+//
+// The scene shader reads a mesh's own model space, and for water that is the
+// base plane -- so it would draw a grid across a flat sheet and ignore the
+// bars entirely, which is the one thing that would give away that they are
+// bars. What has to be outlined is what is actually SEEN: the column the ray
+// landed on, at the height it landed at, so every voxel of water reads as
+// its own block with its own edges.
+//
+// `p` is that hit; `base` is the smooth plane under it, and the derivative
+// comes from THERE. `p` jumps a whole column between neighbouring fragments,
+// so fwidth() of it reports a step rather than a scale and every column edge
+// would blow out into a band. The plane underneath is smooth, and is the
+// same scale in x and z that the columns are built on.
+//
+// `axis` is the direction the face does not vary along -- the top's own y,
+// or a side's x or z. Its distance to the nearest plane is a constant zero,
+// and taken at face value it floods the whole face solid; pushed out of
+// reach it simply drops out, exactly as the scene shader's own seam handles
+// the axis a face's normal points along.
+float columnSeam(vec3 p, vec3 base, float axis) {
+  vec3 w = fwidth(base);
+  float wide = max(w.x, w.z);
+  // the plane has no vertical extent of its own to measure, so y borrows
+  // the horizontal scale -- it sets a line's THICKNESS and nothing else
+  w.y = wide;
+  vec3 d = abs(fract(p + 0.5) - 0.5);
+  vec3 px = d / max(w, vec3(1e-6));
+  if (axis < 0.5) { px.y += 1e6; }
+  else if (axis < 1.5) { px.x += 1e6; }
+  else { px.z += 1e6; }
+  float near = min(min(px.x, px.y), px.z);
+  // Fade out where a column is too small on screen to hold a line at all,
+  // or the far water turns into a flat wash of seams rather than a grid.
+  //
+  // It holds on further than the scene shader's own does. That one is ruling
+  // seams across whole 16px walls and roofs; these are one world pixel
+  // apart, so the fade starts biting while the water is still perfectly
+  // readable -- and at the lowest rung, where the middle distance is most of
+  // the frame, it took the grid off nearly all of it. Full lines by a pixel
+  // and a half of screen space, gone under three quarters of one.
+  float span = 1.0 / max(wide, 1e-6);
+  float fade = clamp((span - 0.75) * 1.35, 0.0, 1.0);
+  return fade * clamp(gridWidth * 0.5 + 0.5 - near, 0.0, 1.0);
+}
+#endif
+
+// ------- the sky, by direction
+
+vec3 bandAt(float i) {
+  return Texel(skyRamp,
+               vec2((clamp(i, 0.0, skyCount - 1.0) + 0.5) / skyCount, 0.5)).rgb;
+}
+
+// Where a DIRECTION lands on the sky's own gradient, as a band coordinate
+// in [0, count]: 0 is straight overhead, count the horizon.
+//
+// Measured by putting the direction through the very matrix the frame is
+// drawn with, as a point at infinity -- which is how Voxel3D finds both the
+// vanishing line and the sun's place on the canvas. So the reflected sky and
+// the painted sky are answering the same question with the same arithmetic,
+// and they agree at the waterline for free at any pitch, fov or zoom.
+//
+// A direction whose w comes out negative is BEHIND the camera plane, which
+// for an upward reflection means near-vertical: the top band, overhead.
+float skyPos(vec3 d) {
+  vec4 c = vp * vec4(d, 0.0);
+  if (c.w <= 1e-6) return 0.0;
+  float py = (c.y / c.w * 0.5 + 0.5) * screen.y;
+  float row = floor(py / cell) * cell;
+  return clamp(row / max(skyEdge, 1.0), 0.0, 1.0) * skyCount;
+}
+
+// `parity` is the diorama checkerboard this fragment sits on -- the same
+// one Sky's own dither is cut from, so the reflected gradient breaks up in
+// the same 8-bit way rather than being the one smooth thing in the frame.
+vec3 skyAt(vec3 d, float parity) {
+  float pos = skyPos(d);
+  float base = min(floor(pos), skyCount - 1.0);
+  vec3 c = bandAt(base);
+  if (base < skyCount - 1.0 && (pos - base) > skyStart && parity < 0.5) {
+    c = bandAt(base + 1.0);
+  }
+  return c;
+}
+
+float crater(vec2 p, vec2 c, float r) {
+  vec2 dd = p - c;
+  return step(dot(dd, dd), r * r);
+}
+
+// The sun or moon, and the twilight warmth around it, laid over the bands.
+//
+// By ANGLE, not by screen position: the reflected direction usually
+// projects off the top of the frame entirely, where screen distances stop
+// meaning anything. bodyAng is Sky.discRadius run back through the camera's
+// field of view, so this disc is the same size as the painted one.
+vec3 bodyAt(vec3 d, vec3 c, float parity) {
+  if (bodyOn <= 0.0) return c;
+  float ang = acos(clamp(dot(d, bodyDir), -1.0, 1.0));
+  if (glowAmt > 0.0) {
+    float g = glowAmt * pow(clamp(1.0 - ang / glowReach, 0.0, 1.0), 2.0);
+    float lvl = floor(g * 4.0);
+    if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
+    c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
+  }
+  if (ang > bodyAng) return c;
+  float t = ang / bodyAng;
+  // the dithered rim, exactly as the painted disc keeps one parity of its
+  // outer ring of cells
+  if (t > 0.86 && parity < 0.5) return c;
+  vec3 disc = (t <= 0.5) ? bodyCore : bodyMain;
+  if (bodyMoon > 0.5) {
+    // disc-local coordinates: a frame built off world up, so the craters
+    // sit on the moon the same way round every night
+    vec3 t1 = normalize(cross(vec3(0.0, 1.0, 0.0), bodyDir));
+    vec2 dc = vec2(dot(d, t1), dot(d, cross(bodyDir, t1))) / bodyAng;
+    float k = 0.0;
+//@CRATERS
+    if (k > 0.0) { disc = bodyDark; }
+  }
+  return disc;
+}
+
+// ------- the screen-space march
+
+// A point as (uv, depth, valid), through the very matrix the frame was drawn
+// with. The uv and the depth are the same numbers the hardware wrote -- the
+// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs
+// the same way its pixel rows do, so one 0.5x+0.5 answers for both.
+//
+// This is why the march walks in the world as DRAWN rather than as authored:
+// the depth buffer holds the curved world, so a straight line in that space
+// is the ray, and a straight line in the flat one would bend through it.
+vec4 project(vec3 p) {
+  vec4 c = vp * vec4(p, 1.0);
+  if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0);
+  return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0);
+}
+
+// Walk the reflected ray until it passes behind the depth buffer. Returns
+// the colour found in .rgb and how much of it to believe in .a -- 0 for a
+// ray that left the frame, ran out of steps, or crossed something it went
+// straight through rather than landed on.
+vec4 march(vec3 origin, vec3 dir) {
+  vec4 miss = vec4(0.0, 0.0, 0.0, 0.0);
+  vec3 a = origin;
+  vec4 pa = project(a);
+  if (pa.w < 0.5) return miss;
+  float len = rayStep;
+  for (int i = 0; i < RAY_STEPS; i++) {
+    vec3 b = a + dir * len;
+    vec4 pb = project(b);
+    if (pb.w < 0.5) return miss;
+    if (pb.x < 0.0 || pb.x > 1.0 || pb.y < 0.0 || pb.y > 1.0) return miss;
+    float scene = Texel(depthTex, pb.xy).r;
+    if (pb.z > scene) {
+      // how much depth this one step covered: the yardstick for whether
+      // the crossing is a surface or a thin thing the ray shot past
+      float span = max(abs(pb.z - pa.z), 1e-7);
+      if (pb.z - scene > span * rayThick) return miss;
+      // binary-refine onto the contact
+      vec3 lo = a;
+      vec3 hi = b;
+      for (int k = 0; k < RAY_REFINE; k++) {
+        vec3 m = (lo + hi) * 0.5;
+        vec4 pm = project(m);
+        if (pm.z > Texel(depthTex, pm.xy).r) { hi = m; } else { lo = m; }
+      }
+      vec4 hit = project(hi);
+      if (hit.w < 0.5) return miss;
+      // Ease out at the frame's rim, where the reflection is about to run
+      // off the only evidence there is -- and with distance travelled, so a
+      // long ray hands back to the sky instead of ending on a hard edge.
+      //
+      // The distance term is doing two jobs. It hides the march's own tail,
+      // where the steps are longest and a grazing crossing is least likely
+      // to be a real surface -- and it is also true: distant water reflects
+      // haze rather than detail, and the haze is what the bands underneath
+      // already are. The small floor keeps a genuine far hit as a trace
+      // rather than deleting it.
+      vec2 e = min(hit.xy, 1.0 - hit.xy);
+      float edge = smoothstep(0.0, edgeFade, min(e.x, e.y));
+      float far = 1.0 - clamp(float(i) / float(RAY_STEPS), 0.0, 1.0);
+      return vec4(Texel(reflectTex, hit.xy).rgb, edge * (0.15 + 0.85 * far));
+    }
+    a = b;
+    pa = pb;
+    len *= rayGrow;
+  }
+  return miss;
+}
+
+// ------- the surface, as a field of pixel-tall columns
+
+// How high the column at world pixel `q` stands, in WHOLE world pixels.
+//
+// Whole, because that is what makes them BARS: a column is a voxel like
+// every other voxel in this mode, one unit on a side, and a surface that
+// stepped in fractions would just be a smooth wave with extra arithmetic.
+// Three crossing wave trains, so the field has no readable repeat inside a
+// lake's worth of pixels.
+//
+// The SMOOTH surface underneath, 0 to 1 -- the thing the columns are a
+// quantisation of. Summed from Water.WAVE_TRAINS, which is where the trains
+// and the reasoning behind their weights live; pasted in rather than sent,
+// so the speed derived from those same numbers cannot drift from the field
+// they describe.
+float waveRaw(vec2 q) {
+  float h = 0.0;
+//@TRAINS
+  return h * 0.5 + 0.5;
+}
+
+// and the voxel surface: that field, in whole world pixels.
+float waveAt(vec2 q) {
+  if (waveHeight <= 0.0) return 0.0;
+  return floor(waveRaw(q) * waveHeight + 0.5);
+}
+
+// The tilt this column reflects with -- taken from the SMOOTH field, not
+// from the stepped one, and this is the difference between a moon on the
+// water and confetti.
+//
+// Floored heights are integers, so their differences are integers too: a
+// column's neighbours are level with it or a whole pixel off, and nothing in
+// between. Build the normal out of THOSE and the reflected ray can only ever
+// point in about five directions -- straight up, or rotated by twice the
+// arctangent of one step, or of two. A flat sky does not mind; the sun and
+// the moon are discs barely two degrees across, and a ray that jumps in
+// eighteen-degree increments simply steps over them. The lake goes dark and
+// the odd column that happens to land dead on flares -- which is exactly
+// what "the moon doesn't reflect right" looks like.
+//
+// The columns are an approximation of a real surface, and light reflects off
+// the surface being approximated. So the SHAPE stays quantised -- it is what
+// you see, and it is the whole point -- while the normal is read off the
+// smooth field the shape is made from. Still one answer per column, because
+// `q` is an integer: pixel-quantised in space, continuous in value, which
+// puts the glitter path back without softening a single edge.
+//
+// Forward differences over one pixel: three samples, and the answer only has
+// to say which way this piece of the surface leans.
+vec3 waveNormal(vec2 q, float tilt) {
+  float h = waveRaw(q);
+  float e = waveRaw(q + vec2(1.0, 0.0)) - h;
+  float s = waveRaw(q + vec2(0.0, 1.0)) - h;
+  return normalize(vec3(-e * tilt, 1.0, -s * tilt));
+}
+
+
+// Walk the view ray down through the wave slab and return the column it
+// actually meets -- RELIEF MAPPING, and the whole reason the bars read as
+// solid rather than as a pattern painted on a flat sheet.
+//
+// The mesh is still one flat quad per tile, so what gets rasterised is the
+// point where the ray crosses the BASE plane. The visible surface is
+// somewhere above that, and the two differ by more the lower the camera
+// sits. So the ray is walked BACKWARD to the top of the slab and then
+// stepped down: the first column whose top it falls below is what the eye is
+// looking at, and everything shorter behind that column is hidden by it for
+// free, because the march simply never reaches it.
+//
+// A step that lands below a column's top having just ARRIVED in that column
+// is looking at its side; one that was already there and fell through is
+// looking at its top. That is the whole of the face test, and it is what
+// gives a crest a lit face and a shaded one.
+//
+// `axis` names which way the face it found points -- 0 top, 1 east/west, 2
+// north/south -- because the wireframe needs to know the one direction the
+// face does not vary along (see columnSeam).
+void relief(vec3 base, vec3 dir, out vec3 hit, out vec2 col, out float face,
+            out float axis) {
+  col = floor(base.xz);
+  hit = base;
+  face = 1.0;
+  axis = 0.0;
+  float dy = -dir.y;
+  // a ray running level along the surface has no slab to walk through, and
+  // dividing by its descent would send the start point to infinity
+  if (waveHeight <= 0.0 || dy < 0.02) return;
+  float across = length(dir.xz);
+  float reach = waveHeight / dy;
+  // How far across the surface the whole slab displaces the answer. Under
+  // half a pixel it cannot pick a different column than the one already
+  // under the fragment, so the march would spend its samples arriving where
+  // it started -- which is exactly the case at the steep rungs, where the
+  // camera looks nearly straight down the columns and there is no side of a
+  // bar to see anyway.
+  float span = reach * across;
+  if (span < 0.5) {
+    hit.y = base.y + waveAt(col);
+    return;
+  }
+  // and the other end: `reach` grows as one over the descent, so a grazing
+  // ray asks for hundreds of world pixels of march from a fixed number of
+  // samples.
+  //
+  // What one sample is worth is a SCREEN pixel of surface, so that is the
+  // stride (see WAVE_STRIDE). A screen pixel covers this much of the water:
+  // the distance to the eye times the angle one pixel subtends, opened out
+  // by the obliquity -- a surface seen edge-on runs away far faster per
+  // pixel than one seen face-on. Floored at a world pixel, because up close
+  // a finer stride than the columns themselves buys nothing and skipping
+  // them costs everything.
+  float dist = length(base - eye);
+  float stride = max(WAVE_STRIDE, dist * pxAngle / dy);
+  float maxSpan = float(WAVE_STEPS) * stride;
+  if (span > maxSpan) { reach = maxSpan / max(across, 1e-4); }
+  vec3 top = base - dir * reach;
+  vec2 wasCol = floor(top.xz);
+  for (int i = 1; i <= WAVE_STEPS; i++) {
+    vec3 p = mix(top, base, float(i) / float(WAVE_STEPS));
+    vec2 q = floor(p.xz);
+    float y = base.y + waveAt(q);
+    if (p.y <= y) {
+      col = q;
+      hit = vec3(p.x, y, p.z);
+      vec2 d = q - wasCol;
+      if (abs(d.x) + abs(d.y) < 0.5) {
+        face = 1.0;                                  // fell through the top
+        axis = 0.0;
+      } else if (abs(d.x) > abs(d.y)) {
+        face = (d.x > 0.0) ? faceShade.y : faceShade.x;   // west : east
+        axis = 1.0;
+      } else {
+        face = (d.y > 0.0) ? faceShade.w : faceShade.z;   // north : south
+        axis = 2.0;
+      }
+      return;
+    }
+    wasCol = q;
+  }
+}
+
+// The water's own art, read at the column the ray landed on rather than at
+// the fragment's own place on the flat quad -- otherwise the bars parallax
+// away and the pixels they are made of stay behind on the plane.
+//
+// Read off the COLUMN, not off the fragment.
+//
+// One world pixel is one atlas texel exactly, and the mesher lays a tile's
+// eight texels across its eight world pixels -- so the column at world
+// (cx, cz) wears texel (cx mod 8, cz mod 8) and nothing else. That makes the
+// lookup exact, and far more importantly STABLE: the art a column shows
+// depends only on where that column stands in the world, so it cannot swim
+// as the camera moves and two fragments that landed on the same column
+// cannot disagree about it.
+//
+// Offsetting the fragment's own uv by the parallax instead makes the art
+// depend on how far the march happened to travel -- and wherever the march
+// skipped a column, neighbouring fragments picked texels several apart. That
+// is what peppered the surface with noise, and why it cleared up in patches:
+// the patches are where the march was not skipping.
+//
+// The tile origin is the FRAGMENT's, so the lookup can never leave the tile
+// this quad was built to sample -- the same bleed the mesher's INSET stops.
+vec2 waveUV(vec2 tc, vec2 col) {
+  vec2 texel = 1.0 / atlasSize;
+  vec2 tile = 8.0 * texel;
+  vec2 org = floor(tc / tile) * tile;
+  return org + (mod(col, 8.0) + 0.5) * texel;
+}
+
+vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+  // THE DEPTH TEST, done here because the buffer that would have done it is
+  // detached for the length of this pass so it can be READ (see the header).
+  // Same comparison, same buffer, same result: a building in front of a pond
+  // still hides it.
+  vec2 uv = sc / screen;
+  if (gl_FragCoord.z > Texel(depthTex, uv).r) discard;
+
+  // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of
+  // its own standing a whole number of pixels tall, and the ray decides
+  // which one it meets -- so what follows is answered per COLUMN and not per
+  // screen pixel: one colour to a bar, at the resolution the water art is
+  // drawn at, with no smooth shading anywhere across it. (The depth test
+  // above is the one thing that stays per fragment: that is the hardware's
+  // own question and it is asked in screen space.)
+  vec3 view = normalize(vBent - eye);
+  vec3 hit;
+  vec2 col;
+  float face;
+  float axis;
+  relief(vBent, view, hit, col, face, axis);
+  // and the bar's centre, so a column is sampled and reflected from one
+  // place rather than from wherever inside it the fragment happened to land
+  vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5);
+
+  vec4 p = Texel(tex, waveUV(tc, col));
+  if (p.a < 0.5) discard;
+  // `face` is the column's own side shading, which is what makes a crest
+  // read as a solid thing with a lit flank rather than as a bright patch
+  vec3 base = p.rgb * vShade * face * sunlight(vSun) * dayTint;
+
+  // the reflection follows the WAVES' own shape -- the tilt this column
+  // takes from the neighbours it stands beside -- rather than an invented
+  // wobble, so the sky and the sun break along the bars instead of across
+  // them. Opened up by the lean, which is about to squash it (see below).
+  vec3 n = waveNormal(col, waveSlope * (1.0 + lean * waveSlopeLean));
+  vec3 r = reflect(view, n);
+  // the same reflection off a LEVEL surface, which is what the lean below
+  // moves: the difference between the two is this column's own contribution
+  vec3 rFlat = reflect(view, vec3(0.0, 1.0, 0.0));
+  // The horizon lean (see LEAN_FROM in Water.lua): zero at the rung whose
+  // horizon is in frame, so the waterline join is untouched; taking the ray
+  // down to the elevation THAT rung reflects at as the camera tips over,
+  // where there is no join to break and a straight-up reflection has nothing
+  // in it. Applied to the sky, the body AND the march, because the same
+  // seventy-five-degree ray that misses the sun also leaves the frame.
+  //
+  // What is leaned is the LEVEL reflection, with this column's own deflection
+  // put back on top afterwards. Leaning the perturbed ray instead sets its
+  // elevation outright, which overwrites the very variation the waves are
+  // there to provide: at full lean every column on the lake reflects the
+  // same elevation, the sky comes out one flat band and the moon -- a disc
+  // two degrees wide that the ray now never sweeps past -- vanishes
+  // completely. Which is exactly what it did.
+  //
+  // The ray keeps its own BEARING and is only tipped in elevation, so a
+  // reflection still points where the water is pointing it -- and a ray so
+  // near vertical that it has no bearing left borrows the camera's.
+  if (lean > 0.0) {
+    float fl = length(rFlat.xz);
+    vec3 bearing = (fl > 1e-3) ? vec3(rFlat.x / fl, 0.0, rFlat.z / fl)
+                               : lookFlat;
+    float e = mix(asin(clamp(rFlat.y, -1.0, 1.0)), leanElev, lean);
+    r = normalize(bearing * cos(e) + vec3(0.0, sin(e), 0.0) + (r - rFlat));
+  }
+
+  // The checkerboard the sky's bands and the sunset's glow are dithered on,
+  // cut from the WATER's own columns rather than from the screen. Same
+  // reasoning the window glint follows in the scene shader: a pattern
+  // anchored to the screen has the world sliding through it at zoom speed
+  // whenever the camera pans, which strobes. Anchored to the surface,
+  // panning moves nothing and only the waves do.
+  float parity = mod(col.x + col.y, 2.0);
+  vec3 refl = base;
+  if (skyOn > 0.5) {
+    refl = bodyAt(r, skyAt(r, parity), parity);
+  }
+  if (rays > 0.5) {
+    vec4 hit = march(surf, r);
+    refl = mix(refl, hit.rgb, hit.a);
+  }
+
+  // Schlick, floored and softened (see FRESNEL_* in Water.lua): the angle
+  // still decides, a grazing camera still gets a mirror, and a steep one
+  // still gets a pond rather than a flat sticker.
+  float ct = clamp(dot(-view, n), 0.0, 1.0);
+  float f = fresnelFloor
+            + (fresnelCeil - fresnelFloor) * pow(1.0 - ct, fresnelPower);
+  vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0));
+
+#ifdef VOXEL_GRID
+  rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis);
+#endif
+  return vec4(rgb, 1.0) * color;
+}
+#endif
+]]
+
+-- The crater list is Sky's (Sky.MOON_CRATERS), pasted in as source rather
+-- than sent as a uniform array: GLSL ES has no array constructors worth
+-- relying on, and the driver bug that cost the sky its bands is exactly
+-- what a uniform array of vectors buys. Built from the one list, so the
+-- moon on the water can never grow craters the moon in the sky has not.
+local function craterSource()
+  local out = {}
+  for _, c in ipairs(Sky.MOON_CRATERS) do
+    out[#out + 1] = ("    k += crater(dc, vec2(%.4f, %.4f), %.4f);")
+      :format(c[1], c[2], Sky.CRATER_FRAC)
+  end
+  return table.concat(out, "\n")
+end
+
+Water._craterSource = craterSource     -- named for the suite
+
+-- and the wave trains, pasted in for the same reason: the rate is derived
+-- from this table (Water.waveRate), so the field the shader sums has to be
+-- the one that table describes rather than a copy of it kept in step by hand
+local function trainSource()
+  local out = {}
+  for _, t in ipairs(Water.WAVE_TRAINS) do
+    out[#out + 1] = ("  h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)"
+                     .. " * %.4f;"):format(t[1], t[2], t[3], t[4])
+  end
+  return table.concat(out, "\n")
+end
+
+Water._trainSource = trainSource       -- named for the suite
+
+local function source(grid)
+  local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%")))
+  src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%")))
+  local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n"
+                .. "#define WAVE_STEPS %d\n#define WAVE_STRIDE %.1f\n")
+    :format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS,
+            Water.WAVE_STRIDE)
+  if grid then head = head .. "#define VOXEL_GRID 1\n" end
+  return head .. src
+end
+
+Water._source = source                 -- named for the suite
+
+-- Two compilations, exactly as Voxel3D keeps two of the scene shader: the
+-- wireframe variant needs derivatives, the one thing a driver can refuse,
+-- and a refusal must cost the seams on the water and nothing else.
+-- nil = untried, false = unavailable.
+local shaders = { [false] = nil, [true] = nil }
+
+function Water.shader(grid)
+  grid = grid and true or false
+  if shaders[grid] == nil then
+    if not (love.graphics and love.graphics.newShader) then
+      shaders[grid] = false
+    else
+      local ok, sh = pcall(love.graphics.newShader, source(grid))
+      if not ok and V and V.mod and V.mod.log then
+        -- once, where it can be read: the fallback is flat water, which is
+        -- easy to look at and impossible to diagnose without this line
+        V.mod.log:warn("water shader did not compile: %s -- lakes draw flat",
+                       tostring(sh))
+      end
+      shaders[grid] = (ok and sh) or false
+    end
+  end
+  return shaders[grid] or nil
+end
+
+-- ------- the pass
+
+local active = nil        -- the shader this pass bound, or nil
+
+-- The ripple phase. Driven by the ENGINE's tile-animation clock, the same
+-- 60Hz counter the water tiles rotate on, so the ripple and the art it
+-- ripples move off one number rather than drifting against each other.
+local function waveTime()
+  -- lazily, and through the mod namespace: TerrainAtlas reaches into the
+  -- engine's renderer at load time, and nothing about a settings row should
+  -- depend on that having happened yet
+  local ok, frame = pcall(function()
+    return V.require("TerrainAtlas")._animFrame()
+  end)
+  if not (ok and type(frame) == "number") then return 0 end
+  -- floored to the wave beat (see WAVE_FPS). The engine's counter runs at
+  -- 60, so this is the frame that step began on.
+  local period = 60 / math.max(1, Water.WAVE_FPS)
+  return (math.floor(frame / period) * period / 60) * Water.waveRate()
+end
+
+Water._waveTime = waveTime
+
+-- Begin the reflective pass.
+--
+-- `ctx` is everything the pass cannot work out for itself, all of it already
+-- computed by whoever set the camera up this frame:
+--
+--   reflect   the frame so far, as a texture (Voxel3D.beginWater)
+--   depth     its depth, likewise
+--   vp, eye, curve, screen, cell   the camera, as beginScene sent it
+--   skyEdge   where the sky's bottom is, or nil indoors / with no bands
+--   grid      whether the voxel wireframe is compiled into this frame
+--
+-- Returns false when the pass cannot run, in which case the caller draws the
+-- water mesh through the ordinary scene shader instead.
+function Water.begin(ctx)
+  if not (ctx and ctx.reflect and ctx.depth) then return false end
+  local level = Water.level()
+  if level <= 0 then return false end
+  local sh = ctx.grid and Water.shader(true) or nil
+  if not sh then sh = Water.shader(false) end
+  if not sh then return false end
+
+  love.graphics.setShader(sh)
+  love.graphics.setColor(1, 1, 1, 1)
+  local function send(name, ...)
+    pcall(sh.send, sh, name, ...)
+  end
+
+  send("vp", "row", ctx.vp)
+  send("eye", ctx.eye)
+  send("curve", ctx.curve)
+  send("screen", { ctx.screen[1], ctx.screen[2] })
+  send("cell", math.max(1, ctx.cell or 1))
+  -- how much of the view one screen pixel is worth: what sets the relief
+  -- march's stride, so a sample is always about a pixel of surface
+  send("pxAngle", (ctx.fov or 1) / math.max(1, ctx.screen[2]))
+  send("reflectTex", ctx.reflect)
+  send("depthTex", ctx.depth)
+
+  -- the sun's pass, sent the same way and for the same reason the scene
+  -- shader sends it: the sampler is declared either way, and leaving one
+  -- unbound is a driver-dependent crash rather than a fallback
+  local map = ShadowMap.active()
+  send("sunVP", "row", map and ShadowMap.uvVP or Mat4.identity())
+  local tex = ShadowMap.texture()
+  if tex then send("sunMap", tex) end
+  local Voxel3D = V.require("Voxel3D")
+  send("sunDark", map and Voxel3D.SHADOW_ALPHA or 0)
+  send("sunBias", ShadowMap.bias)
+  local texel = 1 / ShadowMap.res
+  send("sunTexel", { texel, texel })
+  send("dayTint", Voxel3D.tint or { 1, 1, 1 })
+
+  send("rays", level >= 2 and 1 or 0)
+  -- the horizon lean, and the direction it leans toward (see Water.lean)
+  send("lookFlat", ctx.lookFlat or { 0, 0, -1 })
+  send("lean", Water.lean(ctx.descent))
+  send("leanElev", Water.LEAN_ELEV)
+  send("waveHeight", Water.WAVE_HEIGHT)
+  send("waveSlope", Water.WAVE_SLOPE)
+  send("waveSlopeLean", Water.WAVE_SLOPE_LEAN)
+  send("waveT", waveTime())
+  -- the columns' side faces wear the MESH's own direction shading, sent in
+  -- rather than restated, so a wave crest is lit like every other voxel
+  local fs = Voxel3D.FACE_SHADE
+  send("faceShade", { fs[1], fs[2], fs[5], fs[6] })   -- east, west, south, north
+  send("fresnelFloor", Water.FRESNEL_FLOOR)
+  send("fresnelCeil", Water.FRESNEL_CEIL)
+  send("fresnelPower", Water.FRESNEL_POWER)
+  send("rayStep", Water.RAY_STEP)
+  send("rayGrow", Water.RAY_GROW)
+  send("rayThick", Water.RAY_THICK)
+  send("edgeFade", Water.EDGE_FADE)
+  if ctx.grid then
+    local VoxelGrid = V.require("VoxelGrid")
+    send("gridDark", VoxelGrid.DARK)
+    send("gridWidth", VoxelGrid.WIDTH)
+  end
+
+  Water.sendSky(sh, ctx)
+  active = sh
+  return true
+end
+
+-- The sky half of the uniforms: the band ramp, and whatever hangs in it.
+--
+-- Split out because it is the part with a "there is none" answer -- indoors,
+-- and on any frame whose ramp could not be built -- and that answer has to
+-- leave every sampler bound anyway. skyOn 0 reflects the water's own colour
+-- back at itself, which is what a pond in a cave does.
+function Water.sendSky(sh, ctx)
+  local function send(name, ...)
+    pcall(sh.send, sh, name, ...)
+  end
+  local ramp, count = Sky.ramp()
+  local edge = ctx.skyEdge
+  if not (ramp and count and edge and edge > 0) then
+    -- the sampler still has to hold something; the ramp is the only image
+    -- this shader has for the job, so bind the frame copy and switch it off
+    send("skyRamp", ctx.reflect)
+    send("skyCount", 1)
+    send("skyEdge", 1)
+    send("skyStart", 2)
+    send("skyOn", 0)
+    send("bodyOn", 0)
+    send("glowAmt", 0)
+    return
+  end
+  send("skyRamp", ramp)
+  send("skyCount", count)
+  send("skyEdge", edge)
+  send("skyStart", Sky.DITHER and Sky.DITHER_START or 2)
+  send("skyOn", 1)
+
+  local body = DayNight.body()
+  if not body then
+    send("bodyOn", 0)
+    send("glowAmt", 0)
+    return
+  end
+  local amt, glowColor = DayNight.glow()
+  local h = ctx.screen[2]
+  local cell = math.max(1, ctx.cell or 1)
+  -- Sky sizes the disc in canvas pixels; out here the reflected body is
+  -- usually off the top of the frame, where a pixel is not a unit any more
+  -- -- so it is converted to the ANGLE it subtends through this camera's
+  -- own field of view, which is the same number wherever it is looked at.
+  local perRadian = h / math.max(1e-4, ctx.fov or 1)
+  local rpx = Sky.discRadius(h, cell,
+                             { moon = body.moon, glowAmt = amt })
+  local shades = Sky.discShades(body.moon)
+  local function shade(i)
+    local c = shades[i] or shades[#shades] or { 255, 255, 255 }
+    return { c[1] / 255, c[2] / 255, c[3] / 255 }
+  end
+  local twilight = (amt or 0) > 0.25 and not body.moon
+  send("bodyOn", 1)
+  send("bodyMoon", body.moon and 1 or 0)
+  send("bodyDir", { body.dx, body.dy, body.dz })
+  send("bodyAng", rpx / perRadian)
+  send("bodyCore", shade(1))
+  send("bodyMain", shade(twilight and 3 or 2))
+  send("bodyDark", shade(3))
+  send("glowAmt", (not body.moon) and (amt or 0) or 0)
+  send("glowColor", glowColor
+       and { glowColor[1] / 255, glowColor[2] / 255, glowColor[3] / 255 }
+       or { 1, 0.88, 0.66 })
+  send("glowReach", (ctx.screen[1] * Sky.GLOW_REACH) / perRadian)
+end
+
+-- Draw one water mesh with `model` applied. Mirrors Voxel3D.draw, minus the
+-- camera-ward pull (a flat sheet has nothing to lean over) and the separate
+-- sun transform (water is terrain: the sun saw the same matrix).
+function Water.draw(mesh, texture, model)
+  if not (active and mesh) then return end
+  if texture then mesh:setTexture(texture) end
+  pcall(active.send, active, "model", "row", model or Mat4.identity())
+  -- Per draw rather than per pass, and read off the TEXTURE rather than
+  -- assumed: it is what converts a world pixel of the columns' parallax into
+  -- the texel of art standing on it, and two maps in one frame can be drawn
+  -- from atlases of different sizes.
+  if texture and texture.getDimensions then
+    local ok, w, h = pcall(texture.getDimensions, texture)
+    if ok and w and h then
+      pcall(active.send, active, "atlasSize", { w, h })
+    end
+  end
+  love.graphics.draw(mesh)
+end
+
+function Water.finish()
+  active = nil
+end
+
+-- Drop the compiled shaders (window resize, hot reload): they are GPU
+-- objects on a context that may not exist any more.
+function Water.invalidate()
+  for k, sh in pairs(shaders) do
+    if sh and sh.release then pcall(sh.release, sh) end
+    shaders[k] = nil
+  end
+  active = nil
+end
+
+return Water
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index c6db4ff..c9f0d0e 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -1675,6 +1675,31 @@ T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12,
 VoxelState.angle, Voxel3D.camera = wasAngle, wasCam
 end
 
+-- ------- people do not shadow water
+--
+-- The sun pass is ONE map, so a surface cannot ask what threw a shadow
+-- unless the map says -- and it does, in the blue channel, which was zero
+-- anyway. Water is the only surface that asks: a character standing at a
+-- lake's edge laid a hard cut-out of its own sprite across a surface already
+-- showing the sky and the shoreline, which reads as a sticker rather than as
+-- a shadow. Everything the world casts still shades it.
+do
+local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
+T.check(type(ShadowMap.sprites) == "function",
+  "the sun pass can be told it is drawing the cast rather than the world")
+-- inert outside a pass, like every other toggle on it -- a caller that
+-- brackets a draw it never made must not send to a shader that is not bound
+T.check(pcall(ShadowMap.sprites, true) and pcall(ShadowMap.sprites, false),
+  "and saying so outside one is harmless")
+
+local shadowSrc = ShadowMap._source and ShadowMap._source() or nil
+if shadowSrc then
+  T.check(shadowSrc:find("fract(d), sprite", 1, true) ~= nil,
+    "the marker rides the channel the depth pack left free, so it costs "
+    .. "nothing: the map is still two channels of depth")
+end
+end
+
 -- ------- the compiled variants
 local plain = Water._source(false)
 local gridded = Water._source(true)
@@ -1704,14 +1729,19 @@ T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil,
 -- the march's reach grows as one over the ray's descent, so a grazing camera
 -- asks for hundreds of world pixels of it from a fixed number of samples --
 -- which stepped over whole crests and smeared the surface into streaks
+-- a sample is worth a SCREEN pixel of surface, so that is the stride: held
+-- at a world pixel up close (finer buys nothing and skipping costs the
+-- pepper) and opened out with distance (holding it there just runs the march
+-- out of samples part-way down the slab, which flattened the lowest rung's
+-- whole middle distance)
 T.check(plain:find("#define WAVE_STRIDE", 1, true) ~= nil
-        and plain:find("float maxSpan = float(WAVE_STEPS) * WAVE_STRIDE;",
-                       1, true) ~= nil,
-  "and its span is capped to a stride a sample can actually resolve")
+        and plain:find("max(WAVE_STRIDE, dist * pxAngle / dy)", 1, true) ~= nil,
+  "the relief stride is a screen pixel's worth of surface, floored at a "
+  .. "world pixel")
 T.check(Water.WAVE_STRIDE <= 1,
-  "which is at most ONE world pixel, because a column is one world pixel "
-  .. "wide -- a longer stride steps over columns, and which ones it misses "
-  .. "changes fragment to fragment, which is the peppery noise")
+  "and that floor is at most ONE world pixel, because a column is one world "
+  .. "pixel wide -- a longer one steps over columns, and which ones it "
+  .. "misses changes fragment to fragment, which is the peppery noise")
 -- and the art is read off the COLUMN rather than by offsetting the
 -- fragment's own uv by however far the march happened to travel: one world
 -- pixel is one texel, so a column's texel follows from where it stands and
diff --git a/tests/mon_shots.lua b/tests/mon_shots.lua
new file mode 100644
index 0000000..73cfefb
--- /dev/null
+++ b/tests/mon_shots.lua
@@ -0,0 +1,71 @@
+-- Driver: one overworld-battle screenshot per species, the mon fighting
+-- ITSELF -- its back pic on the player's mark and its front pic on the
+-- enemy's, so a single frame shows both sprites the 3D mode draws for it.
+--
+-- The point is a visual sweep for pic glitches (holes the paper-fill missed,
+-- a silhouette cut wrong, a pin that leaves the mon floating), so every shot
+-- is staged identically: same map, same cells, same beat -- the battle menu,
+-- both HUD panels up. Whatever differs between two shots is the mon.
+--
+--   SHOT_DIR=.scratchpad/mon_shots \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mon_shots.lua love .
+--
+-- Files land as NNN_species.png in dex order.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad/mon_shots"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+
+  -- every real species the merged data carries, walked in dex order
+  local species = {}
+  for id, def in pairs(game.data.pokemon) do
+    if type(id) == "string" and type(def) == "table"
+       and def.dex and def.dex >= 1 and def.dex <= 151 then
+      species[#species + 1] = { id = id, dex = def.dex }
+    end
+  end
+  table.sort(species, function(a, b) return a.dex < b.dex end)
+  U.log(("%d species"):format(#species))
+
+  game.save.player.name = "RED"
+
+  for _, s in ipairs(species) do
+    -- level 50 both sides: high enough that nothing about the staging is
+    -- species-specific, and a wild battle never awards exp off a menu shot
+    game.save.party = { Pokemon.new(game.data, s.id, 50) }
+
+    U.teleport(game, "ROUTE_1", 5, 8, "down")
+    -- let the neighbourhood's meshes land so the first battle frame is the
+    -- real arena rather than the flat fallback
+    U.wait(60)
+
+    local battle = BattleState.newWild(game, s.id, 50)
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+
+    -- the wipe, then tap through "Wild X appeared!" and the send-out until
+    -- the battle MENU is actually up -- a fixed tap count lands on whatever
+    -- beat the intro happened to be on, which is how a shot ends up with the
+    -- trainer still standing where the mon should be
+    U.wait(70)
+    for _ = 1, 200 do
+      if battle.phase == "menu" then break end
+      U.tap(game, "a")
+      U.wait(6)
+    end
+    if battle.phase ~= "menu" then
+      U.log(("STUCK before menu: %s (phase %s)"):format(s.id, tostring(battle.phase)))
+    end
+    -- let the send-out slide/ball beat finish so the mon is standing still
+    U.wait(40)
+    U.shot(game, ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower()))
+
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    U.wait(10)
+  end
+
+  U.log("done -- " .. DIR)
+end
diff --git a/tests/pic_dump.lua b/tests/pic_dump.lua
new file mode 100644
index 0000000..c6a9714
--- /dev/null
+++ b/tests/pic_dump.lua
@@ -0,0 +1,63 @@
+-- Driver: dump the exact pic textures a live 3D battle draws, per stage --
+-- the sprite as loaded (raw) and what picImage hands the billboard after the
+-- palette bake and BattlePics' paper fill (final). Diagnostic for pics that
+-- render with holes: whichever stage the transparency first appears in is
+-- the stage that made it.
+--
+--   SHOT_DIR=.scratchpad/pic_dump \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/pic_dump.lua love .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad/pic_dump"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+
+  local function save(img, path)
+    if not img then U.log("NIL image for " .. path) return end
+    local w, h = img:getDimensions()
+    local g = love.graphics
+    local prev = g.getCanvas()
+    local canvas = g.newCanvas(w, h, { dpiscale = 1 })
+    g.setCanvas(canvas)
+    g.clear(0, 0, 0, 0)
+    g.setBlendMode("replace", "premultiplied")
+    g.setColor(1, 1, 1, 1)
+    g.draw(img, 0, 0)
+    g.setCanvas(prev)
+    g.setBlendMode("alpha")
+    local f = assert(io.open(path, "wb"))
+    f:write(canvas:newImageData():encode("png"):getString())
+    f:close()
+  end
+
+  local SPECIES = os.getenv("PIC_SPECIES")
+  local list = {}
+  if SPECIES then
+    for id in SPECIES:gmatch("[^,%s]+") do list[#list + 1] = id:upper() end
+  else
+    list = { "PIKACHU", "SEEL", "BULBASAUR", "MEWTWO" }
+  end
+
+  for _, id in ipairs(list) do
+    game.save.party = { Pokemon.new(game.data, id, 50) }
+    U.teleport(game, "ROUTE_1", 5, 8, "down")
+    U.wait(30)
+    local battle = BattleState.newWild(game, id, 50)
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+    U.wait(80)
+    local lo = id:lower()
+    save(battle.enemy.sprite, ("%s/%s_front_raw.png"):format(DIR, lo))
+    save(battle:picImage(battle.enemy.sprite), ("%s/%s_front_final.png"):format(DIR, lo))
+    save(battle.player.sprite, ("%s/%s_back_raw.png"):format(DIR, lo))
+    save(battle:picImage(battle.player.sprite), ("%s/%s_back_final.png"):format(DIR, lo))
+    U.log("dumped " .. id)
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    U.wait(5)
+  end
+
+  U.log("done -- " .. DIR)
+  love.event.quit()
+end
diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua
new file mode 100644
index 0000000..f02040f
--- /dev/null
+++ b/tests/water_reflect_probe.lua
@@ -0,0 +1,127 @@
+-- Driver: WHY is the water not reflecting anything?
+--
+-- The reflective pass has several links and every one of them fails quietly
+-- back to flat water, which looks exactly like the row being off. This walks
+-- the chain in the LIVE game and prints where it stops.
+--
+--   POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/water_reflect_probe.lua lovec .
+--
+-- knobs (env):
+--   REFL_MAP    map id                       (default PALLET_TOWN)
+--   REFL_SPOT   "x,y[,facing]"               (default 5,6,down)
+--   REFL_LEVEL  voxel rung                   (default 5, the 75-degree camera,
+--                                             which is where a reflection is
+--                                             most of what you can see)
+--   REFL_TIME   daytime pin (day/dusk/...)   (default: leave as-is)
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local mapId = os.getenv("REFL_MAP") or "PALLET_TOWN"
+  local level = math.floor(tonumber(os.getenv("REFL_LEVEL")) or 5)
+  local sx, sy, facing = (os.getenv("REFL_SPOT") or "5,6,down")
+    :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
+  facing = (facing ~= "" and facing) or "down"
+
+  local function say(...) print("[water-ssr] " .. string.format(...)) end
+
+  U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
+  U.wait(20)
+  Pipelines.setLevel("voxel", level)
+  U.wait(40)                       -- outlast the camera tween and the build
+
+  local V = game.mods.exports["DRAMATIC_SHAPE"]
+  V = V and V.lib
+  if not V then return say("mod exports unreachable -- is it enabled?") end
+
+  local Water = V.require("Water")
+  local Voxel3D = V.require("Voxel3D")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Sky = V.require("Sky")
+  local DayNight = V.require("DayNight")
+
+  if os.getenv("REFL_TIME") then
+    DayNight.setting:sync(os.getenv("REFL_TIME"))
+    U.wait(5)
+  end
+
+  local ow = game.overworld
+  local map = ow and ow.map
+  if not map then return say("no live map") end
+
+  -- 1. is the row on at all?
+  say("row=%s level=%d", tostring(Water.setting:get()), Water.level())
+  if not Water.enabled() then
+    return say("STOP: WATER is OFF -- press 9, or set the row")
+  end
+
+  -- 2. is there any water on this map to reflect in?
+  local terrain, water = ChunkMesher.pair(map, false)
+  if not terrain then terrain, water = ChunkMesher.pair(map, true) end
+  say("terrain mesh=%s water mesh=%s", tostring(terrain ~= nil),
+      tostring(water ~= nil))
+  if not terrain then
+    return say("STOP: no terrain mesh yet -- the build is still cooking")
+  end
+  if not water then
+    say("STOP: this map has no water surface. That is not a fault unless")
+    say("      you can see a lake: check the tileset's water tiles reach")
+    say("      TileShape (run voxel_survey.lua for the shape breakdown).")
+    return
+  end
+
+  -- 3. did the driver give us a depth texture to read? This is the one
+  --    hardware requirement the rest of the mode does not already have.
+  say("depth canvas readable: %s", tostring(Voxel3D.depthReadable()))
+  if not Voxel3D.depthReadable() then
+    say("STOP: no readable depth canvas on this driver (depth24/readable).")
+    say("      The water falls back to the flat scene shader, which is")
+    say("      exactly what it looked like before this feature existed.")
+    return
+  end
+
+  -- 4. did the shader build? Both variants -- the wireframe one needs
+  --    derivatives, which a driver may refuse on its own.
+  say("shader plain=%s grid=%s",
+      tostring(Water.shader(false) ~= nil), tostring(Water.shader(true) ~= nil))
+  if not Water.shader(false) then
+    return say("STOP: the water shader did not compile -- the mod log has "
+               .. "the driver's own message")
+  end
+
+  -- 5. is there a sky to reflect, and something hanging in it?
+  local ramp, count = Sky.ramp()
+  say("sky ramp=%s bands=%s edge=%s", tostring(ramp ~= nil), tostring(count),
+      tostring(Voxel3D.skyEdge))
+  local body = DayNight.body()
+  if body then
+    local amt = DayNight.glow()
+    local w, h = Voxel3D.size()
+    local rpx = Sky.discRadius(h, Voxel3D.cell or 1,
+                               { moon = body.moon, glowAmt = amt })
+    local ang = rpx / ((h or 1) / math.max(1e-4, Voxel3D.fovY or 1))
+    say("body=%s dir=(%.2f, %.2f, %.2f) disc=%.1fpx (%.2f deg)",
+        body.moon and "moon" or "sun", body.dx, body.dy, body.dz, rpx,
+        math.deg(ang))
+  else
+    say("body: none in the sky right now (set REFL_TIME=day or =night)")
+  end
+  if not ramp then
+    say("NOTE: no band ramp -- indoors, or the ramp could not be built. The")
+    say("      sky half of the reflection is off; the ray march still runs.")
+  end
+
+  -- 6. how much reflection this camera is actually asking for. Both numbers
+  --    fall out of the rung, and between them they explain every "it only
+  --    works at 75" report: Fresnel decides how much shows, and the lean
+  --    decides whether what shows has anything in it.
+  local f = Water.FRESNEL_FLOOR + (Water.FRESNEL_CEIL - Water.FRESNEL_FLOOR)
+            * (1 - Voxel3D.descent) ^ Water.FRESNEL_POWER
+  say("camera: descent %.3f -> fresnel about %.2f, horizon lean %.2f",
+      Voxel3D.descent, f, Water.lean(Voxel3D.descent))
+  say("waves: %d px columns, phase %.2f", Water.WAVE_HEIGHT, Water._waveTime())
+
+  say("OK: every link is live. The effect is strongest at REFL_LEVEL=5 (the")
+  say("    75-degree rung, where Fresnel is highest and the reflection is")
+  say("    exact) and with a low sun (REFL_TIME=dusk).")
+end