performance enhancements 1 and decoration names

This commit is contained in:
bryanthaboi
2026-10-02 08:54:15 -04:00
parent f5e02b8dbe
commit 093f2fe0da
23 changed files with 1089 additions and 249 deletions
+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
+189
View File
@@ -0,0 +1,189 @@
-- One CPU worker, bounded decoded residency, and a shared upload budget.
local Decode = require("src.core.game3.asset_decode")
local Stream = {}
Stream.UPLOAD_BUDGET = .002
Stream.MAX_DECODED = 4
Stream.MAX_PENDING = 64
local queue, jobs, serial = {}, {}, 0
local thread, input, output, active
local function cancel(job)
job.cancelled = true
if job.cancelSignal then job.cancelSignal:push(true) end
end
local clock = function() return love and love.timer and love.timer.getTime() or os.clock() end
local function record(job, phase, seconds, route)
if Stream.onTiming then Stream.onTiming(job.kind, job.key, phase, seconds, route) end
if os.getenv("POKEPORT_ASSET_PROFILE") == "1" then
print(string.format("[game3/asset] %s=%s %s=%.3fms route=%s", job.kind, job.key, phase, seconds * 1000, route))
end
end
local function startWorker()
if thread then return true end
if not (love and love.thread and love.thread.newThread and love.thread.newChannel) then return false end
local ok, value = pcall(love.thread.newThread, "src/core/game3/asset_worker.lua")
if not ok then return false end
input, output = love.thread.newChannel(), love.thread.newChannel()
local started = pcall(value.start, value, input, output)
if not started then return false end
thread = value
return true
end
function Stream.shutdown()
Stream.cancelPending()
if thread then
input:clear(); input:push({ stop = true }); thread:wait()
end
thread, input, output, active = nil, nil, nil, nil
queue, jobs = {}, {}
Stream._uploadedThisFrame = false
Stream.workerFailed = false
end
function Stream.cancelPending()
for _, job in pairs(jobs) do
cancel(job)
job.owner.pending[job.key] = nil
end
jobs = {}
end
function Stream.frameComplete() Stream._uploadedThisFrame = false end
local Lifecycle = require("src.core.SessionLifecycle")
if not Lifecycle._game3AssetShutdownRegistered then
Lifecycle._game3AssetShutdownRegistered = true
Lifecycle.registerProcessShutdown(function()
local current = package.loaded["src.core.game3.asset_stream"]
if current then current.shutdown() end
end)
end
function Stream.new(kind, cache, root, upload, publish)
local s = { kind = kind, cache = cache, root = root, upload = upload, publish = publish, pending = {} }
function s:cancel()
self.cancelled = true
for _, job in pairs(self.pending) do cancel(job); jobs[job.id] = nil end
self.pending = {}
end
function s:decoded(key)
local job = self.pending[key]
return job and job.data ~= nil
end
function s:retain(wanted)
for key, job in pairs(self.pending) do
if not wanted[key] then
cancel(job); jobs[job.id], self.pending[key] = nil, nil
end
end
end
function s:prefetch(key, priority)
if self.cancelled then return end
if self.pending[key] then self.pending[key].priority = priority or self.pending[key].priority; return end
local count = 0
for _ in pairs(self.pending) do count = count + 1 end
if count >= Stream.MAX_PENDING then return end
serial = serial + 1
local spec = self.cache and self.cache.assetWorkerSpec and self.cache:assetWorkerSpec(root, kind, key)
local job = { id = serial, kind = kind, key = key, root = root, owner = self, spec = spec, priority = priority or 1 }
self.pending[key], jobs[serial] = job, job
queue[#queue + 1] = job
end
function s:get(key)
Stream.poll()
local job = self.pending[key]
local data = job and job.data
if not data then
if job and job.cancelSignal then job.cancelSignal:push(true) end
local start = clock()
local err
data, err = Decode[kind](cache, root, key)
record({ kind = kind, key = key }, "decode", clock() - start, "sync")
if not data then return nil, err end
end
local start = clock()
local co = job and job.co or coroutine.create(function() return upload(data) end)
local value
repeat
local ok, res = coroutine.resume(co)
if not ok then
if job then job.cancelled = true; jobs[job.id] = nil; self.pending[key] = nil end
return nil, tostring(res)
end
value = res
until coroutine.status(co) == "dead"
if job then job.cancelled = true; jobs[job.id] = nil; self.pending[key] = nil end
publish(key, value)
record({ kind = kind, key = key }, "upload", clock() - start, data == (job and job.data) and "decoded" or "sync")
return value
end
return s
end
function Stream.poll()
if output then
local result = output:pop()
if result then
if result.fatal then
if active then active.spec, active.sent = nil, false end
Stream.workerFailed, active = true, nil
print("[game3/asset] worker unavailable: " .. tostring(result.fatal))
end
local job = jobs[result.id]
if active and active.id == result.id then active = nil end
if job and not job.cancelled then
job.data, job.error, job.done = result.data, result.error, true
record(job, "decode", result.seconds, "worker")
end
end
end
if thread and thread:getError() then
if active then active.spec, active.sent = nil, false end
thread, input, output, active = nil, nil, nil, nil
Stream.workerFailed = true
end
if active then return end
local decoded = 0
for _, job in ipairs(queue) do if not job.cancelled and job.data then decoded = decoded + 1 end end
if decoded >= Stream.MAX_DECODED then return end
local nextJob
for _, job in ipairs(queue) do
if not job.cancelled and not job.done and job ~= active and not job.sent then
if job.spec and (not nextJob or job.priority < nextJob.priority) then nextJob = job end
end
end
if nextJob and not Stream.workerFailed and startWorker() then
nextJob.sent, active = true, nextJob
nextJob.cancelSignal = love.thread.newChannel()
input:push({ id = nextJob.id, kind = nextJob.kind, key = nextJob.key, root = nextJob.root,
spec = nextJob.spec, cancelSignal = nextJob.cancelSignal })
end
-- Unknown/custom caches prepare synchronously only when explicitly consumed.
end
function Stream.update()
Stream.poll()
if Stream._uploadedThisFrame then return end
local start = clock()
for i = #queue, 1, -1 do if queue[i].cancelled then table.remove(queue, i) end end
local nextJob
for _, job in ipairs(queue) do
if job.data and (not nextJob or job.priority < nextJob.priority) then nextJob = job end
end
local job = nextJob
if job then
Stream._uploadedThisFrame = true
if not job.co then job.co = coroutine.create(function() return job.owner.upload(job.data) end) end
local t = clock()
local ok, value = coroutine.resume(job.co)
if not ok then
job.error, job.done, job.data = tostring(value), true, nil
job.co = nil
elseif coroutine.status(job.co) == "dead" then
job.owner.publish(job.key, value)
job.owner.pending[job.key], jobs[job.id], job.cancelled = nil, nil, true
end
record(job, "upload_step", clock() - t, "warm")
-- A texture upload (including animation catch-up on publication) is
-- atomic. One coroutine slice per render frame prevents catch-up ticks
-- and a second ready asset from silently exceeding the upload budget.
if clock() - start > Stream.UPLOAD_BUDGET then Stream.lastOverBudget = clock() - start end
end
end
return Stream
+22
View File
@@ -0,0 +1,22 @@
local input, output = ...
local running, fatal = pcall(function()
require("love.filesystem")
require("love.image")
require("love.data")
require("love.timer")
table.insert(package.searchers or package.loaders, 1, function(name)
local path = name:gsub("%.", "/") .. ".lua"
if love.filesystem.getInfo(path) then return assert(love.filesystem.load(path)) end
end)
local D = require("src.core.game3.asset_decode")
while true do
local job = input:demand()
if job.stop then break end
local start = love.timer.getTime()
local ok, data, err = pcall(D[job.kind], D.cache(job.spec, job.cancelSignal), job.root, job.key)
output:push({ id = job.id, data = ok and data or nil, error = ok and err or tostring(data),
seconds = love.timer.getTime() - start })
end
end)
if not running then output:push({ fatal = tostring(fatal) }) end
+10
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
+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
+12 -1
View File
@@ -1780,7 +1780,7 @@ function FieldView.draw(game, canvasW, canvasH, opts)
end
local FieldWeather = modFieldWeather()
if FieldWeather and FieldWeather.draw then
FieldWeather.draw(camX, camY, canvasW, canvasH)
FieldWeather.draw(camX, camY, canvasW, canvasH, opts.exchangeCanvas)
end
end
@@ -1833,10 +1833,21 @@ function FieldView.invalidate()
if OwSprites and OwSprites.invalidate then
OwSprites.invalidate()
end
local WeatherRse = package.loaded["src.core.game3.field_weather_rse"]
if WeatherRse and WeatherRse.invalidate then WeatherRse.invalidate() end
local FieldEffects = modFieldEffects()
if FieldEffects and FieldEffects.invalidate then
FieldEffects.invalidate()
end
end
local Assets = require("src.render.Assets")
if Assets.register and not Assets._game3FieldInvalidatorRegistered then
Assets._game3FieldInvalidatorRegistered = true
Assets.register(function()
local current = package.loaded["src.core.game3.field_view"]
if current then current.invalidate() end
end)
end
return FieldView
+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)
+93 -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,70 @@ function Map.warmNear(entry, x0, y0, x1, y1)
return ox + w > x0 and ox <= x1 and oy + h > y0 and oy <= y1
end
function Map.stepWarm()
function Map.stepWarm(game)
local Stream = package.loaded["src.core.game3.asset_stream"]
local Native = package.loaded["src.core.game3.tileset_native"]
if Native and Native.prefetch then
local x0, y0, x1, y1 = Map.warmRect()
Map._warmScanTick = ((Map._warmScanTick or 0) + 1) % 4
if Map._warmScanTick ~= 0 and Map._warmScanWorld == Map.world
and Map._warmScanX == x0 and Map._warmScanY == y0
and Map._warmScanX1 == x1 and Map._warmScanY1 == y1 then
if Stream then Stream.update() end
return true
end
Map._warmScanWorld = Map.world
Map._warmScanX, Map._warmScanY, Map._warmScanX1, Map._warmScanY1 = x0, y0, x1, y1
local vx0, vy0, vx1, vy1 = Map.warmRect(0)
local wantedPairs = {}
local priorities = {}
local def = Map.currentDef()
local currentPair = def and (def.pair or (def.midLayout and def.midLayout.pair))
if currentPair then wantedPairs[currentPair], priorities[currentPair] = true, 0 end
local queue = Map._warmQueue or {}
for i = #queue, 1, -1 do
local pair = queue[i]
local near = not (Map._warmEntries and Map._warmEntries[pair])
for _, entry in ipairs(Map._warmEntries and Map._warmEntries[pair] or {}) do
if Map.warmNear(entry, x0, y0, x1, y1) then near = true end
if Map.warmNear(entry, vx0, vy0, vx1, vy1) then priorities[pair] = 0 end
end
if Native._pairs[pair] then table.remove(queue, i)
elseif near then wantedPairs[pair] = true end
end
if not queue[1] then Map._warmQueue = nil end
if Native._stream then Native._stream:retain(wantedPairs) end
for pair in pairs(wantedPairs) do Native.prefetch(pair, priorities[pair] or 1) end
local Ow = package.loaded["src.core.game3.ow_sprites"]
if game and Ow and Ow.prefetch then
local wanted = {}
local function actor(eo, ox, oy)
if not (eo.visible and not eo.hidden and not eo.invisible) then return end
local x, y = (eo.cellX or 0) + (ox or 0), (eo.cellY or 0) + (oy or 0)
if not x0 or (x >= x0 and x <= x1 and y >= y0 and y <= y1) then
local gid = tonumber(eo.graphicsId)
if gid then
local visible = not vx0 or (x >= vx0 and x <= vx1 and y >= vy0 and y <= vy1)
wanted[gid] = math.min(wanted[gid] or 1, visible and 0 or 1)
end
end
end
local Obj = package.loaded["src.core.game3.objects"]
for _, eo in ipairs(Obj and Obj.forDraw and Obj.forDraw() or {}) do actor(eo) end
local Ghosts = package.loaded["src.core.game3.ghosts"]
for _, entry in ipairs(Map.world or {}) do
if Map.warmNear(entry, x0, y0, x1, y1) then
for _, eo in ipairs(Ghosts and Ghosts.forDraw(entry.id) or {}) do actor(eo, entry.ox, entry.oy) end
end
end
local gid = Ow.playerGraphicsId(game)
if gid then wanted[gid] = 0 end
if Ow._stream then Ow._stream:retain(wanted) end
for id, priority in pairs(wanted) do Ow.prefetch(id, priority) end
end
if Stream then Stream.update() end
return true
end
local queue = Map._warmQueue
if not queue then return false end
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
+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
@@ -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
@@ -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,32 @@
local run = require("tests.drivers.game3_seam_walk_stress")
local function stats(v)
table.sort(v)
local sum = 0; for _, n in ipairs(v) do sum = sum + n end
return string.format("n=%d mean=%.3f p99=%.3f max=%.3f", #v, sum / math.max(1, #v), v[math.ceil(#v * .99)] or 0, v[#v] or 0)
end
return function(game)
local samples = { update = {}, draw = {}, seam = {}, pair = {}, sprite = {} }
local function wrap(owner, key, label, onlySeams)
local old = owner[key]
owner[key] = function(...)
local t = love.timer.getTime()
local a, b, c = old(...)
local ms = (love.timer.getTime() - t) * 1000
local args = { ... }
if not onlySeams or (args[4] and args[4].seamless) then
local v = samples[label]; v[#v + 1] = ms
if (label == "pair" or label == "sprite") and ms > 1 then
print(string.format("SEAM_ASSET_STALL %s id=%s ms=%.3f", label, tostring(args[1]), ms))
end
end
return a, b, c
end
end
wrap(game, "update", "update")
wrap(game, "draw", "draw")
wrap(require("src.core.game3.map"), "load", "seam", true)
wrap(require("src.core.game3.tileset_native"), "get", "pair")
wrap(require("src.core.game3.ow_sprites"), "get", "sprite")
run(game)
for label, v in pairs(samples) do print("SEAM_PROFILE " .. label .. " " .. stats(v)) end
end
+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
+82
View File
@@ -0,0 +1,82 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local channels, worker = {}, {}
love.thread = {
newChannel = function()
local ch = { q = {} }
function ch:push(v) self.q[#self.q + 1] = v end
function ch:pop() return table.remove(self.q, 1) end
function ch:clear() self.q = {} end
channels[#channels + 1] = ch
return ch
end,
newThread = function()
function worker:start(input, output) self.input, self.output = input, output end
function worker:getError() return self.error end
function worker:wait() self.joined = true end
return worker
end,
}
local Stream = require("src.core.game3.asset_stream")
local published, uploads = {}, 0
local cache = { assetWorkerSpec = function() return { prefix = "emerald/" } end }
local function owner()
return Stream.new("pair", cache, "ROOT", function(data)
for _ = 1, data.layers do uploads = uploads + 1; coroutine.yield("texture") end
return data
end, function(key, data) published[key] = data end)
end
local s = owner()
s:prefetch("A"); s:prefetch("A"); Stream.poll()
eq(#worker.input.q, 1, "duplicate requests are coalesced")
local request = worker.input:pop()
eq(request.spec.prefix, "emerald/", "worker receives an immutable cartridge namespace")
eq(uploads, 0, "queuing CPU work does not touch graphics")
worker.output:push({ id = request.id, data = { layers = 3 }, seconds = .01 })
Stream.update()
eq(uploads, 1, "only one texture layer uploads in an update")
Stream.update(); eq(uploads, 1, "fixed-step catchup cannot upload another layer in the same render frame")
Stream.frameComplete(); Stream.update(); eq(uploads, 2, "next render frame admits the next layer")
check(published.A == nil, "partially uploaded atlases are never published")
local result = s:get("A")
eq(uploads, 3, "unexpected demand finishes the existing partial upload without duplicating textures")
check(published.A == result and result.layers == 3, "fallback publishes the complete atlas")
s:prefetch("STALE"); Stream.poll(); request = worker.input:pop()
s:cancel()
eq(#request.cancelSignal.q, 1, "cache reset signals active CPU preparation to stop")
worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 })
Stream.frameComplete(); Stream.update()
check(published.STALE == nil, "cache reset rejects a late result")
eq(uploads, 3, "stale data does not allocate a texture")
local nextOwner = owner()
nextOwner:prefetch("OUTSIDE"); Stream.poll(); request = worker.input:pop()
nextOwner:retain({})
eq(#request.cancelSignal.q, 1, "leaving the neighborhood interrupts active preparation")
worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 })
Stream.frameComplete(); Stream.update()
check(published.OUTSIDE == nil, "leaving the warm neighborhood cancels queued work")
for i = 1, 100 do nextOwner:prefetch("LIMIT" .. i) end
local count = 0; for _ in pairs(nextOwner.pending) do count = count + 1 end
eq(count, Stream.MAX_PENDING, "pending assets have a residency bound")
Stream.cancelPending()
check(next(nextOwner.pending) == nil, "field stop cancels all pending requests")
Stream.shutdown()
check(worker.joined, "process teardown joins the worker before filesystem teardown")
local priorityOwner = owner()
priorityOwner:prefetch("NEAR", 1)
priorityOwner:prefetch("VISIBLE", 0)
Stream.poll(); request = worker.input:pop()
eq(request.key, "VISIBLE", "visible assets decode before nearby assets")
worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 })
Stream.poll(); request = worker.input:pop()
eq(request.key, "NEAR", "nearby preparation follows visible preparation")
worker.output:push({ id = request.id, data = { layers = 1 }, seconds = .01 })
Stream.poll()
Stream.frameComplete(); Stream.update()
eq(coroutine.status(priorityOwner.pending.VISIBLE.co), "suspended", "visible decoded assets upload first")
check(priorityOwner.pending.NEAR.co == nil, "nearby uploads wait for the visible upload slice")
Stream.shutdown()
T.finish("game3_asset_stream_test")
@@ -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")