Files
gen1recomp/tests/engine/atlas_prefetch_test.lua
T
Shane McGovern 7e2307f58c Use spare CPU cores to remove in-game hitches on low-power devices (#2872)
* Use spare CPU cores to remove in-game hitches on low-power devices

Per-frame game logic and drawing are cheap; the remaining stalls on
low-power handhelds are one-off main-thread hitches from chip SFX/cry
synthesis, RED++ atlas recolouring, image decodes and the require shim.
Move those onto background threads, each with a synchronous fallback so
behaviour is identical when threads are unavailable (POKEPORT_NO_THREAD=1,
no love.thread, worker failure).

Also adds an F3 frame-time overlay and POKEPORT_GAME_PROF headless
profiling to measure these costs.

* Address code review: shared WorkerLane, F3 gating, decode/epoch fixes

- Extract src/core/WorkerLane.lua (worker lifecycle, prioritised lanes,
  per-lane epochs, bounded LRU) and rebuild the SFX and atlas/decode
  prefetchers on it
- Keep decoded sprite sheets in every colour mode (the OBJ palette bake
  re-reads them), skip oversized decodes, and let palette changes drop
  only atlas bakes
- Only bump the SFX render epoch when the channel mix actually changes
- F3 overlay toggle no longer steals the key from rebinding screens or
  player bindings; profilers stop only when a quit really proceeds
- Require fast path: test hook instead of a hot-path counter
- Shared tests/drivers/perf_util.lua; drivers restore their patches

* WorkerLane: no budget leak on duplicate sends, keep priority on requeue

- Lane:pump only counts a job against the in-flight budget once per key
- requeueInflight puts entries back at the head of their own queue, so
  background hints no longer overtake front requests after a bump
- shutdown() keeps a fatally failed worker off
- Hiding the F3 overlay logs one summary line instead of a full block

* F3 overlay: last-120-frame stats, font-sized box, scale on large screens

* Prefetch: skip useless hints, no main-thread fallback, mix check vs worker config

- Don't queue decodes for sprite sheets whose Image and OBJ bake are cached
- Atlas hint is dropped (sync bake at build time) when the worker can't
  decode the tileset itself, instead of decoding on the main thread
- SFX epoch bump compares the live mix with what the worker was last
  configured with, replacing the load-time snapshot

* Sprite decode skip: raw-path cache check, never under RED++

* Gen 2 battles prefetch both parties' cries; sprite skip keyed by raw path

* Prefetch: skip cached tilesets outside RED++, log hint failures once

* Profiler: bench survives F3 hide, frame total excludes vsync; safer SFX pump, ordered requeue, tileset skip respects pending variants

* Profiler counts GPU stalls in present; water void-fill keeps tileset hint; README + test coverage

* Profiler vsync cap: measured cadence via RefreshRate, no per-frame allocation

* Use upstream's chip-worker prewarm for SFX/cries; drop our separate SFX worker

Our extra coverage (Gen 1 common SFX + party cries, both parties' battle
cries, lead move sounds, hit and battle SFX, Gen 2 bench cries) now goes
through ChipAudio.prewarmSfx/prewarmCry and a new Sound.prewarmMove.

* Skip decode hints for composed assets; share move cache key; drop dead demoteAll

* Drop dev-only profiling extras: POKEPORT_JITP hook and perf drivers

* README: name the worker threads POKEPORT_JIT leaves alone

* Profiler: count the pre-present vblank wait as present, log on NX quit

- time PresentSync.waitBeforePresent as "present" (the X11 GLX vblank wait
  lands there), so it is excluded like the wait inside present
- NX quit now emits the PROF summary / overlay line too
- love.quit's scripted check reads FrameProfiler.benchRequested, so
  POKEPORT_GAME_PROF=0 no longer counts as a bench run
- prefetchAtlas skips composed tileset sheets (Assets.isComposed)
- hit sounds defined once in EffectRegistry.HIT_SOUNDS, shared with the
  battle prewarm (each hit still gets its own copy)
- drop redundant obpPaths, dead FrameProfiler.visible and the unused
  Lane:bump requeue mode; restore applyOptions' doc comment
2026-10-12 08:38:45 +01:00

592 lines
22 KiB
Lua

-- RED++ atlas background bake (AtlasBake, AtlasPrefetch, atlas_worker,
-- TileRenderer.getGbcAtlas / prefetchAtlas, OverworldState.prefetchAtlases).
-- luajit tests/engine/atlas_prefetch_test.lua
-- The worker is driven in-process: the test pops the commands AtlasPrefetch
-- pushed and runs the real Worker.handle + AtlasBake over them.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
-- ---- fake ImageData / Image ------------------------------------------------
local ID = {}
ID.__index = ID
local function newID(w, h)
return setmetatable({ w = w, h = h, px = {} }, ID)
end
function ID:getDimensions() return self.w, self.h end
function ID:getPixel(x, y)
local p = self.px[y * self.w + x]
if not p then return 0, 0, 0, 0 end
return p[1], p[2], p[3], p[4]
end
function ID:setPixel(x, y, r, g, b, a) self.px[y * self.w + x] = { r, g, b, a } end
local function sameImage(a, b)
if a.w ~= b.w or a.h ~= b.h then return false end
for y = 0, a.h - 1 do
for x = 0, a.w - 1 do
local r1, g1, b1, a1 = a:getPixel(x, y)
local r2, g2, b2, a2 = b:getPixel(x, y)
if r1 ~= r2 or g1 ~= g2 or b1 ~= b2 or a1 ~= a2 then return false end
end
end
return true
end
-- deterministic 4-shade source with some transparent texels
local function makeSource(w, h)
local s = newID(w, h)
local shades = { 1, 0.67, 0.33, 0 }
for y = 0, h - 1 do
for x = 0, w - 1 do
local v = shades[(x * 3 + y * 5 + math.floor(x / 8)) % 4 + 1]
s:setPixel(x, y, v, v, v, ((x + y) % 7 == 0) and 0 or 1)
end
end
return s
end
love.image = love.image or {}
local sources = {} -- path -> ImageData (what the worker's file decode returns)
local function stubNewImageData(w, h)
if type(w) == "string" then return sources[w] end
return newID(w, h)
end
love.image.newImageData = stubNewImageData
local imagesBuilt = 0
love.graphics = love.graphics or {}
love.graphics.newImage = function(data)
imagesBuilt = imagesBuilt + 1
return { isImage = true, data = data }
end
local Assets = require("src.render.Assets")
local decodes = 0
Assets.imageData = function(path)
decodes = decodes + 1
return sources[path]
end
local AtlasBake = require("src.render.AtlasBake")
-- ---- 1. AtlasBake == the old inline loop -----------------------------------
local function oldLoop(src, out, perRow, groupColors, tilesetId, mapId, aliasesDef, groupAt)
local recolorSample = AtlasBake.recolorSample
local iw, ih = src:getDimensions()
local total = (iw / 8) * (ih / 8)
local tileColors = {}
for t = 0, total - 1 do
local colors = tileColors[t]
if colors == nil then
local group = groupAt(tilesetId, mapId, t)
colors = (group and groupColors[group + 1]) or false
tileColors[t] = colors
end
local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local sx, sy = ox + px, oy + py
local r, g, b, a = src:getPixel(sx, sy)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(sx, sy, r, g, b, a)
end
end
end
for _, al in ipairs(aliasesDef) do
if al.alias < total then
local colors = groupColors[al.group + 1]
local sxo = (al.tile % perRow) * 8
local syo = math.floor(al.tile / perRow) * 8
local dxo = (al.alias % perRow) * 8
local dyo = math.floor(al.alias / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = src:getPixel(sxo + px, syo + py)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(dxo + px, dyo + py, r, g, b, a)
end
end
end
end
end
local function pal(n)
return { { 255, 255, n }, { 200, 100, n }, { 100, 50, n }, { 0, 0, n } }
end
do
local perRow = 4
local src = makeSource(32, 24) -- 4 x 3 = 12 tiles
local groupColors = { pal(1), pal(2), pal(3), pal(4) }
-- tile 5 and 9 use no group (copy through), 11 is a wild group index
local function groupAt(_, _, t)
if t == 5 or t == 9 then return nil end
return t % 4
end
local aliasesDef = {
{ tile = 1, alias = 10, group = 3 },
{ tile = 2, alias = 99, group = 1 }, -- outside the atlas: skipped
}
local old = newID(32, 24)
oldLoop(src, old, perRow, groupColors, "TS", "M", aliasesDef, groupAt)
-- the main-thread precompute (what TileRenderer hands the bake)
local tileColors = {}
for t = 0, 11 do
local g = groupAt("TS", "M", t)
tileColors[t] = (g and groupColors[g + 1]) or false
end
local aliases = {}
for _, al in ipairs(aliasesDef) do
if al.alias < 12 then
aliases[#aliases + 1] = { tile = al.tile, alias = al.alias,
colors = groupColors[al.group + 1] }
end
end
local new = newID(32, 24)
AtlasBake.bake(src, new, perRow, tileColors, aliases)
check(sameImage(old, new), "AtlasBake is bit-identical to the old inline loop")
check(tileColors[5] == false, "no-group tiles carry false")
-- tile 5 copied through untouched
local r, g, b, a = new:getPixel(8, 8)
local r0, g0, b0, a0 = src:getPixel(8, 8)
check(r == r0 and g == g0 and b == b0 and a == a0, "false-colors tile is a straight copy")
end
-- ---- channel / thread stubs ---------------------------------------------------
local channels = {}
local Channel = {}
Channel.__index = Channel
function Channel:push(msg) self.queue[#self.queue + 1] = msg end
function Channel:pop() return table.remove(self.queue, 1) end
function Channel:clear() self.queue = {} end
function Channel:getCount() return #self.queue end
local function channel(name)
channels[name] = channels[name] or setmetatable({ queue = {} }, Channel)
return channels[name]
end
local threadsStarted, threadError = 0, nil
local function installThread()
love.thread = {
newThread = function()
return { start = function() threadsStarted = threadsStarted + 1 end,
getError = function() return threadError end,
wait = function() end }
end,
getChannel = channel,
}
end
installThread()
local Worker = require("src.render.atlas_worker")
local baked = {} -- key -> ImageData the worker produced
local bakeOrder = {}
local function runWorker()
local cmdCh, outCh = channel("atlas_cmd"), channel("atlas_out")
while true do
local cmd = cmdCh:pop()
if not cmd then break end
if cmd.cmd == "bake" then bakeOrder[#bakeOrder + 1] = cmd.key end
Worker.handle(cmd, AtlasBake, outCh)
end
for _, r in ipairs(outCh.queue) do
if r.data then baked[r.key] = r.data end
end
end
-- ---- engine under test ---------------------------------------------------------
local PaletteFX = require("src.render.PaletteFX")
local TileRenderer = require("src.render.TileRenderer")
local AtlasPrefetch = require("src.render.AtlasPrefetch")
local Game = require("src.core.Game")
local OW = require("src.world.OverworldController")
check(TileRenderer.recolorSample == AtlasBake.recolorSample,
"TileRenderer.recolorSample is the AtlasBake function")
-- 7 maps chained west->east, all the OVERWORLD tileset
local function newData()
local maps = {}
for i = 1, 7 do
local conns = {}
if i > 1 then conns.west = { map = "M" .. (i - 1), offset = 0 } end
if i < 7 then conns.east = { map = "M" .. (i + 1), offset = 0 } end
maps["M" .. i] = { id = "M" .. i, width = 10, height = 10,
tileset = "OVERWORLD", connections = conns }
end
return {
maps = maps,
tilesets = { OVERWORLD = { id = "OVERWORLD", image = "gfx/ts.png",
tilesPerRow = 4 } },
}
end
sources["gfx/ts.png"] = makeSource(32, 24)
-- a readable PNG header lets the worker decode the file itself (no header: the
-- hint is skipped, see section 8)
love.filesystem.write("gfx/ts.png", "\137PNG\r\n\26\n" .. "\0\0\0\13" .. "IHDR" ..
string.char(0, 0, 0, 32) .. string.char(0, 0, 0, 24))
local function keyFor(id) return "gfx/ts.png#gbc:" .. id .. PaletteFX.darkKey() end
local function reset()
PaletteFX.mode = "redpp"
PaletteFX.setDarkWorld(false)
TileRenderer.invalidate() -- empties gbcAtlasCache + AtlasPrefetch
AtlasPrefetch.shutdown()
AtlasPrefetch._setNoThreadEnvForTest(nil)
AtlasPrefetch._setLimitsForTest(10, 10, 10, 24)
threadError = nil
channel("atlas_cmd"):clear()
channel("atlas_out"):clear()
baked, bakeOrder = {}, {}
installThread()
end
local function atlasOf(data, id)
return TileRenderer._getGbcAtlas("gfx/ts.png", "OVERWORLD", id, 4, data)
end
-- a sync bake of `id` for comparison (own fresh state)
local function syncImage(data, id)
reset()
AtlasPrefetch._setNoThreadEnvForTest("1")
return atlasOf(data, id)
end
-- ---- 2. ready-store hit is used, identical to a sync bake ----------------------
do
local data = newData()
local ref = syncImage(data, "M2")
check(ref and ref.isImage, "sync path bakes an image")
reset()
check(TileRenderer.prefetchAtlas(data, "M2", false), "prefetch accepted")
TileRenderer.prefetchAtlas(data, "M2", false) -- duplicate request
eq((AtlasPrefetch.pending()), 1, "duplicate request is not queued twice")
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
eq(AtlasPrefetch.readyCount(), 1, "finished bake waits in the ready store")
local made = imagesBuilt
local img = atlasOf(data, "M2")
eq(imagesBuilt, made + 1, "newImage happens in getGbcAtlas, once")
check(sameImage(img.data, ref.data), "prefetched atlas is pixel-identical to the sync bake")
eq(AtlasPrefetch.readyCount(), 0, "the entry leaves the store once filed")
local decodedForSync = decodes
local again = atlasOf(data, "M2")
check(again == img, "second build reuses the cached atlas")
eq(decodes, decodedForSync, "and decodes nothing")
end
-- ---- 3. existing cache entry is never replaced; late result dropped ------------
do
local data = newData()
reset()
local first = atlasOf(data, "M3") -- sync fill
check(not TileRenderer.prefetchAtlas(data, "M3", false), "cached key is not prefetched")
eq((AtlasPrefetch.pending()), 0, "nothing queued for a cached key")
-- in flight when the sync path fills the slot
reset()
TileRenderer.prefetchAtlas(data, "M4", false)
AtlasPrefetch.update() -- sends to the worker
eq(select(2, AtlasPrefetch.pending()), 1, "bake is at the worker")
local sync = atlasOf(data, "M4") -- map built before the result
runWorker()
AtlasPrefetch.update()
eq(AtlasPrefetch.readyCount(), 0, "late result is dropped (slot already filled)")
check(atlasOf(data, "M4") == sync, "cache entry is unchanged")
check(AtlasPrefetch.idle(), "the dropped result retired the request")
-- queued (unsent) when the sync path fills the slot: never sent
reset()
TileRenderer.prefetchAtlas(data, "M5", false)
atlasOf(data, "M5")
AtlasPrefetch.update()
eq(channel("atlas_cmd"):getCount(), 0, "request for a filled slot is not sent")
-- result arrives while slot filled by a ready entry elsewhere: no replacement
reset()
TileRenderer.prefetchAtlas(data, "M6", false)
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
local img = atlasOf(data, "M6")
runWorker(); AtlasPrefetch.update()
check(atlasOf(data, "M6") == img, "filed atlas object stays the same")
end
-- ---- 4. epoch bumps -------------------------------------------------------------
do
local data = newData()
-- queued + ready + late all dropped by TileRenderer.invalidate
reset()
TileRenderer.prefetchAtlas(data, "M1", false)
TileRenderer.prefetchAtlas(data, "M2", false)
AtlasPrefetch._setLimitsForTest(1, 10, 1, 24)
AtlasPrefetch.update() -- M1 sent, M2 still queued
runWorker(); AtlasPrefetch.update() -- M1 -> ready, M2 sent
local e = AtlasPrefetch.epoch()
TileRenderer.invalidate()
check(AtlasPrefetch.epoch() > e, "TileRenderer.invalidate bumps the epoch")
eq(AtlasPrefetch.readyCount(), 0, "ready store cleared")
eq(channel("atlas_cmd"):getCount(), 0, "worker inbox cleared")
eq(select(1, AtlasPrefetch.pending()), 0, "queue dropped")
runWorker(); AtlasPrefetch.update() -- M2's late result
eq(AtlasPrefetch.readyCount(), 0, "late result from the old epoch is discarded")
check(AtlasPrefetch.idle(), "idle again")
-- palette mode changes bump too
reset()
TileRenderer.prefetchAtlas(data, "M1", false)
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
eq(AtlasPrefetch.readyCount(), 1, "one ready")
e = AtlasPrefetch.epoch()
PaletteFX.setMode("gbc")
check(AtlasPrefetch.epoch() > e, "PaletteFX.setMode bumps the epoch")
eq(AtlasPrefetch.readyCount(), 0, "and drops the ready store")
check(not TileRenderer.prefetchAtlas(data, "M1", false), "no prefetch outside RED++")
PaletteFX.mode = "redpp"
-- the asset flush fan-out (Assets.register) reaches it
reset()
TileRenderer.prefetchAtlas(data, "M1", false)
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
e = AtlasPrefetch.epoch()
Assets.flush()
check(AtlasPrefetch.epoch() > e, "asset flush bumps the epoch")
eq(AtlasPrefetch.readyCount(), 0, "flush drops the ready store")
-- dark shift is part of the key: a lit request is not used for a dark build
reset()
TileRenderer.prefetchAtlas(data, "M1", false)
PaletteFX.setDarkWorld(true)
AtlasPrefetch.update()
eq(channel("atlas_cmd"):getCount(), 0, "a request made lit is not sent once dark")
PaletteFX.setDarkWorld(false)
end
-- ---- 5. fallbacks ------------------------------------------------------------------
do
local data = newData()
reset()
love.thread = nil
check(not TileRenderer.prefetchAtlas(data, "M1", false), "no love.thread: no-op")
check(AtlasPrefetch.idle(), "no love.thread: idle")
check(atlasOf(data, "M1") ~= nil, "no love.thread: sync atlas still built")
AtlasPrefetch.update()
reset()
AtlasPrefetch._setNoThreadEnvForTest("1")
local before = threadsStarted
check(not TileRenderer.prefetchAtlas(data, "M1", false), "POKEPORT_NO_THREAD=1: no-op")
eq(threadsStarted, before, "POKEPORT_NO_THREAD=1 never starts the worker")
check(atlasOf(data, "M1") ~= nil, "POKEPORT_NO_THREAD=1: sync atlas built")
reset()
love.thread.newThread = function() error("boom") end
check(not TileRenderer.prefetchAtlas(data, "M1", false), "start failure: no-op")
check(atlasOf(data, "M1") ~= nil, "start failure: sync atlas built")
reset()
TileRenderer.prefetchAtlas(data, "M1", false)
AtlasPrefetch.update()
channel("atlas_out"):push({ fatal = "kaboom" })
AtlasPrefetch.update()
check(AtlasPrefetch.idle(), "fatal report drops the queue")
check(not TileRenderer.prefetchAtlas(data, "M2", false), "and later prefetches are no-ops")
check(atlasOf(data, "M2") ~= nil, "fatal: sync atlas built")
reset()
TileRenderer.prefetchAtlas(data, "M1", false)
AtlasPrefetch.update()
threadError = "worker crashed"
AtlasPrefetch.update()
check(not TileRenderer.prefetchAtlas(data, "M2", false), "dead worker: no-op")
check(atlasOf(data, "M2") ~= nil, "dead worker: sync atlas built")
threadError = nil
-- a worker-side failure leaves the slot empty for the sync path
reset()
local savedBake = AtlasBake.bake
AtlasBake.bake = function() error("bad bake") end
TileRenderer.prefetchAtlas(data, "M1", false)
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
AtlasBake.bake = savedBake
eq(AtlasPrefetch.readyCount(), 0, "failed bake stores nothing")
check(AtlasPrefetch.idle(), "failed bake retires the request")
check(atlasOf(data, "M1") ~= nil, "failed bake: sync atlas built")
end
-- ---- 6. ready-store cap ------------------------------------------------------------
do
local data = newData()
reset()
AtlasPrefetch._setLimitsForTest(10, 10, 10, 3)
for i = 1, 7 do TileRenderer.prefetchAtlas(data, "M" .. i, false) end
for _ = 1, 3 do AtlasPrefetch.update(); runWorker() end
AtlasPrefetch.update()
eq(AtlasPrefetch.readyCount(), 3, "ready store is bounded")
check(AtlasPrefetch.take(keyFor("M7")) ~= nil, "newest entry kept")
check(AtlasPrefetch.take(keyFor("M1")) == nil, "oldest entry evicted")
end
-- ---- 7. prefetch selection -----------------------------------------------------------
local function bakeCmds()
local out = {}
for _, c in ipairs(channel("atlas_cmd").queue) do
if c.cmd == "bake" then out[#out + 1] = c.key end
end
return out
end
do
local data = newData()
local savedData, savedRenderer = Game.data, Game.renderer
Game.data = data
Game.renderer = { worldViewSize = function() return 160, 144 end }
OW._setGameForTest(Game)
local ow = setmetatable({}, { __index = OW })
eq(#OW.prefetchTargets(data.maps, { "M4" }, 2, 144, 136), 4, "prefetchTargets lists a map's four neighbors")
-- standing on M4: neighbours are M2,M3,M5,M6; one crossing later M1 and M7
reset()
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
local keys = bakeCmds()
table.sort(keys)
eq(table.concat(keys, ","), keyFor("M1") .. "," .. keyFor("M7"),
"after setMap: only maps that join the neighbor set at the next crossing")
-- resident (already cached) maps are skipped
reset()
atlasOf(data, "M7")
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
eq(#bakeCmds(), 1, "a resident map's atlas is not re-queued")
eq(bakeCmds()[1], keyFor("M1"), "and the other one is")
-- warp: destination first, then its neighbours, all ahead of old hints
reset()
AtlasPrefetch._setLimitsForTest(10, 10, 10, 24)
ow:prefetchAtlases("M4", false) -- low-priority hints queued first
ow:prefetchAtlases("M1", true) -- warp to M1
AtlasPrefetch.update()
local order = bakeCmds()
eq(order[1], keyFor("M1"), "warp destination is first")
eq(table.concat(order, ","),
table.concat({ keyFor("M1"), keyFor("M2"), keyFor("M3"), keyFor("M7") }, ","),
"warp: destination, its neighbours, then the older low-priority hint")
-- not RED++: nothing at all
reset()
PaletteFX.mode = "gbc"
ow:prefetchAtlases("M4", false)
ow:prefetchAtlases("M4", true)
eq((AtlasPrefetch.pending()), 0, "outside RED++ the overworld queues nothing")
PaletteFX.mode = "redpp"
Game.data, Game.renderer = savedData, savedRenderer
end
-- ---- 8. bake source: worker-side decode path, NX overlay, shared decode -----------
do
local GameVersion = require("src.core.GameVersion")
local Platform = require("src.core.Platform")
local Overlay = require("src.core.NxAssetOverlay")
local P2 = "assets/generated/ts2.png"
local header = "\137PNG\r\n\26\n" .. "\0\0\0\13" .. "IHDR" ..
string.char(0, 0, 0, 32) .. string.char(0, 0, 0, 24)
local function data2()
local d = newData()
d.tilesets.OVERWORLD.image = P2
return d
end
local function lastBake()
for _, c in ipairs(channel("atlas_cmd").queue) do
if c.cmd == "bake" then return c end
end
end
sources[P2] = makeSource(32, 24)
love.filesystem.write(P2, header)
reset()
TileRenderer.prefetchAtlas(data2(), "M1", false)
AtlasPrefetch.update()
local c = lastBake()
eq(c.srcPath, P2, "plain: the worker decodes the file itself")
check(c.src == nil, "plain: nothing decoded on the main thread")
local n = 0
for _ in pairs(c.tileColors) do n = n + 1 end
eq(n, 12, "tile colours sized from the PNG header")
-- a decode the worker already finished is sent along instead of re-decoding
reset()
local stored = newID(32, 24)
love.image.newImageData = function(a, b) if type(a) == "string" then return stored end return newID(a, b) end
Assets.prefetchImage(P2, true)
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
TileRenderer.prefetchAtlas(data2(), "M1", false)
AtlasPrefetch.update()
c = lastBake()
check(c and c.src == stored and c.srcPath == nil, "stored tileset decode is reused as the bake source")
love.image.newImageData = stubNewImageData
-- NX: size and decode both go through the overlay's versioned path
local savedSystem, savedVersion = love.system, GameVersion.get()
love.system = { getOS = function() return "NX" end }
Platform._resetForTests()
GameVersion.set("yellow")
love.filesystem.write("yellow/" .. P2, header)
Overlay.install()
reset()
TileRenderer.prefetchAtlas(data2(), "M2", false)
AtlasPrefetch.update()
c = lastBake()
eq(c and c.srcPath, "yellow/" .. P2, "NX: bake srcPath is the versioned path")
-- unreadable header (no file at either path): the hint is skipped
love.filesystem.remove("yellow/" .. P2)
love.filesystem.remove(P2)
reset()
local decodesBefore = decodes
TileRenderer.prefetchAtlas(data2(), "M3", false)
AtlasPrefetch.update()
check(lastBake() == nil, "no readable header: no bake is sent")
check(AtlasPrefetch.idle(), "no readable header: the hint is dropped")
eq(decodes, decodesBefore, "no readable header: nothing decoded on the main thread")
-- a file the worker cannot decode (not a PNG): skipped the same way
local P3 = "assets/generated/ts3.jpg"
sources[P3] = makeSource(32, 24)
love.filesystem.write("yellow/" .. P3, header)
love.filesystem.write(P3, header)
reset()
local d3 = data2()
d3.tilesets.OVERWORLD.image = P3
TileRenderer.prefetchAtlas(d3, "M3", false)
AtlasPrefetch.update()
check(lastBake() == nil and AtlasPrefetch.idle(), "non-PNG: hint skipped")
eq(decodes, decodesBefore, "non-PNG: nothing decoded on the main thread")
Overlay.uninstall()
love.system = savedSystem
Platform._resetForTests()
GameVersion.set(savedVersion)
end
-- a composed tileset sheet (UnionCenters) is never baked off a file
do
reset()
Assets.compose("gfx/ts.png", function() return sources["gfx/ts.png"] end)
check(not TileRenderer.prefetchAtlas(newData(), "M1", false),
"composed sheet: no background bake")
check(AtlasPrefetch.idle(), "composed sheet: nothing queued")
Assets.compose("gfx/ts.png", nil)
check(TileRenderer.prefetchAtlas(newData(), "M1", false), "plain sheet: queued")
end
reset()
AtlasPrefetch.shutdown()
T.finish()