mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-08 09:42:55 -04:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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?"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+860
-807
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user