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()