Merge pull request #2622 from bryanthaboi/dev

major perf enhancement for gen3
This commit is contained in:
bryanthaboi
2026-10-02 10:06:55 -04:00
committed by GitHub
38 changed files with 2228 additions and 392 deletions
+1 -1
View File
@@ -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
+13 -4
View File
@@ -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 = {},
+2
View File
@@ -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
+131
View File
@@ -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
+211
View File
@@ -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
+30
View File
@@ -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
+10 -6
View File
@@ -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
+11 -1
View File
@@ -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
+7 -2
View File
@@ -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
+4 -2
View File
@@ -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
+85
View File
@@ -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
+200
View File
@@ -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
+25 -4
View File
@@ -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
+2 -2
View File
@@ -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
+62 -4
View File
@@ -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)
+16 -1
View File
@@ -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)
+29 -3
View File
@@ -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
+105 -6
View File
@@ -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"]
+192
View File
@@ -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
+124 -123
View File
@@ -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
+57 -84
View File
@@ -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
+17
View File
@@ -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
+11 -6
View File
@@ -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
+50 -142
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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 = {}
+9
View File
@@ -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
+4 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
+3
View File
@@ -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)
@@ -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
+104
View File
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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 ---")