diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9d40fa7b..1ccb3ae8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -697,7 +697,7 @@ end -- newly obtained mons carry the player's OT name/ID (status screen) local function stampOT(save, mon) - save.player.id = save.player.id or math.random(0, 65535) + save.player.id = save.player.id or require("src.core.SaveData").rollTrainerId() mon.ot = mon.ot or save.player.name -- engine/battle/experience.asm:69 if not mon.traded then mon.otId = mon.otId or save.player.id end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index b072e431..adc5bc41 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2469,11 +2469,19 @@ function SaveData.runMigrations(save, modChains, activeMods) return save end +local function rollTrainerId() + if love and love.math and love.math.random then + return love.math.random(0, 65535) + end + return math.random(0, 65535) +end +SaveData.rollTrainerId = rollTrainerId + -- saves from before the trainer ID existed: backfill once on load -- (like the OT backfill for old saves) SaveData.addCoreMigration(1, function(save) if save.player and not save.player.id then - save.player.id = math.random(0, 65535) + save.player.id = rollTrainerId() end end) @@ -3022,9 +3030,10 @@ function SaveData.newGame(boot) facing = facing, name = boot.playerName or "RED", rival = boot.rivalName or "BLUE", - -- 16-bit trainer ID rolled at new game (wPlayerID, filled from - -- hRandomAdd in OakSpeech) - id = math.random(0, 65535), + -- 16-bit trainer ID rolled at new game (wPlayerID). The cart copies + -- hRandomAdd/hRandomSub, which have been advancing since power-on; + -- love.math is the stream that has actually been advancing here. + id = rollTrainerId(), }, flags = {}, inventory = {}, 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..fc31f535 --- /dev/null +++ b/src/core/game3/asset_stream.lua @@ -0,0 +1,211 @@ +-- 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 + +-- CPU results are published on receipt, without consuming a graphics slice. +function Stream.newTask(kind, publish) + local payloads = {} + local cache = { assetWorkerSpec = function(_, _, _, key) + return { task = true, payload = payloads[key] } + end } + local s = Stream.new(kind, cache, "", nil, function(key, data, err) + payloads[key] = nil + publish(key, data, err) + end) + function s:submit(key, payload, priority) + payloads[key] = payload + self:prefetch(key, priority) + payloads[key] = nil + 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") + if job.spec.task then + job.owner.publish(job.key, job.data, job.error) + job.owner.pending[job.key], jobs[job.id], job.cancelled = nil, nil, true + end + 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 + 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 (job.spec.task or decoded < Stream.MAX_DECODED) + 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..5f50d946 --- /dev/null +++ b/src/core/game3/asset_worker.lua @@ -0,0 +1,30 @@ +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 + if job.spec.task then + local prepare = require("src.core.game3." .. (job.kind == "objects" and "object_prepare" or "field_cell_prepare")) + ok, data, err = pcall(prepare[job.kind], job.spec.payload, + function() return job.cancelSignal:getCount() > 0 end) + else + ok, data, err = pcall(D[job.kind], D.cache(job.spec, job.cancelSignal), job.root, job.key) + end + if not ok then err, data = tostring(data), nil end + output:push({ id = job.id, data = data, error = err, + seconds = love.timer.getTime() - start }) + end + +end) +if not running then output:push({ fatal = tostring(fatal) }) end diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 3ff68d80..61363338 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -2611,12 +2611,18 @@ function Engine.collectResidualEvents(_st, adapter) return Residuals.collectEvents(adapter) end +-- pokeemerald/src/battle_script_commands.c:3556 +-- pokefirered/src/battle_script_commands.c:3395 +local function mon_can_battle(mon) + if not mon or (tonumber(mon.hp) or 0) <= 0 then return false end + if require("src.core.game3.pokemon").isEgg(mon) then return false end + return true +end + function Engine.hasLivingMons(party) if not party then return false end for _, mon in ipairs(party) do - if mon and (tonumber(mon.hp) or 0) > 0 then - return true - end + if mon_can_battle(mon) then return true end end return false end @@ -2624,9 +2630,7 @@ end function Engine.nextLivingMonIndex(party, currentIdx) if not party then return nil end for i, mon in ipairs(party) do - if i ~= currentIdx and mon and (tonumber(mon.hp) or 0) > 0 then - return i - end + if i ~= currentIdx and mon_can_battle(mon) then return i end end return nil end diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index 98959418..024b6c52 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 @@ -391,7 +401,7 @@ function Dataset.attachMidLayouts(maps, cache) local decoded = NativePack.decodeMidLayout(blob) if decoded then local pair = (info and info.pair) or def.pair - def.midLayout = LayoutNative.fromDecoded(decoded, mapId, pair) + def.midLayout = LayoutNative.fromDecoded(decoded, mapId, pair, blob) local tw = decoded.trueWidth or decoded.width local th = decoded.trueHeight or decoded.height if tw and tw > 0 then def.width = tw end 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_cell_prepare.lua b/src/core/game3/field_cell_prepare.lua new file mode 100644 index 00000000..94f11554 --- /dev/null +++ b/src/core/game3/field_cell_prepare.lua @@ -0,0 +1,85 @@ +-- Immutable metatile/void resolution; no graphics or mutable field state. +local Prepare = {} +local function sample(layout, x, y) + local dx, dy = x - layout.x0, y - layout.y0 + if dx < 0 or dy < 0 or dx >= layout.w or dy >= layout.h then return nil end + local p = layout.packed + if p then + local ov = p.overrides[y * 1024 + x] + if ov then return ov.mid end + if x >= 0 and y >= 0 and x < p.trueWidth and y < p.trueHeight then + local i = p.off + (y * p.width + x) * 4 + local a, b = p.blob:byte(i, i + 1) + return a + b * 256 + end + return p.borderMids[(y % p.borderHeight) * p.borderWidth + x % p.borderWidth + 1] or 0 + end + local i = (dy * layout.w + dx) * 2 + 1 + local a, b = layout.blob:byte(i, i + 1) + return a + b * 256 +end +local function inside(l, x, y) + return x >= 0 and y >= 0 and x < l.width and y < l.height +end +local function resolve(s, x, y) + local root = s.layouts[1] + if inside(root, x, y) then return sample(root, x, y), root.pair, false end + local function dir(direction) + for i = #s.neighbors, 1, -1 do + local n = s.neighbors[i] + if n.dir == direction then + local l = s.layouts[n.layout] + local nx, ny + if direction == "north" then nx, ny = x - n.offset, l.height + y + elseif direction == "south" then nx, ny = x - n.offset, y - root.height + elseif direction == "west" then nx, ny = l.width + x, y - n.offset + else nx, ny = x - root.width, y - n.offset end + if inside(l, nx, ny) then return sample(l, nx, ny), l.pair or root.pair end + end + end + end + local mid, pair + if y < 0 then mid, pair = dir("north") elseif y >= root.height then mid, pair = dir("south") end + if mid ~= nil then return mid, pair, false end + if x < 0 then mid, pair = dir("west") elseif x >= root.width then mid, pair = dir("east") end + if mid ~= nil then return mid, pair, false end + for _, entry in ipairs(s.world) do + if entry.layout ~= 1 then + local l = s.layouts[entry.layout] + local nx, ny = x - entry.ox, y - entry.oy + if inside(l, nx, ny) then return sample(l, nx, ny), l.pair or root.pair, false end + end + end + return sample(root, x, y), root.pair, true +end +function Prepare.cells(s, cancelled) + local pairs, ids, rows = {}, {}, {} + for y = s.y0, s.y0 + s.rows - 1 do + if cancelled and cancelled() then error("field cell preparation cancelled") end + local row = {} + for x = s.x0, s.x0 + s.cols - 1 do + local mid, pair, void = resolve(s, x, y) + assert(mid ~= nil, "incomplete field snapshot") + local skip = void and s.mode == "black" + if void and s.fill then + local f = s.fill + mid = f.mids[(y % f.h) * f.w + x % f.w + 1] + pair = s.layouts[1].pair + end + if not ids[pair] then pairs[#pairs + 1] = pair; ids[pair] = #pairs end + local id = ids[pair] + row[#row + 1] = string.char(mid % 256, math.floor(mid / 256), id % 256, math.floor(id / 256), + (void and 1 or 0) + (skip and 2 or 0)) + end + rows[#rows + 1] = table.concat(row) + end + return { blob = table.concat(rows), pairs = pairs, x0 = s.x0, y0 = s.y0, cols = s.cols, rows = s.rows } +end +function Prepare.cell(plan, x, y) + local dx, dy = x - plan.x0, y - plan.y0 + if dx < 0 or dy < 0 or dx >= plan.cols or dy >= plan.rows then return nil end + local i = (dy * plan.cols + dx) * 5 + 1 + local lo, hi, p0, p1, flags = plan.blob:byte(i, i + 4) + return lo + hi * 256, plan.pairs[p0 + p1 * 256], flags % 2 == 1, flags >= 2 +end +return Prepare diff --git a/src/core/game3/field_plan.lua b/src/core/game3/field_plan.lua new file mode 100644 index 00000000..b7bda86b --- /dev/null +++ b/src/core/game3/field_plan.lua @@ -0,0 +1,200 @@ +-- Prepare current/near seam cell windows ahead of drawing. Snapshot sampling +-- preserves custom layouts; worker results never contain graphics objects. +local Cells = require("src.core.game3.field_cell_prepare") +local Connections = require("src.core.game3.connections") +local Plan = {} +local records, task = {}, nil +local MARGIN = 4 +local function stamp(layout, def) + local borders = {} + for i, mid in pairs(layout.borderMids or {}) do borders[i] = mid end + return { layout = layout, def = def, defPair = def.pair, revision = layout._revision or 0, cells = layout.cells, overrides = layout.overrides, + width = layout.width, height = layout.height, trueWidth = layout.trueWidth, trueHeight = layout.trueHeight, + borderWidth = layout.borderWidth, borderHeight = layout.borderHeight, borders = borders, + pair = layout.pair, midAt = layout.midAt } +end +local function valid(record, mode) + if record.mode ~= mode then return false end + if record.voidRevision ~= (require("src.core.game3.void_fill")._revision or 0) then return false end + for _, s in ipairs(record.stamps) do + local l = s.layout + if s.def.midLayout ~= l or s.def.pair ~= s.defPair or (l._revision or 0) ~= s.revision or l.cells ~= s.cells or l.overrides ~= s.overrides + or l.width ~= s.width or l.height ~= s.height or l.pair ~= s.pair or l.midAt ~= s.midAt then return false end + if l.trueWidth ~= s.trueWidth or l.trueHeight ~= s.trueHeight or l.borderWidth ~= s.borderWidth + or l.borderHeight ~= s.borderHeight then return false end + local current = l.borderMids or {} + for i, mid in pairs(s.borders) do if current[i] ~= mid then return false end end + for i, mid in pairs(current) do if s.borders[i] ~= mid then return false end end + end + return true +end +local function graphValid(record, Map) + if #record.world ~= #(Map.world or {}) or #record.neighbors ~= #(Map.neighborList or {}) then + Plan._graphMiss = string.format("counts:%d/%d,%d/%d", #record.world, #(Map.world or {}), #record.neighbors, #(Map.neighborList or {})); return false + end + for i, entry in ipairs(Map.world or {}) do + local old = record.world[i] + if old.id ~= entry.id or old.def ~= entry.def or old.ox ~= entry.ox or old.oy ~= entry.oy then + Plan._graphMiss = string.format("world:%s/%s,%s/%s,%s/%s", old.id, entry.id, old.ox, entry.ox, old.oy, entry.oy); return false + end + end + for i, n in ipairs(Map.neighborList or {}) do + local old = record.neighbors[i] + if old.dir ~= n.dir or old.offset ~= n.offset or old.def ~= n.def then + Plan._graphMiss = string.format("neighbor:%s/%s,%s/%s", old.dir, n.dir, old.offset, n.offset); return false + end + end + return true +end +local function contains(s, x, y, w, h) + return x >= s.x0 and y >= s.y0 and x + w <= s.x0 + s.cols and y + h <= s.y0 + s.rows +end +function Plan.invalidate() + if task then task:cancel() end + task, records = nil, {} +end +local function snapshot(game, id, x, y, cols, rows, mode, Native, reachW, reachH) + local Map = require("src.core.game3.map") + local maps = game.data.maps + local def = maps[id] + local root = def and def.midLayout + if not root then return nil end + -- Map.load changes current before the next draw refreshes Map.world. Do not + -- replace a forecast with the previous root's graph during that interval. + local world = id == Map._worldRoot and Map.world or Map.computeWorld(maps, id, Map.WORLD_HOPS, reachW, reachH) + local s = { x0 = x - MARGIN, y0 = y - MARGIN, cols = cols + MARGIN * 2, rows = rows + MARGIN * 2, + mode = mode, layouts = {}, neighbors = {}, world = {} } + local stamps, added, neighborDefs = {}, {}, {} + local function add(d, ox, oy, primary) + local l = d and d.midLayout + if not l then return nil end + local positions = added[l] or {}; added[l] = positions + local pair = l.pair or d.pair or root.pair or def.pair + local key = tostring(ox) .. ":" .. tostring(oy) .. ":" .. tostring(pair) + if positions[key] then return positions[key] end + local x0, y0 = s.x0 - ox, s.y0 - oy + local x1, y1 = x0 + s.cols - 1, y0 + s.rows - 1 + if not primary then + x0, y0 = math.max(0, x0), math.max(0, y0) + x1, y1 = math.min(l.width - 1, x1), math.min(l.height - 1, y1) + end + local w, h = math.max(0, x1 - x0 + 1), math.max(0, y1 - y0 + 1) + local packed = l.workerPacked and l:workerPacked() + local lines = {} + for cy = y0, (packed and y0 - 1 or y0 + h - 1) do + local bytes = {} + for cx = x0, x0 + w - 1 do + local mid = l:midAt(cx, cy) + if type(mid) ~= "number" or mid < 0 or mid > 65535 then return nil end + bytes[#bytes + 1] = string.char(mid % 256, math.floor(mid / 256)) + end + lines[#lines + 1] = table.concat(bytes) + end + local n = #s.layouts + 1 + s.layouts[n] = { x0 = x0, y0 = y0, w = w, h = h, width = l.width, height = l.height, + pair = pair, blob = table.concat(lines), packed = packed } + stamps[n] = stamp(l, d); positions[key] = n + return n + end + if not add(def, 0, 0, true) then return nil end + local neighbors = id == Map.current and Map.neighborList or Connections.each(def) + for _, n in ipairs(neighbors or {}) do + local d = n.def or maps[n.map] + local l = d and d.midLayout + if d then neighborDefs[#neighborDefs + 1] = { dir = n.dir, offset = n.offset, def = d } end + if l then + local ox, oy = 0, 0 + if n.dir == "north" then ox, oy = n.offset, -l.height + elseif n.dir == "south" then ox, oy = n.offset, root.height + elseif n.dir == "west" then ox, oy = -l.width, n.offset + else ox, oy = root.width, n.offset end + local index = add(d, ox, oy) + if index then s.neighbors[#s.neighbors + 1] = { dir = n.dir, offset = n.offset, layout = index } end + end + end + for _, entry in ipairs(world or {}) do + local index = add(entry.def, entry.ox, entry.oy, entry.id == id) + if index then s.world[#s.world + 1] = { layout = index, ox = entry.ox, oy = entry.oy } end + end + local Void = require("src.core.game3.void_fill") + if mode ~= "map" and mode ~= "black" and Void.primaryFor(s.layouts[1].pair) == Void.PRIMARY then + -- hasMid(pair) demand-loads the atlas. Forecasting must not bypass the + -- shared main-thread upload budget while the pair is still warming. + local atlas = Native._pairs and Native._pairs[s.layouts[1].pair] + if not atlas then return nil end + local b = Void.borderFor(mode) + local available = b ~= nil + for _, mid in ipairs(b and b.mids or {}) do if not Native.hasMid(atlas, mid) then available = false end end + if available then s.fill = { w = b.w, h = b.h, mids = {} }; for i, mid in ipairs(b.mids) do s.fill.mids[i] = mid end end + end + local worldDefs = {} + for i, e in ipairs(world or {}) do worldDefs[i] = { id = e.id, def = e.def, ox = e.ox, oy = e.oy } end + return s, stamps, worldDefs, neighborDefs +end +function Plan.prefetch(game) + if not (love and love.thread and love.thread.newThread and game and game.data and game.data.maps) then return end + local Stream = require("src.core.game3.asset_stream") + if Stream.workerFailed then return end + if not task then task = Stream.newTask("cells", function(id, data, err) + local r = records[id]; if r then r.data, r.error = data, err end + if err then print("[game3/field-plan] " .. tostring(id) .. ": " .. tostring(err)) end + end) end + local Map = require("src.core.game3.map") + local View = package.loaded["src.core.game3.field_view"] + local Player = package.loaded["src.core.game3.player"] + local Native = package.loaded["src.core.game3.tileset_native"] + if not (View and Player and Native and Map.current) then return end + local vw, vh = View._viewW or 240, View._viewH or 160 + local cols, rows = math.ceil(vw / 16) + 3, math.ceil(vh / 16) + 3 + local cx = math.floor(((Player.px or 0) + 8 - vw / 2 + (View.cameraPanX or 0)) / 16) - 1 + local cy = math.floor(((Player.py or 0) + 8 - vh / 2 + (View.cameraPanY or 0)) / 16) - 1 + local mode = require("src.core.game3.void_fill").normalize(require("src.core.game3.void_fill").mode) + local candidates = { { id = Map.current, ox = 0, oy = 0, distance = -1 } } + local x0, y0, x1, y1 = Map.warmRect() + for _, entry in ipairs(Map.world or {}) do + if entry.id ~= Map.current and Map.warmNear(entry, x0, y0, x1, y1) then + local l = entry.def and entry.def.midLayout + if l then + local dx = math.max(entry.ox - Player.cellX, Player.cellX - (entry.ox + l.width), 0) + local dy = math.max(entry.oy - Player.cellY, Player.cellY - (entry.oy + l.height), 0) + candidates[#candidates + 1] = { id = entry.id, ox = entry.ox, oy = entry.oy, distance = dx + dy } + end + end + end + table.sort(candidates, function(a, b) return a.distance < b.distance end) + local wanted = {} + for i = 1, math.min(3, #candidates) do + local e = candidates[i] + wanted[e.id] = true + local x, y = cx - e.ox, cy - e.oy + local record = records[e.id] + if not (record and valid(record, mode) and (e.id ~= Map._worldRoot or graphValid(record, Map)) + and contains(record.snapshot, x - 1, y - 1, cols + 2, rows + 2) + and (record.data or record.error or task.pending[e.id])) then + local keep = {}; for id in pairs(records) do if id ~= e.id then keep[id] = true end end + task:retain(keep) + local s, stamps, worldDefs, neighborDefs = snapshot(game, e.id, x, y, cols, rows, mode, Native, math.ceil(vw / 16), math.ceil(vh / 16)) + if s then + records[e.id] = { def = game.data.maps[e.id], mode = mode, snapshot = s, stamps = stamps, + world = worldDefs, neighbors = neighborDefs, voidRevision = require("src.core.game3.void_fill")._revision or 0 } + task:submit(e.id, s, i == 1 and 0 or 1) + else records[e.id] = nil end + end + end + task:retain(wanted) + for id in pairs(records) do if not wanted[id] then records[id] = nil end end + Stream.poll() +end +function Plan.get(def, x, y, cols, rows, mode) + local Stream = package.loaded["src.core.game3.asset_stream"] + if Stream then Stream.poll() end + local Map = require("src.core.game3.map") + local id = Map.current + local r = records[id] + Plan._lastMiss = not r and "not_queued" or r.def ~= def and "map_identity" + or r.error and "worker_error" or not r.data and "pending" or not valid(r, mode) and "layout_revision" + or not graphValid(r, Map) and "connections" or not contains(r.data, x, y, cols, rows) and "window" or nil + if not Plan._lastMiss then return r.data end +end +Plan.cell = Cells.cell +return Plan diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 94499c20..f7f45e6b 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -1127,16 +1127,24 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) local visibleCells = FieldView._nativeVisibleCells local visiblePairs = FieldView._nativeVisiblePairs local baseBx, baseBy = FieldView._nativeBaseBx, FieldView._nativeBaseBy + local CellPlan = require("src.core.game3.field_plan") + -- A same-layout bulk invalidation can come from a mod editing its cells + -- directly. Resnapshot it rather than reuse an older planned window. + if FieldView._nativeDirty and FieldView._nativeLayout == layout then CellPlan.invalidate() end + local preparedCells = CellPlan.get(mapDef, cx0, cy0, cols, rows, voidMode) + FieldView._cellPreparationRoute = preparedCells and "worker" or "sync" local function addCell(wx, wy) local mid, srcPair, isVoid - if Map.worldMidAt then + local skip = false + if preparedCells then + mid, srcPair, isVoid, skip = CellPlan.cell(preparedCells, wx, wy) + elseif Map.worldMidAt then mid, srcPair, isVoid = Map.worldMidAt(wx, wy, mapDef) srcPair = srcPair or pair else mid, srcPair, isVoid = layout:midAt(wx, wy), pair, false end - local skip = false - if isVoid and voidMode ~= "map" then + if not preparedCells and isVoid and voidMode ~= "map" then local fill = VoidFill.fillAt(voidMode, wx, wy, function(m) return NativeTileset.hasMid(pair, m) end, VoidFill.primaryFor(pair)) if fill == false then @@ -1780,7 +1788,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 @@ -1791,6 +1799,8 @@ end --- Drop cached atlases/sprites (tileset hot-reload). function FieldView.invalidate() + local Plan = package.loaded["src.core.game3.field_plan"] + if Plan then Plan.invalidate() end FieldView._atlas = nil FieldView._atlasPath = nil FieldView._quads = {} @@ -1833,10 +1843,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/ghosts.lua b/src/core/game3/ghosts.lua index 393b747f..ea17577f 100644 --- a/src/core/game3/ghosts.lua +++ b/src/core/game3/ghosts.lua @@ -121,6 +121,21 @@ local function markFade(pool, entry) end end +local function prepareOffscreen(M, entry) + local Obj = Objects() + local Stream = package.loaded["src.core.game3.asset_stream"] + if not (love and love.thread and love.thread.newThread) or (Stream and Stream.workerFailed) then return false end + if not (M.warmRect and M.warmNear and Obj.prefetchMap and Obj.preparationReady) then return false end + local x0, y0, x1, y1 = M.warmRect(0) + -- Visible actors and collision queries retain immediate authoritative + -- adoption. Offscreen pools can wait for immutable worker preparation. + if not x0 or M.warmNear(entry, x0, y0, x1, y1) then return false end + x0, y0, x1, y1 = M.warmRect() + if not M.warmNear(entry, x0, y0, x1, y1) then return true end + if not Obj.prefetchMap(entry.id, entry.def, 1) then return false end + return not Obj.preparationReady(entry.id, entry.def) +end + function Ghosts.sync() local M = Map() local world = M.world or EMPTY @@ -129,7 +144,7 @@ function Ghosts.sync() for id in pairs(placed) do placed[id] = nil end for _, entry in ipairs(world) do placed[entry.id] = true - if not Ghosts._pools[entry.id] then + if not Ghosts._pools[entry.id] and not prepareOffscreen(M, entry) then local defs = defsFor(entry.id, entry.def) if defs then local pool = Objects().spawnFromDefs(defs, entry.def, entry.id) diff --git a/src/core/game3/layout_native.lua b/src/core/game3/layout_native.lua index 8401a6c0..de4cb355 100644 --- a/src/core/game3/layout_native.lua +++ b/src/core/game3/layout_native.lua @@ -12,7 +12,7 @@ local function wrap_border(cx, cy, w, h, bw, bh) return bx, by end -function LayoutNative.fromDecoded(decoded, mapId, pair) +function LayoutNative.fromDecoded(decoded, mapId, pair, sourceBlob) local self = setmetatable({ mapId = mapId, pair = pair or "sevii_outdoor", @@ -25,10 +25,31 @@ function LayoutNative.fromDecoded(decoded, mapId, pair) borderMids = decoded.borderMids or { 0 }, cells = decoded.cells or {}, overrides = {}, -- [cy*1024+cx] = { mid, coll, elev } + _revision = 0, + _workerSource = sourceBlob and { blob = sourceBlob, cells = decoded.cells, + width = decoded.width, height = decoded.height, trueWidth = decoded.trueWidth or decoded.width, + trueHeight = decoded.trueHeight or decoded.height }, }, LayoutNative) return self end +-- Imported base grids are immutable in the built-in runtime; edits live in +-- overrides. Custom/mod layouts use sampled snapshots instead of this path. +function LayoutNative:workerPacked() + local s = self._workerSource + if not s or self.cells ~= s.cells or self.width ~= s.width or self.height ~= s.height + or self.trueWidth ~= s.trueWidth or self.trueHeight ~= s.trueHeight + or self.cellAt ~= LayoutNative.cellAt or self.midAt ~= LayoutNative.midAt then return nil end + local Assets = package.loaded["src.render.Assets"] + if Assets and Assets.loader and #Assets.loader:overrideOrder() > 0 then return nil end + local overrides, borders = {}, {} + for k, row in pairs(self.overrides) do overrides[k] = { mid = row.mid } end + for i = 1, self.borderWidth * self.borderHeight do borders[i] = self.borderMids[i] end + return { blob = s.blob, off = 17 + (s.blob:byte(15) * s.blob:byte(16)) * 2, + width = self.width, trueWidth = self.trueWidth, trueHeight = self.trueHeight, + borderWidth = self.borderWidth, borderHeight = self.borderHeight, borderMids = borders, overrides = overrides } +end + function LayoutNative:cellAt(cx, cy) local key = cy * 1024 + cx local ov = self.overrides[key] @@ -101,6 +122,7 @@ function LayoutNative:collArray() end function LayoutNative:applyOverride(x, y, mid, coll, elev) + self._revision = (self._revision or 0) + 1 x, y = tonumber(x) or 0, tonumber(y) or 0 self.overrides[y * 1024 + x] = { mid = tonumber(mid) or 0, @@ -159,6 +181,7 @@ function LayoutNative:stamp(src, ox, oy) end end markDirty() + if n > 0 then self._revision = (self._revision or 0) + 1 end return n end @@ -177,12 +200,15 @@ function LayoutNative:setMetatiles(rows) n = n + 1 cells[n * 2 - 1], cells[n * 2] = x, y end - if n > 0 then markCellsDirty(self, cells, n) end + if n > 0 then self._revision = (self._revision or 0) + 1; markCellsDirty(self, cells, n) end return n end function LayoutNative:clearOverrides() - self.overrides = {} + if next(self.overrides) then + self._revision = (self._revision or 0) + 1 + self.overrides = {} + end local FieldView = package.loaded["src.core.game3.field_view"] if FieldView then FieldView._nativeDirty = true diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 0569d55c..8ecff0e4 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,82 @@ 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"] + local Obj = package.loaded["src.core.game3.objects"] + if Obj and Obj.prefetchMap then + local wanted = {} + for _, entry in ipairs(Map.world or {}) do + if Map.warmNear(entry, x0, y0, x1, y1) then + wanted[entry.id] = true + Obj.prefetchMap(entry.id, entry.def, entry.id == Map.current and 0 or 1) + end + end + Obj.retainPrepared(wanted) + end + 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 game then require("src.core.game3.field_plan").prefetch(game) 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/object_prepare.lua b/src/core/game3/object_prepare.lua new file mode 100644 index 00000000..30e92086 --- /dev/null +++ b/src/core/game3/object_prepare.lua @@ -0,0 +1,192 @@ +-- CPU-only object construction shared with the field preparation worker. +local GfxIds = require("src.core.game3.scripting.gfx_ids") +local MovementTypes = require("src.core.game3.movement_types") +local Prepare = {} +local CELL, WALK_FRAMES, MOVEMENT_TYPE_INVISIBLE = 16, 16, 0x4C +local function facingFromDef(def) + local r = tostring(def.facing or def.range or "DOWN"):lower() + return (r == "up" or r == "down" or r == "left" or r == "right") and r or "down" +end +local inPlace +local function hostSpec(mt, rx, ry, enabled) + local spec = GfxIds.hostMovement(mt, rx, ry) + if not inPlace then + inPlace = {} + for _, kind in ipairs({ + { "firered", "MOVEMENT_TYPE_WALK_IN_PLACE_", 16, false }, + { "firered", "MOVEMENT_TYPE_WALK_IN_PLACE_FAST_", 8, false }, + { "firered", "MOVEMENT_TYPE_JOG_IN_PLACE_", 4, false }, + { "emerald", "MOVEMENT_TYPE_WALK_SLOWLY_IN_PLACE_", 32, true }, + }) do + for suffix, dir in pairs({ DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right" }) do + local id = MovementTypes.canonOf(kind[1], kind[2] .. suffix) + if id then inPlace[id] = { dir = dir, frames = kind[3], always = kind[4] } end + end + end + end + local ip = inPlace[mt] + if ip and (ip.always or enabled) then + spec.movement, spec.face, spec.range, spec.frames = "IN_PLACE", ip.dir, ip.dir:upper(), ip.frames + end + return spec +end + +local MT_BERRY_TREE = 0x0C +-- pokeemerald/include/constants/event_object_movement.h:61 +local MT_TREE_DISGUISE = 0x39 +local MT_MOUNTAIN_DISGUISE = 0x3A +local MT_BURIED = 0x3F + +-- pokeemerald/src/event_object_movement.c:1153 +local COPY_TYPES = { + [0x35] = { init = "up" }, [0x36] = { init = "down" }, [0x37] = { init = "left" }, [0x38] = { init = "right" }, + [0x3B] = { init = "up", grass = true }, [0x3C] = { init = "down", grass = true }, + [0x3D] = { init = "left", grass = true }, [0x3E] = { init = "right", grass = true }, +} +function Prepare.initRseKind(eo) + local mt = tonumber(eo.movementType) or 0 + eo.rseKind = nil + local copy = COPY_TYPES[mt] + if copy then + eo.rseKind = "copy" + eo.copy = { init = copy.init, grass = copy.grass } + else + eo.copy = nil + end + if mt == MT_BERRY_TREE then + eo.rseKind = "berry_tree" + eo.invisible = true + eo.berryTree = eo.berryTree or { id = tonumber(eo.def and (eo.def.berryTreeId or eo.def.trainerRange)) or 0 } + else + eo.berryTree = nil + end + if mt == MT_TREE_DISGUISE or mt == MT_MOUNTAIN_DISGUISE then + eo.rseKind = "disguise" + -- pokeemerald/src/event_object_movement.c:4354 + local sheet = mt == MT_TREE_DISGUISE and "tree_disguise" or "mountain_disguise" + if not (eo.disguise and eo.disguise.sheet == sheet) then eo.disguise = { sheet = sheet } end + else + eo.disguise = nil + end + if mt == MT_BURIED then + -- pokeemerald/src/event_object_movement.c:4390 + eo.rseKind = "buried" + eo.buried = true + eo.invisible = true + elseif eo.buried then + eo.buried = nil + eo.invisible = false + end +end + + +function Prepare.instance(def, ctx) + local lid = tonumber(def.localId or def.index) or 0 + local x, y = tonumber(def.x) or 0, tonumber(def.y) or 0 + local rawMt = ctx.rawMt + if rawMt == nil and def.movementType ~= nil then + local ok, value = pcall(MovementTypes.canon, ctx.version, def.movementType) + rawMt = ok and value or tonumber(def.movementType) + end + local mt = rawMt or 0 + local spec = ctx.spec + if not spec then + if rawMt then spec = hostSpec(rawMt, def.rangeX, def.rangeY, ctx.inPlace) + else + local r = def.radius or { x = 1, y = 1 } + spec = { movement = def.movement or "STAY", range = def.range or "DOWN", rangeX = r.x, rangeY = r.y, radius = r } + end + end + local facing = (def.facing and facingFromDef(def)) or (rawMt and spec.face) or facingFromDef(def) + local resolvedGfx = ctx.graphicsId or def.graphicsId or def.graphics + local sprite = def.sprite or (resolvedGfx and GfxIds.spriteFor(resolvedGfx)) + local elev = ctx.elevation or 0 + local mg, mn = ctx.group, ctx.num + local eo = { + localId = lid, + originLocalId = tonumber(def.originLocalId or def.localId or def.index) or lid, + originMapId = def.originMapId or def.mapId or ctx.mapId, + originMapGroup = tonumber(def.originMapGroup or def.mapGroup) or mg, + originMapNum = tonumber(def.originMapNum or def.mapNum) or mn, + def = def, + cellX = x, + cellY = y, + px = x * CELL, + py = y * CELL, + homeX = x, + homeY = y, + facing = facing, + sprite = sprite or "SPRITE_YOUNGSTER", + graphicsId = resolvedGfx, + elevation = elev, + currentElevation = tonumber(def.elevation) or 0, + movementType = mt, + movement = spec.movement, + range = spec.range, + radius = spec.radius or { x = spec.rangeX, y = spec.rangeY }, + rangeX = spec.rangeX, + rangeY = spec.rangeY, + spec = spec, + seqIndex = 0, + sight = tonumber(def.sight or def.trainerRange) or 0, + trainerType = tonumber(def.trainerType) or 0, + scriptKey = def.scriptKey, + flag = def.flag, + visible = ctx.visible ~= false, + hidden = ctx.visible == false, + -- src/event_object_movement.c:1569 + invisible = mt == MOVEMENT_TYPE_INVISIBLE, + frozen = false, + passable = def.passable and true or false, + moving = false, + progress = 0, + stepFrames = WALK_FRAMES, + targetX = x, + targetY = y, + stepFlip = false, + animClock = 0, + scriptBusy = false, + } + if ctx.rse then Prepare.initRseKind(eo) end + return eo +end + +function Prepare.objects(snapshot, cancelled) + local out = {} + for i, def in ipairs(snapshot.defs) do + if cancelled and cancelled() then error("object preparation cancelled") end + out[i] = Prepare.instance(def, snapshot) + end + return out +end + +-- Snapshot only serializable values; custom functions/metatables keep demand loading. +function Prepare.freeze(value) + local visiting, count = {}, 0 + local function copy(v, depth) + if type(v) ~= "table" then + if type(v) == "function" or type(v) == "userdata" or type(v) == "thread" then error("custom object definition") end + return v + end + if depth > 16 or visiting[v] or getmetatable(v) then error("custom object definition") end + visiting[v] = true + local out = {} + for k, item in pairs(v) do + count = count + 1 + if count > 32768 then error("large object definition") end + out[copy(k, depth + 1)] = copy(item, depth + 1) + end + visiting[v] = nil + return out + end + local ok, result = pcall(copy, value, 0) + return ok and result or nil +end +function Prepare.matches(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not Prepare.matches(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +return Prepare diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index f22b774f..5587f51b 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -13,6 +13,7 @@ local Opcodes = require("src.core.game3.scripting.opcodes") local GfxIds = require("src.core.game3.scripting.gfx_ids") local ModRuntime = require("src.mods.Runtime") local VirtualObjects = require("src.core.game3.virtual_objects") +local Prepare = require("src.core.game3.object_prepare") local Objects = {} @@ -209,106 +210,137 @@ end Objects.cloneTemplate = cloneTemplate -local function newEventObject(def, neighbor) - -- Shallow-copy template so setobjectxy / removeobject cannot poison the - -- shared events.lua / mapDef.objects tables for the rest of the session. - local src = def or {} +local function templateCopy(src) + src = src or {} local tpl = cloneTemplate(src) - def = {} - for k, v in pairs(tpl or src) do - def[k] = v - end + local def = {} + for k, v in pairs(tpl or src) do def[k] = v end if tpl then def.localId, def.index, def.x, def.y = src.localId, src.index, src.x, src.y def.kind, def.cloneTarget = src.kind, src.cloneTarget end - local lid = tonumber(def.localId or def.index) or 0 - local x = tonumber(def.x) or 0 - local y = tonumber(def.y) or 0 - local rawMt = canonMt(def.movementType) - local mt = rawMt or 0 - local spec - if rawMt then - spec = hostSpec(rawMt, def.rangeX, def.rangeY) - else - local r = def.radius or { x = 1, y = 1 } - spec = { - movement = def.movement or "STAY", range = def.range or "DOWN", - rangeX = r.x, rangeY = r.y, radius = r, - } - end - local facing = (def.facing and facingFromDef(def)) - or (rawMt and spec.face) or facingFromDef(def) - local sprite = def.sprite + return def +end +local function sameTemplate(src, snapshot) + if cloneTemplate(src) then return Prepare.matches(templateCopy(src), snapshot) end + return Prepare.matches(src, snapshot) +end + +local function newEventObject(src, neighbor, prepared) + local def = templateCopy(src) + local x, y = tonumber(def.x) or 0, tonumber(def.y) or 0 local resolvedGfx = def.graphicsId or def.graphics - do - local okS, Space = pcall(lazyReq, "src.core.game3.scripting.space") - if okS and Space and Space.resolveObjectGraphicsId then - local gid = Space.resolveObjectGraphicsId(def, neighbor) - if gid then resolvedGfx = gid end - end - end - if not sprite and resolvedGfx then - sprite = GfxIds.spriteFor(resolvedGfx) + local okS, Sp = pcall(lazyReq, "src.core.game3.scripting.space") + if okS and Sp and Sp.resolveObjectGraphicsId then + local gid = Sp.resolveObjectGraphicsId(def, neighbor) + if gid then resolvedGfx = gid end end local Coll = Collision() local elev = (def.elevation and def.elevation ~= 0 and def.elevation) or (Coll and Coll.elevationAt and Coll.elevationAt(x, y)) or 0 local MapCatalog = package.loaded["src.import.gba.map_catalog"] or (pcall(lazyReq, "src.import.gba.map_catalog") and package.loaded["src.import.gba.map_catalog"]) - local mg, mn = nil, nil + local mg, mn if MapCatalog and MapCatalog.groupNumFor and (def.mapId or Objects._mapId) then mg, mn = MapCatalog.groupNumFor(def.mapId or Objects._mapId) end - local eo = { - localId = lid, - originLocalId = tonumber(def.originLocalId or def.localId or def.index) or lid, - originMapId = def.originMapId or def.mapId or Objects._mapId, - originMapGroup = tonumber(def.originMapGroup or def.mapGroup) or mg, - originMapNum = tonumber(def.originMapNum or def.mapNum) or mn, - def = def, - cellX = x, - cellY = y, - px = x * CELL, - py = y * CELL, - homeX = x, - homeY = y, - facing = facing, - sprite = sprite or "SPRITE_YOUNGSTER", - graphicsId = resolvedGfx, - elevation = elev, - currentElevation = tonumber(def.elevation) or 0, - movementType = mt, - movement = spec.movement, - range = spec.range, - radius = spec.radius or { x = spec.rangeX, y = spec.rangeY }, - rangeX = spec.rangeX, - rangeY = spec.rangeY, - spec = spec, - seqIndex = 0, - sight = tonumber(def.sight or def.trainerRange) or 0, - trainerType = tonumber(def.trainerType) or 0, - scriptKey = def.scriptKey, - flag = def.flag, - visible = objectVisible(def), - hidden = not objectVisible(def), - -- src/event_object_movement.c:1569 - invisible = mt == MOVEMENT_TYPE_INVISIBLE, - frozen = false, - passable = def.passable and true or false, - moving = false, - progress = 0, - stepFrames = WALK_FRAMES, - targetX = x, - targetY = y, - stepFlip = false, - animClock = 0, - scriptBusy = false, - } - if isRse() then Objects.initRseKind(eo) end + local visible = objectVisible(def) + local eo = prepared + if not eo then + local rawMt = canonMt(def.movementType) + eo = Prepare.instance(def, { rawMt = rawMt, spec = rawMt and hostSpec(rawMt, def.rangeX, def.rangeY), + version = lazyReq("src.core.GameVersion").get(), mapId = Objects._mapId, rse = isRse(), + graphicsId = resolvedGfx, elevation = elev, group = mg, num = mn, visible = visible }) + else + -- Only authoritative live state is rebound here. Construction and movement + -- specifications came from an immutable worker snapshot. + eo.def = def + if tonumber(def.movementType) == nil and def.radius then eo.spec.radius = def.radius end + if eo.spec.radius then eo.radius = eo.spec.radius end + eo.graphicsId = resolvedGfx + eo.sprite = def.sprite or (resolvedGfx and GfxIds.spriteFor(resolvedGfx)) or "SPRITE_YOUNGSTER" + eo.elevation, eo.visible, eo.hidden = elev, visible, not visible + eo.originMapId = def.originMapId or def.mapId or Objects._mapId + eo.originMapGroup = tonumber(def.originMapGroup or def.mapGroup) or mg + eo.originMapNum = tonumber(def.originMapNum or def.mapNum) or mn + end return eo end +local preparedMaps, preparationStream = {}, nil +local function definitions(mapId, mapDef) + local Sp = Space() + local ev = Sp and Sp.bundle and Sp.bundle.events and Sp.bundle.events[mapId] + local defs = ev and (ev.objects or ev.objectEvents) + return type(defs) == "table" and defs or (mapDef and mapDef.objects) or {} +end +local function preparationCurrent(row, defs) + if row.defs ~= defs or row.version ~= lazyReq("src.core.GameVersion").get() + or row.inPlace ~= fieldBlock().inPlaceMovementTypes or #row.snapshot.defs ~= #defs then return false end + for i, def in ipairs(defs) do + if not sameTemplate(def, row.snapshot.defs[i]) then return false end + end + return true +end +function Objects.prefetchMap(mapId, mapDef, priority) + if not (love and love.thread and love.thread.newThread) then return end + local Stream = lazyReq("src.core.game3.asset_stream") + if Stream.workerFailed then return end + local defs = definitions(mapId, mapDef) + local old = preparedMaps[mapId] + if old and old.error and preparationCurrent(old, defs) then return false end + if old and preparationCurrent(old, defs) + and (old.data or (preparationStream and preparationStream.pending[mapId])) then return true end + if not preparationStream then + preparationStream = Stream.newTask("objects", function(key, data, err) + local row = preparedMaps[key] + if row then row.data, row.error = data, err end + end) + end + if old then + local wanted = {}; for id in pairs(preparedMaps) do if id ~= mapId then wanted[id] = true end end + preparationStream:retain(wanted) + end + local templates = {} + for i, def in ipairs(defs) do templates[i] = templateCopy(def) end + local frozen = Prepare.freeze(templates) + if not frozen then preparedMaps[mapId] = nil; return end + local version = lazyReq("src.core.GameVersion").get() + local inPlaceEnabled = fieldBlock().inPlaceMovementTypes + local snapshot = { defs = frozen, version = version, mapId = mapId, rse = isRse(), inPlace = inPlaceEnabled } + preparedMaps[mapId] = { defs = defs, version = version, inPlace = inPlaceEnabled, snapshot = snapshot } + preparationStream:submit(mapId, snapshot, priority) + lazyReq("src.core.game3.asset_stream").poll() + return preparationStream.pending[mapId] ~= nil or preparedMaps[mapId].data ~= nil +end +function Objects.preparationReady(mapId, mapDef) + local Stream = package.loaded["src.core.game3.asset_stream"] + if Stream then Stream.poll() end + local row = preparedMaps[mapId] + return row and row.data ~= nil and preparationCurrent(row, definitions(mapId, mapDef)) or false +end +function Objects.retainPrepared(wanted) + if preparationStream then preparationStream:retain(wanted) end + for id in pairs(preparedMaps) do if not wanted[id] then preparedMaps[id] = nil end end +end +function Objects._preparationPending(mapId) + return preparationStream and preparationStream.pending[mapId] ~= nil +end +local function takePrepared(mapId, defs) + local Stream = package.loaded["src.core.game3.asset_stream"] + if Stream then Stream.poll() end + local row = preparedMaps[mapId] + if row and row.data and preparationCurrent(row, defs) then + preparedMaps[mapId] = nil + Objects._lastPreparationRoute = "worker" + return row.data, row.snapshot.defs + end + Objects._lastPreparationRoute = "sync" +end +local function preparedRow(rows, snapshots, i, def) + return rows and sameTemplate(def, snapshots[i]) and rows[i] or nil +end + function Objects.clear() Objects._byId = {} Objects._order = {} @@ -322,6 +354,7 @@ end -- pokefirered/src/overworld.c:405 function Objects.reset() + Objects.retainPrepared({}) Objects.clear() Objects._perm = {} Objects._templateMt = {} @@ -530,8 +563,8 @@ local function resolveContextualMapObjects(mapId) end end -local function spawnFromTemplate(def, mapId) - local eo = newEventObject(def) +local function spawnFromTemplate(def, mapId, prepared) + local eo = newEventObject(def, nil, prepared) if mapId and (not eo.originMapGroup or not eo.originMapNum) then local okC, MapCatalog = pcall(lazyReq, "src.import.gba.map_catalog") if okC and MapCatalog and MapCatalog.groupNumFor then @@ -617,9 +650,10 @@ function Objects.loadMap(game, mapId, mapDef) end end resolveContextualMapObjects(mapId) + local prepared, snapshots = takePrepared(mapId, Objects._defs) local announce = ModRuntime.wants("world.npc_spawned") - for _, def in ipairs(Objects._defs) do - local eo = spawnFromTemplate(def, mapId) + for i, def in ipairs(Objects._defs) do + local eo = spawnFromTemplate(def, mapId, preparedRow(prepared, snapshots, i, def)) if eo.localId > 0 then Objects._byId[eo.localId] = eo Objects._order[#Objects._order + 1] = eo.localId @@ -1819,41 +1853,7 @@ function Objects.copyDirection(copyInit, playerInit, playerMove) return IDX_DIR[COPY_TO[ci][COPY_FOR[pi][pm]]] end -function Objects.initRseKind(eo) - local mt = tonumber(eo.movementType) or 0 - eo.rseKind = nil - local copy = COPY_TYPES[mt] - if copy then - eo.rseKind = "copy" - eo.copy = { init = copy.init, grass = copy.grass } - else - eo.copy = nil - end - if mt == MT_BERRY_TREE then - eo.rseKind = "berry_tree" - eo.invisible = true - eo.berryTree = eo.berryTree or { id = tonumber(eo.def and (eo.def.berryTreeId or eo.def.trainerRange)) or 0 } - else - eo.berryTree = nil - end - if mt == MT_TREE_DISGUISE or mt == MT_MOUNTAIN_DISGUISE then - eo.rseKind = "disguise" - -- pokeemerald/src/event_object_movement.c:4354 - local sheet = mt == MT_TREE_DISGUISE and "tree_disguise" or "mountain_disguise" - if not (eo.disguise and eo.disguise.sheet == sheet) then eo.disguise = { sheet = sheet } end - else - eo.disguise = nil - end - if mt == MT_BURIED then - -- pokeemerald/src/event_object_movement.c:4390 - eo.rseKind = "buried" - eo.buried = true - eo.invisible = true - elseif eo.buried then - eo.buried = nil - eo.invisible = false - end -end +Objects.initRseKind = Prepare.initRseKind -- pokeemerald/src/event_object_movement.c:1890 local function setBerryTreeGraphics(eo, bt, tree) @@ -2007,8 +2007,9 @@ function Objects.spawnFromDefs(defs, mapDef, mapId) local Sp = mapId and Space() local nb = Sp and Sp.neighborObjectState and Sp.neighborObjectState(mapId) if mapId and not nb and not (Sp and Sp.mapId == mapId) then nb = { store = { flags = {}, vars = {} }, perm = {}, movementType = {} } end - for _, def in ipairs(defs or {}) do - local eo = newEventObject(def, nb) + local prepared, snapshots = takePrepared(mapId, defs or {}) + for i, def in ipairs(defs or {}) do + local eo = newEventObject(def, nb, preparedRow(prepared, snapshots, i, def)) if eo.localId > 0 then eo.mapDef = mapDef if nb then 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/core/game3/void_fill.lua b/src/core/game3/void_fill.lua index d752b10c..7b49662a 100644 --- a/src/core/game3/void_fill.lua +++ b/src/core/game3/void_fill.lua @@ -43,6 +43,7 @@ function VoidFill.label(mode) end function VoidFill.invalidate() + VoidFill._revision = (VoidFill._revision or 0) + 1 VoidFill._borders = {} local FieldView = package.loaded["src.core.game3.field_view"] if FieldView then FieldView._nativeDirty = true 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/src/ui/game3/summary_menu.lua b/src/ui/game3/summary_menu.lua index 472211a7..20997fd2 100644 --- a/src/ui/game3/summary_menu.lua +++ b/src/ui/game3/summary_menu.lua @@ -322,10 +322,13 @@ local function step_pic_bounce() end -- pokefirered/src/pokemon_summary_screen.c:3956 +-- Emerald's summary has no sEggPicShake table local function step_egg_shake() local b = SummaryMenu._bounce if b.count >= 2 then return end - local deltas = SummaryChrome.manifest().eggPicShake[b.vigor + 1] + local m = SummaryChrome.manifest() + local deltas = m and m.eggPicShake and m.eggPicShake[b.vigor + 1] + if not deltas or #deltas == 0 then return end local ready = b.delay >= EGG_SHAKE_DELAY[b.vigor] b.delay = b.delay + 1 if not ready then return end 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_field_prepare_profile.lua b/tests/drivers/game3_field_prepare_profile.lua new file mode 100644 index 00000000..c2070131 --- /dev/null +++ b/tests/drivers/game3_field_prepare_profile.lua @@ -0,0 +1,130 @@ +local U = require("tests.drivers.util") +return function(game) + for _ = 1, 900 do if game.boot then break end U.wait(1) end + game:_handleBootAction({ action = "new_game", name = "PREP", gender = 0 }) + U.wait(60) + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local View = require("src.core.game3.field_view") + local Plan = require("src.core.game3.field_plan") + local Cells = require("src.core.game3.field_cell_prepare") + local Stream = require("src.core.game3.asset_stream") + local Objects = require("src.core.game3.objects") + local Native = require("src.core.game3.tileset_native") + local Void = require("src.core.game3.void_fill") + local Prepare = require("src.core.game3.object_prepare") + local version = require("src.core.GameVersion").get() + local id = version == "emerald" and "EM_PETALBURG_CITY" or "FR_PALLET_TOWN" + Map.load(nil, game, id, { x = 1, y = 1, facing = "down" }); U.wait(60) + local baselinePath = os.getenv("POKEPORT_OBJECT_BASELINE") or "src/core/game3/objects.lua" + local baseline = assert(love.filesystem.load(baselinePath))() + local originalUpdate, originalDraw = game.update, game.draw + game.update, game.draw = function() end, function() end + 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 function waitPrepared(test) + local deadline = love.timer.getTime() + 5 + while not test() and love.timer.getTime() < deadline do Stream.poll(); U.wait(1) end + return test() + end + local function stats(v) + table.sort(v); return string.format("n=%d p99=%.3f max=%.3f", #v, v[math.ceil(#v * .99)] or 0, v[#v] or 0) + end + local def = game.data.maps[id] + local times = { baseline = {}, worker_adopt = {}, snapshot = {} } + for _ = 1, 20 do + baseline._mapId, baseline._perm, baseline._templateMt = id, Objects._perm, Objects._templateMt + local t = love.timer.getTime(); baseline.loadMap(game, id, def) + times.baseline[#times.baseline + 1] = (love.timer.getTime() - t) * 1000 + t = love.timer.getTime(); Objects.prefetchMap(id, def, 0) + times.snapshot[#times.snapshot + 1] = (love.timer.getTime() - t) * 1000 + local deadline = love.timer.getTime() + 5 + repeat + Stream.poll(); U.wait(1) + until not Objects._preparationPending(id) or love.timer.getTime() > deadline + t = love.timer.getTime(); Objects.loadMap(game, id, def) + times.worker_adopt[#times.worker_adopt + 1] = (love.timer.getTime() - t) * 1000 + check(Objects._lastPreparationRoute == "worker", "object reload used worker") + local equal = Prepare.matches(Prepare.freeze(baseline._byId), Prepare.freeze(Objects._byId)) + if not equal and #times.baseline == 1 then + local function differences(a, b, path) + if type(a) ~= type(b) then print("OBJECT_DIFF " .. path .. " " .. tostring(a) .. " / " .. tostring(b)); return end + if type(a) == "table" then + for k, v in pairs(a) do differences(v, b[k], path .. "." .. tostring(k)) end + for k, v in pairs(b) do if a[k] == nil then differences(nil, v, path .. "." .. tostring(k)) end end + elseif a ~= b then print("OBJECT_DIFF " .. path .. " " .. tostring(a) .. " / " .. tostring(b)) end + end + differences(baseline._byId, Objects._byId, "objects") + end + check(equal, "worker object pool equals synchronous pool") + end + for phase, values in pairs(times) do print("OBJECT_PROFILE " .. phase .. " " .. stats(values)) end + local savedGet = Plan.get + local function render(w, h, forceSync) + local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 }) + love.graphics.setCanvas(canvas); love.graphics.clear(0, 0, 0, 1) + View._nativeVisibleCells = nil + if forceSync then Plan.get = function() return nil end end + local t = love.timer.getTime(); View.draw(game, w, h) + local ms = (love.timer.getTime() - t) * 1000 + Plan.get = savedGet + love.graphics.setCanvas() + local image = canvas:newImageData() + canvas:release() + return image, ms + end + for _, size in ipairs({ { "fit", 240, 160 }, { "survey", 960, 640 } }) do + for _, mode in ipairs(Void.MODES) do + Void.setMode(mode) + Player.reset(-2, -2, "down") + render(size[2], size[3], true) + Plan.prefetch(game) + local cx = math.floor((Player.px + 8 - size[2] / 2) / 16) - 1 + local cy = math.floor((Player.py + 8 - size[3] / 2) / 16) - 1 + local cols, rows = math.ceil(size[2] / 16) + 3, math.ceil(size[3] / 16) + 3 + check(waitPrepared(function() return Plan.get(def, cx, cy, cols, rows, mode) ~= nil end), size[1] .. " " .. mode .. " cell worker ready") + local p = Plan.get(def, cx, cy, cols, rows, mode) + local matches = p ~= nil + if p then + for y = cy, cy + rows - 1 do for x = cx, cx + cols - 1 do + local mid, pair, void = Map.worldMidAt(x, y, def) + local skip = false + if void and mode ~= "map" then + local fill = Void.fillAt(mode, x, y, function(m) return Native.hasMid(def.midLayout.pair, m) end, Void.primaryFor(def.midLayout.pair)) + if fill == false then skip = true elseif fill then mid, pair = fill, def.midLayout.pair end + end + local actual, ap, av, as = Cells.cell(p, x, y) + matches = matches and actual == mid and ap == pair and av == not not void and as == skip + end end + end + check(matches, size[1] .. " " .. mode .. " all planned cells equal original resolver") + local prepared, preparedMs = render(size[2], size[3], false) + check(View._cellPreparationRoute == "worker", size[1] .. " " .. mode .. " draw consumes planned cells") + local original, originalMs = render(size[2], size[3], true) + check(prepared:getString() == original:getString(), size[1] .. " " .. mode .. " complete field pixels equal") + local workerTimes, syncTimes = { preparedMs }, { originalMs } + for i = 2, 20 do + -- Alternate order and finish each readback before the next sample. + local a, ams = render(size[2], size[3], i % 2 == 0) + local b, bms = render(size[2], size[3], i % 2 ~= 0) + if i % 2 == 0 then syncTimes[i], workerTimes[i] = ams, bms + else workerTimes[i], syncTimes[i] = ams, bms end + a:release(); b:release() + end + print(string.format("VOID_PROFILE %s %s worker_draw %s sync_draw %s", size[1], mode, stats(workerTimes), stats(syncTimes))) + if os.getenv("POKEPORT_SHOT_DIR") then + local bytes = prepared:encode("png") + local f = assert(io.open(os.getenv("POKEPORT_SHOT_DIR") .. "/" .. size[1] .. "_" .. mode .. ".png", "wb")) + f:write(bytes:getString()); f:close() + end + prepared:release(); original:release() + end + end + Void.setMode("map"); Plan.invalidate() + game.update, game.draw = originalUpdate, originalDraw + print((failures == 0 and "PASS" or "FAIL") .. " game3_field_prepare_profile failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +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..4aa94724 --- /dev/null +++ b/tests/drivers/game3_seam_stream_profile.lua @@ -0,0 +1,55 @@ +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] + if type(old) ~= "function" then return end + samples[label] = samples[label] or {} + 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 == "objects" or label == "ghost_spawn" or label == "field_cells" then + local route = (label == "field_cells" and owner._cellPreparationRoute) or owner._lastPreparationRoute or "sync" + local name = label .. "_" .. route + samples[name] = samples[name] or {}; samples[name][#samples[name] + 1] = ms + if label == "objects" then print(string.format("OBJECT_RELOAD map=%s count=%s ms=%.3f route=%s", tostring(args[2]), tostring(a), ms, route)) end + if label == "field_cells" then + local id = require("src.core.game3.map").current + if owner._profileMap ~= id then + owner._profileMap = id + local Plan = package.loaded["src.core.game3.field_plan"] or {} + print(string.format("VOID_TRANSITION map=%s ms=%.3f route=%s fade=%s miss=%s graph=%s", tostring(id), ms, route, + tostring(owner._voidFrom and owner._voidFrom.t), tostring(Plan._lastMiss), tostring(Plan._graphMiss))) + end + end + end + 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") + local Objects = require("src.core.game3.objects") + wrap(Objects, "loadMap", "objects") + wrap(Objects, "spawnFromDefs", "ghost_spawn") + local hasPlan, Plan = pcall(require, "src.core.game3.field_plan") + if hasPlan then wrap(Plan, "prefetch", "plan_snapshot") end + wrap(require("src.core.game3.field_view"), "draw", "field_cells") + 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..4b7e47af --- /dev/null +++ b/tests/engine/game3_asset_stream_test.lua @@ -0,0 +1,104 @@ +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() +local cpuResult +local cpuTask = Stream.newTask("objects", function(key, data) cpuResult = { key, data } end) +cpuTask:submit("MAP", { defs = { { localId = 1 } } }, 0) +Stream.poll(); request = worker.input:pop() +check(request.spec.task and request.spec.payload.defs[1].localId == 1, "CPU tasks receive their immutable input") +Stream._uploadedThisFrame = true +worker.output:push({ id = request.id, data = { prepared = true }, seconds = .01 }) +Stream.poll() +check(cpuResult[1] == "MAP" and cpuResult[2].prepared, "CPU results publish without a graphics slice") +check(cpuTask.pending.MAP == nil, "CPU publication releases its queued snapshot") +Stream.shutdown() +local fullOwner = owner() +for i = 1, Stream.MAX_DECODED do + fullOwner:prefetch("FULL" .. i); Stream.poll(); request = worker.input:pop() + worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 }); Stream.poll() +end +fullOwner:prefetch("BLOCKED", 0) +cpuTask = Stream.newTask("cells", function() end) +cpuTask:submit("CELL_WINDOW", {}, 1); Stream.poll(); request = worker.input:pop() +eq(request.key, "CELL_WINDOW", "CPU field preparation proceeds while decoded graphics residency is full") +check(not fullOwner.pending.BLOCKED.sent, "graphics decoding still obeys the residency bound") +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") diff --git a/tests/engine/game3_field_prepare_worker_test.lua b/tests/engine/game3_field_prepare_worker_test.lua new file mode 100644 index 00000000..b91cc610 --- /dev/null +++ b/tests/engine/game3_field_prepare_worker_test.lua @@ -0,0 +1,205 @@ +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 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 + function ch:getCount() return #self.q end + return ch + end, + newThread = function() + function worker:start(input, output) self.input, self.output = input, output end + function worker:getError() return nil end + function worker:wait() end + return worker + end, +} +require("src.core.GameVersion").set("firered") +local Stream = require("src.core.game3.asset_stream") +local ObjectPrepare = require("src.core.game3.object_prepare") +local Cells = require("src.core.game3.field_cell_prepare") +local Objects = require("src.core.game3.objects") +local state = { gid = 17, hidden = false } +package.loaded["src.core.game3.scripting.space"] = { + resolveObjectGraphicsId = function(def) return def.graphicsId == 240 and state.gid or def.graphicsId end, + objectVisible = function(def) return not (def.flag == 1 and state.hidden) end, +} +package.loaded["src.core.game3.collision"] = { elevationAt = function() return 3 end } +local function finishJobs() + for _ = 1, 20 do + Stream.poll() + local req = worker.input and worker.input:pop() + if not req then break end + local data = req.kind == "objects" and ObjectPrepare.objects(req.spec.payload) or Cells.cells(req.spec.payload) + worker.output:push({ id = req.id, data = data, seconds = .001 }) + Stream.poll() + end +end +local def = { objects = { + { localId = 1, x = 3, y = 4, graphicsId = 240, movementType = 2, flag = 1 }, + { localId = 2, x = 5, y = 6, graphicsId = 16, movementType = 7 }, +} } +Objects.prefetchMap("WORKER_MAP", def, 0); finishJobs() +state.gid, state.hidden = 25, true +Objects.loadMap({}, "WORKER_MAP", def) +eq(Objects._lastPreparationRoute, "worker", "map adopts worker-built instances") +local eo = Objects._byId[1] +eq(eo.graphicsId, 25, "variable graphics resolve against live state after preparation") +check(eo.hidden and not eo.visible, "hide flags resolve against live state after preparation") +eq(eo.elevation, 3, "elevation binds against the authoritative collision grid") +check(eo.def ~= def.objects[1], "prepared instances own their template copy") +local workerEo = ObjectPrepare.freeze(eo) +check(not pcall(ObjectPrepare.objects, { defs = def.objects }, function() return true end), "stale object preparation stops cooperatively") +Objects.loadMap({}, "WORKER_MAP", def) +check(ObjectPrepare.matches(workerEo, Objects._byId[1]), "worker and synchronous instance fields agree") +Objects.prefetchMap("WORKER_MAP", def); finishJobs() +def.objects[1].x = 9 +Objects.loadMap({}, "WORKER_MAP", def) +eq(Objects._lastPreparationRoute, "sync", "changed definitions reject stale instances") +eq(Objects._byId[1].cellX, 9, "changed template position is applied immediately") +check(ObjectPrepare.freeze({ callback = function() end }) == nil, "custom functions keep the synchronous path") +Objects.prefetchMap("STALE_MAP", def); Stream.poll() +Objects.reset(); finishJobs() +Objects.loadMap({}, "STALE_MAP", def) +eq(Objects._lastPreparationRoute, "sync", "reset discards late object preparation") + +local Layout = require("src.core.game3.layout_native") +local Pack = require("src.import.gba.native_pack") +local decoded = { width = 4, height = 4, trueWidth = 3, trueHeight = 3, borderWidth = 2, borderHeight = 2, + borderMids = { 11, 12, 13, 14 }, cells = {} } +for i = 1, 16 do decoded.cells[i] = { mid = i, coll = 0, elev = 0 } end +local blob = Pack.encodeMidLayout(decoded) +local layout = Layout.fromDecoded(decoded, "PLAN", "general__test", blob) +layout:applyOverride(1, 1, 90, 0, 3) +local p = layout:workerPacked() +check(p ~= nil, "native grid snapshots use immutable packed bytes") +local s = { x0 = -5, y0 = -5, cols = 15, rows = 15, mode = "map", neighbors = {}, world = {}, + layouts = { { x0 = -5, y0 = -5, w = 15, h = 15, width = 4, height = 4, pair = layout.pair, packed = p } } } +local plan = Cells.cells(s) +local equal = true +for y = -5, 9 do for x = -5, 9 do + local mid, pair, void, skip = Cells.cell(plan, x, y) + equal = equal and mid == layout:midAt(x, y) and pair == layout.pair + and void == (x < 0 or y < 0 or x >= 4 or y >= 4) and not skip +end end +check(equal, "packed worker sampling matches borders padding and metatile overrides") +s.mode = "black"; plan = Cells.cells(s) +local _, _, void, skip = Cells.cell(plan, -2, -3) +check(void and skip, "black void skips only off-map cells") +s.mode, s.fill = "trees", { w = 2, h = 2, mids = { 40, 41, 42, 43 } } +plan = Cells.cells(s) +eq(Cells.cell(plan, -1, -1), 43, "patterned void wraps negative coordinates identically") +check(not pcall(Cells.cells, s, function() return true end), "stale cell preparation stops cooperatively") +local Assets = require("src.render.Assets") +Assets.loader = { overrideOrder = function() return { {} } end } +check(layout:workerPacked() == nil, "modded layouts use sampled snapshots") +Assets.loader = nil + +local Map = require("src.core.game3.map") +local Plan = require("src.core.game3.field_plan") +local mapDef = { pair = layout.pair, midLayout = layout, connections = {} } +local game = { data = { maps = { PLAN = mapDef } } } +Map.current, Map._worldRoot, Map._def, Map.neighborList = "PLAN", "PLAN", mapDef, {} +Map.world = { { id = "PLAN", def = mapDef, ox = 0, oy = 0 } } +package.loaded["src.core.game3.field_view"] = { _viewW = 64, _viewH = 64 } +package.loaded["src.core.game3.player"] = { px = 16, py = 16, cellX = 1, cellY = 1 } +package.loaded["src.core.game3.tileset_native"] = { hasMid = function() return true end } +require("src.core.game3.void_fill").mode = "map" +Plan.prefetch(game); finishJobs() +check(Plan.get(mapDef, -2, -2, 7, 7, "map") ~= nil, "completed cell windows are consumed without waiting") +local oldBorder = layout.borderMids[1] +layout.borderMids[1] = 99 +check(Plan.get(mapDef, -2, -2, 7, 7, "map") == nil, "border edits reject stale cell windows") +layout.borderMids[1] = oldBorder +layout:applyOverride(2, 2, 91) +check(Plan.get(mapDef, -2, -2, 7, 7, "map") == nil, "layout edits reject stale cell windows") +Plan.prefetch(game); finishJobs() +Map.world[2] = { id = "OTHER", def = mapDef, ox = 4, oy = 0 } +check(Plan.get(mapDef, -2, -2, 7, 7, "map") == nil, "world topology changes reject stale cell windows") +Map.world[2] = nil +Plan.invalidate() +check(Plan.get(mapDef, -2, -2, 7, 7, "map") == nil, "context reset clears prepared cell windows") + +local nextDef = { pair = layout.pair, midLayout = Layout.fromDecoded(decoded, "NEXT", layout.pair, blob), + connections = { { direction = "west", map = "PLAN", offset = 0 } } } +mapDef.connections = { { direction = "east", map = "NEXT", offset = 0 } } +game.data.maps.NEXT = nextDef +Map.neighborList = { { dir = "east", offset = 0, def = nextDef } } +Map.world = Map.computeWorld(game.data.maps, "PLAN", 2, 4, 4) +Plan.prefetch(game); finishJobs() +Map.current, Map._def, Map._worldRoot = "NEXT", nextDef, nil +Map.neighborList = { { dir = "west", offset = 0, def = mapDef } } +local player = package.loaded["src.core.game3.player"] +player.px, player.cellX = -48, -3 +-- The old world graph survives Map.load until the first draw refreshes it. +Plan.prefetch(game) +Map.world, Map._worldRoot = Map.computeWorld(game.data.maps, "NEXT", 2, 4, 4), "NEXT" +check(Plan.get(nextDef, -6, -2, 7, 7, "map") ~= nil, "seam forecast survives stale world between load and first draw") +local Ghosts = require("src.core.game3.ghosts") +Ghosts.clear() +player.px, player.cellX = 16, 1 +local ghostDef = { midLayout = layout, objects = { { localId = 1, x = 2, y = 2, graphicsId = 17 } } } +Map.world = { { id = "WORKER_GHOST", def = ghostDef, ox = 5, oy = 0 } } +Ghosts.sync() +check(Ghosts._pools.WORKER_GHOST == nil and Objects._preparationPending("WORKER_GHOST"), "offscreen nearby ghosts wait for worker preparation") +finishJobs(); Ghosts.sync() +check(Ghosts._pools.WORKER_GHOST ~= nil and Objects._lastPreparationRoute == "worker", "completed ghost preparation adopts on main thread") +Map.world = { { id = "COLLISION_GHOST", def = ghostDef, ox = 5, oy = 0 } } +Ghosts.sync() +check(Ghosts.blocksOn("COLLISION_GHOST", ghostDef, 2, 2), "collision remains authoritative while ghost work is pending") +Map.world = { { id = "FAR_GHOST", def = ghostDef, ox = 100, oy = 0 } } +Ghosts.sync() +check(Ghosts._pools.FAR_GHOST == nil and not Objects._preparationPending("FAR_GHOST"), "far ghosts do not preload indiscriminately") +Stream.workerFailed = true; Ghosts.sync() +check(Ghosts._pools.FAR_GHOST ~= nil, "unavailable worker retains synchronous ghost fallback") +Stream.workerFailed = false +Plan.invalidate() +Map.current, Map._worldRoot, Map._def = "PLAN", "PLAN", mapDef +Map.world = Map.computeWorld(game.data.maps, "PLAN", 2, 4, 4) +Map.neighborList = { { dir = "east", offset = 0, def = nextDef } } +local Native = package.loaded["src.core.game3.tileset_native"] +local Void = require("src.core.game3.void_fill") +local calls, savedBorder = 0, Void.borderFor +Void.mode, Void.borderFor = "trees", function() return { w = 1, h = 1, mids = { 40 } } end +Native.hasMid = function(atlas) calls = calls + 1; return type(atlas) == "table" end +Plan.prefetch(game); finishJobs() +check(calls == 0, "forecasting a cold patterned pair never demand-loads a graphics atlas") +Native._pairs = { [layout.pair] = {} } +Plan.prefetch(game); finishJobs() +local trees = Plan.get(mapDef, -2, -2, 7, 7, "trees") +check(trees and Plan.cell(trees, -2, -2) == 40 and calls > 0, "pattern validation consumes the warmed atlas without bypassing upload budgets") +Void.mode, Void.borderFor = "map", savedBorder +Plan.invalidate() +local shared = { width = 4, height = 4, midAt = function() return 77 end } +local a, b = { midLayout = shared, pair = "pair_a" }, { midLayout = shared, pair = "pair_b" } +game.data.maps.A, game.data.maps.B = a, b +mapDef.connections = { { dir = "east", map = "A", offset = 0 }, { dir = "east", map = "B", offset = 0 } } +Map.world = Map.computeWorld(game.data.maps, "PLAN", 2, 4, 4) +Map.neighborList = { { dir = "east", offset = 0, def = a }, { dir = "east", offset = 0, def = b } } +Plan.prefetch(game); finishJobs() +local aliases = Plan.get(mapDef, -2, -2, 7, 7, "map") +local mid, pair +if aliases then mid, pair = Plan.cell(aliases, 4, 0) end +check(mid == 77 and pair == "pair_b", "shared mod layouts retain each map's pair and reverse connection priority") +Stream.shutdown() +-- Exercise the production worker's result envelope, including a cancelled +-- job, rather than just the Channel transport double above. +for _, name in ipairs({ "filesystem", "image", "data", "timer" }) do package.loaded["love." .. name] = {} end +local workerInput, workerOutput = love.thread.newChannel(), love.thread.newChannel() +function workerInput:demand() return self:pop() end +workerInput:push({ id = 1, kind = "objects", spec = { task = true, + payload = { defs = { { localId = 1 } }, version = "firered" } }, cancelSignal = { getCount = function() return 0 end } }) +workerInput:push({ id = 2, kind = "objects", spec = { task = true, + payload = { defs = { { localId = 1 } }, version = "firered" } }, cancelSignal = { getCount = function() return 1 end } }) +workerInput:push({ stop = true }) +assert(loadfile("src/core/game3/asset_worker.lua"))(workerInput, workerOutput) +local success, cancelled = workerOutput:pop(), workerOutput:pop() +check(success and success.data and success.data[1].localId == 1, "production worker returns prepared objects") +check(success and success.error == nil, "successful production worker replies have no error") +check(cancelled and cancelled.data == nil and cancelled.error:find("cancelled"), "cancelled production worker replies return an error without data") +T.finish("game3_field_prepare_worker_test") diff --git a/tests/engine/game3_summary_layout_s3_test.lua b/tests/engine/game3_summary_layout_s3_test.lua index 9ba1bab8..449fc9a4 100644 --- a/tests/engine/game3_summary_layout_s3_test.lua +++ b/tests/engine/game3_summary_layout_s3_test.lua @@ -390,6 +390,16 @@ draw() eq(drawn, 60 - 32 + 2 + 64, "the egg pic draws h-flipped at its shaken x") SummaryMenu.close() Pokemon.frontPic = realFront + +print("[test] 13. emerald chrome has no egg shake table") +for _, manifest in ipairs({ nil, {}, { eggPicShake = {} } }) do + MANIFEST = manifest + SummaryMenu.openMenu({ { species = 1, isEgg = true, friendship = 5 } }, 1, {}) + frames(200) + eq(SummaryMenu._bounce.dx, 0, "missing eggPicShake leaves the egg still") + eq(SummaryMenu._bounce.egg, true, "the summary still treats the mon as an egg") + SummaryMenu.close() +end MANIFEST = nil T.finish("game3_summary_layout_s3_test") diff --git a/tests/game3_battle_switch_and_faint_test.lua b/tests/game3_battle_switch_and_faint_test.lua index 1640fcb4..c9db5759 100644 --- a/tests/game3_battle_switch_and_faint_test.lua +++ b/tests/game3_battle_switch_and_faint_test.lua @@ -71,6 +71,24 @@ do { species = 4, hp = 0, maxHp = 19 }, } eq(Engine.hasLivingMons(deadParty), false, "hasLivingMons false when all dead") + + -- pokeemerald/src/battle_script_commands.c:3556 Cmd_checkteamslost + local eggParty = { + { species = 1, hp = 0, maxHp = 20 }, + { species = 4, hp = 20, maxHp = 20, isEgg = true }, + } + eq(Engine.hasLivingMons(eggParty), false, "an egg with HP is not a living battler") + eq(Engine.nextLivingMonIndex(eggParty, 1), nil, "nextLivingMonIndex skips the egg") + local foeUp = { { species = 16, hp = 30, maxHp = 30 } } + local foeDown = { { species = 16, hp = 0, maxHp = 30 } } + eq(Engine.checkEnd({ playerParty = eggParty, foeParty = foeUp }), "lose", + "egg-only party loses while the foe is still up") + eq(Engine.checkEnd({ playerParty = eggParty, foeParty = foeDown }), "lose", + "egg-only party loses even when the foe has also fainted") + eq(Engine.checkEnd({ + playerParty = { { species = 1, hp = 12, maxHp = 20 } }, + foeParty = { { species = 16, hp = 20, maxHp = 20, isEgg = true } }, + }), "win", "a foe party that is only an egg is defeated") end print("\n--- Testing SwitchSeq Dynamic Withdraw Strings ---")