diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index dbdc5f74..8cc370af 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -21,6 +21,8 @@ end -- version session (save editor or game). GPU release runs before soft -- invalidate via installLoader(nil). function SessionLifecycle.endMountedSession(version) + local stream = package.loaded["src.core.game3.asset_stream"] + if stream then stream.shutdown() end local Assets = require("src.render.Assets") if Assets.releaseSession then Assets.releaseSession() end if version then diff --git a/src/core/game3/asset_decode.lua b/src/core/game3/asset_decode.lua new file mode 100644 index 00000000..34bf6de4 --- /dev/null +++ b/src/core/game3/asset_decode.lua @@ -0,0 +1,131 @@ +-- CPU-only preparation shared by the render thread and the asset worker. +local Pack = require("src.import.gba.native_pack") +local Palette = require("src.core.game3.palette") +local OwExtract = require("src.import.gba.ow_extract") +local D = {} + +function D.imageData(rgba, w, h) + if not (love and love.image) then return nil, "love.image unavailable" end + local ok, data = pcall(love.image.newImageData, w, h, "rgba8", rgba) + if ok then return data end + data = love.image.newImageData(w, h) + local i = 1 + for y = 0, h - 1 do + for x = 0, w - 1 do + data:setPixel(x, y, (rgba:byte(i) or 0) / 255, (rgba:byte(i + 1) or 0) / 255, + (rgba:byte(i + 2) or 0) / 255, (rgba:byte(i + 3) or 0) / 255) + i = i + 4 + end + end + return data +end + +function D.pair(cache, root, pair) + local path = root .. "/" .. pair + local blob, pals = cache:read(path .. "/mids.idx"), cache:read(path .. "/palettes.bin") + if not blob or not pals then return nil, "missing native blobs for " .. pair end + local idx, err = Pack.decodeIdx(blob) + if not idx then return nil, err end + local rgb, bgr = Palette.load(pals) + if not rgb then return nil, bgr end + local overBlob = cache:read(path .. "/mids_over.idx") + local out = { pair = pair, cols = idx.atlasCols, rows = idx.atlasRows, midCount = idx.midCount, + idxBlob = blob, bgr = bgr, midToSlot = {}, layers = {} } + for i, mid in ipairs(idx.midIds) do out.midToSlot[mid] = i - 1 end + local function layer(key, dataKey, indexed, source, tag, transparent) + local hash = Palette.hash(bgr, source) + local rel = path .. "/atlas_" .. tag .. "_" .. hash .. ".rgba" + local w, h = indexed.atlasCols * 16, indexed.atlasRows * 16 + local rgba = cache:read(rel) + if not rgba or #rgba ~= w * h * 4 then + rgba = Pack.bakeRgba(indexed, rgb, { transparentZero = transparent, cancelled = cache.cancelled }) + if not rgba then return nil, "asset preparation cancelled" end + if cache.write then pcall(cache.write, cache, rel, rgba) end + end + local data, why = D.imageData(rgba, w, h) + if not data then return nil, why end + out.layers[#out.layers + 1] = { imageKey = key, dataKey = dataKey, data = data, bytes = w * h * 4 } + return true + end + local ok, why = layer("image", "imageData", idx, blob, overBlob and "u" or "flat", false) + if not ok then return nil, why end + local over = overBlob and Pack.decodeIdx(overBlob) + if over then + ok, why = layer("overImage", "overImageData", over, overBlob, "o", true) + if not ok then return nil, why end + out.overBlob, out.layered = overBlob, true + end + if overBlob then + local midBlob = cache:read(path .. "/mids_mid.idx") + local mid = midBlob and Pack.decodeIdx(midBlob) + if mid then + ok, why = layer("midImage", "midImageData", mid, midBlob, "m", true) + if not ok then return nil, why end + end + end + out.animFiles = {} + local manifestRel = path .. "/anim_manifest.lua" + local manifestBlob = cache:read(manifestRel) + out.animFiles[manifestRel] = manifestBlob + local chunk = manifestBlob and load(manifestBlob, "@anim_manifest.lua", "t", {}) + local man = chunk and chunk() + if man then + local function read(file) + if file then out.animFiles[path .. "/" .. file] = cache:read(path .. "/" .. file) end + end + out.animFiles[path .. "/palettes.bin"] = pals + for _, row in ipairs(man.banks or {}) do read(row.file); read(row.overFile) end + for _, kind in ipairs({ "water", "sand", "flower" }) do + if man[kind] then read("anim_" .. kind .. ".rgba") end + end + end + return out +end + +function D.sprite(cache, root, gid) + local metaBlob, rgba = cache:read(root .. "/" .. gid .. ".meta"), cache:read(root .. "/" .. gid .. ".rgba") + local meta = metaBlob and OwExtract.decodeMeta(metaBlob) + if not meta or not rgba then return nil, "missing OW sheet " .. tostring(gid) end + local w, h, n = meta.width, meta.height, meta.frameCount + if w < 1 or h < 1 or n < 1 or w > 256 or h > 256 or n > 512 then return nil, "bad OW dimensions" end + local bytes = w * h * n * 4 + if #rgba ~= bytes then + if #rgba < w * h * 4 then return nil, "truncated OW sheet" end + rgba = rgba:sub(1, bytes) .. string.rep("\0", math.max(0, bytes - #rgba)) + end + local data, err = D.imageData(rgba, w, h * n) + if not data then return nil, err end + meta.imageData, meta.bytes = data, bytes + return meta +end + +-- Snapshots never consult a later GameVersion, cache prefix, or mod mount. +function D.cache(spec, cancelSignal) + local cache = { cancelled = cancelSignal and function() return cancelSignal:getCount() > 0 end or nil } + local function checkCancel() + if cache.cancelled and cache.cancelled() then error("asset preparation cancelled") end + end + local function path(rel) return (spec.prefix or "") .. rel end + function cache:read(rel) + checkCancel() + if spec.directory then + local f = io.open(spec.directory .. "/" .. path(rel), "rb") + if not f then return nil end + local bytes = f:read("*a"); f:close(); return bytes + end + return love.filesystem.read(path(rel)) + end + function cache:write(rel, bytes) + checkCancel() + -- No directory creation or mounting in a worker; derived caches are optional. + if spec.directory then + local f = io.open(spec.directory .. "/" .. path(rel), "wb") + if not f then return false end + f:write(bytes); f:close(); return true + end + return love.filesystem.write(path(rel), bytes) + end + return cache +end + +return D diff --git a/src/core/game3/asset_stream.lua b/src/core/game3/asset_stream.lua new file mode 100644 index 00000000..f9ca8d29 --- /dev/null +++ b/src/core/game3/asset_stream.lua @@ -0,0 +1,189 @@ +-- One CPU worker, bounded decoded residency, and a shared upload budget. +local Decode = require("src.core.game3.asset_decode") +local Stream = {} +Stream.UPLOAD_BUDGET = .002 +Stream.MAX_DECODED = 4 +Stream.MAX_PENDING = 64 +local queue, jobs, serial = {}, {}, 0 +local thread, input, output, active +local function cancel(job) + job.cancelled = true + if job.cancelSignal then job.cancelSignal:push(true) end +end +local clock = function() return love and love.timer and love.timer.getTime() or os.clock() end +local function record(job, phase, seconds, route) + if Stream.onTiming then Stream.onTiming(job.kind, job.key, phase, seconds, route) end + if os.getenv("POKEPORT_ASSET_PROFILE") == "1" then + print(string.format("[game3/asset] %s=%s %s=%.3fms route=%s", job.kind, job.key, phase, seconds * 1000, route)) + end +end +local function startWorker() + if thread then return true end + if not (love and love.thread and love.thread.newThread and love.thread.newChannel) then return false end + local ok, value = pcall(love.thread.newThread, "src/core/game3/asset_worker.lua") + if not ok then return false end + input, output = love.thread.newChannel(), love.thread.newChannel() + local started = pcall(value.start, value, input, output) + if not started then return false end + thread = value + return true +end +function Stream.shutdown() + Stream.cancelPending() + if thread then + input:clear(); input:push({ stop = true }); thread:wait() + end + thread, input, output, active = nil, nil, nil, nil + queue, jobs = {}, {} + Stream._uploadedThisFrame = false + Stream.workerFailed = false +end +function Stream.cancelPending() + for _, job in pairs(jobs) do + cancel(job) + job.owner.pending[job.key] = nil + end + jobs = {} +end +function Stream.frameComplete() Stream._uploadedThisFrame = false end +local Lifecycle = require("src.core.SessionLifecycle") +if not Lifecycle._game3AssetShutdownRegistered then + Lifecycle._game3AssetShutdownRegistered = true + Lifecycle.registerProcessShutdown(function() + local current = package.loaded["src.core.game3.asset_stream"] + if current then current.shutdown() end + end) +end + +function Stream.new(kind, cache, root, upload, publish) + local s = { kind = kind, cache = cache, root = root, upload = upload, publish = publish, pending = {} } + function s:cancel() + self.cancelled = true + for _, job in pairs(self.pending) do cancel(job); jobs[job.id] = nil end + self.pending = {} + end + function s:decoded(key) + local job = self.pending[key] + return job and job.data ~= nil + end + function s:retain(wanted) + for key, job in pairs(self.pending) do + if not wanted[key] then + cancel(job); jobs[job.id], self.pending[key] = nil, nil + end + end + end + function s:prefetch(key, priority) + if self.cancelled then return end + if self.pending[key] then self.pending[key].priority = priority or self.pending[key].priority; return end + local count = 0 + for _ in pairs(self.pending) do count = count + 1 end + if count >= Stream.MAX_PENDING then return end + serial = serial + 1 + local spec = self.cache and self.cache.assetWorkerSpec and self.cache:assetWorkerSpec(root, kind, key) + local job = { id = serial, kind = kind, key = key, root = root, owner = self, spec = spec, priority = priority or 1 } + self.pending[key], jobs[serial] = job, job + queue[#queue + 1] = job + end + function s:get(key) + Stream.poll() + local job = self.pending[key] + local data = job and job.data + if not data then + if job and job.cancelSignal then job.cancelSignal:push(true) end + local start = clock() + local err + data, err = Decode[kind](cache, root, key) + record({ kind = kind, key = key }, "decode", clock() - start, "sync") + if not data then return nil, err end + end + local start = clock() + local co = job and job.co or coroutine.create(function() return upload(data) end) + local value + repeat + local ok, res = coroutine.resume(co) + if not ok then + if job then job.cancelled = true; jobs[job.id] = nil; self.pending[key] = nil end + return nil, tostring(res) + end + value = res + until coroutine.status(co) == "dead" + if job then job.cancelled = true; jobs[job.id] = nil; self.pending[key] = nil end + publish(key, value) + record({ kind = kind, key = key }, "upload", clock() - start, data == (job and job.data) and "decoded" or "sync") + return value + end + return s +end + +function Stream.poll() + if output then + local result = output:pop() + if result then + if result.fatal then + if active then active.spec, active.sent = nil, false end + Stream.workerFailed, active = true, nil + print("[game3/asset] worker unavailable: " .. tostring(result.fatal)) + end + local job = jobs[result.id] + if active and active.id == result.id then active = nil end + if job and not job.cancelled then + job.data, job.error, job.done = result.data, result.error, true + record(job, "decode", result.seconds, "worker") + end + end + end + if thread and thread:getError() then + if active then active.spec, active.sent = nil, false end + thread, input, output, active = nil, nil, nil, nil + Stream.workerFailed = true + end + if active then return end + local decoded = 0 + for _, job in ipairs(queue) do if not job.cancelled and job.data then decoded = decoded + 1 end end + if decoded >= Stream.MAX_DECODED then return end + local nextJob + for _, job in ipairs(queue) do + if not job.cancelled and not job.done and job ~= active and not job.sent then + if job.spec and (not nextJob or job.priority < nextJob.priority) then nextJob = job end + end + end + if nextJob and not Stream.workerFailed and startWorker() then + nextJob.sent, active = true, nextJob + nextJob.cancelSignal = love.thread.newChannel() + input:push({ id = nextJob.id, kind = nextJob.kind, key = nextJob.key, root = nextJob.root, + spec = nextJob.spec, cancelSignal = nextJob.cancelSignal }) + end + -- Unknown/custom caches prepare synchronously only when explicitly consumed. +end + +function Stream.update() + Stream.poll() + if Stream._uploadedThisFrame then return end + local start = clock() + for i = #queue, 1, -1 do if queue[i].cancelled then table.remove(queue, i) end end + local nextJob + for _, job in ipairs(queue) do + if job.data and (not nextJob or job.priority < nextJob.priority) then nextJob = job end + end + local job = nextJob + if job then + Stream._uploadedThisFrame = true + if not job.co then job.co = coroutine.create(function() return job.owner.upload(job.data) end) end + local t = clock() + local ok, value = coroutine.resume(job.co) + if not ok then + job.error, job.done, job.data = tostring(value), true, nil + job.co = nil + elseif coroutine.status(job.co) == "dead" then + job.owner.publish(job.key, value) + job.owner.pending[job.key], jobs[job.id], job.cancelled = nil, nil, true + end + record(job, "upload_step", clock() - t, "warm") + -- A texture upload (including animation catch-up on publication) is + -- atomic. One coroutine slice per render frame prevents catch-up ticks + -- and a second ready asset from silently exceeding the upload budget. + if clock() - start > Stream.UPLOAD_BUDGET then Stream.lastOverBudget = clock() - start end + end +end +return Stream diff --git a/src/core/game3/asset_worker.lua b/src/core/game3/asset_worker.lua new file mode 100644 index 00000000..989e6c93 --- /dev/null +++ b/src/core/game3/asset_worker.lua @@ -0,0 +1,22 @@ +local input, output = ... +local running, fatal = pcall(function() + require("love.filesystem") + require("love.image") + require("love.data") + require("love.timer") + table.insert(package.searchers or package.loaders, 1, function(name) + local path = name:gsub("%.", "/") .. ".lua" + if love.filesystem.getInfo(path) then return assert(love.filesystem.load(path)) end + end) + local D = require("src.core.game3.asset_decode") + while true do + local job = input:demand() + if job.stop then break end + local start = love.timer.getTime() + local ok, data, err = pcall(D[job.kind], D.cache(job.spec, job.cancelSignal), job.root, job.key) + output:push({ id = job.id, data = ok and data or nil, error = ok and err or tostring(data), + seconds = love.timer.getTime() - start }) + end + +end) +if not running then output:push({ fatal = tostring(fatal) }) end diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index 98959418..1467d4c9 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -65,6 +65,16 @@ end local function loveCache() return { + assetWorkerSpec = function(_, root, kind, key) + local rel = root .. "/" .. tostring(key) .. (kind == "pair" and "/mids.idx" or ".meta") + local CacheFs = require("src.import.CacheFs") + local prefix = require("src.core.GameVersion").cachePrefix() + -- Only a version-qualified cache can be read independently of mounted + -- overlays. Overrides/custom readers retain the synchronous fallback. + if Dataset.cacheRootOverride or os.getenv("POKEPORT_GBA_CACHE") then return nil end + if not CacheFs.existsAt(prefix .. rel) then return nil end + return { prefix = prefix, directory = CacheFs.root() } + end, read = function(_, rel) local ok, CacheFs = pcall(require, "src.import.CacheFs") if ok and CacheFs and CacheFs.readActive then diff --git a/src/core/game3/display.lua b/src/core/game3/display.lua index d29c017e..f4bedc42 100644 --- a/src/core/game3/display.lua +++ b/src/core/game3/display.lua @@ -231,13 +231,16 @@ local function drawFieldPlane(game, vw, vh, Renderer) local transitioning = Transition and Transition.isActive and Transition.isActive() Oam.resetFrame() local prev = Oam.setLayer("world") + local function exchange(current, replacement) + return Renderer and Renderer:exchangeWorldCanvas(current, replacement) + end if Tilt.active() and not transitioning and Renderer and Renderer.beginUprightPass then - FieldView.draw(game, vw, vh, { skipActors = true }) + FieldView.draw(game, vw, vh, { skipActors = true, exchangeCanvas = exchange }) Renderer:beginUprightPass() FieldView.draw(game, vw, vh, { actorsOnly = true, billboard = true }) Renderer:endUprightPass() else - FieldView.draw(game, vw, vh) + FieldView.draw(game, vw, vh, { exchangeCanvas = exchange }) end Oam.setLayer(prev) Oam.animateSprites("world") @@ -439,6 +442,8 @@ end Display.presentFlat = presentFlat function Display.present(game, winW, winH) + local Stream = package.loaded["src.core.game3.asset_stream"] + if Stream then Stream.frameComplete() end if not Display.planesBroken then local ok, err = pcall(presentPlanes, game) if ok then return true end diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index bb742b28..a9c4de81 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -147,6 +147,8 @@ function Field.start(mod, game, session) end function Field.stop() + local Stream = package.loaded["src.core.game3.asset_stream"] + if Stream then Stream.cancelPending() end lazyReq("src.world.game3.Follower").reset() Field.running = false Field._session = nil @@ -213,9 +215,9 @@ function Field.update(_dt) local Ghosts = lazyReq("src.core.game3.ghosts") Ghosts.sync() Ghosts.update(game) - -- pre-load tileset pairs queued by a seamless world refresh, one per frame + -- Prepare nearby assets on a worker; share a main-thread texture budget. local MapMod = package.loaded["src.core.game3.map"] - if MapMod and MapMod._warmQueue and MapMod.stepWarm then MapMod.stepWarm() end + if MapMod and MapMod.stepWarm then MapMod.stepWarm(game) end Field.pollMapChange(game) -- pokefirered/src/safari_zone.c:60 CB2_EndSafariBattle diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 94499c20..ad962278 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -1780,7 +1780,7 @@ function FieldView.draw(game, canvasW, canvasH, opts) end local FieldWeather = modFieldWeather() if FieldWeather and FieldWeather.draw then - FieldWeather.draw(camX, camY, canvasW, canvasH) + FieldWeather.draw(camX, camY, canvasW, canvasH, opts.exchangeCanvas) end end @@ -1833,10 +1833,21 @@ function FieldView.invalidate() if OwSprites and OwSprites.invalidate then OwSprites.invalidate() end + local WeatherRse = package.loaded["src.core.game3.field_weather_rse"] + if WeatherRse and WeatherRse.invalidate then WeatherRse.invalidate() end local FieldEffects = modFieldEffects() if FieldEffects and FieldEffects.invalidate then FieldEffects.invalidate() end end +local Assets = require("src.render.Assets") +if Assets.register and not Assets._game3FieldInvalidatorRegistered then + Assets._game3FieldInvalidatorRegistered = true + Assets.register(function() + local current = package.loaded["src.core.game3.field_view"] + if current then current.invalidate() end + end) +end + return FieldView diff --git a/src/core/game3/field_weather.lua b/src/core/game3/field_weather.lua index d74721a2..e22ed223 100644 --- a/src/core/game3/field_weather.lua +++ b/src/core/game3/field_weather.lua @@ -130,11 +130,11 @@ function FieldWeather.drawBelow(camX, camY, canvasW, canvasH) if E then E.drawBelow(camX or 0, camY or 0, canvasW or W, canvasH or H) end end -function FieldWeather.draw(camX, camY, canvasW, canvasH) +function FieldWeather.draw(camX, camY, canvasW, canvasH, exchangeCanvas) if Weather.isSuspended() then return end local E = Weather.rseEngine() if E then - E.draw(camX or 0, camY or 0, canvasW or W, canvasH or H) + E.draw(camX or 0, camY or 0, canvasW or W, canvasH or H, exchangeCanvas) return end local w = FieldWeather._current diff --git a/src/core/game3/field_weather_rse.lua b/src/core/game3/field_weather_rse.lua index 62ef3695..7ff33d62 100644 --- a/src/core/game3/field_weather_rse.lua +++ b/src/core/game3/field_weather_rse.lua @@ -1931,6 +1931,39 @@ local function drawTiled(sheet, frame, baseX, baseY, ox, oy, cw, ch) local sy = oy + (baseY % 64) - 64 while sx > -64 do sx = sx - 64 end while sy > -64 do sy = sy - 64 end + if love.graphics.newSpriteBatch then + local r, g, b, a = love.graphics.getColor() + local bucket = r == 1 and g == 1 and b == 1 and a == 1 and "tiledBase" or "tiledTint" + local store = sheet[bucket] or {}; sheet[bucket] = store + local key = table.concat({ frame, sx, sy, cw, ch, r, g, b, a }, ":") + if store.key ~= key then + local q = sheetQuad(sheet, frame, 8, 8) + local count = (math.floor((cw - sx) / 64) + 1) * (math.floor((ch - sy) / 64) + 1) + count = count * (type(q) == "table" and #q or 1) + if not store.batch or store.capacity < count then + store.batch = love.graphics.newSpriteBatch(sheet.image, count, "dynamic") + store.capacity = count + end + local batch = store.batch + batch:clear() + -- Image draws store their colour as bytes in the vertex buffer. Use + -- the same byte conversion here, including fractional EVA/16, instead + -- of applying a float constant colour to the finished SpriteBatch. + batch:setColor(r, g, b, a) + for y = sy, ch, 64 do + for x = sx, cw, 64 do + if type(q) == "table" then + for _, part in ipairs(q) do batch:add(part[1], x + part[2], y + part[3]) end + else batch:add(q, x, y) end + end + end + store.key = key + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(store.batch) + love.graphics.setColor(r, g, b, a) + return + end for y = sy, ch, 64 do for x = sx, cw, 64 do drawSheetFrame(sheet, frame, 8, 8, x, y) @@ -1988,17 +2021,40 @@ local function drawPriority1(A, ox, oy, cw, ch) end -- pokeemerald/src/field_weather.c:459 ApplyColorMap -local function colorMapPass(A, cw, ch) +local function colorMapPass(A, cw, ch, exchangeCanvas) if S.colorMapIndex == 0 or not A.shader then return end local cur = love.graphics.getCanvas() if not cur then return end cw, ch = cur:getWidth(), cur:getHeight() local tmp = R._tmpCanvas if not tmp or tmp:getWidth() ~= cw or tmp:getHeight() ~= ch then - tmp = love.graphics.newCanvas(cw, ch) + if tmp and tmp.release then tmp:release() end + tmp = love.graphics.newCanvas(cw, ch, { dpiscale = cur.getDPIScale and cur:getDPIScale() or 1, + format = cur.getFormat and cur:getFormat() or "normal" }) tmp:setFilter("nearest", "nearest") R._tmpCanvas = tmp end + if exchangeCanvas and not love.graphics.getScissor() and exchangeCanvas(cur, tmp) then + local shader = love.graphics.getShader() + local blend, alpha = love.graphics.getBlendMode() + local r, g, b, a = love.graphics.getColor() + -- A transform-only push leaves ownership of the new target with the + -- caller. push("all") would restore the old canvas and switch twice more. + love.graphics.push() + love.graphics.origin() + love.graphics.setCanvas(tmp) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + colorMapUniforms(A.shader, COLOR_MAP_DARK_CONTRAST) + love.graphics.setShader(A.shader) + love.graphics.draw(cur, 0, 0) + love.graphics.pop() + love.graphics.setShader(shader) + love.graphics.setBlendMode(blend, alpha) + love.graphics.setColor(r, g, b, a) + R._tmpCanvas = cur + return tmp + end love.graphics.push("all") love.graphics.origin() love.graphics.setShader() @@ -2028,7 +2084,7 @@ function R.drawBelow(camX, camY, canvasW, canvasH) love.graphics.pop() end -function R.draw(camX, camY, canvasW, canvasH) +function R.draw(camX, camY, canvasW, canvasH, exchangeCanvas) R.setCamera(camX, camY) if not S.started or not S.initialized then return end local A = loadAssets() @@ -2041,7 +2097,9 @@ function R.draw(camX, camY, canvasW, canvasH) if R._belowFrame ~= S.frames then drawPriority2(A, camX, camY, ox, oy, canvasW, canvasH) end - colorMapPass(A, canvasW, canvasH) + love.graphics.pop() + colorMapPass(A, canvasW, canvasH, exchangeCanvas) + love.graphics.push("all") drawPriority1(A, ox, oy, canvasW, canvasH) love.graphics.pop() love.graphics.setColor(1, 1, 1, 1) diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 0569d55c..dc9b903b 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -156,7 +156,7 @@ function Map.refreshWorld(game, reachW, reachH, rootId) local queue, seen, entries = {}, {}, {} for _, entry in ipairs(Map.world) do local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair)) - if sync and pair and Map.warmNear(entry, x0, y0, x1, y1) then + if sync and pair and not NativeTileset.prefetch and Map.warmNear(entry, x0, y0, x1, y1) then if NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end elseif type(pair) == "string" and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then if not seen[pair] then @@ -170,6 +170,12 @@ function Map.refreshWorld(game, reachW, reachH, rootId) end Map._warmQueue = queue[1] and queue or nil Map._warmEntries = queue[1] and entries or nil + if NativeTileset._stream then + local root = maps[rootId] + local pair = root and (root.pair or (root.midLayout and root.midLayout.pair)) + if pair then seen[pair] = true end + NativeTileset._stream:retain(seen) + end end Map._worldRoot = rootId Map._worldReachW = reachW @@ -190,10 +196,27 @@ function Map.warmNow(game, rootId) local vw = (FieldView and FieldView._viewW) or (Display and Display.W) or 240 local vh = (FieldView and FieldView._viewH) or (Display and Display.H) or 160 Map._warmPairs = nil - return Map.refreshWorld(game, math.ceil(vw / 16), math.ceil(vh / 16), rootId) + local world = Map.refreshWorld(game, math.ceil(vw / 16), math.ceil(vh / 16), rootId) + local Native = package.loaded["src.core.game3.tileset_native"] + local def = host_map_def(game, rootId) + local pair = def and (def.pair or (def.midLayout and def.midLayout.pair)) + if Native and pair and Native.ready(pair) then pcall(Native.get, pair) end + local Ow = package.loaded["src.core.game3.ow_sprites"] + if Ow and Ow.get and Ow.playerGraphicsId then + local gid = Ow.playerGraphicsId(game) + if gid then pcall(Ow.get, gid) end + local Objects = package.loaded["src.core.game3.objects"] + local x0, y0, x1, y1 = Map.warmRect() + for _, eo in ipairs(Objects and Objects.forDraw and Objects.forDraw() or {}) do + local x, y = eo.cellX or 0, eo.cellY or 0 + if eo.graphicsId and (not x0 or (x >= x0 and x <= x1 and y >= y0 and y <= y1)) then pcall(Ow.get, eo.graphicsId) end + end + end + Map.stepWarm(game) + return world end -function Map.warmRect() +function Map.warmRect(margin) local P = package.loaded["src.core.game3.player"] local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY) if not (px and py) then return nil end @@ -202,8 +225,9 @@ function Map.warmRect() local vw = (FieldView and FieldView._viewW) or (Display and Display.W) or 240 local vh = (FieldView and FieldView._viewH) or (Display and Display.H) or 160 local cols, rows = math.ceil(vw / 16), math.ceil(vh / 16) - local mx = math.ceil(cols / 2) + 1 + cols * Map.WARM_MARGIN_SCREENS - local my = math.ceil(rows / 2) + 1 + rows * Map.WARM_MARGIN_SCREENS + margin = margin or Map.WARM_MARGIN_SCREENS + local mx = math.ceil(cols / 2) + 1 + cols * margin + local my = math.ceil(rows / 2) + 1 + rows * margin return px - mx, py - my, px + mx, py + my end @@ -216,7 +240,70 @@ function Map.warmNear(entry, x0, y0, x1, y1) return ox + w > x0 and ox <= x1 and oy + h > y0 and oy <= y1 end -function Map.stepWarm() +function Map.stepWarm(game) + local Stream = package.loaded["src.core.game3.asset_stream"] + local Native = package.loaded["src.core.game3.tileset_native"] + if Native and Native.prefetch then + local x0, y0, x1, y1 = Map.warmRect() + Map._warmScanTick = ((Map._warmScanTick or 0) + 1) % 4 + if Map._warmScanTick ~= 0 and Map._warmScanWorld == Map.world + and Map._warmScanX == x0 and Map._warmScanY == y0 + and Map._warmScanX1 == x1 and Map._warmScanY1 == y1 then + if Stream then Stream.update() end + return true + end + Map._warmScanWorld = Map.world + Map._warmScanX, Map._warmScanY, Map._warmScanX1, Map._warmScanY1 = x0, y0, x1, y1 + local vx0, vy0, vx1, vy1 = Map.warmRect(0) + local wantedPairs = {} + local priorities = {} + local def = Map.currentDef() + local currentPair = def and (def.pair or (def.midLayout and def.midLayout.pair)) + if currentPair then wantedPairs[currentPair], priorities[currentPair] = true, 0 end + local queue = Map._warmQueue or {} + for i = #queue, 1, -1 do + local pair = queue[i] + local near = not (Map._warmEntries and Map._warmEntries[pair]) + for _, entry in ipairs(Map._warmEntries and Map._warmEntries[pair] or {}) do + if Map.warmNear(entry, x0, y0, x1, y1) then near = true end + if Map.warmNear(entry, vx0, vy0, vx1, vy1) then priorities[pair] = 0 end + end + if Native._pairs[pair] then table.remove(queue, i) + elseif near then wantedPairs[pair] = true end + end + if not queue[1] then Map._warmQueue = nil end + if Native._stream then Native._stream:retain(wantedPairs) end + for pair in pairs(wantedPairs) do Native.prefetch(pair, priorities[pair] or 1) end + local Ow = package.loaded["src.core.game3.ow_sprites"] + if game and Ow and Ow.prefetch then + local wanted = {} + local function actor(eo, ox, oy) + if not (eo.visible and not eo.hidden and not eo.invisible) then return end + local x, y = (eo.cellX or 0) + (ox or 0), (eo.cellY or 0) + (oy or 0) + if not x0 or (x >= x0 and x <= x1 and y >= y0 and y <= y1) then + local gid = tonumber(eo.graphicsId) + if gid then + local visible = not vx0 or (x >= vx0 and x <= vx1 and y >= vy0 and y <= vy1) + wanted[gid] = math.min(wanted[gid] or 1, visible and 0 or 1) + end + end + end + local Obj = package.loaded["src.core.game3.objects"] + for _, eo in ipairs(Obj and Obj.forDraw and Obj.forDraw() or {}) do actor(eo) end + local Ghosts = package.loaded["src.core.game3.ghosts"] + for _, entry in ipairs(Map.world or {}) do + if Map.warmNear(entry, x0, y0, x1, y1) then + for _, eo in ipairs(Ghosts and Ghosts.forDraw(entry.id) or {}) do actor(eo, entry.ox, entry.oy) end + end + end + local gid = Ow.playerGraphicsId(game) + if gid then wanted[gid] = 0 end + if Ow._stream then Ow._stream:retain(wanted) end + for id, priority in pairs(wanted) do Ow.prefetch(id, priority) end + end + if Stream then Stream.update() end + return true + end local queue = Map._warmQueue if not queue then return false end local NativeTileset = package.loaded["src.core.game3.tileset_native"] diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index c1e20500..434519b8 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -4,7 +4,13 @@ local Extract = require("src.import.gba.extract_island1") local OwExtract = require("src.import.gba.ow_extract") local Versions = require("src.import.gba.versions") +local Stream = require("src.core.game3.asset_stream") local OwSprites = {} +local makeStream +local function resetStream() + if OwSprites._stream then OwSprites._stream:cancel() end + OwSprites._stream = OwSprites._cache and makeStream() or nil +end OwSprites._cache = nil OwSprites._manifest = nil @@ -34,22 +40,35 @@ local function owRoot() return (Extract.CACHE_ROOT or "data/generated/gba") .. "/ow" end +local function loadManifest() + OwSprites._manifestAttempted = true + local src = OwSprites._cache and OwSprites._cache:read(owRoot() .. "/manifest.lua") + local chunk = src and load(src, "@ow/manifest.lua", "t", {}) + OwSprites._manifest = chunk and chunk() or nil +end + +local function resetPalettes(changed) + if changed then OwSprites._overrides = {} end + for _, o in pairs(OwSprites._overrides or {}) do o.spr = nil end +end + function OwSprites.install(cache) + resetPalettes(OwSprites._cache ~= cache or OwSprites._root ~= owRoot()) + OwSprites._root = owRoot() OwSprites._cache = cache OwSprites._loaded = {} + resetStream() OwSprites._reflectionLoaded = {} OwSprites._manifest = nil OwSprites._logged = false - if not cache then return end - local src = cache:read(owRoot() .. "/manifest.lua") - if src then - local chunk = load(src, "@ow/manifest.lua", "t", {}) - if chunk then OwSprites._manifest = chunk() end - end + loadManifest() end function OwSprites.invalidate() + resetPalettes(false) + OwSprites._manifestAttempted = false OwSprites._loaded = {} + resetStream() OwSprites._reflectionLoaded = {} OwSprites._manifest = nil OwSprites._logged = false @@ -62,69 +81,33 @@ function OwSprites.ready() return OwExtract.ready(cache, Extract.CACHE_ROOT or "data/generated/gba") end -local function load_one(gid) - local cache = OwSprites._cache - if not cache then return nil end - local root = owRoot() - local metaBlob = cache:read(root .. "/" .. gid .. ".meta") - local rgba = cache:read(root .. "/" .. gid .. ".rgba") - if not metaBlob or not rgba then return nil end - local meta = OwExtract.decodeMeta(metaBlob) - if not meta then return nil end +local function uploadSprite(meta) + local image = love.graphics.newImage(meta.imageData) + image:setFilter("nearest", "nearest") + coroutine.yield("texture") local w, h, n = meta.width, meta.height, meta.frameCount - -- decodeMeta reads these as u16 without validating, so a corrupt .meta can - -- carry 65535 for any of them and `aw * ah` then reaches ~4.3e9 pixels on a - -- cache file in the user-writable save directory. Bound them before sizing. - local MAX_FRAME_DIM, MAX_FRAMES = 256, 512 - if type(w) ~= "number" or type(h) ~= "number" or type(n) ~= "number" - or w < 1 or h < 1 or n < 1 - or w > MAX_FRAME_DIM or h > MAX_FRAME_DIM or n > MAX_FRAMES then - return nil - end - local aw, ah = w, h * n - if #rgba ~= aw * ah * 4 then - if #rgba < aw * h * 4 then return nil end - end - if not (love and love.image and love.graphics) then return nil end - - local ok, imageData = pcall(love.image.newImageData, aw, ah, "rgba8", rgba) - if not ok or not imageData then - imageData = love.image.newImageData(aw, ah) - local i = 1 - for y = 0, ah - 1 do - for x = 0, aw - 1 do - local r = (rgba:byte(i) or 0) / 255 - local g = (rgba:byte(i + 1) or 0) / 255 - local b = (rgba:byte(i + 2) or 0) / 255 - local a = (rgba:byte(i + 3) or 0) / 255 - imageData:setPixel(x, y, r, g, b, a) - i = i + 4 - end - end - end - local image = love.graphics.newImage(imageData) - if image.setFilter then image:setFilter("nearest", "nearest") end local quads = {} for fi = 0, n - 1 do - quads[fi] = love.graphics.newQuad(0, fi * h, w, h, aw, ah) + quads[fi] = love.graphics.newQuad(0, fi * h, w, h, w, h * n) + if fi % 32 == 31 then coroutine.yield("quads") end end - return { - image = image, - -- Kept so setObjectPalette can recolour the sheet: LOVE 11 has no - -- Image:newImageData, so the decoded pixels are the only CPU-side source. - imageData = imageData, - quads = quads, - width = w, - height = h, - frameCount = n, - inanimate = meta.inanimate, - paletteTag = meta.paletteTag, - reflectionPaletteTag = meta.reflectionPaletteTag, - reflectionPaletteMappedTag = meta.reflectionPaletteMappedTag, - palette = meta.palette, - reflectionPalette = meta.reflectionPalette, - mappedReflectionPalette = meta.mappedReflectionPalette, - } + meta.image, meta.quads = image, quads + meta.bytes = nil + return meta +end +makeStream = function() + return Stream.new("sprite", OwSprites._cache, owRoot(), uploadSprite, function(gid, spr) + OwSprites._loaded[gid] = spr + if not OwSprites._logged then + print(string.format("[game3/ow] sprites ready (%s sheets)", tostring(OwSprites._manifest and OwSprites._manifest.count or "?"))) + OwSprites._logged = true + end + end) +end + +function OwSprites.prefetch(gid, priority) + gid = tonumber(gid) + if gid and not OwSprites._loaded[gid] and OwSprites._stream then OwSprites._stream:prefetch(gid, priority) end end function OwSprites.get(graphicsId) @@ -132,20 +115,8 @@ function OwSprites.get(graphicsId) if graphicsId == nil then return nil end local cached = OwSprites._loaded[graphicsId] if cached then return cached end - if not OwSprites._manifest then - OwSprites.install(OwSprites._cache) - end - local spr = load_one(graphicsId) - if spr then - OwSprites._loaded[graphicsId] = spr - if not OwSprites._logged then - local n = OwSprites._manifest and OwSprites._manifest.count - print(string.format("[game3/ow] FRLG sprites ready (%s sheets)", - tostring(n or "?"))) - OwSprites._logged = true - end - end - return spr + if not OwSprites._manifestAttempted then loadManifest() end + return OwSprites._stream and OwSprites._stream:get(graphicsId) or nil end -- src/data/object_events/object_event_anims.h:633 @@ -325,7 +296,7 @@ function OwSprites.setObjectPalette(graphicsId, key, colours, sourceColours) graphicsId = tonumber(graphicsId) if graphicsId == nil or type(key) ~= "string" then return false end local current = OwSprites._overrides[graphicsId] - if current and current.key == key then return true end + if current and current.key == key and current.spr then return true end if type(colours) ~= "table" or #colours == 0 then return false end local base = OwSprites.get(graphicsId) @@ -337,7 +308,7 @@ function OwSprites.setObjectPalette(graphicsId, key, colours, sourceColours) end local spr = recolour_sprite(base, from, colours) if not spr then return false end - OwSprites._overrides[graphicsId] = { key = key, spr = spr } + OwSprites._overrides[graphicsId] = { key = key, spr = spr, colours = colours, sourceColours = from } return true end @@ -426,6 +397,10 @@ function OwSprites.getDraw(graphicsId) graphicsId = tonumber(graphicsId) if graphicsId == nil then return nil end local ov = OwSprites._overrides and OwSprites._overrides[graphicsId] + if ov and not ov.spr then + OwSprites.setObjectPalette(graphicsId, ov.key, ov.colours, ov.sourceColours) + ov = OwSprites._overrides[graphicsId] + end if ov and ov.spr then return ov.spr end return OwSprites.get(graphicsId) end @@ -493,9 +468,7 @@ function OwSprites.draw(graphicsId, px, py, camX, camY, facing, walkPhase, stepF end function OwSprites.avatars() - if not OwSprites._manifest and OwSprites._cache then - OwSprites.install(OwSprites._cache) - end + if not OwSprites._manifestAttempted and OwSprites._cache then loadManifest() end local m = OwSprites._manifest return m and m.avatars or nil end diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index c594afeb..8855353a 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -27,6 +27,17 @@ local function stdString(id) end Adapters.stdString = stdString +-- pokeemerald/src/scrcmd.c:1599 StringCopy(..., gDecorations[decorId].name) +local function decorationName(src) + local ok, DecorInv = pcall(require, "src.core.game3.rse.decoration_inventory") + if not ok or type(DecorInv) ~= "table" or not DecorInv.info then return nil end + local okInfo, info = pcall(DecorInv.info, src) + if okInfo and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then + return info.name + end + return nil +end + -- pokefirered/src/event_object_movement.c:5208 GetOppositeDirection local OPPOSITE_DIR = { down = "up", up = "down", left = "right", right = "left" } @@ -161,6 +172,9 @@ function Adapters.stub(opts) if op == "bufferstdstring" then return stdString(src) end + if op == "bufferdecorationname" then + return decorationName(src) + end return nil end a.leadMonName = opts.leadMonName or function() return "POKéMON" end @@ -1527,6 +1541,9 @@ function Adapters.host(mod, game, world) if op == "bufferstdstring" then return stdString(src) or tostring(src) end + if op == "bufferdecorationname" then + return decorationName(src) + end if op == "bufferpartymonnick" then local Runtime = package.loaded["src.core.game3.runtime"] local party = Runtime and Runtime.session and Runtime.session.party diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua index c64bcf8a..5d6870d6 100644 --- a/src/core/game3/tileset_anim.lua +++ b/src/core/game3/tileset_anim.lua @@ -295,11 +295,11 @@ local function load_bank(cache, pair, kind, info) end -- pokefirered/src/tileset_anims.c:223 -function TilesetAnim.bindPair(pair, atlas) +function TilesetAnim.bindPair(pair, atlas, prepared) if not Versions.NATIVE_RENDER or Versions.TILESET_ANIM == false then return false end - local cache = TilesetAnim._cache + local cache = prepared and { read = function(_, rel) return prepared[rel] end } or TilesetAnim._cache if not cache or not pair or not atlas then return false end local entry = TilesetAnim._pairs[pair] if entry == false then return false end @@ -326,9 +326,13 @@ function TilesetAnim.bindPair(pair, atlas) end TilesetAnim._visible[pair] = true if entry.rse then return true end - TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame) - TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame) - TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame) + local dirty = false + if TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame, true) then dirty = true end + if TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame, true) then dirty = true end + if TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame, true) then dirty = true end + -- Initial/catch-up preparation uploads once after all CPU patches. This + -- also keeps a warming pair's final step within one texture submission. + if dirty then NativeTileset().flush(atlas, true, false) end return true end @@ -370,7 +374,7 @@ local function get_frame_piece(bank, frame, mi, srcOff) return nil end -function TilesetAnim._applyKind(entry, kind, frame) +function TilesetAnim._applyKind(entry, kind, frame, deferUpload) local bank = entry.banks[kind] if not bank or bank.frames < 1 then return end frame = frame % bank.frames @@ -411,6 +415,7 @@ function TilesetAnim._applyKind(entry, kind, frame) end end end + if deferUpload then return true end if partial then NativeTileset().flush(ts, true, false) elseif ts.image and love and love.graphics then diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua index d314a040..5e3a8beb 100644 --- a/src/core/game3/tileset_native.lua +++ b/src/core/game3/tileset_native.lua @@ -3,7 +3,7 @@ local Extract = require("src.import.gba.extract_island1") local NativePack = require("src.import.gba.native_pack") -local Palette = require("src.core.game3.palette") +local Stream = require("src.core.game3.asset_stream") local Versions = require("src.import.gba.versions") local NativeTileset = {} @@ -13,7 +13,12 @@ NativeTileset._cache = nil NativeTileset._logged = {} NativeTileset._ready = {} -local NATIVE = Extract.NATIVE_ROOT or (Extract.CACHE_ROOT .. "/native") +local makeStream +local function nativeRoot() return Extract.NATIVE_ROOT or (Extract.CACHE_ROOT .. "/native") end +local function resetStream() + if NativeTileset._stream then NativeTileset._stream:cancel() end + NativeTileset._stream = NativeTileset._cache and makeStream() or nil +end local function log(msg) print("[game3/native] " .. tostring(msg)) @@ -24,6 +29,7 @@ function NativeTileset.install(cache, _bundle) NativeTileset._pairs = {} NativeTileset._logged = {} NativeTileset._ready = {} + resetStream() local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim") if okA and TilesetAnim and TilesetAnim.install then TilesetAnim.install(cache) @@ -34,134 +40,21 @@ function NativeTileset.invalidate() NativeTileset._pairs = {} NativeTileset._logged = {} NativeTileset._ready = {} + resetStream() local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim") if okA and TilesetAnim and TilesetAnim.invalidate then TilesetAnim.invalidate() end end -local function rgba_to_image(rgba, w, h) - if not (love and love.image and love.image.newImageData) then - return nil, nil, "love.image unavailable" - end - local ok, imageData = pcall(love.image.newImageData, w, h, "rgba8", rgba) - if not ok or not imageData then - ok, imageData = pcall(function() - local id = love.image.newImageData(w, h) - if id.setString then - id:setString(rgba) - end - return id - end) - end - if not ok or not imageData then - imageData = love.image.newImageData(w, h) - local i = 1 - for y = 0, h - 1 do - for x = 0, w - 1 do - local r = rgba:byte(i) or 0 - local g = rgba:byte(i + 1) or 0 - local b = rgba:byte(i + 2) or 0 - local a = rgba:byte(i + 3) or 255 - imageData:setPixel(x, y, r / 255, g / 255, b / 255, a / 255) - i = i + 4 - end - end - end - local image = love.graphics.newImage(imageData) - if image.setFilter then image:setFilter("nearest", "nearest") end - return image, imageData -end - -local function bake_or_load(cache, pair, idxTbl, rgb, hash, tag, transparentZero) - local w = (idxTbl.atlasCols or 16) * 16 - local h = (idxTbl.atlasRows or 1) * 16 - local rgbaRel = NATIVE .. "/" .. pair .. "/atlas_" .. tag .. "_" .. hash .. ".rgba" - local rgba = cache:read(rgbaRel) - if not rgba or #rgba ~= w * h * 4 then - rgba, w, h = NativePack.bakeRgba(idxTbl, rgb, { transparentZero = transparentZero }) - pcall(function() cache:write(rgbaRel, rgba) end) - end - return rgba_to_image(rgba, w, h) -end - -local function load_pair(cache, pair) - local idxBlob = cache:read(NATIVE .. "/" .. pair .. "/mids.idx") - local palBlob = cache:read(NATIVE .. "/" .. pair .. "/palettes.bin") - if not idxBlob or not palBlob then - return nil, "missing native blobs for " .. tostring(pair) - end - local idxTbl, ierr = NativePack.decodeIdx(idxBlob) - if not idxTbl then return nil, ierr end - local rgb, bgr = Palette.load(palBlob) - if not rgb then return nil, bgr end - - local hash = Palette.hash(bgr, idxBlob) - local layered = cache:exists(NATIVE .. "/" .. pair .. "/mids_over.idx") - local tag = layered and "u" or "flat" - local image, imageData, err = bake_or_load(cache, pair, idxTbl, rgb, hash, tag, false) - if not image then return nil, err or "bake under failed" end - - local midToSlot = {} - for i, mid in ipairs(idxTbl.midIds or {}) do - midToSlot[mid] = i - 1 - end - - local cols = idxTbl.atlasCols or 16 - if (idxTbl.atlasRows or 0) * 16 > 2048 then - log("WARNING atlas rows large for " .. pair) - end - - local ts = { - pair = pair, - image = image, - imageData = imageData, - midToSlot = midToSlot, - cols = cols, - rows = idxTbl.atlasRows or 1, - midCount = idxTbl.midCount or 0, - quads = {}, - layered = false, - overImage = nil, - overImageData = nil, - overQuads = {}, - idxBlob = idxBlob, - overBlob = nil, - bgr = bgr, - slotPix = {}, - } - - if layered then - local overBlob = cache:read(NATIVE .. "/" .. pair .. "/mids_over.idx") - local overTbl = overBlob and NativePack.decodeIdx(overBlob) - if overTbl then - local overHash = Palette.hash(bgr, overBlob) - local oImg, oData = bake_or_load(cache, pair, overTbl, rgb, overHash, "o", true) - if oImg then - ts.layered = true - ts.overImage = oImg - ts.overImageData = oData - ts.overBlob = overBlob - end - end - local midBlob = cache:read(NATIVE .. "/" .. pair .. "/mids_mid.idx") - local midTbl = midBlob and NativePack.decodeIdx(midBlob) - if midTbl then - ts.midImage = bake_or_load(cache, pair, midTbl, rgb, Palette.hash(bgr, midBlob), "m", true) - end - end - - return ts -end - function NativeTileset.ready(pair) if not Versions.NATIVE_RENDER then return false end if not pair then return false end if NativeTileset._pairs[pair] or NativeTileset._ready[pair] then return true end local cache = NativeTileset._cache if not cache then return false end - local ok = cache:exists(NATIVE .. "/" .. pair .. "/mids.idx") - and cache:exists(NATIVE .. "/" .. pair .. "/palettes.bin") + local ok = cache:exists(nativeRoot() .. "/" .. pair .. "/mids.idx") + and cache:exists(nativeRoot() .. "/" .. pair .. "/palettes.bin") if ok then NativeTileset._ready[pair] = true end return ok end @@ -175,40 +68,55 @@ local function tilesetAnim() return animMod or nil end -local function bind_anim(pair, atlas) +local function bind_anim(pair, atlas, prepared) local TilesetAnim = tilesetAnim() if TilesetAnim and TilesetAnim.bindPair then - TilesetAnim.bindPair(pair, atlas) + TilesetAnim.bindPair(pair, atlas, prepared) + end +end + +local function uploadPair(data) + local ts = { pair = data.pair, midToSlot = data.midToSlot, cols = data.cols, rows = data.rows, + midCount = data.midCount, layered = data.layered or false, idxBlob = data.idxBlob, + overBlob = data.overBlob, bgr = data.bgr, quads = {}, overQuads = {}, slotPix = {}, preparedAnim = data.animFiles } + for _, layer in ipairs(data.layers) do + local image = love.graphics.newImage(layer.data) + image:setFilter("nearest", "nearest") + ts[layer.imageKey], ts[layer.dataKey] = image, layer.data + coroutine.yield("texture") + end + return ts +end +makeStream = function() + return Stream.new("pair", NativeTileset._cache, nativeRoot(), uploadPair, function(pair, ts) + NativeTileset._pairs[pair] = ts + bind_anim(pair, ts, ts.preparedAnim) + ts.preparedAnim = nil + if not NativeTileset._logged[pair] then + log(string.format("atlas ready pair=%s mids=%d %dx%d layered=%s", pair, + ts.midCount, ts.cols * 16, ts.rows * 16, tostring(ts.layered))) + NativeTileset._logged[pair] = true + end + end) +end + +function NativeTileset.prefetch(pair, priority) + if type(pair) == "string" and not NativeTileset._pairs[pair] and NativeTileset._stream then + NativeTileset._stream:prefetch(pair, priority) end end function NativeTileset.get(pair) if not pair then return nil end local cached = NativeTileset._pairs[pair] - if cached then - -- Cheap for a bound pair (no manifest read): marks it visible and - -- catches its atlas up to the current anim frames before a draw. - -- FieldView reads cached atlases directly and does not come here per frame. - bind_anim(pair, cached) - return cached - end - local cache = NativeTileset._cache - if not cache then return nil end - local ts, err = load_pair(cache, pair) - if not ts then - if not NativeTileset._logged[pair] then - log("load failed " .. tostring(pair) .. ": " .. tostring(err)) - NativeTileset._logged[pair] = true - end - return nil - end - NativeTileset._pairs[pair] = ts - if not NativeTileset._logged[pair] then - log(string.format("atlas ready pair=%s mids=%d %dx%d layered=%s", - pair, ts.midCount, ts.cols * 16, ts.rows * 16, tostring(ts.layered))) + if cached then bind_anim(pair, cached); return cached end + local stream = NativeTileset._stream + if not stream then return nil end + local ts, err = stream:get(pair) + if not ts and not NativeTileset._logged[pair] then + log("load failed " .. tostring(pair) .. ": " .. tostring(err)) NativeTileset._logged[pair] = true end - bind_anim(pair, ts) return ts end diff --git a/src/import/gba/native_pack.lua b/src/import/gba/native_pack.lua index 4febe655..885d9011 100644 --- a/src/import/gba/native_pack.lua +++ b/src/import/gba/native_pack.lua @@ -235,6 +235,7 @@ function NativePack.bakeRgba(idxTbl, rgbPals, opts) local pixels = idxTbl.pixels or {} local rowChunks = {} for ay = 0, h - 1 do + if ay % 16 == 0 and opts.cancelled and opts.cancelled() then return nil, w, h end local midRow = math.floor(ay / 16) local py = ay % 16 local line = {} diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index e5daa3d3..eeed022f 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -496,6 +496,15 @@ function Renderer:endWorldPass() love.graphics.setCanvas(self.canvas) end +-- A field compositor may consume the current world into another compatible +-- target. Its caller continues drawing into the replacement until endWorldPass. +function Renderer:exchangeWorldCanvas(current, replacement) + if self.worldCanvas ~= current or not self.worldActive then return false end + if current:getWidth() ~= replacement:getWidth() or current:getHeight() ~= replacement:getHeight() then return false end + self.worldCanvas = replacement + return true +end + -- Tilt mode's upright pass: standing things (sprites, tall-grass feet -- overdraw, screen-anchored FX) draw here instead of into the ground -- world canvas, each already projected to its ground anchor and colorized diff --git a/tests/drivers/game3_asset_stream_profile.lua b/tests/drivers/game3_asset_stream_profile.lua new file mode 100644 index 00000000..502d6dab --- /dev/null +++ b/tests/drivers/game3_asset_stream_profile.lua @@ -0,0 +1,108 @@ +local U = require("tests.drivers.util") +local function stats(v) + table.sort(v) + return string.format("n=%d p99=%.3f max=%.3f", #v, v[math.max(1, math.ceil(#v * .99))] or 0, v[#v] or 0) +end +return function(game) + for _ = 1, 900 do if game.boot then break end U.wait(1) end + local Native = require("src.core.game3.tileset_native") + local Ow = require("src.core.game3.ow_sprites") + local Map = require("src.core.game3.map") + local family = require("src.core.GameVersion").get() + local records = {} + for _, spec in ipairs({ { Native, "get", "pair" }, { Ow, "get", "sprite" }, { Map, "load", "map" } }) do + local owner, key, label = unpack(spec) + local fn = owner[key] + owner[key] = function(...) + local args = { ... } + local t = love.timer.getTime() + local a, b = fn(...) + local ms = (love.timer.getTime() - t) * 1000 + if ms > 1 then print(string.format("ASSET_STALL %s id=%s ms=%.3f", label, tostring(args[label == "map" and 3 or 1]), ms)) end + records[label] = records[label] or {}; records[label][#records[label] + 1] = ms + return a, b + end + end + game:_handleBootAction({ action = "new_game", name = "STREAM", gender = 0 }) + U.wait(60) + local map = family == "emerald" and "EM_ROUTE111" or "FR_CELADON_CITY" + Map.load(nil, game, map, { x = 10, y = 10 }) + U.wait(120) + local pairsToTest = {} + for pair in pairs(Native._pairs) do pairsToTest[#pairsToTest + 1] = pair end + table.sort(pairsToTest) + local spritesToTest = {} + for gid in pairs(Ow._loaded) do spritesToTest[#spritesToTest + 1] = gid end + table.sort(spritesToTest) + local base = Native._cache + local cold = { read = function(_, rel) if rel:find("/atlas_", 1, true) then return nil end return base:read(rel) end, + write = function() return true end, exists = function(_, rel) return base:exists(rel) end } + local savedUpdate, savedDraw = game.update, game.draw + game.update, game.draw = function() end, function() end + if os.getenv("ASSET_COLD_WORKER") == "1" and Native.prefetch then + assert(love.filesystem.getIdentity() == os.getenv("POKEPORT_IDENTITY"), "use a disposable driver identity") + Native.install(base) + local Stream = require("src.core.game3.asset_stream") + for _, pair in ipairs(pairsToTest) do + local root = require("src.import.gba.extract_island1").NATIVE_ROOT + local spec = assert(base:assetWorkerSpec(root, "pair", pair)) + assert(not spec.directory, "cold worker benchmark requires a disposable save-dir cache") + local dir = spec.prefix .. root .. "/" .. pair + for _, file in ipairs(love.filesystem.getDirectoryItems(dir)) do + if file:match("^atlas_.*%.rgba$") then assert(love.filesystem.remove(dir .. "/" .. file)) end + end + Native.prefetch(pair) + local deadline = love.timer.getTime() + 20 + while not Native._stream:decoded(pair) and love.timer.getTime() < deadline do Stream.poll(); U.wait(1) end + assert(Native._stream:decoded(pair), pair) + local data = Native._stream.pending[pair].data + local reference = assert(require("src.core.game3.asset_decode").pair(base, root, pair)) + for i, layer in ipairs(data.layers) do + assert(layer.data:getString() == reference.layers[i].data:getString(), "worker RGBA mismatch " .. pair) + end + print("PASS worker cold RGBA parity " .. pair) + while not Native._pairs[pair] do Stream.frameComplete(); Stream.update(); U.wait(1) end + end + end + for _, mode in ipairs({ "cold_rgba_cache", "disk_rgba_cache", "decoded", "gpu_warm" }) do + local v = {} + if mode ~= "gpu_warm" then Native.install(mode == "cold_rgba_cache" and cold or base) end + for _, pair in ipairs(pairsToTest) do + if mode == "decoded" and Native.prefetch then + Native.prefetch(pair) + local Stream = require("src.core.game3.asset_stream") + local deadline = love.timer.getTime() + 20 + while not Native._stream:decoded(pair) and love.timer.getTime() < deadline do Stream.poll(); U.wait(1) end + assert(Native._stream:decoded(pair), "worker failed to prepare " .. pair) + end + local t = love.timer.getTime(); assert(Native.get(pair), pair) + local ms = (love.timer.getTime() - t) * 1000 + v[#v + 1] = ms + print(string.format("ASSET_PAIR mode=%s id=%s ms=%.3f", mode, pair, ms)) + end + print("ASSET_PROFILE " .. mode .. " " .. stats(v)) + end + local spriteCache = Ow._cache + for _, mode in ipairs({ "disk_rgba_cache", "decoded", "gpu_warm" }) do + if mode ~= "gpu_warm" then Ow.install(spriteCache) end + local v = {} + for _, gid in ipairs(spritesToTest) do + if mode == "decoded" and Ow.prefetch then + Ow.prefetch(gid) + local Stream = require("src.core.game3.asset_stream") + local deadline = love.timer.getTime() + 20 + while not Ow._stream:decoded(gid) and love.timer.getTime() < deadline do Stream.poll(); U.wait(1) end + assert(Ow._stream:decoded(gid), "worker failed to prepare sprite " .. gid) + end + local t = love.timer.getTime(); assert(Ow.get(gid), gid) + local ms = (love.timer.getTime() - t) * 1000 + v[#v + 1] = ms + print(string.format("ASSET_SPRITE mode=%s id=%s ms=%.3f", mode, gid, ms)) + end + print("ASSET_PROFILE sprite_" .. mode .. " " .. stats(v)) + end + game.update, game.draw = savedUpdate, savedDraw + for label, v in pairs(records) do print("ASSET_PROFILE " .. label .. " " .. stats(v)) end + print("PASS game3_asset_stream_profile") + love.event.quit(0) +end diff --git a/tests/drivers/game3_seam_stream_profile.lua b/tests/drivers/game3_seam_stream_profile.lua new file mode 100644 index 00000000..6245c0c9 --- /dev/null +++ b/tests/drivers/game3_seam_stream_profile.lua @@ -0,0 +1,32 @@ +local run = require("tests.drivers.game3_seam_walk_stress") +local function stats(v) + table.sort(v) + local sum = 0; for _, n in ipairs(v) do sum = sum + n end + return string.format("n=%d mean=%.3f p99=%.3f max=%.3f", #v, sum / math.max(1, #v), v[math.ceil(#v * .99)] or 0, v[#v] or 0) +end +return function(game) + local samples = { update = {}, draw = {}, seam = {}, pair = {}, sprite = {} } + local function wrap(owner, key, label, onlySeams) + local old = owner[key] + owner[key] = function(...) + local t = love.timer.getTime() + local a, b, c = old(...) + local ms = (love.timer.getTime() - t) * 1000 + local args = { ... } + if not onlySeams or (args[4] and args[4].seamless) then + local v = samples[label]; v[#v + 1] = ms + if (label == "pair" or label == "sprite") and ms > 1 then + print(string.format("SEAM_ASSET_STALL %s id=%s ms=%.3f", label, tostring(args[1]), ms)) + end + end + return a, b, c + end + end + wrap(game, "update", "update") + wrap(game, "draw", "draw") + wrap(require("src.core.game3.map"), "load", "seam", true) + wrap(require("src.core.game3.tileset_native"), "get", "pair") + wrap(require("src.core.game3.ow_sprites"), "get", "sprite") + run(game) + for label, v in pairs(samples) do print("SEAM_PROFILE " .. label .. " " .. stats(v)) end +end diff --git a/tests/drivers/game3_seam_walk_stress.lua b/tests/drivers/game3_seam_walk_stress.lua index acb3c670..642afec2 100644 --- a/tests/drivers/game3_seam_walk_stress.lua +++ b/tests/drivers/game3_seam_walk_stress.lua @@ -335,6 +335,9 @@ return function(game) patch(Field, "tryWalkIntoSign", function() return false end) patch(TrainerSight, "check", function() return false end) patch(ForcedMovement, "onStepFinished", function() return false end) + -- Keep Cycling Road's automatic downhill steps from restarting movement + -- during stepOnce's settling window, like the other forced paths above. + patch(Player, "cyclingRoadPull", function() return false end) patch(StepEvents, "onStepTaken", function() return false end) patch(Encounters, "onStep", function() return nil end) patch(Encounters, "noteGrass", function() return nil end) diff --git a/tests/drivers/game3_weather_compositor_profile.lua b/tests/drivers/game3_weather_compositor_profile.lua new file mode 100644 index 00000000..8e067665 --- /dev/null +++ b/tests/drivers/game3_weather_compositor_profile.lua @@ -0,0 +1,132 @@ +local U = require("tests.drivers.util") +local function stats(v) + table.sort(v) + local sum = 0; for _, n in ipairs(v) do sum = sum + n end + return string.format("n=%d mean=%.3f p99=%.3f max=%.3f", #v, sum / #v, v[math.ceil(#v * .99)], v[#v]) +end +return function(game) + for _ = 1, 900 do if game.boot then break end U.wait(1) end + game:_handleBootAction({ action = "new_game", name = "WEATHER", gender = 0 }) + U.wait(60) + local Map = require("src.core.game3.map") + Map.load(nil, game, "EM_PETALBURG_CITY", { x = 17, y = 13 }) + U.wait(90) + local R = require("src.core.game3.field_weather_rse") + local baselinePath = os.getenv("WEATHER_BASELINE") + local baseline = baselinePath and assert(love.filesystem.load(baselinePath))() or nil + local Field = require("src.core.game3.field_view") + local Renderer = require("src.render.Renderer") + local Rng = require("src.core.game3.rng") + local Oam = require("src.core.game3.oam") + local failures = 0 + local function check(ok, label) print((ok and "PASS " or "FAIL ") .. label); if not ok then failures = failures + 1 end end + local saveUpdate, saveDraw = game.update, game.draw + game.update, game.draw = function() end, function() end + local function weather(engine, id, frames) + engine.restart(); Rng._value = 42 + engine.setCurrentAndNextWeatherNoDelay(id) + for _ = 1, frames do engine.update() end + end + local function render(engine, w, h, swap) + package.loaded["src.core.game3.field_weather_rse"] = engine + Oam.resetFrame() + local canvas = Renderer.worldCanvas + if not canvas or canvas:getWidth() ~= w or canvas:getHeight() ~= h then + canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 }) + end + Renderer.worldCanvas, Renderer.worldActive = canvas, true + love.graphics.setCanvas(canvas); love.graphics.clear(.2, .3, .4, 1) + local switches, oldSet = 0, love.graphics.setCanvas + love.graphics.setCanvas = function(...) switches = switches + 1; return oldSet(...) end + local t = love.timer.getTime() + Field.draw(game, w, h, { exchangeCanvas = swap and function(cur, next) return Renderer:exchangeWorldCanvas(cur, next) end or nil }) + love.graphics.setCanvas = oldSet + local cpu = (love.timer.getTime() - t) * 1000 + oldSet() + local image = Renderer.worldCanvas:newImageData() + local sync = (love.timer.getTime() - t) * 1000 + return image, cpu, sync, switches + end + for _, size in ipairs({ { "fit", 240, 160 }, { "survey", 960, 640 } }) do + for _, spec in ipairs({ { "clear", 2, 180 }, { "shade", 11, 180 }, { "rain", 3, 180 }, + { "drought", 12, 180 }, { "fog", 6, 180 }, { "sandstorm", 8, 180 }, { "sandstorm_blend", 8, 2 } }) do + weather(R, spec[2], spec[3]) + local copy = render(R, size[2], size[3], false) + local swapped = render(R, size[2], size[3], true) + check(copy:getString() == swapped:getString(), size[1] .. " " .. spec[1] .. " copy vs exchange pixels") + if baseline then + weather(baseline, spec[2], spec[3]) + render(baseline, size[2], size[3], false) + local original = render(baseline, size[2], size[3], false) + if original:getString() ~= swapped:getString() then + local a, b, count, max = original:getString(), swapped:getString(), 0, 0 + for i = 1, #a do local d = math.abs(a:byte(i) - b:byte(i)); if d > 0 then count = count + 1; max = math.max(max, d) end end + print(string.format("WEATHER_DIFF %s %s channels=%d max=%d eva=%d evb=%d", size[1], spec[1], count, max, R.state.currBlendEVA, R.state.currBlendEVB)) + end + check(original:getString() == swapped:getString(), size[1] .. " " .. spec[1] .. " original vs batched exchange pixels") + end + for _, mode in ipairs({ { "copy", baseline or R, false }, { "exchange", R, true } }) do + weather(mode[2], spec[2], spec[3]) + local cpus, synced, switches = {}, {}, {} + for _ = 1, 60 do + local _, cpu, sync, count = render(mode[2], size[2], size[3], mode[3]) + cpus[#cpus + 1], synced[#synced + 1], switches[#switches + 1] = cpu, sync, count + end + print(string.format("WEATHER_PROFILE %s %s %s cpu %s completion_with_readback %s switches=%d", size[1], spec[1], mode[1], + stats(cpus), stats(synced), switches[#switches])) + end + if os.getenv("POKEPORT_SHOT_DIR") then + swapped:encode("png", "weather-preview.png") + local bytes = love.filesystem.read("weather-preview.png") + local f = assert(io.open(os.getenv("POKEPORT_SHOT_DIR") .. "/" .. size[1] .. "_" .. spec[1] .. ".png", "wb")); f:write(bytes); f:close() + love.filesystem.remove("weather-preview.png") + end + end + end + -- Exercise Display's actual target exchange, upright actors, and UI planes. + local Zoom, Tilt = require("src.render.Zoom"), require("src.render.Tilt") + local uprightCalls, beginUpright = 0, Renderer.beginUprightPass + Renderer.beginUprightPass = function(self, ...) + uprightCalls = uprightCalls + 1 + return beginUpright(self, ...) + end + Zoom.allowSurvey = true + if baseline then + for _, zoom in ipairs({ 0, Zoom.offsetRange(Renderer:fitScale()) }) do + game.options.zoom = zoom; Zoom.offset = zoom + for _, tilt in ipairs({ 0, 2 }) do + game.options.tilt = tilt; Tilt.setLevel(tilt); Tilt.update(1) + for _, id in ipairs({ 2, 3, 6, 11, 12, 8 }) do + local function planes(engine) + package.loaded["src.core.game3.field_weather_rse"] = engine + weather(engine, id, 180) + local before = uprightCalls + saveDraw(game); saveDraw(game) + if tilt > 0 then check(uprightCalls == before + 2, "upright pass rendered") end + local out = {} + for _, key in ipairs({ "worldCanvas", "uprightCanvas", "canvas" }) do + local target = Renderer[key] + if target then out[key] = target:newImageData():getString() end + end + return out + end + local a, b = planes(baseline), planes(R) + for key, bytes in pairs(a) do + check(bytes == b[key], string.format("display zoom=%d tilt=%d weather=%d %s pixels", zoom, tilt, id, key)) + end + end + if os.getenv("POKEPORT_SHOT_DIR") then + game.draw = saveDraw + U.still(game, os.getenv("POKEPORT_SHOT_DIR") .. string.format("/display_zoom%d_tilt%d.png", zoom, tilt)) + game.draw = function() end + end + end + end + end + Renderer.beginUprightPass = beginUpright + package.loaded["src.core.game3.field_weather_rse"] = R + game.update, game.draw = saveUpdate, saveDraw + love.graphics.setCanvas() + print((failures == 0 and "PASS" or "FAIL") .. " game3_weather_compositor_profile failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/engine/game3_asset_stream_test.lua b/tests/engine/game3_asset_stream_test.lua new file mode 100644 index 00000000..6921c370 --- /dev/null +++ b/tests/engine/game3_asset_stream_test.lua @@ -0,0 +1,82 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = require("tests.love_stub") +local channels, worker = {}, {} +love.thread = { + newChannel = function() + local ch = { q = {} } + function ch:push(v) self.q[#self.q + 1] = v end + function ch:pop() return table.remove(self.q, 1) end + function ch:clear() self.q = {} end + channels[#channels + 1] = ch + return ch + end, + newThread = function() + function worker:start(input, output) self.input, self.output = input, output end + function worker:getError() return self.error end + function worker:wait() self.joined = true end + return worker + end, +} +local Stream = require("src.core.game3.asset_stream") +local published, uploads = {}, 0 +local cache = { assetWorkerSpec = function() return { prefix = "emerald/" } end } +local function owner() + return Stream.new("pair", cache, "ROOT", function(data) + for _ = 1, data.layers do uploads = uploads + 1; coroutine.yield("texture") end + return data + end, function(key, data) published[key] = data end) +end +local s = owner() +s:prefetch("A"); s:prefetch("A"); Stream.poll() +eq(#worker.input.q, 1, "duplicate requests are coalesced") +local request = worker.input:pop() +eq(request.spec.prefix, "emerald/", "worker receives an immutable cartridge namespace") +eq(uploads, 0, "queuing CPU work does not touch graphics") +worker.output:push({ id = request.id, data = { layers = 3 }, seconds = .01 }) +Stream.update() +eq(uploads, 1, "only one texture layer uploads in an update") +Stream.update(); eq(uploads, 1, "fixed-step catchup cannot upload another layer in the same render frame") +Stream.frameComplete(); Stream.update(); eq(uploads, 2, "next render frame admits the next layer") +check(published.A == nil, "partially uploaded atlases are never published") +local result = s:get("A") +eq(uploads, 3, "unexpected demand finishes the existing partial upload without duplicating textures") +check(published.A == result and result.layers == 3, "fallback publishes the complete atlas") + +s:prefetch("STALE"); Stream.poll(); request = worker.input:pop() +s:cancel() +eq(#request.cancelSignal.q, 1, "cache reset signals active CPU preparation to stop") +worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 }) +Stream.frameComplete(); Stream.update() +check(published.STALE == nil, "cache reset rejects a late result") +eq(uploads, 3, "stale data does not allocate a texture") +local nextOwner = owner() +nextOwner:prefetch("OUTSIDE"); Stream.poll(); request = worker.input:pop() +nextOwner:retain({}) +eq(#request.cancelSignal.q, 1, "leaving the neighborhood interrupts active preparation") +worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 }) +Stream.frameComplete(); Stream.update() +check(published.OUTSIDE == nil, "leaving the warm neighborhood cancels queued work") +for i = 1, 100 do nextOwner:prefetch("LIMIT" .. i) end +local count = 0; for _ in pairs(nextOwner.pending) do count = count + 1 end +eq(count, Stream.MAX_PENDING, "pending assets have a residency bound") +Stream.cancelPending() +check(next(nextOwner.pending) == nil, "field stop cancels all pending requests") +Stream.shutdown() +check(worker.joined, "process teardown joins the worker before filesystem teardown") +local priorityOwner = owner() +priorityOwner:prefetch("NEAR", 1) +priorityOwner:prefetch("VISIBLE", 0) +Stream.poll(); request = worker.input:pop() +eq(request.key, "VISIBLE", "visible assets decode before nearby assets") +worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 }) +Stream.poll(); request = worker.input:pop() +eq(request.key, "NEAR", "nearby preparation follows visible preparation") +worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 }) +Stream.poll() +Stream.frameComplete(); Stream.update() +eq(coroutine.status(priorityOwner.pending.VISIBLE.co), "suspended", "visible decoded assets upload first") +check(priorityOwner.pending.NEAR.co == nil, "nearby uploads wait for the visible upload slice") +Stream.shutdown() +T.finish("game3_asset_stream_test") diff --git a/tests/engine/game3_bufferdecorationname_test.lua b/tests/engine/game3_bufferdecorationname_test.lua new file mode 100644 index 00000000..2ffb78e9 --- /dev/null +++ b/tests/engine/game3_bufferdecorationname_test.lua @@ -0,0 +1,53 @@ +-- pokeemerald/src/scrcmd.c:1599 bufferdecorationname copies gDecorations[id].name. +-- Lanette's givedecoration DECOR_LOTAD_DOLL (99) was printing "99" because +-- bufferName never asked the decoration cache. +-- luajit tests/engine/game3_bufferdecorationname_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local DecorInv = require("src.core.game3.rse.decoration_inventory") +local Adapters = require("src.core.game3.scripting.adapters") +local Vm = require("src.core.game3.scripting.vm") +local Ops = require("src.core.game3.scripting.ops_a") +local Flags = require("src.core.game3.scripting.flags") + +DecorInv.install({ + decorations = { + [99] = { id = 99, name = "LOTAD DOLL", category = 6 }, + }, +}) + +local stub = Adapters.stub({}) +local host = Adapters.host(nil, nil, nil) + +eq(stub.bufferName("bufferdecorationname", 99), "LOTAD DOLL", "stub names decoration 99") +eq(host.bufferName("bufferdecorationname", 99), "LOTAD DOLL", "host names decoration 99") +eq(stub.bufferName("bufferdecorationname", 1), nil, "unknown decoration stays unresolved") + +local vm = Vm.new({ + store = Flags.newStore(), + scripts = { t = { { op = "end" } } }, + adapters = stub, +}) +vm:start("t") +Ops.dispatch(vm, { op = "setvar", [1] = 0x8000, [2] = 99 }) +Ops.dispatch(vm, { op = "bufferdecorationname", dest = 1, src = 0x8000 }) +eq(vm.ctx.stringVars[2], "LOTAD DOLL", "VAR_0x8000 decoration name fills STR_VAR_2") + +local home = os.getenv("HOME") +local cache = home and (home .. "/Library/Application Support/LOVE/pokemon-love2d/emerald/data/generated/gba/decorations/decorations.lua") +local f = cache and io.open(cache, "rb") +if f then + local src = f:read("*a") + f:close() + DecorInv.reset() + local pack = assert(load(src, "@decorations.lua", "t", {}))() + DecorInv.install(pack) + eq(stub.bufferName("bufferdecorationname", 99), "LOTAD DOLL", "emerald cache decoration 99 is LOTAD DOLL") +end + +T.finish("game3_bufferdecorationname_test")