DramaticShapeVoxelMod archival mirror · read-only
ccc0105f3fe5704b68940c72930d8bd13d8967f2

fixed pallette transitioning errors

DramaticShape · 2026-07-26 · parent aa49898fd6

fixed pallette transitioning errors

Changed files

CHANGELOG.md                  |  76 ++++++++++++++
 README.md                     |  15 ++-
 lib/TerrainAtlas.lua          | 119 +++++++++++++++++++++-
 lib/VoxelScene.lua            |  28 +++++-
 main.lua                      |  20 +++-
 tests/dramatic_shape_test.lua | 334 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 583 insertions(+), 9 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index afc8558..d72c32c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -105,6 +105,82 @@ long as the thing throwing them is tall.
 
 ### Fixed
 
+- Cycling palette modes with voxel mode on eventually killed the pipeline
+  outright: `attempt to call field 'atlasImageData' (a nil value) --
+  disabled for this session`. Nothing brought it back short of a restart.
+
+  `TerrainAtlas` reads three engine seams to animate water and flowers in
+  the terrain texture, and this build ships only one of them
+  (`defaultAnimatedTiles`). The tile clock, `animFrame`, was already read
+  guarded and simply degrades. `atlasImageData` was called straight -- but
+  only down the branch where the mod had NOT baked the atlas itself, which
+  is why it looked stable until a palette changed. Every mode with no world
+  palette for the map (`PaletteFX.pal` answering nil), plus RED++ and any
+  trueColor tileset, takes that branch, so the first map with animated
+  tiles entered under one of them threw out of `drawWorld` and the engine
+  disabled the pass for the session, exactly as it should.
+
+  The seam is now read guarded like its sibling, and when it is absent the
+  pixels are recovered rather than given up on. An atlas neither we nor
+  RED++ replaced is the tileset art itself, so animation carries on from
+  the art on disk. RED++'s per-map bake exists only as a texture --
+  `getGbcAtlas` throws its `ImageData` away -- so that one comes back off
+  the GPU: the atlas is drawn 1:1 into a canvas and read back, once per map,
+  with the pass's own render target captured and restored around it (the
+  usual `setCanvas()` would drop the rest of the frame). A driver that
+  refuses the readback declines to animate and keeps the static atlas.
+  Worst case now costs one animation, never the pipeline.
+
+- Water and flowers did not animate in voxel mode at all, and had not since
+  the mode shipped -- a silent one, since the terrain was otherwise correct.
+
+  The tile clock is the third seam, and this build does not export it
+  either. Being read guarded, it answered 0 forever instead of throwing,
+  which pinned every animated tile at step 0. `animFrame` is a plain local
+  in `TileRenderer`, but an upvalue of the exported `tick()`, so the mod now
+  reads the real counter through it. That it is the ENGINE's counter is the
+  point: the flat tile layer draws from the same number, so toggling voxel
+  mode mid-cycle continues the animation rather than restarting it. A build
+  that exports `animFrame()` outright is preferred; a build that hides the
+  local falls back to wall time in 60Hz steps, which free-runs against the
+  2D path but still moves the water.
+
+- Toggling palettes in voxel mode flashed the flat 2D world for a moment on
+  every switch.
+
+  `PaletteFX.setMode` reloads the live map to rebuild its atlas, and this
+  mod dropped that map's terrain mesh on any `map.reloaded` at all. Mesh
+  builds are asynchronous, so the frames between the drop and the first
+  rebuilt mesh had no terrain to draw -- and a voxel `drawWorld` with no
+  terrain returns nil, which is exactly how the pipeline asks for the 2D
+  fallback. The flash was the mod correctly reporting that it had nothing
+  to show.
+
+  The geometry was never stale: the mesher reads block layout and tile ids
+  and never reads colour, and the palette lives entirely in the texture
+  `TerrainAtlas` hands back per frame, keyed by palette and so already
+  rebuilt by the next frame. A reload whose reason is `colors` now keeps
+  the mesh, and the new palette lands on the diorama already on screen in
+  one frame. Every other reload -- warps re-entering a map, hot reload, a
+  replaced block -- still drops it.
+
+- Every non-colour palette mode rendered as SGB in voxel mode: GRAY and
+  both INVERTED modes came through as the map's blue.
+
+  `paletteFor` hands a pipeline the map's RAW SGB zone palette. The flat
+  path runs that through `PaletteFX.effectiveColors` on its way to the
+  shade-remap shader, and that call is where the non-colour modes actually
+  happen -- OG and OG INV swap in the DMG greys (reversed for the latter),
+  CLASSIC swaps in the green set, GBC INV permutes the zone's own shades,
+  and only GBC and RED++ pass through. This pass bakes colour into the
+  atlas and the sprite sheets ahead of the draw rather than shading at blit
+  time, so it never reached that call and painted the raw zone palette in
+  every mode.
+
+  Both bakes now run the same transform the shader would have. Terrain and
+  characters go through one resolve, so they cannot disagree about what
+  mode is on.
+
 - VOID FILL did nothing in voxel mode, in two separate ways.
 
   **BLACK crashed the build.** The mode is not a block at all --
