fixed pallette transitioning errors

This commit is contained in:
DramaticShape
2026-07-26 17:22:56 -04:00
parent aa49898fd6
commit ccc0105f3f
6 changed files with 583 additions and 9 deletions
+334
View File
@@ -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()