mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:11:11 +02:00
fixed pallette transitioning errors
This commit is contained in:
@@ -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 --
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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