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
+76
View File
@@ -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 --
+13 -2
View File
@@ -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
+117 -2
View File
@@ -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
+25 -3
View File
@@ -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)
+18 -2
View File
@@ -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)
+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()