mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:30:49 +02:00
fixed pallette transitioning errors
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user