Files
gen1recomp/tests/engine/image_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

582 lines
24 KiB
Lua

-- Background image FILE decode (AtlasPrefetch.requestDecode, the "decode"
-- worker command, Assets.image / Assets.imageData store hits, the sprite-path
-- helpers and OverworldState.prefetchAtlases' decode hints).
-- luajit tests/engine/image_prefetch_test.lua
-- The worker is driven in-process like atlas_prefetch_test.lua.
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, tag)
return setmetatable({ w = w, h = h, px = {}, tag = tag }, 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
function ID:clone()
local c = newID(self.w, self.h, self.tag)
for k, v in pairs(self.px) do c.px[k] = { v[1], v[2], v[3], v[4] } end
return c
end
function ID:typeOf() return false end
function ID:mapPixel() end
local decodes = {} -- path -> times decoded
love.image = love.image or {}
love.image.newImageData = function(a, b)
if type(a) == "string" then
decodes[a] = (decodes[a] or 0) + 1
local id = newID(16, 16, a)
id:setPixel(0, 0, 0.5, 0.5, 0.5, 1)
return id
end
return newID(a, b)
end
local newImageArgs = {}
love.graphics = love.graphics or {}
love.graphics.newImage = function(a)
newImageArgs[#newImageArgs + 1] = a
if type(a) == "string" then decodes[a] = (decodes[a] or 0) + 1 end
return { isImage = true, src = a,
getDimensions = function() return 16, 16 end }
end
love.graphics.newQuad = function() return {} 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 AtlasBake = require("src.render.AtlasBake")
local Worker = require("src.render.atlas_worker")
local sentCmds = {}
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
sentCmds[#sentCmds + 1] = cmd
Worker.handle(cmd, AtlasBake, outCh)
end
end
local Assets = require("src.render.Assets")
local AtlasPrefetch = require("src.render.AtlasPrefetch")
local PaletteFX = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local Platform = require("src.core.Platform")
local Overlay = require("src.core.NxAssetOverlay")
local function reset()
AtlasPrefetch.shutdown()
AtlasPrefetch._setNoThreadEnvForTest(nil)
AtlasPrefetch._setDecodeLimitsForTest()
AtlasPrefetch._setLimitsForTest()
Assets.invalidate()
threadError = nil
channel("atlas_cmd"):clear()
channel("atlas_out"):clear()
decodes, newImageArgs, sentCmds = {}, {}, {}
installThread()
end
local function settle()
AtlasPrefetch.update(); runWorker(); AtlasPrefetch.update()
end
local P = "assets/generated/sprites/red.png"
-- ---- worker command ------------------------------------------------------------
do
reset()
local out = { queue = {}, push = function(self, m) self.queue[#self.queue + 1] = m end }
Worker.handle({ cmd = "decode", epoch = 3, key = P }, AtlasBake, out)
eq(out.queue[1].key, P, "decode result carries the path")
check(out.queue[1].decode and out.queue[1].data and out.queue[1].epoch == 3,
"decode result is flagged, carries the ImageData and the epoch")
local saved = love.image.newImageData
love.image.newImageData = function() error("bad png") end
Worker.handle({ cmd = "decode", epoch = 3, key = "x.png" }, AtlasBake, out)
love.image.newImageData = saved
check(out.queue[2].failed and not out.queue[2].data, "decode error is reported, not thrown")
-- bake from a path: the worker decodes the file itself
Worker.handle({ cmd = "bake", epoch = 1, key = "k", srcPath = "t.png", perRow = 2,
tileColors = {}, aliases = {} }, AtlasBake, out)
check(out.queue[3].data and decodes["t.png"] == 1, "bake with srcPath decodes in the worker")
end
-- ---- store hit used by Assets.image / imageData --------------------------------
do
reset()
PaletteFX.mode = "redpp" -- RED++ keeps the ImageData (OBJ / atlas recolours re-read it)
check(Assets.prefetchImage(P, false), "decode request accepted")
check(not AtlasPrefetch.idle(), "not idle while a decode is pending")
settle()
eq(AtlasPrefetch.decodedCount(), 1, "decoded ImageData waits in the store")
eq(decodes[P], 1, "decoded once (by the worker)")
local img = Assets.image(P)
check(img.src and img.src.tag == P, "Assets.image uploads the stored ImageData")
eq(decodes[P], 1, "and decodes nothing on the main thread")
check(Assets.image(P) == img, "second call is the cached Image")
eq(AtlasPrefetch.decodedCount(), 1, "the ImageData stays in the store")
local a = Assets.imageData(P)
local b = Assets.imageData(P)
check(a ~= b, "each imageData call gets its own object")
check(a ~= AtlasPrefetch.peekDecoded(P), "never the stored object")
a:setPixel(0, 0, 1, 0, 0, 1)
local r = AtlasPrefetch.peekDecoded(P):getPixel(0, 0)
eq(r, 0.5, "mutating a returned ImageData leaves the stored copy unchanged")
eq((b:getPixel(0, 0)), 0.5, "and the other clone")
eq(decodes[P], 1, "imageData from the store decodes nothing")
PaletteFX.mode = "gbc"
end
-- ---- the stored decode survives the upload in EVERY colour mode -------------------
-- SpriteRenderer.getObpImage re-reads the sheet through Assets.imageData in
-- dmgObj / ogObj / gen2Obp / gbc-pack alike.
for _, mode in ipairs({ "gbc", "redpp" }) do
reset()
PaletteFX.mode = mode
Assets.prefetchImage(P, false); settle()
eq(AtlasPrefetch.decodedCount(), 1, mode .. ": decoded and waiting")
Assets.image(P)
eq(AtlasPrefetch.decodedCount(), 1, mode .. ": the stored ImageData is kept after upload")
check(newImageArgs[1] ~= P, mode .. ": ...and the upload came from the store")
local before = decodes[P]
Assets.imageData(P)
eq(decodes[P], before, mode .. ": the OBJ-bake read after the upload decodes nothing")
end
PaletteFX.mode = "gbc"
-- ---- send-time skips are bounded per update --------------------------------------
do
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
-- thirty paths that resolve to nothing decodable cost a resolve each: one
-- update looks at a bounded number of them
local realResolve = AtlasPrefetch.resolveDecodable
AtlasPrefetch.resolveDecodable = function() return nil end
for i = 1, 30 do
Assets.prefetchImage("assets/generated/c" .. i .. ".png", false)
end
AtlasPrefetch.update()
local queued = AtlasPrefetch.decodePending()
check(queued >= 30 - 8 and queued < 30, "one update examines at most 8 queued entries (" .. queued .. " left)")
for _ = 1, 4 do AtlasPrefetch.update() end
eq((AtlasPrefetch.decodePending()), 0, "later updates drain the rest")
eq(#channel("atlas_cmd").queue, 0, "unresolvable paths were never sent")
AtlasPrefetch.resolveDecodable = realResolve
end
-- ---- NX asset overlay: the worker gets the path main would open ------------------
do
local savedSystem, savedVersion = love.system, GameVersion.get()
love.system = { getOS = function() return "NX" end }
Platform._resetForTests()
GameVersion.set("yellow")
love.filesystem.write("yellow/" .. P, "yellow-png")
Overlay.install()
reset()
Assets.prefetchImage(P, false); settle()
eq(sentCmds[1].key, P, "NX: the store key stays the resolved path")
eq(sentCmds[1].path, "yellow/" .. P, "NX: the worker is sent the versioned path")
check(AtlasPrefetch.peekDecoded(P) ~= nil, "NX: result stored under the resolved key")
-- no versioned copy: the unprefixed path is what main would open too
reset()
local other = "assets/generated/sprites/other.png"
Assets.prefetchImage(other, false); settle()
eq(sentCmds[1].path, other, "NX: no versioned file, the plain path is sent")
-- a mod override is not under assets/generated: untouched
check(Overlay.mapPath("mods/m/overrides/x.png") == "mods/m/overrides/x.png",
"NX: non-generated paths are not remapped")
Overlay.uninstall()
check(Overlay.mapPath(P) == P, "uninstalled overlay maps nothing")
reset()
Assets.prefetchImage(P, false); settle()
eq(sentCmds[1].path, P, "no overlay: the plain path")
love.filesystem.remove("yellow/" .. P)
love.system = savedSystem
Platform._resetForTests()
GameVersion.set(savedVersion)
end
-- ---- Assets entry never replaced; excluded paths --------------------------------
do
reset()
local first = Assets.image(P) -- sync fill
eq(decodes[P], 1, "sync decode through newImage(path) form")
check(newImageArgs[1] == P, "no store: newImage gets the path")
Assets.prefetchImage(P, false)
settle()
check(Assets.image(P) == first, "a late decode never replaces the cached Image")
reset()
check(not Assets.prefetchImage("assets/generated/foo@2x.png"), "@2x names are excluded")
check(not Assets.prefetchImage("assets/generated/foo.dds"), "non-PNG is excluded")
check(AtlasPrefetch.idle(), "nothing queued for excluded paths")
Assets.image("assets/generated/foo@2x.png")
check(newImageArgs[1] == "assets/generated/foo@2x.png", "excluded path loads by filename")
end
-- ---- epoch ---------------------------------------------------------------------
do
reset()
Assets.prefetchImage(P, false)
settle()
Assets.prefetchImage("assets/generated/b.png", false)
AtlasPrefetch.update() -- b is at the worker
Assets.prefetchImage("assets/generated/c.png", false)
local e = AtlasPrefetch.epoch()
Assets.invalidate()
check(AtlasPrefetch.epoch() > e, "Assets.invalidate bumps the epoch")
eq(AtlasPrefetch.decodedCount(), 0, "decoded store dropped")
eq(channel("atlas_cmd"):getCount(), 0, "worker inbox cleared")
eq((AtlasPrefetch.decodePending()), 0, "queued decodes dropped")
runWorker(); AtlasPrefetch.update()
eq(AtlasPrefetch.decodedCount(), 0, "late result from the old epoch is discarded")
check(AtlasPrefetch.idle(), "idle again")
-- a palette change only obsoletes bakes: decoded files (stored, queued and
-- the one already sitting in the worker's inbox) survive it
reset()
Assets.prefetchImage(P, false); settle()
Assets.prefetchImage("assets/generated/b.png", false)
AtlasPrefetch.update() -- b is in the worker's inbox
Assets.prefetchImage("assets/generated/c.png", false) -- still queued
e = AtlasPrefetch.epoch()
PaletteFX.setMode("redpp") -- invalidateColorCaches -> bumpBakes
check(AtlasPrefetch.epoch() > e, "palette change bumps the bake epoch")
eq(AtlasPrefetch.decodedCount(), 1, "palette change keeps the decoded store")
check(AtlasPrefetch.peekDecoded(P) ~= nil, "...and the decoded sheet is still served")
local queuedAfter = AtlasPrefetch.decodePending()
eq(queuedAfter, 2, "queued and un-started decodes are kept (b re-queued, c)")
for _ = 1, 3 do settle() end
eq(AtlasPrefetch.decodedCount(), 3, "every decode is still delivered")
check(AtlasPrefetch.idle(), "and nothing is stranded in flight")
PaletteFX.setMode("gbc")
-- a mod-set change (installLoader) re-resolves paths: also a bump
reset()
Assets.prefetchImage(P, false); settle()
e = AtlasPrefetch.epoch()
Assets.installLoader(nil)
check(AtlasPrefetch.epoch() > e and AtlasPrefetch.decodedCount() == 0,
"installLoader drops the store")
-- the store is keyed by the RESOLVED path
reset()
Assets.loader = { overrideOrder = function() return { { id = "m", path = "mods/m" } } end,
derivedPath = function() return nil end }
local savedGet = love.filesystem.getInfo
love.filesystem.getInfo = function(p) if p == "mods/m/overrides/sprites/red.png" then return {} end end
Assets.prefetchImage(P, false); settle()
eq(sentCmds[1].key, "mods/m/overrides/sprites/red.png", "mod override: the resolved path is decoded")
check(Assets.image(P).src.tag == "mods/m/overrides/sprites/red.png", "and used for the shadowed asset")
love.filesystem.getInfo = savedGet
Assets.loader = nil
Assets.invalidate()
end
-- ---- fallbacks -----------------------------------------------------------------
do
reset()
love.thread = nil
check(not Assets.prefetchImage(P), "no love.thread: no-op")
check(AtlasPrefetch.idle(), "no love.thread: idle")
check(Assets.image(P) ~= nil and decodes[P] == 1, "no love.thread: sync image")
check(Assets.imageData(P) ~= nil, "no love.thread: sync imageData")
reset()
AtlasPrefetch._setNoThreadEnvForTest("1")
local before = threadsStarted
check(not Assets.prefetchImage(P), "POKEPORT_NO_THREAD=1: no-op")
eq(threadsStarted, before, "POKEPORT_NO_THREAD=1 never starts the worker")
reset()
love.thread.newThread = function() error("boom") end
check(not Assets.prefetchImage(P), "start failure: no-op")
reset()
Assets.prefetchImage(P); AtlasPrefetch.update()
channel("atlas_out"):push({ fatal = "kaboom" })
AtlasPrefetch.update()
check(AtlasPrefetch.idle() and not Assets.prefetchImage("assets/generated/z.png"),
"fatal: queue dropped, later requests no-ops")
check(Assets.image(P) ~= nil, "fatal: sync image")
reset()
Assets.prefetchImage(P); AtlasPrefetch.update()
threadError = "worker crashed"
AtlasPrefetch.update()
check(not Assets.prefetchImage("assets/generated/z.png"), "dead worker: no-op")
threadError = nil
reset()
local savedND = love.image.newImageData
love.image.newImageData = function() error("corrupt") end
Assets.prefetchImage(P); settle()
love.image.newImageData = savedND
eq(AtlasPrefetch.decodedCount(), 0, "failed decode stores nothing")
check(AtlasPrefetch.idle(), "failed decode retires the request")
check(Assets.image(P) ~= nil, "failed decode: sync image")
end
-- ---- budget, priority, LRU -------------------------------------------------------
do
reset()
AtlasPrefetch._setDecodeLimitsForTest(2, 4, 64)
for i = 1, 5 do Assets.prefetchImage("assets/generated/s" .. i .. ".png", false) end
Assets.prefetchImage("assets/generated/s5.png", true) -- promote
Assets.prefetchImage("assets/generated/s5.png", true) -- duplicate
local queued = AtlasPrefetch.decodePending()
eq(queued, 5, "duplicates are not queued twice")
AtlasPrefetch.update()
local q, flying = AtlasPrefetch.decodePending()
eq(flying, 2, "in-flight decode budget is respected")
eq(q, 3, "the rest wait")
runWorker()
eq(sentCmds[1].key, "assets/generated/s5.png", "front request overtakes queued hints")
for _ = 1, 4 do settle() end
eq(AtlasPrefetch.decodedCount(), 5, "all decodes eventually land")
reset()
AtlasPrefetch._setDecodeLimitsForTest(8, 8, 3)
for i = 1, 6 do Assets.prefetchImage("assets/generated/s" .. i .. ".png", false) end
for _ = 1, 3 do settle() end
eq(AtlasPrefetch.decodedCount(), 3, "decoded store is bounded")
check(AtlasPrefetch.peekDecoded("assets/generated/s6.png") ~= nil, "newest kept")
check(AtlasPrefetch.peekDecoded("assets/generated/s1.png") == nil, "oldest evicted")
end
-- ---- PNG header size ---------------------------------------------------------------
do
local head = "\137PNG\r\n\26\n" .. "\0\0\0\13" .. "IHDR" ..
string.char(0, 0, 0, 128) .. string.char(0, 0, 1, 64)
local saved = love.filesystem.read
love.filesystem.read = function(p) if p == "t.png" then return head, #head end end
local w, h = Assets.pngSize("t.png")
eq(w, 128, "png width from the header")
eq(h, 320, "png height from the header")
check(Assets.pngSize("nope.png") == nil, "unreadable file: nil")
love.filesystem.read = saved
end
-- ---- sprite path helper parity -------------------------------------------------------
local SpriteRenderer = require("src.render.SpriteRenderer")
local NPC = require("src.world.NPC")
do
reset()
local loaded = {}
local realImage = Assets.image
Assets.image = function(path) loaded[#loaded + 1] = path; return realImage(path) end
local defs = {
vanilla = { image = "assets/generated/sprites/red.png", frames = 3 },
mod = { image = "mods/foo/sprites/hero.png", frames = 1, frameWidth = 24 },
}
for name, def in pairs(defs) do
loaded = {}
SpriteRenderer.new(def, "seed")
eq(loaded[1], SpriteRenderer.imagePath(def), name .. " def: SpriteRenderer.new loads imagePath")
end
check(SpriteRenderer.imagePath({}) == nil and SpriteRenderer.imagePath(nil) == nil,
"no image: nil")
local data = { sprites = { SPRITE_RED = defs.vanilla, SPRITE_MOD = defs.mod } }
eq(NPC.spriteImagePath(data, { sprite = "SPRITE_MOD" }), defs.mod.image,
"NPC.spriteImagePath resolves through data.sprites")
check(NPC.spriteImagePath(data, { sprite = "NOPE" }) == nil, "unknown sprite: nil")
Assets.image = realImage
end
-- ---- overworld hints (every colour mode) ---------------------------------------------
local Game = require("src.core.Game")
local OW = require("src.world.OverworldController")
do
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,
objects = {
{ index = 0, sprite = "S" .. i, name = "shown" },
{ index = 1, sprite = "SHIDDEN", name = "hid", hidden = true },
} }
end
local sprites = { SHIDDEN = { image = "assets/generated/sprites/hidden.png" } }
for i = 1, 7 do sprites["S" .. i] = { image = "assets/generated/sprites/s" .. i .. ".png" } end
local data = { maps = maps, sprites = sprites,
tilesets = { OVERWORLD = { id = "OVERWORLD", image = "assets/generated/ts.png", tilesPerRow = 4 } } }
local savedData, savedRenderer, savedSave = Game.data, Game.renderer, Game.save
Game.data = data
Game.save = { objectToggles = {}, itemsTaken = {}, defeatedTrainers = {} }
Game.renderer = { worldViewSize = function() return 160, 144 end }
OW._setGameForTest(Game)
local ow = setmetatable({}, { __index = OW })
local function decodeKeys()
local out = {}
for _, c in ipairs(channel("atlas_cmd").queue) do
if c.cmd == "decode" then out[#out + 1] = c.key end
end
table.sort(out)
return out
end
for _, mode in ipairs({ "gbc", "redpp" }) do
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = mode
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
eq(table.concat(decodeKeys(), ","),
table.concat({ "assets/generated/sprites/s1.png", "assets/generated/sprites/s7.png",
"assets/generated/ts.png" }, ","),
mode .. ": next-hop maps' tileset + visible-object sheets are decoded ahead")
end
-- a hidden object comes back when its toggle says so
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
Game.save.objectToggles = { M1 = { hid = true } }
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
local keys = table.concat(decodeKeys(), ",")
check(keys:find("hidden.png", 1, true) ~= nil, "objectVisible filter is the rebuildGhosts one")
Game.save.objectToggles = {}
-- every mode: a sheet whose Image is already cached is still decoded, the
-- sprite OBJ bake (getObpImage) re-reads its pixels through imageData
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
Assets.image("assets/generated/sprites/s1.png")
channel("atlas_cmd"):clear()
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
check(table.concat(decodeKeys(), ","):find("s1.png", 1, true) ~= nil,
"gbc: a cached Image's sheet is still decoded for the OBJ bake")
-- Image cached AND an OBJ-palette bake exists: the pixels are never read
-- again, so no decode is queued (the tileset hint stays)
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
local S1 = "assets/generated/sprites/s1.png"
SpriteRenderer.new({ image = S1 }) -- caches the sheet by its raw path
SpriteRenderer.obpImage(S1, { {0,0,0}, {0,0,0}, {0,0,0}, {0,0,0} }, 1)
check(SpriteRenderer.isBaked(S1), "Image and OBJ bake are held")
channel("atlas_cmd"):clear()
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
local skipKeys = table.concat(decodeKeys(), ",")
check(not skipKeys:find("s1.png", 1, true), "cached Image + OBJ bake: sheet not decoded")
check(skipKeys:find("s7.png", 1, true) and skipKeys:find("ts.png", 1, true),
"other sheets and the tileset are still decoded")
SpriteRenderer.invalidate()
check(not SpriteRenderer.hasObp(S1), "invalidate forgets the OBJ bakes")
-- non-RED++: a cached tileset Image is skipped, unless an animated tile's
-- variants still have to be built from its pixels
do
local TR = require("src.render.TileRenderer")
local TS = "assets/generated/ts.png"
local tsDef = data.tilesets.OVERWORLD
local realHas = TR.hasImage
TR.hasImage = function(path) return path == TS or realHas(path) end
tsDef.animatedTiles = { { tile = 5, kind = "hshift", offsets = { 0, 1 } } }
local function tsDecoded()
return table.concat(decodeKeys(), ","):find("ts.png", 1, true) ~= nil
end
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
check(tsDecoded(), "cached tileset with an unbuilt animated variant: hint still queued")
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
TR._shiftVariantsForTest()[TS .. "#5"] = {}
ow:prefetchAtlases("M4", false)
AtlasPrefetch.update()
check(not tsDecoded(), "cached tileset with everything built: hint skipped")
TR._shiftVariantsForTest()[TS .. "#5"] = nil
tsDef.animatedTiles = nil
TR.hasImage = realHas
end
-- warp: destination first
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
ow:prefetchAtlases("M4", false)
ow:prefetchAtlases("M7", true)
AtlasPrefetch.update()
local first
for _, c in ipairs(channel("atlas_cmd").queue) do
if c.cmd == "decode" then first = c.key break end
end
eq(first, "assets/generated/ts.png", "warp: destination's decodes jump the hint queue")
local seenS7 = false
for _, c in ipairs(channel("atlas_cmd").queue) do
if c.key == "assets/generated/sprites/s7.png" then seenS7 = true end
end
check(seenS7, "warp: destination sprite queued")
-- per-setMap cap on low-priority hints
reset()
AtlasPrefetch._setDecodeLimitsForTest(64, 64, 64)
PaletteFX.mode = "gbc"
for i = 1, 7 do
for j = 2, 40 do
table.insert(maps["M" .. i].objects, { index = j, sprite = "SX" .. i .. "_" .. j })
sprites["SX" .. i .. "_" .. j] = { image = "assets/generated/sprites/x" .. i .. "_" .. j .. ".png" }
end
end
ow:prefetchAtlases("M4", false)
local q = AtlasPrefetch.decodePending()
check(q <= 32, "low-priority decode hints are capped per setMap (" .. q .. ")")
PaletteFX.mode = "redpp"
Game.data, Game.renderer, Game.save = savedData, savedRenderer, savedSave
end
reset()
AtlasPrefetch.shutdown()
T.finish()