CLOSES #2726, CLOSES #2727, CLOSES #2729, CLOSES #2730, CLOSES #2731, CLOSES #2733, CLOSES #2734, CLOSES #2735, CLOSES #2736, CLOSES #2737, CLOSES #2740

This commit is contained in:
bryanthaboi
2026-10-07 06:04:57 -04:00
parent 2f89018cb3
commit fcdaecd496
59 changed files with 4775 additions and 943 deletions
+14
View File
@@ -23,6 +23,18 @@ function Transaction.assetValid(path, body)
return be(17) == 1136 and be(21) == 432
end
local function forgetOptions(journal)
for _, job in ipairs(journal.jobs) do
if job.path == "options.lua" then
local SaveData = package.loaded["src.core.SaveData"]
if type(SaveData) == "table" and SaveData.invalidateOptionsCache then
SaveData.invalidateOptionsCache()
end
return
end
end
end
local function write(fs, path, body)
local dir = path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then
@@ -105,6 +117,7 @@ local function abort(fs, journal, body)
local engine = type(sync) == "table" and sync._shared
if type(engine) == "table" then engine:noteSaveWritten() end
end
if wrote then forgetOptions(journal) end
local kept, err = quarantine(fs, body)
if not kept then return nil, err end
return true, false, "A save changed while a Box transfer was pending. The transfer was undone and its record kept at " .. kept .. "."
@@ -188,6 +201,7 @@ function Transaction.recover(fs)
if not ok or fs.getInfo(job.path .. ".tmp") then return nil, err or "Could not clear a stale save recovery file." end
end
end
forgetOptions(journal)
local ok, err = fs.remove(Transaction.PATH)
if not ok or fs.getInfo(Transaction.PATH) then
return nil, err or "The completed Box transfer journal could not be cleared."
+175 -25
View File
@@ -75,6 +75,9 @@ local statFrames, statUnderruns, statRestarts = 0, 0, 0
local statDepthMin, statDepthSum, statDepthFrames = nil, 0, 0
local statWorkerJit, statWorkerXrt = nil, nil
local statXrtSum, statXrtCount, statXrtMax = 0, 0, nil
local statSyncRenders, statSyncMs, statSyncMsMax = 0, 0, 0
local statSyncLast
local statPrewarmHits = 0
local lastCosted
-- ---------------------------------------------------------------------------
@@ -91,8 +94,18 @@ local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def
local fxReady = {} -- key -> SoundData | false (renders to nothing)
local fxPending = {} -- key -> true while a request is in flight
local fxReadyCount = 0
local fxReadyOrder = {}
local fxOrderCount = 0
local FX_READY_MAX = 64
local drainEffects, dropEffects
local fxPinned = {}
local fxPinnedCount = 0
local FX_PINNED_MAX = 24
local pinRequests = false
local fxQueue = {}
local fxInFlight = {}
local fxInFlightCount = 0
local FX_IN_FLIGHT_MAX = 2
local drainEffects, dropEffects, pumpEffects
local function ensureWorker()
if workerReady ~= nil then return workerReady end
@@ -397,6 +410,14 @@ function ChipAudio.stats()
xrtAvg = statXrtCount > 0 and (statXrtSum / statXrtCount) or nil,
xrtMax = statXrtMax,
buffers = statXrtCount,
syncRenders = statSyncRenders,
syncRenderMs = statSyncMs,
syncRenderMsMax = statSyncMsMax,
syncRenderLast = statSyncLast,
prewarmHits = statPrewarmHits,
prewarmQueued = #fxQueue,
prewarmInFlight = fxInFlightCount,
prewarmReady = fxReadyCount,
}
local function num(value, places)
if type(value) ~= "number" then return "-" end
@@ -404,18 +425,25 @@ function ChipAudio.stats()
end
stats.line = string.format(
"rate=%d worker=%s depth=%s/%d min=%s avg=%s underruns=%d restarts=%d "
.. "xrt=%s/%s/%s n=%d",
.. "xrt=%s/%s/%s n=%d sync=%d/%sms max=%sms last=%s prewarm=%d q=%d",
stats.rate, worker, depth and tostring(depth) or "-", MUSIC_BUFFER_COUNT,
statDepthMin and tostring(statDepthMin) or "-", num(average, 1),
statUnderruns, statRestarts,
num(statWorkerXrt, 3), num(stats.xrtAvg, 3), num(statXrtMax, 3),
statXrtCount)
statXrtCount, statSyncRenders, num(statSyncMs, 1), num(statSyncMsMax, 1),
tostring(statSyncLast or "-"), statPrewarmHits, #fxQueue + fxInFlightCount)
return stats
end
function ChipAudio.resetSyncStats()
statSyncRenders, statSyncMs, statSyncMsMax = 0, 0, 0
statSyncLast = nil
statPrewarmHits = 0
end
function ChipAudio.update()
if suspended then return end
if fxCh then drainEffects() end
if fxCh then pumpEffects() end
local m = currentMusic
if not m then return end
if m.threaded then
@@ -708,19 +736,59 @@ local function effectKey(data, header, options)
}, "|")
end
local function storeReady(key, sd)
if fxReady[key] ~= nil then return end
if fxPinned[key] then
fxReady[key] = sd
fxReadyCount = fxReadyCount + 1
return
end
while fxOrderCount >= FX_READY_MAX and #fxReadyOrder > 0 do
local oldest = table.remove(fxReadyOrder, 1)
if fxReady[oldest] ~= nil and not fxPinned[oldest] then
fxReady[oldest] = nil
fxReadyCount = fxReadyCount - 1
fxOrderCount = fxOrderCount - 1
end
end
if #fxReadyOrder > FX_READY_MAX * 2 then
local live = {}
for _, k in ipairs(fxReadyOrder) do
if fxReady[k] ~= nil and not fxPinned[k] then live[#live + 1] = k end
end
fxReadyOrder = live
end
fxReady[key] = sd
fxReadyCount = fxReadyCount + 1
fxOrderCount = fxOrderCount + 1
fxReadyOrder[#fxReadyOrder + 1] = key
end
local function pinEffect(key)
if fxPinned[key] or fxPinnedCount >= FX_PINNED_MAX then return end
fxPinned[key] = true
fxPinnedCount = fxPinnedCount + 1
if fxReady[key] ~= nil then fxOrderCount = fxOrderCount - 1 end
end
function drainEffects()
if not fxCh then return end
while true do
local result = fxCh:pop()
if not result then return end
if result.epoch == fxEpoch and fxPending[result.key] then
fxPending[result.key] = nil
if result.error then
require("src.core.Logger").warn("chip audio prewarm: %s",
tostring(result.error))
elseif fxReadyCount < FX_READY_MAX then
fxReady[result.key] = result.sd or false
fxReadyCount = fxReadyCount + 1
if result.epoch == fxEpoch then
if fxInFlight[result.key] then
fxInFlight[result.key] = nil
fxInFlightCount = fxInFlightCount - 1
end
if fxPending[result.key] then
fxPending[result.key] = nil
if result.error then
require("src.core.Logger").warn("chip audio prewarm: %s",
tostring(result.error))
else
storeReady(result.key, result.sd or false)
end
end
end
end
@@ -729,17 +797,53 @@ end
function dropEffects()
fxEpoch = fxEpoch + 1
fxReady, fxPending, fxReadyCount = {}, {}, 0
fxReadyOrder, fxQueue, fxInFlight, fxInFlightCount = {}, {}, {}, 0
fxOrderCount, fxPinned, fxPinnedCount = 0, {}, 0
if fxCh then fxCh:clear() end
end
local function musicHasHeadroom()
local m = currentMusic
if not (m and m.threaded and m.started) or m.finished or musicHeld then
return true
end
local depth = queuedBuffers(m.source)
if not depth then return true end
if outCh then depth = depth + outCh:getCount() end
return depth >= MUSIC_PREROLL * 2
end
function pumpEffects()
drainEffects()
while #fxQueue > 0 and fxInFlightCount < FX_IN_FLIGHT_MAX do
if not workerAlive() then
fxQueue = {}
return
end
if fxInFlightCount > 0 and not musicHasHeadroom() then return end
local entry = table.remove(fxQueue, 1)
if fxPending[entry.key] then
local pushed = pcall(cmdCh.push, cmdCh, entry.request)
if pushed then
fxInFlight[entry.key] = true
fxInFlightCount = fxInFlightCount + 1
else
fxPending[entry.key] = nil
end
end
end
end
local function requestEffect(data, header, options)
if not ensureWorker() or not workerAlive() then return false end
local key = effectKey(data, header, options)
if not key then return false end
drainEffects()
if fxReady[key] ~= nil or fxPending[key] then return true end
fxCh = fxCh or love.thread.getChannel("chipaudio_fx")
local pushed = pcall(cmdCh.push, cmdCh, {
drainEffects()
if pinRequests then pinEffect(key) end
if fxReady[key] ~= nil or fxPending[key] then return true end
fxPending[key] = true
fxQueue[#fxQueue + 1] = { key = key, request = {
cmd = "effect", key = key, epoch = fxEpoch, header = header,
options = {
frequencyOffset = options.frequencyOffset,
@@ -753,9 +857,8 @@ local function requestEffect(data, header, options)
channelPitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo(),
sampleRate = sampleRate(),
})
if not pushed then return false end
fxPending[key] = true
} }
pumpEffects()
return true
end
@@ -769,16 +872,46 @@ local function takeEffect(data, header, options)
if sd ~= nil then
fxReady[key] = nil
fxReadyCount = fxReadyCount - 1
if not fxPinned[key] then fxOrderCount = fxOrderCount - 1 end
return sd
end
-- about to render it here; the worker's copy would arrive unused
fxPending[key] = nil
if fxPending[key] then
fxPending[key] = nil
for i, entry in ipairs(fxQueue) do
if entry.key == key then
table.remove(fxQueue, i)
break
end
end
end
return nil
end
local function renderEffect(data, header, options)
local function nowMs()
local timer = love and love.timer
if timer and timer.getTime then return timer.getTime() * 1000 end
return os.clock() * 1000
end
local function renderEffect(data, header, options, label)
local sd = takeEffect(data, header, options)
if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end
if sd == nil then
local began = nowMs()
sd = ChipSynth.renderEffectData(data, header, options)
local ms = nowMs() - began
statSyncRenders = statSyncRenders + 1
statSyncMs = statSyncMs + ms
if ms > statSyncMsMax then statSyncMsMax = ms end
statSyncLast = label
if STATS then
require("src.core.Logger").info("chipaudio: sync render %s %.1fms",
tostring(label), ms)
end
local FixedStep = require("src.core.FixedStep")
if ms >= FixedStep.STEP * 1000 then FixedStep:discardCatchup() end
else
statPrewarmHits = statPrewarmHits + 1
end
if not sd then return nil end
return love.audio.newSource(sd, "static")
end
@@ -801,7 +934,8 @@ end
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name]
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames),
name)
end
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
@@ -810,7 +944,8 @@ end
function ChipAudio.newCry(data, species, resolved)
local cry = cryDef(data, species, resolved)
if not cry then return nil end
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry))
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry),
"cry:" .. tostring(species))
end
-- Same arguments as newSfx / newCry; returns true when a render was queued
@@ -830,11 +965,26 @@ function ChipAudio.prewarmCry(data, species, resolved)
return requestEffect(data, header, cryOptions(cry))
end
function ChipAudio.pumpEffects()
if suspended or not fxCh then return end
pumpEffects()
end
function ChipAudio.prewarmPinned(fn, ...)
local was = pinRequests
pinRequests = true
local ok, err = pcall(fn, ...)
pinRequests = was
if not ok then error(err, 0) end
end
-- test hook: prewarm bookkeeping
function ChipAudio._effectStateForTest()
local pending = 0
for _ in pairs(fxPending) do pending = pending + 1 end
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch }
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch,
queued = #fxQueue, inFlight = fxInFlightCount,
pinned = fxPinnedCount, unpinnedReady = fxOrderCount }
end
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
+22
View File
@@ -1234,6 +1234,7 @@ function Game2:load(opts)
pcall(Font.load, self.data)
end
Strings.load(self.data)
self:prewarmSessionSfx()
-- The boot skeleton (Game2.new built it, before any bus existed) announced
-- here rather than at its construction, which is the same spot in the boot
@@ -1341,6 +1342,25 @@ function Game2:load(opts)
end
end
Game2.SESSION_SFX = {
-- engine/overworld/scripting.asm:467
"Sfx_Item", "Sfx_KeyItem", "Sfx_GetTm",
"Sfx_ReadText2", "Sfx_Menu", "Sfx_Bump", "Sfx_EnterDoor",
"Sfx_ExitBuilding", "Sfx_JumpOverLedge", "Sfx_WarpTo", "Sfx_WarpFrom",
"Sfx_Save", "Sfx_Transaction", "Sfx_Wrong", "Sfx_SwitchPokemon",
"Sfx_GetBadge", "Sfx_Strength",
}
function Game2:prewarmSessionSfx()
local Sound = require("src.core.Sound")
local ChipAudio = require("src.core.ChipAudio")
ChipAudio.prewarmPinned(function()
for _, name in ipairs(Game2.SESSION_SFX) do
pcall(Sound.prewarmSfx, self.data, name)
end
end)
end
function Game2:inFillBoot()
-- Entire pre-world cinema (copyright / title / Oak / name / nested NamingScreen)
-- draws in GB letterbox space.
@@ -1376,6 +1396,8 @@ end
function Game2:update(dt)
require("src.core.DeferredWrite").tick()
local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then ChipAudio.pumpEffects() end
-- _UpdateSound is a VBlank job, so it runs at 60Hz off real time whatever the
-- logic multiplier is (audio/engine.asm:84, home/vblank.asm:141-143).
local step = FixedStep.STEP
+2 -1
View File
@@ -368,7 +368,8 @@ end
function Game3:writeOptions()
if type(self.options) ~= "table" then return end
if SaveData.saveOptions then pcall(SaveData.saveOptions, self.options) end
local write = SaveData.saveSessionOptions or SaveData.saveOptions
if write then pcall(write, self.options) end
end
Game3.persistOptions = Game3.writeOptions
+19 -10
View File
@@ -236,21 +236,30 @@ local function windowsModulePath()
return api and api.modulePath() or nil
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
-- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- Android (#575) and iOS restart in-process: love.quit joins every worker
-- first, so no physfs handle outlives lua_close. iOS turns every quit into
-- DONE_RESTART in love.cpp, so a bare quit() is the restart there.
-- quit("restart") re-inits physfs, which an AppImage refuses ("already initialized"), so Linux execs the binary.
function HostShell.loopRestarts()
return rawget(_G, "POKEPORT_LOOP_RESTART") == true
end
function HostShell.canRestart()
local osName = love and love.system and love.system.getOS and love.system.getOS()
return osName ~= "Android" or HostShell.loopRestarts()
end
HostShell.restarting = false
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
HostShell.restarting = true
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Android" then
love.event.quit("restart")
if HostShell.loopRestarts() then
love.event.quit("restart")
return
end
if love.system.restartApp and love.system.restartApp() then return end
love.event.quit()
return
end
if osName == "iOS" then
+36 -2
View File
@@ -871,7 +871,36 @@ end
-- Both take an optional fs (write/getInfo/read) defaulting to
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
SaveData.LAUNCHER_KEYS = {
"autoReimport", "splashVideo", "splashMute", "themeVideoBg", "reduceMotion",
"boxMusicVol", "boxCryVol", "romSources", "lastVersion", "activeCart", "modSort",
}
function SaveData.launcherKeys()
local out = {}
for _, key in ipairs(SaveData.LAUNCHER_KEYS) do out[#out + 1] = key end
local ok, SecretGames = pcall(require, "src.import.SecretGames")
if ok and type(SecretGames) == "table" and type(SecretGames.GAMES) == "table" then
for _, key in pairs(SecretGames.GAMES) do out[#out + 1] = key end
end
return out
end
function SaveData.invalidateOptionsCache()
for _, slot in pairs(optionsCache) do slot.rev = slot.rev + 1 end
end
local writeOptions
function SaveData.saveOptions(opts, fs)
return writeOptions(opts, fs, false)
end
function SaveData.saveSessionOptions(opts, fs)
return writeOptions(opts, fs, true)
end
writeOptions = function(opts, fs, launcherFromDisk)
fs = persistFs(fs)
-- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a
-- PARTIAL table (just the keys it changed) would silently drop every key it
@@ -893,6 +922,11 @@ function SaveData.saveOptions(opts, fs)
-- a partial write would push the global value into the cart's bucket.
local cartGlobalValues = cartGlobals(onDisk)
applyCartOverlay(onDisk)
if launcherFromDisk and type(opts) == "table" and type(onDisk) == "table" then
for _, key in ipairs(SaveData.launcherKeys()) do
opts[key] = deepCopy(onDisk[key])
end
end
local isFull = type(opts) == "table"
if isFull then
for k in pairs(SaveData.defaultOptions()) do
@@ -2720,7 +2754,7 @@ end)
function SaveData.saveLiveOptions(data)
if type(data) ~= "table" or type(data.options) ~= "table" then return nil end
data.options = rememberPlaythroughId(data, data.options)
return SaveData.saveOptions(data.options)
return SaveData.saveSessionOptions(data.options)
end
-- Game progress only; options are written separately via saveOptions.
@@ -2740,7 +2774,7 @@ function SaveData.save(data, mods)
SaveData.saveLiveOptions(data)
else
local opts, changed = rememberPlaythroughId(data)
if changed then SaveData.saveOptions(opts) end
if changed then SaveData.saveSessionOptions(opts) end
end
if activeCart and type(data.meta) == "table" then
data.meta.cartId = activeCart
@@ -39,5 +39,17 @@ return function()
end
K.destroy(t)
end),
-- pokeruby/src/battle/anim/sfx.c:194
sub_812B2B8 = K.wrap(function(t, vm)
local isContest = Context.isContest(vm)
local pan = isContest and 63 or (P.atkId(vm) % 2 == 0 and -64 or 63)
local species
if isContest then species = tonumber(P.species(vm, P.atk(vm))) or 0
else species = partySpecies(K.battleState(), P.atkId(vm)) end
if species ~= 0 then
require("src.core.game3.audio").playCry(species, { mode = 4, pan = pan, volume = 125 })
end
K.destroy(t)
end),
}
end
+5 -1
View File
@@ -2198,6 +2198,10 @@ local function is_rs_battle()
return P.font.nativeLayout == "rs"
end
function Ui.drawMenuFrames(panelMode)
BattleChrome.drawMenuFrames(panelMode, is_rs_battle() and "rs" or nil)
end
local function c5to8(x)
return (x * 8 + math.floor(x / 4)) / 255
end
@@ -2804,7 +2808,7 @@ function Ui.draw(w, h)
panelMode = "moves"
end
BattleChrome.drawPanel(panelMode)
BattleChrome.drawMenuFrames(panelMode)
Ui.drawMenuFrames(panelMode)
if Ui._mode == "menu" then
draw_action_menu(st)
+1
View File
@@ -2008,6 +2008,7 @@ function BattleTransition.drawWorld(canvas, vw, vh)
local fx = BattleTransition._fx
G.push("all")
G.origin()
G.setCanvas(canvas)
if fx and fx.def.redraw then
local scratch = BattleTransition._scratch
if not scratch or scratch:getWidth() ~= vw or scratch:getHeight() ~= vh then
+1
View File
@@ -360,6 +360,7 @@ local function presentPlanes(game)
local vw, vh = Renderer:worldViewSize()
drawFieldPlane(game, vw, vh, Renderer)
love.graphics.pop()
love.graphics.setCanvas(Renderer.worldCanvas)
local Transition = package.loaded["src.core.game3.battle_transition"]
if Transition and Transition.isActive and Transition.isActive() then
Transition.drawWorld(Renderer.worldCanvas, vw, vh)
+5
View File
@@ -2111,6 +2111,11 @@ function Field.respawnAtHeal(opts)
Flags.setVar(Space.store, Space.vm and Space.vm.ctx or nil, Ctx.VAR_LAST_TALKED, healerId)
end
end
if whiteOut and healRow then
-- pokeemerald/src/overworld.c:1563
local Fade = lazyReq("src.ui.game3.fade")
if Fade.active or (Fade.t or 0) > 0 then Fade.begin(Fade.MODE.FROM_BLACK, 1) end
end
if whiteOut and not healRow then
-- pokefirered/src/overworld.c:1558
local home = HealLocations.get(1)
+23 -1
View File
@@ -264,6 +264,8 @@ local function newEventObject(src, neighbor, prepared)
eo.originMapGroup = tonumber(def.originMapGroup or def.mapGroup) or mg
eo.originMapNum = tonumber(def.originMapNum or def.mapNum) or mn
end
-- pokeemerald/src/event_object_movement.c:1301
eo.spawnElevation = true
return eo
end
@@ -737,7 +739,11 @@ function Objects.snapshot()
local protos = {}
for _, def in ipairs(Objects._defs or {}) do
local lid = tonumber(def.localId or def.index) or 0
if lid > 0 then protos[lid] = newEventObject(def) end
if lid > 0 then
local p = newEventObject(def)
Objects.spawnElevation(p)
protos[lid] = p
end
end
local list = {}
for _, lid in ipairs(Objects._order) do
@@ -1045,6 +1051,20 @@ function Objects.updateElevation(eo)
cx, cy, eo.cellX, eo.cellY)
end
-- pokeemerald/src/event_object_movement.c:7737
function Objects.spawnElevation(eo)
eo.spawnElevation = nil
if eo.fixedPriority then return end
local Coll = Collision()
if not (Coll and Coll.nextElevation) then return end
local cx, cy = eo.cellX, eo.cellY
if eo.moving then cx, cy = eo.targetX, eo.targetY end
local cur, prev = Coll.nextElevation(eo.mapDef or Coll._mapDef, eo.currentElevation or 0,
cx, cy, eo.cellX, eo.cellY)
eo.currentElevation = cur
if prev then eo.elevation = prev end
end
local function beginStep(eo, tx, ty)
eo.moving = true
eo.progress = 0
@@ -1815,6 +1835,7 @@ function Objects.update(game)
for _, lid in ipairs(Objects._order) do
local eo = Objects._byId[lid]
if eo then
if eo.spawnElevation then Objects.spawnElevation(eo) end
if eo.bowFrames and eo.bowFrames > 0 then
eo.bowFrames = eo.bowFrames - 1
if eo.bowFrames <= 0 then eo.bowFrames = nil end
@@ -2139,6 +2160,7 @@ function Objects.tickPool(pool, game, ctx)
for _, lid in ipairs(pool.order or {}) do
local eo = pool.byId[lid]
if eo then
if eo.spawnElevation then Objects.spawnElevation(eo) end
if eo.bowFrames and eo.bowFrames > 0 then
eo.bowFrames = eo.bowFrames - 1
if eo.bowFrames <= 0 then eo.bowFrames = nil end
@@ -47,6 +47,8 @@ return {
eggHatchText = "Text_EggHatchHuh",
-- pokeemerald/src/field_poison.c:76
poisonFaintText = "gText_PkmnFainted_FldPsn",
-- pokeemerald/src/field_control_avatar.c:551
fieldPoisonScript = "EventScript_FieldPoison",
-- pokeemerald/src/event_object_movement.c:1927
invalidGfx = "OBJ_EVENT_GFX_NINJA_BOY",
-- pokeemerald/data/scripts/field_move_scripts.inc:60
+2
View File
@@ -44,6 +44,8 @@ return {
eggHatchText = "UnknownString_81B2C68",
-- pokeruby/src/field_poison.c:81
poisonFaintText = "fieldPoisonText_PokemonFainted",
-- pokeruby/src/field_control_avatar.c:580
fieldPoisonScript = "gUnknown_081A14B8",
-- pokeruby/src/event_object_movement.c:1767
invalidGfx = "OBJ_EVENT_GFX_LITTLE_BOY_1",
fieldMoveScripts = true,
+28 -22
View File
@@ -84,26 +84,6 @@ local function field_white_out_event(session, game)
end
local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field")
Field.lock()
local Rse = require("src.core.game3.rse.init")
if Rse.isRse(session) then
local Pike = require("src.core.game3.rse.frontier.pike")
local Pyramid = require("src.core.game3.rse.frontier.pyramid")
local Hill = require("src.core.game3.rse.trainer_hill")
if Pike.inBattlePike(session) or Pyramid.inPyramid(session) or Hill.inChallenge(session) then
-- pokeemerald/data/scripts/field_poison.inc:23
local Space = package.loaded["src.core.game3.scripting.space"]
or require("src.core.game3.scripting.space")
local key = Space.scriptKey("EventScript_FrontierFieldWhiteOut")
if key and Space.startScript(key) then
ev.phase = "frontier_script"
ev.tick = function()
local vm = Space.vm
if not vm or not vm.active then onDone() end
end
return
end
end
end
local BattleBridge = require("src.core.game3.battle_bridge")
local save = game and game.save
local name = session.name or session.playerName or ""
@@ -147,6 +127,23 @@ local function field_white_out_event(session, game)
return ev
end
local function field_poison_script_event(scriptName)
local ev = { type = "field_poison_script", script = scriptName }
ev.run = function(onDone)
local Space = package.loaded["src.core.game3.scripting.space"]
or require("src.core.game3.scripting.space")
local key = Space.scriptKey(scriptName)
if not (key and Space.startScript(key)) then
error("field poison script did not start: " .. tostring(scriptName))
end
ev.tick = function()
local vm = Space.vm
if not vm or not vm.active then onDone() end
end
end
return ev
end
--- Evaluate step counters upon completing a grid step (Walk, Run, Bike, Surf).
function StepEvents.onStepTaken(session, game)
if not session then return end
@@ -241,6 +238,8 @@ function StepEvents.onStepTaken(session, game)
psnSteps = 0
local anyPoisonDamage = false
local faintedMons = {}
local profileField = require("src.core.game3.profile").forSession(session).field
local poisonScript = profileField and profileField.fieldPoisonScript
for slotIdx, mon in ipairs(party) do
local isEgg = mon.isEgg or (type(mon.egg) == "boolean" and mon.egg)
@@ -251,7 +250,9 @@ function StepEvents.onStepTaken(session, game)
if not isEgg and isPsn and hp > 0 then
anyPoisonDamage = true
mon.hp = math.max(0, hp - 1)
if mon.hp == 0 then
if mon.hp == 0 and poisonScript then
faintedMons[#faintedMons + 1] = { slot = slotIdx, mon = mon }
elseif mon.hp == 0 then
-- pokefirered/src/field_poison.c:36
Pokemon.adjustFriendship(mon, Pokemon.FRIENDSHIP_EVENT_FAINT_OUTSIDE_BATTLE,
{ mapSec = Pokemon.currentMapSec(session) })
@@ -273,6 +274,11 @@ function StepEvents.onStepTaken(session, game)
-- pokefirered/src/field_control_avatar.c:727 FLDPSN_FNT
poisonFainted = #faintedMons > 0
if poisonFainted and poisonScript then
-- pokeemerald/src/field_control_avatar.c:551
push_event(field_poison_script_event(poisonScript))
faintedMons = {}
end
for _, fainted in ipairs(faintedMons) do
push_event({
type = "poison_faint",
@@ -289,7 +295,7 @@ function StepEvents.onStepTaken(session, game)
})
end
-- pokefirered/src/field_poison.c:76
if poisonFainted then push_event(field_white_out_event(session, game)) end
if poisonFainted and not poisonScript then push_event(field_white_out_event(session, game)) end
end
end
session.vars[psnVar] = psnSteps
+60 -9
View File
@@ -23,6 +23,54 @@ local SaveData = require("src.core.SaveData")
local LauncherSettings = {}
local function copyValue(v)
if type(v) ~= "table" then return v end
local out = {}
for k, val in pairs(v) do out[k] = copyValue(val) end
return out
end
local function sameValue(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
for k, v in pairs(a) do
if not sameValue(v, b[k]) then return false end
end
for k in pairs(b) do
if a[k] == nil then return false end
end
return true
end
local function restoreInto(dst, key, value)
local cur = dst[key]
if type(cur) == "table" and type(value) == "table" then
for k in pairs(cur) do cur[k] = nil end
for k, v in pairs(value) do cur[k] = copyValue(v) end
else
dst[key] = copyValue(value)
end
end
function LauncherSettings.persist(opts, base)
local changed = {}
for k, v in pairs(opts) do
if not sameValue(v, base[k]) then changed[#changed + 1] = k end
end
for k in pairs(base) do
if opts[k] == nil then changed[#changed + 1] = k end
end
if #changed == 0 then return true end
SaveData.invalidateOptionsCache()
local fresh = SaveData.loadOptions()
for _, k in ipairs(changed) do fresh[k] = copyValue(opts[k]) end
if SaveData.saveOptions(fresh) ~= nil then
for _, k in ipairs(changed) do base[k] = copyValue(opts[k]) end
return true
end
for _, k in ipairs(changed) do restoreInto(opts, k, base[k]) end
return false
end
local function bgLocked(opts)
return opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended"
@@ -850,7 +898,6 @@ function LauncherSettings.open(hooks, version)
})
end
local RomSources = require("src.import.RomSources")
local forgotRoms = false
launcher.rows[#launcher.rows + 1] = {
label = Strings("Auto Re-import"),
value = function()
@@ -875,7 +922,6 @@ function LauncherSettings.open(hooks, version)
doneText = Strings("Saved ROMs forgotten."),
action = function()
RomSources.forgetAll(opts)
forgotRoms = true
return true
end,
}
@@ -888,17 +934,22 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
return {
local base = copyValue(opts)
local model = {
opts = opts,
version = version,
sections = sections,
save = function()
if not forgotRoms then
opts.romSources = SaveData.loadOptions().romSources
end
SaveData.saveOptions(opts)
end,
}
model.save = function()
if LauncherSettings.persist(opts, base) then
model.saveError = nil
return true
end
model.saveError = Strings("Settings could not be saved. Check the log.")
model.flash = nil
return false
end
return model
end
return LauncherSettings
+5 -3
View File
@@ -5716,7 +5716,10 @@ local function buildSettingsModal(imp, m)
local x, cy, width = px + pad, py + pad, pw - 2 * pad
Kit.textBold("title", Strings("Settings"), x, cy, PAL.heading)
local subtitleW = width - m.btnH - gap
if model.flash then
if model.saveError then
Kit.text("small", Kit.ellipsize("small", model.saveError, subtitleW), x,
cy + Kit.textHeight("title") + math.floor(3 * m.s), PAL.red)
elseif model.flash then
Kit.text("small", Kit.ellipsize("small", model.flash, subtitleW), x,
cy + Kit.textHeight("title") + math.floor(3 * m.s), PAL.green)
else
@@ -5832,8 +5835,7 @@ local function buildSettingsModal(imp, m)
local aw = math.min(inner, chipWidth(label, m))
local function run()
if row.action() ~= false then
model.save()
if row.doneText then model.flash = row.doneText end
if model.save() ~= false and row.doneText then model.flash = row.doneText end
end
end
control(rx - aw, aw, key .. "-act", label, {
+13 -2
View File
@@ -257,7 +257,7 @@ local function sha1(data)
end
local function readExternalPath(path)
local file, openError = io.open(path, "rb")
local file, openError = require("src.core.SaveData").openNative(path, "rb")
if not file then return nil, openError end
local data = file:read("*a")
file:close()
@@ -2458,7 +2458,18 @@ function RomImporter:_offerReimport(version, cand)
end
local name = GameVersion.info(version).displayName
local lines
if cand.pick then
if cand.missing then
local why = cand.missing ~= "changed"
and Strings("The saved ROM at %s could not be found or opened.",
RomSources.shortPath(cand.path))
or Strings("The saved ROM at %s no longer matches this game.",
RomSources.shortPath(cand.path))
lines = {
Strings("%s needs its ROM imported again.", name),
why,
Strings("Choose the ROM file to re-import it?"),
}
elseif cand.pick then
lines = {
Strings("%s needs its ROM imported again.", name),
Strings("Choose the ROM file to re-import it?"),
+29 -5
View File
@@ -1,5 +1,6 @@
local GameVersion = require("src.core.GameVersion")
local SaveData = require("src.core.SaveData")
local Logger = require("src.core.Logger")
local RomSources = {}
@@ -236,13 +237,22 @@ end
local function readSource(rec)
if rec.kept then return RomSources.readKept(rec.path) end
local file = io.open(rec.path, "rb")
local file = SaveData.openNative(rec.path, "rb")
if not file then return nil end
local data = file:read("*a")
file:close()
return data
end
local function unwrapArchive(data, rec)
local RomArchive = require("src.import.RomArchive")
if not RomArchive.kind(data) then return data end
local bytes, why = RomArchive.unwrap(data, rec.path:match("[^/\\]+$"), {
prefer = function(candidate) return sha1(candidate) == rec.sha1 end,
})
return bytes, why
end
function RomSources.candidate(version, mobile, opts)
local rec = RomSources.get(version, opts)
if not rec or type(rec.sha1) ~= "string" then return nil end
@@ -251,11 +261,25 @@ function RomSources.candidate(version, mobile, opts)
return nil
end
local ok, data = pcall(readSource, rec)
local okHash, digest = false, nil
if ok and data then okHash, digest = pcall(sha1, data) end
if not okHash or digest ~= rec.sha1 then
local reason
if not ok or type(data) ~= "string" then
reason = "missing"
else
local okUnwrap, bytes, why = pcall(unwrapArchive, data, rec)
if not okUnwrap or not bytes then
reason = "archive"
Logger.warn("re-import source %s: %s", tostring(rec.path),
tostring(okUnwrap and why or bytes))
else
local okHash, digest = pcall(sha1, bytes)
if not okHash or digest ~= rec.sha1 then reason = "changed" end
end
end
if reason then
Logger.warn("re-import source for %s is unusable (%s): %s",
tostring(version), reason, tostring(rec.path))
if rec.kept then return { pick = true, broken = true } end
return nil
return { pick = true, missing = reason, path = rec.path }
end
return { path = rec.path, kept = rec.kept == true }
end
+1 -1
View File
@@ -6,7 +6,7 @@ local Rs = {}
local function tables(game, revision)
local V = VersionsRse.new(game, revision.build)
local sym, count = V.sym, V.count
V.CACHE_VERSION = 4
V.CACHE_VERSION = 5
V.NATIVE_VERSION = 2
V.OW_VERSION = 1
V.ANIM_VERSION = 1
+3 -10
View File
@@ -27,16 +27,9 @@ return function(V)
SURF_OPPONENT = {gfx = sym("gBattleAnimBackgroundImage_Surf"), pal = sym("gBattleAnimBackgroundPalette_Surf"), map = sym("gUnknown_08E70968")},
}}
V.ANIM_NAMES, V.ANIM_TAG_NAMES = Names, Names.tagNames
local function byPrefix(prefix)
local out = {}
for n, id in pairs(require("src.core.game3.constants").of(V.GAME).battle.byName) do
if n:sub(1, #prefix) == prefix then out[id] = n:sub(8) end
end
return out
end
V.BATTLE_ANIM_STATUS_NAMES = byPrefix("B_ANIM_STATUS_")
V.BATTLE_ANIM_GENERAL_NAMES = {[0] = "CASTFORM_CHANGE", "STATS_CHANGE", "SUBSTITUTE_FADE", "SUBSTITUTE_APPEAR", "POKEBLOCK_THROW", "ITEM_KNOCKOFF", "TURN_TRAP", "ITEM_EFFECT", "SMOKEBALL_ESCAPE", "HANGED_ON", "RAIN_CONTINUES", "SUN_CONTINUES", "SANDSTORM_CONTINUES", "HAIL_CONTINUES", "LEECH_SEED_DRAIN", "MON_HIT", "ITEM_STEAL", "SNATCH_MOVE", "FUTURE_SIGHT_HIT", "DOOM_DESIRE_HIT", "FOCUS_PUNCH_SETUP", "INGRAIN_HEAL", "WISH_HEAL"}
V.BATTLE_ANIM_SPECIAL_NAMES = {[0] = "LVL_UP", "SWITCH_OUT_PLAYER_MON", "SWITCH_OUT_OPPONENT_MON", "BALL_THROW", "BALL_THROW_WITH_TRAINER", "SUBSTITUTE_TO_MON", "MON_TO_SUBSTITUTE"}
V.BATTLE_ANIM_STATUS_NAMES = Names.statusNames
V.BATTLE_ANIM_GENERAL_NAMES = Names.generalNames
V.BATTLE_ANIM_SPECIAL_NAMES = Names.specialNames
local templates, callbacks, tasks = {}, {}, {}
local function functionOffset(name)
if S.hasFunc(name) then return S.funcOff(name) end
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,7 +1,8 @@
local K = require("src.import.gba.rse.boot_gfx")
local A = require("src.import.gba.rs.assets")
local M = {SUB = "rse/bag", FILES = {"bg.png", "bg_female.png", "bag_male.png", "bag_female.png", "ball.png", "select.png",
"arrows_vertical.png", "arrows_horizontal.png", "hm.png", "number.png", "indicator_0.png", "indicator_1.png", "list_cursor.png"}}
"arrows_vertical.png", "arrows_horizontal.png", "hm.png", "number.png", "indicator_0.png", "indicator_1.png",
"indicator_0_female.png", "indicator_1_female.png", "list_cursor.png"}}
for i = 0, 4 do for _, g in ipairs({"male", "female"}) do M.FILES[#M.FILES + 1] = "label_" .. i .. "_" .. g .. ".png" end end
M.REQUIRED = K.required(M.SUB, M.FILES)
local N, H = "item_menu.o:", "menu_helpers.o:"
@@ -39,11 +40,15 @@ function M.run(rom, cache, opts)
local words = {}; for _, word in ipairs(g[2]) do words[#words + 1] = string.char(word % 256, math.floor(word / 256)) end
local pixels, gw, gh = K.bakeText(gfx, table.concat(words), g[3], g[4], {linear = true, mapWidth = g[3]})
local rec = {png = c:png(g[1] .. ".png", gw, gh, pixels, male, true), w = gw, h = gh, words = g[2], paletteBank = math.floor(g[2][1] / 4096)}
if g[1]:find("indicator", 1, true) then man.indicators[tonumber(g[1]:sub(-1))] = rec else man.glyphs[g[1]] = rec end
if g[1]:find("indicator", 1, true) then
rec.female = c:png(g[1] .. "_female.png", gw, gh, pixels, female, true)
man.indicators[tonumber(g[1]:sub(-1))] = rec
else man.glyphs[g[1]] = rec end
end
man.indicatorGeometry = {x = 40, y = 72, spacing = 8, pockets = 5}
man.hm, man.number = man.glyphs.hm.png, man.glyphs.number.png
man.indicators.idle, man.indicators.selected = man.indicators[0].png, man.indicators[1].png
man.indicators.idleFemale, man.indicators.selectedFemale = man.indicators[0].female, man.indicators[1].female
local tableOff = c:off("gSubspriteTables_842F6C0") + 15 * 8
local count, parts, tiles = c:u8(tableOff), {}, c:raw("OutlineCursorTiles_12")
local sub = assert(c:ptr(tableOff + 4))
+3 -2
View File
@@ -6,7 +6,7 @@ local M = {REQUIRED = Base.REQUIRED}
function M.run(rom, cache, opts)
local c = A.context(rom, cache, opts, "pokemon/battle_anims")
local templates = {}
for name in pairs(Names.templates) do
for name, canonical in pairs(Names.templates) do
local off = c:off(name)
local row = c:readTemplate(name)
local affine, tableOff = {}, c:ptr(off + 16)
@@ -21,7 +21,8 @@ function M.run(rom, cache, opts)
end
row.affineAnims, row.romOffset = affine, off
row.images = nil
templates[name] = row
assert(templates[canonical] == nil, "RS template canonical name collides: " .. canonical)
templates[canonical] = row
end
local result = Base.run(rom, cache, opts)
local path = c:path("pack.lua")
+2 -2
View File
@@ -882,8 +882,8 @@ end
function ManagerState:restartGame()
if self.game.restartWithMods then
self.game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
else
require("src.core.HostShell").restart()
end
end
+14 -2
View File
@@ -493,10 +493,22 @@ BattleChrome.RSE_MENU_FRAMES = {
moves = { { 1, 15, 18, 4 }, { 21, 15, 8, 4 } },
}
-- pokeruby/graphics/interface/menu_map.bin
BattleChrome.RS_MENU_FRAMES = {
menu = { { 18, 15, 11, 4 } },
moves = { { 1, 15, 20, 4 }, { 23, 15, 6, 4 } },
}
function BattleChrome.menuFrameRects(mode, layout)
local set = layout == "rs" and BattleChrome.RS_MENU_FRAMES or BattleChrome.RSE_MENU_FRAMES
return set[mode]
end
-- pokeemerald/src/battle_bg.c:744
function BattleChrome.drawMenuFrames(mode)
-- pokeruby/src/battle_bg.c:266
function BattleChrome.drawMenuFrames(mode, layout)
if not BattleChrome.isRse() then return end
local rects = BattleChrome.RSE_MENU_FRAMES[mode]
local rects = BattleChrome.menuFrameRects(mode, layout)
if not rects then return end
local Chrome = require("src.ui.game3.chrome")
for _, r in ipairs(rects) do
+24 -5
View File
@@ -383,6 +383,12 @@ end
local function drawText(str, x, y, colors, width)
Font.draw(str, x, y, {font = "normal", colors = colors, maxWidth = width or 120, linePitch = 16})
end
local function quantity(qty, multX, y, colors)
local count = tostring(qty)
drawText("×", multX, y, colors)
-- pokeruby/src/text.c:3474
drawText(count, 232 - Font.measure(count, {font = "normal"}), y, colors)
end
local function frame(left, top, width, height) Chrome.stdFrame(left + 1, top + 1, width - 1, height - 1) end
function M.draw(Bag)
@@ -409,7 +415,10 @@ function M.draw(Bag)
local labels = m.labels[Bag.pocketIdx]
image(fem and labels.female or labels.male, 32, 80)
if m.indicators then
for p = 1, 5 do image(p == Bag.pocketIdx and m.indicators.selected or m.indicators.idle, 32 + p * 8, 72) end
-- pokeruby/src/item_menu.c:779
local selected, idle = m.indicators.selected, m.indicators.idle
if fem then selected, idle = m.indicators.selectedFemale, m.indicators.idleFemale end
for p = 1, 5 do image(p == Bag.pocketIdx and selected or idle, 32 + p * 8, 72) end
end
local rows = Bag.list()
local scroll = Bag.scroll
@@ -428,16 +437,26 @@ function M.draw(Bag)
local hm = pocket == "TM_CASE" and Items.isHm(row.id)
image(hm and m.hm or m.number, 112, y)
local number = pocket == "TM_CASE" and Items.tmNumber(row.id) or Items.berryNumber(row.id)
drawText(hm and tostring(number) or string.format("%02d", number or 0), 129, y, rowColors)
local move = pocket == "TM_CASE" and Pokemon.moveFromTmItem(row.id)
drawText(move and Pokemon.moveName(move) or row.name, 136, y, rowColors, hm and 96 or 78)
if not hm then drawText("×" .. tostring(row.qty), 232 - Font.measure("×" .. tostring(row.qty), {font = "normal"}), y, rowColors) end
local name = move and Pokemon.moveName(move) or row.name
if hm then
-- pokeruby/src/item_menu.c:1249
drawText(tostring(number), 129, y, rowColors)
drawText(name, 136, y, rowColors, 96)
else
-- pokeruby/src/item_menu.c:1091
local multX = 112 + 120 - ((pocket == "TM_CASE" and 2 or 3) + 1) * 6
drawText(string.format("%02d", number or 0), 120, y, rowColors)
drawText(name, 136, y, rowColors, multX - 136)
quantity(row.qty, multX, y, rowColors)
end
elseif pocket == "KEY_ITEMS" then
drawText(row.name, 112, y, rowColors, 96)
if Bag._session and Items.toNumericId(Bag._session.registeredItem) == Items.toNumericId(row.id) then image(m.select, 208, y) end
else
-- pokeruby/src/item_menu.c:1166
drawText(row.name, 112, y, rowColors, 102)
drawText("×" .. tostring(row.qty), 232 - Font.measure("×" .. tostring(row.qty), {font = "normal"}), y, rowColors)
quantity(row.qty, 214, y, rowColors)
end
end
end
+74
View File
@@ -377,6 +377,75 @@ function BattleState.trainerArt(data, classId)
return path, (classDef and classDef.trueColor) and true or false
end
local BATTLE_SFX = {
"Sfx_Damage", "Sfx_SuperEffective", "Sfx_NotVeryEffective",
"Sfx_ExpBar", "Sfx_HitEndOfExpBar", "Sfx_DexFanfare5079",
"Sfx_Faint", "Sfx_Kinesis", "Sfx_CaughtMon", "Sfx_Run",
"Sfx_SwitchPokemon", "Sfx_Potion",
}
local function animSounds(anims, order, key, out, seen)
if not key or seen[key] then return end
seen[key] = true
for _, row in ipairs(anims.scripts[key] or {}) do
if row[1] == "sound" then
local name = order[(row[3] or 0) + 1]
if name then out[#out + 1] = name end
else
for i = 2, #row do
local target = row[i]
if type(target) == "string" and anims.scripts[target] then
animSounds(anims, order, target, out, seen)
end
end
end
end
end
local function moveIdOf(move)
if type(move) == "table" then return move.id end
return move
end
function BattleState.battleSfxNames(data, anims, battle)
local audio = data and data.audio
local sfx = audio and audio.sfx
if not sfx then return {} end
local order = audio.sfxOrder or {}
local names, listed = {}, {}
local function add(name)
if name and sfx[name] and not listed[name] then
listed[name] = true
names[#names + 1] = name
end
end
local function addMoves(mon)
if not (mon and anims and anims.scripts and anims.moves) then return end
for _, move in ipairs(mon.moves or {}) do
local out = {}
animSounds(anims, order, anims.moves[moveIdOf(move)], out, {})
for _, name in ipairs(out) do add(name) end
end
end
battle = battle or {}
addMoves(battle.enemy)
addMoves(battle.player)
for _, name in ipairs(BATTLE_SFX) do add(name) end
if anims and anims.scripts and anims.ids then
local ids = {}
for id in pairs(anims.ids) do ids[#ids + 1] = id end
table.sort(ids)
for _, id in ipairs(ids) do
local out = {}
animSounds(anims, order, anims.ids[id], out, {})
for _, name in ipairs(out) do add(name) end
end
end
for _, mon in ipairs(battle.enemyParty or {}) do addMoves(mon) end
for _, mon in ipairs(battle.party or {}) do addMoves(mon) end
return names
end
-- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts)
opts = opts or {}
@@ -629,6 +698,11 @@ function BattleState.new(game, opts)
for _, mon in ipairs({ enemy or false, player or false }) do
if mon and mon.species then pcall(Sound.prewarmCry, data, mon.species) end
end
local okNames, names = pcall(BattleState.battleSfxNames, data, self.anims,
self.battle)
if okNames then
for _, name in ipairs(names) do pcall(Sound.prewarmSfx, data, name) end
end
return self
end