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