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
+1
View File
@@ -26,6 +26,7 @@ Features intentionally added to the games themselves, beyond what the original c
* **Bank-style Box migration** from Gen 1 and 2 to Gen 3: nature from EXP, three perfect IVs (five for Mew and Celebi), EVs reset, second ability when there is one
* **EXP-to-nature helper** in Box migration, showing how much EXP each nature needs before a Gen 1 or 2 Pokémon moves to Gen 3
* **Box item stash**: store bag items in Box, send them to another game's bag, give or take held items and change a Gen 3 Pokémon's ball
* **Unown form picker** in the save editor on Gen 2 and Gen 3, keeping HP DV, nature and shininess where the cart allows
## Gen 2 Specifics
+7 -2
View File
@@ -179,8 +179,13 @@ bundled game, in that case.
in-app. If neither transport exists, the worker reports `needs_full` and
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
`needs_full`). Applying a downloaded payload on Android restarts
in-process with `quit("restart")` only when the running `love.run` sets
`POKEPORT_LOOP_RESTART`; an older APK shell's stepper would hand that
string to `os.exit`, so there `HostShell.restart` falls back to
`love.system.restartApp` (or a plain quit) and EXIT GAME rebuilds the
launcher in the same Lua state instead. iOS uses a bare `quit()`, which
its `love.cpp` turns into a restart.
- **Android full updates are user-confirmed and certificate-bound.** The app
uses a private `FileProvider` cache path plus
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
+56 -5
View File
@@ -525,10 +525,56 @@ local function makeLauncher(launcherOpts)
})
end
local deferredLaunchRequest
local function rebuildLauncherInProcess(opts)
if require("src.core.RequireGuard").repair() then
print("boot: restored love.filesystem searcher (see #2001)")
end
local currentVersion = require("src.core.GameVersion").get()
SessionLifecycle.endGameSession(Game)
Game = nil
pcall(function() require("src.online.Trade").hostIsLive = nil end)
local syncEngine = package.loaded["src.sync.SyncEngine"]
if type(syncEngine) == "table" and type(syncEngine._shared) == "table" then
pcall(syncEngine._shared.protectPlaythrough, syncEngine._shared, nil, nil)
end
autopilot = nil
driverCo = nil
local SaveData = require("src.core.SaveData")
local cartId = SaveData.getCart()
SaveData.setCart(nil)
require("src.core.GameSpeed").setAllowed(nil)
SessionLifecycle.endMountedSession(currentVersion)
SaveData.refreshSlotResolution(currentVersion)
if cartId then SaveData.refreshSlotResolution("cart_" .. cartId) end
applySavedOrientation()
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
end
Importer = makeLauncher({ initialTab = opts.tab, invite = opts.invite })
if Importer.ignoreReturningPointer then
Importer:ignoreReturningPointer()
end
if type(opts.request) == "table" then deferredLaunchRequest = opts.request end
end
local function returnToLauncher(opts)
if not Game or quitToLauncher then return end
quitToLauncher = true
opts = opts or {}
if not require("src.core.HostShell").canRestart() then
return rebuildLauncherInProcess(opts)
end
quitToLauncher = true
local handoff = { tab = opts.tab, invite = opts.invite, request = opts.request }
local okEncode, body = pcall(require("src.core.SaveSerializer").encode, handoff)
pcall(love.filesystem.write, RELAUNCH_MARKER, okEncode and body or "return {}\n")
@@ -538,6 +584,7 @@ local function returnToLauncher(opts)
autopilot = nil
driverCo = nil
endProcessOnce()
if opts.restarting then return end
require("src.core.HostShell").restart()
end
@@ -673,8 +720,6 @@ local function autoUpdateMods(request, tab)
end
end
local deferredLaunchRequest
local function launcherBusy()
return launcherSplash ~= nil or Importer ~= nil and (Importer._updateAll ~= nil
or Importer._modInstall ~= nil or Importer._cartInstall ~= nil
@@ -1617,8 +1662,13 @@ function love.quit()
and (mobile or not launchedIntoGame)
end)
if wouldReturnToLauncher then
returnToLauncher()
return true
local HostShell = require("src.core.HostShell")
if HostShell.restarting and HostShell.canRestart() then
returnToLauncher({ restarting = true })
else
returnToLauncher()
return true
end
end
endProcessOnce()
end
@@ -1658,6 +1708,7 @@ local function idlePresentationCap(idleFor)
end
function love.run()
_G.POKEPORT_LOOP_RESTART = true
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
+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
+153
View File
@@ -0,0 +1,153 @@
-- ../pokecrystal/engine/overworld/scripting.asm:467
-- ../pokecrystal/engine/battle_anims/anim_commands.asm:1191
-- tools/run_driver.sh crystal <identity> tests/drivers/crystal_sfx_prewarm_2732.lua <shotdir>
local U = require("tests.drivers.util")
local Sound = require("src.core.Sound")
local ChipAudio = require("src.core.ChipAudio")
local Mon = require("src.battle.gen2.Mon")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR")
or ".bazinga/BSA/10-07-26-00-userreported/shots/crystal_sfx_prewarm_2732"
return function(game)
local fails = 0
local function say(line) print("[2732] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
return cond
end
local function finish()
say("stats " .. tostring(ChipAudio.stats().line))
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
while true do U.wait(60) end
end
if jit and jit.off then jit.off() end
local function drained(seconds)
local stop = love.timer.getTime() + seconds
while love.timer.getTime() < stop do
local st = ChipAudio.stats()
if (st.prewarmQueued or 0) + (st.prewarmInFlight or 0) == 0 then
return true
end
U.wait(1)
end
return false
end
local events = {}
local function watch(fnName)
local real = Sound[fnName]
Sound[fnName] = function(data, name, ...)
local before = ChipAudio.stats().syncRenders or 0
local src = real(data, name, ...)
local after = ChipAudio.stats().syncRenders or 0
events[#events + 1] = { kind = fnName, name = name,
sync = after - before, ms = ChipAudio.stats().syncRenderMsMax }
return src
end
end
watch("play")
watch("playStereo")
U.wait(45)
local world = game.world
if not ok(world and world.map, "the crystal world booted") then finish() end
if not ok(ChipAudio.stats().syncRenders ~= nil,
"ChipAudio.stats() counts main-thread sfx renders") then
finish()
end
ok(drained(20), "session sfx prewarm drained")
world:warpToMapId("ROUTE_29", 48, 3, "up")
U.wait(30)
local ball
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.itemball then ball = npc end
end
if not ok(ball ~= nil, "Route 29 has its POTION item ball") then finish() end
world:warpToMapId("ROUTE_29", ball.cellX, ball.cellY + 1, "up")
U.wait(30)
ChipAudio.resetSyncStats()
events = {}
world:interact()
local itemEvent
for _ = 1, 600 do
for _, e in ipairs(events) do
if e.name == "Sfx_Item" then itemEvent = e end
end
if itemEvent then break end
U.wait(1)
end
if ok(itemEvent ~= nil, "the item ball script played Sfx_Item") then
ok(itemEvent.sync == 0, "Sfx_Item started with 0 sync renders (got "
.. itemEvent.sync .. ")")
end
U.wait(60)
U.still(game, SHOT_DIR .. "/2732_01_found_potion_chime.png")
for _ = 1, 40 do
if not world:busy() then break end
U.tap(game, "a")
U.wait(6)
end
local cyndaquil = Mon.new(game.data, "CYNDAQUIL", 20)
game.save.party = { cyndaquil }
ok(world:startBattle({ wild = Mon.new(game.data, "SENTRET", 12) }),
"a wild battle started")
local screen
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
if not ok(screen ~= nil, "the battle screen came up") then finish() end
ok(drained(20), "battle sfx prewarm drained")
ChipAudio.resetSyncStats()
events = {}
for _ = 1, 300 do
if screen.phase == "menu" then break end
if screen.anim == nil then U.tap(game, "a") end
U.wait(4)
end
if not ok(screen.phase == "menu", "the battle menu came up") then finish() end
local introSounds = #events
U.tap(game, "a")
U.wait(10)
ok(screen.phase == "moves", "FIGHT opened the move list")
local moveName = cyndaquil.moves[1] and cyndaquil.moves[1].id
U.tap(game, "a")
local moveEvent
for _ = 1, 600 do
for i = introSounds + 1, #events do
local e = events[i]
if e.kind == "playStereo" then moveEvent = moveEvent or e end
end
if moveEvent then break end
U.wait(1)
end
if ok(moveEvent ~= nil, "the turn's first move animation played a sound ("
.. tostring(moveName) .. " chosen)") then
ok(moveEvent.sync == 0, ("%s started with 0 sync renders (got %d)")
:format(moveEvent.name, moveEvent.sync))
end
U.still(game, SHOT_DIR .. "/2732_02_move_anim_sound.png")
for _ = 1, 200 do
if not (game.stack:top() or {}).battle then break end
if screen.anim == nil then U.tap(game, "a") end
U.wait(4)
end
local stereo, played = 0, 0
for _, e in ipairs(events) do
played = played + 1
if e.kind == "playStereo" then stereo = stereo + 1 end
end
local st = ChipAudio.stats()
ok(stereo > 0, "the turn played " .. stereo .. " anim sounds of " .. played)
ok((st.syncRenders or 0) == 0, "0 sync sfx renders across the battle (last "
.. tostring(st.syncRenderLast) .. ")")
finish()
end
+29
View File
@@ -0,0 +1,29 @@
local U = require("tests.drivers.util")
local F = require("tests.drivers.em_fa_util")
local S = F.new("em_mr_stone_head_2737", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_mr_stone_head_2737")
return function(game)
if not F.boot(game, S) then return S.finish() end
local Runtime = require("src.core.game3.runtime")
local Objects = require("src.core.game3.objects")
local FieldView = require("src.core.game3.field_view")
local session = Runtime.getSession()
if not S.check(session.version == "emerald", "driver runs Emerald") then return S.finish() end
F.setVar("VAR_DEVON_CORP_3F_STATE", 3)
S.check(F.goTo(game, "EM_RUSTBORO_CITY_DEVON_CORP_3F", 13, 5, "right"), "Devon Corp 3F loads")
U.wait(20)
local stone
for _, lid in ipairs(Objects._order or {}) do
local eo = Objects._byId[lid]
if eo and eo.cellX == 17 and eo.cellY == 5 then stone = eo end
end
if not S.check(stone ~= nil, "Mr. Stone spawned at 17,5") then return S.finish() end
S.note("stone elevation=" .. tostring(stone.elevation) .. " current=" .. tostring(stone.currentElevation))
S.check(stone.elevation == 4, "Mr. Stone elevation follows chair metatile")
local _, over = FieldView.applyDrawOrder({
{ kind = "npc", eventObject = stone, elevation = stone.elevation, y = stone.py },
}, {}, {}, 0)
S.check(#over == 1, "Mr. Stone draws over the chair back layer")
S.check(S.still(game, "2737_01_mr_stone_head_over_chair.png"), "Mr. Stone office screenshot")
S.finish()
end
+181
View File
@@ -0,0 +1,181 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_poison_whiteout_2736"
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") .. " em_poison_whiteout_2736 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
if not check(game.boot ~= nil, "boot reached") then return finish() end
game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 })
U.wait(60)
local Runtime = require("src.core.game3.runtime")
local Map = require("src.core.game3.map")
local MapIds = require("src.core.game3.map_ids")
local Player = require("src.core.game3.player")
local Message = require("src.ui.game3.message")
local Party = require("src.core.game3.party")
local Field = require("src.core.game3.field")
local Space = require("src.core.game3.scripting.space")
local Flags = require("src.core.game3.scripting.flags")
local Warp = require("src.core.game3.warp")
local StepEvents = require("src.core.game3.step_events")
local Sem = require("src.core.game3.field_semantics")
local HealLocations = require("src.core.game3.heal_locations")
local RomText = require("src.core.game3.rom_text")
local session = Runtime.getSession()
if not check(session ~= nil, "field session exists") then return finish() end
local C = require("src.core.game3.constants").active(session)
local id = require("src.core.game3.profile").forSession(session).id
local isEm = id == "emerald"
local tag = id .. ": "
local whiteKey = isEm and "gText_PlayerWhitedOut" or "UnknownString_81A1141"
local route = MapIds.forConst("MAP_ROUTE103", id)
for _ = 1, 600 do
if not (Space.vm and Space.vm:isRunning()) and not Message.isOpen() and not Warp.isBusy() then break end
if Message.isOpen() then U.tap(game, "a") end
U.wait(2)
end
local romWhite = RomText.box(whiteKey, { playerName = "BRENDAN" })
check(type(romWhite) == "string" and romWhite:find("whited out", 1, true) ~= nil,
tag .. whiteKey .. " resolves from the ROM cache")
local function setup(mons, money)
Map.load(nil, game, route, { x = 10, y = 10, facing = "down" })
session.x, session.y, session.facing = 10, 10, "down"
Field.unlock()
U.wait(20)
session.party = {}
for i, row in ipairs(mons) do
Party.giveMon(session, C:require("species", row.species), 5)
local mon = session.party[i]
if row.psn then
mon.hp = 1
mon.status = "PSN"
end
end
session.money = money
session.vars[Sem.var(session, "poisonSteps")] = 3
session.poisonSteps = 3
end
local function step()
local sx, sy = Player.cellX, Player.cellY
for _, dir in ipairs({ "down", "left", "right", "up" }) do
U.hold(game, dir, 20)
U.wait(10)
if Player.cellX ~= sx or Player.cellY ~= sy then return true end
end
return false
end
local function pump(name, shotName, expectWhiteout)
local seen = { faint = 0, white = false, rom = false }
local lastFaint
for _ = 1, 1500 do
local p = Message.isOpen() and Message.currentPage and Message.currentPage() or ""
if p:find("fainted", 1, true) and p ~= lastFaint then
seen.faint = seen.faint + 1
lastFaint = p
end
if p:find("whited out", 1, true) and not seen.white then
seen.white = true
seen.rom = romWhite:find(p, 1, true) ~= nil
Message.skipReveal()
U.wait(4)
if shotName then U.shot(game, DIR .. "/" .. shotName) end
end
local settled = not Message.isOpen() and not Warp.isBusy() and not StepEvents.busy()
and not (Space.vm and Space.vm:isRunning())
if settled and (not expectWhiteout or Map.current ~= route) then break end
if Message.isOpen() and Message.isTyping() then Message.skipReveal() end
if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end
U.wait(2)
end
print("INFO " .. name .. " map=" .. tostring(Map.current) .. " money=" .. tostring(session.money))
return seen
end
local Fade = require("src.ui.game3.fade")
local function fadedIn()
for _ = 1, 300 do
if not Fade.active and (Fade.t or 0) <= 0 then return true end
U.wait(2)
end
return false
end
local function healed()
for _, mon in ipairs(session.party) do
if (mon.hp or 0) <= 0 or (mon.hp or 0) ~= (mon.maxHp or mon.hp) or mon.status then return false end
end
return true
end
local okRun, err = pcall(function()
setup({ { species = "SPECIES_TORCHIC", psn = true }, { species = "SPECIES_MUDKIP" } }, 3000)
check(Map.current == route, tag .. "survive case on " .. tostring(route))
check(step(), tag .. "survive case took a poisoned step")
local s = pump("survive", nil, false)
check(s.faint == 1, tag .. "survive case shows one fainted message (" .. s.faint .. ")")
check(not s.white, tag .. "survive case shows no whited out text")
check(Map.current == route, tag .. "survive case stays on " .. tostring(route))
check(session.money == 3000, tag .. "survive case money unchanged (" .. tostring(session.money) .. ")")
local m1 = session.party[1]
check(m1 and (m1.hp or 0) == 0 and not m1.status, tag .. "fainted mon has 0 HP and poison cleared")
check(not Field.isLocked or not Field.isLocked(), tag .. "survive case releases the field")
setup({ { species = "SPECIES_TORCHIC", psn = true } }, 3000)
local oldale = C:require("heal_locations", "HEAL_LOCATION_OLDALE_TOWN")
Field.setRespawn(oldale)
local oldaleMap = HealLocations.get(oldale).map
check(step(), tag .. "whiteout case took a poisoned step")
local w = pump("whiteout", "2736_01_" .. id .. "_whited_out_text.png", true)
check(w.faint == 1, tag .. "fainted message shown before whiteout")
check(w.white, tag .. "whited out message shown")
check(w.rom, tag .. "whited out page comes from ROM " .. whiteKey)
check(Map.current == oldaleMap, tag .. "respawned at last heal location " .. tostring(oldaleMap)
.. " (" .. tostring(Map.current) .. ")")
check(session.money == 1500, tag .. "money halved 3000 -> 1500 (" .. tostring(session.money) .. ")")
check(healed(), tag .. "party healed")
check(fadedIn(), tag .. "screen fades in from black after respawn (fade t=" .. tostring(Fade.t) .. ")")
U.wait(30)
U.shot(game, DIR .. "/2736_02_" .. id .. "_after_respawn.png")
setup({ { species = "SPECIES_TORCHIC", psn = true } }, 0)
Field.setRespawn(oldale)
local target = oldaleMap
if isEm then
local lavaridge = C:require("heal_locations", "HEAL_LOCATION_LAVARIDGE_TOWN")
target = HealLocations.get(lavaridge).map
Flags.setFlag(Space.store, nil, C:require("flags", "FLAG_WHITEOUT_TO_LAVARIDGE"), true)
end
check(step(), tag .. "zero money case took a poisoned step")
local z = pump("zero_money", isEm and "2736_03_emerald_lavaridge_whited_out.png" or nil, true)
check(z.white and z.rom, tag .. "zero money whited out text from ROM")
check(Map.current == target, tag .. (isEm and "FLAG_WHITEOUT_TO_LAVARIDGE respawns at " or "respawns at ")
.. tostring(target) .. " (" .. tostring(Map.current) .. ")")
check(session.money == 0, tag .. "zero money stays 0 (" .. tostring(session.money) .. ")")
check(healed(), tag .. "party healed after zero money whiteout")
check(fadedIn(), tag .. "screen fades in after zero money respawn")
end)
check(okRun, tag .. "no crash " .. tostring(okRun and "" or err))
return finish()
end
+243
View File
@@ -0,0 +1,243 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/BSA/10-07-26-00-userreported/shots/game3_rs_anim_ports_2735"
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
for _ = 1, 600 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "RUBY" })
U.wait(180)
local Runtime = require("src.core.game3.runtime")
local Party = require("src.core.game3.party")
local BattleBridge = require("src.core.game3.battle_bridge")
local Anim = require("src.core.game3.battle.anim")
local Ui = require("src.core.game3.battle.ui")
local AnimSprites = require("src.core.game3.battle.anim_sprites")
local AnimTasks = require("src.core.game3.battle.anim_tasks")
local AnimCallbacks = require("src.core.game3.battle.anim_callbacks")
local Secondary = require("src.core.game3.battle.effects.secondary")
local session = Runtime.getSession()
session.party = {}
Party.giveMon(session, 6, 36)
local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 41, level = 30 }, { fade = false })
result(ok == true, "wild battle started " .. tostring(err or ""))
if not ok then love.event.quit(1) return end
local ready = false
for _ = 1, 3000 do
if Ui._mode == "menu" and not Anim.busy() then ready = true break end
if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end
end
result(ready, "battle reached the action menu")
if not ready then love.event.quit(1) return end
U.wait(20)
local fallbacks, stubs, spawned = 0, 0, {}
local get, spawn = AnimCallbacks.get, AnimTasks.spawn
AnimCallbacks.get = function(name)
local fn = get(name)
if fn == AnimCallbacks.SimpleFadeOut and name then fallbacks = fallbacks + 1 end
return fn
end
AnimTasks.spawn = function(name, ...)
spawned[tostring(name)] = true
if not AnimTasks.REGISTRY[tostring(name)] then stubs = stubs + 1 end
return spawn(name, ...)
end
local function sprites(cb)
local out = {}
AnimSprites.forEachActive(function(s)
if s._cbName == cb and s.visible ~= false and not s.invisible then out[#out + 1] = s end
end)
return out
end
local function pos(s) return s.x + (s.ox or 0), s.y + (s.oy or 0) end
local function sideOpts(side)
local other = side == "player" and "enemy" or "player"
return { attackerSide = side, targetSide = other, isReversed = side == "enemy",
attackerSpecies = side == "player" and 6 or 41, targetSpecies = side == "player" and 41 or 6 }
end
local function reset()
for _, s in ipairs({ "player", "enemy" }) do
local p = Anim.present(s)
if p then p.visible, p.ox, p.oy, p.sx, p.sy, p.rotation = true, 0, 0, 1, 1, 0 end
end
for _ = 1, 20 do U.wait(1) end
end
local function run(label, launch, onFrame)
fallbacks, stubs, spawned = 0, 0, {}
local launched = launch()
local f = 0
while f < 900 do
onFrame(f)
U.wait(1)
f = f + 1
if f > 4 and not Anim.busy() then break end
end
result(launched ~= false, label .. " launched")
result(fallbacks == 0, label .. " used no SimpleFadeOut fallback callbacks (" .. fallbacks .. ")")
result(stubs == 0, label .. " spawned no unbound tasks (" .. stubs .. ")")
result(f < 900, label .. " finished in " .. f .. " frames")
reset()
end
local function purplePixels(path)
local f = io.open(path, "rb")
if not f then return -1 end
local bytes = f:read("*a")
f:close()
local img = love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
local w, h = img:getDimensions()
local n = 0
for y = 0, math.floor(h * 0.6) do
for x = math.floor(w * 0.45), w - 1 do
local r, g, b = img:getPixel(x, y)
r, g, b = r * 255, g * 255, b * 255
if b >= 150 and b - g >= 40 and b - r >= 20 then n = n + 1 end
end
end
return n
end
local function targetDrawn()
local p = Anim.present("enemy")
return p.visible ~= false and not p.invisible and not p.battlerInvisible and not p.blinkHidden
and (p.alpha or 1) == 1 and (p.scale or 1) == 1
end
local function rockLayers()
local monZ = require("src.core.game3.battle.anim_coords").monBehindZ("enemy") + 5
local front, behind, xFront = 0, 0, false
AnimSprites.forEachActive(function(s)
if s._cbName == "RockTomb" then
if s.z > monZ then front = front + 1 elseif s.z < monZ then behind = behind + 1 end
elseif s._cbName == "RedX" then
xFront = s.z > monZ
end
end)
return front, behind, xFront
end
local vm
local rockFall, xOk, xShot, rockShot = 0, nil, false, false
local rockStart = {}
local rockPx, xPx, xDrawn = -1, -1, false
local frontRocks, behindRocks, xFront = 0, 0, false
run("ROCK_TOMB", function() return Anim.launchMove(317, sideOpts("player")) end, function()
vm = Anim.vm()
for _, s in ipairs(sprites("RockTomb")) do
local _, y = pos(s)
rockStart[s] = rockStart[s] or y
rockFall = math.max(rockFall, y - rockStart[s])
if not rockShot and y - rockStart[s] > 20 then
local path = DIR .. "/2733_01_rock_tomb_rocks_falling.png"
rockShot = U.still(game, path)
rockPx = purplePixels(path)
end
end
local xs = sprites("RedX")
if #xs > 0 then
local x, y = pos(xs[1])
local tx, ty = vm:battlerCenter("target")
xOk = math.abs(x - tx) <= 8 and math.abs(y - ty) <= 16
if not xShot then
U.wait(4)
local path = DIR .. "/2733_02_rock_tomb_red_x_on_target.png"
xDrawn = targetDrawn()
frontRocks, behindRocks, xFront = rockLayers()
xShot = U.still(game, path)
xPx = purplePixels(path)
end
end
end)
result(rockFall >= 32, "ROCK_TOMB rocks drop onto the target (fell " .. rockFall .. " px)")
result(xOk == true, "ROCK_TOMB red X sits on the target picture")
result(xDrawn, "ROCK_TOMB target picture is still drawn under the red X")
result(frontRocks == 3 and behindRocks == 1 and xFront,
"ROCK_TOMB red X and three rocks layer in front of the target, one rock behind (" .. frontRocks .. "/" .. behindRocks .. ")")
result(rockPx > 1000 and xPx >= 0 and xPx < rockPx * 0.15,
"ROCK_TOMB red X and rock pile cover the target like the cart (" .. xPx .. " of " .. rockPx .. " purple px)")
local ringOk, noteMoved, uShot = nil, false, false
local noteStart = {}
run("UPROAR", function() return Anim.launchMove(253, sideOpts("enemy")) end, function()
vm = Anim.vm()
local rings = sprites("UproarRing")
if #rings > 0 and ringOk == nil then
local x, y = pos(rings[1])
local ax, ay = vm:battlerCenter("attacker")
ringOk = math.abs(x - ax) <= 8 and math.abs(y - ay) <= 16
end
for _, s in ipairs(sprites("JaggedMusicNote")) do
local x, y = pos(s)
noteStart[s] = noteStart[s] or { x, y }
if math.abs(x - noteStart[s][1]) + math.abs(y - noteStart[s][2]) >= 12 then noteMoved = true end
end
if not uShot and #rings > 0 and noteMoved then
uShot = U.still(game, DIR .. "/2734_01_uproar_ring_notes_on_user.png")
end
end)
result(ringOk == true, "UPROAR ring is centered on the user")
result(noteMoved, "UPROAR notes fly outward")
run("SUPERSONIC", function() return Anim.launchMove(48, sideOpts("enemy")) end, function() end)
local ducks, dShot = 0, false
run("STATUS_CONFUSION", function()
local o = sideOpts("player")
o.targetSide = "player"
return Anim.launchStatus("CONFUSION", o)
end, function(f)
local n = #sprites("ConfusionDuck")
ducks = math.max(ducks, n)
if not dShot and n > 0 and f >= 12 then
dShot = U.still(game, DIR .. "/2735_01_confusion_ducks_over_head.png")
end
end)
result(ducks >= 2, "STATUS_CONFUSION ducks circle the head (" .. ducks .. ")")
local needleFrom, needleTo, nShot = nil, nil, false
run("LEECH_LIFE", function() return Anim.launchMove(141, sideOpts("enemy")) end, function()
local n = sprites("LeechLifeNeedle")
if #n > 0 then
local x, y = pos(n[1])
needleFrom = needleFrom or { x, y }
needleTo = { x, y }
if not nShot and math.abs(x - needleFrom[1]) + math.abs(y - needleFrom[2]) >= 6 then
nShot = U.still(game, DIR .. "/2735_02_leech_life_needle_flying.png")
end
end
end)
local travel = needleFrom and (math.abs(needleTo[1] - needleFrom[1]) + math.abs(needleTo[2] - needleFrom[2])) or 0
result(travel >= 8, "LEECH_LIFE needle travels before the drain (" .. travel .. " px)")
local sShot = false
run("STATS_CHANGE", function()
local o = sideOpts("enemy")
o.targetSide = "enemy"
o.animArg = Secondary.statAnimArg("attack", -1)
return Anim.launchGeneral("STATS_CHANGE", o)
end, function(f)
if not sShot and f == 20 then sShot = U.still(game, DIR .. "/2735_03_stats_change_growl_glow.png") end
end)
result(spawned.StatsChange == true, "STATS_CHANGE runs the StatsChange task")
local hShot = false
run("HARDEN_STATS_CHANGE", function()
local o = sideOpts("player")
o.targetSide = "player"
o.animArg = Secondary.statAnimArg("defense", 1)
return Anim.launchGeneral("STATS_CHANGE", o)
end, function(f)
if not hShot and f == 20 then hShot = U.still(game, DIR .. "/2735_04_stats_change_harden_glow.png") end
end)
AnimCallbacks.get, AnimTasks.spawn = get, spawn
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,103 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/drv2729"
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() love.event.quit(fails == 0 and 0 or 1) 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 = "TAI", 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 version = os.getenv("POKEPORT_VERSION") or "sapphire"
local C = require("src.core.game3.constants").of(version)
local session = Runtime.getSession()
result(session ~= nil, "field session exists")
if not session then finish() return end
local function sp(name) return C.species.byName["SPECIES_" .. name] end
local function mv(name) return C.moves.byName["MOVE_" .. name] end
session.party = {}
Party.giveMon(session, sp("TREECKO"), 11, "TREECKO")
local m = session.party[1]
m.moves = { mv("POUND"), mv("LEER"), mv("ABSORB"), mv("QUICK_ATTACK") }
m.pp = { 35, 30, 20, 30 }
m.maxPp = { 35, 30, 20, 30 }
local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = sp("WURMPLE"), level = 3 }, {})
result(ok == true, "battle started " .. tostring(err or ""))
if not ok then finish() return end
local lastTap, f = 0, 0
local function at_command()
return Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu"
end
for _ = 1, 6000 do
if at_command() 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(at_command(), "reached command menu")
if not at_command() then finish() return end
U.wait(30)
local BattleChromeA = require("src.ui.game3.battle_chrome")
local menu = BattleChromeA.menuFrameRects("menu", "rs")
result(menu and menu[1][1] - 1 == 17, "action menu frame left border at tile 17")
U.still(game, DIR .. "/2729_00_action_menu.png")
U.tap(game, "a")
U.wait(30)
result(Ui._mode == "moves", "move menu open")
U.tap(game, "down")
U.wait(6)
U.tap(game, "right")
U.wait(10)
result(Ui._moveIndex == 4, "cursor on QUICK ATTACK")
local BattleChrome = require("src.ui.game3.battle_chrome")
local Moves = require("src.core.game3.battle.moves")
local P = require("src.core.game3.profile").forSession(session)
local F = require(P.font.module)
local drawn
local orig = BattleChrome.drawMenuFrames
BattleChrome.drawMenuFrames = function(mode, layout)
drawn = BattleChrome.menuFrameRects(mode, layout)
return orig(mode, layout)
end
for _ = 1, 2000 do
if drawn then break end
U.wait(1)
end
BattleChrome.drawMenuFrames = orig
local left = drawn and drawn[1]
result(left ~= nil, "move menu frame drawn")
if left then
local limit = (left[1] + left[3]) * 8
result(limit == 168, "left move frame content ends at x=168 (got " .. limit .. ")")
for i = 1, 4 do
local label = Moves.displayName(m.moves[i])
local x = 8 + 80 * ((i - 1) % 2)
local right = x + F.measure(label)
result(right <= limit, string.format("move %d %s right edge %d within %d", i, label, right, limit))
end
end
U.still(game, DIR .. "/2729_01_quick_attack_move_menu.png")
Battle.abort("run")
U.wait(30)
finish()
end
@@ -0,0 +1,156 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/BSA/10-07-26-00-userreported/shots/game3_transition_shade_2731"
local VERSION = os.getenv("POKEPORT_VERSION") or "emerald"
local function loadPng(path)
local f = path and io.open(path, "rb")
if not f then return nil end
local s = f:read("*a")
f:close()
return love.image.newImageData(love.filesystem.newFileData(s, "x.png"))
end
local function diffCount(a, b)
if not (a and b) then return -1 end
local w, h = math.min(a:getWidth(), b:getWidth()), math.min(a:getHeight(), b:getHeight())
local n = 0
for y = 0, h - 1, 4 do
for x = 0, w - 1, 4 do
local r1, g1, b1 = a:getPixel(x, y)
local r2, g2, b2 = b:getPixel(x, y)
if math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) > 0.06 then n = n + 1 end
end
end
return n
end
local function grayShare(img)
if not img then return 0 end
local w, h = img:getWidth(), img:getHeight()
local n, g = 0, 0
for y = math.floor(h * 0.3), math.floor(h * 0.7), 4 do
for x = math.floor(w * 0.3), math.floor(w * 0.7), 4 do
local r, gg, b = img:getPixel(x, y)
n = n + 1
if math.max(r, gg, b) - math.min(r, gg, b) < 0.08 and r > 0.2 and r < 0.55 then g = g + 1 end
end
end
return n > 0 and g / n or 0
end
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
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 BT = require("src.core.game3.battle_transition")
local Map = require("src.core.game3.map")
local MapIds = require("src.core.game3.map_ids")
local C = require("src.core.game3.constants").of(VERSION)
local session = Runtime.getSession()
local Ids = BT.ids()
local rse = VERSION ~= "firered" and VERSION ~= "leafgreen"
local W = rse and require("src.core.game3.field_weather_rse") or nil
local cases
if rse then
cases = {
{ map = "MAP_ROUTE104", x = 20, y = 40, tid = "POKEBALLS_TRAIL", shade = false },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "POKEBALLS_TRAIL", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "ANGLED_WIPES", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "SLICE", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "WHITE_BARS_FADE", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, pick = "WHITE_BARS_FADE", lead = 5, foe = 6, shade = true },
{ map = "MAP_ROUTE120", x = 22, y = 61, tid = "POKEBALLS_TRAIL", rain = true },
{ map = "MAP_ROUTE120", x = 22, y = 61, tid = "WHITE_BARS_FADE", rain = true },
}
else
cases = {
{ map = "MAP_ROUTE1", x = 10, y = 20, tid = "POKEBALLS_TRAIL" },
}
end
for i, c in ipairs(cases) do
local mapId = MapIds.forConst(c.map, VERSION)
local tag = string.format("%s %s", c.map, c.tid or ("pick " .. c.pick))
local stem = string.format("%s/2731_%02d_%s_%s_%s", DIR, i, VERSION, c.map:lower():gsub("^map_", ""),
(c.tid or c.pick):lower())
local ok, err = pcall(function() Map.load(nil, game, mapId, { x = c.x, y = c.y, facing = "down" }) end)
result(ok and mapId ~= nil, "load " .. tag .. " " .. tostring(err or ""))
U.wait(120)
if c.rain then
W.setNextWeather(W.WEATHER.RAIN)
end
U.wait(240)
session.party = {}
Party.giveMon(session, C.species.byName.SPECIES_MUDKIP, c.lead or 20, "MUDKIP")
if W then
local snap = W.snapshot()
print(string.format("[driver] %s weather %s colorMapIndex %s", tag, tostring(snap.curr), tostring(snap.colorMapIndex)))
if c.shade or c.rain then
result(snap.colorMapIndex ~= 0, tag .. " weather color map active")
end
end
local base = stem .. "_base.png"
U.still(game, base)
local opts = {}
if c.tid then opts.transitionId = Ids.ID[c.tid] end
BattleBridge.startWild(Runtime._mod, game, { species = C.species.byName.SPECIES_WURMPLE, level = c.foe or 5 }, opts)
if c.pick then
result(BT._transitionId == Ids.ID[c.pick], tag .. " level compare picked " .. c.pick
.. " (got " .. tostring(BT._transitionId) .. ")")
end
local peakShot
local mainShots = {}
local frames, mainAt = 0, nil
while frames < 900 do
U.wait(1)
frames = frames + 1
if BT._phase == "intro" and BT._intro and BT._intro.blend >= 14 and not peakShot then
peakShot = stem .. "_gray_flash.png"
U.still(game, peakShot)
end
if BT._phase == "main" and not mainAt then mainAt = frames end
if mainAt and (frames - mainAt == 8 or frames - mainAt == 20 or frames - mainAt == 32) then
local p = string.format("%s_main_%02d.png", stem, frames - mainAt)
U.still(game, p)
mainShots[#mainShots + 1] = p
end
if Battle.isActive() then break end
end
local b = loadPng(base)
local peakImg = loadPng(peakShot)
local dPeak = diffCount(b, peakImg)
local dMain, best = -1, nil
for _, p in ipairs(mainShots) do
local d = diffCount(b, loadPng(p))
if d > dMain then dMain, best = d, p end
end
for _, p in ipairs(mainShots) do
if p ~= best then os.remove(p) end
end
local gray = grayShare(peakImg)
print(string.format("[driver] %s introDiff %d mainDiff %d grayShare %.2f", tag, dPeak, dMain, gray))
result(dPeak > 2000, tag .. " intro flash visible (diff " .. dPeak .. ")")
result(gray > 0.8, string.format("%s intro flash is gray (%.2f)", tag, gray))
result(dMain > 2000, tag .. " main transition visible (diff " .. dMain .. ")")
Battle.abort("run")
for _ = 1, 600 do
if not Battle.isActive() then break end
U.wait(1)
end
U.wait(60)
end
love.event.quit(fails == 0 and 0 or 1)
end
+135
View File
@@ -0,0 +1,135 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/drv2727"
local function until_(n, fn)
for _ = 1, n do
if fn() then return true end
U.wait(1)
end
return fn()
end
local function loadShot(path)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
end
local function hex(r, g, b)
return string.format("%02x%02x%02x", math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))
end
local function cellColors(img, cellX, cellY)
local scale = img:getWidth() / 240
local seen = {}
for y = 0, 7 do for x = 0, 7 do
local px, py = math.floor((cellX + x + 0.5) * scale), math.floor((cellY + y + 0.5) * scale)
seen[hex(img:getPixel(px, py))] = true
end end
return seen
end
return function(game)
local pass = true
local function check(cond, label)
print((cond and "PASS " or "FAIL ") .. label)
if not cond then pass = false end
end
until_(900, function() return game.phase == "boot" and game.boot end)
local ok, err = xpcall(function()
local Runtime = require("src.core.game3.runtime")
local Warp = require("src.core.game3.warp")
local Message = require("src.ui.game3.message")
pcall(function() game:_handleBootAction({ action = "new_game", name = "MAY", gender = 1 }) end)
U.wait(60)
until_(600, function()
if Message.isOpen and Message.isOpen() then U.tap(game, "b") end
return not (Warp.isBusy() or (Message.isOpen and Message.isOpen()))
end)
local s = Runtime.getSession()
local C = require("src.core.game3.constants").active(s)
local Bag = require("src.core.game3.bag")
Bag.add(s.bag, C:require("items", "ITEM_POTION"), 3)
Bag.add(s.bag, C:require("items", "ITEM_LIECHI_BERRY"), 1)
Bag.add(s.bag, C:require("items", "ITEM_RAZZ_BERRY"), 10)
Bag.add(s.bag, C:require("items", "ITEM_TM39_ROCK_TOMB"), 1)
Bag.add(s.bag, C:require("items", "ITEM_HM01_CUT"), 1)
local BagMenu = require("src.ui.game3.bag_menu")
local Font = require("src.ui.game3.frlg_font")
local realDraw = Font.draw
local calls = {}
Font.draw = function(str, x, y, opts)
calls[#calls + 1] = {s = str, x = x, y = y}
return realDraw(str, x, y, opts)
end
local function find(str, y)
for i = #calls, 1, -1 do
local c = calls[i]
if c.s == str and (y == nil or c.y == y) then return c.x, c.y end
end
end
local function rightEdge(str, y)
local x = find(str, y)
return x and x + Font.measure(str, {font = "normal"})
end
local function capture(gender, tag, pocket, idx)
s.gender, s.playerGender = gender, gender
BagMenu.show(s, { session = s, pocket = pocket })
until_(120, function() return not BagMenu._open end)
U.wait(10)
calls = {}
local path = string.format("%s/2727_%s_%s_%s.png", DIR, idx, tag, pocket:lower())
U.still(game, path)
local img = loadShot(path)
BagMenu.close()
U.wait(20)
return img
end
for _, who in ipairs({{1, "female", "0"}, {0, "male", "1"}}) do
local gender, tag, base = who[1], who[2], who[3]
local img = capture(gender, tag, "BERRY_POUCH", base .. "1")
local lx, ly = find("36")
check(lx == 120, tag .. " LIECHI No36 number at x=120")
check(ly ~= nil and find("×", ly) == 208, tag .. " LIECHI multiply sign at x=208")
check(ly ~= nil and rightEdge("1", ly) == 232, tag .. " LIECHI count right-aligned to 232")
local _, ry = find("10")
check(ry ~= nil and rightEdge("10", ry) == 232 and find("×", ry) == 208, tag .. " RAZZ x10 count right-aligned to 232")
if img then
local selectedCell, stripeLeak, idleStripe = 40 + 3 * 8, false, false
for p = 0, 4 do
local cx = 40 + p * 8
local seen = cellColors(img, cx, 72)
if cx ~= selectedCell and (seen["6bb5d6"] or seen["297ba5"]) then idleStripe = true end
if seen["297ba5"] then stripeLeak = true end
end
if gender == 1 then
check(not stripeLeak and not idleStripe, "female indicator dots have no blue stripe pixels")
else
check(idleStripe, "male indicator dots sit on blue stripes")
end
else check(false, tag .. " berry shot readable") end
capture(gender, tag, "TM_CASE", base .. "2")
local tx, ty = find("39")
check(tx == 120, tag .. " TM39 number at x=120")
check(ty ~= nil and find("ROCK TOMB", ty) == 136, tag .. " TM39 name at x=136")
check(ty ~= nil and find("×", ty) == 214, tag .. " TM multiply sign at x=214")
check(ty ~= nil and rightEdge("1", ty) == 232, tag .. " TM count right-aligned to 232")
local _, hy = find("CUT")
check(hy ~= nil and find("1", hy) == 129, tag .. " HM01 number at x=129")
check(hy ~= nil and find("×", hy) == nil, tag .. " HM row has no quantity")
capture(gender, tag, "ITEMS", base .. "3")
check(find("×", 16) == 214, tag .. " item multiply sign at x=214")
check(rightEdge("3", 16) == 232, tag .. " item count right-aligned to 232")
end
Font.draw = realDraw
end, debug.traceback)
if not ok then print("FAIL driver error: " .. tostring(err)); pass = false end
love.event.quit(pass and 0 or 1)
U.wait(10)
end
@@ -0,0 +1,146 @@
local U = require("tests.drivers.util")
return function(game)
U.wait(20)
package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
local App, Paint, Kit, path, tmp, oldPaint
local dim, safe, osName
local failures = 0
local function pass(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
end
local ok, err = xpcall(function()
App = require("tools.save-editor.App")
local Gen, Ops, MonOps = require("Gen"), require("Ops"), require("MonOps")
local Touch, History = require("TouchEditor"), require("History")
local Serializer = require("src.core.SaveSerializer")
local version = require("src.core.GameVersion").get()
local dir = assert(os.getenv("POKEPORT_SHOT_DIR"))
Kit, Paint = require("Kit"), require("src.ui.kit.Button")
dim, safe, osName = love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS
local controls = {}
oldPaint = Paint.draw
Paint.draw = function(owner, x, y, w, h, label, opts, hot, focused)
if owner == Kit then controls[opts.id or label] = { x = x, y = y, w = w, h = h, label = label } end
return oldPaint(owner, x, y, w, h, label, opts, hot, focused)
end
tmp = os.tmpname()
path = tmp .. "-unown-2726.lua"
local file = assert(io.open(path, "wb"))
file:write(Serializer.encode(Gen.newGame(version))); file:close()
App.load(path, { version = version, embedded = true })
local S = App.getState()
assert(S.save and not S.loadError and not S.missingCache, S.status)
local g = Gen.ofState(S)
local mon = MonOps.create(S.data, g == 3 and MonOps.SPECIES_UNOWN_G3 or "UNOWN", 25, g)
if g == 3 then
mon.otId, mon.otSecretId = S.save.player and S.save.player.trainerId or 0, S.save.player and S.save.player.secretId or 0
end
S.save.party = { mon }
Ops.selectParty(S, 1)
assert(Ops.setUnownForm(S, mon, g == 3 and 5 or 6), "seed form F")
S.dirty = false
local W, H, platform = 390, 844, "Android"
local function frame(name)
controls = {}
love.graphics.getDimensions = function() return W, H end
love.window.getSafeArea = function() return 0, 0, W, H end
love.system.getOS = function() return platform end
local canvas = love.graphics.newCanvas(W, H, { msaa = 0 })
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.origin()
love.graphics.setScissor()
love.graphics.setShader()
require("src.render.GameViewport").reset()
love.graphics.clear(0, 0, 0, 1)
App.draw()
love.graphics.setCanvas()
love.graphics.pop()
if name then
local pixels = canvas:newImageData()
local bytes = pixels:encode("png"):getString()
pixels:release()
local out = assert(io.open(dir .. "/" .. version .. "-" .. name .. ".png", "wb"))
out:write(bytes); out:close()
end
canvas:release()
love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName
end
local function tap(id)
local r = assert(controls[id], "missing control " .. id)
App.mousepressed(r.x + r.w / 2, r.y + r.h / 2, 1)
frame()
frame()
end
local function current() return Ops.unownForm(S, S.save.party[1]) end
local function letterName() return Ops.unownFormName(S, current()) end
local function pick(letter)
local p = assert(S.editPopup, "form popup open")
p.query, p.index = "", nil
for i, o in ipairs(p.options) do if o[1] == letter then p.index = i end end
assert(p.index, "option present")
Touch.commit(S, Kit)
frame()
end
S.tab, S.monSection, S.mobileInspector = "party", "main", false
frame(); frame("2726_01_roster_unown_f_phone")
pass(letterName() == "F", version .. " roster seeded with UNOWN F")
S.mobileInspector = true
frame(); frame()
local r = controls["choice-form"]
for _ = 1, 40 do
if r and r.y >= H * 0.3 and r.y + r.h <= H * 0.7 then break end
S.inspectorScroll = (S.inspectorScroll or 0) + 40
frame()
r = controls["choice-form"]
end
frame("2726_02_form_picker_row_phone")
pass(r ~= nil, version .. " phone inspector shows the Unown form picker")
pass(r and r.label == "Unown form: F", version .. " picker reads the current letter (" .. tostring(r and r.label) .. ")")
tap("choice-form")
pass(S.editPopup and S.editPopup.id == "form", version .. " tapping the picker opens the touch chooser")
pass(S.editPopup and #S.editPopup.options == (g == 3 and 28 or 26), version .. " chooser lists every form")
frame("2726_03_form_chooser_open_phone")
local target = g == 3 and 27 or 17
pick(target)
pass(current() == target, version .. " chooser sets the form to " .. letterName())
pass(S.dirty, version .. " form edit marks the save dirty")
frame()
frame("2726_04_form_set_" .. letterName():gsub("%?", "qmark") .. "_phone")
pass(History.undo(S) and letterName() == "F", version .. " undo restores F")
pass(History.redo(S) and current() == target, version .. " redo reapplies")
Ops.selectParty(S, 1)
W, H, platform = 1100, 720, "Linux"
S.inspectorScroll = 0
frame(); frame()
for _ = 1, 40 do
r = controls["choice-form"]
if r and r.y >= 0 and r.y + r.h <= H then break end
S.inspectorScroll = (S.inspectorScroll or 0) + 80
frame()
end
frame("2726_05_desktop_docked_picker_and_roster")
pass(controls["choice-form"] ~= nil, version .. " desktop docked inspector shows the picker")
assert(App.save(), "save")
App.unload()
App.load(path, { version = version, embedded = true })
S = App.getState()
mon = S.save.party[1]
pass(Ops.unownForm(S, mon) == target, version .. " reopened save keeps the form")
end, debug.traceback)
if App then App.unload() end
if Paint and oldPaint then Paint.draw = oldPaint end
if dim then love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName end
if path then
os.remove(path)
for _, backup in ipairs(require("tests.fs_io").globPrefix(path .. ".bak-")) do os.remove(backup) end
end
if tmp then os.remove(tmp) end
if not ok then print("FAIL save_editor_unown_form_2726: " .. tostring(err)) end
love.event.quit((ok and failures == 0) and 0 or 1)
end
@@ -0,0 +1,128 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local HostShell = require("src.core.HostShell")
local quits = {}
love.event = {
quit = function(...)
quits[#quits + 1] = { n = select("#", ...), arg = (...) }
end,
}
local osName = "Android"
local restartApp = nil
love.system = love.system or {}
love.system.getOS = function() return osName end
local exits = {}
local realExit = os.exit
os.exit = function(code)
exits[#exits + 1] = code
if code ~= nil and type(code) ~= "number" and type(code) ~= "boolean" then
error("bad argument #1 to 'exit' (number expected, got " .. type(code) .. ")")
end
end
local function oldShellStep(a)
if love.system and love.system.getOS() == "Android" then
os.exit(a or 0)
end
return a or 0
end
local function driveOldShell()
local errs = {}
for _, q in ipairs(quits) do
local ok, err = pcall(oldShellStep, q.arg)
if not ok then errs[#errs + 1] = tostring(err) end
end
return errs
end
local function reset()
for i = #quits, 1, -1 do quits[i] = nil end
for i = #exits, 1, -1 do exits[i] = nil end
end
_G.POKEPORT_LOOP_RESTART = nil
love.system.restartApp = nil
HostShell.restart()
eq(#quits, 1, "Android restart on an older shell still quits once")
eq(quits[1] and quits[1].n, 0, "with a bare quit(), never quit(\"restart\")")
local errs = driveOldShell()
eq(#errs, 0, "the old stepper's os.exit(a or 0) never errors: " .. table.concat(errs, "; "))
for _, code in ipairs(exits) do
check(type(code) == "number", "old stepper exits with a number, got " .. type(code))
end
check(HostShell.canRestart ~= nil and not HostShell.canRestart(),
"an older Android shell cannot restart in-process")
reset()
local restartCalls = 0
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
HostShell.restart()
eq(restartCalls, 1, "an older shell with the restartApp bridge relaunches through it")
eq(#quits, 0, "and sends no quit event")
reset()
love.system.restartApp = function() return false end
HostShell.restart()
eq(#quits, 1, "a failed restartApp falls back to one quit")
eq(#driveOldShell(), 0, "which the old stepper exits on cleanly")
for _, mod in ipairs({ "src.core.Game", "src.core.Game3" }) do
reset()
love.system.restartApp = nil
local ok, M = pcall(require, mod)
if ok and type(M) == "table" and type(M.restartWithMods) == "function" then
local stub = setmetatable({}, { __index = function() return function() end end })
pcall(M.restartWithMods, stub)
for _, q in ipairs(quits) do
check(type(q.arg) ~= "string", mod .. ":restartWithMods sends no string quit on an older shell")
end
eq(#driveOldShell(), 0, mod .. ":restartWithMods survives the old stepper")
end
end
reset()
local ManagerState = require("src.mods.ManagerState")
ManagerState.restartGame({ game = {} })
for _, q in ipairs(quits) do
check(type(q.arg) ~= "string", "ManagerState:restartGame sends no string quit on an older shell")
end
eq(#driveOldShell(), 0, "ManagerState:restartGame survives the old stepper")
reset()
_G.POKEPORT_LOOP_RESTART = true
check(HostShell.canRestart and HostShell.canRestart(),"a shell whose love.run sets the flag restarts in-process")
HostShell.restart()
eq(quits[1] and quits[1].arg, "restart", "and HostShell.restart sends quit(\"restart\")")
_G.POKEPORT_LOOP_RESTART = nil
osName = "iOS"
check(HostShell.canRestart and HostShell.canRestart(),"iOS keeps its bare-quit restart")
osName = "OS X"
check(HostShell.canRestart and HostShell.canRestart(),"desktop keeps quit(\"restart\")")
local f = assert(io.open("main.lua", "rb"))
local mainSrc = f:read("*a")
f:close()
local runBody = mainSrc:match("\nfunction love%.run%(%)(.-)\nend\n") or ""
local flagAt = runBody:find("_G.POKEPORT_LOOP_RESTART = true", 1, true)
local loadAt = runBody:find("love.load(", 1, true)
check(flagAt ~= nil and loadAt ~= nil and flagAt < loadAt,
"love.run advertises restart support before love.load chainloads a payload")
local returnBody = mainSrc:match("local function returnToLauncher%(opts%)(.-)\nend\n") or ""
local gateAt = returnBody:find("canRestart()", 1, true)
local inProcAt = returnBody:find("rebuildLauncherInProcess(opts)", 1, true)
local restartAt = returnBody:find('require("src.core.HostShell").restart()', 1, true)
check(gateAt ~= nil and inProcAt ~= nil and restartAt ~= nil and gateAt < restartAt
and inProcAt < restartAt,
"EXIT GAME rebuilds the launcher in-process when the shell cannot restart")
os.exit = realExit
T.finish("android_old_shell_restart_bug2738")
@@ -0,0 +1,167 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("chipaudio prewarm queue 2732")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local ffi = require("ffi")
local SoundData = {}
SoundData.__index = SoundData
function SoundData:setSample(index, a, b)
if b == nil then
self.buf[index * self.channels] = a * 32767
else
self.buf[index * self.channels + (a - 1)] = b * 32767
end
end
function SoundData:getFFIPointer() return self.buf end
function SoundData:getSampleCount() return self.samples end
function SoundData:getSampleRate() return self.rate end
function SoundData:getChannelCount() return self.channels end
love.sound.newSoundData = function(samples, rate, _, channels)
return setmetatable({ samples = samples, rate = rate, channels = channels,
buf = ffi.new("int16_t[?]", samples * channels) }, SoundData)
end
love.audio = { newSource = function(sd) return { sd = sd } end }
local clock = 0
local clockStep = 0
love.timer.getTime = function()
clock = clock + clockStep
return clock
end
local channels = {}
local function channel(name)
local ch = channels[name]
if ch then return ch end
local items = {}
ch = { items = items }
function ch:push(v) items[#items + 1] = v end
function ch:pop() return table.remove(items, 1) end
function ch:clear() for i = #items, 1, -1 do items[i] = nil end end
function ch:getCount() return #items end
channels[name] = ch
return ch
end
love.thread = {
newThread = function()
return { start = function() end, getError = function() return nil end }
end,
getChannel = channel,
}
local ChipSynth = require("src.core.ChipSynth")
local ChipAsm = require("src.audio.ChipAsm")
local ChipAudio = require("src.core.ChipAudio")
local FixedStep = require("src.core.FixedStep")
local beep = ChipAsm.sfx{ channels = { { hw = 1, program = {
{ squareNote = { len = 4, volume = 15, fade = 1, frequency = 0x600 } },
} } } }
local data = { audio = { sfx = { Beep = beep }, cries = {} } }
local function workerStep(limit)
local cmdCh, fxCh = channel("chipaudio_cmd"), channel("chipaudio_fx")
local n = 0
while n < (limit or math.huge) do
local req = cmdCh:pop()
if not req then break end
if req.cmd == "effect" then
n = n + 1
fxCh:push({ key = req.key, epoch = req.epoch,
sd = ChipSynth.renderEffectData({ audio = req.audio }, req.header,
req.options or {}) })
end
end
return n
end
for pitch = 1, 5 do ChipAudio.prewarmSfx(data, "Beep", pitch) end
local st = ChipAudio._effectStateForTest()
eq(st.inFlight, 2, "two prewarms handed to the worker at once")
eq(st.queued, 3, "the rest wait on the main thread")
eq(#channel("chipaudio_cmd").items, 2, "only two effect commands pushed")
ChipAudio.resetSyncStats()
check(ChipAudio.newSfx(data, "Beep", 5) ~= nil, "a queued sfx still plays")
eq(ChipAudio._effectStateForTest().queued, 2,
"playing a queued sfx drops its prewarm")
eq(ChipAudio.stats().syncRenders, 1, "and is counted as a sync render")
workerStep()
ChipAudio.pumpEffects()
st = ChipAudio._effectStateForTest()
eq(st.ready, 2, "finished prewarms are ready")
eq(st.inFlight, 2, "the queue refills the worker as results land")
workerStep()
ChipAudio.pumpEffects()
st = ChipAudio._effectStateForTest()
eq(st.ready, 4, "all four left are ready")
eq(st.queued + st.inFlight, 0, "nothing left queued")
ChipAudio.resetSyncStats()
check(ChipAudio.newSfx(data, "Beep", 1) ~= nil, "prewarmed sfx plays")
eq(ChipAudio.stats().syncRenders, 0, "a prewarmed play renders nothing")
eq(ChipAudio.stats().prewarmHits, 1, "and is counted as a hit")
ChipAudio.prewarmPinned(function()
for pitch = 50, 66 do ChipAudio.prewarmSfx(data, "Beep", pitch) end
end)
for _ = 1, 20 do
workerStep()
ChipAudio.pumpEffects()
end
st = ChipAudio._effectStateForTest()
eq(st.pinned, 17, "a session set of 17 is pinned")
eq(st.ready, 20, "pinned prewarms are ready")
for pitch = 100, 199 do
ChipAudio.prewarmSfx(data, "Beep", pitch)
workerStep()
ChipAudio.pumpEffects()
end
for _ = 1, 10 do
workerStep()
ChipAudio.pumpEffects()
end
st = ChipAudio._effectStateForTest()
eq(st.unpinnedReady, 64, "unpinned ready prewarms are capped")
eq(st.ready, 64 + 17, "pinned ones sit outside the cap")
ChipAudio.resetSyncStats()
for pitch = 50, 66 do ChipAudio.newSfx(data, "Beep", pitch) end
eq(ChipAudio.stats().syncRenders, 0,
"every pinned session sound survives a 100-sound battle prewarm")
ChipAudio.newSfx(data, "Beep", 199)
eq(ChipAudio.stats().syncRenders, 0, "the newest unpinned prewarm survives the cap")
ChipAudio.newSfx(data, "Beep", 100)
eq(ChipAudio.stats().syncRenders, 1, "the oldest unpinned one was evicted")
eq(ChipAudio._effectStateForTest().pinned, 17, "the pin budget is unchanged")
ChipAudio.prewarmPinned(function()
for pitch = 400, 440 do ChipAudio.prewarmSfx(data, "Beep", pitch) end
end)
eq(ChipAudio._effectStateForTest().pinned, 24, "pins stop at the pin budget")
for _ = 1, 30 do
workerStep()
ChipAudio.pumpEffects()
end
check(ChipAudio._effectStateForTest().ready <= 64 + 24,
"ready PCM never exceeds both budgets together")
FixedStep:init(function() end)
ChipAudio.resetSyncStats()
clockStep = 0.001
ChipAudio.newSfx(data, "Beep", 300)
check(not FixedStep.suppressCatchup, "a short sync render keeps the catch-up")
clockStep = 0.05
ChipAudio.newSfx(data, "Beep", 301)
check(FixedStep.suppressCatchup, "a sync render over a frame drops the catch-up")
eq(FixedStep.accum, 0, "and clears the accumulator")
check(ChipAudio.stats().syncRenderMsMax >= 16, "its duration is recorded")
check(ChipAudio.stats().line:find("sync=2/", 1, true) ~= nil,
"stats.line carries the counters")
clockStep = 0
S.finish()
@@ -0,0 +1,98 @@
-- pokeemerald/src/event_object_movement.c:7737, pokeemerald/src/event_object_movement.c:4931
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local GameVersion = require("src.core.GameVersion")
GameVersion.set("emerald")
local Collision = require("src.core.game3.collision")
local Objects = require("src.core.game3.objects")
local FieldView = require("src.core.game3.field_view")
local W, H = 19, 9
local elev = {}
for y = 0, H - 1 do
for x = 0, W - 1 do elev[y * W + x] = 3 end
end
elev[5 * W + 17] = 4
elev[5 * W + 2] = 15
elev[6 * W + 4] = 0
local layout = { width = W, height = H, pair = "spawn_elev_test" }
function layout:elevAt(x, y) return elev[y * W + x] end
function layout:collAt() return 0 end
function layout:midAt() return 0x200 end
function layout:collArray()
local c = {}
for i = 1, W * H do c[i] = 0 end
return c
end
local mapDef = { midLayout = layout, pair = layout.pair, warps = {} }
Collision.bindMap({ save = { position = {} } }, "SPAWN_ELEV_TEST", mapDef)
local defs = {
{ localId = 1, x = 17, y = 5, elevation = 3, graphicsId = 7, movementType = 0x0A },
{ localId = 2, x = 2, y = 5, elevation = 3, graphicsId = 7, movementType = 0x0A },
{ localId = 3, x = 4, y = 6, elevation = 3, graphicsId = 7, movementType = 0x0A },
}
local pool = Objects.spawnFromDefs(defs, mapDef, nil)
local stone, multi, trans = pool.byId[1], pool.byId[2], pool.byId[3]
check(stone and multi and trans, "objects spawned")
eq(stone.elevation, 3, "template elevation before first update")
local savedById, savedOrder, savedTracks = Objects._byId, Objects._order, Objects._tracks
Objects._byId, Objects._order, Objects._tracks = pool.byId, pool.order, {}
Objects.update({ save = { position = {} } })
eq(stone.elevation, 4, "spawn_elevation_follows_metatile")
eq(stone.currentElevation, 4, "spawn current elevation follows metatile")
eq(multi.elevation, 3, "multi-level tile keeps template elevation")
eq(trans.elevation, 3, "transition tile keeps previous elevation")
eq(trans.currentElevation, 0, "transition tile sets current elevation 0")
check(not stone.spawnElevation, "spawn update runs once")
local under, over = FieldView.applyDrawOrder({
{ kind = "npc", eventObject = stone, elevation = stone.elevation, y = 5 * 16 },
}, {}, {}, 0)
eq(#over, 1, "mr_stone_draws_over_bg1")
eq(#under, 0, "mr stone not under top layer")
local savedMapId, savedDefs = Objects._mapId, Objects._defs
Objects._mapId, Objects._defs = "SPAWN_ELEV_TEST", defs
local snap = Objects.snapshot()
eq(#snap.list, 0, "snapshot has no row for a spawn-resolved elevation")
Objects._mapId, Objects._defs = savedMapId, savedDefs
Objects._byId, Objects._order, Objects._tracks = savedById, savedOrder, savedTracks
local NW, NH = 5, 5
local nelev = {}
for y = 0, NH - 1 do
for x = 0, NW - 1 do nelev[y * NW + x] = 3 end
end
nelev[1 * NW + 1] = 4
local nlayout = { width = NW, height = NH, pair = "spawn_elev_neighbor" }
function nlayout:elevAt(x, y) return nelev[y * NW + x] end
function nlayout:collAt() return 0 end
function nlayout:midAt() return 0x200 end
function nlayout:collArray()
local c = {}
for i = 1, NW * NH do c[i] = 0 end
return c
end
local nmapDef = { midLayout = nlayout, pair = nlayout.pair, warps = {} }
local npool = Objects.spawnFromDefs({
{ localId = 1, x = 1, y = 1, elevation = 3, graphicsId = 7, movementType = 0x0A },
{ localId = 2, x = 3, y = 3, elevation = 3, graphicsId = 7, movementType = 0x0A },
}, nmapDef, nil)
local raised, flat = npool.byId[1], npool.byId[2]
eq(raised.elevation, 3, "neighbor template elevation before first tick")
eq(layout:elevAt(1, 1), 3, "active map cell under the neighbor NPC is elevation 3")
Objects.tickPool(npool, { save = { position = {} } }, nil)
eq(raised.elevation, 4, "neighbor_pool_spawn_elevation_follows_own_metatile")
eq(raised.currentElevation, 4, "neighbor pool current elevation follows own metatile")
eq(flat.elevation, 3, "neighbor pool flat tile keeps template elevation")
check(not raised.spawnElevation, "neighbor pool spawn update runs once")
GameVersion.set("firered")
T.finish("game3_object_spawn_elevation_2737_test")
+78
View File
@@ -0,0 +1,78 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Names = require("src.import.gba.rs.anim_names")
local Emerald = require("src.import.gba.anim_names_emerald")
local AnimCallbacks = require("src.core.game3.battle.anim_callbacks")
local AnimTasks = require("src.core.game3.battle.anim_tasks")
local Versions = require("src.import.gba.versions")
local function count(t)
local n = 0
for _ in pairs(t or {}) do n = n + 1 end
return n
end
eq(count(Names.templates), 329, "329 RS sprite templates")
eq(count(Names.callbacks), 269, "269 RS sprite callbacks")
eq(count(Names.tasks), 208, "208 RS visual/sound tasks")
local missCb, missTask = {}, {}
for key, name in pairs(Names.callbacks) do
local bound = AnimCallbacks.get(name) ~= AnimCallbacks.SimpleFadeOut
or AnimTasks.REGISTRY["_noGfx_" .. name] ~= nil
if not bound then missCb[#missCb + 1] = key .. ">" .. name end
end
for key, name in pairs(Names.tasks) do
if not AnimTasks.REGISTRY[name] then missTask[#missTask + 1] = key .. ">" .. name end
end
table.sort(missCb)
table.sort(missTask)
eq(table.concat(missCb, " "), "", "every RS callback resolves to a runtime callback")
eq(table.concat(missTask, " "), "", "every RS task resolves to a runtime task")
local emTemplates = {}
for _, v in pairs(Emerald.templates) do emTemplates[v] = true end
local seen, dup = {}, {}
for key, name in pairs(Names.templates) do
if seen[name] then dup[#dup + 1] = name end
seen[name] = key
check(emTemplates[name], "RS template " .. key .. " carries an Emerald template name (" .. name .. ")")
end
eq(#dup, 0, "RS template canonical names are unique")
local spot = {
callbacks = {
sub_80DC8F4 = "LeechLifeNeedle", sub_80DC068 = "RedX", sub_80DD8E8 = "RockTomb",
sub_80D1318 = "UproarRing", sub_80D2D68 = "JaggedMusicNote", AnimConfusionDuck = "ConfusionDuck",
sub_80CA9A8 = "sub_80CA9A8",
},
tasks = {
sub_807BB88 = "StatsChange", sub_80D2CF8 = "UproarDistortion", sub_80E26BC = "ShakeBattleTerrain",
sub_80CE7E0 = "DoubleTeam", sub_812B2B8 = "sub_812B2B8",
},
templates = {
gBattleAnimSpriteTemplate_83DA8F4 = "gRedXSpriteTemplate",
gBattleAnimSpriteTemplate_83DADA8 = "gRockTombRockSpriteTemplate",
gBattleAnimSpriteTemplate_83D79A4 = "gUproarRingSpriteTemplate",
gBattleAnimSpriteTemplate_83D7CC8 = "gJaggedMusicNoteSpriteTemplate",
gBattleAnimSpriteTemplate_83DAB10 = "gLeechLifeNeedleSpriteTemplate",
},
}
for section, rows in pairs(spot) do
for key, want in pairs(rows) do eq(Names[section][key], want, section .. " " .. key) end
end
local V = Versions.forGame("sapphire")
for i, n in pairs({ [0] = "PSN", "CONFUSION", "BRN", "INFATUATION", "SLP", "PRZ", "FRZ", "CURSED", "NIGHTMARE" }) do
eq(V.BATTLE_ANIM_STATUS_NAMES[i], "STATUS_" .. n, "RS status anim " .. i)
end
eq(V.BATTLE_ANIM_GENERAL_NAMES[7], "HELD_ITEM_EFFECT", "RS general anim 7 uses the runtime name")
eq(V.BATTLE_ANIM_GENERAL_NAMES[9], "FOCUS_BAND", "RS general anim 9 uses the runtime name")
eq(count(V.ANIM_CALLBACK_NAMES), 269, "RS callback address map built from syms")
eq(count(V.ANIM_TASK_NAMES), 208, "RS task address map built from syms")
eq(count(V.ANIM_TEMPLATE_NAMES), 329, "RS template address map built from syms")
T.finish("game3_rs_anim_names_test")
@@ -0,0 +1,113 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local eq = T.eq
local draws, images = {}, {}
_G.love = _G.love or {}
love.graphics = {
setColor = function() end,
rectangle = function() end,
newQuad = function() return {} end,
draw = function(img) images[#images + 1] = img.path end,
}
local manifest = {
layout = "rs",
layers = {bg = {png = "bg.png", variants = {female = "bg_female.png"}}},
sprites = {male = {anims = {}}, female = {anims = {}}},
labels = {{male = "l_m", female = "l_f"}, {male = "l_m", female = "l_f"}, {male = "l_m", female = "l_f"},
{male = "l_m", female = "l_f"}, {male = "l_m", female = "l_f"}},
indicators = {idle = "indicator_0.png", selected = "indicator_1.png",
idleFemale = "indicator_0_female.png", selectedFemale = "indicator_1_female.png"},
hm = "hm.png", number = "number.png", select = "select.png",
}
package.loaded["src.ui.game3.rse.scene_kit"] = {
manifest = function() return manifest end,
image = function(path)
if path and path:find("indicator", 1, true) then
return {path = path, getDimensions = function() return 8, 8 end}
end
end,
playSe = function() end, resetCaches = function() end,
}
package.loaded["src.ui.game3.frlg_font"] = {
STDPAL = {{1, 1, 1, 1}, {1, 0, 0, 1}, [8] = {0, 0, 0, 1}},
COLOR = {NORMAL = {}},
sync = function() end,
measure = function(s) return #s * 6 end,
draw = function(s, x, y, opts) draws[#draws + 1] = {s = s, x = x, y = y, w = opts and opts.maxWidth} end,
}
package.loaded["src.ui.game3.chrome"] = {stdFrame = function() end, dialogueFrame = function() end}
package.loaded["src.ui.game3.rs.menu_cursor"] = {draw = function() end}
package.loaded["src.core.game3.rom_text"] = {plain = function(key) return key end, overrides = {}}
package.loaded["src.core.game3.trig"] = {sin = function() return 0 end}
package.loaded["src.core.game3.bag"] = {}
local HM = {HM01 = 1}
local TM = {TM39 = 39, HM01 = 1}
local BERRY = {LIECHI = 36}
package.loaded["src.core.game3.items_data"] = {
isHm = function(id) return HM[id] ~= nil end,
tmNumber = function(id) return TM[id] end,
berryNumber = function(id) return BERRY[id] end,
toNumericId = function(id) return id end,
}
package.loaded["src.core.game3.pokemon"] = {
moveFromTmItem = function(id) return id end,
moveName = function(id) return ({TM39 = "ROCK TOMB", HM01 = "CUT"})[id] end,
}
local M = require("src.ui.game3.rs.bag_menu")
local function render(pocket, rows, gender, pocketIdx)
draws, images = {}, {}
local Bag = {
open = true, mode = "list", scroll = 0, cursor = 1, pocketIdx = pocketIdx or 1,
_session = {gender = gender},
list = function() return rows end,
currentPocket = function() return pocket end,
}
Bag._rsBag = {pos = {}, frame = 0, gridPos = 1, lastMode = "list"}
M.draw(Bag)
return draws
end
local function at(list, s, y)
for _, d in ipairs(list) do if d.s == s and (y == nil or d.y == y) then return d end end
end
T.suite("rs bag row layout")
local tm = render("TM_CASE", {{id = "TM39", name = "TM39", qty = 1, description = ""},
{id = "HM01", name = "HM01", qty = 1, description = ""}}, 0, 4)
eq(at(tm, "39", 16) and at(tm, "39", 16).x, 120, "TM number x")
eq(at(tm, "ROCK TOMB", 16) and at(tm, "ROCK TOMB", 16).x, 136, "TM name x")
eq(at(tm, "ROCK TOMB", 16) and at(tm, "ROCK TOMB", 16).w, 78, "TM name width")
eq(at(tm, "×", 16) and at(tm, "×", 16).x, 214, "TM multiply sign x")
eq(at(tm, "1", 16) and at(tm, "1", 16).x, 226, "TM count right-aligned to 232")
eq(at(tm, "1", 32) and at(tm, "1", 32).x, 129, "HM number x")
eq(at(tm, "CUT", 32) and at(tm, "CUT", 32).x, 136, "HM name x")
eq(at(tm, "×", 32), nil, "HM row has no quantity")
local berry = render("BERRY_POUCH", {{id = "LIECHI", name = "LIECHI", qty = 10, description = ""}}, 1, 5)
eq(at(berry, "36", 16) and at(berry, "36", 16).x, 120, "berry number x")
eq(at(berry, "LIECHI", 16) and at(berry, "LIECHI", 16).w, 72, "berry name width")
eq(at(berry, "×", 16) and at(berry, "×", 16).x, 208, "berry multiply sign x")
eq(at(berry, "10", 16) and at(berry, "10", 16).x, 220, "berry count right-aligned to 232")
local items = render("ITEMS", {{id = "POTION", name = "POTION", qty = 5, description = ""}}, 0, 1)
eq(at(items, "POTION", 16) and at(items, "POTION", 16).x, 112, "item name x")
eq(at(items, "×", 16) and at(items, "×", 16).x, 214, "item multiply sign x")
eq(at(items, "5", 16) and at(items, "5", 16).x, 226, "item count right-aligned to 232")
render("ITEMS", {}, 1, 2)
local fem = table.concat(images, ",")
eq(fem, "indicator_0_female.png,indicator_1_female.png,indicator_0_female.png,indicator_0_female.png,indicator_0_female.png",
"female indicator dots use female bake")
render("ITEMS", {}, 0, 2)
eq(table.concat(images, ","), "indicator_0.png,indicator_1.png,indicator_0.png,indicator_0.png,indicator_0.png",
"male indicator dots use male bake")
T.finish("rs bag row layout")
@@ -0,0 +1,62 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.fixture_data.game3_map_sections").install()
local T = require("tests.harness")
local eq = T.eq
require("tests.game3_cache").stubSpeciesNames()
require("src.core.GameVersion").set("firered")
package.loaded["src.core.game3.audio"] = {
playSe = function() end, playCry = function() end,
playSong = function() end, stopAll = function() end,
}
package.loaded["src.core.game3.rom_text"] = {
plain = function(key) return key end, box = function(key) return key end,
ascii = function(key) return key end, has = function() return true end,
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
count = function() return 0 end, list = function() return {} end,
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
}
local drawn = {}
package.loaded["src.ui.game3.chrome"] = {
_frameType = 0,
userFrame = function(_, x, y, w, h) drawn[#drawn + 1] = { x, y, w, h } end,
}
local BattleChrome = require("src.ui.game3.battle_chrome")
local Ui = require("src.core.game3.battle.ui")
local nativeLayout = "rs"
package.loaded["src.core.game3.profile"] = {
forSession = function() return { font = { nativeLayout = nativeLayout } } end,
}
BattleChrome._manifest = { layout = "rse" }
local function frames(mode)
drawn = {}
Ui.drawMenuFrames(mode)
return drawn
end
local function same(a, b)
if #a ~= #b then return false end
for i = 1, #a do
for j = 1, 4 do if a[i][j] ~= b[i][j] then return false end end
end
return true
end
nativeLayout = "rs"
local mv = frames("moves")
eq(same(mv, { { 1, 15, 20, 4 }, { 23, 15, 6, 4 } }), true, "RS move menu frames follow menu_map.bin")
eq((mv[1] and (mv[1][1] + mv[1][3]) * 8) or -1, 168, "RS left move box content ends at x=168")
eq(same(frames("menu"), { { 18, 15, 11, 4 } }), true, "RS action menu frame follows menu_map.bin")
nativeLayout = nil
eq(same(frames("moves"), { { 1, 15, 18, 4 }, { 21, 15, 8, 4 } }), true, "Emerald move menu frames unchanged")
eq(same(frames("menu"), { { 16, 15, 13, 4 } }), true, "Emerald action menu frame unchanged")
T.finish("game3 RS battle menu frames #2729")
@@ -0,0 +1,108 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local noop = function() end
local profileField
local started = {}
local vm = { active = false }
package.loaded["src.core.game3.pokemon"] = {
FRIENDSHIP_EVENT_WALKING = 0,
FRIENDSHIP_EVENT_FAINT_OUTSIDE_BATTLE = 1,
adjustFriendship = noop,
currentMapSec = function() return 0 end,
displayMonName = function(mon) return mon.name or "MON" end,
}
package.loaded["src.core.game3.rom_text"] = { box = function(key) return key end }
package.loaded["src.core.game3.field_semantics"] = {
var = function(_, name) return name end,
getVar = function() return 0 end,
setVar = noop,
}
package.loaded["src.core.game3.field_modules"] = { enabled = function() return false end }
package.loaded["src.core.game3.profile"] = {
family = function() return profileField and "rse" or "frlg" end,
forSession = function() return { field = profileField } end,
}
package.loaded["src.core.game3.capabilities"] = { gate = function() return false end }
package.loaded["src.core.game3.rs.rematch"] = { enabled = function() return false end }
package.loaded["src.core.game3.faraway_island"] = { updateStepCounter = noop }
package.loaded["src.core.game3.mystery_gift"] = { incrementNewsStepCounter = noop }
package.loaded["src.core.game3.rse.init"] = { call = noop, isRse = function() return profileField ~= nil end }
package.loaded["src.core.game3.player"] = { cellX = 0, cellY = 0 }
package.loaded["src.core.game3.forced_movement"] = {
isForced = function() return false end,
isForcedMovementTile = function() return false end,
}
package.loaded["src.core.game3.se_ids"] = { SE_FIELD_POISON = 1 }
package.loaded["src.core.game3.audio"] = { playSe = noop, fadeOutBgm = noop }
package.loaded["src.core.game3.special_scene_rse"] = { countSSTidalStep = function() return false end }
package.loaded["src.core.game3.safari"] = { takeStep = function() return false end }
package.loaded["src.core.game3.daycare"] = { step = function() return nil end }
package.loaded["src.core.game3.scripting.space"] = {
vm = vm,
scriptKey = function(name) return name end,
startScript = function(key)
started[#started + 1] = key
vm.active = true
return true
end,
}
local StepEvents = require("src.core.game3.step_events")
local function poisonedSession()
return {
vars = { poisonSteps = 3 },
party = {
{ name = "TORCHIC", hp = 1, status = "PSN" },
{ name = "MUDKIP", hp = 20 },
},
}
end
local function types()
local out = {}
for i, ev in ipairs(StepEvents._queue) do out[i] = ev.type end
return table.concat(out, ",")
end
for _, case in ipairs({
{ name = "emerald", field = require("src.core.game3.profiles.emerald.field"), script = "EventScript_FieldPoison" },
{ name = "rs", field = require("src.core.game3.profiles.rs.field"), script = "gUnknown_081A14B8" },
}) do
StepEvents.flush()
started = {}
vm.active = false
profileField = case.field
eq(case.field.fieldPoisonScript, case.script, case.name .. " profile names the cart field poison script")
local session = poisonedSession()
StepEvents.onStepTaken(session, {})
local mon = session.party[1]
eq(mon.hp, 0, case.name .. ": poison step takes the last HP")
eq(mon.status, "PSN", case.name .. ": status is left for the script's faint check")
eq(types(), "field_poison_script", case.name .. ": only the field poison script is queued")
StepEvents.update(1 / 60, {})
eq(started[1], case.script, case.name .. ": the step starts " .. case.script)
check(StepEvents.busy(), case.name .. ": step events stay busy while the script runs")
StepEvents.update(1 / 60, {})
check(StepEvents.busy(), case.name .. ": still busy on the next frame")
vm.active = false
for _ = 1, 8 do StepEvents.update(1 / 60, {}) end
check(not StepEvents.busy(), case.name .. ": step events release once the script ends")
end
StepEvents.flush()
started = {}
profileField = nil
local session = poisonedSession()
StepEvents.onStepTaken(session, {})
eq(types(), "poison_faint,poison_white_out", "firered keeps the Lua faint + white out events")
eq(session.party[1].status, nil, "firered clears status at faint time")
eq(#started, 0, "firered starts no script")
StepEvents.flush()
T.finish("game3_rse_field_poison_script_2736")
@@ -0,0 +1,95 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local G = love.graphics
local canvasA = G.newCanvas(240, 160)
local canvasB = G.newCanvas(240, 160)
canvasA.name, canvasB.name = "A", "B"
local function noop() end
local Renderer = { canvas = G.newCanvas(240, 160), worldCanvas = canvasA }
function Renderer:init() end
function Renderer:setUISize() end
function Renderer:beginFrame() end
function Renderer:worldViewSize() return 240, 160 end
function Renderer:beginWorldPass()
self.worldActive = true
G.setCanvas(self.worldCanvas)
end
function Renderer:exchangeWorldCanvas(current, replacement)
if self.worldCanvas ~= current then return false end
self.worldCanvas = replacement
return true
end
function Renderer:endWorldPass() G.setCanvas(self.canvas) end
function Renderer:endFrame() end
local seen = {}
local Transition = {
isActive = function() return true end,
drawWorld = function(canvas)
seen.handed = canvas
seen.bound = G.getCanvas()
end,
}
local stubs = {
["src.render.Renderer"] = Renderer,
["src.render.PaletteFX"] = { mode = "gbc" },
["src.render.Tilt"] = { active = function() return false end },
["src.ui.game3.help_system"] = { isOpen = function() return false end },
["src.core.game3.battle"] = { isActive = function() return false end },
["src.core.game3.bg"] = { hasVisible = function() return false end },
["src.core.game3.oam"] = {
resetFrame = noop, setLayer = function() return "world" end, animateSprites = noop,
buildOamBuffer = noop, flush = noop, flushPriority = noop,
},
["src.core.game3.field_view"] = {
draw = function(_, _, _, opts)
local cur = G.getCanvas()
if opts.exchangeCanvas and opts.exchangeCanvas(cur, canvasB) then
G.setCanvas(canvasB)
end
end,
},
["src.core.game3.battle_transition"] = Transition,
}
local saved = {}
for name, mod in pairs(stubs) do
saved[name] = package.loaded[name]
package.loaded[name] = mod
end
package.loaded["src.core.game3.display"] = nil
local Display = require("src.core.game3.display")
Display.setUiRenderer(noop)
Display.planesBroken = false
Display.present({ options = {} }, 240, 160)
check(not Display.planesBroken, "plane present ran without falling back")
check(Renderer.worldCanvas == canvasB, "field pass exchanged the world canvas")
check(seen.handed == canvasB, "drawWorld is handed the exchanged world canvas")
check(seen.bound == canvasB, "world canvas still bound after the field pass pop")
for name in pairs(stubs) do package.loaded[name] = saved[name] end
package.loaded["src.core.game3.display"] = nil
local BT = require("src.core.game3.battle_transition")
local Pal = require("src.core.game3.pal_fade")
local drawnOn = {}
local rect = G.rectangle
G.rectangle = function() drawnOn[#drawnOn + 1] = G.getCanvas() end
BT._active, BT._opts, BT._fx = true, {}, nil
BT._pal = Pal.new()
BT._pal.slots[0].y = 8
G.setCanvas(canvasA)
BT.drawWorld(canvasB, 240, 160)
G.rectangle = rect
check(#drawnOn > 0, "intro overlay drew")
check(drawnOn[1] == canvasB, "drawWorld draws the intro overlay into the canvas it is handed")
check(G.getCanvas() == canvasA, "drawWorld restores the caller's canvas")
BT._active, BT._pal, BT._worldDrawn = false, nil, false
T.finish()
+248
View File
@@ -0,0 +1,248 @@
-- engine/battle_anims/anim_commands.asm:1191
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 sfx prewarm 2732")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local ffi = require("ffi")
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local gen = cache .. "/data/generated/"
local audioFile = io.open(gen .. "audio.lua", "r")
local animFile = io.open(gen .. "battle_anims.lua", "r")
local progFile = io.open(cache .. "/assets/generated/audio/programs.bin", "rb")
if not (audioFile and animFile and progFile) then
for _, f in ipairs({ audioFile or false, animFile or false, progFile or false }) do
if f then f:close() end
end
print("[skip] no crystal cache at " .. cache)
os.exit(0)
end
audioFile:close()
animFile:close()
local progBytes = progFile:read("*a")
progFile:close()
local audio = assert(loadfile(gen .. "audio.lua"))()
local anims = assert(loadfile(gen .. "battle_anims.lua"))()
local constants = loadfile(gen .. "constants.lua")
constants = constants and constants() or nil
if audio.runtime ~= true or not audio.sfx or not audio.sfx.Sfx_Item then
print("[skip] crystal cache has no runtime audio")
os.exit(0)
end
love.filesystem.write(audio.programFile, progBytes)
local SoundData = {}
SoundData.__index = SoundData
function SoundData:setSample(index, a, b)
if b == nil then
self.buf[index * self.channels] = a * 32767
else
self.buf[index * self.channels + (a - 1)] = b * 32767
end
end
function SoundData:getSample(index, channel)
return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767
end
function SoundData:getFFIPointer() return self.buf end
function SoundData:getSampleCount() return self.samples end
function SoundData:getSampleRate() return self.rate end
function SoundData:getBitDepth() return 16 end
function SoundData:getChannelCount() return self.channels end
love.sound.newSoundData = function(samples, rate, _, channels)
return setmetatable({ samples = samples, rate = rate, channels = channels,
buf = ffi.new("int16_t[?]", samples * channels) }, SoundData)
end
local Source = {}
Source.__index = Source
function Source:play() self.plays = (self.plays or 0) + 1 end
function Source:stop() self.playing = false end
function Source:isPlaying() return self.playing == true end
function Source:setVolume() end
function Source:setPitch(p) self.pitch = p end
function Source:getPitch() return self.pitch or 1 end
function Source:getDuration() return self.sd.samples / self.sd.rate end
function Source:tell() return 0 end
love.audio = {
newSource = function(sd) return setmetatable({ sd = sd }, Source) end,
}
love.timer.getTime = os.clock
local channels = {}
local function channel(name)
local ch = channels[name]
if ch then return ch end
local items = {}
ch = {}
function ch:push(v) items[#items + 1] = v end
function ch:pop() return table.remove(items, 1) end
function ch:clear() for i = #items, 1, -1 do items[i] = nil end end
function ch:getCount() return #items end
channels[name] = ch
return ch
end
love.thread = {
newThread = function()
return { start = function() end, getError = function() return nil end }
end,
getChannel = channel,
}
local ChipSynth = require("src.core.ChipSynth")
local ChipAudio = require("src.core.ChipAudio")
local Sound = require("src.core.Sound")
local UI = require("src.ui.gen2.BattleState")
local inWorker = false
local mainRenders = 0
local realRender = ChipSynth.renderEffectData
ChipSynth.renderEffectData = function(...)
if not inWorker then mainRenders = mainRenders + 1 end
return realRender(...)
end
local function workerStep()
local cmdCh, fxCh = channel("chipaudio_cmd"), channel("chipaudio_fx")
local did = false
inWorker = true
while true do
local req = cmdCh:pop()
if not req then break end
if req.cmd == "effect" then
did = true
local sd = ChipSynth.renderEffectData({ audio = req.audio }, req.header,
req.options or {})
fxCh:push({ key = req.key, epoch = req.epoch, sd = sd })
end
end
inWorker = false
return did
end
local function pump()
if ChipAudio.pumpEffects then ChipAudio.pumpEffects() end
ChipAudio.update()
end
local function drain()
for _ = 1, 400 do
pump()
if not workerStep() then
pump()
local st = ChipAudio._effectStateForTest()
if (st.queued or 0) == 0 and (st.inFlight or 0) == 0 then return end
end
end
end
local function resetStats()
mainRenders = 0
if ChipAudio.resetSyncStats then ChipAudio.resetSyncStats() end
end
local data = { audio = audio, gen2BattleAnims = anims,
gen2Constants = constants }
if jit and jit.off then jit.off() end
local before = os.clock()
realRender(data, audio.sfx.Sfx_Item, { frequencyOffset = 0, frameTicks = 0x100 })
local syncMs = (os.clock() - before) * 1000
check(Sound.prewarmSfx(data, "Sfx_Item"), "Sfx_Item prewarm queued")
drain()
resetStats()
before = os.clock()
check(Sound.play(data, "Sfx_Item") ~= nil, "Sfx_Item plays")
local warmMs = (os.clock() - before) * 1000
print(("Sfx_Item first play, jit off: sync render %.1f ms, prewarmed %.1f ms")
:format(syncMs, warmMs))
eq(mainRenders, 0, "prewarmed Sfx_Item: no main-thread render")
local enemy = { species = "RATTATA", moves = {
{ id = "TACKLE" }, { id = "TAIL_WHIP" }, { id = "BITE" } } }
local player = { species = "CYNDAQUIL", moves = {
{ id = "EMBER" }, { id = "SMOKESCREEN" }, { id = "QUICK_ATTACK" },
{ id = "LEER" } } }
local battle = { wild = true, enemy = enemy, player = player,
party = { player }, enemyParty = { enemy } }
local s = UI.new({ data = data, options = {}, save = {} }, { battle = battle })
drain()
resetStats()
local played = {}
local realStereo = Sound.playStereo
Sound.playStereo = function(d, name)
played[#played + 1] = name
return realStereo(d, name)
end
for _, turn in ipairs({ { player, "player" }, { enemy, "enemy" } }) do
for _, move in ipairs(turn[1].moves) do
if s:animForMove(move.id, turn[2]) then
for _ = 1, 2000 do
if not s.anim:step() then break end
end
end
s:playHitSound(10)
end
end
Sound.playStereo = realStereo
check(#played >= 5, "the turn's anims played their sounds (" .. #played .. ")")
eq(mainRenders, 0, "zero main-thread ChipSynth renders during the turn")
local stats = ChipAudio.stats()
eq(stats.syncRenders, 0, "zero main-thread sfx renders during the turn"
.. (stats.syncRenderLast and (" (last " .. stats.syncRenderLast .. ")") or ""))
check((stats.prewarmHits or 0) > 0, "plays took prewarmed PCM")
check(stats.line and stats.line:find("sync=0/", 1, true) ~= nil,
"stats.line carries the sync counters")
resetStats()
check(Sound.play(data, "Sfx_GetTm") ~= nil, "cold Sfx_GetTm plays")
eq(ChipAudio.stats().syncRenders, 1, "a cold play is counted as a sync render")
check((ChipAudio.stats().syncRenderMsMax or 0) > 0, "its render time is recorded")
local Game2 = require("src.core.Game2")
Game2.prewarmSessionSfx({ data = data })
drain()
local function mon(species, moves)
local m = { species = species, moves = {} }
for _, id in ipairs(moves) do m.moves[#m.moves + 1] = { id = id } end
return m
end
local party = {
mon("TYPHLOSION", { "FLAMETHROWER", "THUNDERPUNCH", "EARTHQUAKE", "SWIFT" }),
mon("FERALIGATR", { "SURF", "ICE_PUNCH", "CRUNCH", "SLASH" }),
mon("MEGANIUM", { "RAZOR_LEAF", "BODY_SLAM", "SYNTHESIS", "REFLECT" }),
mon("AMPHAROS", { "THUNDER", "FIRE_PUNCH", "THUNDER_WAVE", "LIGHT_SCREEN" }),
mon("PIDGEOT", { "FLY", "WING_ATTACK", "MIRROR_MOVE", "QUICK_ATTACK" }),
mon("GYARADOS", { "HYDRO_PUMP", "HYPER_BEAM", "DRAGON_RAGE", "BITE" }),
}
local enemyParty = {
mon("ALAKAZAM", { "PSYCHIC", "RECOVER", "REFLECT", "FUTURE_SIGHT" }),
mon("EXEGGUTOR", { "SOLARBEAM", "PSYCHIC", "SLEEP_POWDER", "EXPLOSION" }),
mon("SLOWBRO", { "SURF", "AMNESIA", "PSYCHIC", "CURSE" }),
mon("JYNX", { "ICE_BEAM", "LOVELY_KISS", "PSYCHIC", "DREAM_EATER" }),
mon("MR__MIME", { "BARRIER", "PSYBEAM", "SUBSTITUTE", "ENCORE" }),
mon("XATU", { "PSYCHIC", "FLY", "CONFUSE_RAY", "NIGHT_SHADE" }),
}
local trainer = { player = party[1], enemy = enemyParty[1],
party = party, enemyParty = enemyParty }
local names = UI.battleSfxNames(data, anims, trainer)
check(#names + #Game2.SESSION_SFX > 64, "6v6 list plus session set exceeds the cap ("
.. #names .. " + " .. #Game2.SESSION_SFX .. ")")
for _, name in ipairs(names) do Sound.prewarmSfx(data, name) end
drain()
for _, name in ipairs({ "Sfx_KeyItem", "Sfx_ReadText2", "Sfx_Menu", "Sfx_Save" }) do
resetStats()
check(Sound.play(data, name) ~= nil, name .. " plays")
eq(mainRenders, 0, name .. " stays prewarmed after a 6v6 battle prewarm")
end
S.finish()
@@ -25,6 +25,7 @@ local restartCalls = 0
love.system = love.system or {}
love.system.getOS = function() return osName end
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
_G.POKEPORT_LOOP_RESTART = true
HostShell.restart()
eq(restartCalls, 0, "Android never schedules the blocked restartApp relaunch")
@@ -0,0 +1,85 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
_G.love = require("tests.love_stub")
local spawns = 0
package.loaded["src.core.WinApi"] = {
spawn = function() spawns = spawns + 1 return true end,
dirOf = function(p) return p:match("^(.*)\\") or p end,
modulePath = function() return "C:\\Games\\gen1recomp.exe" end,
}
package.loaded["src.core.SessionLifecycle"] = setmetatable({}, {
__index = function() return function() end end,
})
love.system.getOS = function() return "Windows" end
love.filesystem.isFused = function() return true end
love.filesystem.getSource = function() return "C:\\Games\\gen1recomp.exe" end
local queue = {}
love.event = love.event or {}
love.event.quit = function(...) queue[#queue + 1] = { n = select("#", ...), arg = (...) } end
local loaded, err = pcall(dofile, "main.lua")
check(loaded, "main.lua loads headless: " .. tostring(err))
local HostShell = require("src.core.HostShell")
local function upvalue(fn, name)
for i = 1, 200 do
local n = debug.getupvalue(fn, i)
if n == nil then return nil end
if n == name then return i end
end
end
local function setLocal(name, value)
local fn = love.quit
local i = upvalue(fn, name)
if i then
debug.setupvalue(fn, i, value)
return true
end
local ret = upvalue(fn, "returnToLauncher")
local _, rtl = debug.getupvalue(fn, ret)
i = upvalue(rtl, name)
if i then debug.setupvalue(rtl, i, value) return true end
return false
end
local function runQuits()
local exited = false
while #queue > 0 and not exited do
table.remove(queue, 1)
if not love.quit() then exited = true end
end
return exited
end
local function reset()
spawns = 0
for i = #queue, 1, -1 do queue[i] = nil end
HostShell.restarting = false
setLocal("quitToLauncher", false)
setLocal("processEnded", false)
love.filesystem.remove("relaunch_to_launcher.txt")
end
reset()
check(setLocal("Game", {}), "a game session is on screen")
HostShell.restart()
eq(spawns, 1, "restartWithMods spawns the new process")
check(runQuits(), "the old process exits")
eq(spawns, 1, "and love.quit does not spawn a second instance")
check(love.filesystem.getInfo("relaunch_to_launcher.txt") ~= nil,
"the relaunch still lands in the launcher")
reset()
setLocal("Game", {})
love.event.quit()
check(runQuits(), "closing a game window ends this process")
eq(spawns, 1, "and relaunches the launcher exactly once")
T.finish("hostshell_restart_single_spawn_2740")
@@ -0,0 +1,175 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.data = love.data or {}
love.data.hash = function(_, data) return "h" .. #data .. ":" .. data:sub(1, 24) end
love.data.encode = function(_, _, digest) return (digest:gsub(".", function(c)
return ("%02x"):format(c:byte())
end)) end
local SaveData = require("src.core.SaveData")
local Serializer = require("src.core.SaveSerializer")
local function disk()
return Serializer.decode(love.filesystem.read("options.lua") or "") or {}
end
local function externalWrite(patch)
local tree = disk()
for k, v in pairs(patch) do tree[k] = v end
love.filesystem.write("options.lua", Serializer.encode(tree))
end
SaveData.saveOptions(SaveData.defaultOptions())
local stale = SaveData.loadOptions()
externalWrite({ autoReimport = true, splashVideo = false, themeVideoBg = false,
reduceMotion = true })
stale.textSpeed = 1
SaveData.saveLiveOptions({ version = "red", options = stale })
local after = disk()
eq(after.autoReimport, true, "Gen 1 in-game save keeps autoReimport")
eq(after.splashVideo, false, "Gen 1 in-game save keeps Splash Video OFF")
eq(after.themeVideoBg, false, "Gen 1 in-game save keeps Theme Video BG OFF")
eq(after.reduceMotion, true, "Gen 1 in-game save keeps Reduce Motion")
eq(after.textSpeed, 1, "and the in-game change still lands")
local Game3 = require("src.core.Game3")
SaveData.saveOptions(SaveData.defaultOptions())
local stale3 = SaveData.loadOptions()
externalWrite({ autoReimport = true, splashVideo = false, themeVideoBg = false })
stale3.musicVol = 3
Game3.writeOptions({ options = stale3 })
after = disk()
eq(after.autoReimport, true, "Gen 3 writeOptions keeps autoReimport")
eq(after.splashVideo, false, "Gen 3 writeOptions keeps Splash Video OFF")
eq(after.themeVideoBg, false, "Gen 3 writeOptions keeps Theme Video BG OFF")
eq(after.musicVol, 3, "and the Gen 3 change still lands")
local LauncherSettings = require("src.import.LauncherSettings")
local function findRow(model, label)
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do
if row.label == label then return row end
end
end
end
SaveData.saveOptions({ lastVersion = "red", splashVideo = true, reduceMotion = false })
local model = LauncherSettings.open(nil, "red")
externalWrite({ lastVersion = "blue", autoReimport = true })
local splash = findRow(model, "Splash Video")
check(splash.step(), "Splash Video row steps")
model.save()
after = disk()
eq(after.splashVideo, false, "the settings panel writes its own change")
eq(after.lastVersion, "blue", "and keeps a launcher key another writer changed")
eq(after.autoReimport, true, "and keeps autoReimport another writer set")
local realWrite = love.filesystem.write
love.filesystem.write = function(name, ...)
if tostring(name):match("^options%.lua") then return false, "sharing violation" end
return realWrite(name, ...)
end
local motion = findRow(model, "Reduce Motion")
eq(motion.value(), "OFF", "Reduce Motion starts OFF")
motion.step()
local saved = model.save()
love.filesystem.write = realWrite
check(saved == false, "a failed options write reports failure")
eq(motion.value(), "OFF", "and the row shows what the file holds, not the lost value")
check(type(model.saveError) == "string", "and the panel carries a visible save error")
eq(disk().reduceMotion, false, "the file still holds OFF")
local RomSources = require("src.import.RomSources")
local RomArchive = require("src.import.RomArchive")
local rom = ("R"):rep(1048576)
local romSha = love.data.encode("string", "hex", love.data.hash("sha1", rom))
local mounts = {}
local fs = love.filesystem
local baseMount, baseUnmount, baseItems, baseInfo, baseRead =
fs.mount, fs.unmount, fs.getDirectoryItems, fs.getInfo, fs.read
fs.newFileData = function(data, name) return { data = data, name = name } end
fs.mount = function(fd, point)
if type(point) == "string" and point:match("^_rom_") then
mounts[point] = fd
return true
end
return baseMount(fd, point)
end
fs.unmount = function(fd)
for point, m in pairs(mounts) do if m == fd then mounts[point] = nil return true end end
return baseUnmount(fd)
end
local function inner(path)
local point, rest = tostring(path):match("^(_rom_[%w_]+)/?(.*)$")
if not point or not mounts[point] then return nil end
return point, rest
end
fs.getDirectoryItems = function(path)
local point, rest = inner(path)
if point then
if rest == "" then
return { point:match("probe") and "probe" or "Pokemon Red.gb" }
end
return {}
end
return baseItems(path)
end
fs.getInfo = function(path, ...)
local point, rest = inner(path)
if point then
if rest == "" then return { type = "directory" } end
return { type = "file", size = rest == "probe" and 2 or #rom }
end
return baseInfo(path, ...)
end
fs.read = function(path, ...)
local point, rest = inner(path)
if point then return rest == "probe" and "ok" or rom end
return baseRead(path, ...)
end
RomArchive._resetForTests()
local tmp = os.tmpname()
local zipPath = tmp .. "-red.zip"
local f = assert(io.open(zipPath, "wb"))
f:write("PK\3\4" .. ("z"):rep(4096))
f:close()
RomSources.remember("red", { sha1 = romSha, path = zipPath, at = 1 })
local cand = RomSources.candidate("red", false)
check(cand ~= nil and not cand.pick and cand.path == zipPath,
"a zip source whose inner ROM matches the record is a re-import candidate")
local rawPath = tmp .. "-red.gb"
f = assert(io.open(rawPath, "wb"))
f:write(rom)
f:close()
RomSources.remember("red", { sha1 = romSha, path = rawPath, at = 1 })
cand = RomSources.candidate("red", false)
check(cand ~= nil and not cand.pick and cand.path == rawPath, "a raw source still matches")
os.remove(rawPath)
cand = RomSources.candidate("red", false)
check(cand ~= nil and cand.pick == true, "a moved or deleted source asks for the ROM instead of nothing")
eq(cand and cand.missing, "missing", "and says the file is gone")
f = assert(io.open(rawPath, "wb"))
f:write(("X"):rep(1048576))
f:close()
cand = RomSources.candidate("red", false)
check(cand ~= nil and cand.pick == true and cand.missing == "changed",
"a source that no longer matches asks for the ROM too")
os.remove(rawPath)
os.remove(zipPath)
os.remove(tmp)
T.finish("launcher_settings_persist_2740")
@@ -0,0 +1,220 @@
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
love = require("tests.love_stub")
local T = require("tests.harness")
local bit = require("bit")
local Version = require("src.core.GameVersion")
local Serializer = require("src.core.SaveSerializer")
local K = require("tests.save_compat._codec")
local D = require("tests.save_compat._gen3_decode")
local G3 = require("tests.fixtures.save.gen3_build")
local G2 = require("tests.fixtures.save.gen2_build")
local Gen2Save = require("src.save_convert.Gen2Save")
local PokemonG3 = require("src.core.game3.pokemon")
local Unown = require("src.core.gen2.Unown")
local Gen, Ops, MonOps = require("Gen"), require("Ops"), require("MonOps")
local State = require("State")
local Legality = require("Legality")
local function shinyOf(p, tid, sid)
return bit.bxor(bit.bxor(tid, sid), bit.bxor(math.floor(p / 65536), p % 65536)) < 8
end
do
local rng = 2726
local function rand(n)
rng = (rng * 1103515245 + 12345) % 2147483648
return rng % n
end
local bad = 0
for _ = 1, 120 do
local pid = rand(65536) * 65536 + rand(65536)
local tid, sid = rand(65536), rand(65536)
local nature = pid % 25
for letter = 0, 27 do
for _, shiny in ipairs({ false, true }) do
local p = MonOps.unownPid(pid, letter, nature, shiny, tid, sid)
if not (p and PokemonG3.unownLetter(p) == letter and p % 25 == nature and shinyOf(p, tid, sid) == shiny) then
bad = bad + 1
if bad <= 5 then
T.check(false, ("unownPid pid=%08x tid=%d sid=%d letter=%d shiny=%s -> %s")
:format(pid, tid, sid, letter, tostring(shiny), tostring(p)))
end
end
end
end
end
T.eq(bad, 0, "gen3 unownPid keeps nature and shininess for every letter")
for tsv89 = 0, 3 do
for letter = 0, 27 do
local p = MonOps.unownPid(0x12345678, letter, 7, true, tsv89 * 256, 0)
T.check(p and PokemonG3.unownLetter(p) == letter, ("shiny letter %d reachable with tsv bits %d"):format(letter, tsv89))
end
end
local rewritten = 0
for _ = 1, 300 do
local pid = rand(65536) * 65536 + rand(65536)
local tid, sid = rand(65536), rand(65536)
if MonOps.unownPid(pid, PokemonG3.unownLetter(pid), pid % 25, shinyOf(pid, tid, sid), tid, sid) ~= pid then
rewritten = rewritten + 1
end
local low = rand(65536)
local spid = bit.bxor(low, bit.bxor(tid, sid), rand(8)) * 65536 + low
if MonOps.unownPid(spid, PokemonG3.unownLetter(spid), spid % 25, true, tid, sid) ~= spid then
rewritten = rewritten + 1
end
end
T.eq(rewritten, 0, "gen3 unownPid leaves a PID that already fits unchanged (plain and shiny)")
end
local function g3state(version, mons)
Version.set(version)
local w = G3.base(version)
G3.setParty(w, mons)
local s = State.new()
s.save, s.version = assert(K.import(3, version, G3.emit(w))), version
s.save.pcItems = s.save.pcItems or {}
Gen.ensureBoxes(s.save)
return s
end
local UNOWN_F = 0x00000105
assert(PokemonG3.unownLetter(UNOWN_F) == 5)
do
local s = g3state("firered", { G3.mon({ species = 201, pid = UNOWN_F, tid = 0x1234, sid = 0x5678 }) })
local mon = s.save.party[1]
T.eq(PokemonG3.unownLetter(mon.personality), 5, "fixture unown is F")
local natureBefore = mon.personality % 25
MonOps.setNature(s.data, mon, (natureBefore + 3) % 25, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setNature keeps unown letter")
T.eq(mon.personality % 25, (natureBefore + 3) % 25, "setNature still sets the nature")
MonOps.setShiny(s.data, mon, true, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setShiny(true) keeps unown letter")
T.check(shinyOf(mon.personality, 0x1234, 0x5678), "setShiny(true) makes the PID shiny")
MonOps.setShiny(s.data, mon, false, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setShiny(false) keeps unown letter")
T.check(not shinyOf(mon.personality, 0x1234, 0x5678), "setShiny(false) clears shiny")
MonOps.setAbility(s.data, mon, 0, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setAbility keeps unown letter")
local before = mon.personality
MonOps.setAbility(s.data, mon, 0, 3)
T.eq(mon.personality, before, "setAbility to the current slot keeps the Unown PID")
MonOps.setShiny(s.data, mon, false, 3)
T.eq(mon.personality, before, "setShiny to the current state keeps the Unown PID")
MonOps.setNature(s.data, mon, before % 25, 3)
T.eq(mon.personality, before, "setNature to the current nature keeps the Unown PID")
end
for _, v in ipairs({ "firered", "emerald" }) do
local fam = v == "emerald" and "emerald" or "frlg"
local s = g3state(v, { G3.mon({ species = 201, pid = UNOWN_F, tid = 0x1234, sid = 0x5678, ability = 1,
ivs = { 31, 2, 17, 4, 25, 6 }, moves = { 237, 0, 0, 0 }, pp = { 15, 0, 0, 0 } }) })
local mon = s.save.party[1]
local before = assert(D.deep(assert(K.export(3, v, Serializer.decode(Serializer.encode(s.save)), nil)), fam)).party[1]
T.eq(Ops.setUnownForm(s, mon, 27), true, v .. " setUnownForm accepted ?")
T.eq(s.status, "UNOWN is now ?", v .. " status names the form")
T.eq(#s.undoStack, 1, v .. " one history entry")
T.eq(PokemonG3.unownLetter(mon.personality), 27, v .. " editor mon is ?")
local out = assert(K.export(3, v, Serializer.decode(Serializer.encode(s.save)), nil))
local after = assert(D.deep(out, fam)).party[1]
T.check(after.checksumOk, v .. " substruct checksum valid after PID change")
T.eq(after.species, 201, v .. " species decrypts as UNOWN")
T.eq(PokemonG3.unownLetter(after.pid), 27, v .. " written PID spells ?")
T.eq(after.pid % 25, UNOWN_F % 25, v .. " nature kept")
T.eq(after.shiny, false, v .. " still not shiny")
T.eq(after.ability, 1, v .. " abilityNum bit kept")
T.eq(table.concat(after.ivs, ","), table.concat(before.ivs, ","), v .. " IVs kept")
T.eq(table.concat(after.moves, ","), table.concat(before.moves, ","), v .. " moves kept")
T.eq(after.exp, before.exp, v .. " exp kept")
local back = assert(K.import(3, v, out))
T.eq(PokemonG3.unownLetter(back.party[1].personality), 27, v .. " re-import reads ?")
T.eq(Ops.setUnownForm(s, mon, 27), false, v .. " same form is a no-op")
end
do
local s = g3state("firered", { G3.mon({ species = 4 }) })
T.eq(Ops.setUnownForm(s, s.save.party[1], 3), false, "non-unown refuses a form")
T.eq(s.status, "Only an Unown has a form", "and says why")
end
local UNOWN_DEF = {
id = "UNOWN", name = "UNOWN", dex = 201, index = 201, types = { "PSYCHIC" },
baseStats = { hp = 48, attack = 72, defense = 48, speed = 48, specialAttack = 72, specialDefense = 48 },
catchRate = 225, baseExp = 61, growthRate = "MEDIUM_FAST", genderRatio = 255,
}
local g2data = {
pokemon = { UNOWN = UNOWN_DEF, CYNDAQUIL = { id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155 } },
items = K.gen2Data.items, maps = K.gen2Data.maps, moves = {},
}
local function g2state(version, dvs)
Version.set(version)
local bytes = G2.build({ version = version, party = { G2.mon({ species = 201, nick = "UNOWN", dvs = dvs }) } })
local s = State.new()
s.data = g2data
s.save, s.version = assert(Gen2Save.decode(bytes, version, g2data)), version
Gen.ensureBoxes(s.save)
return s
end
local function dvsOf(t) return { attack = t[1], defense = t[2], speed = t[3], special = t[4] } end
for _, v in ipairs({ "gold", "crystal" }) do
local start = { 9, 4, 13, 6 }
for letter = 1, 26 do
local s = g2state(v, start)
local mon = s.save.party[1]
T.eq(mon.species, "UNOWN", v .. " fixture decodes as UNOWN")
local hp = mon.dvs.hp
local ok = Ops.setUnownForm(s, mon, letter)
if letter == Unown.letterFromDVs(dvsOf(start)) then
T.eq(ok, false, v .. " setting the current letter is a no-op")
else
T.eq(ok, true, v .. " set letter " .. letter)
end
T.eq(Unown.letterFromDVs(mon.dvs), letter, v .. " DVs spell letter " .. letter)
T.eq(Unown.monLetter(mon), letter, v .. " stored letter " .. letter)
T.eq(mon.dvs.hp, hp, v .. " HP DV kept for " .. letter)
for i, k in ipairs({ "attack", "defense", "speed", "special" }) do
T.eq(mon.dvs[k] % 2, start[i] % 2, v .. " bit 0 of " .. k .. " kept")
T.eq(math.floor(mon.dvs[k] / 8), math.floor(start[i] / 8), v .. " bit 3 of " .. k .. " kept")
end
local report = Legality.mon(s, mon)
local issues = Legality.highlights(report, mon)
T.check(issues.fields.form == nil and issues.fields.shiny == nil, v .. " no form/shiny issue for " .. letter)
local out = assert(Gen2Save.encode(s.save, v, nil, g2data))
local L = Gen2Save.layoutFor(v)
T.eq(Gen2Save.checksumValid(out, L), true, v .. " checksum valid for " .. letter)
local dvHi, dvLo = out:byte(L.wPartyMons + 0x15 + 1), out:byte(L.wPartyMons + 0x16 + 1)
T.eq(Unown.letterFromDVs(dvsOf({ math.floor(dvHi / 16), dvHi % 16, math.floor(dvLo / 16), dvLo % 16 })), letter,
v .. " DV bytes at +0x15 spell " .. letter)
local back = assert(Gen2Save.decode(out, v, g2data))
T.eq(Unown.monLetter(back.party[1]), letter, v .. " re-read letter " .. letter)
end
local s = g2state(v, { 2, 10, 10, 10 })
local mon = s.save.party[1]
T.eq(Unown.monLetter(mon), 9, v .. " shiny fixture is I")
T.eq(require("src.battle.gen2.Mon").vanillaShiny(mon.dvs), true, v .. " and shiny")
Ops.setUnownForm(s, mon, 22)
T.eq(Unown.monLetter(mon), 22, v .. " shiny I -> V")
T.eq(mon.shiny, true, v .. " V stays shiny")
Ops.setUnownForm(s, mon, 1)
T.eq(Unown.monLetter(mon), 1, v .. " shiny V -> A")
T.eq(mon.shiny, false, v .. " A is not shiny")
T.eq(s.status, "UNOWN is now A (no longer shiny: only I and V can be shiny)", v .. " status warns about shininess")
local out, why = Gen2Save.encode(s.save, v, nil, g2data)
T.check(out ~= nil, v .. " a former shiny now A still writes: " .. tostring(why))
for _, letter in ipairs({ 9, 22 }) do
local s2 = g2state(v, { 9, 8, 12, 14 })
local m2 = s2.save.party[1]
T.eq(require("src.battle.gen2.Mon").vanillaShiny(m2.dvs), false, v .. " plain fixture not shiny")
Ops.setUnownForm(s2, m2, letter)
T.eq(Unown.monLetter(m2), letter, v .. " plain -> " .. letter)
T.eq(m2.shiny, false, v .. " plain never becomes shiny at " .. letter)
end
end
T.finish()
+6
View File
@@ -4,6 +4,7 @@ tests/engine/amnesia_rest_share_anim_bug1881.lua
tests/engine/android_asymmetric_display.lua
tests/engine/android_exit_to_launcher_test.lua
tests/engine/android_host_extension_test.lua
tests/engine/android_old_shell_restart_bug2738.lua
tests/engine/android_secondary_present.lua
tests/engine/android_shortcuts_payload_test.lua
tests/engine/applying_attack_sfx_wait_bug1998.lua
@@ -114,7 +115,9 @@ tests/engine/game3_em_bike_doors_2637.lua
tests/engine/game3_em_catch_prompt_2637.lua
tests/engine/game3_frontier_obedience_2638.lua
tests/engine/game3_post_dex_background_2637.lua
tests/engine/game3_rs_anim_names_test.lua
tests/engine/game3_rs_bag_cursor_width_2709.lua
tests/engine/game3_rs_bag_row_layout_2727.lua
tests/engine/game3_rs_item_use_text_2699_test.lua
tests/engine/game3_rs_starter_choose_scale_2703.lua
tests/engine/game3_rs_summary_memo_location_2707.lua
@@ -389,6 +392,7 @@ tests/engine/host_shell_bridge_request.lua
tests/engine/host_shell_fetch_errors.lua
tests/engine/host_shell_postlog.lua
tests/engine/host_shell_request_headers.lua
tests/engine/hostshell_restart_single_spawn_2740.lua
tests/engine/hostshell_spawn_self.lua
tests/engine/hostshell_win_http_nonascii.lua
tests/engine/hostshell_win_restart_2721.lua
@@ -446,6 +450,7 @@ tests/engine/launcher_page_scroll.lua
tests/engine/launcher_patch_notes.lua
tests/engine/launcher_return_pointer_bug2079.lua
tests/engine/launcher_secret_emerald_test.lua
tests/engine/launcher_settings_persist_2740.lua
tests/engine/launcher_skins_options_reads_bug2038.lua
tests/engine/launcher_skins_tab.lua
tests/engine/launcher_skins_ux.lua
@@ -620,6 +625,7 @@ tests/engine/save_convert_toggle_objects.lua
tests/engine/save_editor_berry_powder_2698.lua
tests/engine/save_editor_buena_points_2640.lua
tests/engine/save_editor_custom_maps.lua
tests/engine/save_editor_unown_form_2726.lua
tests/engine/save_export_map_window_bug1691.lua
tests/engine/save_export_portable_bug752.lua
tests/engine/save_export_same_map_moved_2468.lua
+312
View File
@@ -2,21 +2,32 @@
"""Generate src/import/gba/anim_names_<game>.lua for an RSE-family pret build.
usage: tools/gen_anim_names.py --game emerald [--pret ../pokeemerald] [--ref ../pokefirered] [--check]
tools/gen_anim_names.py --game ruby [--pret ../pokeruby] [--ref ../pokeemerald] [--check]
"""
import argparse
import difflib
import hashlib
import importlib.util
import os
import re
import sys
from collections import Counter
ROM_LO = 0x08000000
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAMES = {
"emerald": {"repo": "pokeemerald", "elf": "pokeemerald.elf", "rom": "pokeemerald.gba"},
"ruby": {"repo": "pokeruby", "elf": "pokeruby.elf", "rom": "pokeruby.gba"},
}
REF = {"repo": "pokefirered", "elf": "pokefirered.elf", "rom": "pokefirered.gba"}
RS_REF = {"repo": "pokeemerald", "elf": "pokeemerald.elf", "rom": "pokeemerald.gba"}
RS_NATIVE_GLOB = ("rs_callbacks.lua", "rs_early_callbacks.lua", "rs_tasks.lua", "rs_sound_tasks.lua")
PTR_FIELDS = {
0x02: ((1, "template"),), 0x03: ((1, "task"),), 0x1F: ((1, "task"),),
0x0E: ((1, "branch"),), 0x13: ((1, "branch"),), 0x24: ((1, "branch"),),
0x21: ((4, "branch"),), 0x12: ((2, "branch"),), 0x11: ((1, "branch"), (5, "branch")),
}
FIXED = {
0x00: 3, 0x01: 3, 0x04: 2, 0x05: 1, 0x06: 1, 0x07: 1, 0x08: 1, 0x09: 3,
@@ -247,6 +258,305 @@ def render_indexed(name, d, indent=" "):
return rows
def decode_ops(bd, o, errors):
ops, pos = [], o
for _ in range(8192):
op = bd.u8(pos)
n = op_len(bd, pos)
raw = bytearray(bd.b[pos:pos + n])
ptrs = []
for at, kind in PTR_FIELDS.get(op, ()):
ptrs.append((kind, bd.u32(pos + at)))
raw[at:at + 4] = b"\0\0\0\0"
ops.append((bytes(raw), ptrs))
if op in (0x08, 0x0F, 0x13):
return ops
pos += n
errors.append("script at 0x%X did not terminate" % o)
return ops
def parse_lua_maps(path, sections):
out, cur = {s: {} for s in sections}, None
with open(path) as f:
for line in f:
m = re.match(r"^ (\w+) = \{$", line)
if m:
cur = m.group(1) if m.group(1) in out else None
continue
if line.startswith(" }"):
cur = None
continue
if cur:
m = re.match(r'^ (?:\["([^"]+)"\]|(\w+)) = "([^"]*)",$', line)
if m:
out[cur][m.group(1) or m.group(2)] = m.group(3)
return out
def native_names():
names = set()
base = os.path.join(ROOT, "src", "core", "game3", "battle", "anim_port")
for fn in RS_NATIVE_GLOB:
with open(os.path.join(base, fn)) as f:
for m in re.finditer(r"\b(sub_[0-9A-Fa-f]{7})\s*=", f.read()):
names.add(m.group(1))
return names
def status_names(pret, count):
out = {}
with open(os.path.join(pret, "include", "constants", "battle.h")) as f:
for l in f:
m = re.match(r"#define\s+B_ANIM_(STATUS_\w+)\s+(0x[0-9A-Fa-f]+|\d+)", l)
if m and int(m.group(2), 0) < count:
out[int(m.group(2), 0)] = m.group(1)
return out
def best(counter):
top = counter.most_common(2)
if len(top) > 1 and top[0][1] == top[1][1]:
return None
return top[0][0]
def main_rs(args):
info = GAMES[args.game]
pret = args.pret or os.path.join(os.path.dirname(ROOT), info["repo"])
refp = args.ref or os.path.join(os.path.dirname(ROOT), RS_REF["repo"])
out = args.out or os.path.join(ROOT, "src", "import", "gba", "rs", "anim_names.lua")
symmod = load_syms_module()
rs = Build(pret, info, symmod)
em = Build(refp, RS_REF, symmod)
errors = []
em_names = parse_lua_maps(os.path.join(ROOT, "src", "import", "gba", "anim_names_emerald.lua"),
("templates", "callbacks", "tasks"))
rs_tables, em_tables = table_labels(pret), table_labels(refp)
roots, queue, counts = [], [], {}
for key, tname in TABLES:
rbase, ebase = rs.sym_off(tname), em.sym_off(tname)
rn = len([l for l, _ in rs_tables[tname] if l != "Move_COUNT"])
en = len([l for l, _ in em_tables[tname] if l != "Move_COUNT"])
counts[key] = rn
for i in range(rn):
ro = rs.off(rs.u32(rbase + i * 4))
if ro is None:
errors.append("%s[%d] bad pointer" % (tname, i))
continue
roots.append(ro)
if i < en:
eo = em.off(em.u32(ebase + i * 4))
if eo is not None:
queue.append((ro, eo))
tmpl_ptrs, task_ptrs = walk(rs, roots, errors)
def func_size(bd, ptr):
r = bd.func_row(ptr)
return r and r["size"]
def close(x, y):
return x is not None and y is not None and abs(x - y) <= max(8, max(x, y) // 10)
def same_size(pa, pb):
if pa[0] == "template":
ra, rb = rs.data_row(pa[1]), em.data_row(pb[1])
if not ra or not rb:
return False
return close(func_size(rs, rs.u32(ra["off"] + 20)), func_size(em, em.u32(rb["off"] + 20)))
return close(func_size(rs, pa[1]), func_size(em, pb[1]))
votes = {"template": {}, "task": {}}
seen, drift = set(), 0
while queue:
pair = queue.pop()
if pair in seen:
continue
seen.add(pair)
a, b = decode_ops(rs, pair[0], errors), decode_ops(em, pair[1], errors)
ta, tb = [x[0] for x in a], [x[0] for x in b]
if ta != tb:
drift += 1
sm = difflib.SequenceMatcher(None, ta, tb, autojunk=False)
matched, loose_a, loose_b = [], [], []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal" or (tag == "replace" and i2 - i1 == j2 - j1
and all(ta[i1 + k][0] == tb[j1 + k][0] for k in range(i2 - i1))):
matched += [(i1 + k, j1 + k) for k in range(i2 - i1)]
else:
loose_a += [i for i in range(i1, i2) if ta[i][0] in (0x02, 0x03, 0x1F)]
loose_b += [j for j in range(j1, j2) if tb[j][0] in (0x02, 0x03, 0x1F)]
ca, cb = Counter(ta[i] for i in loose_a), Counter(tb[j] for j in loose_b)
matched += [(i, j) for i in loose_a for j in loose_b if ta[i] == tb[j] and ca[ta[i]] == 1 and cb[tb[j]] == 1
and same_size(a[i][1][0], b[j][1][0])]
for ia, ib in matched:
for (ka, pa), (_, pb) in zip(a[ia][1], b[ib][1]):
if ka == "branch":
ro, eo = rs.off(pa), em.off(pb)
if ro is not None and eo is not None:
queue.append((ro, eo))
else:
votes[ka].setdefault(pa, Counter())[pb] += 1
natives = native_names()
ambiguous, resized = [], []
def rs_func_key(r):
return rs.func_key(r) if r.get("obj") else r["name"]
def em_func_canon(ptr, section):
r = em.func_row(ptr)
return r and em_names[section].get(em.func_key(r))
tasks, task_src = {}, {}
for ptr in sorted(task_ptrs):
r = rs.func_row(ptr)
if not r:
errors.append("task pointer 0x%08X has no ELF function" % ptr)
continue
key = rs_func_key(r)
canon = None
if ptr in votes["task"] and r["name"] not in natives:
eptr = best(votes["task"][ptr])
if eptr is None:
ambiguous.append("task %s: %s" % (key, ", ".join(
"%s x%d" % (em.func_row(p)["name"], c) for p, c in votes["task"][ptr].most_common())))
else:
canon = em_func_canon(eptr, "tasks")
if canon and func_size(rs, ptr) != func_size(em, eptr):
resized.append("task %s %d -> %s %d" % (key, func_size(rs, ptr), canon, func_size(em, eptr)))
tasks[key] = canon or short_task(r["name"])
task_src[key] = canon is not None
cb_votes, tmpl_canon = {}, {}
templates, callbacks, cb_src = {}, {}, {}
for ptr in sorted(tmpl_ptrs):
r = rs.data_row(ptr)
if not r:
errors.append("template pointer 0x%08X has no ELF symbol" % ptr)
continue
key = rs.data_key(r) if r.get("obj") else r["name"]
cbptr = rs.u32(r["off"] + 20)
cbr = rs.func_row(cbptr)
if not cbr:
errors.append("template %s callback 0x%08X has no ELF function" % (key, cbptr))
continue
eptr = None
if ptr in votes["template"]:
eptr = best(votes["template"][ptr])
if eptr is None:
ambiguous.append("template %s: %s" % (key, ", ".join(
"%s x%d" % (em.data_row(p)["name"], c) for p, c in votes["template"][ptr].most_common())))
if eptr is not None:
er = em.data_row(eptr)
tmpl_canon[key] = (em_names["templates"].get(em.data_key(er)), sum(votes["template"][ptr].values()))
ecb = em.u32(er["off"] + 20)
cb_votes.setdefault(cbptr, Counter())[ecb] += votes["template"][ptr][eptr]
templates[key] = key
callbacks.setdefault(rs_func_key(cbr), None)
by_canon = {}
for key, (canon, n) in tmpl_canon.items():
if canon:
by_canon.setdefault(canon, []).append((n, key))
shared, tmpl_src = [], set()
for canon, rows in by_canon.items():
rows.sort(key=lambda x: (-x[0], x[1]))
if canon in templates and canon not in [k for _, k in rows]:
shared.append("%s (RS template of that name exists): %s" % (canon, ", ".join(k for _, k in rows)))
continue
templates[rows[0][1]] = canon
tmpl_src.add(rows[0][1])
if len(rows) > 1:
shared.append("%s <- %s; kept RS name for %s" % (canon, rows[0][1], ", ".join(k for _, k in rows[1:])))
for ptr in sorted({rs.u32(rs.data_row(p)["off"] + 20) for p in tmpl_ptrs if rs.data_row(p)}):
r = rs.func_row(ptr)
if not r:
continue
key = rs_func_key(r)
canon = None
if ptr in cb_votes and r["name"] not in natives:
eptr = best(cb_votes[ptr])
if eptr is None:
ambiguous.append("callback %s: %s" % (key, ", ".join(
"%s x%d" % (em.func_row(p)["name"], c) for p, c in cb_votes[ptr].most_common())))
else:
canon = em_func_canon(eptr, "callbacks")
if canon and func_size(rs, ptr) != func_size(em, eptr):
resized.append("callback %s %d -> %s %d" % (key, func_size(rs, ptr), canon, func_size(em, eptr)))
callbacks[key] = canon or short_cb(r["name"])
cb_src[key] = canon is not None
tags = tag_names(pret)
if sorted(tags) != list(range(len(tags))):
errors.append("ANIM_TAG_ indices are not contiguous")
status = status_names(pret, counts["status"])
shared_names = {}
for key, tname in TABLES[2:]:
lst = [re.match(r"B_ANIM_(\w+)", c) for _, c in em_tables[tname]]
if len(lst) != counts[key] or not all(lst):
errors.append("%s does not line up with %s" % (tname, RS_REF["repo"]))
shared_names[key] = dict(enumerate(m.group(1) for m in lst if m))
if sorted(status) != list(range(counts["status"])):
errors.append("B_ANIM_STATUS_ constants do not cover gBattleAnims_StatusConditions")
rs_only_cb = sorted(k for k, v in callbacks.items() if not cb_src.get(k) and k not in natives)
rs_only_task = sorted(k for k, v in tasks.items() if not task_src.get(k) and k not in natives)
rs_only_tmpl = sorted(k for k in templates if k not in tmpl_src)
print("%s: %d script roots, %d paired scripts (%d drifted), %d templates, %d callbacks, %d tasks" % (
args.game, len(roots), len(seen), drift, len(templates), len(callbacks), len(tasks)))
print("aligned: callbacks=%d tasks=%d templates=%d natives=%d" % (
sum(1 for v in cb_src.values() if v), sum(1 for v in task_src.values() if v),
len(templates) - len(rs_only_tmpl), len(natives)))
print("rs-only callbacks: %s" % (", ".join(rs_only_cb) or "-"))
print("rs-only tasks: %s" % (", ".join(rs_only_task) or "-"))
for s in ambiguous:
print(" ambiguous " + s)
for s in shared:
print(" shared " + s)
for s in resized:
print(" resized " + s)
if errors:
print("ERRORS (%d):" % len(errors))
for e in errors[:80]:
print(" " + e)
sys.exit(1)
lines = [
"-- tools/gen_anim_names.py --game ruby from %s, %s/data/battle_anim_scripts.s aligned with %s/data/battle_anim_scripts.s"
% (info["elf"], info["repo"], RS_REF["repo"]),
"return {",
' game = "rs",',
]
lines += render_map("templates", templates)
lines += render_map("callbacks", callbacks)
lines += render_map("tasks", tasks)
lines += [" rsOnly = {"]
lines += render_list("callbacks", rs_only_cb, " ")
lines += render_list("tasks", rs_only_task, " ")
lines += render_list("templates", rs_only_tmpl, " ")
lines += [" },"]
lines += render_indexed("tagNames", tags)
lines += render_indexed("statusNames", status)
lines += render_indexed("generalNames", shared_names["general"])
lines += render_indexed("specialNames", shared_names["special"])
lines += ["}", ""]
text = "\n".join(lines)
if args.check:
cur = open(out).read() if os.path.exists(out) else ""
if cur != text:
print("STALE %s" % out)
sys.exit(1)
print("OK %s" % out)
return
with open(out, "w") as f:
f.write(text)
print("wrote %s" % out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--game", default="emerald", choices=sorted(GAMES))
@@ -255,6 +565,8 @@ def main():
ap.add_argument("--out")
ap.add_argument("--check", action="store_true")
args = ap.parse_args()
if args.game == "ruby":
return main_rs(args)
info = GAMES[args.game]
pret = args.pret or os.path.join(os.path.dirname(ROOT), info["repo"])
+17 -4
View File
@@ -152,8 +152,16 @@ local function drawSection(S, Kit, x, y, w, h, report, issues)
cy = cy + row + 2 * gap
hint(id)
end
local function choice(id, label, value, options, apply)
cy = cy + Touch.choice(S, Kit, id, label, value, options, cx, cy, inner, apply, nil, issues.fields[id]) + 2 * gap
local function choice(id, label, value, options, apply, help)
cy = cy + Touch.choice(S, Kit, id, label, value, options, cx, cy, inner, apply, help, issues.fields[id]) + 2 * gap
end
local function unownForm(help)
local letter = Ops.unownForm(S, mon)
if letter == nil then return false end
choice("form", "Unown form", letter, Ops.unownFormOptions(S), function(v)
return Ops.setUnownForm(S, mon, v)
end, help)
return true
end
local function props(list)
for _, d in ipairs(list) do
@@ -308,13 +316,18 @@ local function drawSection(S, Kit, x, y, w, h, report, issues)
button("Shiny: " .. (shiny and "ON" or "OFF"), function()
Ops.setShiny(S, mon, not shiny)
end, "warn", "Changes shininess and personality. Review Checks after editing.", "shiny")
unownForm("Keeps nature and shininess. Changes the personality value.")
elseif g == 2 then
local d = P.find(S, "pokerus")
choice("pokerus", "Pokérus", mon.pokerus or 0, Named.property(S, d), function(v)
return Ops.setPokerus(S, mon, v)
end)
text("Gender, shininess and Unown form follow DVs.")
for _, id in ipairs({ "gender", "shiny", "form" }) do hint(id) end
local isUnown = unownForm("Rewrites the middle bits of the DVs. Only I and V can be shiny.")
text(isUnown and "Gender and shininess follow DVs. Picking a form rewrites the middle DV bits."
or "Gender and shininess follow DVs.")
hint("gender")
hint("shiny")
if not isUnown then hint("form") end
end
button("Fix all errors", function()
Ops.fixMonErrors(S, mon)
+139 -11
View File
@@ -126,12 +126,81 @@ function MonOps.recalc(data, mon, gen)
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
end
MonOps.SPECIES_UNOWN_G3 = 201
MonOps.UNOWN_FORMS_G3 = 28
local function spliceLetterBits(half, lo2, hi2)
local bit = require("bit")
return bit.bor(bit.band(half, 0xFCFC), lo2, hi2 * 256)
end
-- include/pokemon.h:364 GET_UNOWN_LETTER, src/pokemon.c:2318 CreateMonWithGenderNatureLetter
function MonOps.unownPid(pid, letter, nature, shiny, tid, sid)
local bit = require("bit")
local PokemonG3 = require("src.core.game3.pokemon")
pid = (tonumber(pid) or 0) % 4294967296
letter = math.floor(tonumber(letter) or 0) % MonOps.UNOWN_FORMS_G3
nature = nature ~= nil and tonumber(nature) or nil
local tsv = bit.bxor((tonumber(tid) or 0) % 65536, (tonumber(sid) or 0) % 65536)
local oldLow, oldHigh = pid % 65536, math.floor(pid / 65536)
local function accept(high, low)
local p = high * 65536 + low
if p == 0 then return nil end
if nature ~= nil and p % 25 ~= nature then return nil end
local isShiny = bit.bxor(tsv, bit.bxor(high, low)) < 8
if shiny ~= nil and isShiny ~= (shiny and true or false) then return nil end
if PokemonG3.unownLetter(p) ~= letter then return nil end
return p
end
local same = accept(oldHigh, oldLow)
if same then return same end
local tsv89 = math.floor(tsv / 256) % 4
for d = 0, 4095 do
local low0 = bit.bxor(oldLow, (d % 64) * 4 + math.floor(d / 64) * 1024)
if shiny then
for b1 = 0, 3 do
local b3 = bit.bxor(b1, tsv89)
for b0 = 0, 3 do
for b2 = 0, 3 do
if (b3 * 64 + b2 * 16 + b1 * 4 + b0) % MonOps.UNOWN_FORMS_G3 == letter then
local low = spliceLetterBits(low0, b0, b1)
local base = bit.bxor(low, tsv)
for k2 = 0, 1 do
local high = bit.bor(bit.band(bit.bxor(base, k2 * 4), 0xFFFC), b2)
local p = accept(high, low)
if p then return p end
end
end
end
end
end
else
for v = letter, 255, MonOps.UNOWN_FORMS_G3 do
local b0, b1 = v % 4, math.floor(v / 4) % 4
local b2, b3 = math.floor(v / 16) % 4, math.floor(v / 64)
local p = accept(spliceLetterBits(oldHigh, b2, b3), spliceLetterBits(low0, b0, b1))
if p then return p end
end
end
end
return nil
end
local function unownReq(mon)
if tonumber(mon.speciesId or mon.species) ~= MonOps.SPECIES_UNOWN_G3 then return nil end
return require("src.core.game3.pokemon").unownLetter(mon.personality)
end
function MonOps.generatePid(species, otId, otSecretId, reqs)
reqs = reqs or {}
local PokemonG3 = require("src.core.game3.pokemon")
local bit = require("bit")
local spId = tonumber(species) or (PokemonG3.speciesFromName and PokemonG3.speciesFromName(tostring(species))) or 1
if spId == MonOps.SPECIES_UNOWN_G3 and reqs.unownLetter ~= nil then
local p = MonOps.unownPid(reqs.basePid, reqs.unownLetter, reqs.nature, reqs.shiny, otId, otSecretId)
if p then return p end
end
otId = bit.band(tonumber(otId) or 0, 0xFFFF)
otSecretId = bit.band(tonumber(otSecretId) or 0, 0xFFFF)
local trainerXor = bit.bxor(otId, otSecretId)
@@ -224,6 +293,8 @@ function MonOps.setNature(data, mon, natureId, gen)
ability = currentAbility,
gender = mon.gender,
shiny = isShiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
MonOps.recalc(data, mon, gen)
end
@@ -241,6 +312,8 @@ function MonOps.setAbility(data, mon, abilitySlot, gen)
ability = abilitySlot,
gender = mon.gender,
shiny = isShiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
mon.abilityNum = abilitySlot
local PokemonG3 = require("src.core.game3.pokemon")
@@ -261,6 +334,8 @@ function MonOps.setGender(data, mon, gender, gen)
ability = currentAbility,
gender = gender,
shiny = isShiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
local PokemonG3 = require("src.core.game3.pokemon")
mon.gender = PokemonG3.gender(mon.speciesId or mon.species, mon.personality)
@@ -281,6 +356,8 @@ function MonOps.setShiny(data, mon, shiny, gen)
ability = currentAbility,
gender = mon.gender,
shiny = shiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
mon.isShiny = shiny
MonOps.recalc(data, mon, gen)
@@ -534,6 +611,20 @@ function MonOps.syncHpDv(dvs)
return dvs
end
local function deriveGen2(data, mon)
local Mon = require("src.battle.gen2.Mon")
local Unown = require("src.core.gen2.Unown")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data and data.pokemon and data.pokemon[mon.species]
if def then
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
end
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
function MonOps.setDv(data, mon, key, value, gen)
if type(mon) ~= "table" then return end
mon.dvs = mon.dvs or { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 }
@@ -552,21 +643,58 @@ function MonOps.setDv(data, mon, key, value, gen)
mon.ivs.spa = math.min(31, (mon.dvs.special or 0) * 2 + 1)
mon.ivs.spd = math.min(31, (mon.dvs.special or 0) * 2 + 1)
elseif gen == 2 or (mon.stats and mon.stats.specialAttack) then
local Mon = require("src.battle.gen2.Mon")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data and data.pokemon and data.pokemon[mon.species]
if def then
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
local Unown = require("src.core.gen2.Unown")
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
deriveGen2(data, mon)
end
MonOps.recalc(data, mon, gen)
end
local DV_KEYS = { "attack", "defense", "speed", "special" }
-- engine/gfx/load_pics.asm:1 GetUnownLetter, engine/gfx/color.asm:8 CheckShininess
function MonOps.unownDvs(dvs, letter, keepShiny)
local Mon = require("src.battle.gen2.Mon")
letter = math.floor(tonumber(letter) or 1)
if letter < 1 or letter > 26 then return nil end
local lo, hi = (letter - 1) * 10, math.min(255, letter * 10 - 1)
local best, bestKey
for p = lo, hi do
local out = {}
for i, k in ipairs(DV_KEYS) do
local d = tonumber(dvs[k]) or 0
local mid = math.floor(p / 4 ^ (4 - i)) % 4
out[k] = d - (math.floor(d / 2) % 4) * 2 + mid * 2
end
local cost = 0
for _, k in ipairs(DV_KEYS) do cost = cost + math.abs(out[k] - (tonumber(dvs[k]) or 0)) end
local key = (Mon.vanillaShiny(out) == (keepShiny and true or false) and 0 or 1000) + cost
if not bestKey or key < bestKey then best, bestKey = out, key end
end
return best
end
function MonOps.setUnownForm(data, mon, letter, gen)
if type(mon) ~= "table" then return nil end
if gen == 3 then
local Summary = require("src.core.game3.summary_data")
local p = MonOps.unownPid(mon.personality, letter, (tonumber(mon.personality) or 0) % 25,
Summary.isShiny(mon), mon.otId, mon.otSecretId)
if not p then return nil end
mon.personality = p
mon.isShiny = nil
MonOps.recalc(data, mon, gen)
return true
end
local Mon = require("src.battle.gen2.Mon")
mon.dvs = mon.dvs or { attack = 0, defense = 0, speed = 0, special = 0 }
local wasShiny = mon.shiny == true or Mon.vanillaShiny(mon.dvs)
local out = MonOps.unownDvs(mon.dvs, letter, wasShiny)
if not out then return nil end
for k, v in pairs(out) do mon.dvs[k] = v end
deriveGen2(data, mon)
MonOps.recalc(data, mon, gen)
return true, wasShiny and not mon.shiny
end
local EV_KEYS = { "hp", "atk", "def", "spe", "spa", "spd" }
function MonOps.setEv(data, mon, key, value, gen)
+48
View File
@@ -2241,6 +2241,54 @@ function Ops.setShiny(S, mon, shiny)
return Ops.mark(S, ("%s %s"):format(mon.species, shiny and "is now shiny!" or "is no longer shiny"))
end
Ops.UNOWN_FORM_NAMES_G3 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?"
function Ops.unownForm(S, mon)
if not mon or mon.isEgg or mon.egg then return nil end
local g = Gen.ofState(S)
if g == 3 and tonumber(mon.speciesId or mon.species) == MonOps.SPECIES_UNOWN_G3 then
return require("src.core.game3.pokemon").unownLetter(mon.personality)
elseif g == 2 then
return require("src.core.gen2.Unown").monLetter(mon)
end
return nil
end
function Ops.unownFormName(S, letter)
if letter == nil then return nil end
if Gen.ofState(S) == 3 then
local i = letter + 1
return Ops.UNOWN_FORM_NAMES_G3:sub(i, i)
end
return require("src.core.gen2.Unown").name(letter)
end
function Ops.unownFormOptions(S)
local out = {}
if Gen.ofState(S) == 3 then
for i = 0, MonOps.UNOWN_FORMS_G3 - 1 do out[#out + 1] = { i, Ops.unownFormName(S, i) } end
else
for i = 1, 26 do out[#out + 1] = { i, Ops.unownFormName(S, i) } end
end
return out
end
function Ops.setUnownForm(S, mon, letter)
local current = Ops.unownForm(S, mon)
if current == nil then return Ops.say(S, "Only an Unown has a form") end
letter = math.floor(tonumber(letter) or -1)
local name = Ops.unownFormName(S, letter)
if not name or name == "" then return Ops.say(S, "Unknown Unown form") end
if letter == current then return Ops.say(S, ("UNOWN is already %s"):format(name)) end
local g = Gen.ofState(S)
local ok, lostShiny = MonOps.setUnownForm(S.data, mon, letter, g)
if not ok then return Ops.say(S, ("UNOWN %s cannot keep this nature and shininess"):format(name)) end
if lostShiny then
return Ops.mark(S, ("UNOWN is now %s (no longer shiny: only I and V can be shiny)"):format(name))
end
return Ops.mark(S, ("UNOWN is now %s"):format(name))
end
function Ops.setPokerus(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
+22 -8
View File
@@ -11,17 +11,28 @@ local MonEditor = {}
-- species, cached for the process: the old panel called newImage every frame,
-- which re-decoded a PNG sixty times a second.
local spriteCache = {}
function MonEditor.sprite(S, species)
function MonEditor.spriteKey(S, species, mon)
if not species then return nil end
if spriteCache[species] ~= nil then return spriteCache[species] or nil end
local letter = mon and require("Ops").unownForm(S, mon)
if letter == nil then return species end
return tostring(species) .. ":" .. letter
end
function MonEditor.sprite(S, species, mon)
if not species then return nil end
local key = MonEditor.spriteKey(S, species, mon)
local letter = key ~= species and require("Ops").unownForm(S, mon) or nil
if spriteCache[key] ~= nil then return spriteCache[key] or nil end
if Gen.ofState(S) == 3 then
local okP, Pokemon = pcall(require, "src.core.game3.pokemon")
if okP and Pokemon then
local spId = tonumber(species) or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(species)))
if spId then
local pic = (Pokemon.frontPic and Pokemon.frontPic(spId)) or (Pokemon.icon and Pokemon.icon(spId))
local picId = letter ~= nil and Pokemon.picSpecies(spId, mon.personality) or spId
local pic = (Pokemon.frontPic and Pokemon.frontPic(picId)) or (Pokemon.icon and Pokemon.icon(picId))
if pic and pic.image then
spriteCache[species] = pic.image
spriteCache[key] = pic.image
return pic.image
end
end
@@ -29,19 +40,22 @@ function MonEditor.sprite(S, species)
end
local def = S.data and S.data.pokemon and (S.data.pokemon[species] or (type(species) == "number" and S.data.pokemon[species]))
local path = def and def.spriteFront
if letter ~= nil and Gen.ofState(S) == 2 then
path = require("src.core.gen2.Unown").formSprite(S.data.pokemon, letter) or path
end
if not path or not love.graphics.newImage then
spriteCache[species] = false
spriteCache[key] = false
return nil
end
local ok, img = pcall(love.graphics.newImage, path)
spriteCache[species] = ok and img or false
spriteCache[key] = ok and img or false
return ok and img or nil
end
-- Draw a species sprite fitted into a box, or a dashed placeholder when the
-- cache has no art for it (a modded species, or a headless run).
function MonEditor.drawSprite(S, Kit, species, x, y, size)
local img = MonEditor.sprite(S, species)
function MonEditor.drawSprite(S, Kit, species, x, y, size, mon)
local img = MonEditor.sprite(S, species, mon)
if img and love.graphics.draw and img.getDimensions then
local iw, ih = img:getDimensions()
if iw > 0 and ih > 0 then
+1 -1
View File
@@ -84,7 +84,7 @@ local function drawRoster(S, Kit, x, y, listW, h)
local rpad = 12 * s
local icon = 44 * s
MonEditor.drawSprite(S, Kit, mon.species, cx + rpad, ry + (rowH - icon) / 2, icon)
MonEditor.drawSprite(S, Kit, mon.species, cx + rpad, ry + (rowH - icon) / 2, icon, mon)
-- right cluster first, so the name knows how much room it has left
local rightW = 52 * s