diff --git a/README.md b/README.md
index fad3d8f..6210320 100644
--- a/README.md
+++ b/README.md
@@ -106,8 +106,19 @@ The mod reads a few engine internals (hence `"permissions":
 - `TileRenderer.animFrame()` / `.atlasImageData()` / `.defaultAnimatedTiles()`
   — the tile-animation clock, the pixels behind the atlas texture, and the
   vanilla water/flower spec. A static mesh cannot overdraw animated cells
-  the way the tile layer does, so it animates the texture instead; all
-  three are needed to do that on the engine's own clock and colours.
+  the way the tile layer does, so it animates the texture instead, and
+  these are what let it do that on the engine's own clock and colours.
+
+  Only `defaultAnimatedTiles` is required. The other two are OPTIONAL and
+  read guarded, because an engine build is free not to carry them — this
+  one does not carry either. Missing `animFrame` freezes the tile clock at
+  step 0 (terrain stops animating in voxel mode; everything else is
+  unaffected). Missing `atlasImageData` costs nothing where the mod baked
+  the atlas itself, falls back to the tileset art where the engine is
+  drawing that art unmodified, and skips animating a RED++ per-map bake,
+  whose pixels are unreachable without the seam. None of the three may ever
+  be called unguarded: a throw inside `drawWorld` costs the player the whole
+  pipeline for the session, not one frame of moving water.
 
 ## Tests
 
diff --git a/lib/TerrainAtlas.lua b/lib/TerrainAtlas.lua
index a658cad..c932d70 100644
--- a/lib/TerrainAtlas.lua
+++ b/lib/TerrainAtlas.lua
@@ -179,6 +179,121 @@ local function specsFor(tileset)
   return out
 end
 
+-- Pixels back off a texture the engine built on the GPU and kept no copy
+-- of. LOVE 11 hands out no ImageData for an Image, so the only route is a
+-- round trip: draw it 1:1 into a canvas and read that back.
+--
+-- This runs inside the world pass, with the pipeline's own canvas bound, so
+-- the previous target is captured and put back rather than unbound -- the
+-- usual setCanvas() would drop the rest of the frame on the floor. One
+-- readback per map, cached with the entry it feeds; the atlas is a couple
+-- of hundred pixels square, so the GPU sync costs far less than the mesh
+-- build it happens alongside. Every step is guarded: a driver that refuses
+-- canvas readback costs the animation and nothing else.
+local function readback(image)
+  if not (image and love.graphics and love.graphics.newCanvas
+          and love.graphics.getCanvas) then
+    return nil
+  end
+  local prev = love.graphics.getCanvas()
+  local ok, data = pcall(function()
+    local w, h = image:getDimensions()
+    local canvas = love.graphics.newCanvas(w, h)
+    love.graphics.setCanvas(canvas)
+    love.graphics.clear(0, 0, 0, 0)
+    -- straight copy: no blending against the cleared target, no tint from
+    -- whatever colour the pass left set, or the atlas comes back wrong
+    love.graphics.setBlendMode("replace", "premultiplied")
+    love.graphics.setColor(1, 1, 1, 1)
+    love.graphics.draw(image, 0, 0)
+    love.graphics.setBlendMode("alpha", "alphamultiply")
+    local out = canvas:newImageData()
+    if canvas.release then canvas:release() end
+    return out
+  end)
+  pcall(love.graphics.setCanvas, prev)
+  return ok and data or nil
+end
+
+-- The pixels behind the atlas texture the engine is drawing with, for the
+-- frames where we did not bake one ourselves (staticAtlas returns `false`
+-- for its own bake whenever the palette is absent, RED++ already baked, or
+-- the tileset is trueColor).
+--
+-- TileRenderer.atlasImageData is the engine's own accessor for exactly this
+-- and is preferred wherever the build offers it -- but like the sibling
+-- clock TileRenderer.animFrame it is an OPTIONAL seam, and a build without
+-- it has to cost us the animation, not the whole render pipeline. Reading
+-- it unguarded is what took the pass down for the session.
+--
+-- Without the seam the pixels are still recoverable, by two different
+-- routes. An atlas neither we nor RED++ replaced is the tileset art itself,
+-- so the art on disk IS what it was built from. RED++'s per-map bake exists
+-- only on the GPU -- getGbcAtlas throws its ImageData away once the texture
+-- is made -- so that one has to come back off the texture (readback below).
+local function rendererPixels(map)
+  local renderer = map.renderer
+  if not renderer then return nil end
+  if TileRenderer.atlasImageData then
+    local ok, data = pcall(TileRenderer.atlasImageData, renderer)
+    if ok and data then return data end
+  end
+  if renderer.gbcAtlas then return readback(renderer.image) end
+  local ok, data = pcall(Assets.imageData, map.tileset.image)
+  return ok and data or nil
+end
+
+-- The engine's tile-animation clock: TileRenderer's 60Hz counter, by
+-- whatever route this build offers.
+--
+-- It matters that this is the ENGINE's number and not one of our own. The
+-- 2D tile layer and this texture animate the same water off the same
+-- counter, so toggling voxel mode mid-cycle continues the animation instead
+-- of restarting or jumping it. A clock of our own would free-run against
+-- the one the flat path is drawing from.
+--
+--   1. TileRenderer.animFrame(), where the build exports it.
+--   2. else the counter itself, off tick()'s upvalues. It is a plain local
+--      in that module, so this is exact and live -- the same number, not an
+--      approximation of it. Reading engine internals is what this mod's
+--      "engine_internals" permission is declared for, and this one is
+--      read-only and entirely optional.
+--   3. else wall time in 60Hz steps. Free-running, but the water moves,
+--      which beats a frozen pond. Derived from absolute time rather than
+--      accumulated deltas because animate() is called once per map in the
+--      neighbourhood, so a per-call accumulator would run several times
+--      too fast.
+local clockUpvalue = nil        -- nil = not looked for yet, false = absent
+
+local function findClockUpvalue()
+  if not (debug and debug.getupvalue) then return false end
+  if type(TileRenderer.tick) ~= "function" then return false end
+  for i = 1, 32 do
+    local ok, name, value = pcall(debug.getupvalue, TileRenderer.tick, i)
+    if not (ok and name) then break end
+    if name == "animFrame" and type(value) == "number" then return i end
+  end
+  return false
+end
+
+local function animFrame()
+  if TileRenderer.animFrame then
+    local ok, f = pcall(TileRenderer.animFrame)
+    if ok and type(f) == "number" then return f end
+  end
+  if clockUpvalue == nil then clockUpvalue = findClockUpvalue() end
+  if clockUpvalue then
+    local ok, _, value = pcall(debug.getupvalue, TileRenderer.tick, clockUpvalue)
+    if ok and type(value) == "number" then return value end
+  end
+  if love.timer and love.timer.getTime then
+    return math.floor(love.timer.getTime() * 60)
+  end
+  return 0
+end
+
+TerrainAtlas._animFrame = animFrame   -- named for the suite
+
 local function newEntry(map, base, baked)
   local tileset = map.tileset
   local specs = specsFor(tileset)
@@ -189,7 +304,7 @@ local function newEntry(map, base, baked)
   end
   -- the pixels the atlas texture was built from: our own SGB bake when we
   -- made one, else whatever the engine's renderer is drawing with
-  local src = baked or TileRenderer.atlasImageData(map.renderer)
+  local src = baked or rendererPixels(map)
   if not src then return false end
 
   local ok, entry = pcall(function()
@@ -241,7 +356,7 @@ function TerrainAtlas.animate(map, colors, base, baked)
   end
   if not entry then return nil end
 
-  local frame = TileRenderer.animFrame and TileRenderer.animFrame() or 0
+  local frame = animFrame()
   -- one number for the whole entry: every spec's own step, folded together,
   -- so a repatch happens when ANY of them turns over
   local step = 0
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index dc5cd0a..6fb4351 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -20,9 +20,32 @@ local VoxelModels = V.require("VoxelModels")
 local SpriteBillboards = V.require("SpriteBillboards")
 local TileShape = V.require("TileShape")
 local TerrainAtlas = V.require("TerrainAtlas")
+local PaletteFX = require("src.render.PaletteFX")
 
 local VoxelScene = {}
 
+-- What the active display mode actually paints with.
+--
+-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
+-- any of the non-colour modes draw. The flat path runs it through
+-- PaletteFX.effectiveColors on the way to the shade-remap shader, and that
+-- call IS where GRAY, INVERTED and CLASSIC happen -- OG / OG INV replace
+-- the palette with the DMG greys (inverted for the latter), CLASSIC
+-- replaces it with the green DMG set, and GBC INV permutes the zone's own
+-- shades. GBC and RED++ pass through untouched.
+--
+-- This pass has no shader to apply that in: colour is baked into the atlas
+-- and into the sprite sheets ahead of the draw, so it has to run the same
+-- transform itself. Without it every mode that is not already a colour mode
+-- comes through wearing the SGB palette -- grey and inverted both rendering
+-- as plain SGB blue.
+local function modeColors(paletteFor, map)
+  local c = paletteFor and paletteFor(map) or nil
+  return PaletteFX.effectiveColors(c)
+end
+
+VoxelScene._modeColors = modeColors   -- named for the suite
+
 -- A facing is a yaw about +Y. Models are carved facing +Z (south, "down"),
 -- and Mat4.rotateY(+90 deg) maps +Z to +X, so east is +90 and west is -90.
 -- Nothing is mirrored: yawing to face east shows the model's east side,
@@ -367,15 +390,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
   local cx, cy = cam.x + vw / 2, cam.y + vh / 2
 
   local function atlasFor(map)
-    return TerrainAtlas.forMap(map, paletteFor and paletteFor(map) or nil)
+    return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
   end
 
   -- sprite palettes only exist in the SGB modes; under RED++ the OBP bake
   -- inside sprite:resolveImage() already colors the sheet
-  local PaletteFX = require("src.render.PaletteFX")
   local function spriteColors(map)
     if PaletteFX.usesGbcPack() then return nil end
-    return paletteFor and paletteFor(map) or nil
+    return modeColors(paletteFor, map)
   end
 
   local posed = posesOf(state, spriteColors)
diff --git a/main.lua b/main.lua
index fca7d13..a78783e 100644
--- a/main.lua
+++ b/main.lua
@@ -235,9 +235,25 @@ mod.events:on("world.block_replaced", function(payload)
   if mapId then ChunkMesher.invalidate(mapId) end
 end)
 
--- A reloaded map is rebuilt from scratch (palette pack switches, warps
--- that re-enter the same map), so its mesh is stale for the same reason.
+-- A reloaded map is rebuilt from scratch (warps that re-enter the same map,
+-- hot reload), so its mesh is stale for the same reason -- with one
+-- exception, and it is the common one.
+--
+-- A palette switch reloads the map ONLY to rebuild its atlas
+-- (PaletteFX.setMode -> reloadMap(id, "colors")). The geometry that comes
+-- back is identical: this mesher reads block layout and tile ids and never
+-- reads colour, and the palette lives entirely in the texture TerrainAtlas
+-- hands back per frame -- which is keyed BY palette, so the new colours are
+-- already built by the time the next frame draws.
+--
+-- Dropping the mesh anyway cost a visible flash of the flat 2D world on
+-- every palette toggle. Mesh builds are asynchronous, so the frames between
+-- the drop and the first finished mesh have no terrain to draw, and
+-- drawWorld returning nil IS the 2D fallback. Keeping the geometry lets the
+-- new colours land on the diorama already on screen, in one frame, which is
+-- what a palette toggle should look like from inside voxel mode.
 mod.events:on("map.reloaded", function(payload)
+  if payload and payload.reason == "colors" then return end
   local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
   if mapId then ChunkMesher.invalidate(mapId) end
 end)
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index e26b46a..12d655b 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -167,6 +167,340 @@ curve.step(settingGame, 1)
 curve.step(settingGame, 1)
 T.eq(curve.value(), "OFF", "the curve is left off for the rows below")
 
+-- ------- the animated terrain atlas survives an engine without its seams
+--
+-- Regression: cycling palette modes with voxel mode on eventually killed
+-- the pass outright --
+--
+--   render pipeline voxel failed: lib/TerrainAtlas.lua:192: attempt to
+--   call field 'atlasImageData' (a nil value) -- disabled for this session
+--
+-- TerrainAtlas reads three OPTIONAL engine seams (README, "engine
+-- internals"); this build ships only defaultAnimatedTiles, so animFrame and
+-- atlasImageData are both absent. animFrame was already read guarded and
+-- degrades to a frozen clock. atlasImageData was called straight, and only
+-- on the branch where staticAtlas did NOT bake its own pixels -- which is
+-- exactly what a palette change flips. Every mode whose world palette is
+-- absent (pal() -> nil), plus RED++ (whose per-map bake sets gbcAtlas) and
+-- any trueColor tileset, hands `baked = false` down to newEntry. So the
+-- first map with animated water or flowers entered under one of those modes
+-- took the whole pipeline down for the session.
+--
+-- The harness has no love.image at all, which is why the checks above never
+-- reached this branch. Stand up just enough of one to walk it.
+
+local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas")
+local TileRenderer = require("src.render.TileRenderer")
+
+local realImage, realNewImage = love.image, love.graphics.newImage
+
+local function fakePixels(w, h)
+  local d = { w = w or 128, h = h or 48 }
+  function d:getDimensions() return self.w, self.h end
+  function d:getPixel() return 0.5, 0.5, 0.5, 1 end
+  function d:setPixel() end
+  function d:paste() end
+  return d
+end
+
+love.image = { newImageData = function(a, b)
+  if type(a) == "number" then return fakePixels(a, b) end
+  return fakePixels()          -- the "decoded from a path" overload
+end }
+-- animate() only uploads when the animation step actually turns over, so
+-- counting replacePixels is how the suite sees the step move from outside
+local patches = 0
+love.graphics.newImage = function()
+  return { setFilter = function() end,
+           replacePixels = function() patches = patches + 1 end }
+end
+
+-- a tileset whose water tile rotates, i.e. one specsFor will accept
+local function animatedMap(id, renderer)
+  return {
+    id = id,
+    tileset = {
+      -- a label, never opened: love.image is stubbed above
+      image = "assets/tilesets/overworld.png",
+      tilesPerRow = 16,
+      animatedTiles = { { tile = 0x14, kind = "hshift",
+                          offsets = { 0, 1, 2, 3 }, period = 20 } },
+    },
+    renderer = renderer,
+  }
+end
+
+-- stands in for the atlas texture: what the engine hands over as
+-- renderer.image, and what animate() patches through replacePixels
+local base = { replacePixels = function() end,
+               getDimensions = function() return 128, 48 end }
+
+T.eq(TileRenderer.atlasImageData, nil,
+  "this engine build does not carry the atlasImageData seam (the premise)")
+
+-- 1. the crash itself: no bake of our own, and no engine seam to ask
+TerrainAtlas.invalidate()
+local plain = animatedMap("PLAIN", { image = base })
+local ok, err = pcall(TerrainAtlas.animate, plain, nil, base, false)
+T.check(ok, "an unbaked atlas does not take the pipeline down: " .. tostring(err))
+
+-- 2. and it is a real recovery, not a shrug: the pixels behind an atlas the
+--    engine never replaced are the tileset art, so the animation still runs
+TerrainAtlas.invalidate()
+local okArt, artImg = pcall(TerrainAtlas.animate, plain, nil, base, false)
+T.check(okArt and artImg ~= nil,
+  "unbaked terrain still animates, from the tileset art the atlas was built from")
+
+-- 3. RED++ bakes per map and keeps no ImageData, so those pixels come back
+--    off the texture. Where the driver will not read a canvas back -- which
+--    is this harness, whose stub canvas has no newImageData -- the fallback
+--    is to decline rather than patch grey art into a coloured atlas.
+TerrainAtlas.invalidate()
+local gbc = animatedMap("GBC", { image = base, gbcAtlas = true })
+local okGbc, gbcImg = pcall(TerrainAtlas.animate, gbc, nil, base, false)
+T.check(okGbc, "a RED++ atlas does not take the pipeline down either")
+T.eq(gbcImg, nil, "and declines rather than patching raw art into a baked atlas")
+
+-- 3b. give the harness a canvas it CAN read back and the same map animates,
+--     with the pass's own render target put back afterwards -- this runs
+--     mid-frame, so unbinding instead of restoring would cost the frame.
+TerrainAtlas.invalidate()
+local passCanvas = { name = "the pipeline's own target" }
+love.graphics.setCanvas(passCanvas)
+local realNewCanvas = love.graphics.newCanvas
+love.graphics.newCanvas = function(w, h)
+  return { w = w, h = h, setFilter = function() end,
+           release = function() end,
+           newImageData = function() return fakePixels(w, h) end }
+end
+local okRead, readImg = pcall(TerrainAtlas.animate, gbc, nil, base, false)
+T.check(okRead and readImg ~= nil,
+  "a RED++ atlas animates from a texture readback when the driver allows it")
+T.eq(love.graphics.getCanvas(), passCanvas,
+  "and the readback puts the pass's render target back")
+love.graphics.newCanvas = realNewCanvas
+love.graphics.setCanvas()
+
+-- 4. the reported path end to end: cycle every palette mode over a map with
+--    animated tiles. PaletteFX.pal returns nil for a mode with no world
+--    palette, which is the `colors = nil` that flips staticAtlas to no-bake.
+local PaletteFX = require("src.render.PaletteFX")
+local sgb = { { 1, 1, 1 }, { 0.6, 0.6, 0.6 }, { 0.3, 0.3, 0.3 }, { 0, 0, 0 } }
+for _, mode in ipairs(PaletteFX.MODES) do
+  for _, colors in ipairs({ sgb, false }) do   -- false stands in for nil
+    TerrainAtlas.invalidate()
+    local okMode = pcall(TerrainAtlas.forMap, animatedMap("M_" .. mode, { image = base }),
+                         colors or nil)
+    T.check(okMode, "palette mode " .. mode .. " survives a terrain atlas build"
+                    .. (colors and " (with a world palette)" or " (with none)"))
+  end
+end
+
+-- 5. forward compatible: a build that DOES carry the seam is preferred over
+--    reading the art back off disk, and one that throws is still survivable
+TerrainAtlas.invalidate()
+local asked = false
+TileRenderer.atlasImageData = function() asked = true; return fakePixels() end
+local okSeam, seamImg = pcall(TerrainAtlas.animate, plain, nil, base, false)
+T.check(okSeam and seamImg ~= nil,
+  "an engine that provides the seam still animates")
+T.check(asked, "and the engine's own accessor is what was asked")
+
+TerrainAtlas.invalidate()
+TileRenderer.atlasImageData = function() error("seam is angry") end
+local okThrow = pcall(TerrainAtlas.animate, plain, nil, base, false)
+T.check(okThrow, "a seam that throws costs the animation, not the pipeline")
+
+TileRenderer.atlasImageData = nil
+
+-- ------- the tile clock, the other half of the same seam problem
+--
+-- animFrame is the engine's 60Hz tile-animation counter and this build does
+-- not export it either. It was read guarded, so it never crashed -- it just
+-- answered 0 forever, which pinned every animated tile at step 0: water and
+-- flowers stood still in voxel mode and nowhere else. It is a plain local in
+-- TileRenderer, but an upvalue of the exported tick(), so the mod reads the
+-- real counter rather than inventing one. That distinction is the point: the
+-- flat tile layer draws from this same number, so a mode switch mid-cycle
+-- continues the animation instead of restarting it.
+
+local clock = TerrainAtlas._animFrame
+T.check(type(clock) == "function", "the atlas exposes its clock for the suite")
+
+T.eq(TileRenderer.animFrame, nil,
+  "this engine build does not carry the animFrame seam either (the premise)")
+
+local before = clock()
+for _ = 1, 7 do TileRenderer.tick(nil) end
+T.eq(clock() - before, 7, "the clock follows the engine's tick, rather than sitting at 0")
+TileRenderer.tick(1 / 60)
+T.eq(clock() - before, 8, "and a 60Hz frame of wall time advances it exactly one step")
+
+-- End to end, and observed from OUTSIDE the clock: animate() re-uploads the
+-- atlas only when the step turns over, so walking a full cycle has to
+-- produce one upload per step. This is what a frozen clock silently
+-- prevented -- it uploads once and then agrees with itself forever, which
+-- is why reading the counter back here would prove nothing.
+local spec = plain.tileset.animatedTiles[1]
+TerrainAtlas.invalidate()
+patches = 0
+TerrainAtlas.animate(plain, nil, base, false)   -- builds, uploads step 0
+local built = patches
+for _ = 1, #spec.offsets do
+  for _ = 1, spec.period do TileRenderer.tick(nil) end
+  TerrainAtlas.animate(plain, nil, base, false)
+end
+T.eq(patches - built, #spec.offsets,
+  "walking a full cycle re-patches the atlas once per step, rather than freezing at step 0")
+
+-- repeat calls inside one step must NOT re-upload: animate() runs once per
+-- map in the neighbourhood every frame, and repatching ~130 pixels each
+-- time is the cost the step check exists to avoid
+local settled = patches
+for _ = 1, 5 do TerrainAtlas.animate(plain, nil, base, false) end
+T.eq(patches, settled, "and holds still between steps rather than repatching every call")
+
+-- and the same two guarantees the pixel seam gets: prefer the real thing,
+-- survive a broken one
+TileRenderer.animFrame = function() return 4242 end
+T.eq(clock(), 4242, "an engine that exports the clock is preferred over the upvalue")
+TileRenderer.animFrame = function() error("clock is angry") end
+local okClock, clockVal = pcall(clock)
+T.check(okClock and type(clockVal) == "number",
+  "a clock that throws falls back to a working one rather than propagating")
+TileRenderer.animFrame = nil
+
+TerrainAtlas.invalidate()
+love.image, love.graphics.newImage = realImage, realNewImage
+
+-- ------- a palette switch must not drop the geometry
+--
+-- Regression: toggling palettes in voxel mode flashed the flat 2D world for
+-- a moment on every switch.
+--
+-- PaletteFX.setMode reloads the live map to rebuild its atlas, passing
+-- reason "colors", and this mod dropped the map's terrain mesh on any
+-- map.reloaded at all. Mesh builds are asynchronous, so the frames between
+-- the drop and the first rebuilt mesh have no terrain -- and a voxel
+-- drawWorld with no terrain returns nil, which IS the engine's 2D
+-- fallback. The geometry was never stale to begin with: the mesher reads
+-- block layout and tile ids, and the palette lives in the texture.
+--
+-- Every OTHER reload still has to drop it, so this pins the distinction
+-- rather than just the fix.
+
+local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher")
+local Runtime = require("src.mods.Runtime")
+
+local realInvalidate = ChunkMesher.invalidate
+local dropped = {}
+ChunkMesher.invalidate = function(id) dropped[#dropped + 1] = id or "<every map>" end
+
+Runtime.emit("map.reloaded", { mapId = "PALLET_TOWN", reason = "colors" })
+T.eq(#dropped, 0,
+  "a palette switch keeps the terrain mesh, so the diorama stays on screen")
+
+Runtime.emit("map.reloaded", { mapId = "PALLET_TOWN", reason = "invalidate" })
+T.eq(#dropped, 1, "a reload for any other reason still drops the stale mesh")
+T.eq(dropped[1], "PALLET_TOWN", "and drops exactly the map that reloaded")
+
+-- the reason field is the engine's, not ours: a payload without one is a
+-- real reload and must still invalidate
+Runtime.emit("map.reloaded", { mapId = "VIRIDIAN_CITY" })
+T.eq(#dropped, 2, "a reload with no stated reason is treated as a real one")
+
+-- and the edits that genuinely change geometry are untouched by any of this
+Runtime.emit("world.block_replaced", { mapId = "PALLET_TOWN" })
+T.eq(#dropped, 3, "a replaced block still drops the mesh it changed")
+
+ChunkMesher.invalidate = realInvalidate
+
+-- ------- the non-colour palette modes must not come through as SGB
+--
+-- Regression: GRAY, INVERTED and CLASSIC all rendered as the SGB palette in
+-- voxel mode -- grey and inverted came out blue.
+--
+-- The engine's paletteFor hands back a map's RAW SGB zone palette. The flat
+-- path then runs it through PaletteFX.effectiveColors on the way to the
+-- shade-remap shader, and THAT is where the non-colour modes happen: OG and
+-- OG INV swap in the DMG greys, CLASSIC swaps in the green set, GBC INV
+-- permutes the zone's own shades. This pass bakes colour into the atlas
+-- ahead of the draw instead of shading at blit time, so it never reached
+-- that call and every mode drew the raw zone palette.
+--
+-- Asserted against what each mode should PAINT, not against the engine
+-- function, so this stays a claim about the picture rather than a
+-- restatement of the implementation.
+
+local VoxelScene = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelScene")
+local modeColors = VoxelScene._modeColors
+T.check(type(modeColors) == "function", "the scene exposes its palette resolve")
+
+-- a recognisable stand-in for a map's SGB zone palette: strongly blue, so
+-- "came through as SGB" is visible in the values themselves
+local sgbBlue = { { 248, 248, 248 }, { 96, 152, 232 },
+                  { 40, 80, 176 }, { 8, 24, 64 } }
+local function paletteForBlue() return sgbBlue end
+local function under(mode, fn)
+  local prev = PaletteFX.mode
+  PaletteFX.mode = mode
+  local ok, err = pcall(fn)
+  PaletteFX.mode = prev
+  if not ok then error(err, 0) end
+end
+
+local function sameColors(a, b)
+  if not (a and b) then return false end
+  for i = 1, 4 do
+    for ch = 1, 3 do
+      if a[i][ch] ~= b[i][ch] then return false end
+    end
+  end
+  return true
+end
+
+under("gbc", function()
+  T.check(sameColors(modeColors(paletteForBlue), sgbBlue),
+    "GBC is a colour mode, so the zone palette passes through untouched")
+end)
+
+under("og", function()
+  local c = modeColors(paletteForBlue)
+  T.check(sameColors(c, PaletteFX.GRAYS),
+    "GRAY paints the DMG greys, not the map's SGB blue")
+  T.check(not sameColors(c, sgbBlue), "and is not the SGB palette in disguise")
+end)
+
+under("og_inv", function()
+  local c = modeColors(paletteForBlue)
+  T.check(sameColors(c, PaletteFX.permute(PaletteFX.GRAYS,
+                                          { [0] = 3, [1] = 2, [2] = 1, [3] = 0 })),
+    "GRAY INV paints the greys with the shade ramp reversed")
+  T.eq(c[1][1], PaletteFX.GRAYS[4][1],
+    "so the lightest shade becomes the darkest -- an actual inversion")
+end)
+
+under("classic", function()
+  T.check(sameColors(modeColors(paletteForBlue), PaletteFX.CLASSIC),
+    "CLASSIC paints the green DMG set")
+end)
+
+under("gbc_inv", function()
+  local c = modeColors(paletteForBlue)
+  T.check(not sameColors(c, sgbBlue), "GBC INV does not pass the zone palette through")
+  for i = 1, 4 do
+    T.check(sameColors({ c[i], c[i], c[i], c[i] },
+                       { sgbBlue[5 - i], sgbBlue[5 - i], sgbBlue[5 - i], sgbBlue[5 - i] }),
+      "GBC INV reverses the zone's own shades, keeping its colours (shade " .. i .. ")")
+  end
+end)
+
+-- a map with no palette at all stays uncoloured rather than inventing one,
+-- which is what the flat path does when it has nothing to send the shader
+T.eq(modeColors(function() return nil end), nil,
+  "a map with no world palette bakes no colour, as on the flat path")
+T.eq(modeColors(nil), nil, "and a pipeline given no paletteFor at all is safe")
+
 Pipelines.reset()
 run.release()