diff --git a/src/core/Game3.lua b/src/core/Game3.lua index c0539d1d..8598835e 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -15,6 +15,7 @@ local MapIds = require("src.core.game3.map_ids") local Profile = require("src.core.game3.profile") local Runtime = require("src.core.game3.runtime") local Audio = require("src.core.game3.audio") +local Warm = require("src.core.game3.warm") local Options = require("src.core.game3.options") local Dataset = require("src.core.game3.dataset") local Display = require("src.core.game3.display") @@ -117,6 +118,10 @@ function Game3:_enterField(session, reason, opts) -- pokefirered/src/fieldmap.c:100 Runtime.start(nil, self, session, { reason = reason or "new_game" }) Map._nextEnterVia = nil + pcall(function() require("src.core.game3.prewarm").session(session) end) + local Syms = package.loaded["src.import.gba.syms"] + if Syms and Syms.reset then Syms.reset() end + collectgarbage("collect") if fieldCallback then require(fieldCallback).execute() end if reason == "continue" then if lazyReq("src.core.game3.profile").family(session) == "rse" then @@ -755,6 +760,8 @@ function Game3:update(dt) local okP, errP = pcall(function() lazyReq("src.render.Pipelines").update(dt) end) if not okP then s9log("pipelines", errP) end pcall(function() lazyReq("src.core.DiscordPresence").update(dt) end) + local okW, errW = pcall(Warm.step) + if not okW then s9log("warm", errW) end Game3.stepGC() end diff --git a/src/core/Music.lua b/src/core/Music.lua index 404c6189..6be8d797 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -23,8 +23,8 @@ local function applyVolume(src) fading = state.fade ~= nil, optionScale = volumeScale, } - local ok, Game = pcall(require, "src.core.Game") - if ok and Game then + local Game = package.loaded["src.core.Game"] + if type(Game) == "table" then local ow = Game.overworld if ow and ow.player then ctx.x, ctx.y = ow.player.cellX, ow.player.cellY diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 9bad18dc..0a8fc0d1 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -9,6 +9,7 @@ local Mix = require("src.core.game3.m4a_mix") local Player = require("src.core.game3.m4a_player") local SE = require("src.core.game3.se_ids") local Song = require("src.core.game3.song_ids") +local Warm = require("src.core.game3.warm") local ffiOk, ffi = pcall(require, "ffi") local Audio = {} @@ -817,6 +818,7 @@ Audio.SE_LOOP_MAX_SEC = 2.5 Audio.SE_ONESHOT_MAX_SEC = 30 function Audio._seRawClear() + Audio._cryCache, Audio._cryCacheN = nil, 0 Audio._seRaw = {} Audio._seRawFrames = 0 Audio._seRawTick = 0 @@ -919,6 +921,55 @@ function Audio._seSourceFor(id, rawL, rawR, master, pan, mono, loop) return src end +local function bake_se(id, opts, memoable) + local slot = { voices = {} } + -- SE must run the M4A sequencer (SE_SELECT is CGB pulse, not voice0 PCM). + local ok = Player.start(Audio._pack, Audio._cache, slot, id, { forceSeq = true }) + if not ok then + warn_once("se:" .. tostring(id), "SE " .. tostring(id) .. " missing") + return nil + end + + local loop = opts.loop + if loop == nil then + -- SE_LOW_HEALTH and any track with GOTO before FINE are hardware loops. + loop = (id == SE.SE_LOW_HEALTH) or Audio._songHasGoto(slot) + end + + local loopBody = loop and opts.loop == nil and id ~= SE.SE_LOW_HEALTH + -- pokefirered/src/battle_anim_special.c:1200 + local cut = ((loop and not loopBody) or id == SE.SE_EXP) + local maxSec = opts.maxSec + or (cut and Audio.SE_LOOP_MAX_SEC or Audio.SE_ONESHOT_MAX_SEC) + local rawL, rawR, loopStart = Player.bakeSlot(slot, { + raw = true, + maxSec = maxSec, + stopOnGoto = loop and true or false, + loopBody = loopBody, + }) + if not cut and opts.maxSec == nil and type(rawL) == "table" + and #rawL >= math.floor(Mix.SAMPLE_RATE * maxSec) then + warn_once("selen:" .. tostring(id), + "SE " .. tostring(id) .. " hit the " .. tostring(maxSec) .. "s bake ceiling") + end + if memoable then Audio._seRawPut(id, loop and true or false, rawL, rawR, loopStart) end + return loop and true or false, rawL, rawR, loopStart +end + +function Audio.prewarmSe(id, priority) + id = SE.resolve(id) + if id == nil or not Audio.isReady() then return false end + if Audio._seRaw and Audio._seRaw[id] then return false end + local info = Audio.songInfo(id) + if info and info.kind == "fanfare" then return false end + return Warm.add("se:" .. tostring(id), function() + if Audio._seRaw and Audio._seRaw[id] then return end + local loop, rawL, rawR = bake_se(id, {}, true) + if loop == nil or loop then return end + Audio._seSourceFor(id, rawL, rawR, Audio._sfxVolume or 1, Audio.normalizePan(nil), Audio._mono, false) + end, priority) +end + function Audio.playSe(id, opts) opts = opts or {} id = SE.resolve(id) @@ -938,42 +989,14 @@ function Audio.playSe(id, opts) Audio._stopSePlayer(mplay) local memoable = opts.loop == nil and opts.maxSec == nil + if memoable then Warm.flush("se:" .. tostring(id)) end local hit = memoable and Audio._seRawGet(id) or nil local loop, rawL, rawR, loopStart if hit then loop, rawL, rawR, loopStart = hit.loop, hit.rawL, hit.rawR, hit.loopStart else - local slot = { voices = {} } - -- SE must run the M4A sequencer (SE_SELECT is CGB pulse, not voice0 PCM). - local ok = Player.start(Audio._pack, Audio._cache, slot, id, { forceSeq = true }) - if not ok then - warn_once("se:" .. tostring(id), "SE " .. tostring(id) .. " missing") - return false - end - - loop = opts.loop - if loop == nil then - -- SE_LOW_HEALTH and any track with GOTO before FINE are hardware loops. - loop = (id == SE.SE_LOW_HEALTH) or Audio._songHasGoto(slot) - end - - local loopBody = loop and opts.loop == nil and id ~= SE.SE_LOW_HEALTH - -- pokefirered/src/battle_anim_special.c:1200 - local cut = ((loop and not loopBody) or id == SE.SE_EXP) - local maxSec = opts.maxSec - or (cut and Audio.SE_LOOP_MAX_SEC or Audio.SE_ONESHOT_MAX_SEC) - rawL, rawR, loopStart = Player.bakeSlot(slot, { - raw = true, - maxSec = maxSec, - stopOnGoto = loop and true or false, - loopBody = loopBody, - }) - if not cut and opts.maxSec == nil and type(rawL) == "table" - and #rawL >= math.floor(Mix.SAMPLE_RATE * maxSec) then - warn_once("selen:" .. tostring(id), - "SE " .. tostring(id) .. " hit the " .. tostring(maxSec) .. "s bake ceiling") - end - if memoable then Audio._seRawPut(id, loop and true or false, rawL, rawR, loopStart) end + loop, rawL, rawR, loopStart = bake_se(id, opts, memoable) + if loop == nil then return false end end local pan = Audio.normalizePan(opts.pan) @@ -1045,6 +1068,7 @@ function Audio._buildSeSoundData(L, R, master, pan, mono) master = master or 1 local gainL, gainR = Audio._seGains(pan) local gl, gr = master * gainL, master * gainR + local warm = package.loaded["src.core.game3.warm"] local ptr if ffiOk and ffi and sd.getFFIPointer then local okP, p = pcall(sd.getFFIPointer, sd) @@ -1068,6 +1092,7 @@ function Audio._buildSeSoundData(L, R, master, pan, mono) sd:setSample(i - 1, 1, l) sd:setSample(i - 1, 2, r) end + if warm and i % 4096 == 0 then warm.yield() end end return sd end @@ -1338,6 +1363,67 @@ function Audio.waitFanfare(cb) if not Audio._fanfareActive and cb then cb() end end +Audio.CRY_CACHE_MAX = 24 + +local function cry_inputs(mode, pan, volume) + mode = tonumber(mode) or 0 + local cfg = Audio.config() + -- pokeruby/src/sound.c:364 + if cfg.cryModeMax and (mode < 0 or mode > cfg.cryModeMax) then mode = 0 end + local params = Sample.cryParams(mode, volume or cfg.cryDefaultVolume, cfg.cryModeOverrides) + return params, pan and pan ~= 0 and Audio.normalizePan(pan) or nil +end + +local function cry_meta(slot) + local cry = slot and slot.info + if not (cry and cry.cryIndex ~= nil) then return nil end + local c = Audio._pack.index.cries[cry.cryIndex] + return c and Audio._pack.samples[c.sampleId], cry.cryIndex +end + +local function cry_render(slot, params, pan, mode, volume) + local meta, cryIndex = cry_meta(slot) + if not meta then return nil end + local key = table.concat({ tostring(cryIndex), tostring(mode), tostring(volume), tostring(pan), + Audio._mono and "m" or "s" }, ":") + Warm.flush("cry:" .. key) + local cache = Audio._cryCache or {} + Audio._cryCache = cache + local hit = cache[key] + if hit then return hit.sd, hit.info end + local pcm = Sample.loadPcm(Audio._pack.samplesBin, meta) + if not pcm then return nil end + local sd, info = Sample.renderCry(pcm, Mix.waveRate(meta.freq), params, { + outRate = Mix.SAMPLE_RATE, + pan = pan, + mono = Audio._mono, + }) + if sd then + Audio._cryCacheN = (Audio._cryCacheN or 0) + 1 + if Audio._cryCacheN > Audio.CRY_CACHE_MAX then + Audio._cryCache, Audio._cryCacheN = {}, 1 + cache = Audio._cryCache + end + cache[key] = { sd = sd, info = info } + end + return sd, info +end + +function Audio.prewarmCry(species, mode, pan, priority) + species = tonumber(species) or species + if species == nil or not Audio.isReady() then return false end + local params, npan = cry_inputs(mode, pan, nil) + local slot = Player.startCry(Audio._pack, species, { pitch = 1.0 }) + local _, cryIndex = cry_meta(slot) + if cryIndex == nil then return false end + local key = table.concat({ tostring(cryIndex), tostring(tonumber(mode) or 0), "nil", tostring(npan), + Audio._mono and "m" or "s" }, ":") + if Audio._cryCache and Audio._cryCache[key] then return false end + return Warm.add("cry:" .. key, function() + cry_render(slot, params, npan, tonumber(mode) or 0, nil) + end, priority) +end + -- pokefirered/src/sound.c:333 function Audio.playCry(species, mode, pan) species = tonumber(species) or species @@ -1350,10 +1436,7 @@ function Audio.playCry(species, mode, pan) noDuck = o.noDuck == true end mode = tonumber(mode) or 0 - local cfg = Audio.config() - -- pokeruby/src/sound.c:364 - if cfg.cryModeMax and (mode < 0 or mode > cfg.cryModeMax) then mode = 0 end - local params = Sample.cryParams(mode, volume or cfg.cryDefaultVolume, cfg.cryModeOverrides) + local params, npan = cry_inputs(mode, pan, volume) local doubles = params.mode == 1 or noDuck Audio._cryParams = params log(string.format("playCry species=%s mode=%d", tostring(species), params.mode)) @@ -1367,25 +1450,14 @@ function Audio.playCry(species, mode, pan) return false end Audio._crySlot = slot - local cry = slot.info - local meta = nil - if cry and cry.cryIndex ~= nil then - local c = Audio._pack.index.cries[cry.cryIndex] - if c then meta = Audio._pack.samples[c.sampleId] end - end - local pcm = meta and Sample.loadPcm(Audio._pack.samplesBin, meta) - if pcm then + if cry_meta(slot) then Audio._crySource = nil if not doubles then Audio._duck = 85 / 256 Audio._duckHold = 2 apply_bgm_gain() end - local sd, info = Sample.renderCry(pcm, Mix.waveRate(meta.freq), params, { - outRate = Mix.SAMPLE_RATE, - pan = pan and pan ~= 0 and Audio.normalizePan(pan) or nil, - mono = Audio._mono, - }) + local sd, info = cry_render(slot, params, npan, mode, volume) if info then Audio._cryUntil = (Audio._cryClock or 0) + info.frames end diff --git a/src/core/game3/battle/bg.lua b/src/core/game3/battle/bg.lua index 95c6861f..ec006531 100644 --- a/src/core/game3/battle/bg.lua +++ b/src/core/game3/battle/bg.lua @@ -81,6 +81,21 @@ local function frlgSheetKey(env, id) return primary end +function BattleBg.resolveOpts(opts) + local terrain = opts.terrain + -- pokefirered/src/battle_main.c:689 + if terrain == nil and (opts.mapBehavior ~= nil or opts.mapType ~= nil) then + terrain = BattleBg.resolveFromBehavior(opts.mapBehavior, opts.mapKind, opts.mapType) + end + if terrain == nil and opts.mapKind then + terrain = BattleBg.resolveFromMapKind(opts.mapKind) + end + if terrain == nil then + terrain = BattleBg.TERRAIN.BUILDING + end + return terrain +end + function BattleBg.sheetKey(id) id = tonumber(id) or BattleBg._terrainId local env = BattleBg.env() diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index caa71f9d..c9a64ca9 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -679,17 +679,7 @@ function Battle.start(opts) end local BattleBg = require("src.core.game3.battle.bg") - local terrain = opts.terrain - -- pokefirered/src/battle_main.c:689 - if terrain == nil and (opts.mapBehavior ~= nil or opts.mapType ~= nil) then - terrain = BattleBg.resolveFromBehavior(opts.mapBehavior, opts.mapKind, opts.mapType) - end - if terrain == nil and opts.mapKind then - terrain = BattleBg.resolveFromMapKind(opts.mapKind) - end - if terrain == nil then - terrain = BattleBg.TERRAIN.BUILDING - end + local terrain = BattleBg.resolveOpts(opts) st.terrain = terrain -- pokefirered/src/battle_bg.c:714 BattleBg.setTerrain(BattleBg.resolveOverride(terrain, { diff --git a/src/core/game3/battle/moves.lua b/src/core/game3/battle/moves.lua index 433881d6..a7173770 100644 --- a/src/core/game3/battle/moves.lua +++ b/src/core/game3/battle/moves.lua @@ -48,8 +48,13 @@ function Moves.loadRomPack(cache) Moves._romLoaded = true local root = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon/battle_moves.lua" cache = cache or require("src.core.game3.dataset").cache() - local src = assert(cache:read(root), "pokemon/battle_moves.lua is not in the cache") - local pack = assert(load(src, "@" .. root, "t", {}))() + local chunk = Moves._romChunk + if not (chunk and Moves._romChunkCache == cache) then + local src = assert(cache:read(root), "pokemon/battle_moves.lua is not in the cache") + chunk = assert(load(src, "@" .. root, "t", {})) + Moves._romChunk, Moves._romChunkCache = chunk, cache + end + local pack = chunk() Moves._rom = assert(pack and pack.moves, "pokemon/battle_moves.lua has no moves") Moves._numByName = nil build_names() diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 33b8e0aa..e4498081 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -243,7 +243,7 @@ function Ui.reset(opts) Ui._oldManSubstate = nil if Message and Message.isHeld and Message.isHeld() then Message.close() end if not Ui._headless then - local okC, errC = pcall(BattleChrome.install, nil) + local okC, errC = pcall(BattleChrome.ensureInstalled) if not okC and not chromeInstallWarned then chromeInstallWarned = true print("[game3/battle.ui] BattleChrome.install failed: " .. tostring(errC)) diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 37435a61..5018bab8 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -146,8 +146,8 @@ function BattleBridge.installWhiteoutIntercept(mod, game) end BattleBridge._whiteoutHook = onWhiteout - local ok, World = pcall(require, "src.world.gen2.World") - if ok and World then + local World = package.loaded["src.world.gen2.World"] + if type(World) == "table" then if type(World.whiteOut) == "function" and not World._game3WhiteOut then local prev = World.whiteOut World.whiteOut = function(self, ...) @@ -398,6 +398,9 @@ function BattleBridge.start(mod, game, foe, opts) if not session then return nil, "no session" end BattleBridge.installWhiteoutIntercept(mod, game) + if not opts.headless then + pcall(function() require("src.core.game3.prewarm").battle(session, foe) end) + end if opts.wild and require("src.core.game3.profile").family(session) == "rse" then -- pokeemerald/src/battle_setup.c:417 require("src.core.game3.rse.init").call("tv", "incrementDailyWildBattles", "IncrementDailyWildBattles", nil) @@ -659,6 +662,9 @@ function BattleBridge.start(mod, game, foe, opts) local StayMessage = package.loaded["src.ui.game3.message"] if StayMessage and StayMessage.closeStay then StayMessage.closeStay() end end + if not opts.headless then + pcall(function() require("src.core.game3.prewarm").battleStart(startOpts) end) + end local function doStart() local ok, err = Battle.start(startOpts) diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index 1afd4edb..8fccf24d 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -376,11 +376,29 @@ function Dataset.mountExtractRoots() end local manifestLayouts = {} +local lazyLayouts, LazyDef function Dataset.invalidateManifestCache() manifestLayouts = {} end +lazyLayouts = setmetatable({}, { __mode = "k" }) + +LazyDef = { + __index = function(def, k) + if k ~= "midLayout" then return nil end + local src = lazyLayouts[def] + if not src then return nil end + lazyLayouts[def] = nil + local blob = src.cache:read(src.rel) + local decoded = blob and require("src.import.gba.native_pack").decodeMidLayout(blob) + if not decoded then return nil end + local layout = require("src.core.game3.layout_native").fromDecoded(decoded, src.mapId, src.pair, blob) + rawset(def, "midLayout", layout) + return layout + end, +} + --- Bind LayoutNative handles onto map defs (FieldView needs midLayout). function Dataset.attachMidLayouts(maps, cache) if type(maps) ~= "table" then return 0 end @@ -397,25 +415,28 @@ function Dataset.attachMidLayouts(maps, cache) end local attached = 0 for mapId, def in pairs(maps) do - if def and not def.midLayout then + if def and not rawget(def, "midLayout") and not lazyLayouts[def] then local info = layouts[mapId] local rel = nativeRoot .. "/" .. ((info and info.file) or ("layouts/" .. mapId .. ".mid")) local blob = cache:read(rel) - if blob then - local decoded = NativePack.decodeMidLayout(blob) - if decoded then - local pair = (info and info.pair) or def.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 - if th and th > 0 then def.height = th end - if pair then def.pair = pair end - attached = attached + 1 + if blob and #blob >= 16 and blob:sub(1, 4) == NativePack.MAGIC_MID then + local pair = (info and info.pair) or def.pair + local tw = blob:byte(11) + blob:byte(12) * 256 + local th = blob:byte(13) + blob:byte(14) * 256 + if tw > 0 then def.width = tw end + if th > 0 then def.height = th end + if pair then def.pair = pair end + if getmetatable(def) == nil then + lazyLayouts[def] = { cache = cache, rel = rel, mapId = mapId, pair = pair } + setmetatable(def, LazyDef) + else + local decoded = NativePack.decodeMidLayout(blob) + if decoded then def.midLayout = LayoutNative.fromDecoded(decoded, mapId, pair, blob) end end + attached = attached + 1 end - elseif def and def.midLayout then + elseif def then attached = attached + 1 end end diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index ed9b0cee..3597cbd7 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -146,7 +146,7 @@ local function drawFrlgReflections(camX, camY) for j = 1, #live do local obj = live[j] if not obj.hideReflection and obj.graphicsId and not obj.virtualId then - local spr = Ow.getDraw(obj.graphicsId) + local spr = (Ow.peekDraw or Ow.getDraw)(obj.graphicsId) if spr then objectPoseOpts.frame = obj.customFrame local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip, diff --git a/src/core/game3/field_effects_rse.lua b/src/core/game3/field_effects_rse.lua index 1ca1be8b..4d80f3fb 100644 --- a/src/core/game3/field_effects_rse.lua +++ b/src/core/game3/field_effects_rse.lua @@ -682,7 +682,7 @@ local ghostPose = {} local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY, ox, oy) local Ow = package.loaded["src.core.game3.ow_sprites"] - local spr = Ow and Ow.getDraw and Ow.getDraw(gid) + local spr = Ow and (Ow.peekDraw or Ow.getDraw)(gid) if not (spr and spr.quads and spr.quads[frame]) then return false end ox, oy = ox or 0, oy or 0 local cx = (obj.moving and obj.targetX or obj.cellX) + ox @@ -871,7 +871,7 @@ local function drawReflections(camX, camY) for j = 1, #live do local eo = live[j] if not eo.hideReflection and eo.graphicsId and not eo.virtualId then - local spr = Ow.getDraw(eo.graphicsId) + local spr = (Ow.peekDraw or Ow.getDraw)(eo.graphicsId) if spr then ghostPose.frame = eo.customFrame local frame, flip = Ow.pose(spr, eo.facing, O.walkPhase(eo), eo.stepFlip, ghostPose) diff --git a/src/core/game3/item_use.lua b/src/core/game3/item_use.lua index c63ac4b2..7b4ec871 100644 --- a/src/core/game3/item_use.lua +++ b/src/core/game3/item_use.lua @@ -47,7 +47,8 @@ end local function not_the_time(session) if require("src.core.game3.profile").family(session) == "rse" then -- pokeemerald/src/item_use.c:158 DisplayDadsAdviceCannotUseItemMessage - return (RomText.box("gText_DadsAdvice", { playerName = player_name(session) })) + -- pokeruby/src/item_use.c:146 + return (RomText.box(is_rs() and "gOtherText_DadsAdvice" or "gText_DadsAdvice", { playerName = player_name(session) })) end return (RomText.box("gText_OakForbidsUseOfItemHere", { playerName = player_name(session) })) end diff --git a/src/core/game3/m4a_player.lua b/src/core/game3/m4a_player.lua index 2b65744b..839e9e84 100644 --- a/src/core/game3/m4a_player.lua +++ b/src/core/game3/m4a_player.lua @@ -590,6 +590,8 @@ function Player.bakeSlot(slot, opts) else idle = 0 end + local warm = package.loaded["src.core.game3.warm"] + if warm then warm.yield() end end if #L == 0 then L[1] = 0 diff --git a/src/core/game3/m4a_sample.lua b/src/core/game3/m4a_sample.lua index f57d7013..1551f652 100644 --- a/src/core/game3/m4a_sample.lua +++ b/src/core/game3/m4a_sample.lua @@ -181,6 +181,7 @@ function Sample.renderCryMix(pcm, sampleRate, params, opts) local gain = (tonumber(params.volume) or Sample.CRY_VOLUME) / 127 local reverse = params.reverse and true or false local out = {} + local warm = package.loaded["src.core.game3.warm"] for i = 0, nOut - 1 do local g = env.gain(math.floor(i * frameRate / outRate)) * gain local acc = 0 @@ -196,6 +197,7 @@ function Sample.renderCryMix(pcm, sampleRate, params, opts) if acc > 1 then acc = 1 elseif acc < -1 then acc = -1 end end out[i + 1] = acc + if warm and i % 2048 == 2047 then warm.yield() end end local v1 = voices[1] local sampleFrames = v1.samples * frameRate / outRate @@ -223,6 +225,7 @@ function Sample.renderCry(pcm, sampleRate, params, opts) gainL = (127 - pan) / 191 gainR = (128 + pan) / 191 end + local warm = package.loaded["src.core.game3.warm"] for i = 0, #out - 1 do local v = out[i + 1] if channels == 2 then @@ -231,6 +234,7 @@ function Sample.renderCry(pcm, sampleRate, params, opts) else sd:setSample(i, v * (gainL + gainR) * 0.5) end + if warm and i % 2048 == 2047 then warm.yield() end end return sd, info end diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index 5cb15540..b081ee30 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -405,6 +405,15 @@ function OwSprites.getDraw(graphicsId) return OwSprites.get(graphicsId) end +function OwSprites.peekDraw(graphicsId) + graphicsId = tonumber(graphicsId) + if graphicsId == nil then return nil end + local ov = OwSprites._overrides and OwSprites._overrides[graphicsId] + if ov or OwSprites._loaded[graphicsId] or not OwSprites._stream then return OwSprites.getDraw(graphicsId) end + OwSprites.prefetch(graphicsId, 0) + return nil +end + local function paletteRgb(colors) if type(colors) ~= "table" then return nil end local out = {} diff --git a/src/core/game3/prewarm.lua b/src/core/game3/prewarm.lua new file mode 100644 index 00000000..efc271f2 --- /dev/null +++ b/src/core/game3/prewarm.lua @@ -0,0 +1,96 @@ +local Warm = require("src.core.game3.warm") +local Prewarm = {} + +Prewarm.FIELD_SE = { "SE_SELECT", "SE_WIN_OPEN", "SE_WALL_HIT", "SE_EXIT", "SE_RS_DOOR", "SE_LEDGE", + "SE_SLIDING_DOOR", "SE_PIN" } + +Prewarm.BATTLE_SE = { "SE_BALL_OPEN", "SE_BALL_THROW", "SE_FAINT", "SE_NOT_EFFECTIVE", "SE_EFFECTIVE", + "SE_SUPER_EFFECTIVE", "SE_EXP" } + +Prewarm.MODULES = { + "src.core.game3.party", "src.core.game3.trainer_sight", "src.core.game3.warp", + "src.core.game3.battle_downgrade", "src.core.game3.battle_bridge", "src.core.game3.battle_transition", + "src.core.game3.battle.ai", "src.core.game3.battle.ai_cmds", "src.core.game3.battle.ai_vm", + "src.core.game3.battle.ai_items", "src.core.game3.battle.link_guard", "src.ui.game3.stat_growth", + "src.ui.game3.map_name_popup", +} + +Prewarm.FAMILY_MODULES = { + rse = { "src.core.game3.audio_policy_rse", "src.ui.game3.rse.mapsec", "src.ui.game3.rse.scene_kit", + "src.core.game3.battle_transition_ids_rse", "src.core.game3.battle_transition_rse_a", + "src.core.game3.battle_transition_rse_b", "src.core.game3.battle_transition_rse_frontier" }, + frlg = { "src.core.game3.battle_transition_ids_frlg" }, +} + +Prewarm.FONT_FACES = { "normal", "small", "short", "narrow", "small_narrow" } + +Prewarm.CONSTANTS = { "weather", "region_map_sections", "trainer_classes", "moves", "abilities" } + +local function se_list(names, priority) + local Audio = package.loaded["src.core.game3.audio"] + if not Audio then return end + for _, name in ipairs(names) do Audio.prewarmSe(name, priority) end +end + +function Prewarm.session(session) + local Profile = require("src.core.game3.profile") + local family = Profile.family(session) + Warm.require(Prewarm.MODULES, 6) + Warm.require(Prewarm.FAMILY_MODULES[family] or {}, 6) + local okG, game = pcall(function() return require("src.core.GameVersion").get() end) + if okG and game then + for _, kind in ipairs(Prewarm.CONSTANTS) do + Warm.add("const:" .. tostring(game) .. ":" .. kind, function() + local C = require("src.core.game3.constants").of(game) + local _ = C[kind] + end, 7) + end + end + for _, face in ipairs(Prewarm.FONT_FACES) do + Warm.add("font:" .. face, function() + local Font = require("src.ui.game3.frlg_font") + if Font.face then pcall(Font.face, { font = face }) end + end, 5) + end + Warm.add("data:pic_coords", function() require("src.core.game3.battle.pic_coords").active() end, 7) + Warm.add("data:mon_anim", function() require("src.core.game3.mon_anim_data").get() end, 7) + Warm.add("data:moves", function() require("src.core.game3.battle.moves").loadRomPack() end, 7) + se_list(Prewarm.FIELD_SE, 9) +end + +local function species_of(mon) + if type(mon) ~= "table" then return nil end + return tonumber(mon.species or mon.speciesId) +end + +local function lead_of(party) + for _, mon in ipairs(type(party) == "table" and party or {}) do + if type(mon) == "table" and not mon.isEgg and not mon.egg and (tonumber(mon.hp) or 1) > 0 then return mon end + end +end + +function Prewarm.battle(session, foe) + local Audio = package.loaded["src.core.game3.audio"] + if not Audio then return end + local lead = lead_of(session and session.party) + local IntroSeq = package.loaded["src.core.game3.battle.intro_seq"] + local mode = lead and IntroSeq and IntroSeq.releaseCryMode and IntroSeq.releaseCryMode(lead) or 0 + if species_of(lead) then Audio.prewarmCry(species_of(lead), mode, -25, 1) end + local first = foe and ((type(foe.party) == "table" and foe.party[1]) or foe) + if species_of(first) then Audio.prewarmCry(species_of(first), 0, 25, 1) end + se_list(Prewarm.BATTLE_SE, 2) +end + +function Prewarm.battleStart(opts) + if type(opts) ~= "table" then return end + Warm.add("battle:chrome", function() + require("src.ui.game3.battle_chrome").ensureInstalled() + local BattleBg = require("src.core.game3.battle.bg") + require("src.ui.game3.battle_chrome").terrain(BattleBg.sheetKey(BattleBg.resolveOpts(opts))) + end, 1) + Warm.add("battle:back", function() + require("src.core.game3.trainer_pic").back(tonumber(opts.playerGender) or 0) + end, 1) +end + +return Prewarm diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index 67fbec1c..61fd9471 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -466,7 +466,8 @@ function Runtime.install(mod) Runtime._mod = mod log("Runtime.install — display ownership + START intercept + game.ready") - local ok, World = pcall(lazyReq, "src.world.gen2.World") + local World = package.loaded["src.world.gen2.World"] + local ok = type(World) == "table" if ok and World and World.step and not World._game3RuntimeStep then local prev = World.step World.step = function(self, ...) @@ -507,7 +508,8 @@ function Runtime.install(mod) end -- Own the frame: replace Gen2 drawScene presentation while active. - local ok2, Game2 = pcall(lazyReq, "src.core.Game2") + local Game2 = package.loaded["src.core.Game2"] + local ok2 = type(Game2) == "table" if ok2 and Game2 and Game2.drawScene and not Game2._game3Display then local prevDraw = Game2.drawScene Game2.drawScene = function(self, w, h) diff --git a/src/core/game3/scripting/space.lua b/src/core/game3/scripting/space.lua index 8098c81a..8c42d121 100644 --- a/src/core/game3/scripting/space.lua +++ b/src/core/game3/scripting/space.lua @@ -744,7 +744,8 @@ function Space.install(mod) end end -- Direct Gen2 World:setMap (ferry/warps often skip the facade). - local ok, World = pcall(lazyReq, "src.world.gen2.World") + local World = package.loaded["src.world.gen2.World"] + local ok = type(World) == "table" if ok and World and World.setMap and not World._game3SetMap then local prevW = World.setMap World.setMap = function(self, mapId, ...) @@ -796,7 +797,8 @@ function Space.install(mod) -- Gen2 World:busy must see game3 scripts or frozeNpcs clears mid-dialog. do - local ok, World = pcall(lazyReq, "src.world.gen2.World") + local World = package.loaded["src.world.gen2.World"] + local ok = type(World) == "table" if ok and World and World.busy and not World._game3Busy then local prevBusy = World.busy World.busy = function(self) diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua index 40004ece..d4f6940c 100644 --- a/src/core/game3/tileset_anim.lua +++ b/src/core/game3/tileset_anim.lua @@ -18,6 +18,12 @@ TilesetAnim._enabled = true local MID_RGBA = 16 * 16 * 4 -- 1024 +local nativeMod +local function NativeTileset() + nativeMod = nativeMod or require("src.core.game3.tileset_native") + return nativeMod +end + local function log(msg) print("[game3/anim] " .. tostring(msg)) end @@ -167,6 +173,7 @@ local function rse_paste(entry, bank, blob, mids, quads, frame, imageData, lut, if piece then local ax, ay = (slot % cols) * 16, math.floor(slot / cols) * 16 local mask = quads and quads[k] or 15 + NativeTileset().markDirty(ts, over, slot) if mask == 15 then imageData:paste(piece, ax, ay, 0, 0, 16, 16) else @@ -234,12 +241,6 @@ end local rseDirty = {} -local nativeMod -local function NativeTileset() - nativeMod = nativeMod or require("src.core.game3.tileset_native") - return nativeMod -end - -- pokeemerald/src/tileset_anims.c:586 function TilesetAnim.stepRse() local st = TilesetAnim._rse @@ -295,6 +296,49 @@ local function load_bank(cache, pair, kind, info) } end +local get_frame_piece + +local function prewarm(pair, entry) + local Warm = require("src.core.game3.warm") + Warm.add("anim:" .. pair, function() + if TilesetAnim._pairs[pair] ~= entry then return end + NativeTileset().ensureAlt(entry.atlas) + if entry.rse then + for _, bank in ipairs(entry.banks) do + local row = bank and bank.row + if row and row.kind == "palette" then + if row.paletteSlot then NativeTileset().prepareSlot(entry.atlas, tonumber(row.paletteSlot) or 0) end + elseif row then + local frames = tonumber(row.frames) or 0 + for frame = 0, frames - 1 do + local n, on = #(row.mids or EMPTY), #(row.overMids or EMPTY) + for k = 1, n do + if bank.under then rse_piece(entry, bank, bank.under, entry.lut, frame, k, n, false) end + if k % 4 == 0 then Warm.yield() end + end + for k = 1, on do + if bank.over then rse_piece(entry, bank, bank.over, entry.lutOver, frame, k, on, true) end + if k % 4 == 0 then Warm.yield() end + end + Warm.yield() + end + end + end + else + for _, bank in pairs(entry.banks) do + local nMids = #bank.mids + for frame = 0, bank.frames - 1 do + for mi = 1, nMids do + get_frame_piece(bank, frame, mi, (frame * nMids + (mi - 1)) * MID_RGBA) + if mi % 8 == 0 then Warm.yield() end + end + Warm.yield() + end + end + end + end, 3) +end + -- pokefirered/src/tileset_anims.c:223 function TilesetAnim.bindPair(pair, atlas, prepared) if not Versions.NATIVE_RENDER or Versions.TILESET_ANIM == false then @@ -314,6 +358,7 @@ function TilesetAnim.bindPair(pair, atlas, prepared) entry = rse_entry(cache, pair, atlas, man) TilesetAnim._pairs[pair] = entry if not entry then return false end + prewarm(pair, entry) if not TilesetAnim._rse then TilesetAnim.enterMap(pair, false) end TilesetAnim._visible[pair] = true return true @@ -324,6 +369,7 @@ function TilesetAnim.bindPair(pair, atlas, prepared) flower = load_bank(cache, pair, "flower", man.flower), } } TilesetAnim._pairs[pair] = entry + prewarm(pair, entry) end TilesetAnim._visible[pair] = true if entry.rse then return true end @@ -356,7 +402,7 @@ function TilesetAnim.setVisiblePairs(visible) end end -local function get_frame_piece(bank, frame, mi, srcOff) +function get_frame_piece(bank, frame, mi, srcOff) if not (love and love.image and love.image.newImageData) then return nil end local pieces = bank.pieces if not pieces then @@ -395,6 +441,7 @@ function TilesetAnim._applyKind(entry, kind, frame, deferUpload) local ax = (slot % cols) * 16 local ay = math.floor(slot / cols) * 16 local piece = get_frame_piece(bank, frame, mi, srcOff) + NativeTileset().markDirty(ts, false, slot) local pasted = false if piece and ts.imageData.paste then ts.imageData:paste(piece, ax, ay) diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua index 5e3a8beb..b0a38a52 100644 --- a/src/core/game3/tileset_native.lua +++ b/src/core/game3/tileset_native.lua @@ -166,11 +166,16 @@ local function scan_slot(blob, cols, slot, skipZero) local base = 13 + midCount * 2 local lo, hi = slot * 16, slot * 16 + 15 local n = 0 + local cells = {} + out.cells = cells + local warm = package.loaded["src.core.game3.warm"] for i = 0, midCount * 256 - 1 do local b = blob:byte(base + i) + if warm and i % 16384 == 16383 then warm.yield() end if b and b >= lo and b <= hi and not (skipZero and b == 0) then local mid = math.floor(i / 256) local within = i % 256 + cells[mid] = true n = n + 1 out[n] = { (mid % cols) * 16 + within % 16, @@ -200,43 +205,98 @@ local function retarget(old, new) end end -local function flip(ts, imgKey, dataKey, altKey) +local scratch +local function cellData(data, x, y) + if not scratch then scratch = love.image.newImageData(16, 16) end + scratch:paste(data, 0, 0, x, y, 16, 16) + return scratch +end + +local function merge(pend, slots, full) + if full or not slots then + pend.full, pend.slots = true, nil + elseif not pend.full then + pend.slots = pend.slots or {} + for slot in pairs(slots) do pend.slots[slot] = true end + end +end + +local function apply(ts, image, data, pend) + if pend.full then + image:replacePixels(data) + elseif pend.slots then + local cols = ts.cols or 16 + for slot in pairs(pend.slots) do + local x, y = (slot % cols) * 16, math.floor(slot / cols) * 16 + image:replacePixels(cellData(data, x, y), 1, 1, x, y) + end + end + pend.full, pend.slots = false, nil +end + +local function flip(ts, imgKey, dataKey, altKey, slots) local img, data = ts[imgKey], ts[dataKey] if not (img and data and img.replacePixels) then return end + ts._pend = ts._pend or setmetatable({}, { __mode = "k" }) + local pend = ts._pend local alt = ts[altKey] + local full = not slots or not next(slots) if alt and alt.replacePixels then - alt:replacePixels(data) + pend[alt] = pend[alt] or {} + merge(pend[alt], slots, full) + apply(ts, alt, data, pend[alt]) elseif love and love.graphics and love.graphics.newImage then alt = love.graphics.newImage(data) if alt.setFilter then alt:setFilter("nearest", "nearest") end + pend[alt] = {} else img:replacePixels(data) return end + pend[img] = pend[img] or {} + merge(pend[img], slots, full) ts[imgKey], ts[altKey] = alt, img retarget(img, alt) end -function NativeTileset.flush(ts, under, over) - if under then flip(ts, "image", "imageData", "imageAlt") end - if over then flip(ts, "overImage", "overImageData", "overImageAlt") end +function NativeTileset.markDirty(ts, over, slot) + local key = over and "_dirtyOver" or "_dirtyUnder" + local set = ts[key] + if not set then set = {}; ts[key] = set end + set[slot] = true end -local function paint(imageData, list, colors) +function NativeTileset.flush(ts, under, over) + local du, dov = ts._dirtyUnder, ts._dirtyOver + ts._dirtyUnder, ts._dirtyOver = nil, nil + if under then flip(ts, "image", "imageData", "imageAlt", du) end + if over then flip(ts, "overImage", "overImageData", "overImageAlt", dov) end +end + +function NativeTileset.ensureAlt(ts) + for _, k in ipairs({ { "image", "imageData", "imageAlt" }, { "overImage", "overImageData", "overImageAlt" } }) do + local img, data = ts[k[1]], ts[k[2]] + if img and data and not ts[k[3]] and love and love.graphics and love.graphics.newImage then + local alt = love.graphics.newImage(data) + if alt.setFilter then alt:setFilter("nearest", "nearest") end + ts[k[3]] = alt + end + end +end + +local function paint(ts, over, imageData, list, colors) if not (imageData and #list > 0) then return false end for i = 1, #list do local p = list[i] local c = colors[p[3]] imageData:setPixel(p[1], p[2], c[1] / 255, c[2] / 255, c[3] / 255, 1) end + for slot in pairs(list.cells or {}) do NativeTileset.markDirty(ts, over, slot) end return true end -- pokefirered/src/palette.c:88 -function NativeTileset.setSlotPalette(pairOrTs, slot, bgr16) - local ts = type(pairOrTs) == "table" and pairOrTs or NativeTileset.get(pairOrTs) - if not (ts and ts.imageData and type(bgr16) == "table") then return false end - slot = tonumber(slot) or 0 +function NativeTileset.prepareSlot(ts, slot) local pix = ts.slotPix[slot] if not pix then pix = { @@ -245,10 +305,19 @@ function NativeTileset.setSlotPalette(pairOrTs, slot, bgr16) } ts.slotPix[slot] = pix end + return pix +end + +function NativeTileset.setSlotPalette(pairOrTs, slot, bgr16) + local ts = type(pairOrTs) == "table" and pairOrTs or NativeTileset.get(pairOrTs) + if not (ts and ts.imageData and type(bgr16) == "table") then return false end + slot = tonumber(slot) or 0 + local pix = NativeTileset.prepareSlot(ts, slot) local src = {} for c = 0, 15 do src[c] = bgr16[c + 1] or 0 end local colors = NativePack.palsToRgb8({ [0] = src })[0] - NativeTileset.flush(ts, paint(ts.imageData, pix.under, colors), paint(ts.overImageData, pix.over, colors)) + NativeTileset.flush(ts, paint(ts, false, ts.imageData, pix.under, colors), + paint(ts, true, ts.overImageData, pix.over, colors)) ts.patchedSlots = ts.patchedSlots or {} ts.patchedSlots[slot] = true return true diff --git a/src/core/game3/warm.lua b/src/core/game3/warm.lua new file mode 100644 index 00000000..71fe7c36 --- /dev/null +++ b/src/core/game3/warm.lua @@ -0,0 +1,83 @@ +local Warm = {} +Warm.BUDGET = 0.002 + +local jobs, order = {}, {} +local clock = function() return love and love.timer and love.timer.getTime() or os.clock() end + +function Warm.add(key, fn, priority) + if jobs[key] then + if priority and priority < jobs[key].priority then jobs[key].priority = priority end + return false + end + local job = { key = key, fn = fn, priority = priority or 5 } + jobs[key] = job + order[#order + 1] = job + return true +end + +function Warm.pending(key) return jobs[key] ~= nil end + +local function finish(job) + jobs[job.key] = nil + for i = #order, 1, -1 do + if order[i] == job then table.remove(order, i) break end + end +end + +local function resume(job) + if not job.co then job.co = coroutine.create(job.fn) end + local ok, err = coroutine.resume(job.co) + if not ok then + print("[game3/warm] " .. tostring(job.key) .. ": " .. tostring(err)) + finish(job) + elseif coroutine.status(job.co) == "dead" then + finish(job) + end +end + +function Warm.flush(key) + local job = jobs[key] + if not job or (job.co and job.co == coroutine.running()) then return false end + job.flushing = true + while jobs[key] == job do resume(job) end + return true +end + +function Warm.step(budget) + if #order == 0 then return end + local deadline = clock() + (budget or Warm.BUDGET) + repeat + local best + for _, job in ipairs(order) do + if not best or job.priority < best.priority then best = job end + end + if not best then return end + resume(best) + until clock() >= deadline +end + +function Warm.require(names, priority) + for _, name in ipairs(names) do + if not package.loaded[name] then + Warm.add("mod:" .. name, function() pcall(require, name) end, priority or 8) + end + end +end + +function Warm.yield() + local co, main = coroutine.running() + if co and not main then + for _, job in pairs(jobs) do + if job.co == co then + if not job.flushing then coroutine.yield() end + return + end + end + end +end + +function Warm.reset() + jobs, order = {}, {} +end + +return Warm diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index e49f7aa6..7354bddd 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -959,12 +959,13 @@ function PaletteFX.shadeMap() end local function invalidateColorCaches() - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) + local loaded = package.loaded + pcall(function() if loaded["src.battle.BattleState"] then loaded["src.battle.BattleState"].invalidate() end end) + pcall(function() if loaded["src.render.SpriteRenderer"] then loaded["src.render.SpriteRenderer"].invalidate() end end) pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + if loaded["src.world.MapLoader"] then loaded["src.world.MapLoader"].invalidateAll() end + local Game = loaded["src.core.Game"] + if type(Game) == "table" and Game.overworld and Game.overworld.map and Game.overworld.reloadMap then Game.overworld:reloadMap(Game.overworld.map.id, "colors") end end) @@ -995,8 +996,8 @@ function PaletteFX.setCustomRamp(ramp) end function PaletteFX.pickerActive() - local ok, Game = pcall(require, "src.core.Game") - local states = ok and Game.stack and Game.stack.states + local Game = package.loaded["src.core.Game"] + local states = type(Game) == "table" and Game.stack and Game.stack.states or nil local top = states and states[#states] return top ~= nil and top.screenId == "PaletteScreen" end diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index eeed022f..46890945 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -970,8 +970,8 @@ function Renderer:endFrame(zones, worldZones) local extendedBlackBand = false local bandR, bandG, bandB = 1, 1, 1 if not self.worldActive then - local ok, Game = pcall(require, "src.core.Game") - local stack = ok and Game and Game.stack + local Game = package.loaded["src.core.Game"] + local stack = type(Game) == "table" and Game.stack local base = stack and stack.visibleBase and stack:visibleBase() local state = base and stack.states and stack.states[base] local ownState = self.surroundState @@ -1214,8 +1214,8 @@ function Renderer:endFrame(zones, worldZones) -- field, so never cover it with the native back-sprite fallback. if self.extendedWorldBand and not self.worldOverride and not FaithfulRes.scaleCap() then - local ok, Game = pcall(require, "src.core.Game") - love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data)) + local Game = package.loaded["src.core.Game"] + love.graphics.setColor(PaletteFX.paperShade(type(Game) == "table" and Game.data or nil)) love.graphics.rectangle("fill", uox, vuy, uvpw, vuh) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index 9f2f42c6..6bfd854b 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -161,8 +161,18 @@ local TERRAIN_MT = { __index = function(_, key) return BattleChrome.terrain(key) end, } +local function install_key(cache) + return tostring(cache) .. "|" .. battle_root() +end + +function BattleChrome.ensureInstalled() + if BattleChrome._installKey == install_key(resolve_cache(nil)) and BattleChrome._manifest then return end + BattleChrome.install(nil) +end + function BattleChrome.install(cache) BattleChrome._cache = resolve_cache(cache) + BattleChrome._installKey = install_key(BattleChrome._cache) BattleChrome._manifest = nil BattleChrome._textbox = nil BattleChrome._playerBox = nil diff --git a/src/ui/game3/rse/item_storage.lua b/src/ui/game3/rse/item_storage.lua index 55e6d74f..e3e9beb5 100644 --- a/src/ui/game3/rse/item_storage.lua +++ b/src/ui/game3/rse/item_storage.lua @@ -19,6 +19,21 @@ local WIN = { yesno = Window.template(9, 7, 5, 4), } ItemStorage.WIN = WIN +-- pokeruby/src/player_pc.c:973 +local WIN_RS = { + list = WIN.list, + message = WIN.message, + title = Window.template(1, 1, 10, 2), + -- pokeruby/src/player_pc.c:606 + quantity = Window.template(7, 9, 6, 2), + -- pokeruby/src/player_pc.c:703 + yesno = Window.template(8, 7, 5, 4), +} +ItemStorage.WIN_RS = WIN_RS + +local function is_rs() + return require("src.ui.game3.rs.player_pc_policy").matches(ItemStorage._session) +end -- pokeemerald/src/player_pc.c:283 local ITEM_X, UP_TEXT_Y = 8, 9 -- pokeemerald/src/player_pc.c:699 @@ -53,10 +68,12 @@ local function pos() end local function narrow() + if is_rs() then return { font = "normal" } end return { font = "narrow" } end local function row_height() + if is_rs() then return 16 end local face = FrlgFont.face and FrlgFont.face(narrow()) return face and face.height or 16 end @@ -384,7 +401,44 @@ local function frame(tpl) Window.fill(tpl, 1, 1, 1, 1) end +-- pokeruby/src/player_pc.c:862 +local function draw_list_rs() + local tpl = WIN_RS.list + frame(tpl) + local ox = tpl.left * 8 + local list = items() + local shown = page_items() + local font = narrow() + local ItemsData = require("src.core.game3.items_data") + for i = 0, shown - 1 do + local idx = ItemStorage.scroll + i + local y = (i * 2 + 2) * 8 + local e = list[idx + 1] + if not e then + FrlgFont.draw(RomText.plain("gText_Cancel2"), ox, y, + { font = "normal", colors = { fg = FrlgFont.STDPAL[1], bg = { 0, 0, 0, 0 }, shadow = FrlgFont.STDPAL[8] } }) + break + end + local colors = { fg = FrlgFont.STDPAL[ItemStorage.swapFrom == idx and 2 or 1], bg = { 0, 0, 0, 0 }, + shadow = FrlgFont.STDPAL[8] } + FrlgFont.draw(item_name(e.id), ox, y, { font = "normal", colors = colors }) + local pocket = ItemsData.pocketOf(e.id) + -- pokeruby/src/player_pc.c:815 + if pocket ~= "KEY_ITEMS" and not ItemsData.isHm(e.id) then + local qx = 26 * 8 + FrlgFont.draw("×", qx, y, { font = "normal", colors = colors }) + local digits = tostring(tonumber(e.qty) or 0) + local dx = qx + FrlgFont.measure("×", font) + (3 - #digits) * 6 + for c = 1, #digits do + FrlgFont.draw(digits:sub(c, c), dx + (c - 1) * 6, y, { font = "normal", colors = colors }) + end + end + end + require("src.ui.game3.rs.menu_cursor").draw(ox, 16 + ItemStorage.row * 16, 13 * 8) +end + local function draw_list() + if is_rs() then return draw_list_rs() end local tpl = WIN.list frame(tpl) local ox, oy = tpl.left * 8, tpl.top * 8 @@ -427,14 +481,20 @@ end function ItemStorage.draw() if not ItemStorage.open then return end - local title = WIN.title + local rs = is_rs() + local W = rs and WIN_RS or WIN + local title = W.title frame(title) local t = RomText.plain(ItemStorage._toss and "gText_TossItem" or "gText_WithdrawItem") - -- pokeemerald/src/player_pc.c:960 - Window.printPx(t, title.left * 8 + math.floor((104 - FrlgFont.measure(t)) / 2), title.top * 8 + 1) + if rs then + Window.printPx(t, title.left * 8, title.top * 8) + else + -- pokeemerald/src/player_pc.c:960 + Window.printPx(t, title.left * 8 + math.floor((104 - FrlgFont.measure(t)) / 2), title.top * 8 + 1) + end draw_list() - draw_icon() - local m = WIN.message + if not rs then draw_icon() end + local m = W.message frame(m) local pitch = FrlgFont.linePitch() local y = m.top * 8 + 1 @@ -443,13 +503,13 @@ function ItemStorage.draw() y = y + pitch end if ItemStorage.state == "quantity" then - local q = WIN.quantity + local q = W.quantity frame(q) local s = RomText.plain("gText_xVar1", { stringVars = { string.format("%03d", ItemStorage.quantity) } }) -- pokeemerald/src/player_pc.c:1361 Window.printPx(s, q.left * 8 + math.floor((48 - FrlgFont.measure(s)) / 2), q.top * 8 + 1) elseif ItemStorage.state == "yesno" then - local yn = WIN.yesno + local yn = W.yesno frame(yn) local ph = Window.optionHeight() Window.printPx(RomText.plain("gText_Yes"), yn.left * 8 + 8, yn.top * 8 + 1) diff --git a/tests/drivers/em_sendout_profile.lua b/tests/drivers/em_sendout_profile.lua new file mode 100644 index 00000000..6f2e8383 --- /dev/null +++ b/tests/drivers/em_sendout_profile.lua @@ -0,0 +1,57 @@ +local U = require("tests.drivers.util") + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + local function finish() + print(string.format("[driver] em_sendout_profile: %d failure(s)", fails)) + love.event.quit(fails == 0 and 0 or 1) + U.wait(10) + end + + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "NICK", gender = 0 }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Map = require("src.core.game3.map") + local C = require("src.core.game3.constants").of("emerald") + local session = Runtime.getSession() + if not session then result(false, "field session"); return finish() end + + local ok = pcall(function() Map.load(nil, game, "EM_ROUTE101", { x = 7, y = 13, facing = "down" }) end) + result(ok, "Route 101 loads") + U.wait(120) + session.party = {} + -- pokeemerald/src/battle_setup.c:923 + Party.giveMon(session, C.species.byName.SPECIES_TREECKO, 5, "TREECKO") + print("SENDOUT_MARK battle_start") + local started = BattleBridge.startFirstBattle(Runtime._mod, game, { done = function() end }) + result(started == true, "first battle started") + if not started then return finish() end + + local lastTap, f = 0, 0 + for _ = 1, 4000 do + if Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu" then break end + f = f + 1 + if Ui.dialogPending and Ui.dialogPending() and f - lastTap >= 14 then + lastTap = f + U.tap(game, "a") + else + U.wait(1) + end + end + result(Battle._phase == "command" and Ui._mode == "menu", "reached the action menu after send-out") + U.wait(30) + finish() +end diff --git a/tests/drivers/heap_census.lua b/tests/drivers/heap_census.lua new file mode 100644 index 00000000..f2d7cd30 --- /dev/null +++ b/tests/drivers/heap_census.lua @@ -0,0 +1,74 @@ +local U = require("tests.drivers.util") + +local BIG = {} +local function census() + collectgarbage("collect") + print(string.format("HEAP_AFTER_COLLECT_KB %.0f", collectgarbage("count"))) + local seen, rows = {}, {} + local function size(t, depth) + if seen[t] then return 0, 0 end + seen[t] = true + local slots, bytes = 0, 0 + for k, v in next, t do + slots = slots + 1 + if type(v) == "string" then + bytes = bytes + #v + if #v > 262144 then BIG[#BIG + 1] = { #v, tostring(k) } end + end + if type(k) == "string" then bytes = bytes + #k end + end + return slots, bytes + end + local function walk(t, path, depth) + if type(t) ~= "table" or seen[t] or depth > 12 then return 0 end + local slots, bytes = size(t, depth) + local total = slots * 40 + bytes + for k, v in next, t do + if type(v) == "table" then + total = total + walk(v, path .. "." .. tostring(k), depth + 1) + elseif type(v) == "function" then + for i = 1, 60 do + local n, uv = debug.getupvalue(v, i) + if not n then break end + if type(uv) == "table" then total = total + walk(uv, path .. "." .. tostring(k) .. "^" .. n, depth + 1) end + end + end + end + rows[#rows + 1] = { path, total, slots } + return total + end + for name, mod in pairs(package.loaded) do + if type(mod) == "table" then walk(mod, name, 0) end + if type(mod) == "function" then + for i = 1, 60 do + local n, uv = debug.getupvalue(mod, i) + if not n then break end + if type(uv) == "table" then walk(uv, name .. "^" .. n, 1) end + end + end + end + walk(debug.getregistry(), "REG", 0) + table.sort(rows, function(a, b) return a[2] > b[2] end) + local bigTotal = 0 + table.sort(BIG, function(a, b) return a[1] > b[1] end) + for i, b in ipairs(BIG) do bigTotal = bigTotal + b[1]; if i <= 25 then print(string.format("BIGSTR %8.0fKB key=%s", b[1] / 1024, b[2])) end end + print(string.format("BIGSTR_TOTAL %.0fKB n=%d", bigTotal / 1024, #BIG)) + print(string.format("HEAP_TOTAL_KB %.0f", collectgarbage("count"))) + local shown = 0 + for _, r in ipairs(rows) do + if shown >= 60 then break end + print(string.format("HEAP %8.0fKB slots=%-8d %s", r[2] / 1024, r[3], r[1])) + shown = shown + 1 + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 }) + U.wait(300) + census() + love.event.quit(0) +end diff --git a/tests/drivers/hitch_profile.lua b/tests/drivers/hitch_profile.lua new file mode 100644 index 00000000..1f2c21c5 --- /dev/null +++ b/tests/drivers/hitch_profile.lua @@ -0,0 +1,100 @@ +local INNER = assert(os.getenv("HITCH_INNER"), "HITCH_INNER=tests/drivers/.lua") +local LIMIT_MS = tonumber(os.getenv("HITCH_MS") or "8") or 8 +local DEPTH = tonumber(os.getenv("HITCH_DEPTH") or "10") or 10 +if os.getenv("HITCH_JIT") ~= "1" and jit then jit.off(); jit.flush() end + +local okProf, profile = pcall(require, "jit.profile") +if os.getenv("HITCH_STACKS") == "0" then okProf = false end +local frame, stacks, vm, samples = 0, {}, {}, 0 +local hitches, all, known = {}, {}, {} +local frameStart, stuckShown +local clock = love.timer.getTime + +local function top(map, n) + local rows = {} + for k, v in pairs(map) do rows[#rows + 1] = { k, v } end + table.sort(rows, function(a, b) return a[2] > b[2] end) + local out = {} + for i = 1, math.min(n, #rows) do out[i] = rows[i] end + return out +end + +if okProf then + profile.start("i1", function(th, n, state) + samples = samples + n + vm[state] = (vm[state] or 0) + n + local s = profile.dumpstack(th, "pl < ", DEPTH) + stacks[s] = (stacks[s] or 0) + n + if frameStart and clock() - frameStart > 5 and not stuckShown then + stuckShown = true + print("HITCH_STUCK " .. profile.dumpstack(th, "pl\n", 40)) + end + end) +end + +local function reset() stacks, vm, samples = {}, {}, 0 end + +local baseUpdate, baseDraw = love.update, love.draw +local tu, td, kb0 +love.update = function(dt) + frame = frame + 1 + reset() + kb0 = collectgarbage("count") + local t = clock() + frameStart = t + baseUpdate(dt) + tu = (clock() - t) * 1000 +end +love.draw = function() + local t = clock() + baseDraw() + td = (clock() - t) * 1000 + local total = (tu or 0) + td + all[#all + 1] = total + if os.getenv("HITCH_STATUS") and frame % 60 == 0 then + local B = package.loaded["src.core.game3.battle"] + local T = package.loaded["src.core.game3.battle_transition"] + print(string.format("HITCH_STATUS frame=%d battle=%s phase=%s transition=%s", + frame, tostring(B and B.isActive and B.isActive()), tostring(B and B._phase), + tostring(T and T._transitionId))) + end + for name in pairs(package.loaded) do + if not known[name] then + known[name] = true + if frame > 1 then print(string.format("LAZY_REQUIRE frame=%d %s", frame, name)) end + end + end + if total >= LIMIT_MS then + local Map = package.loaded["src.core.game3.map"] + hitches[#hitches + 1] = total + print(string.format("HITCH frame=%d total=%.1fms update=%.1f draw=%.1f gcKB=%+.0f heapKB=%.0f map=%s samples=%d vm[N=%d I=%d C=%d G=%d J=%d]", + frame, total, tu or 0, td, collectgarbage("count") - (kb0 or 0), collectgarbage("count"), + tostring(Map and Map.current), samples, vm.N or 0, vm.I or 0, vm.C or 0, vm.G or 0, vm.J or 0)) + for _, row in ipairs(top(stacks, 6)) do + print(string.format(" %3d %s", row[2], row[1])) + end + end +end + +local quit = love.event.quit +love.event.quit = function(...) + table.sort(all) + local n = #all + local function p(q) return all[math.max(1, math.ceil(n * q))] or 0 end + print(string.format("HITCH_SUMMARY frames=%d hitches=%d p50=%.2f p95=%.2f p99=%.2f max=%.2f jit=%s", + n, #hitches, p(.5), p(.95), p(.99), all[n] or 0, tostring(jit and jit.status()))) + local foreign = {} + for name in pairs(package.loaded) do + if name:find("^src%.[%w_]+%.gen2%.") or name:find("^src%.[%w_]+%.gen2$") + or name == "src.core.Game" or name == "src.core.Game2" or name == "src.world.OverworldController" + or name == "src.battle.BattleState" then + foreign[#foreign + 1] = name + end + end + table.sort(foreign) + print(string.format("FOREIGN_MODULES n=%d %s", #foreign, table.concat(foreign, " "))) + if okProf then profile.stop() end + return quit(...) +end + +return assert(loadfile(INNER))() diff --git a/tests/drivers/rs_bike_pc_items.lua b/tests/drivers/rs_bike_pc_items.lua new file mode 100644 index 00000000..d97adc9c --- /dev/null +++ b/tests/drivers/rs_bike_pc_items.lua @@ -0,0 +1,64 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/rs_bike_pc_items" + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + print((failures == 0 and "PASS" or "FAIL") .. " rs_bike_pc_items failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) + U.wait(10) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local session = Runtime.getSession() + if not check(session ~= nil, "session") then return finish() end + local C = require("src.core.game3.constants").active(session) + local ItemUse = require("src.core.game3.item_use") + local Bag = require("src.core.game3.bag") + + local bike = C:id("items", "ITEM_MACH_BIKE") + Bag.add(session.bag, bike, 1) + local ok, res, a, b, c = pcall(ItemUse.useField, session, session.bag, bike) + check(ok, "bike use indoors does not error (" .. tostring(ok and "" or res) .. ")") + check(ok and res == false and type(b) == "string" and b:find("DAD") ~= nil, + "bike indoors gives DAD's advice: " .. tostring(b)) + + local Storage = require("src.core.game3.storage") + local list = Storage.ensure(session).items + for i = #list, 1, -1 do list[i] = nil end + list[1] = { id = C:id("items", "ITEM_POTION"), qty = 5 } + list[2] = { id = C:id("items", "ITEM_SUPER_POTION"), qty = 12 } + list[3] = { id = C:id("items", "ITEM_HM01_CUT"), qty = 1 } + list[4] = { id = C:id("items", "ITEM_ACRO_BIKE"), qty = 1 } + local ItemStorage = require("src.ui.game3.rse.item_storage") + ItemStorage.show({ session = session }) + U.wait(10) + check(U.still(game, DIR .. "/01_withdraw.png"), "withdraw shot") + U.tap(game, "down") + U.wait(6) + U.tap(game, "select") + U.wait(6) + check(U.still(game, DIR .. "/02_swap.png"), "swap shot") + U.tap(game, "b") + U.wait(6) + U.tap(game, "a") + U.wait(6) + check(U.still(game, DIR .. "/03_quantity.png"), "quantity shot") + U.tap(game, "b") + U.wait(4) + ItemStorage.close() + U.wait(4) + finish() +end