diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index 6abd5646..0d04923f 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -220,6 +220,7 @@ return { rows[#rows + 1] = { "text_sound", "Get_Key_Item" } rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } } rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 } + rows[#rows + 1] = { "set_catch_rate", 1, 0xA3 } -- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009) rows[#rows + 1] = { "set_field", "pikachuInBall", true } rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" } diff --git a/docs/new-features.md b/docs/new-features.md index a781bea4..00b9fc77 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -23,6 +23,10 @@ Features intentionally added to the games themselves, beyond what the original c * **Button remapping** on every game: OPTION -> CONTROLS (keys, pads, L/R on FireRed/LeafGreen/Emerald, fast-forward buttons) * **Full controller support**: both thumbsticks move and navigate menus, and stick directions, triggers, stick clicks and extra SDL buttons can be bound in CONTROLS * **Duplicate a save slot** from the launcher, a one-tap independent copy for backups or trimming a save before export +* **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 diff --git a/docs/updater.md b/docs/updater.md index de5e3848..b045f272 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -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 diff --git a/main.lua b/main.lua index 360f3d06..5e220e44 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/src/box/Items.lua b/src/box/Items.lua new file mode 100644 index 00000000..9d74f6f9 --- /dev/null +++ b/src/box/Items.lua @@ -0,0 +1,261 @@ +local Store = require("src.box.Store") +local Catalog = require("src.box.Catalog") +local GameVersion = require("src.core.GameVersion") +local Items = { MAX = 999 } + +function Items.family(generation) return generation == 3 and "gen3" or "gen12" end + +local function placeholder(name) + return type(name) ~= "string" or name == "" or name:match("^%?+$") ~= nil or name == "TERU-SAMA" +end + +local function nameOf(def, id) + if type(def) == "table" then return def.name or tostring(id) end + return def or tostring(id) +end + +local function technical(name) return tostring(name):upper():match("^[TH]M%s*%d") ~= nil end + +function Items.key(generation, def, id) + local name = nameOf(def, id) + local key = tostring(name):upper():gsub("[%s_%p]", "") + if generation < 3 and technical(name) then key = "G" .. generation .. ":" .. key end + return key +end + +function Items.isMail(def, id) return tostring(nameOf(def, id)):upper():find("MAIL", 1, true) ~= nil end + +function Items.depositable(generation, data, id) + local def = data.items and data.items[id] + if type(def) ~= "table" or placeholder(def.name) or id == 0 then return false end + if generation == 1 then + return not def.keyItem and not tostring(id):find("BADGE", 1, true) + elseif generation == 2 then + return def.pocket ~= "KEY_ITEM" and def.canToss ~= false + end + return def.pocket ~= "KEY_ITEMS" and (tonumber(def.importance) or 0) == 0 +end + +local function withGen3(version, fn) + return require("src.box.GameContext").withItems(version, fn) +end + +function Items.bag(save, version) + local generation, data = GameVersion.generation(version), Catalog.get(version) + local counts = {} + if generation == 3 then + for _, slots in pairs(type(save.bag) == "table" and save.bag.pockets or {}) do + for _, slot in ipairs(slots) do + local id = tonumber(slot.id) or slot.id + if id and (tonumber(slot.qty) or 0) > 0 then counts[id] = (counts[id] or 0) + slot.qty end + end + end + else + for id, count in pairs(save.inventory or {}) do + if type(count) == "number" and count > 0 then counts[id] = count end + end + end + local rows = {} + for id, count in pairs(counts) do + if Items.depositable(generation, data, id) then + rows[#rows + 1] = { id = id, count = count, name = nameOf(data.items[id], id) } + end + end + table.sort(rows, function(a, b) return a.name < b.name end) + return rows +end + +function Items.locker(state, family) + local rows = {} + for key, row in pairs(state.items and state.items[family] or {}) do + rows[#rows + 1] = { key = key, name = row.name, count = row.count } + end + table.sort(rows, function(a, b) return a.name < b.name end) + return rows +end + +function Items.find(version, key) + local generation, data = GameVersion.generation(version), Catalog.get(version) + for id, def in pairs(data.items or {}) do + if type(def) == "table" and not placeholder(def.name) and Items.key(generation, def, id) == key + and Items.depositable(generation, data, id) then return id, def end + end +end + +local function add(state, family, key, name, count) + state.items = state.items or {} + state.items[family] = state.items[family] or {} + local row = state.items[family][key] + local total = (row and row.count or 0) + count + if total > Items.MAX then return nil, "The locker holds at most " .. Items.MAX .. " of each item." end + state.items[family][key] = { name = row and row.name or name, count = total } + return true +end + +local function take(state, family, key, count) + local row = state.items and state.items[family] and state.items[family][key] + if not row or row.count < count then return nil, "The locker does not have enough of that item." end + row.count = row.count - count + if row.count == 0 then state.items[family][key] = nil end + return true +end + +local function bagRemove(save, version, id, count) + if GameVersion.generation(version) == 3 then + return withGen3(version, function() + local Bag = require("src.core.game3.bag") + if type(save.bag) ~= "table" or not Bag.has(save.bag, id, count) then return nil end + return Bag.remove(save.bag, id, count) or nil + end) + end + if (save.inventory and save.inventory[id] or 0) < count then return nil end + require("src.inventory.Bag").remove(save, id, count, Catalog.get(version)) + return true +end + +local function bagAdd(save, version, id, count) + if GameVersion.generation(version) == 3 then + return withGen3(version, function() + local Bag = require("src.core.game3.bag") + save.bag = type(save.bag) == "table" and save.bag or Bag.new() + if not Bag.canAdd(save.bag, id, count) then return nil end + local ok, placed = Bag.add(save.bag, id, count) + return ok and placed == count or nil + end) + end + return require("src.inventory.Bag").add(save, id, count, Catalog.get(version)) or nil +end + +function Items.deposit(state, save, version, id, count) + local generation, data = GameVersion.generation(version), Catalog.get(version) + count = math.floor(tonumber(count) or 0) + if count < 1 then return nil, "Choose how many to deposit." end + if not data.ready then return nil, "Import this game's ROM first." end + if not Items.depositable(generation, data, id) then return nil, "Key items and badges stay in the game." end + local nextState, nextSave = Store.copy(state), Store.copy(save) + if not bagRemove(nextSave, version, id, count) then return nil, "The bag does not have that many." end + local ok, why = add(nextState, Items.family(generation), Items.key(generation, data.items[id], id), + nameOf(data.items[id], id), count) + if not ok then return nil, why end + nextState.revision = state.revision + 1 + return nextState, nextSave +end + +function Items.withdraw(state, save, version, key, count) + local generation = GameVersion.generation(version) + count = math.floor(tonumber(count) or 0) + if count < 1 then return nil, "Choose how many to withdraw." end + if not Catalog.get(version).ready then return nil, "Import this game's ROM first." end + local id = Items.find(version, key) + if id == nil then return nil, "This game has no item by that name." end + local nextState, nextSave = Store.copy(state), Store.copy(save) + local ok, why = take(nextState, Items.family(generation), key, count) + if not ok then return nil, why end + if not bagAdd(nextSave, version, id, count) then return nil, "That bag pocket is full." end + nextState.revision = state.revision + 1 + return nextState, nextSave +end + +local function holder(state, ref) + local entry = Store.at(state, ref and ref.box, ref and ref.slot) + if not entry then return nil, "Choose a Pokémon in Box storage." end + if entry.generation == 1 then return nil, "Gen 1 Pokémon cannot hold items." end + if entry.mon.isEgg then return nil, "Eggs cannot hold items." end + return entry +end + +local function refresh(entry) + entry.display = Catalog.describe(entry.version, entry.mon) + entry.gciRaw, entry.gciOriginal = nil, nil +end + +local function heldId(entry) + local item = entry.mon.heldItem or entry.mon.item + if item == 0 then return nil end + return item +end + +local function stash(state, entry) + local item = heldId(entry) + if item == nil then return true end + local data = Catalog.get(entry.version) + if Items.isMail(data.items[item], item) then return nil, "Mail stays with its Pokémon. Take it off in the game." end + if not Catalog.knownItem(data, item) then return nil, "The held item is unknown." end + local ok, why = add(state, Items.family(entry.generation), Items.key(entry.generation, data.items[item], item), + nameOf(data.items[item], item), 1) + if not ok then return nil, why end + if entry.generation == 3 then entry.mon.heldItem = 0 else entry.mon.item = nil end + return true +end + +function Items.give(state, ref, key) + local nextState = Store.copy(state) + local entry, why = holder(nextState, ref) + if not entry then return nil, why end + local id, def = Items.find(entry.version, key) + if id == nil then return nil, "This Pokémon's game has no item by that name." end + if Items.isMail(def, id) then return nil, "Mail needs a message. Give it in the game." end + local ok, err = stash(nextState, entry) + if not ok then return nil, err end + ok, err = take(nextState, Items.family(entry.generation), key, 1) + if not ok then return nil, err end + if entry.generation == 3 then entry.mon.heldItem = id else entry.mon.item = id end + refresh(entry) + nextState.revision = state.revision + 1 + return nextState +end + +function Items.takeHeld(state, ref) + local nextState = Store.copy(state) + local entry, why = holder(nextState, ref) + if not entry then return nil, why end + if heldId(entry) == nil then return nil, "This Pokémon is not holding anything." end + local ok, err = stash(nextState, entry) + if not ok then return nil, err end + refresh(entry) + nextState.revision = state.revision + 1 + return nextState +end + +function Items.balls(state) + local rows = {} + for _, row in ipairs(Items.locker(state, "gen3")) do + if row.name:upper():find("BALL", 1, true) then rows[#rows + 1] = row end + end + return rows +end + +function Items.swapBall(state, ref, key) + local nextState = Store.copy(state) + local entry = Store.at(nextState, ref and ref.box, ref and ref.slot) + if not entry then return nil, "Choose a Pokémon in Box storage." end + if entry.generation ~= 3 then return nil, "Only Gen 3 Pokémon remember their ball." end + if entry.mon.isEgg then return nil, "Eggs have no ball yet." end + local id, def = Items.find(entry.version, key) + if id == nil or type(def) ~= "table" or def.pocket ~= "POKE_BALLS" then return nil, "Choose a ball." end + if tonumber(entry.mon.pokeball) == id then return nil, "It is already in that ball." end + local ok, why = take(nextState, "gen3", key, 1) + if not ok then return nil, why end + entry.mon.pokeball = id + refresh(entry) + nextState.revision = state.revision + 1 + return nextState +end + +function Items.validate(items) + if items == nil then return true end + if type(items) ~= "table" then return false end + for family, rows in pairs(items) do + if (family ~= "gen12" and family ~= "gen3") or type(rows) ~= "table" then return false end + for key, row in pairs(rows) do + if type(key) ~= "string" or #key == 0 or #key > 64 or type(row) ~= "table" + or type(row.name) ~= "string" or #row.name == 0 or #row.name > 64 + or type(row.count) ~= "number" or row.count % 1 ~= 0 or row.count < 1 or row.count > Items.MAX then + return false + end + end + end + return true +end + +return Items diff --git a/src/box/Metadata.lua b/src/box/Metadata.lua index bb4c8bfc..26b85f15 100644 --- a/src/box/Metadata.lua +++ b/src/box/Metadata.lua @@ -2,6 +2,7 @@ local Metadata = {} local NATURES = { "Hardy", "Lonely", "Brave", "Adamant", "Naughty", "Bold", "Docile", "Relaxed", "Impish", "Lax", "Timid", "Hasty", "Serious", "Jolly", "Naive", "Modest", "Mild", "Quiet", "Bashful", "Rash", "Calm", "Gentle", "Sassy", "Careful", "Quirky" } +Metadata.NATURES = NATURES local function number(value) return tonumber(value) end local function name(records, key) local value = records and records[key] diff --git a/src/box/Migration.lua b/src/box/Migration.lua index 63e17af2..b87f6ed5 100644 --- a/src/box/Migration.lua +++ b/src/box/Migration.lua @@ -58,21 +58,21 @@ local function gender2(def,dvs) return gender=="unknown" and "Genderless" or gender=="male" and "Male" or "Female" end local pidCache={} -local function pidFor(mon,def,national,dvs,shiny) +local function pidFor(mon,def,national,dvs,shiny,nature) local tid=clamp(mon.otId,0,65535) local wantedGender=gender2({genderRatio=def.genderRatio},dvs) local letter=national==201 and Unown.letterFromDVs(dvs)-1 - local key=table.concat({tid,tostring(def.genderRatio),wantedGender,tostring(letter),tostring(shiny)},"|") + local key=table.concat({tid,tostring(def.genderRatio),wantedGender,tostring(letter),tostring(shiny),nature},"|") local hit=pidCache[key] if hit~=nil then return hit or nil end local found=false for highVariant=0,31 do local first,step=0,1 - if not shiny then first,step=(-(0x4000+highVariant)*65536)%25,25 end + if not shiny then first,step=(nature-(0x4000+highVariant)*65536)%25,25 end for low=first,65535,step do local high=shiny and bit.bxor(low,tid,highVariant%8) or (0x4000+highVariant) local pid=high*65536+low - if pid%25==0 and gender3(def.genderRatio,pid)==wantedGender + if pid%25==nature and gender3(def.genderRatio,pid)==wantedGender and (bit.bxor(tid,high,low)<8)==shiny and (not letter or Pokemon.unownLetter(pid)==letter) then found=pid;break end end @@ -81,6 +81,30 @@ local function pidFor(mon,def,national,dvs,shiny) pidCache[key]=found return found or nil end +local function roller(...) + local state=0x2545F491 + for _,value in ipairs({...}) do + state=bit.bxor(state,bit.tobit(math.floor(tonumber(value) or 0)%4294967296)) + state=bit.bxor(state,bit.lshift(state,13));state=bit.bxor(state,bit.rshift(state,17));state=bit.bxor(state,bit.lshift(state,5)) + end + if state==0 then state=1 end + return function(n) + state=bit.bxor(state,bit.lshift(state,13));state=bit.bxor(state,bit.rshift(state,17));state=bit.bxor(state,bit.lshift(state,5)) + return (state%4294967296)%n + end +end +Migration.PERFECT={[151]=5,[251]=5} +local IV_LABELS={hp="HP",atk="Attack",def="Defense",spe="Speed",spa="Sp. Atk",spd="Sp. Def"} +local function bankIvs(id,national) + local roll=roller(id,national) + local keys={"hp","atk","def","spe","spa","spd"} + for i=#keys,2,-1 do local j=roll(i)+1;keys[i],keys[j]=keys[j],keys[i] end + local ivs,perfect={}, {} + for i,key in ipairs(keys) do + if i<=(Migration.PERFECT[national] or 3) then ivs[key]=31;perfect[#perfect+1]=IV_LABELS[key] else ivs[key]=roll(32) end + end + return ivs,perfect +end local function dvsFor(mon,def,national,generation) local iv=mon.ivs or {} local target={attack=math.floor((iv.atk or 0)/2),defense=math.floor((iv.def or 0)/2), @@ -140,10 +164,12 @@ function Migration.convert(entry,version) local item=mon.heldItem or mon.item local targetItem if item and item~=0 then - if g2==1 then return nil,"Gen 1 cannot represent held items. Remove this item in its game first." end + if g2==1 and g1==3 then return nil,"Gen 1 cannot represent held items. Remove this item in its game first." end if not from.items[item] then return nil,"The held item is unknown in the source game." end - targetItem=mapped(to.items,label(from.items[item],item)) - if not targetItem then return nil,"The destination cannot represent this held item." end + if g2~=1 then + targetItem=mapped(to.items,label(from.items[item],item)) + if not targetItem then return nil,"The destination cannot represent this held item." end + end end local moves,pps,ups={},{},{} for _,move in ipairs(mon.moves or {}) do @@ -167,6 +193,8 @@ function Migration.convert(entry,version) pokerus=g2>=2 and clamp(mon.pokerus,0,255) or nil} local lines={"Original native record will be archived. Restore replaces this converted record; it does not create a duplicate."} local function note(value) lines[#lines+1]=value end + lines.facts={} + local function fact(name,value,tone) lines.facts[#lines.facts+1]={label=name,value=tostring(value),tone=tone} end if g1<3 then if type(mon.dvs)~="table" then return nil,"The source DVs are missing." end for _,key in ipairs({"attack","defense","speed","special"}) do @@ -174,34 +202,46 @@ function Migration.convert(entry,version) end elseif type(mon.personality)~="number" or type(mon.ivs)~="table" then return nil,"The native personality or IVs are missing." end if g2==3 then - local pid=pidFor(mon,def,national,mon.dvs,Mon.vanillaShiny(mon.dvs)) + local sourceExp=math.floor(tonumber(mon.exp or mon.experience) or expAt(from,mon.species,d.level)) + local nature=sourceExp%25 + local pid=pidFor(mon,def,national,mon.dvs,Mon.vanillaShiny(mon.dvs),nature) if not pid then return nil,"No personality can preserve this Pokémon's gender, shininess and form." end - out.personality,out.otSecretId,out.nature=pid,0,0 - out.ivs,out.evs={},{} - for _,pair in ipairs(KEYS) do out.ivs[pair[1]]=clamp(mon.dvs[pair[2]],0,15)*2+1 end - out.ivs.hp=Mon.hpDV(mon.dvs)*2+1 - local raw,total={},0 - for _,pair in ipairs(KEYS) do - local value=math.min(252,math.floor(math.sqrt(clamp((mon.statExp or {})[pair[2]],0,65535))/4)*4) - raw[pair[1]],total=value,total+value - end - local used=0 - for _,pair in ipairs(KEYS) do - local value=total>510 and math.floor(raw[pair[1]]*510/total/4)*4 or raw[pair[1]] - out.evs[pair[1]],used=value,used+value - end + out.personality,out.otSecretId,out.nature=pid,0,nature + local perfect + out.ivs,perfect=bankIvs(entry.id,national) + out.evs={} + for _,pair in ipairs(KEYS) do out.evs[pair[1]]=0 end out.friendship=mon.isEgg and clamp(mon.happiness or mon.eggCycles,0,255) or clamp(mon.happiness or 70,0,255) out.eggCycles=mon.isEgg and out.friendship or nil out.heldItem,out.pp=targetItem or 0,pps out.ppBonusesPacked=0;for i,value in ipairs(ups) do out.ppBonusesPacked=out.ppBonusesPacked+value*4^(i-1) end out.markings=entry.markings or 0 - out.abilityNum,out.language,out.otGender=0,2,mon.caughtGender or 0 + local pair=to.abilities and to.abilities[species] or {} + out.abilityNum=(pair[2] or 0)~=0 and 1 or 0 + out.language,out.otGender=2,mon.caughtGender or 0 + -- include/constants/region_map_sections.h:229 out.metLocation,out.metLevel,out.metGame,out.pokeball=255,out.level,MET_GAME[version],4 - out.modernFatefulEncounter=false - note("DVs → odd IVs (2 × DV + 1); HP uses the derived HP DV.") - note("Stat Exp → EVs with the same effort contribution, proportionally capped: "..used.." / 510 EVs.") - note("New personality: Hardy nature, first ability, Secret ID 0. DV shininess, gender and Unown letter are preserved.") - note("Origin: destination game, Poké Ball, met level "..out.level..", location 255. Earlier met data remains in the archive.") + -- src/battle_util.c:3898 + out.modernFatefulEncounter=national==151 + local ability=pair[out.abilityNum+1] or pair[1] + local NATURES=require("src.box.Metadata").NATURES + note("Nature from EXP: "..sourceExp.." % 25 = "..nature..", "..NATURES[nature+1]..".") + lines.natureExp,lines.ivs,lines.perfect=sourceExp,Store.copy(out.ivs),perfect + local up,down=math.floor(nature/5)+1,nature%5+1 + local short={"Atk","Def","Spe","SpA","SpD"} + fact("Nature",NATURES[nature+1]..(up~=down and " +"..short[up].." -"..short[down] or ""),"new") + fact("Ability",ability and to.abilityNames and label(to.abilityNames[ability],ability) or "First slot","new") + fact("EVs","Reset to 0","lost") + fact("Ball","Poké Ball","new") + fact("Shiny",Mon.vanillaShiny(mon.dvs) and "Yes" or "No","kept") + if out.modernFatefulEncounter then fact("Mew","Obeys in battle","new") end + note("IVs: "..table.concat(perfect,", ").." set to 31; the rest rolled 0 to 31.") + note("EVs reset to 0.") + note("Ability: "..tostring(ability and to.abilityNames and label(to.abilityNames[ability],ability) or "first slot") + ..(out.abilityNum==1 and " (second slot)." or ".")) + note("DV shininess, gender and Unown letter are preserved. Secret ID 0.") + note("Origin: Poké Ball, met level "..out.level..", fateful encounter location." + ..(out.modernFatefulEncounter and " Fateful encounter flag set, so Mew obeys." or "")) elseif g1==3 then local input=Store.copy(mon);input._boxVersion=entry.version local dvs,why=dvsFor(input,def,national,g2) @@ -217,6 +257,9 @@ function Migration.convert(entry,version) note(("DVs: Attack %d · Defense %d · Speed %d · Special %d · HP %d"):format(dvs.attack,dvs.defense,dvs.speed,dvs.special,dvs.hp)) note("EVs → Stat Exp; Special uses the greater Special Attack/Defense effort. HP DV is derived.") note("Nature, ability, Secret ID, ball, ribbons, contests and Gen 3 origin data remain only in the archive.") + lines.dvs={hp=dvs.hp,atk=dvs.attack,def=dvs.defense,spe=dvs.speed,spc=dvs.special} + fact("Stat Exp","From EVs","new") + fact("Archived","Nature, ability, ball, ribbons","lost") else local prepared=Store.copy(mon);prepared.species=mon.species local Convert=require("src.online.Convert") @@ -226,7 +269,23 @@ function Migration.convert(entry,version) out.happiness=g2==2 and 70 or nil out.caughtLevel=g2==2 and out.level or nil note("DVs and Stat Exp are preserved; stats are recalculated using the destination's base stats.") - if g2==2 then note("Friendship starts at 70; caught level is the current level.") end + fact("DVs","Kept","kept") + if g2==2 then + out.item=native.item + note("Friendship starts at 70; caught level is the current level.") + local rate=mon.catchRate or (from.pokemon[mon.species] or {}).catchRate or 0 + note(native.item and ("Holds "..label(to.items[native.item],native.item).." from catch rate "..rate..".") + or "Catch rate "..rate.." gives no held item.") + fact("Held item",native.item and label(to.items[native.item],native.item) or "None",native.item and "new" or nil) + fact("Friendship","70","new") + else + out.catchRate=native.catchRate + if item and item~=0 then + note("Held "..label(from.items[item],item).." kept as catch rate "..native.catchRate..". It returns as the held item in Gen 2.") + fact("Held item",label(from.items[item],item).." (kept for Gen 2)","kept") + end + fact("Archived","Friendship, Pokérus, met data","lost") + end end local oldExp=tonumber(mon.exp or mon.experience) local oldBase=expAt(from,mon.species,out.level) @@ -246,16 +305,27 @@ function Migration.convert(entry,version) if g2==2 then out.gender=Mon.vanillaGender(def,out.dvs);out.shiny=Mon.vanillaShiny(out.dvs) if national==201 then out.unownLetter=Unown.letterFromDVs(out.dvs) end - elseif g2==1 then out.catchRate=def.catchRate end + elseif g2==1 and out.catchRate==nil then out.catchRate=def.catchRate end local display=Catalog.describe(version,out) out.stats=g2<3 and Store.copy(display.stats) or nil out.maxHp,out.hp=display.hp,display.hp if g2==3 then for _,key in ipairs({"attack","defense","speed","spAtk","spDef"}) do out[key]=display[key] end end note("Stats recalculated; boxed HP restored to full. Status: "..tostring(mon.status or "healthy").." → "..tostring(out.status or "healthy")..".") + fact("Level",out.level,"kept") + fact("EXP",oldExp and math.floor(oldExp)~=experience and (math.floor(oldExp).." → "..experience) or experience,oldExp and math.floor(oldExp)~=experience and "new" or "kept") + if targetItem and g2>1 and g1==3 or targetItem and g1==2 and g2==3 then fact("Held item",label(to.items[targetItem],targetItem),"kept") end + fact("HP","Restored to full","new") note("Moves and held item mapped by exact name; current PP is capped to destination maximum, PP Ups retained.") if g2==1 then note("Friendship, Pokérus, gender and met data remain only in the archive.") end return out,lines end +function Migration.natureSteps(exp) + local NATURES=require("src.box.Metadata").NATURES + local rows={} + for n=0,24 do rows[#rows+1]={nature=NATURES[n+1],add=(n-math.floor(exp))%25} end + table.sort(rows,function(a,b) return a.add 20 then return nil, "Auto Box presets are invalid." end diff --git a/src/box/Transaction.lua b/src/box/Transaction.lua index 6c1b2d34..0caac15d 100644 --- a/src/box/Transaction.lua +++ b/src/box/Transaction.lua @@ -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." diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 1bfa58cb..d75edb1d 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -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: diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4cd831f1..10fd86ce 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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 diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 96abe3bb..a19cc260 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -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 diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 3f1b47d7..f745f0e0 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index acf63848..0798aedf 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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 diff --git a/src/core/game3/battle/anim_port/rs_sound_tasks.lua b/src/core/game3/battle/anim_port/rs_sound_tasks.lua index 27f0f2a0..d7283228 100644 --- a/src/core/game3/battle/anim_port/rs_sound_tasks.lua +++ b/src/core/game3/battle/anim_port/rs_sound_tasks.lua @@ -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 diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index e4498081..84432611 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -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) diff --git a/src/core/game3/battle_transition.lua b/src/core/game3/battle_transition.lua index c909b4bb..d576732c 100644 --- a/src/core/game3/battle_transition.lua +++ b/src/core/game3/battle_transition.lua @@ -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 diff --git a/src/core/game3/display.lua b/src/core/game3/display.lua index dcd57252..956b26fe 100644 --- a/src/core/game3/display.lua +++ b/src/core/game3/display.lua @@ -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) diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index c87754f8..cc93c67d 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -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) diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index f55fd879..cc2efa57 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -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 diff --git a/src/core/game3/profiles/emerald/field.lua b/src/core/game3/profiles/emerald/field.lua index fb472714..5e384a81 100644 --- a/src/core/game3/profiles/emerald/field.lua +++ b/src/core/game3/profiles/emerald/field.lua @@ -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 diff --git a/src/core/game3/profiles/rs/field.lua b/src/core/game3/profiles/rs/field.lua index 1eb44320..82e83681 100644 --- a/src/core/game3/profiles/rs/field.lua +++ b/src/core/game3/profiles/rs/field.lua @@ -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, diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua index 52349291..d342a747 100644 --- a/src/core/game3/step_events.lua +++ b/src/core/game3/step_events.lua @@ -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 diff --git a/src/import/BoxOrganizer.lua b/src/import/BoxOrganizer.lua index 44dc879f..f33683f0 100644 --- a/src/import/BoxOrganizer.lua +++ b/src/import/BoxOrganizer.lua @@ -133,7 +133,7 @@ local function previewActions(imp,s,o,layout,report,why,x,y,w,m,api,compact) api.button(imp,x,y,(w-gap)/2,h,"organize-preview","Preview",function() local source=s.sources[s.sourceIndex] local p,err=s.service:previewOrganize(o.target,source,o.effective or o.config) - o.preview,s.notice,o.previewBox=p,err,1 + o.preview,s.notice,s.noticeKind,o.previewBox=p,err,"error",1 if p then local moves={} for _,row in ipairs(p.report.assignments) do @@ -243,7 +243,7 @@ function Panel.draw(imp,s,x,y,w,m,api,mode) end local profileIndex=findProfile(profiles,mode,o.profile) local function stalePreset() - s.notice,o.profile="That preset changed. Choose it again.",nil + s.notice,s.noticeKind,o.profile="That preset changed. Choose it again.","error",nil end local function savePreset(update) require("src.import.BoxPrompt").open(imp,"Preset name",update and profiles[profileIndex] and profiles[profileIndex].name or "",64,function(name) diff --git a/src/import/BoxPanel.lua b/src/import/BoxPanel.lua index c3e12e0c..f1eeb061 100644 --- a/src/import/BoxPanel.lua +++ b/src/import/BoxPanel.lua @@ -20,7 +20,7 @@ local function readPC(s) local source = s.sources[s.sourceIndex] if not source or not s.service then return end local save, why, body = s.service:read(source) - if not save then s.notice = why; return end + if not save then s.notice, s.noticeKind = why, "error"; return end s.pcSave, s.pcBody = save, body local generation = GameVersion.generation(source.version) for _, row in ipairs(Records.candidates(save, generation)) do @@ -37,7 +37,8 @@ function BoxPanel.refresh(imp) Catalog.reset() local previous = imp._boxState or {} local service, why = Service.open() - local s = { service = service, notice = why or (service and service.recoveryNotice), sources = Service.sources(), + local s = { service = service, notice = why or (service and service.recoveryNotice), noticeKind = why and "error" or "info", + sources = Service.sources(), sourceIndex = 1, box = previous.box or 1, pcBox = previous.pcBox or 1, query = previous.query or "", sort = previous.sort or "slot", selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, sourcePage = 1, filterQuery = previous.filterQuery, @@ -87,7 +88,7 @@ local function checkDisk(s, dt) s._diskSig, s._diskBody = sig, s.service.body local stale = fs.read(Store.PATH) ~= s.service.body if stale and not s.diskStale then - s.notice, s._latestBackup = "Box changed outside this screen. Reload before making changes.", nil + s.notice, s.noticeKind, s.noticeSticky, s._latestBackup = "Box changed outside this screen. Reload before making changes.", "error", true, nil end s.diskStale = stale or nil end @@ -126,7 +127,7 @@ local function perform(imp, operation, text) if not called then why, ok = ok, nil end s._latestBackup = nil if not ok then - s.notice = tostring(why or "Box operation failed.") + s.notice, s.noticeKind = tostring(why or "Box operation failed."), "error" local beforeBox, beforePC = s.service and s.service.body, s.pcBody if s.service then local recovered, err = Service.open(s.service.fs) @@ -142,7 +143,7 @@ local function perform(imp, operation, text) end return false end - s.notice = text + s.notice, s.noticeKind = text, "ok" require("src.box.Cry").stop() Animation.release(s.animation) s.animation, s.animationEntry = nil, nil @@ -159,7 +160,7 @@ end function BoxPanel.importGCI(imp, bytes, why) local s = state(imp) - if not bytes then s.notice = why or "The file could not be read."; return false end + if not bytes then s.notice, s.noticeKind = why or "The file could not be read.", "error"; return false end if not s.service then return false end return perform(imp, function() return s.service:importGCI(bytes) end, "Native Pokémon Box save imported.") end @@ -701,7 +702,7 @@ local function drawBody(imp, s, x, y, w, _, m) Kit.textRight("small", ("%d / %d"):format(Store.count(s.service.state), Store.BOXES * Store.SLOTS), x + w, y + 3, PAL.muted) end y = y + Kit.textHeight("button") + gap - if s.notice then y = y + Kit.textWrapped("small", Strings(s.notice), x, y, w, s.service and PAL.muted or PAL.red, 3) + gap end + if s.notice and not s.service then y = y + Kit.textWrapped("small", Strings(s.notice), x, y, w, PAL.red, 3) + gap end local queryAffectsPage = not s.summary and (not s.toolsPage or s.toolsPage=="List" or s.toolsPage=="Filters" or s.toolsPage=="Showcase" or s.toolsPage=="Arrange" and s.arrangeSection=="Move") @@ -815,15 +816,15 @@ local function drawBody(imp, s, x, y, w, _, m) if s[movingKey] then return end s[movingKey] = selected(selection); s.query = "" if kind == "box" then s.sort = "slot" end - s.notice = kind == "box" and "Pick the first destination slot. Occupied slots swap back." - or "Pick a slot. The Pokémon fill that slot and the next free ones." + s.notice, s.noticeKind = kind == "box" and "Pick the first destination slot. Occupied slots swap back." + or "Pick a slot. The Pokémon fill that slot and the next free ones.", "info" end, moving ~= nil) local used = moveW + gap if moving then local cancelW = math.min(110 * m.s, selectorW * .2) LV().btn(imp, selectorX + used, selectorY, cancelW, rowH, "box-move-cancel", Strings("Cancel"), { kind = "danger", font = "small", icon = "x", - action = function() s[movingKey] = nil; s.notice = nil end, + action = function() s[movingKey] = nil; UI.toast(imp, nil) end, }) used = used + cancelW + gap end @@ -849,6 +850,11 @@ local function drawBody(imp, s, x, y, w, _, m) end function BoxPanel.draw(imp, x, y, w, h, m) + local current = state(imp) + if current.notice and current.service then + UI.toast(imp, current.notice, current.noticeKind, current.noticeSticky) + current.notice, current.noticeKind, current.noticeSticky = nil, nil, nil + end local f=imp._boxOrganizerFooter if f and imp._boxState==f.s and f.s.toolsPage=="Arrange" then Kit.occlude(f.x,f.y-10*m.s,f.w,f.h+20*m.s) diff --git a/src/import/BoxTools.lua b/src/import/BoxTools.lua index 5f266596..f85711ba 100644 --- a/src/import/BoxTools.lua +++ b/src/import/BoxTools.lua @@ -6,7 +6,7 @@ local UI = require("src.import.BoxUI") local Icons = require("src.ui.kit.Icons") local PAL = Theme.PAL local Tools = {} -Tools.PAGES = { "List", "Arrange", "Dashboard", "Teams", "Gifts", "Showcase", "Files", "Events", "Migration" } +Tools.PAGES = { "List", "Arrange", "Dashboard", "Teams", "Gifts", "Showcase", "Files", "Events", "Migration", "Items" } Tools.SORTS = { "slot", "name", "species", "level", "hp", "attack", "defense", "speed", "special", "spAtk", "spDef", "nature", "ability", "gender", "trainer", "friendship" } Tools.SORT_LABELS = { slot = "Slot order", name = "Nickname", species = "Species", level = "Level", @@ -154,6 +154,88 @@ function Tools.draw(imp, s, x, y, w, m, api) options, s.box, function(b) UI.change(imp, s, "toolsPage", nil, -1); s.box, s.pageBox, s.view, s.railFirst = b, 1, "box", nil end, "package") nextRow() end + local function migrationCard(row, destination) + local GameVersion = require("src.core.GameVersion") + local found = findEntry(s, row.id) + local source = found and found.entry + local sc, pad, cg = m.s, 12 * m.s, 6 * m.s + local microH, smallH = Kit.textHeight("micro"), Kit.textHeight("small") + local headH = 56 * sc + local facts = row.converted and row.lines.facts or {} + local cols = w >= 560 * sc and 3 or 2 + local tw = (w - 2 * pad - (cols - 1) * cg) / cols + local th = microH + smallH + 16 * sc + local cells = row.converted and (row.lines.ivs and { label = "IVs · 31 is perfect", max = 31, values = row.lines.ivs, + order = { { "hp", "HP" }, { "atk", "Atk" }, { "def", "Def" }, { "spa", "SpA" }, { "spd", "SpD" }, { "spe", "Spe" } } } + or row.lines.dvs and { label = "DVs · 15 is max", max = 15, values = row.lines.dvs, + order = { { "hp", "HP" }, { "atk", "Atk" }, { "def", "Def" }, { "spe", "Spe" }, { "spc", "Spc" } } }) + local ivH = cells and microH + 6 * sc + th + cg or 0 + local spots, col, rowY, order = {}, 0, 0, {} + for i, item in ipairs(facts) do if Kit.textWidth("small", item.value) <= tw - 18 * sc then order[#order + 1] = i end end + for i, item in ipairs(facts) do if Kit.textWidth("small", item.value) > tw - 18 * sc then order[#order + 1] = i end end + for _, i in ipairs(order) do + local item = facts[i] + if Kit.textWidth("small", item.value) > tw - 18 * sc then + if col > 0 then col, rowY = 0, rowY + th + cg end + spots[i] = { 0, rowY, w - 2 * pad }; rowY = rowY + th + cg + else + spots[i] = { col * (tw + cg), rowY, tw } + col = col + 1 + if col == cols then col, rowY = 0, rowY + th + cg end + end + end + if col > 0 then rowY = rowY + th + cg end + local reason = not row.converted and tostring(row.lines[1] or "This Pokémon cannot be converted.") + local reasonH = reason and Kit.wrapHeight("small", reason, w - 2 * pad, 4) + gap or 0 + local footH = microH + gap + local total = pad + headH + gap + rowY + ivH + reasonH + footH + pad + local accent = row.converted and PAL.green or PAL.red + Theme.card(x, y, w, total, { shadow = true, stroke = accent, strokeA = .55, strokeW = 1.5 }) + Theme.fillRounded(x + pad, y + pad, headH, headH, PAL.bg, 1, 8) + if source then + local art = require("src.box.Catalog").art(source) + if art then require("src.online.OnlineSprites").drawIcon(art, x + pad, y + pad, headH, math.floor((s.artTime or 0) * 4)) end + end + local status = row.converted and "READY" or "BLOCKED" + local tagW, tagH = Kit.textWidth("micro", status) + 16 * sc, microH + 8 * sc + Kit.tag(x + w - pad - tagW, y + pad + 4 * sc, tagW, tagH, status, accent, { fill = true, bold = true }) + local hx = x + pad + headH + gap + local nameW = x + w - pad - tagW - gap - hx + Kit.textBold("button", Kit.ellipsize("button", row.name, nameW), hx, y + pad + 4 * sc, PAL.heading) + local route = (source and GameVersion.info(source.version).label or "?") .. " → " .. GameVersion.info(destination).label + Kit.text("small", Kit.ellipsize("small", route, w - 2 * pad - headH - gap), hx, + y + pad + 8 * sc + Kit.textHeight("button"), PAL.muted) + local cy = y + pad + headH + gap + if reason then cy = cy + Kit.textWrapped("small", reason, x + pad, cy, w - 2 * pad, PAL.red, 4) + gap end + local tones = { new = PAL.blue, kept = PAL.green, lost = PAL.yellow } + for i, item in ipairs(facts) do + local tx, ty, fw = x + pad + spots[i][1], cy + spots[i][2], spots[i][3] + Theme.fillRounded(tx, ty, fw, th, PAL.raised, 1, 8) + if tones[item.tone] then Theme.strokeRounded(tx, ty, fw, th, tones[item.tone], .75, 1.5, 8) end + Kit.text("micro", Kit.ellipsize("micro", item.label, fw - 18 * sc), tx + 10 * sc, ty + 7 * sc, PAL.muted) + Kit.textBold("small", Kit.ellipsize("small", item.value, fw - 18 * sc), tx + 10 * sc, ty + 9 * sc + microH, PAL.heading) + end + cy = cy + rowY + if cells then + Kit.text("micro", cells.label, x + pad, cy, PAL.muted) + cy = cy + microH + 6 * sc + local n = #cells.order + local cw = (w - 2 * pad - (n - 1) * cg) / n + local ivs = cells.values + for i, stat in ipairs(cells.order) do + local cx = x + pad + (i - 1) * (cw + cg) + local perfect = ivs[stat[1]] == cells.max + Theme.fillRounded(cx, cy, cw, th, perfect and PAL.green or PAL.raised, perfect and .22 or 1, 8) + if perfect then Theme.strokeRounded(cx, cy, cw, th, PAL.green, .8, 1.5, 8) end + Kit.textCenter("micro", stat[2], cx, cy + 7 * sc, cw, PAL.muted) + Kit.textCenterBold("small", tostring(ivs[stat[1]]), cx, cy + 9 * sc + microH, cw, perfect and PAL.green or PAL.heading) + end + cy = cy + th + cg + end + Kit.text("micro", Kit.ellipsize("micro", "The original stays archived. Restore swaps it back.", w - 2 * pad), + x + pad, y + total - pad - microH, PAL.muted) + return total + end if page == "Migration" then local Migration=require("src.box.Migration") local GameVersion=require("src.core.GameVersion") @@ -174,7 +256,7 @@ function Tools.draw(imp, s, x, y, w, m, api) end) button("migration-preview","Review "..#refs.." selected",function() local preview,why=Migration.preview(s.service.state,refs,s.migrationVersion) - s.migrationPreview=preview;if not preview then s.notice=why end + s.migrationPreview=preview;if not preview then s.notice,s.noticeKind=why,"error" end end,#refs==0);nextRow() local preview=s.migrationPreview if preview then @@ -183,13 +265,22 @@ function Tools.draw(imp, s, x, y, w, m, api) options[#options + 1] = { id = i, label = row.name .. (row.converted and " · Ready" or " · Blocked"), icon = row.converted and "check" or "lock" } if row.converted then ready = ready + 1 end end - text(ready .. " ready · " .. (#preview.rows - ready) .. " blocked") s.migrationReviewRow = math.min(s.migrationReviewRow or 1, #preview.rows) local row = preview.rows[s.migrationReviewRow] if row then - UI.dropdown(imp, x, y, w, h, "migration-record", row.name, options, s.migrationReviewRow, - function(value) s.migrationReviewRow = value end, "eye"); nextRow() - for _, line in ipairs(row.lines) do text(line, true) end + if #preview.rows > 1 then + UI.dropdown(imp, x, y, w, h, "migration-record", row.name .. " · " .. ready .. " of " .. #preview.rows .. " ready", + options, s.migrationReviewRow, function(value) s.migrationReviewRow = value end, "eye"); nextRow() + end + y = y + migrationCard(row, preview.destination) + gap + if row.lines.natureExp then + local options = {} + for _, step in ipairs(Migration.natureSteps(row.lines.natureExp)) do + options[#options + 1] = { id = step.nature, label = step.nature .. (step.add == 0 and " · now" or " · +" .. step.add .. " EXP"), + icon = "arrow-up", disabled = true } + end + UI.dropdown(imp, x, y, w, h, "migration-natures", "EXP for each nature", options, nil, nil, "arrow-up"); nextRow() + end end button("migration-apply","Convert selected",function() operation(function() return s.service:migrate(refs,s.migrationVersion,preview) end,"Migration saved with archived originals.") @@ -213,6 +304,88 @@ function Tools.draw(imp, s, x, y, w, m, api) end end UI.help(imp, "MigrationPolicy", x + w - h, y, h); nextRow() + elseif page == "Items" then + local Items = require("src.box.Items") + local GameVersion = require("src.core.GameVersion") + local source = s.sources[s.sourceIndex] + s.itemAmount = s.itemAmount or 1 + cycle("item-amount", "Amount", { 1, 5, 10, 99 }, s.itemAmount, function(value) s.itemAmount = value end) + if source and s.pcSave then + local family = Items.family(GameVersion.generation(source.version)) + local cache = s._itemBag + if not cache or cache.body ~= s.pcBody or cache.version ~= source.version then + cache = { body = s.pcBody, version = source.version, rows = Items.bag(s.pcSave, source.version) } + s._itemBag = cache + end + local deposit = {} + for _, row in ipairs(cache.rows) do + deposit[#deposit + 1] = { label = row.name .. " × " .. row.count, icon = "download", action = function() + local count = math.min(s.itemAmount, row.count) + operation(function() return s.service:depositItem(source, row.id, count) end, count .. " " .. row.name .. " stored in Box.") + end } + end + UI.dropdown(imp, x, y, w, h, "item-deposit", "Store from bag · " .. #deposit, deposit, nil, nil, "download"); nextRow() + local withdraw = {} + for _, row in ipairs(Items.locker(s.service.state, family)) do + local found = Items.find(source.version, row.key) ~= nil + withdraw[#withdraw + 1] = { label = row.name .. " × " .. row.count, icon = "upload", disabled = not found, action = function() + local count = math.min(s.itemAmount, row.count) + operation(function() return s.service:withdrawItem(source, row.key, count) end, count .. " " .. row.name .. " sent to the bag.") + end } + end + UI.dropdown(imp, x, y, w, h, "item-withdraw", "Send to bag · " .. #withdraw, withdraw, nil, nil, "upload"); nextRow() + else text("Choose a game save to move items.", true) end + for _, family in ipairs({ "gen12", "gen3" }) do + local rows = Items.locker(s.service.state, family) + if #rows > 0 then + Kit.textBold("small", family == "gen3" and "Gen 3 items" or "Gen 1 and 2 items", x, y, PAL.heading) + y = y + Kit.textHeight("small") + gap + local parts = {} + for _, row in ipairs(rows) do parts[#parts + 1] = row.name .. " × " .. row.count end + text(table.concat(parts, " · "), true) + end + end + if #refs == 1 then s.itemRef = refs[1] end + local ref = s.itemRef + local entry = ref and Store.at(s.service.state, ref.box, ref.slot) + if not entry or entry.generation == 1 then ref, entry = nil, nil end + local holders = {} + for _, row in ipairs(search(s, "", "slot")) do + if row.entry.generation > 1 then + holders[#holders + 1] = { id = row.box .. ":" .. row.slot, entry = row.entry, label = row.entry.display.name + .. " · Lv. " .. row.entry.display.level .. " · " .. s.service.state.boxes[row.box].name, + action = function() s.itemRef = { box = row.box, slot = row.slot } end } + end + end + UI.dropdown(imp, x, y, w, h, "item-holder", entry and "Pokémon: " .. entry.display.name or "Choose a Pokémon · " .. #holders, + holders, ref and ref.box .. ":" .. ref.slot, nil, "users"); nextRow() + if entry then + local family = Items.family(entry.generation) + text(entry.display.name .. " · " .. (entry.display.item ~= "" and entry.display.item or "no held item") + .. (entry.generation == 3 and entry.display.ball and " · " .. entry.display.ball or "")) + local give = {} + for _, row in ipairs(Items.locker(s.service.state, family)) do + if not Items.isMail(row.name) then + give[#give + 1] = { label = row.name .. " × " .. row.count, icon = "package", action = function() + operation(function() return s.service:give(ref, row.key) end, entry.display.name .. " is holding " .. row.name .. ".") + end } + end + end + local cw = (w - gap) / 2 + UI.dropdown(imp, x, y, cw, h, "item-give", "Give item", give, nil, nil, "package") + button("item-take", "Take item", function() + operation(function() return s.service:takeHeld(ref) end, "Item stored in Box.") + end, entry.display.item == "" or entry.mon.isEgg, cw, x + cw + gap, false, "download"); nextRow() + if entry.generation == 3 then + local balls = {} + for _, row in ipairs(Items.balls(s.service.state)) do + balls[#balls + 1] = { label = row.name .. " × " .. row.count, icon = "award", action = function() + operation(function() return s.service:swapBall(ref, row.key) end, entry.display.name .. " moved into a " .. row.name .. ".") + end } + end + UI.dropdown(imp, x, y, w, h, "item-ball", "Change ball · " .. #balls, balls, nil, nil, "award"); nextRow() + end + else text("Pick a Gen 2 or Gen 3 Pokémon to give it an item or change its ball.", true) end elseif page == "Events" then local Events=require("src.box.Events") local source=s.sources[s.sourceIndex] @@ -250,7 +423,7 @@ function Tools.draw(imp, s, x, y, w, m, api) elseif page == "Files" then local export = function() local ok, path = s.service:exportGCI() - s.notice = ok and "Exported and verified: " .. path or path + s.notice, s.noticeKind = ok and "Exported and verified: " .. path or path, ok and "ok" or "error" end actions({ { "gci-import", "Import Box save", function() imp:chooseBoxImport() end }, { "gci-export", "Export GCI", export, not s.service.state.gciTemplate } }) @@ -271,8 +444,10 @@ function Tools.draw(imp, s, x, y, w, m, api) button("sync-restore", "Restore sync backup", function() s._latestBackup = nil local ok, why = sync:restoreBoxBackup(backup) - if ok then require("src.import.BoxPanel").refresh(imp).notice = "Box and linked saves restored." - else s.notice = why end + if ok then + local fresh = require("src.import.BoxPanel").refresh(imp) + fresh.notice, fresh.noticeKind = "Box and linked saves restored.", "ok" + else s.notice, s.noticeKind = why, "error" end end, not backup or sync:busy(), w - h - gap) UI.help(imp, "Backup", x + w - h, y, h); nextRow() elseif page == "Showcase" then @@ -311,13 +486,13 @@ function Tools.draw(imp, s, x, y, w, m, api) local out={} if #refs>0 then out[#out+1]={label="Add "..#refs.." selected Pokémon",icon="plus",action=function() local added,why=Showcase.add(s.stageDraft,refs,s.service.state) - if added then edit(function() s.stageDraft=added end);s.stagePiece=#added.pieces else s.notice=why end + if added then edit(function() s.stageDraft=added end);s.stagePiece=#added.pieces else s.notice,s.noticeKind=why,"error" end end} end for _,row in ipairs(search(s,s.query,"species")) do out[#out+1]={label=(row.entry.display.national and ("#"..row.entry.display.national.." · ") or "").. row.entry.display.name,entry=row.entry,icon="package",action=function() local added,why=Showcase.add(s.stageDraft,{{box=row.box,slot=row.slot}},s.service.state) - if added then edit(function() s.stageDraft=added end);s.stagePiece=#added.pieces else s.notice=why end + if added then edit(function() s.stageDraft=added end);s.stagePiece=#added.pieces else s.notice,s.noticeKind=why,"error" end end} end return out @@ -325,7 +500,7 @@ function Tools.draw(imp, s, x, y, w, m, api) UI.dropdown(imp,x,y,(w-gap)/2,h,"stage-add-pokemon","Add Pokémon",addOptions,nil,nil,"plus") local scenery={} for _,kind in ipairs(Showcase.PIECES) do scenery[#scenery+1]={label=kind,prop=kind,icon="package",action=function() - if #s.stageDraft.pieces>=1500 then s.notice="The stage is full.";return end + if #s.stageDraft.pieces>=1500 then s.notice,s.noticeKind="The stage is full.","error";return end edit(function(d) d.pieces[#d.pieces+1]={kind=kind,x=.5,y=.5,scale=1.5,rotation=0,flip=false} end) s.stagePiece=#s.stageDraft.pieces end} end @@ -360,7 +535,7 @@ function Tools.draw(imp, s, x, y, w, m, api) end) button("stage-play", s.stagePlaying and "Stop music" or "Play music", function() if s.stagePlaying then Showcase.stopMusic(); s.stagePlaying = nil - else local ok, why = Showcase.playMusic(draft); if not ok then s.notice=why else s.stagePlaying = true end end + else local ok, why = Showcase.playMusic(draft); if not ok then s.notice,s.noticeKind=why,"error" else s.stagePlaying = true end end end,draft.music=="Silent",w,nil,false,s.stagePlaying and "square" or "play");nextRow() elseif stageSection == "Placement" then local function pieceLabel(i) @@ -391,7 +566,7 @@ function Tools.draw(imp, s, x, y, w, m, api) actions({ { "stage-export", "Export PNG", function() local ok,path=s.service:exportStage(s.stageIndex) if ok then ok,path=userExport(s,path,s.stageIndex) end - s.notice=ok and "PNG exported: "..ok or path + s.notice,s.noticeKind=ok and "PNG exported: "..ok or path,ok and "ok" or "error" end, not s.service.state.stages or not s.service.state.stages[s.stageIndex] }, { "stage-wallpaper", "Use as wallpaper", function() operation(function() return s.service:exportStage(s.stageIndex,s.box) end,"Wallpaper saved.") @@ -589,7 +764,7 @@ function Tools.draw(imp, s, x, y, w, m, api) end end }, { "move-group", "Move / swap " .. #refs, function() s.moving, s.view, s.toolsPage, s.query, s.sort = refs, "box", nil, "", "slot" - s.notice = "Choose the group's first destination slot. Occupied slots swap back." + s.notice, s.noticeKind = "Choose the group's first destination slot. Occupied slots swap back.", "info" end, #refs == 0 } }) picker() elseif section == "Sort" or section == "Auto Box" then diff --git a/src/import/BoxUI.lua b/src/import/BoxUI.lua index a6397d4a..0fae3180 100644 --- a/src/import/BoxUI.lua +++ b/src/import/BoxUI.lua @@ -2,11 +2,12 @@ local Kit = require("src.ui.kit.Kit") local Theme = require("src.ui.kit.Theme") local Transition = require("src.ui.kit.Transition") local Strings = require("src.core.Strings") +local Icons = require("src.ui.kit.Icons") local UI = {} local PAL = Theme.PAL UI.ICONS = { List = "book-open", Filters = "list-filter", Arrange = "arrow-left-right", Themes = "paintbrush", Dashboard = "chart-no-axes-column", Teams = "users", Gifts = "package", - Showcase = "grid-2x2", Files = "folder", Events = "mail", Migration = "arrow-left-right" } + Showcase = "grid-2x2", Files = "folder", Events = "mail", Migration = "arrow-left-right", Items = "backpack" } UI.HELP = { Box = "these pokémon stay here until you send them to a game. Box saves separately, and sync keeps both sides of a move together.", List = "all your stored pokémon in one list. select up to six to compare them.", @@ -20,7 +21,8 @@ UI.HELP = { Backup = "restores Box and its linked saves together. your current collection gets backed up first. older incomplete backups won’t restore.", Events = "tickets for event trips in the linked game. collect the ticket, then use the normal ship route. story requirements still apply.", Migration = "same pokémon, new generation. check the changes first. the original stays archived so you can switch back.", - MigrationPolicy = "Gen 2 and Gen 3 never had an official transfer route. G1R converts what fits and blocks what doesn’t.", + MigrationPolicy = "Gen 2 and Gen 3 never had an official transfer route. G1R converts what fits and blocks what doesn’t. Gen 1 and 2 to Gen 3 follows Pokémon Bank: nature from EXP, three perfect IVs, EVs reset, Poké Ball.", + Items = "store bag items here, then send them to another game's bag. gen 1 and 2 share a stash. changing a ball uses one up.", } local HELP_TITLES = { Box = "Box storage", Backup = "Restore backup", MigrationPolicy = "Conversion rules" } @@ -29,6 +31,48 @@ function UI.button(imp, x, y, w, h, id, label, action, opts) opts = opts or {}; opts.action, opts.font = action, opts.font or "small" view().btn(imp, x, y, w, h, "box-" .. id, Strings(label), opts) end +local TOAST = { ok = { color = "green", icon = "check", hold = 3.5 }, + error = { color = "red", icon = "triangle-alert", hold = 7 }, + info = { color = "blue", icon = "circle-help", hold = 5 } } +function UI.toast(imp, text, kind, sticky) + if text == nil or text == "" then imp._boxToast = nil; return end + imp._boxToast = { text = Strings(tostring(text)), kind = TOAST[kind] and kind or "info", + at = love.timer.getTime(), sticky = sticky } +end +function UI.occludeToast(imp) + local t = imp.tab == "box" and imp._boxToast + if t and t.rect then Kit.occlude(t.rect[1], t.rect[2], t.rect[3], t.rect[4]) end +end +function UI.drawToast(imp, m, bottom) + local t = imp._boxToast + if not t or imp.tab ~= "box" then return end + local style = TOAST[t.kind] + local elapsed = love.timer.getTime() - t.at + local hold = t.sticky and math.huge or style.hold + if elapsed > hold + .25 then imp._boxToast = nil; return end + local slide = math.min(1, elapsed / .18, (hold + .25 - elapsed) / .25) + slide = 1 - (1 - math.max(0, slide)) ^ 3 + local s, color = m.s, PAL[style.color] + local pad, gap, icon = 12 * s, 10 * s, 20 * s + local w = math.min(420 * s, m.W - 32 * s) + local textW = w - 2 * pad - icon - gap + local h = 2 * pad + math.max(icon, Kit.wrapHeight("small", t.text, textW, 4)) + local x = (m.W - w) / 2 + local y = bottom - h - 12 * s + (1 - slide) * (h + 24 * s) + t.rect = { x, y, w, h } + local r = Theme.cardRadius() + Theme.fillRounded(x + 2 * s, y + 4 * s, w, h, PAL.inverse, .35, r) + Theme.fillRounded(x, y, w, h, PAL.surface, .98, r) + Theme.fillRounded(x, y, w, h, color, .16, r) + Theme.strokeRounded(x, y, w, h, color, .75, 1.5, r) + Icons.draw(style.icon, x + pad, y + (h - icon) / 2, icon, color, 1) + Kit.textWrapped("small", t.text, x + pad + icon + gap, y + pad, textW, PAL.heading, 4) + local overlay = Kit._overlay + Kit._overlay = true + view().btn(imp, x, y, w, h, "box-toast", "", { face = "bare", ring = false, + action = function() imp._boxToast = nil end }) + Kit._overlay = overlay +end function UI.close(imp) imp._boxPopup = nil; Kit.blur(); Kit._drag = nil Kit.mouseClicked, Kit.wheelY = false, 0 diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 3222801f..2a4ca802 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -16,9 +16,10 @@ CacheContract.VERSION_FORMAT = { -- wCurMapObjectEventsPointer. A v10 cache has no address to write, and -- such an export is refused until the ROM re-imports. -- gfx/sgb/predef.pal:28 - gold = "rom-cache-v14:", - silver = "rom-cache-v14:", - crystal = "rom-cache-v14-crystal6:", + -- data/items/catch_rate_items.asm:5 + gold = "rom-cache-v15:", + silver = "rom-cache-v15:", + crystal = "rom-cache-v15-crystal6:", -- engine/overworld/map_sprites.asm:181, engine/battle/animations.asm:2600 -- data/pikachu/pikachu_pic_animation.asm:340 yellow = "rom-cache-v12-yellow2:", diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index d1898a25..d1fb670b 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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 diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index f9fa5e58..bc31d724 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -363,7 +363,9 @@ function LauncherView.touchpressed(imp, id, x, y) region = not shielded and tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y), page = not shielded, picker = picker and inRect(picker.rect, x, y) and picker or nil, + inner = not noDragAt(imp, x, y), } + if imp._touchAt[tostring(id)].inner then Kit.dragBegin(x, y) end end function LauncherView.touchmoved(imp, id, x, y) @@ -388,8 +390,12 @@ function LauncherView.touchmoved(imp, id, x, y) move = leftover end if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then - imp._pageScroll = (imp._pageScroll or 0) + move + local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0, + imp._pageScrollMax, move) + imp._pageScroll = at + move = leftover end + if move ~= 0 and start.inner then Kit.dragAdd(move) end end start.lastY = y end @@ -407,6 +413,7 @@ function LauncherView.touchreleased(imp, id, x, y) end local start = imp._touchAt and imp._touchAt[tid] if imp._touchAt then imp._touchAt[tid] = nil end + if start and start.inner then Kit.dragEnd() end -- A release with no matching press is leftover from the previous host -- (game / save editor), not a launcher tap (#2079). if not start then @@ -5709,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 @@ -5825,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, { @@ -6965,6 +6974,7 @@ function LauncherView.draw(imp) imp._noDragN = 0 local Toast = require("src.import.online.Toast") Toast.occlude(imp) + require("src.import.BoxUI").occludeToast(imp) Theme.field() if imp._themeVideo then imp._themeVideo:draw() end @@ -7071,6 +7081,7 @@ function LauncherView.draw(imp) local held = imp._modalHeld if held then imp[held.key] = held.value end Toast.occlude(imp) + require("src.import.BoxUI").occludeToast(imp) buildModals(imp, m) endModalDraw(m) if held then imp[held.key] = nil end @@ -7079,6 +7090,7 @@ function LauncherView.draw(imp) Toast.draw(imp, m, contentY + math.floor(8 * m.s), spec ~= nil or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) or (Kit.FileBrowser and Kit.FileBrowser.active) or false) + require("src.import.BoxUI").drawToast(imp, m, math.min(contentY + viewH, m.top + m.h)) -- The loader sits above everything, including modals: it is the one thing -- that must never be clicked around. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 1126dcc6..a9551687 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -4509,6 +4509,14 @@ function RomExtractorGen2:extractItems() entry.name = entry.name or entry.tmLabel end end + -- data/items/catch_rate_items.asm:5 + local capsule = self:symbol("TimeCapsule_CatchRateItems") + out.timeCapsule = {} + for row = 0, 255 do + local from = self.rom:byte(capsule.bank, capsule.address + row * 2) + if from == 0 then break end + out.timeCapsule[from] = order[self.rom:byte(capsule.bank, capsule.address + row * 2 + 1)] + end self:write("items", out) self:tick("Items", #order, #order) return out diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ca4de77c..dff478d5 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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?"), @@ -5280,7 +5291,7 @@ function RomImporter:_pumpSync(dt) end end local notice = type(eng.takeNotice) == "function" and eng:takeNotice() or nil - if notice and self._boxState then self._boxState.notice = notice end + if notice and self._boxState then self._boxState.notice, self._boxState.noticeKind = notice, "info" end if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then if not self._syncModal and not self._syncConflictShown then self._syncConflictShown = true @@ -5295,7 +5306,8 @@ function RomImporter:_syncNoteDownload(row) if type(row) == "table" and row.box then if self.tab == "box" then require("src.import.BoxPrompt").close(self, false) - require("src.import.BoxPanel").refresh(self).notice = "Box sync complete." + local fresh = require("src.import.BoxPanel").refresh(self) + fresh.notice, fresh.noticeKind = "Box sync complete.", "ok" elseif self._boxState then require("src.import.BoxPrompt").close(self, false) require("src.import.BoxPanel").refresh(self) diff --git a/src/import/RomSources.lua b/src/import/RomSources.lua index 67c5dee2..e090b4f0 100644 --- a/src/import/RomSources.lua +++ b/src/import/RomSources.lua @@ -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 diff --git a/src/import/gba/games/rs.lua b/src/import/gba/games/rs.lua index 520fa1ff..f694d8bb 100644 --- a/src/import/gba/games/rs.lua +++ b/src/import/gba/games/rs.lua @@ -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 diff --git a/src/import/gba/games/rs/anims.lua b/src/import/gba/games/rs/anims.lua index 3b8daee3..7b178583 100644 --- a/src/import/gba/games/rs/anims.lua +++ b/src/import/gba/games/rs/anims.lua @@ -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 diff --git a/src/import/gba/rs/anim_names.lua b/src/import/gba/rs/anim_names.lua index 5f13a534..c5b9faf2 100644 --- a/src/import/gba/rs/anim_names.lua +++ b/src/import/gba/rs/anim_names.lua @@ -1,817 +1,825 @@ +-- tools/gen_anim_names.py --game ruby from pokeruby.elf, pokeruby/data/battle_anim_scripts.s aligned with pokeemerald/data/battle_anim_scripts.s return { game = "rs", templates = { - ["IceBeamCenterParticleSpriteTemplate"] = "IceBeamCenterParticleSpriteTemplate", - ["RainbowRingSpriteTemplate"] = "RainbowRingSpriteTemplate", - ["gAncientPowerRockSpriteTemplate"] = "gAncientPowerRockSpriteTemplate", - ["gBasicHitSplatSpriteTemplate"] = "gBasicHitSplatSpriteTemplate", - ["gBattleAnimSpriteTemplate_83930A8"] = "gBattleAnimSpriteTemplate_83930A8", - ["gBattleAnimSpriteTemplate_83930C0"] = "gBattleAnimSpriteTemplate_83930C0", - ["gBattleAnimSpriteTemplate_83930F4"] = "gBattleAnimSpriteTemplate_83930F4", - ["gBattleAnimSpriteTemplate_83C2010"] = "gBattleAnimSpriteTemplate_83C2010", - ["gBattleAnimSpriteTemplate_83D62EC"] = "gBattleAnimSpriteTemplate_83D62EC", - ["gBattleAnimSpriteTemplate_83D6304"] = "gBattleAnimSpriteTemplate_83D6304", - ["gBattleAnimSpriteTemplate_83D6350"] = "gBattleAnimSpriteTemplate_83D6350", - ["gBattleAnimSpriteTemplate_83D637C"] = "gBattleAnimSpriteTemplate_83D637C", - ["gBattleAnimSpriteTemplate_83D6394"] = "gBattleAnimSpriteTemplate_83D6394", - ["gBattleAnimSpriteTemplate_83D63F8"] = "gBattleAnimSpriteTemplate_83D63F8", - ["gBattleAnimSpriteTemplate_83D6428"] = "gBattleAnimSpriteTemplate_83D6428", - ["gBattleAnimSpriteTemplate_83D6440"] = "gBattleAnimSpriteTemplate_83D6440", - ["gBattleAnimSpriteTemplate_83D649C"] = "gBattleAnimSpriteTemplate_83D649C", - ["gBattleAnimSpriteTemplate_83D64B4"] = "gBattleAnimSpriteTemplate_83D64B4", - ["gBattleAnimSpriteTemplate_83D64E4"] = "gBattleAnimSpriteTemplate_83D64E4", - ["gBattleAnimSpriteTemplate_83D65A0"] = "gBattleAnimSpriteTemplate_83D65A0", - ["gBattleAnimSpriteTemplate_83D65E8"] = "gBattleAnimSpriteTemplate_83D65E8", - ["gBattleAnimSpriteTemplate_83D6658"] = "gBattleAnimSpriteTemplate_83D6658", - ["gBattleAnimSpriteTemplate_83D6670"] = "gBattleAnimSpriteTemplate_83D6670", - ["gBattleAnimSpriteTemplate_83D6698"] = "gBattleAnimSpriteTemplate_83D6698", - ["gBattleAnimSpriteTemplate_83D671C"] = "gBattleAnimSpriteTemplate_83D671C", - ["gBattleAnimSpriteTemplate_83D6764"] = "gBattleAnimSpriteTemplate_83D6764", - ["gBattleAnimSpriteTemplate_83D677C"] = "gBattleAnimSpriteTemplate_83D677C", - ["gBattleAnimSpriteTemplate_83D67F4"] = "gBattleAnimSpriteTemplate_83D67F4", - ["gBattleAnimSpriteTemplate_83D68B8"] = "gBattleAnimSpriteTemplate_83D68B8", - ["gBattleAnimSpriteTemplate_83D68D0"] = "gBattleAnimSpriteTemplate_83D68D0", - ["gBattleAnimSpriteTemplate_83D693C"] = "gBattleAnimSpriteTemplate_83D693C", - ["gBattleAnimSpriteTemplate_83D6954"] = "gBattleAnimSpriteTemplate_83D6954", - ["gBattleAnimSpriteTemplate_83D696C"] = "gBattleAnimSpriteTemplate_83D696C", - ["gBattleAnimSpriteTemplate_83D6994"] = "gBattleAnimSpriteTemplate_83D6994", - ["gBattleAnimSpriteTemplate_83D69DC"] = "gBattleAnimSpriteTemplate_83D69DC", - ["gBattleAnimSpriteTemplate_83D69F4"] = "gBattleAnimSpriteTemplate_83D69F4", - ["gBattleAnimSpriteTemplate_83D6B40"] = "gBattleAnimSpriteTemplate_83D6B40", - ["gBattleAnimSpriteTemplate_83D6BE8"] = "gBattleAnimSpriteTemplate_83D6BE8", - ["gBattleAnimSpriteTemplate_83D6C48"] = "gBattleAnimSpriteTemplate_83D6C48", - ["gBattleAnimSpriteTemplate_83D6C88"] = "gBattleAnimSpriteTemplate_83D6C88", - ["gBattleAnimSpriteTemplate_83D6CA0"] = "gBattleAnimSpriteTemplate_83D6CA0", - ["gBattleAnimSpriteTemplate_83D6D94"] = "gBattleAnimSpriteTemplate_83D6D94", - ["gBattleAnimSpriteTemplate_83D6DAC"] = "gBattleAnimSpriteTemplate_83D6DAC", - ["gBattleAnimSpriteTemplate_83D6DC4"] = "gBattleAnimSpriteTemplate_83D6DC4", - ["gBattleAnimSpriteTemplate_83D6DE4"] = "gBattleAnimSpriteTemplate_83D6DE4", - ["gBattleAnimSpriteTemplate_83D6E38"] = "gBattleAnimSpriteTemplate_83D6E38", - ["gBattleAnimSpriteTemplate_83D6E50"] = "gBattleAnimSpriteTemplate_83D6E50", - ["gBattleAnimSpriteTemplate_83D6E68"] = "gBattleAnimSpriteTemplate_83D6E68", - ["gBattleAnimSpriteTemplate_83D6EF0"] = "gBattleAnimSpriteTemplate_83D6EF0", - ["gBattleAnimSpriteTemplate_83D6F08"] = "gBattleAnimSpriteTemplate_83D6F08", - ["gBattleAnimSpriteTemplate_83D6F3C"] = "gBattleAnimSpriteTemplate_83D6F3C", - ["gBattleAnimSpriteTemplate_83D6F80"] = "gBattleAnimSpriteTemplate_83D6F80", - ["gBattleAnimSpriteTemplate_83D6FB0"] = "gBattleAnimSpriteTemplate_83D6FB0", - ["gBattleAnimSpriteTemplate_83D6FC8"] = "gBattleAnimSpriteTemplate_83D6FC8", - ["gBattleAnimSpriteTemplate_83D6FF8"] = "gBattleAnimSpriteTemplate_83D6FF8", - ["gBattleAnimSpriteTemplate_83D7038"] = "gBattleAnimSpriteTemplate_83D7038", - ["gBattleAnimSpriteTemplate_83D7050"] = "gBattleAnimSpriteTemplate_83D7050", - ["gBattleAnimSpriteTemplate_83D7080"] = "gBattleAnimSpriteTemplate_83D7080", - ["gBattleAnimSpriteTemplate_83D7114"] = "gBattleAnimSpriteTemplate_83D7114", - ["gBattleAnimSpriteTemplate_83D715C"] = "gBattleAnimSpriteTemplate_83D715C", - ["gBattleAnimSpriteTemplate_83D7174"] = "gBattleAnimSpriteTemplate_83D7174", - ["gBattleAnimSpriteTemplate_83D71A8"] = "gBattleAnimSpriteTemplate_83D71A8", - ["gBattleAnimSpriteTemplate_83D7220"] = "gBattleAnimSpriteTemplate_83D7220", - ["gBattleAnimSpriteTemplate_83D72C8"] = "gBattleAnimSpriteTemplate_83D72C8", - ["gBattleAnimSpriteTemplate_83D72E0"] = "gBattleAnimSpriteTemplate_83D72E0", - ["gBattleAnimSpriteTemplate_83D7358"] = "gBattleAnimSpriteTemplate_83D7358", - ["gBattleAnimSpriteTemplate_83D7450"] = "gBattleAnimSpriteTemplate_83D7450", - ["gBattleAnimSpriteTemplate_83D748C"] = "gBattleAnimSpriteTemplate_83D748C", - ["gBattleAnimSpriteTemplate_83D7564"] = "gBattleAnimSpriteTemplate_83D7564", - ["gBattleAnimSpriteTemplate_83D757C"] = "gBattleAnimSpriteTemplate_83D757C", - ["gBattleAnimSpriteTemplate_83D7594"] = "gBattleAnimSpriteTemplate_83D7594", - ["gBattleAnimSpriteTemplate_83D75E4"] = "gBattleAnimSpriteTemplate_83D75E4", - ["gBattleAnimSpriteTemplate_83D75FC"] = "gBattleAnimSpriteTemplate_83D75FC", - ["gBattleAnimSpriteTemplate_83D7628"] = "gBattleAnimSpriteTemplate_83D7628", - ["gBattleAnimSpriteTemplate_83D765C"] = "gBattleAnimSpriteTemplate_83D765C", - ["gBattleAnimSpriteTemplate_83D769C"] = "gBattleAnimSpriteTemplate_83D769C", - ["gBattleAnimSpriteTemplate_83D76DC"] = "gBattleAnimSpriteTemplate_83D76DC", - ["gBattleAnimSpriteTemplate_83D7764"] = "gBattleAnimSpriteTemplate_83D7764", - ["gBattleAnimSpriteTemplate_83D7798"] = "gBattleAnimSpriteTemplate_83D7798", - ["gBattleAnimSpriteTemplate_83D77E0"] = "gBattleAnimSpriteTemplate_83D77E0", - ["gBattleAnimSpriteTemplate_83D77F8"] = "gBattleAnimSpriteTemplate_83D77F8", - ["gBattleAnimSpriteTemplate_83D7828"] = "gBattleAnimSpriteTemplate_83D7828", - ["gBattleAnimSpriteTemplate_83D78BC"] = "gBattleAnimSpriteTemplate_83D78BC", - ["gBattleAnimSpriteTemplate_83D7928"] = "gBattleAnimSpriteTemplate_83D7928", - ["gBattleAnimSpriteTemplate_83D795C"] = "gBattleAnimSpriteTemplate_83D795C", - ["gBattleAnimSpriteTemplate_83D7974"] = "gBattleAnimSpriteTemplate_83D7974", - ["gBattleAnimSpriteTemplate_83D798C"] = "gBattleAnimSpriteTemplate_83D798C", - ["gBattleAnimSpriteTemplate_83D79A4"] = "gBattleAnimSpriteTemplate_83D79A4", - ["gBattleAnimSpriteTemplate_83D7A44"] = "gBattleAnimSpriteTemplate_83D7A44", - ["gBattleAnimSpriteTemplate_83D7A68"] = "gBattleAnimSpriteTemplate_83D7A68", - ["gBattleAnimSpriteTemplate_83D7A80"] = "gBattleAnimSpriteTemplate_83D7A80", - ["gBattleAnimSpriteTemplate_83D7AB0"] = "gBattleAnimSpriteTemplate_83D7AB0", - ["gBattleAnimSpriteTemplate_83D7AC8"] = "gBattleAnimSpriteTemplate_83D7AC8", - ["gBattleAnimSpriteTemplate_83D7AE0"] = "gBattleAnimSpriteTemplate_83D7AE0", - ["gBattleAnimSpriteTemplate_83D7B60"] = "gBattleAnimSpriteTemplate_83D7B60", - ["gBattleAnimSpriteTemplate_83D7B94"] = "gBattleAnimSpriteTemplate_83D7B94", - ["gBattleAnimSpriteTemplate_83D7BB8"] = "gBattleAnimSpriteTemplate_83D7BB8", - ["gBattleAnimSpriteTemplate_83D7BD0"] = "gBattleAnimSpriteTemplate_83D7BD0", - ["gBattleAnimSpriteTemplate_83D7C00"] = "gBattleAnimSpriteTemplate_83D7C00", - ["gBattleAnimSpriteTemplate_83D7C48"] = "gBattleAnimSpriteTemplate_83D7C48", - ["gBattleAnimSpriteTemplate_83D7C90"] = "gBattleAnimSpriteTemplate_83D7C90", - ["gBattleAnimSpriteTemplate_83D7CC8"] = "gBattleAnimSpriteTemplate_83D7CC8", - ["gBattleAnimSpriteTemplate_83D7D1C"] = "gBattleAnimSpriteTemplate_83D7D1C", - ["gBattleAnimSpriteTemplate_83D7D34"] = "gBattleAnimSpriteTemplate_83D7D34", - ["gBattleAnimSpriteTemplate_83D7D74"] = "gBattleAnimSpriteTemplate_83D7D74", - ["gBattleAnimSpriteTemplate_83D9178"] = "gBattleAnimSpriteTemplate_83D9178", - ["gBattleAnimSpriteTemplate_83D91F4"] = "gBattleAnimSpriteTemplate_83D91F4", - ["gBattleAnimSpriteTemplate_83D920C"] = "gBattleAnimSpriteTemplate_83D920C", - ["gBattleAnimSpriteTemplate_83D9224"] = "gBattleAnimSpriteTemplate_83D9224", - ["gBattleAnimSpriteTemplate_83D923C"] = "gBattleAnimSpriteTemplate_83D923C", - ["gBattleAnimSpriteTemplate_83D9268"] = "gBattleAnimSpriteTemplate_83D9268", - ["gBattleAnimSpriteTemplate_83D9280"] = "gBattleAnimSpriteTemplate_83D9280", - ["gBattleAnimSpriteTemplate_83D92D0"] = "gBattleAnimSpriteTemplate_83D92D0", - ["gBattleAnimSpriteTemplate_83D92E8"] = "gBattleAnimSpriteTemplate_83D92E8", - ["gBattleAnimSpriteTemplate_83D9318"] = "gBattleAnimSpriteTemplate_83D9318", - ["gBattleAnimSpriteTemplate_83D9330"] = "gBattleAnimSpriteTemplate_83D9330", - ["gBattleAnimSpriteTemplate_83D9348"] = "gBattleAnimSpriteTemplate_83D9348", - ["gBattleAnimSpriteTemplate_83D9360"] = "gBattleAnimSpriteTemplate_83D9360", - ["gBattleAnimSpriteTemplate_83D9408"] = "gBattleAnimSpriteTemplate_83D9408", - ["gBattleAnimSpriteTemplate_83D9438"] = "gBattleAnimSpriteTemplate_83D9438", - ["gBattleAnimSpriteTemplate_83D9478"] = "gBattleAnimSpriteTemplate_83D9478", - ["gBattleAnimSpriteTemplate_83D9490"] = "gBattleAnimSpriteTemplate_83D9490", - ["gBattleAnimSpriteTemplate_83D9508"] = "gBattleAnimSpriteTemplate_83D9508", - ["gBattleAnimSpriteTemplate_83D9520"] = "gBattleAnimSpriteTemplate_83D9520", - ["gBattleAnimSpriteTemplate_83D9538"] = "gBattleAnimSpriteTemplate_83D9538", - ["gBattleAnimSpriteTemplate_83D95B0"] = "gBattleAnimSpriteTemplate_83D95B0", - ["gBattleAnimSpriteTemplate_83D9614"] = "gBattleAnimSpriteTemplate_83D9614", - ["gBattleAnimSpriteTemplate_83D9694"] = "gBattleAnimSpriteTemplate_83D9694", - ["gBattleAnimSpriteTemplate_83D96AC"] = "gBattleAnimSpriteTemplate_83D96AC", - ["gBattleAnimSpriteTemplate_83D96F8"] = "gBattleAnimSpriteTemplate_83D96F8", - ["gBattleAnimSpriteTemplate_83D974C"] = "gBattleAnimSpriteTemplate_83D974C", - ["gBattleAnimSpriteTemplate_83D977C"] = "gBattleAnimSpriteTemplate_83D977C", - ["gBattleAnimSpriteTemplate_83D97D0"] = "gBattleAnimSpriteTemplate_83D97D0", - ["gBattleAnimSpriteTemplate_83D985C"] = "gBattleAnimSpriteTemplate_83D985C", - ["gBattleAnimSpriteTemplate_83D9874"] = "gBattleAnimSpriteTemplate_83D9874", - ["gBattleAnimSpriteTemplate_83D98A0"] = "gBattleAnimSpriteTemplate_83D98A0", - ["gBattleAnimSpriteTemplate_83D98F0"] = "gBattleAnimSpriteTemplate_83D98F0", - ["gBattleAnimSpriteTemplate_83D9908"] = "gBattleAnimSpriteTemplate_83D9908", - ["gBattleAnimSpriteTemplate_83D9950"] = "gBattleAnimSpriteTemplate_83D9950", - ["gBattleAnimSpriteTemplate_83D9A6C"] = "gBattleAnimSpriteTemplate_83D9A6C", - ["gBattleAnimSpriteTemplate_83D9A9C"] = "gBattleAnimSpriteTemplate_83D9A9C", - ["gBattleAnimSpriteTemplate_83D9AB4"] = "gBattleAnimSpriteTemplate_83D9AB4", - ["gBattleAnimSpriteTemplate_83D9B28"] = "gBattleAnimSpriteTemplate_83D9B28", - ["gBattleAnimSpriteTemplate_83D9F78"] = "gBattleAnimSpriteTemplate_83D9F78", - ["gBattleAnimSpriteTemplate_83D9F90"] = "gBattleAnimSpriteTemplate_83D9F90", - ["gBattleAnimSpriteTemplate_83D9FC0"] = "gBattleAnimSpriteTemplate_83D9FC0", - ["gBattleAnimSpriteTemplate_83D9FD8"] = "gBattleAnimSpriteTemplate_83D9FD8", - ["gBattleAnimSpriteTemplate_83D9FF0"] = "gBattleAnimSpriteTemplate_83D9FF0", - ["gBattleAnimSpriteTemplate_83DA024"] = "gBattleAnimSpriteTemplate_83DA024", - ["gBattleAnimSpriteTemplate_83DA088"] = "gBattleAnimSpriteTemplate_83DA088", - ["gBattleAnimSpriteTemplate_83DA0A0"] = "gBattleAnimSpriteTemplate_83DA0A0", - ["gBattleAnimSpriteTemplate_83DA0B8"] = "gBattleAnimSpriteTemplate_83DA0B8", - ["gBattleAnimSpriteTemplate_83DA0FC"] = "gBattleAnimSpriteTemplate_83DA0FC", - ["gBattleAnimSpriteTemplate_83DA114"] = "gBattleAnimSpriteTemplate_83DA114", - ["gBattleAnimSpriteTemplate_83DA12C"] = "gBattleAnimSpriteTemplate_83DA12C", - ["gBattleAnimSpriteTemplate_83DA144"] = "gBattleAnimSpriteTemplate_83DA144", - ["gBattleAnimSpriteTemplate_83DA198"] = "gBattleAnimSpriteTemplate_83DA198", - ["gBattleAnimSpriteTemplate_83DA1E0"] = "gBattleAnimSpriteTemplate_83DA1E0", - ["gBattleAnimSpriteTemplate_83DA214"] = "gBattleAnimSpriteTemplate_83DA214", - ["gBattleAnimSpriteTemplate_83DA244"] = "gBattleAnimSpriteTemplate_83DA244", - ["gBattleAnimSpriteTemplate_83DA2B8"] = "gBattleAnimSpriteTemplate_83DA2B8", - ["gBattleAnimSpriteTemplate_83DA2D0"] = "gBattleAnimSpriteTemplate_83DA2D0", - ["gBattleAnimSpriteTemplate_83DA2E8"] = "gBattleAnimSpriteTemplate_83DA2E8", - ["gBattleAnimSpriteTemplate_83DA31C"] = "gBattleAnimSpriteTemplate_83DA31C", - ["gBattleAnimSpriteTemplate_83DA380"] = "gBattleAnimSpriteTemplate_83DA380", - ["gBattleAnimSpriteTemplate_83DA3B4"] = "gBattleAnimSpriteTemplate_83DA3B4", - ["gBattleAnimSpriteTemplate_83DA3E4"] = "gBattleAnimSpriteTemplate_83DA3E4", - ["gBattleAnimSpriteTemplate_83DA450"] = "gBattleAnimSpriteTemplate_83DA450", - ["gBattleAnimSpriteTemplate_83DA468"] = "gBattleAnimSpriteTemplate_83DA468", - ["gBattleAnimSpriteTemplate_83DA498"] = "gBattleAnimSpriteTemplate_83DA498", - ["gBattleAnimSpriteTemplate_83DA51C"] = "gBattleAnimSpriteTemplate_83DA51C", - ["gBattleAnimSpriteTemplate_83DA568"] = "gBattleAnimSpriteTemplate_83DA568", - ["gBattleAnimSpriteTemplate_83DA594"] = "gBattleAnimSpriteTemplate_83DA594", - ["gBattleAnimSpriteTemplate_83DA5D8"] = "gBattleAnimSpriteTemplate_83DA5D8", - ["gBattleAnimSpriteTemplate_83DA614"] = "gBattleAnimSpriteTemplate_83DA614", - ["gBattleAnimSpriteTemplate_83DA62C"] = "gBattleAnimSpriteTemplate_83DA62C", - ["gBattleAnimSpriteTemplate_83DA65C"] = "gBattleAnimSpriteTemplate_83DA65C", - ["gBattleAnimSpriteTemplate_83DA690"] = "gBattleAnimSpriteTemplate_83DA690", - ["gBattleAnimSpriteTemplate_83DA6A8"] = "gBattleAnimSpriteTemplate_83DA6A8", - ["gBattleAnimSpriteTemplate_83DA6C0"] = "gBattleAnimSpriteTemplate_83DA6C0", - ["gBattleAnimSpriteTemplate_83DA6D8"] = "gBattleAnimSpriteTemplate_83DA6D8", - ["gBattleAnimSpriteTemplate_83DA6F0"] = "gBattleAnimSpriteTemplate_83DA6F0", - ["gBattleAnimSpriteTemplate_83DA708"] = "gBattleAnimSpriteTemplate_83DA708", - ["gBattleAnimSpriteTemplate_83DA73C"] = "gBattleAnimSpriteTemplate_83DA73C", - ["gBattleAnimSpriteTemplate_83DA76C"] = "gBattleAnimSpriteTemplate_83DA76C", - ["gBattleAnimSpriteTemplate_83DA784"] = "gBattleAnimSpriteTemplate_83DA784", - ["gBattleAnimSpriteTemplate_83DA824"] = "gBattleAnimSpriteTemplate_83DA824", - ["gBattleAnimSpriteTemplate_83DA88C"] = "gBattleAnimSpriteTemplate_83DA88C", - ["gBattleAnimSpriteTemplate_83DA8F4"] = "gBattleAnimSpriteTemplate_83DA8F4", - ["gBattleAnimSpriteTemplate_83DA9E0"] = "gBattleAnimSpriteTemplate_83DA9E0", - ["gBattleAnimSpriteTemplate_83DAA68"] = "gBattleAnimSpriteTemplate_83DAA68", - ["gBattleAnimSpriteTemplate_83DAABC"] = "gBattleAnimSpriteTemplate_83DAABC", - ["gBattleAnimSpriteTemplate_83DAB10"] = "gBattleAnimSpriteTemplate_83DAB10", - ["gBattleAnimSpriteTemplate_83DAB28"] = "gBattleAnimSpriteTemplate_83DAB28", - ["gBattleAnimSpriteTemplate_83DAB40"] = "gBattleAnimSpriteTemplate_83DAB40", - ["gBattleAnimSpriteTemplate_83DAB74"] = "gBattleAnimSpriteTemplate_83DAB74", - ["gBattleAnimSpriteTemplate_83DAC10"] = "gBattleAnimSpriteTemplate_83DAC10", - ["gBattleAnimSpriteTemplate_83DAC4C"] = "gBattleAnimSpriteTemplate_83DAC4C", - ["gBattleAnimSpriteTemplate_83DAC64"] = "gBattleAnimSpriteTemplate_83DAC64", - ["gBattleAnimSpriteTemplate_83DAC7C"] = "gBattleAnimSpriteTemplate_83DAC7C", - ["gBattleAnimSpriteTemplate_83DACB8"] = "gBattleAnimSpriteTemplate_83DACB8", - ["gBattleAnimSpriteTemplate_83DACD0"] = "gBattleAnimSpriteTemplate_83DACD0", - ["gBattleAnimSpriteTemplate_83DADA8"] = "gBattleAnimSpriteTemplate_83DADA8", - ["gBattleAnimSpriteTemplate_83DADE8"] = "gBattleAnimSpriteTemplate_83DADE8", - ["gBattleAnimSpriteTemplate_83DAE00"] = "gBattleAnimSpriteTemplate_83DAE00", - ["gBattleAnimSpriteTemplate_83DAE18"] = "gBattleAnimSpriteTemplate_83DAE18", - ["gBattleAnimSpriteTemplate_83DAE30"] = "gBattleAnimSpriteTemplate_83DAE30", - ["gBattleAnimSpriteTemplate_83DAE64"] = "gBattleAnimSpriteTemplate_83DAE64", - ["gBattleAnimSpriteTemplate_83DAE7C"] = "gBattleAnimSpriteTemplate_83DAE7C", - ["gBattleAnimSpriteTemplate_83DAEDC"] = "gBattleAnimSpriteTemplate_83DAEDC", - ["gBattleAnimSpriteTemplate_83DAF20"] = "gBattleAnimSpriteTemplate_83DAF20", - ["gBattleAnimSpriteTemplate_83DAF38"] = "gBattleAnimSpriteTemplate_83DAF38", - ["gBattleAnimSpriteTemplate_83DAF50"] = "gBattleAnimSpriteTemplate_83DAF50", - ["gBattleAnimSpriteTemplate_83DAFCC"] = "gBattleAnimSpriteTemplate_83DAFCC", - ["gBattleAnimSpriteTemplate_83DB044"] = "gBattleAnimSpriteTemplate_83DB044", - ["gBattleAnimSpriteTemplate_83DB078"] = "gBattleAnimSpriteTemplate_83DB078", - ["gBattleAnimSpriteTemplate_83DB0D0"] = "gBattleAnimSpriteTemplate_83DB0D0", - ["gBattleAnimSpriteTemplate_83DB0E8"] = "gBattleAnimSpriteTemplate_83DB0E8", - ["gBattleAnimSpriteTemplate_83DB100"] = "gBattleAnimSpriteTemplate_83DB100", - ["gBattleAnimSpriteTemplate_83DB1D0"] = "gBattleAnimSpriteTemplate_83DB1D0", - ["gBattleAnimSpriteTemplate_83DB1E8"] = "gBattleAnimSpriteTemplate_83DB1E8", - ["gBattleAnimSpriteTemplate_83DB238"] = "gBattleAnimSpriteTemplate_83DB238", - ["gBattleAnimSpriteTemplate_83DB288"] = "gBattleAnimSpriteTemplate_83DB288", - ["gBattleAnimSpriteTemplate_83DB3DC"] = "gBattleAnimSpriteTemplate_83DB3DC", - ["gBattleAnimSpriteTemplate_83DB428"] = "gBattleAnimSpriteTemplate_83DB428", - ["gBattleAnimSpriteTemplate_83DB4C0"] = "gBattleAnimSpriteTemplate_83DB4C0", - ["gBattleAnimSpriteTemplate_83DB4D8"] = "gBattleAnimSpriteTemplate_83DB4D8", - ["gBattleAnimSpriteTemplate_83DB4F0"] = "gBattleAnimSpriteTemplate_83DB4F0", - ["gBattleAnimSpriteTemplate_83DB508"] = "gBattleAnimSpriteTemplate_83DB508", - ["gBattleAnimSpriteTemplate_83DB520"] = "gBattleAnimSpriteTemplate_83DB520", - ["gBattleAnimSpriteTemplate_83DB538"] = "gBattleAnimSpriteTemplate_83DB538", - ["gBattleAnimSpriteTemplate_83DB550"] = "gBattleAnimSpriteTemplate_83DB550", - ["gBattleAnimSpriteTemplate_8402180"] = "gBattleAnimSpriteTemplate_8402180", - ["gBattleAnimSpriteTemplate_8402198"] = "gBattleAnimSpriteTemplate_8402198", - ["gBattleAnimSpriteTemplate_84021B0"] = "gBattleAnimSpriteTemplate_84021B0", - ["gBattleAnimSpriteTemplate_84021DC"] = "gBattleAnimSpriteTemplate_84021DC", - ["gBattleAnimSpriteTemplate_84021F4"] = "gBattleAnimSpriteTemplate_84021F4", - ["gBattleAnimSpriteTemplate_840220C"] = "gBattleAnimSpriteTemplate_840220C", - ["gBattleAnimSpriteTemplate_8402264"] = "gBattleAnimSpriteTemplate_8402264", - ["gBattleAnimSpriteTemplate_840227C"] = "gBattleAnimSpriteTemplate_840227C", - ["gBattleAnimSpriteTemplate_84022B0"] = "gBattleAnimSpriteTemplate_84022B0", - ["gBattleAnimSpriteTemplate_84022F0"] = "gBattleAnimSpriteTemplate_84022F0", - ["gBattleAnimSpriteTemplate_840233C"] = "gBattleAnimSpriteTemplate_840233C", - ["gBattleAnimSpriteTemplate_840238C"] = "gBattleAnimSpriteTemplate_840238C", - ["gBattleAnimSpriteTemplate_84023A4"] = "gBattleAnimSpriteTemplate_84023A4", - ["gBattleAnimSpriteTemplate_84023BC"] = "gBattleAnimSpriteTemplate_84023BC", - ["gBattleAnimSpriteTemplate_84023E8"] = "gBattleAnimSpriteTemplate_84023E8", - ["gBattleAnimSpriteTemplate_8402458"] = "gBattleAnimSpriteTemplate_8402458", - ["gBattleAnimSpriteTemplate_8402498"] = "gBattleAnimSpriteTemplate_8402498", - ["gBattleAnimSpriteTemplate_84024D0"] = "gBattleAnimSpriteTemplate_84024D0", - ["gBattleAnimSpriteTemplate_84024E8"] = "gBattleAnimSpriteTemplate_84024E8", - ["gBattleAnimSpriteTemplate_8402578"] = "gBattleAnimSpriteTemplate_8402578", - ["gBattleAnimSpriteTemplate_84025EC"] = "gBattleAnimSpriteTemplate_84025EC", - ["gBattleAnimSpriteTemplate_8402630"] = "gBattleAnimSpriteTemplate_8402630", - ["gBattleAnimSpriteTemplate_84026A4"] = "gBattleAnimSpriteTemplate_84026A4", - ["gBattleAnimSpriteTemplate_84026F0"] = "gBattleAnimSpriteTemplate_84026F0", - ["gBattleAnimSpriteTemplate_8402708"] = "gBattleAnimSpriteTemplate_8402708", - ["gBattleAnimSpriteTemplate_8402720"] = "gBattleAnimSpriteTemplate_8402720", - ["gBattleAnimSpriteTemplate_8402738"] = "gBattleAnimSpriteTemplate_8402738", - ["gBattleAnimSpriteTemplate_84027EC"] = "gBattleAnimSpriteTemplate_84027EC", - ["gBattleAnimSpriteTemplate_8402934"] = "gBattleAnimSpriteTemplate_8402934", - ["gBattleAnimSpriteTemplate_8402964"] = "gBattleAnimSpriteTemplate_8402964", - ["gBattleAnimSpriteTemplate_84029C4"] = "gBattleAnimSpriteTemplate_84029C4", - ["gBattleAnimSpriteTemplate_84029F4"] = "gBattleAnimSpriteTemplate_84029F4", - ["gBattleAnimSpriteTemplate_8402A0C"] = "gBattleAnimSpriteTemplate_8402A0C", - ["gBattleAnimSpriteTemplate_8402A24"] = "gBattleAnimSpriteTemplate_8402A24", - ["gBattleAnimSpriteTemplate_8402A3C"] = "gBattleAnimSpriteTemplate_8402A3C", - ["gBattleAnimSpriteTemplate_8402A6C"] = "gBattleAnimSpriteTemplate_8402A6C", - ["gBattleAnimSpriteTemplate_8402AE4"] = "gBattleAnimSpriteTemplate_8402AE4", - ["gBattleAnimSpriteTemplate_8402B10"] = "gBattleAnimSpriteTemplate_8402B10", - ["gBattleAnimSpriteTemplate_840B4FC"] = "gBattleAnimSpriteTemplate_840B4FC", - ["gBattleAnimSpriteTemplate_EndureFlame"] = "gBattleAnimSpriteTemplate_EndureFlame", - ["gBoneHitSpriteTemplate"] = "gBoneHitSpriteTemplate", - ["gBonemerangSpriteTemplate"] = "gBonemerangSpriteTemplate", - ["gConfusionDuckSpriteTemplate"] = "gConfusionDuckSpriteTemplate", - ["gCuttingSliceSpriteTemplate"] = "gCuttingSliceSpriteTemplate", - ["gDigDirtMoundSpriteTemplate"] = "gDigDirtMoundSpriteTemplate", - ["gDirtPlumeSpriteTemplate"] = "gDirtPlumeSpriteTemplate", - ["gElectricitySpriteTemplate"] = "gElectricitySpriteTemplate", - ["gEmberFlareSpriteTemplate"] = "gEmberFlareSpriteTemplate", - ["gEmberSpriteTemplate"] = "gEmberSpriteTemplate", - ["gFallingItemBagTemplate"] = "gFallingItemBagTemplate", - ["gFireCrossSpriteTemplate"] = "gFireCrossSpriteTemplate", - ["gFireRingSpriteTemplate"] = "gFireRingSpriteTemplate", - ["gFistFootSpriteTemplate"] = "gFistFootSpriteTemplate", - ["gFlyingDirtSpriteTemplate"] = "gFlyingDirtSpriteTemplate", - ["gHiddenPowerOrbScatterSpriteTemplate"] = "gHiddenPowerOrbScatterSpriteTemplate", - ["gHiddenPowerOrbSpriteTemplate"] = "gHiddenPowerOrbSpriteTemplate", - ["gHorizontalLungeSpriteTemplate"] = "gHorizontalLungeSpriteTemplate", - ["gIceBallParticleSpriteTemplate"] = "gIceBallParticleSpriteTemplate", - ["gIceBallSpriteTemplate"] = "gIceBallSpriteTemplate", - ["gIceBeamOuterParticleSpriteTemplate"] = "gIceBeamOuterParticleSpriteTemplate", - ["gIceSpikeSpriteTemplate"] = "gIceSpikeSpriteTemplate", - ["gIcicleSpearSpriteTemplate"] = "gIcicleSpearSpriteTemplate", - ["gLargeIceEffectParticleSpriteTemplate"] = "gLargeIceEffectParticleSpriteTemplate", - ["gLargeIcePunchCrystalSpriteTemplate"] = "gLargeIcePunchCrystalSpriteTemplate", - ["gLargeSnowballSpriteTemplate"] = "gLargeSnowballSpriteTemplate", - ["gLeechSeedSpriteTemplate"] = "gLeechSeedSpriteTemplate", - ["gLinearStingerSpriteTemplate"] = "gLinearStingerSpriteTemplate", - ["gMegaPunchKickSpriteTemplate"] = "gMegaPunchKickSpriteTemplate", - ["gMistBallSpriteTemplate"] = "gMistBallSpriteTemplate", - ["gMistCloudSpriteTemplate"] = "gMistCloudSpriteTemplate", - ["gMudSlapDirtSpriteTemplate"] = "gMudSlapDirtSpriteTemplate", - ["gMudSportDirtSpriteTemplate"] = "gMudSportDirtSpriteTemplate", - ["gPinMissileSpriteTemplate"] = "gPinMissileSpriteTemplate", - ["gPinkSmokeTemplate"] = "gPinkSmokeTemplate", - ["gPoisonBubbleSpriteTemplate"] = "gPoisonBubbleSpriteTemplate", - ["gPoisonGasCloudSpriteTemplate"] = "gPoisonGasCloudSpriteTemplate", - ["gPoisonPowderParticleSpriteTemplate"] = "gPoisonPowderParticleSpriteTemplate", - ["gSandAttackDirtSpriteTemplate"] = "gSandAttackDirtSpriteTemplate", - ["gScreechWaveSpriteTemplate"] = "gScreechWaveSpriteTemplate", - ["gShadowBallSpriteTemplate"] = "gShadowBallSpriteTemplate", - ["gSimplePaletteBlendSpriteTemplate"] = "gSimplePaletteBlendSpriteTemplate", - ["gSleepPowderParticleSpriteTemplate"] = "gSleepPowderParticleSpriteTemplate", - ["gSlideMonToOffsetSpriteTemplate"] = "gSlideMonToOffsetSpriteTemplate", - ["gSlideMonToOriginalPosSpriteTemplate"] = "gSlideMonToOriginalPosSpriteTemplate", - ["gSmallIceEffectParticleSpriteTemplate"] = "gSmallIceEffectParticleSpriteTemplate", - ["gSmallIcePunchCrystalSpriteTemplate"] = "gSmallIcePunchCrystalSpriteTemplate", - ["gSmallSnowballSpriteTemplate1"] = "gSmallSnowballSpriteTemplate1", - ["gSmallSnowballSpriteTemplate2"] = "gSmallSnowballSpriteTemplate2", - ["gSmogCloudSpriteTemplate"] = "gSmogCloudSpriteTemplate", - ["gSonicBoomSpriteTemplate"] = "gSonicBoomSpriteTemplate", - ["gStompFootSpriteTemplate"] = "gStompFootSpriteTemplate", - ["gStunSporeParticleSpriteTemplate"] = "gStunSporeParticleSpriteTemplate", - ["gSupersonicWaveSpriteTemplate"] = "gSupersonicWaveSpriteTemplate", - ["gSwiftStarSpriteTemplate"] = "gSwiftStarSpriteTemplate", - ["gVerticalDipSpriteTemplate"] = "gVerticalDipSpriteTemplate", - ["gWaterBubbleSpriteTemplate"] = "gWaterBubbleSpriteTemplate", - ["gWeatherBallHailSpriteTemplate"] = "gWeatherBallHailSpriteTemplate", + IceBeamCenterParticleSpriteTemplate = "gIceBeamInnerCrystalSpriteTemplate", + RainbowRingSpriteTemplate = "gAuroraBeamRingSpriteTemplate", + gAncientPowerRockSpriteTemplate = "gAncientPowerRockSpriteTemplate", + gBasicHitSplatSpriteTemplate = "gBasicHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_83930A8 = "gWeatherBallUpSpriteTemplate", + gBattleAnimSpriteTemplate_83930C0 = "gWeatherBallNormalDownSpriteTemplate", + gBattleAnimSpriteTemplate_83930F4 = "gSpinningSparkleSpriteTemplate", + gBattleAnimSpriteTemplate_83C2010 = "gSlideMonToOffsetAndBackSpriteTemplate", + gBattleAnimSpriteTemplate_83D62EC = "gPowerAbsorptionOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D6304 = "gSolarBeamBigOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D6350 = "gStockpileAbsorptionOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D637C = "gAbsorptionOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D6394 = "gHyperBeamOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D63F8 = "gSporeParticleSpriteTemplate", + gBattleAnimSpriteTemplate_83D6428 = "gPetalDanceBigFlowerSpriteTemplate", + gBattleAnimSpriteTemplate_83D6440 = "gPetalDanceSmallFlowerSpriteTemplate", + gBattleAnimSpriteTemplate_83D649C = "gRazorLeafParticleSpriteTemplate", + gBattleAnimSpriteTemplate_83D64B4 = "gTwisterLeafSpriteTemplate", + gBattleAnimSpriteTemplate_83D64E4 = "gRazorLeafCutterSpriteTemplate", + gBattleAnimSpriteTemplate_83D65A0 = "gConstrictBindingSpriteTemplate", + gBattleAnimSpriteTemplate_83D65E8 = "gMimicOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D6658 = "gIngrainRootSpriteTemplate", + gBattleAnimSpriteTemplate_83D6670 = "gFrenzyPlantRootSpriteTemplate", + gBattleAnimSpriteTemplate_83D6698 = "gIngrainOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D671C = "gPresentSpriteTemplate", + gBattleAnimSpriteTemplate_83D6764 = "gPresentHealParticleSpriteTemplate", + gBattleAnimSpriteTemplate_83D677C = "gItemStealSpriteTemplate", + gBattleAnimSpriteTemplate_83D67F4 = "gTrickBagSpriteTemplate", + gBattleAnimSpriteTemplate_83D68B8 = "gAromatherapySmallFlowerSpriteTemplate", + gBattleAnimSpriteTemplate_83D68D0 = "gAromatherapyBigFlowerSpriteTemplate", + gBattleAnimSpriteTemplate_83D693C = "gSilverWindBigSparkSpriteTemplate", + gBattleAnimSpriteTemplate_83D6954 = "gSilverWindMediumSparkSpriteTemplate", + gBattleAnimSpriteTemplate_83D696C = "gSilverWindSmallSparkSpriteTemplate", + gBattleAnimSpriteTemplate_83D6994 = "gNeedleArmSpikeSpriteTemplate", + gBattleAnimSpriteTemplate_83D69DC = "gSlamHitSpriteTemplate", + gBattleAnimSpriteTemplate_83D69F4 = "gVineWhipSpriteTemplate", + gBattleAnimSpriteTemplate_83D6B40 = "gAirCutterSliceSpriteTemplate", + gBattleAnimSpriteTemplate_83D6BE8 = "gProtectSpriteTemplate", + gBattleAnimSpriteTemplate_83D6C48 = "gMilkBottleSpriteTemplate", + gBattleAnimSpriteTemplate_83D6C88 = "gGrantingStarsSpriteTemplate", + gBattleAnimSpriteTemplate_83D6CA0 = "gSparklingStarsSpriteTemplate", + gBattleAnimSpriteTemplate_83D6D94 = "gSleepLetterZSpriteTemplate", + gBattleAnimSpriteTemplate_83D6DAC = "gLockOnTargetSpriteTemplate", + gBattleAnimSpriteTemplate_83D6DC4 = "gLockOnMoveTargetSpriteTemplate", + gBattleAnimSpriteTemplate_83D6DE4 = "gBowMonSpriteTemplate", + gBattleAnimSpriteTemplate_83D6E38 = "gSlashSliceSpriteTemplate", + gBattleAnimSpriteTemplate_83D6E50 = "gFalseSwipeSliceSpriteTemplate", + gBattleAnimSpriteTemplate_83D6E68 = "gFalseSwipePositionedSliceSpriteTemplate", + gBattleAnimSpriteTemplate_83D6EF0 = "gSharpenSphereSpriteTemplate", + gBattleAnimSpriteTemplate_83D6F08 = "gOctazookaBallSpriteTemplate", + gBattleAnimSpriteTemplate_83D6F3C = "gOctazookaSmokeSpriteTemplate", + gBattleAnimSpriteTemplate_83D6F80 = "gConversionSpriteTemplate", + gBattleAnimSpriteTemplate_83D6FB0 = "gConversion2SpriteTemplate", + gBattleAnimSpriteTemplate_83D6FC8 = "gMoonSpriteTemplate", + gBattleAnimSpriteTemplate_83D6FF8 = "gMoonlightSparkleSpriteTemplate", + gBattleAnimSpriteTemplate_83D7038 = "gHealingBlueStarSpriteTemplate", + gBattleAnimSpriteTemplate_83D7050 = "gHornHitSpriteTemplate", + gBattleAnimSpriteTemplate_83D7080 = "gSuperFangSpriteTemplate", + gBattleAnimSpriteTemplate_83D7114 = "gWavyMusicNotesSpriteTemplate", + gBattleAnimSpriteTemplate_83D715C = "gFastFlyingMusicNotesSpriteTemplate", + gBattleAnimSpriteTemplate_83D7174 = "gBellyDrumHandSpriteTemplate", + gBattleAnimSpriteTemplate_83D71A8 = "gSlowFlyingMusicNotesSpriteTemplate", + gBattleAnimSpriteTemplate_83D7220 = "gThoughtBubbleSpriteTemplate", + gBattleAnimSpriteTemplate_83D72C8 = "gMetronomeFingerSpriteTemplate", + gBattleAnimSpriteTemplate_83D72E0 = "gFollowMeFingerSpriteTemplate", + gBattleAnimSpriteTemplate_83D7358 = "gTauntFingerSpriteTemplate", + gBattleAnimSpriteTemplate_83D7450 = "gKinesisZapEnergySpriteTemplate", + gBattleAnimSpriteTemplate_83D748C = "gSwordsDanceBladeSpriteTemplate", + gBattleAnimSpriteTemplate_83D7564 = "gMetalSoundSpriteTemplate", + gBattleAnimSpriteTemplate_83D757C = "gWaterPulseRingSpriteTemplate", + gBattleAnimSpriteTemplate_83D7594 = "gEggThrowSpriteTemplate", + gBattleAnimSpriteTemplate_83D75E4 = "gCoinThrowSpriteTemplate", + gBattleAnimSpriteTemplate_83D75FC = "gFallingCoinSpriteTemplate", + gBattleAnimSpriteTemplate_83D7628 = "gBulletSeedSpriteTemplate", + gBattleAnimSpriteTemplate_83D765C = "gRazorWindTornadoSpriteTemplate", + gBattleAnimSpriteTemplate_83D769C = "gViceGripSpriteTemplate", + gBattleAnimSpriteTemplate_83D76DC = "gGuillotineSpriteTemplate", + gBattleAnimSpriteTemplate_83D7764 = "gBreathPuffSpriteTemplate", + gBattleAnimSpriteTemplate_83D7798 = "gAngerMarkSpriteTemplate", + gBattleAnimSpriteTemplate_83D77E0 = "gPencilSpriteTemplate", + gBattleAnimSpriteTemplate_83D77F8 = "gSnoreZSpriteTemplate", + gBattleAnimSpriteTemplate_83D7828 = "gExplosionSpriteTemplate", + gBattleAnimSpriteTemplate_83D78BC = "gSoftBoiledEggSpriteTemplate", + gBattleAnimSpriteTemplate_83D7928 = "gThinRingExpandingSpriteTemplate", + gBattleAnimSpriteTemplate_83D795C = "gThinRingShrinkingSpriteTemplate", + gBattleAnimSpriteTemplate_83D7974 = "gBlendThinRingExpandingSpriteTemplate", + gBattleAnimSpriteTemplate_83D798C = "gHyperVoiceRingSpriteTemplate", + gBattleAnimSpriteTemplate_83D79A4 = "gUproarRingSpriteTemplate", + gBattleAnimSpriteTemplate_83D7A44 = "gBellSpriteTemplate", + gBattleAnimSpriteTemplate_83D7A68 = "gHealBellMusicNoteSpriteTemplate", + gBattleAnimSpriteTemplate_83D7A80 = "gMagentaHeartSpriteTemplate", + gBattleAnimSpriteTemplate_83D7AB0 = "gRedHeartProjectileSpriteTemplate", + gBattleAnimSpriteTemplate_83D7AC8 = "gRedHeartBurstSpriteTemplate", + gBattleAnimSpriteTemplate_83D7AE0 = "gRedHeartRisingSpriteTemplate", + gBattleAnimSpriteTemplate_83D7B60 = "gSpitUpOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D7B94 = "gEyeSparkleSpriteTemplate", + gBattleAnimSpriteTemplate_83D7BB8 = "gAngelSpriteTemplate", + gBattleAnimSpriteTemplate_83D7BD0 = "gPinkHeartSpriteTemplate", + gBattleAnimSpriteTemplate_83D7C00 = "gDevilSpriteTemplate", + gBattleAnimSpriteTemplate_83D7C48 = "gFurySwipesSpriteTemplate", + gBattleAnimSpriteTemplate_83D7C90 = "gMovementWavesSpriteTemplate", + gBattleAnimSpriteTemplate_83D7CC8 = "gJaggedMusicNoteSpriteTemplate", + gBattleAnimSpriteTemplate_83D7D1C = "gPerishSongMusicNoteSpriteTemplate", + gBattleAnimSpriteTemplate_83D7D34 = "gPerishSongMusicNote2SpriteTemplate", + gBattleAnimSpriteTemplate_83D7D74 = "gGuardRingSpriteTemplate", + gBattleAnimSpriteTemplate_83D9178 = "gWaterBubbleProjectileSpriteTemplate", + gBattleAnimSpriteTemplate_83D91F4 = "gHydroPumpOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D920C = "gMudShotOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D9224 = "gSignalBeamRedOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D923C = "gSignalBeamGreenOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D9268 = "gFlamethrowerFlameSpriteTemplate", + gBattleAnimSpriteTemplate_83D9280 = "gPsywaveRingSpriteTemplate", + gBattleAnimSpriteTemplate_83D92D0 = "gHydroCannonChargeSpriteTemplate", + gBattleAnimSpriteTemplate_83D92E8 = "gHydroCannonBeamSpriteTemplate", + gBattleAnimSpriteTemplate_83D9318 = "gWaterGunProjectileSpriteTemplate", + gBattleAnimSpriteTemplate_83D9330 = "gWaterGunDropletSpriteTemplate", + gBattleAnimSpriteTemplate_83D9348 = "gSmallBubblePairSpriteTemplate", + gBattleAnimSpriteTemplate_83D9360 = "gSmallDriftingBubblesSpriteTemplate", + gBattleAnimSpriteTemplate_83D9408 = "gWaterPulseBubbleSpriteTemplate", + gBattleAnimSpriteTemplate_83D9438 = "gWeatherBallWaterDownSpriteTemplate", + gBattleAnimSpriteTemplate_83D9478 = "gFireSpiralInwardSpriteTemplate", + gBattleAnimSpriteTemplate_83D9490 = "gFireSpreadSpriteTemplate", + gBattleAnimSpriteTemplate_83D9508 = "gLargeFlameSpriteTemplate", + gBattleAnimSpriteTemplate_83D9520 = "gLargeFlameScatterSpriteTemplate", + gBattleAnimSpriteTemplate_83D9538 = "gFirePlumeSpriteTemplate", + gBattleAnimSpriteTemplate_83D95B0 = "gSunlightRaySpriteTemplate", + gBattleAnimSpriteTemplate_83D9614 = "gBurnFlameSpriteTemplate", + gBattleAnimSpriteTemplate_83D9694 = "gFireSpiralOutwardSpriteTemplate", + gBattleAnimSpriteTemplate_83D96AC = "gWeatherBallFireDownSpriteTemplate", + gBattleAnimSpriteTemplate_83D96F8 = "gEruptionFallingRockSpriteTemplate", + gBattleAnimSpriteTemplate_83D974C = "gWillOWispOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D977C = "gWillOWispFireSpriteTemplate", + gBattleAnimSpriteTemplate_83D97D0 = "gLightningSpriteTemplate", + gBattleAnimSpriteTemplate_83D985C = "gSparkElectricitySpriteTemplate", + gBattleAnimSpriteTemplate_83D9874 = "gZapCannonBallSpriteTemplate", + gBattleAnimSpriteTemplate_83D98A0 = "gZapCannonSparkSpriteTemplate", + gBattleAnimSpriteTemplate_83D98F0 = "gThunderboltOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D9908 = "gSparkElectricityFlashingSpriteTemplate", + gBattleAnimSpriteTemplate_83D9950 = "gThunderWaveSpriteTemplate", + gBattleAnimSpriteTemplate_83D9A6C = "gGrowingChargeOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D9A9C = "gElectricPuffSpriteTemplate", + gBattleAnimSpriteTemplate_83D9AB4 = "gVoltTackleOrbSlideSpriteTemplate", + gBattleAnimSpriteTemplate_83D9B28 = "gGrowingShockWaveOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83D9F78 = "gKarateChopSpriteTemplate", + gBattleAnimSpriteTemplate_83D9F90 = "gJumpKickSpriteTemplate", + gBattleAnimSpriteTemplate_83D9FC0 = "gFistFootRandomPosSpriteTemplate", + gBattleAnimSpriteTemplate_83D9FD8 = "gCrossChopHandSpriteTemplate", + gBattleAnimSpriteTemplate_83D9FF0 = "gSlidingKickSpriteTemplate", + gBattleAnimSpriteTemplate_83DA024 = "gSpinningHandOrFootSpriteTemplate", + gBattleAnimSpriteTemplate_83DA088 = "gDizzyPunchDuckSpriteTemplate", + gBattleAnimSpriteTemplate_83DA0A0 = "gBrickBreakWallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA0B8 = "gBrickBreakWallShardSpriteTemplate", + gBattleAnimSpriteTemplate_83DA0FC = "gSuperpowerOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83DA114 = "gSuperpowerRockSpriteTemplate", + gBattleAnimSpriteTemplate_83DA12C = "gSuperpowerFireballSpriteTemplate", + gBattleAnimSpriteTemplate_83DA144 = "gArmThrustHandSpriteTemplate", + gBattleAnimSpriteTemplate_83DA198 = "gRevengeSmallScratchSpriteTemplate", + gBattleAnimSpriteTemplate_83DA1E0 = "gRevengeBigScratchSpriteTemplate", + gBattleAnimSpriteTemplate_83DA214 = "gFocusPunchFistSpriteTemplate", + gBattleAnimSpriteTemplate_83DA244 = "gToxicBubbleSpriteTemplate", + gBattleAnimSpriteTemplate_83DA2B8 = "gSludgeProjectileSpriteTemplate", + gBattleAnimSpriteTemplate_83DA2D0 = "gAcidPoisonBubbleSpriteTemplate", + gBattleAnimSpriteTemplate_83DA2E8 = "gSludgeBombHitParticleSpriteTemplate", + gBattleAnimSpriteTemplate_83DA31C = "gAcidPoisonDropletSpriteTemplate", + gBattleAnimSpriteTemplate_83DA380 = "gEllipticalGustSpriteTemplate", + gBattleAnimSpriteTemplate_83DA3B4 = "gGustToTargetSpriteTemplate", + gBattleAnimSpriteTemplate_83DA3E4 = "gAirWaveCrescentSpriteTemplate", + gBattleAnimSpriteTemplate_83DA450 = "gFlyBallUpSpriteTemplate", + gBattleAnimSpriteTemplate_83DA468 = "gFlyBallAttackSpriteTemplate", + gBattleAnimSpriteTemplate_83DA498 = "gFallingFeatherSpriteTemplate", + gBattleAnimSpriteTemplate_83DA51C = "gWhirlwindLineSpriteTemplate", + gBattleAnimSpriteTemplate_83DA568 = "gBounceBallShrinkSpriteTemplate", + gBattleAnimSpriteTemplate_83DA594 = "gBounceBallLandSpriteTemplate", + gBattleAnimSpriteTemplate_83DA5D8 = "gDiveBallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA614 = "gDiveWaterSplashSpriteTemplate", + gBattleAnimSpriteTemplate_83DA62C = "gSprayWaterDropletSpriteTemplate", + gBattleAnimSpriteTemplate_83DA65C = "gSkyAttackBirdSpriteTemplate", + gBattleAnimSpriteTemplate_83DA690 = "gPsychUpSpiralSpriteTemplate", + gBattleAnimSpriteTemplate_83DA6A8 = "gLightScreenWallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA6C0 = "gReflectWallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA6D8 = "gMirrorCoatWallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA6F0 = "gBarrierWallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA708 = "gMagicCoatWallSpriteTemplate", + gBattleAnimSpriteTemplate_83DA73C = "gReflectSparkleSpriteTemplate", + gBattleAnimSpriteTemplate_83DA76C = "gSpecialScreenSparkleSpriteTemplate", + gBattleAnimSpriteTemplate_83DA784 = "gGoldRingSpriteTemplate", + gBattleAnimSpriteTemplate_83DA824 = "gBentSpoonSpriteTemplate", + gBattleAnimSpriteTemplate_83DA88C = "gQuestionMarkSpriteTemplate", + gBattleAnimSpriteTemplate_83DA8F4 = "gRedXSpriteTemplate", + gBattleAnimSpriteTemplate_83DA9E0 = "gLusterPurgeCircleSpriteTemplate", + gBattleAnimSpriteTemplate_83DAA68 = "gPsychoBoostOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83DAABC = "gMegahornHornSpriteTemplate", + gBattleAnimSpriteTemplate_83DAB10 = "gLeechLifeNeedleSpriteTemplate", + gBattleAnimSpriteTemplate_83DAB28 = "gWebThreadSpriteTemplate", + gBattleAnimSpriteTemplate_83DAB40 = "gStringWrapSpriteTemplate", + gBattleAnimSpriteTemplate_83DAB74 = "gSpiderWebSpriteTemplate", + gBattleAnimSpriteTemplate_83DAC10 = "gTailGlowOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83DAC4C = "gFallingRockSpriteTemplate", + gBattleAnimSpriteTemplate_83DAC64 = "gRockFragmentSpriteTemplate", + gBattleAnimSpriteTemplate_83DAC7C = "gSwirlingDirtSpriteTemplate", + gBattleAnimSpriteTemplate_83DACB8 = "gWhirlpoolSpriteTemplate", + gBattleAnimSpriteTemplate_83DACD0 = "gFireSpinSpriteTemplate", + gBattleAnimSpriteTemplate_83DADA8 = "gRockTombRockSpriteTemplate", + gBattleAnimSpriteTemplate_83DADE8 = "gRockBlastRockSpriteTemplate", + gBattleAnimSpriteTemplate_83DAE00 = "gRockScatterSpriteTemplate", + gBattleAnimSpriteTemplate_83DAE18 = "gTwisterRockSpriteTemplate", + gBattleAnimSpriteTemplate_83DAE30 = "gWeatherBallRockDownSpriteTemplate", + gBattleAnimSpriteTemplate_83DAE64 = "gConfuseRayBallBounceSpriteTemplate", + gBattleAnimSpriteTemplate_83DAE7C = "gConfuseRayBallSpiralSpriteTemplate", + gBattleAnimSpriteTemplate_83DAEDC = "gLickSpriteTemplate", + gBattleAnimSpriteTemplate_83DAF20 = "gCurseNailSpriteTemplate", + gBattleAnimSpriteTemplate_83DAF38 = "gCurseGhostSpriteTemplate", + gBattleAnimSpriteTemplate_83DAF50 = "gNightmareDevilSpriteTemplate", + gBattleAnimSpriteTemplate_83DAFCC = "gOutrageFlameSpriteTemplate", + gBattleAnimSpriteTemplate_83DB044 = "gDragonBreathFireSpriteTemplate", + gBattleAnimSpriteTemplate_83DB078 = "gDragonRageFirePlumeSpriteTemplate", + gBattleAnimSpriteTemplate_83DB0D0 = "gDragonRageFireSpitSpriteTemplate", + gBattleAnimSpriteTemplate_83DB0E8 = "gDragonDanceOrbSpriteTemplate", + gBattleAnimSpriteTemplate_83DB100 = "gOverheatFlameSpriteTemplate", + gBattleAnimSpriteTemplate_83DB1D0 = "gSharpTeethSpriteTemplate", + gBattleAnimSpriteTemplate_83DB1E8 = "gClampJawSpriteTemplate", + gBattleAnimSpriteTemplate_83DB238 = "gTearDropSpriteTemplate", + gBattleAnimSpriteTemplate_83DB288 = "gClawSlashSpriteTemplate", + gBattleAnimSpriteTemplate_83DB3DC = "gComplexPaletteBlendSpriteTemplate", + gBattleAnimSpriteTemplate_83DB428 = "gShakeMonOrTerrainSpriteTemplate", + gBattleAnimSpriteTemplate_83DB4C0 = "gHandleInvertHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_83DB4D8 = "gWaterHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_83DB4F0 = "gRandomPosHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_83DB508 = "gMonEdgeHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_83DB520 = "gCrossImpactSpriteTemplate", + gBattleAnimSpriteTemplate_83DB538 = "gFlashingHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_83DB550 = "gPersistHitSplatSpriteTemplate", + gBattleAnimSpriteTemplate_8402180 = "gScratchSpriteTemplate", + gBattleAnimSpriteTemplate_8402198 = "gBlackSmokeSpriteTemplate", + gBattleAnimSpriteTemplate_84021B0 = "gBlackBallSpriteTemplate", + gBattleAnimSpriteTemplate_84021DC = "gOpeningEyeSpriteTemplate", + gBattleAnimSpriteTemplate_84021F4 = "gWhiteHaloSpriteTemplate", + gBattleAnimSpriteTemplate_840220C = "gTealAlertSpriteTemplate", + gBattleAnimSpriteTemplate_8402264 = "gMeanLookEyeSpriteTemplate", + gBattleAnimSpriteTemplate_840227C = "gSpikesSpriteTemplate", + gBattleAnimSpriteTemplate_84022B0 = "gLeerSpriteTemplate", + gBattleAnimSpriteTemplate_84022F0 = "gLetterZSpriteTemplate", + gBattleAnimSpriteTemplate_840233C = "gFangSpriteTemplate", + gBattleAnimSpriteTemplate_840238C = "gSpotlightSpriteTemplate", + gBattleAnimSpriteTemplate_84023A4 = "gClappingHandSpriteTemplate", + gBattleAnimSpriteTemplate_84023BC = "gClappingHand2SpriteTemplate", + gBattleAnimSpriteTemplate_84023E8 = "gRapidSpinSpriteTemplate", + gBattleAnimSpriteTemplate_8402458 = "gTriAttackTriangleSpriteTemplate", + gBattleAnimSpriteTemplate_8402498 = "gEclipsingOrbSpriteTemplate", + gBattleAnimSpriteTemplate_84024D0 = "gBatonPassPokeballSpriteTemplate", + gBattleAnimSpriteTemplate_84024E8 = "gWishStarSpriteTemplate", + gBattleAnimSpriteTemplate_8402578 = "gSwallowBlueOrbSpriteTemplate", + gBattleAnimSpriteTemplate_84025EC = "gGreenStarSpriteTemplate", + gBattleAnimSpriteTemplate_8402630 = "gWeakFrustrationAngerMarkSpriteTemplate", + gBattleAnimSpriteTemplate_84026A4 = "gSweetScentPetalSpriteTemplate", + gBattleAnimSpriteTemplate_84026F0 = "gPainSplitProjectileSpriteTemplate", + gBattleAnimSpriteTemplate_8402708 = "gFlatterConfettiSpriteTemplate", + gBattleAnimSpriteTemplate_8402720 = "gFlatterSpotlightSpriteTemplate", + gBattleAnimSpriteTemplate_8402738 = "gReversalOrbSpriteTemplate", + gBattleAnimSpriteTemplate_84027EC = "gYawnCloudSpriteTemplate", + gBattleAnimSpriteTemplate_8402934 = "gRoarNoiseLineSpriteTemplate", + gBattleAnimSpriteTemplate_8402964 = "gAssistPawprintSpriteTemplate", + gBattleAnimSpriteTemplate_84029C4 = "gSmellingSaltsHandSpriteTemplate", + gBattleAnimSpriteTemplate_84029F4 = "gSmellingSaltExclamationSpriteTemplate", + gBattleAnimSpriteTemplate_8402A0C = "gHelpingHandClapSpriteTemplate", + gBattleAnimSpriteTemplate_8402A24 = "gForesightMagnifyingGlassSpriteTemplate", + gBattleAnimSpriteTemplate_8402A3C = "gMeteorMashStarSpriteTemplate", + gBattleAnimSpriteTemplate_8402A6C = "gBlockXSpriteTemplate", + gBattleAnimSpriteTemplate_8402AE4 = "gKnockOffStrikeSpriteTemplate", + gBattleAnimSpriteTemplate_8402B10 = "gRecycleSpriteTemplate", + gBattleAnimSpriteTemplate_840B4FC = "gSafariBaitSpriteTemplate", + gBattleAnimSpriteTemplate_EndureFlame = "gEndureEnergySpriteTemplate", + gBoneHitSpriteTemplate = "gSpinningBoneSpriteTemplate", + gBonemerangSpriteTemplate = "gBonemerangSpriteTemplate", + gConfusionDuckSpriteTemplate = "gConfusionDuckSpriteTemplate", + gCuttingSliceSpriteTemplate = "gCuttingSliceSpriteTemplate", + gDigDirtMoundSpriteTemplate = "gDirtMoundSpriteTemplate", + gDirtPlumeSpriteTemplate = "gDirtPlumeSpriteTemplate", + gElectricitySpriteTemplate = "gElectricitySpriteTemplate", + gEmberFlareSpriteTemplate = "gEmberFlareSpriteTemplate", + gEmberSpriteTemplate = "gEmberSpriteTemplate", + gFallingItemBagTemplate = "gKnockOffItemSpriteTemplate", + gFireCrossSpriteTemplate = "gFireBlastCrossSpriteTemplate", + gFireRingSpriteTemplate = "gFireBlastRingSpriteTemplate", + gFistFootSpriteTemplate = "gFistFootSpriteTemplate", + gFlyingDirtSpriteTemplate = "gFlyingSandCrescentSpriteTemplate", + gHiddenPowerOrbScatterSpriteTemplate = "gHiddenPowerOrbScatterSpriteTemplate", + gHiddenPowerOrbSpriteTemplate = "gHiddenPowerOrbSpriteTemplate", + gHorizontalLungeSpriteTemplate = "gHorizontalLungeSpriteTemplate", + gIceBallParticleSpriteTemplate = "gIceBallImpactShardSpriteTemplate", + gIceBallSpriteTemplate = "gIceBallChunkSpriteTemplate", + gIceBeamOuterParticleSpriteTemplate = "gIceBeamOuterCrystalSpriteTemplate", + gIceSpikeSpriteTemplate = "gIceGroundSpikeSpriteTemplate", + gIcicleSpearSpriteTemplate = "gIcicleSpearSpriteTemplate", + gLargeIceEffectParticleSpriteTemplate = "gIceCrystalHitLargeSpriteTemplate", + gLargeIcePunchCrystalSpriteTemplate = "gIceCrystalSpiralInwardLarge", + gLargeSnowballSpriteTemplate = "gBlizzardIceCrystalSpriteTemplate", + gLeechSeedSpriteTemplate = "gLeechSeedSpriteTemplate", + gLinearStingerSpriteTemplate = "gLinearStingerSpriteTemplate", + gMegaPunchKickSpriteTemplate = "gMegaPunchKickSpriteTemplate", + gMistBallSpriteTemplate = "gMistBallSpriteTemplate", + gMistCloudSpriteTemplate = "gMistCloudSpriteTemplate", + gMudSlapDirtSpriteTemplate = "gMudSlapMudSpriteTemplate", + gMudSportDirtSpriteTemplate = "gMudsportMudSpriteTemplate", + gPinMissileSpriteTemplate = "gPinMissileSpriteTemplate", + gPinkSmokeTemplate = "gSmokeBallEscapeCloudSpriteTemplate", + gPoisonBubbleSpriteTemplate = "gPoisonBubbleSpriteTemplate", + gPoisonGasCloudSpriteTemplate = "gPoisonGasCloudSpriteTemplate", + gPoisonPowderParticleSpriteTemplate = "gPoisonPowderParticleSpriteTemplate", + gSandAttackDirtSpriteTemplate = "gSandAttackDirtSpriteTemplate", + gScreechWaveSpriteTemplate = "gScreechRingSpriteTemplate", + gShadowBallSpriteTemplate = "gShadowBallSpriteTemplate", + gSimplePaletteBlendSpriteTemplate = "gSimplePaletteBlendSpriteTemplate", + gSleepPowderParticleSpriteTemplate = "gSleepPowderParticleSpriteTemplate", + gSlideMonToOffsetSpriteTemplate = "gSlideMonToOffsetSpriteTemplate", + gSlideMonToOriginalPosSpriteTemplate = "gSlideMonToOriginalPosSpriteTemplate", + gSmallIceEffectParticleSpriteTemplate = "gIceCrystalHitSmallSpriteTemplate", + gSmallIcePunchCrystalSpriteTemplate = "gIceCrystalSpiralInwardSmall", + gSmallSnowballSpriteTemplate1 = "gSwirlingSnowballSpriteTemplate", + gSmallSnowballSpriteTemplate2 = "gPowderSnowSnowballSpriteTemplate", + gSmogCloudSpriteTemplate = "gSmogCloudSpriteTemplate", + gSonicBoomSpriteTemplate = "gSonicBoomSpriteTemplate", + gStompFootSpriteTemplate = "gStompFootSpriteTemplate", + gStunSporeParticleSpriteTemplate = "gStunSporeParticleSpriteTemplate", + gSupersonicWaveSpriteTemplate = "gSupersonicRingSpriteTemplate", + gSwiftStarSpriteTemplate = "gSwiftStarSpriteTemplate", + gVerticalDipSpriteTemplate = "gVerticalDipSpriteTemplate", + gWaterBubbleSpriteTemplate = "gWaterBubbleSpriteTemplate", + gWeatherBallHailSpriteTemplate = "gWeatherBallIceDownSpriteTemplate", }, callbacks = { - sub_807A908 = "sub_807A908", - sub_807A9BC = "sub_807A9BC", - sub_807A63C = "sub_807A63C", - ["AnimAuroraRings"] = "AuroraRings", - ["AnimBasicFistOrFoot"] = "BasicFistOrFoot", - ["AnimBoneHitProjectile"] = "BoneHitProjectile", - ["AnimBonemerangProjectile"] = "BonemerangProjectile", - ["AnimBubbleEffect"] = "BubbleEffect", - ["AnimConfusionDuck"] = "ConfusionDuck", - ["AnimCuttingSlice"] = "CuttingSlice", - ["AnimDigDirtMound"] = "DigDirtMound", - ["AnimDirtParticleAcrossScreen"] = "DirtParticleAcrossScreen", - ["AnimDirtScatter"] = "DirtScatter", - ["AnimEmberFlare"] = "EmberFlare", - ["AnimFallingFeather"] = "FallingFeather", - ["AnimFireCross"] = "FireCross", - ["AnimFireRing"] = "FireRing", - ["AnimFissureDirtPlumeParticle"] = "FissureDirtPlumeParticle", - ["AnimIceBeamParticle"] = "IceBeamParticle", - ["AnimIceEffectParticle"] = "IceEffectParticle", - ["AnimIcePunchSwirlingParticle"] = "IcePunchSwirlingParticle", - ["AnimKnockOffStrike"] = "KnockOffStrike", - ["AnimLeechSeed"] = "LeechSeed", - ["AnimMissileArc"] = "MissileArc", - ["AnimMoveParticleBeyondTarget"] = "MoveParticleBeyondTarget", - ["AnimMovePowderParticle"] = "MovePowderParticle", - ["AnimMoveTwisterParticle"] = "MoveTwisterParticle", - ["AnimMudSportDirt"] = "MudSportDirt", - ["AnimOrbitFast"] = "OrbitFast", - ["AnimOrbitScatter"] = "OrbitScatter", - ["AnimRaiseSprite"] = "RaiseSprite", - ["AnimRecycle"] = "Recycle", - ["AnimSimplePaletteBlend"] = "SimplePaletteBlend", - ["AnimSonicBoomProjectile"] = "SonicBoomProjectile", - ["AnimSpinningKickOrPunch"] = "SpinningKickOrPunch", - ["AnimStompFoot"] = "StompFoot", - ["AnimSwirlingSnowball_Step1"] = "SwirlingSnowball_Step1", - ["AnimThrowMistBall"] = "ThrowMistBall", - ["AnimTranslateLinearSingleSineWave"] = "TranslateLinearSingleSineWave", - ["AnimTranslateStinger"] = "TranslateStinger", - ["AnimWaveFromCenterOfTarget"] = "WaveFromCenterOfTarget", - ["DoHorizontalLunge"] = "DoHorizontalLunge", - ["DoVerticalDip"] = "DoVerticalDip", - ["InitAnimShadowBall"] = "InitAnimShadowBall", - ["InitIceBallAnim"] = "InitIceBallAnim", - ["InitIceBallParticle"] = "InitIceBallParticle", - ["InitPoisonGasCloudAnim"] = "InitPoisonGasCloudAnim", - ["InitSwirlingFogAnim"] = "InitSwirlingFogAnim", - ["SlideMonToOffset"] = "SlideMonToOffset", - ["SlideMonToOriginalPos"] = "SlideMonToOriginalPos", - ["TranslateAnimSpriteToTargetMonLocation"] = "TranslateAnimSpriteToTargetMonLocation", - ["sub_80793C4"] = "sub_80793C4", - ["sub_80794A8"] = "sub_80794A8", - ["sub_8079534"] = "sub_8079534", - ["sub_807A9BC"] = "sub_807A9BC", - ["sub_80A8818"] = "sub_80A8818", - ["sub_80CA7B0"] = "sub_80CA7B0", - ["sub_80CA800"] = "sub_80CA800", - ["sub_80CA9A8"] = "sub_80CA9A8", - ["sub_80CAA14"] = "sub_80CAA14", - ["sub_80CABF8"] = "sub_80CABF8", - ["sub_80CAD54"] = "sub_80CAD54", - ["sub_80CAE20"] = "sub_80CAE20", - ["sub_80CAED8"] = "sub_80CAED8", - ["sub_80CB25C"] = "sub_80CB25C", - ["sub_80CB4CC"] = "sub_80CB4CC", - ["sub_80CB59C"] = "sub_80CB59C", - ["sub_80CB620"] = "sub_80CB620", - ["sub_80CB768"] = "sub_80CB768", - ["sub_80CB94C"] = "sub_80CB94C", - ["sub_80CBA28"] = "sub_80CBA28", - ["sub_80CBAA4"] = "sub_80CBAA4", - ["sub_80CBAE8"] = "sub_80CBAE8", - ["sub_80CBBF0"] = "sub_80CBBF0", - ["sub_80CC474"] = "sub_80CC474", - ["sub_80CC6CC"] = "sub_80CC6CC", - ["sub_80CC884"] = "sub_80CC884", - ["sub_80CC9BC"] = "sub_80CC9BC", - ["sub_80CCD24"] = "sub_80CCD24", - ["sub_80CCF04"] = "sub_80CCF04", - ["sub_80CD140"] = "sub_80CD140", - ["sub_80CD190"] = "sub_80CD190", - ["sub_80CD328"] = "sub_80CD328", - ["sub_80CD3E0"] = "sub_80CD3E0", - ["sub_80CD6CC"] = "sub_80CD6CC", - ["sub_80CD774"] = "sub_80CD774", - ["sub_80CDD74"] = "sub_80CDD74", - ["sub_80CDDDC"] = "sub_80CDDDC", - ["sub_80CDE24"] = "sub_80CDE24", - ["sub_80CDF0C"] = "sub_80CDF0C", - ["sub_80CDFB0"] = "sub_80CDFB0", - ["sub_80CE09C"] = "sub_80CE09C", - ["sub_80CE17C"] = "sub_80CE17C", - ["sub_80CE30C"] = "sub_80CE30C", - ["sub_80CE36C"] = "sub_80CE36C", - ["sub_80CE670"] = "sub_80CE670", - ["sub_80CEA04"] = "sub_80CEA04", - ["sub_80CEB0C"] = "sub_80CEB0C", - ["sub_80CECE8"] = "sub_80CECE8", - ["sub_80CEDF0"] = "sub_80CEDF0", - ["sub_80CEE60"] = "sub_80CEE60", - ["sub_80CEF9C"] = "sub_80CEF9C", - ["sub_80CF040"] = "sub_80CF040", - ["sub_80CF0BC"] = "sub_80CF0BC", - ["sub_80CF1C8"] = "sub_80CF1C8", - ["sub_80CF610"] = "sub_80CF610", - ["sub_80CF690"] = "sub_80CF690", - ["sub_80CFE9C"] = "sub_80CFE9C", - ["sub_80CFF50"] = "sub_80CFF50", - ["sub_80CFFD8"] = "sub_80CFFD8", - ["sub_80D0118"] = "sub_80D0118", - ["sub_80D0178"] = "sub_80D0178", - ["sub_80D0228"] = "sub_80D0228", - ["sub_80D0930"] = "sub_80D0930", - ["sub_80D09C0"] = "sub_80D09C0", - ["sub_80D0E30"] = "sub_80D0E30", - ["sub_80D0FD8"] = "sub_80D0FD8", - ["sub_80D10B8"] = "sub_80D10B8", - ["sub_80D1318"] = "sub_80D1318", - ["sub_80D1368"] = "sub_80D1368", - ["sub_80D1C08"] = "sub_80D1C08", - ["sub_80D1C80"] = "sub_80D1C80", - ["sub_80D1F58"] = "sub_80D1F58", - ["sub_80D1FDC"] = "sub_80D1FDC", - ["sub_80D2064"] = "sub_80D2064", - ["sub_80D28AC"] = "sub_80D28AC", - ["sub_80D2920"] = "sub_80D2920", - ["sub_80D2938"] = "sub_80D2938", - ["sub_80D2A38"] = "sub_80D2A38", - ["sub_80D2ABC"] = "sub_80D2ABC", - ["sub_80D2BE8"] = "sub_80D2BE8", - ["sub_80D2C38"] = "sub_80D2C38", - ["sub_80D2D68"] = "sub_80D2D68", - ["sub_80D2E68"] = "sub_80D2E68", - ["sub_80D2EC8"] = "sub_80D2EC8", - ["sub_80D3014"] = "sub_80D3014", - ["sub_80D31C8"] = "sub_80D31C8", - ["sub_80D3554"] = "sub_80D3554", - ["sub_80D3698"] = "sub_80D3698", - ["sub_80D3728"] = "sub_80D3728", - ["sub_80D37FC"] = "sub_80D37FC", - ["sub_80D3838"] = "sub_80D3838", - ["sub_80D4044"] = "sub_80D4044", - ["sub_80D4BF0"] = "sub_80D4BF0", - ["sub_80D4CA4"] = "sub_80D4CA4", - ["sub_80D4ED8"] = "sub_80D4ED8", - ["sub_80D4F18"] = "sub_80D4F18", - ["sub_80D4F5C"] = "sub_80D4F5C", - ["sub_80D4FCC"] = "sub_80D4FCC", - ["sub_80D517C"] = "sub_80D517C", - ["sub_80D5210"] = "sub_80D5210", - ["sub_80D53B4"] = "sub_80D53B4", - ["sub_80D5A20"] = "sub_80D5A20", - ["sub_80D5B0C"] = "sub_80D5B0C", - ["sub_80D5CC0"] = "sub_80D5CC0", - ["sub_80D61C8"] = "sub_80D61C8", - ["sub_80D6328"] = "sub_80D6328", - ["sub_80D648C"] = "sub_80D648C", - ["sub_80D65DC"] = "sub_80D65DC", - ["sub_80D6658"] = "sub_80D6658", - ["sub_80D679C"] = "sub_80D679C", - ["sub_80D6A6C"] = "sub_80D6A6C", - ["sub_80D6D18"] = "sub_80D6D18", - ["sub_80D6D70"] = "sub_80D6D70", - ["sub_80D6DD8"] = "sub_80D6DD8", - ["sub_80D727C"] = "sub_80D727C", - ["sub_80D902C"] = "sub_80D902C", - ["sub_80D9078"] = "sub_80D9078", - ["sub_80D90F4"] = "sub_80D90F4", - ["sub_80D92D0"] = "sub_80D92D0", - ["sub_80D9378"] = "sub_80D9378", - ["sub_80D9540"] = "sub_80D9540", - ["sub_80D95D0"] = "sub_80D95D0", - ["sub_80D96B8"] = "sub_80D96B8", - ["sub_80D97CC"] = "sub_80D97CC", - ["sub_80D98D8"] = "sub_80D98D8", - ["sub_80D9A38"] = "sub_80D9A38", - ["sub_80D9B48"] = "sub_80D9B48", - ["sub_80D9BD4"] = "sub_80D9BD4", - ["sub_80D9C40"] = "sub_80D9C40", - ["sub_80D9D70"] = "sub_80D9D70", - ["sub_80D9DF0"] = "sub_80D9DF0", - ["sub_80D9E94"] = "sub_80D9E94", - ["sub_80D9F14"] = "sub_80D9F14", - ["sub_80DA034"] = "sub_80DA034", - ["sub_80DA16C"] = "sub_80DA16C", - ["sub_80DA208"] = "sub_80DA208", - ["sub_80DA300"] = "sub_80DA300", - ["sub_80DA38C"] = "sub_80DA38C", - ["sub_80DB000"] = "sub_80DB000", - ["sub_80DB194"] = "sub_80DB194", - ["sub_80DB1F4"] = "sub_80DB1F4", - ["sub_80DB288"] = "sub_80DB288", - ["sub_80DB374"] = "sub_80DB374", - ["sub_80DB458"] = "sub_80DB458", - ["sub_80DB5E4"] = "sub_80DB5E4", - ["sub_80DB74C"] = "sub_80DB74C", - ["sub_80DBA4C"] = "sub_80DBA4C", - ["sub_80DBAF4"] = "sub_80DBAF4", - ["sub_80DBB70"] = "sub_80DBB70", - ["sub_80DC068"] = "sub_80DC068", - ["sub_80DC700"] = "sub_80DC700", - ["sub_80DC824"] = "sub_80DC824", - ["sub_80DC8F4"] = "sub_80DC8F4", - ["sub_80DC9A0"] = "sub_80DC9A0", - ["sub_80DCA70"] = "sub_80DCA70", - ["sub_80DCB38"] = "sub_80DCB38", - ["sub_80DCE40"] = "sub_80DCE40", - ["sub_80DCE9C"] = "sub_80DCE9C", - ["sub_80DCF60"] = "sub_80DCF60", - ["sub_80DCFE4"] = "sub_80DCFE4", - ["sub_80DD8E8"] = "sub_80DD8E8", - ["sub_80DD978"] = "sub_80DD978", - ["sub_80DD9A4"] = "sub_80DD9A4", - ["sub_80DDB6C"] = "sub_80DDB6C", - ["sub_80DDD58"] = "sub_80DDD58", - ["sub_80DE0FC"] = "sub_80DE0FC", - ["sub_80DEF3C"] = "sub_80DEF3C", - ["sub_80DF0B8"] = "sub_80DF0B8", - ["sub_80DF5A0"] = "sub_80DF5A0", - ["sub_80DF6F0"] = "sub_80DF6F0", - ["sub_80DF760"] = "sub_80DF760", - ["sub_80DF78C"] = "sub_80DF78C", - ["sub_80DFB28"] = "sub_80DFB28", - ["sub_80DFF1C"] = "sub_80DFF1C", - ["sub_80DFFD0"] = "sub_80DFFD0", - ["sub_80E0A10"] = "sub_80E0A10", - ["sub_80E1E2C"] = "sub_80E1E2C", - ["sub_80E24B8"] = "sub_80E24B8", - ["sub_80E27A0"] = "sub_80E27A0", - ["sub_80E27E8"] = "sub_80E27E8", - ["sub_80E2838"] = "sub_80E2838", - ["sub_80E2870"] = "sub_80E2870", - ["sub_80E2908"] = "sub_80E2908", - ["sub_80E2978"] = "sub_80E2978", - ["sub_80E29C0"] = "sub_80E29C0", - ["sub_812C144"] = "sub_812C144", - ["sub_812C220"] = "sub_812C220", - ["sub_812C2BC"] = "sub_812C2BC", - ["sub_812C358"] = "sub_812C358", - ["sub_812C720"] = "sub_812C720", - ["sub_812C80C"] = "sub_812C80C", - ["sub_812C848"] = "sub_812C848", - ["sub_812C908"] = "sub_812C908", - ["sub_812C990"] = "sub_812C990", - ["sub_812CAFC"] = "sub_812CAFC", - ["sub_812CC28"] = "sub_812CC28", - ["sub_812CCE8"] = "sub_812CCE8", - ["sub_812D294"] = "sub_812D294", - ["sub_812D3AC"] = "sub_812D3AC", - ["sub_812D4B4"] = "sub_812D4B4", - ["sub_812D724"] = "sub_812D724", - ["sub_812DEAC"] = "sub_812DEAC", - ["sub_812E4F0"] = "sub_812E4F0", - ["sub_812E7A0"] = "sub_812E7A0", - ["sub_812EA4C"] = "sub_812EA4C", - ["sub_812EC78"] = "sub_812EC78", - ["sub_812ED84"] = "sub_812ED84", - ["sub_812EEA4"] = "sub_812EEA4", - ["sub_812F88C"] = "sub_812F88C", - ["sub_812F948"] = "sub_812F948", - ["sub_81300F4"] = "sub_81300F4", - ["sub_813051C"] = "sub_813051C", - ["sub_81307B0"] = "sub_81307B0", - ["sub_8130A2C"] = "sub_8130A2C", - ["sub_8130AEC"] = "sub_8130AEC", - ["sub_8130F5C"] = "sub_8130F5C", - ["sub_8131264"] = "sub_8131264", - ["sub_8131564"] = "sub_8131564", - ["sub_8141C30"] = "sub_8141C30", + AnimAuroraRings = "AuroraBeamRings", + AnimBasicFistOrFoot = "BasicFistOrFoot", + AnimBoneHitProjectile = "BoneHitProjectile", + AnimBonemerangProjectile = "BonemerangProjectile", + AnimBubbleEffect = "BubbleEffect", + AnimConfusionDuck = "ConfusionDuck", + AnimCuttingSlice = "CuttingSlice", + AnimDigDirtMound = "DigDirtMound", + AnimDirtParticleAcrossScreen = "FlyingSandCrescent", + AnimDirtScatter = "DirtScatter", + AnimEmberFlare = "EmberFlare", + AnimFallingFeather = "FallingFeather", + AnimFireCross = "FireCross", + AnimFireRing = "FireRing", + AnimFissureDirtPlumeParticle = "DirtPlumeParticle", + AnimIceBeamParticle = "IceBeamParticle", + AnimIceEffectParticle = "IceEffectParticle", + AnimIcePunchSwirlingParticle = "IcePunchSwirlingParticle", + AnimKnockOffStrike = "KnockOffStrike", + AnimLeechSeed = "LeechSeed", + AnimMissileArc = "MissileArc", + AnimMoveParticleBeyondTarget = "MoveParticleBeyondTarget", + AnimMovePowderParticle = "MovePowderParticle", + AnimMoveTwisterParticle = "MoveTwisterParticle", + AnimMudSportDirt = "MudSportDirt", + AnimOrbitFast = "OrbitFast", + AnimOrbitScatter = "OrbitScatter", + AnimRaiseSprite = "RaiseSprite", + AnimRecycle = "Recycle", + AnimSimplePaletteBlend = "SimplePaletteBlend", + AnimSonicBoomProjectile = "SonicBoomProjectile", + AnimSpinningKickOrPunch = "SpinningKickOrPunch", + AnimStompFoot = "StompFoot", + AnimSwirlingSnowball_Step1 = "SwirlingSnowball", + AnimThrowMistBall = "ThrowMistBall", + AnimTranslateLinearSingleSineWave = "TranslateLinearSingleSineWave", + AnimTranslateStinger = "TranslateStinger", + AnimWaveFromCenterOfTarget = "WaveFromCenterOfTarget", + DoHorizontalLunge = "DoHorizontalLunge", + DoVerticalDip = "DoVerticalDip", + InitAnimShadowBall = "ShadowBall", + InitIceBallAnim = "InitIceBallAnim", + InitIceBallParticle = "InitIceBallParticle", + InitPoisonGasCloudAnim = "InitPoisonGasCloudAnim", + InitSwirlingFogAnim = "InitSwirlingFogAnim", + SlideMonToOffset = "SlideMonToOffset", + SlideMonToOriginalPos = "SlideMonToOriginalPos", + TranslateAnimSpriteToTargetMonLocation = "TranslateAnimSpriteToTargetMonLocation", + sub_80793C4 = "sub_80793C4", + sub_80794A8 = "sub_80794A8", + sub_8079534 = "sub_8079534", + sub_807A63C = "SpinningSparkle", + sub_807A908 = "WeatherBallUp", + sub_807A9BC = "WeatherBallDown", + sub_80A8818 = "SlideMonToOffsetAndBack", + sub_80CA7B0 = "PowerAbsorptionOrb", + sub_80CA800 = "SolarBeamBigOrb", + sub_80CA9A8 = "sub_80CA9A8", + sub_80CAA14 = "HyperBeamOrb", + sub_80CABF8 = "SporeParticle", + sub_80CAD54 = "PetalDanceBigFlower", + sub_80CAE20 = "PetalDanceSmallFlower", + sub_80CAED8 = "RazorLeafParticle", + sub_80CB25C = "ConstrictBinding", + sub_80CB4CC = "MimicOrb", + sub_80CB59C = "IngrainRoot", + sub_80CB620 = "FrenzyPlantRoot", + sub_80CB768 = "IngrainOrb", + sub_80CB94C = "Present", + sub_80CBA28 = "KnockOffItem", + sub_80CBAA4 = "PresentHealParticle", + sub_80CBAE8 = "ItemSteal", + sub_80CBBF0 = "TrickBag", + sub_80CC474 = "FlyingParticle", + sub_80CC6CC = "NeedleArmSpike", + sub_80CC884 = "WhipHit", + sub_80CC9BC = "AirCutterSlice", + sub_80CCD24 = "Protect", + sub_80CCF04 = "MilkBottle", + sub_80CD140 = "GrantingStars", + sub_80CD190 = "SparklingStars", + sub_80CD328 = "SleepLetterZ", + sub_80CD3E0 = "LockOnTarget", + sub_80CD6CC = "LockOnMoveTarget", + sub_80CD774 = "BowMon", + sub_80CDD74 = "SlashSlice", + sub_80CDDDC = "FalseSwipeSlice", + sub_80CDE24 = "FalseSwipePositionedSlice", + sub_80CDF0C = "sub_80CDF0C", + sub_80CDFB0 = "SharpenSphere", + sub_80CE09C = "Conversion", + sub_80CE17C = "Conversion2", + sub_80CE30C = "Moon", + sub_80CE36C = "MoonlightSparkle", + sub_80CE670 = "HornHit", + sub_80CEA04 = "SuperFang", + sub_80CEB0C = "WavyMusicNotes", + sub_80CECE8 = "FlyingMusicNotes", + sub_80CEDF0 = "BellyDrumHand", + sub_80CEE60 = "SlowFlyingMusicNotes", + sub_80CEF9C = "ThoughtBubble", + sub_80CF040 = "MetronomeFinger", + sub_80CF0BC = "FollowMeFinger", + sub_80CF1C8 = "TauntFinger", + sub_80CF610 = "KinesisZapEnergy", + sub_80CF690 = "SwordsDanceBlade", + sub_80CFE9C = "CoinThrow", + sub_80CFF50 = "FallingCoin", + sub_80CFFD8 = "BulletSeed", + sub_80D0118 = "RazorWindTornado", + sub_80D0178 = "ViceGripPincer", + sub_80D0228 = "GuillotinePincer", + sub_80D0930 = "BreathPuff", + sub_80D09C0 = "AngerMark", + sub_80D0E30 = "Pencil", + sub_80D0FD8 = "BlendThinRing", + sub_80D10B8 = "HyperVoiceRing", + sub_80D1318 = "UproarRing", + sub_80D1368 = "SoftBoiledEgg", + sub_80D1C08 = "HealBellMusicNote", + sub_80D1C80 = "MagentaHeart", + sub_80D1F58 = "RedHeartProjectile", + sub_80D1FDC = "ParticleBurst", + sub_80D2064 = "RedHeartRising", + sub_80D28AC = "SpitUpOrb", + sub_80D2920 = "EyeSparkle", + sub_80D2938 = "Angel", + sub_80D2A38 = "PinkHeart", + sub_80D2ABC = "Devil", + sub_80D2BE8 = "FurySwipes", + sub_80D2C38 = "MovementWaves", + sub_80D2D68 = "JaggedMusicNote", + sub_80D2E68 = "PerishSongMusicNote2", + sub_80D2EC8 = "PerishSongMusicNote", + sub_80D3014 = "GuardRing", + sub_80D31C8 = "WaterBubbleProjectile", + sub_80D3554 = "ToTargetInSinWave", + sub_80D3698 = "HydroCannonCharge", + sub_80D3728 = "HydroCannonBeam", + sub_80D37FC = "sub_80D37FC", + sub_80D3838 = "SmallBubblePair", + sub_80D4044 = "SmallDriftingBubbles", + sub_80D4BF0 = "WaterPulseBubble", + sub_80D4CA4 = "WaterPulseRing", + sub_80D4ED8 = "FireSpiralInward", + sub_80D4F18 = "FireSpread", + sub_80D4F5C = "FirePlume", + sub_80D4FCC = "LargeFlame", + sub_80D517C = "Sunlight", + sub_80D5210 = "sub_80D5210", + sub_80D53B4 = "FireSpiralOutward", + sub_80D5A20 = "EruptionFallingRock", + sub_80D5B0C = "WillOWispOrb", + sub_80D5CC0 = "WillOWispFire", + sub_80D61C8 = "Lightning", + sub_80D6328 = "SparkElectricity", + sub_80D648C = "ZapCannonSpark", + sub_80D65DC = "ThunderboltOrb", + sub_80D6658 = "SparkElectricityFlashing", + sub_80D679C = "Electricity", + sub_80D6A6C = "ThunderWave", + sub_80D6D18 = "GrowingChargeOrb", + sub_80D6D70 = "ElectricPuff", + sub_80D6DD8 = "VoltTackleOrbSlide", + sub_80D727C = "GrowingShockWaveOrb", + sub_80D902C = "SlideHandOrFootToTarget", + sub_80D9078 = "JumpKick", + sub_80D90F4 = "sub_80D90F4", + sub_80D92D0 = "CrossChopHand", + sub_80D9378 = "SlidingKick", + sub_80D9540 = "DizzyPunchDuck", + sub_80D95D0 = "BrickBreakWall", + sub_80D96B8 = "BrickBreakWallShard", + sub_80D97CC = "SuperpowerOrb", + sub_80D98D8 = "SuperpowerRock", + sub_80D9A38 = "SuperpowerFireball", + sub_80D9B48 = "ArmThrustHit", + sub_80D9BD4 = "RevengeScratch", + sub_80D9C40 = "FocusPunchFist", + sub_80D9D70 = "SludgeProjectile", + sub_80D9DF0 = "AcidPoisonBubble", + sub_80D9E94 = "SludgeBombHitParticle", + sub_80D9F14 = "AcidPoisonDroplet", + sub_80DA034 = "sub_80DA034", + sub_80DA16C = "GustToTarget", + sub_80DA208 = "AirWaveCrescent", + sub_80DA300 = "FlyBallUp", + sub_80DA38C = "FlyBallAttack", + sub_80DB000 = "WhirlwindLine", + sub_80DB194 = "BounceBallShrink", + sub_80DB1F4 = "BounceBallLand", + sub_80DB288 = "DiveBall", + sub_80DB374 = "DiveWaterSplash", + sub_80DB458 = "SprayWaterDroplet", + sub_80DB5E4 = "SkyAttackBird", + sub_80DB74C = "DefensiveWall", + sub_80DBA4C = "WallSparkle", + sub_80DBAF4 = "BentSpoon", + sub_80DBB70 = "QuestionMark", + sub_80DC068 = "RedX", + sub_80DC700 = "PsychoBoost", + sub_80DC824 = "MegahornHorn", + sub_80DC8F4 = "LeechLifeNeedle", + sub_80DC9A0 = "TranslateWebThread", + sub_80DCA70 = "StringWrap", + sub_80DCB38 = "SpiderWeb", + sub_80DCE40 = "TailGlowOrb", + sub_80DCE9C = "FallingRock", + sub_80DCF60 = "RockFragment", + sub_80DCFE4 = "ParticleInVortex", + sub_80DD8E8 = "RockTomb", + sub_80DD978 = "RockBlastRock", + sub_80DD9A4 = "RockScatter", + sub_80DDB6C = "ConfuseRayBallBounce", + sub_80DDD58 = "ConfuseRayBallSpiral", + sub_80DE0FC = "Lick", + sub_80DEF3C = "CurseNail", + sub_80DF0B8 = "GhostStatusSprite", + sub_80DF5A0 = "OutrageFlame", + sub_80DF6F0 = "DragonRageFirePlume", + sub_80DF760 = "DragonFireToTarget", + sub_80DF78C = "DragonDanceOrb", + sub_80DFB28 = "OverheatFlame", + sub_80DFF1C = "Bite", + sub_80DFFD0 = "TearDrop", + sub_80E0A10 = "ClawSlash", + sub_80E1E2C = "ComplexPaletteBlend", + sub_80E24B8 = "ShakeMonOrBattleTerrain", + sub_80E27A0 = "sub_80E27A0", + sub_80E27E8 = "sub_80E27E8", + sub_80E2838 = "sub_80E2838", + sub_80E2870 = "sub_80E2870", + sub_80E2908 = "sub_80E2908", + sub_80E2978 = "CrossImpact", + sub_80E29C0 = "sub_80E29C0", + sub_812C144 = "BlackSmoke", + sub_812C220 = "WhiteHalo", + sub_812C2BC = "TealAlert", + sub_812C358 = "MeanLookEye", + sub_812C720 = "Spikes", + sub_812C80C = "sub_812C80C", + sub_812C848 = "LetterZ", + sub_812C908 = "Fang", + sub_812C990 = "Spotlight", + sub_812CAFC = "ClappingHand", + sub_812CC28 = "ClappingHand2", + sub_812CCE8 = "RapidSpin", + sub_812D294 = "TriAttackTriangle", + sub_812D3AC = "BatonPassPokeball", + sub_812D4B4 = "WishStar", + sub_812D724 = "SwallowBlueOrb", + sub_812DEAC = "GreenStar", + sub_812E4F0 = "WeakFrustrationAngerMark", + sub_812E7A0 = "SweetScentPetal", + sub_812EA4C = "PainSplitProjectile", + sub_812EC78 = "FlatterConfetti", + sub_812ED84 = "FlatterSpotlight", + sub_812EEA4 = "ReversalOrb", + sub_812F88C = "YawnCloud", + sub_812F948 = "SmokeBallEscapeCloud", + sub_81300F4 = "sub_81300F4", + sub_813051C = "AssistPawprint", + sub_81307B0 = "SmellingSaltsHand", + sub_8130A2C = "SmellingSaltExclamation", + sub_8130AEC = "HelpingHandClap", + sub_8130F5C = "ForesightMagnifyingGlass", + sub_8131264 = "MeteorMashStar", + sub_8131564 = "BlockX", + sub_8141C30 = "SpriteCB_SafariBaitOrRock_Init", }, tasks = { - ["AnimTask_BlendInterfaceColor"] = "BlendInterfaceColor", - ["AnimTask_BlendMonInAndOut"] = "BlendMonInAndOut", - ["AnimTask_BlendSpriteColor"] = "BlendSpriteColor", - ["AnimTask_CountIceBallThrows"] = "CountIceBallThrows", - ["AnimTask_CreateSurfWave"] = "CreateSurfWave", - ["AnimTask_GetBattleEnvironment"] = "GetBattleEnvironment", - ["AnimTask_GetReturnPowerLevel"] = "GetReturnPowerLevel", - ["AnimTask_GetWeather"] = "GetWeather", - ["AnimTask_Hail1"] = "Hail1", - ["AnimTask_Haze1"] = "Haze1", - ["AnimTask_LoadMistTiles"] = "LoadMistTiles", - ["AnimTask_Minimize"] = "Minimize", - ["AnimTask_RolePlaySilhouette"] = "RolePlaySilhouette", - ["AnimTask_RotateMonPalette1"] = "RotateMonPalette1", - ["AnimTask_ScaleMonAndRestore"] = "ScaleMonAndRestore", - ["AnimTask_ShakeAndSinkMon"] = "ShakeAndSinkMon", - ["AnimTask_ShakeMon"] = "ShakeMon", - ["AnimTask_ShakeMon2"] = "ShakeMon2", - ["AnimTask_ShakeMonInPlace"] = "ShakeMonInPlace", - ["AnimTask_SlackOffSquish"] = "SlackOffSquish", - ["AnimTask_SnatchOpposingMonMove"] = "SnatchOpposingMonMove", - ["AnimTask_SnatchPartnerMove"] = "SnatchPartnerMove", - ["AnimTask_Splash"] = "Splash", - ["AnimTask_SwayMon"] = "SwayMon", - ["AnimTask_TeeterDanceMovement"] = "TeeterDanceMovement", - ["AnimTask_TranslateMonElliptical"] = "TranslateMonElliptical", - ["AnimTask_TranslateMonEllipticalRespectSide"] = "TranslateMonEllipticalRespectSide", - ["AnimTask_WindUpLunge"] = "WindUpLunge", - ["CreateAnimRaindrops"] = "CreateAnimRaindrops", - ["UpdateFuryCutterAnimCount"] = "UpdateFuryCutterAnimCount", - ["UpdateFuryCutterAnimDirection"] = "UpdateFuryCutterAnimDirection", - ["c3_80DFBE4"] = "c3_80DFBE4", - ["do_boulder_dust"] = "do_boulder_dust", - ["sub_8079670"] = "sub_8079670", - ["sub_80798AC"] = "sub_80798AC", - ["sub_8079CEC"] = "sub_8079CEC", - ["sub_807A69C"] = "sub_807A69C", - ["sub_807B920"] = "sub_807B920", - ["sub_807BB88"] = "sub_807BB88", - ["sub_80A8A80"] = "sub_80A8A80", - ["sub_80A8E04"] = "sub_80A8E04", - ["sub_80A8EFC"] = "sub_80A8EFC", - ["sub_80A9058"] = "sub_80A9058", - ["sub_80CA928"] = "sub_80CA928", - ["sub_80CACEC"] = "sub_80CACEC", - ["sub_80CB340"] = "sub_80CB340", - ["sub_80CBDF4"] = "sub_80CBDF4", - ["sub_80CC5F8"] = "sub_80CC5F8", - ["sub_80CDAC8"] = "sub_80CDAC8", - ["sub_80CE108"] = "sub_80CE108", - ["sub_80CE210"] = "sub_80CE210", - ["sub_80CE3EC"] = "sub_80CE3EC", - ["sub_80CE7E0"] = "sub_80CE7E0", - ["sub_80CEA20"] = "sub_80CEA20", - ["sub_80CEAD8"] = "sub_80CEAD8", - ["sub_80CF4D8"] = "sub_80CF4D8", - ["sub_80CFB04"] = "sub_80CFB04", - ["sub_80D03C4"] = "sub_80D03C4", - ["sub_80D08C8"] = "sub_80D08C8", - ["sub_80D0A4C"] = "sub_80D0A4C", - ["sub_80D0AB8"] = "sub_80D0AB8", - ["sub_80D0C88"] = "sub_80D0C88", - ["sub_80D15A4"] = "sub_80D15A4", - ["sub_80D1638"] = "sub_80D1638", - ["sub_80D17C4"] = "sub_80D17C4", - ["sub_80D18D4"] = "sub_80D18D4", - ["sub_80D1ADC"] = "sub_80D1ADC", - ["sub_80D1B80"] = "sub_80D1B80", - ["sub_80D1CD0"] = "sub_80D1CD0", - ["sub_80D1E38"] = "sub_80D1E38", - ["sub_80D1EC8"] = "sub_80D1EC8", - ["sub_80D2100"] = "sub_80D2100", - ["sub_80D23B4"] = "sub_80D23B4", - ["sub_80D2CF8"] = "sub_80D2CF8", - ["sub_80D3630"] = "sub_80D3630", - ["sub_80D40F4"] = "sub_80D40F4", - ["sub_80D45D8"] = "sub_80D45D8", - ["sub_80D48F4"] = "sub_80D48F4", - ["sub_80D5470"] = "sub_80D5470", - ["sub_80D5DDC"] = "sub_80D5DDC", - ["sub_80D60B4"] = "sub_80D60B4", - ["sub_80D681C"] = "sub_80D681C", - ["sub_80D6B3C"] = "sub_80D6B3C", - ["sub_80D6E9C"] = "sub_80D6E9C", - ["sub_80D700C"] = "sub_80D700C", - ["sub_80D72DC"] = "sub_80D72DC", - ["sub_80D759C"] = "sub_80D759C", - ["sub_80D9C80"] = "sub_80D9C80", - ["sub_80DA09C"] = "sub_80DA09C", - ["sub_80DB0E8"] = "sub_80DB0E8", - ["sub_80DBC94"] = "sub_80DBC94", - ["sub_80DBCFC"] = "sub_80DBCFC", - ["sub_80DBE00"] = "sub_80DBE00", - ["sub_80DC0B0"] = "sub_80DC0B0", - ["sub_80DC2D4"] = "sub_80DC2D4", - ["sub_80DC4F4"] = "sub_80DC4F4", - ["sub_80DD4D4"] = "sub_80DD4D4", - ["sub_80DDA4C"] = "sub_80DDA4C", - ["sub_80DDA8C"] = "sub_80DDA8C", - ["sub_80DDAF0"] = "sub_80DDAF0", - ["sub_80DDDF0"] = "sub_80DDDF0", - ["sub_80DE1B0"] = "sub_80DE1B0", - ["sub_80DE3AC"] = "sub_80DE3AC", - ["sub_80DE918"] = "sub_80DE918", - ["sub_80DECB0"] = "sub_80DECB0", - ["sub_80DF1A4"] = "sub_80DF1A4", - ["sub_80DF924"] = "sub_80DF924", - ["sub_80DFC24"] = "sub_80DFC24", - ["sub_80DFD24"] = "sub_80DFD24", - ["sub_80DFDC0"] = "sub_80DFDC0", - ["sub_80E00EC"] = "sub_80E00EC", - ["sub_80E03BC"] = "sub_80E03BC", - ["sub_80E0918"] = "sub_80E0918", - ["sub_80E09C4"] = "sub_80E09C4", - ["sub_80E0A4C"] = "sub_80E0A4C", - ["sub_80E0E24"] = "sub_80E0E24", - ["sub_80E0EE8"] = "sub_80E0EE8", - ["sub_80E1244"] = "sub_80E1244", - ["sub_80E149C"] = "sub_80E149C", - ["sub_80E1864"] = "sub_80E1864", - ["sub_80E1B88"] = "sub_80E1B88", - ["sub_80E1BB0"] = "sub_80E1BB0", - ["sub_80E1F8C"] = "sub_80E1F8C", - ["sub_80E2094"] = "sub_80E2094", - ["sub_80E21A8"] = "sub_80E21A8", - ["sub_80E2324"] = "sub_80E2324", - ["sub_80E26BC"] = "sub_80E26BC", - ["sub_80E2A38"] = "sub_80E2A38", - ["sub_80E2A7C"] = "sub_80E2A7C", - ["sub_80E2B74"] = "sub_80E2B74", - ["sub_80E2D78"] = "sub_80E2D78", - ["sub_80E2DD8"] = "sub_80E2DD8", - ["sub_80E2F2C"] = "sub_80E2F2C", - ["sub_80E388C"] = "sub_80E388C", - ["sub_80E3A08"] = "sub_80E3A08", - ["sub_80E3A58"] = "sub_80E3A58", - ["sub_80E3B4C"] = "sub_80E3B4C", - ["sub_80E3B78"] = "sub_80E3B78", - ["sub_80E3BA4"] = "sub_80E3BA4", - ["sub_80E3BDC"] = "sub_80E3BDC", - ["sub_80E4028"] = "sub_80E4028", - ["sub_80E40D0"] = "sub_80E40D0", - ["sub_80E4178"] = "sub_80E4178", - ["sub_80E4200"] = "sub_80E4200", - ["sub_80E4234"] = "sub_80E4234", - ["sub_80E4264"] = "sub_80E4264", - ["sub_80E42B0"] = "sub_80E42B0", - ["sub_80E42D0"] = "sub_80E42D0", - ["sub_80E4300"] = "sub_80E4300", - ["sub_812AF30"] = "sub_812AF30", - ["sub_812B058"] = "sub_812B058", - ["sub_812B18C"] = "sub_812B18C", - ["sub_812B2B8"] = "sub_812B2B8", - ["sub_812B30C"] = "sub_812B30C", - ["sub_812B340"] = "sub_812B340", - ["sub_812B374"] = "sub_812B374", - ["sub_812C1D0"] = "sub_812C1D0", - ["sub_812C560"] = "sub_812C560", - ["sub_812C624"] = "sub_812C624", - ["sub_812C924"] = "sub_812C924", - ["sub_812C960"] = "sub_812C960", - ["sub_812CC44"] = "sub_812CC44", - ["sub_812CCA8"] = "sub_812CCA8", - ["sub_812CDC8"] = "sub_812CDC8", - ["sub_812D008"] = "sub_812D008", - ["sub_812D350"] = "sub_812D350", - ["sub_812D674"] = "sub_812D674", - ["sub_812D6CC"] = "sub_812D6CC", - ["sub_812D790"] = "sub_812D790", - ["sub_812D7E8"] = "sub_812D7E8", - ["sub_812DB58"] = "sub_812DB58", - ["sub_812DB84"] = "sub_812DB84", - ["sub_812E14C"] = "sub_812E14C", - ["sub_812E498"] = "sub_812E498", - ["sub_812E568"] = "sub_812E568", - ["sub_812E860"] = "sub_812E860", - ["sub_812EB10"] = "sub_812EB10", - ["sub_812F314"] = "sub_812F314", - ["sub_812F724"] = "sub_812F724", - ["sub_812FC68"] = "sub_812FC68", - ["sub_812FD7C"] = "sub_812FD7C", - ["sub_812FFE4"] = "sub_812FFE4", - ["sub_81300A4"] = "sub_81300A4", - ["sub_81301EC"] = "sub_81301EC", - ["sub_8130554"] = "sub_8130554", - ["sub_8130918"] = "sub_8130918", - ["sub_8130D20"] = "sub_8130D20", - ["sub_81312E4"] = "sub_81312E4", - ["sub_81316F8"] = "sub_81316F8", - ["sub_813F4EC"] = "sub_813F4EC", - ["sub_813F5E8"] = "sub_813F5E8", - ["sub_813F6A0"] = "sub_813F6A0", - ["sub_813F798"] = "sub_813F798", - ["sub_813F844"] = "sub_813F844", - ["sub_813F990"] = "sub_813F990", - ["sub_813F9B8"] = "sub_813F9B8", - ["sub_813F9E0"] = "sub_813F9E0", - ["sub_813FA94"] = "sub_813FA94", - ["sub_813FBB8"] = "sub_813FBB8", - ["sub_814151C"] = "sub_814151C", - ["sub_81416C4"] = "sub_81416C4", - ["sub_81417D8"] = "sub_81417D8", - ["sub_8141808"] = "sub_8141808", - ["sub_8141BD4"] = "sub_8141BD4", - ["sub_8141C08"] = "sub_8141C08", - ["sub_8141D7C"] = "sub_8141D7C", - ["sub_8141DAC"] = "sub_8141DAC", - ["sub_8141E10"] = "sub_8141E10", + AnimTask_BlendInterfaceColor = "BlendBackground", + AnimTask_BlendMonInAndOut = "BlendMonInAndOut", + AnimTask_BlendSpriteColor = "BlendParticle", + AnimTask_CountIceBallThrows = "GetRolloutCounter", + AnimTask_CreateSurfWave = "CreateSurfWave", + AnimTask_GetBattleEnvironment = "GetBattleTerrain", + AnimTask_GetReturnPowerLevel = "GetReturnPowerLevel", + AnimTask_GetWeather = "GetWeather", + AnimTask_Hail1 = "Hail", + AnimTask_Haze1 = "HazeScrollingFog", + AnimTask_LoadMistTiles = "MistBallFog", + AnimTask_Minimize = "Minimize", + AnimTask_RolePlaySilhouette = "RolePlaySilhouette", + AnimTask_RotateMonPalette1 = "RotateAuroraRingColors", + AnimTask_ScaleMonAndRestore = "ScaleMonAndRestore", + AnimTask_ShakeAndSinkMon = "ShakeAndSinkMon", + AnimTask_ShakeMon = "ShakeMon", + AnimTask_ShakeMon2 = "ShakeMon2", + AnimTask_ShakeMonInPlace = "ShakeMonInPlace", + AnimTask_SlackOffSquish = "SlackOffSquish", + AnimTask_SnatchOpposingMonMove = "SnatchOpposingMonMove", + AnimTask_SnatchPartnerMove = "SnatchPartnerMove", + AnimTask_Splash = "Splash", + AnimTask_SwayMon = "SwayMon", + AnimTask_TeeterDanceMovement = "TeeterDanceMovement", + AnimTask_TranslateMonElliptical = "TranslateMonElliptical", + AnimTask_TranslateMonEllipticalRespectSide = "TranslateMonEllipticalRespectSide", + AnimTask_WindUpLunge = "WindUpLunge", + CreateAnimRaindrops = "CreateRaindrops", + UpdateFuryCutterAnimCount = "GetFuryCutterHitCount", + UpdateFuryCutterAnimDirection = "IsFuryCutterHitRight", + c3_80DFBE4 = "IsMonInvisible", + do_boulder_dust = "LoadSandstormBackground", + sub_8079670 = "AlphaFadeIn", + sub_80798AC = "BlendPalInAndOutByTag", + sub_8079CEC = "GetFrustrationPowerLevel", + sub_807A69C = "AttackerPunchWithTrace", + sub_807B920 = "FrozenIceCube", + sub_807BB88 = "StatsChange", + sub_80A8A80 = "SlideOffScreen", + sub_80A8E04 = "RotateMonSpriteToSide", + sub_80A8EFC = "sub_80A8EFC", + sub_80A9058 = "ShakeTargetBasedOnMovePowerOrDmg", + sub_80CA928 = "CreateSmallSolarBeamOrbs", + sub_80CACEC = "SporeDoubleBattle", + sub_80CB340 = "ShrinkTargetCopy", + sub_80CBDF4 = "LeafBlade", + sub_80CC5F8 = "CycleMagicalLeafPal", + sub_80CDAC8 = "SkullBashPosition", + sub_80CE108 = "ConversionAlphaBlend", + sub_80CE210 = "Conversion2AlphaBlend", + sub_80CE3EC = "MoonlightEndFade", + sub_80CE7E0 = "DoubleTeam", + sub_80CEA20 = "MusicNotesRainbowBlend", + sub_80CEAD8 = "MusicNotesClearRainbowBlend", + sub_80CF4D8 = "Withdraw", + sub_80CFB04 = "AirCutterProjectile", + sub_80D03C4 = "GrowAndGrayscale", + sub_80D08C8 = "GrowAndShrink", + sub_80D0A4C = "ThrashMoveMonHorizontal", + sub_80D0AB8 = "ThrashMoveMonVertical", + sub_80D0C88 = "SketchDrawMon", + sub_80D15A4 = "AttackerStretchAndDisappear", + sub_80D1638 = "ExtremeSpeedImpact", + sub_80D17C4 = "ExtremeSpeedMonReappear", + sub_80D18D4 = "SpeedDust", + sub_80D1ADC = "LoadMusicNotesPals", + sub_80D1B80 = "FreeMusicNotesPals", + sub_80D1CD0 = "FakeOut", + sub_80D1E38 = "StretchTargetUp", + sub_80D1EC8 = "StretchAttackerUp", + sub_80D2100 = "HeartsBackground", + sub_80D23B4 = "ScaryFace", + sub_80D2CF8 = "UproarDistortion", + sub_80D3630 = "StartSinAnimTimer", + sub_80D40F4 = "WaterSpoutLaunch", + sub_80D45D8 = "WaterSpoutRain", + sub_80D48F4 = "WaterSport", + sub_80D5470 = "EruptionLaunchRocks", + sub_80D5DDC = "MoveHeatWaveTargets", + sub_80D60B4 = "ShakeTargetInPattern", + sub_80D681C = "ElectricBolt", + sub_80D6B3C = "ElectricChargingParticles", + sub_80D6E9C = "VoltTackleAttackerReappear", + sub_80D700C = "VoltTackleBolt", + sub_80D72DC = "ShockWaveProgressingBolt", + sub_80D759C = "ShockWaveLightning", + sub_80D9C80 = "MoveSkyUppercutBg", + sub_80DA09C = "sub_80DA09C", + sub_80DB0E8 = "DrillPeckHitSplats", + sub_80DBC94 = "MeditateStretchAttacker", + sub_80DBCFC = "Teleport", + sub_80DBE00 = "ImprisonOrbs", + sub_80DC0B0 = "SkillSwap", + sub_80DC2D4 = "ExtrasensoryDistortion", + sub_80DC4F4 = "TransparentCloneGrowAndShrink", + sub_80DD4D4 = "Rollout", + sub_80DDA4C = "GetSeismicTossDamageLevel", + sub_80DDA8C = "MoveSeismicTossBg", + sub_80DDAF0 = "SeismicTossBgAccelerateDownAtEnd", + sub_80DDDF0 = "NightShadeClone", + sub_80DE1B0 = "NightmareClone", + sub_80DE3AC = "SpiteTargetShadow", + sub_80DE918 = "DestinyBondWhiteShadow", + sub_80DECB0 = "CurseStretchingBlackBg", + sub_80DF1A4 = "GrudgeFlames", + sub_80DF924 = "DragonDanceWaver", + sub_80DFC24 = "AttackerFadeToInvisible", + sub_80DFD24 = "AttackerFadeFromInvisible", + sub_80DFDC0 = "InitAttackerFadeFromInvisible", + sub_80E00EC = "MoveAttackerMementoShadow", + sub_80E03BC = "MoveTargetMementoShadow", + sub_80E0918 = "InitMementoShadow", + sub_80E09C4 = "MementoHandleBg", + sub_80E0A4C = "MetallicShine", + sub_80E0E24 = "SetGrayscaleOrOriginalPal", + sub_80E0EE8 = "GetIsDoomDesireHitTurn", + sub_80E1244 = "DigDownMovement", + sub_80E149C = "DigUpMovement", + sub_80E1864 = "HorizontalShake", + sub_80E1B88 = "IsPowerOver99", + sub_80E1BB0 = "PositionFissureBgOnBattler", + sub_80E1F8C = "sub_80E1F8C", + sub_80E2094 = "BlendColorCycleByTag", + sub_80E21A8 = "FlashAnimTagWithColor", + sub_80E2324 = "InvertScreenColor", + sub_80E26BC = "ShakeBattleTerrain", + sub_80E2A38 = "BlendBattleAnimPal", + sub_80E2A7C = "BlendBattleAnimPalExclude", + sub_80E2B74 = "SetCamouflageBlend", + sub_80E2D78 = "HardwarePaletteFade", + sub_80E2DD8 = "sub_80E2DD8", + sub_80E2F2C = "DrawFallingWhiteLinesOnAttacker", + sub_80E388C = "Flash", + sub_80E3A08 = "BlendNonAttackerPalettes", + sub_80E3A58 = "StartSlidingBg", + sub_80E3B4C = "GetAttackerSide", + sub_80E3B78 = "GetTargetSide", + sub_80E3BA4 = "GetTargetIsAttackerPartner", + sub_80E3BDC = "SetAllNonAttackersInvisiblity", + sub_80E4028 = "CopyPalUnfadedToBackup", + sub_80E40D0 = "CopyPalUnfadedFromBackup", + sub_80E4178 = "CopyPalFadedToUnfaded", + sub_80E4200 = "IsContest", + sub_80E4234 = "SetAnimAttackerAndTargetForEffectTgt", + sub_80E4264 = "IsTargetSameSide", + sub_80E42B0 = "SetAnimTargetToBattlerTarget", + sub_80E42D0 = "SetAnimAttackerAndTargetForEffectAtk", + sub_80E4300 = "SetAttackerInvisibleWaitForSignal", + sub_812AF30 = "SoundTask_FireBlast", + sub_812B058 = "SoundTask_LoopSEAdjustPanning", + sub_812B18C = "sub_812B18C", + sub_812B2B8 = "sub_812B2B8", + sub_812B30C = "SoundTask_PlaySE1WithPanning", + sub_812B340 = "SoundTask_PlaySE2WithPanning", + sub_812B374 = "SoundTask_AdjustPanningVar", + sub_812C1D0 = "SmokescreenImpact", + sub_812C560 = "SetPsychicBackground", + sub_812C624 = "FadeScreenToWhite", + sub_812C924 = "IsTargetPlayerSide", + sub_812C960 = "IsHealingMove", + sub_812CC44 = "CreateSpotlight", + sub_812CCA8 = "RemoveSpotlight", + sub_812CDC8 = "RapinSpinMonElevation", + sub_812D008 = "TormentAttacker", + sub_812D350 = "DefenseCurlDeformMon", + sub_812D674 = "StockpileDeformMon", + sub_812D6CC = "SpitUpDeformMon", + sub_812D790 = "SwallowDeformMon", + sub_812D7E8 = "TransformMon", + sub_812DB58 = "CastformGfxChange", + sub_812DB84 = "MorningSunLightBeam", + sub_812E14C = "DoomDesireLightBeam", + sub_812E498 = "StrongFrustrationGrowAndShrink", + sub_812E568 = "RockMonBackAndForth", + sub_812E860 = "FlailMovement", + sub_812EB10 = "PainSplitMovement", + sub_812F314 = "AcidArmor", + sub_812F724 = "DeepInhale", + sub_812FC68 = "SlideMonForFocusBand", + sub_812FD7C = "SquishAndSweatDroplets", + sub_812FFE4 = "FacadeColorBlend", + sub_81300A4 = "StatusClearedEffect", + sub_81301EC = "GlareEyeDots", + sub_8130554 = "BarrageBall", + sub_8130918 = "SmellingSaltsSquish", + sub_8130D20 = "HelpingHandAttackerMovement", + sub_81312E4 = "MonToSubstitute", + sub_81316F8 = "OdorSleuthMovement", + sub_813F4EC = "LoadHealthboxPalsForLevelUp", + sub_813F5E8 = "FreeHealthboxPalsForLevelUp", + sub_813F6A0 = "FlashHealthboxOnLevelUp", + sub_813F798 = "SwitchOutShrinkMon", + sub_813F844 = "SwitchOutBallEffect", + sub_813F990 = "LoadBallGfx", + sub_813F9B8 = "FreeBallGfx", + sub_813F9E0 = "IsBallBlockedByTrainer", + sub_813FA94 = "ThrowBall", + sub_813FBB8 = "ThrowBall_StandingTrainer", + sub_814151C = "SwapMonSpriteToFromSubstitute", + sub_81416C4 = "SubstituteFadeToInvisible", + sub_81417D8 = "IsAttackerBehindSubstitute", + sub_8141808 = "SetTargetToEffectBattler", + sub_8141BD4 = "LoadBaitGfx", + sub_8141C08 = "FreeBaitGfx", + sub_8141D7C = "SafariOrGhost_DecideAnimSides", + sub_8141DAC = "GetTrappedMoveAnimId", + sub_8141E10 = "GetBattlersFromArg", + }, + rsOnly = { + callbacks = { + }, + tasks = { + }, + templates = { + }, }, tagNames = { [0] = "BONE", @@ -1104,4 +1112,49 @@ return { [287] = "WHIP_HIT", [288] = "BLUE_RING_2", }, + statusNames = { + [0] = "STATUS_PSN", + [1] = "STATUS_CONFUSION", + [2] = "STATUS_BRN", + [3] = "STATUS_INFATUATION", + [4] = "STATUS_SLP", + [5] = "STATUS_PRZ", + [6] = "STATUS_FRZ", + [7] = "STATUS_CURSED", + [8] = "STATUS_NIGHTMARE", + }, + generalNames = { + [0] = "CASTFORM_CHANGE", + [1] = "STATS_CHANGE", + [2] = "SUBSTITUTE_FADE", + [3] = "SUBSTITUTE_APPEAR", + [4] = "POKEBLOCK_THROW", + [5] = "ITEM_KNOCKOFF", + [6] = "TURN_TRAP", + [7] = "HELD_ITEM_EFFECT", + [8] = "SMOKEBALL_ESCAPE", + [9] = "FOCUS_BAND", + [10] = "RAIN_CONTINUES", + [11] = "SUN_CONTINUES", + [12] = "SANDSTORM_CONTINUES", + [13] = "HAIL_CONTINUES", + [14] = "LEECH_SEED_DRAIN", + [15] = "MON_HIT", + [16] = "ITEM_STEAL", + [17] = "SNATCH_MOVE", + [18] = "FUTURE_SIGHT_HIT", + [19] = "DOOM_DESIRE_HIT", + [20] = "FOCUS_PUNCH_SETUP", + [21] = "INGRAIN_HEAL", + [22] = "WISH_HEAL", + }, + specialNames = { + [0] = "LVL_UP", + [1] = "SWITCH_OUT_PLAYER_MON", + [2] = "SWITCH_OUT_OPPONENT_MON", + [3] = "BALL_THROW", + [4] = "BALL_THROW_WITH_TRAINER", + [5] = "SUBSTITUTE_TO_MON", + [6] = "MON_TO_SUBSTITUTE", + }, } diff --git a/src/import/gba/rs/extract_bag.lua b/src/import/gba/rs/extract_bag.lua index f22e1a5b..620d1e6f 100644 --- a/src/import/gba/rs/extract_bag.lua +++ b/src/import/gba/rs/extract_bag.lua @@ -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)) diff --git a/src/import/gba/rs/extract_battle_anims.lua b/src/import/gba/rs/extract_battle_anims.lua index 5334d92d..52054f0e 100644 --- a/src/import/gba/rs/extract_battle_anims.lua +++ b/src/import/gba/rs/extract_battle_anims.lua @@ -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") diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 7df16abe..0bdb833b 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.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 diff --git a/src/online/Convert.lua b/src/online/Convert.lua index a55a028e..bac4e8ae 100644 --- a/src/online/Convert.lua +++ b/src/online/Convert.lua @@ -117,6 +117,26 @@ local function levelOf(mon) return math.max(1, math.min(100, math.floor(tonumber(mon.level) or 1))) end +-- engine/link/link.asm:1180 TimeCapsule_ReplaceTeruSama +function Convert.heldItemFromCatchRate(byte, gen2Data) + byte = tonumber(byte) + if not byte or byte <= 0 or byte > 255 then return nil end + local items = gen2Data and gen2Data.items or {} + local replaced = type(items.timeCapsule) == "table" and items.timeCapsule[byte] + if replaced then return replaced end + for id, def in pairs(items) do + if type(def) == "table" and def.index == byte and def.id == id then return id end + end + return nil +end + +-- engine/link/link.asm:823 +function Convert.catchRateFromHeldItem(item, gen2Data) + if item == nil or item == 0 then return 0 end + local def = gen2Data and gen2Data.items and gen2Data.items[item] + return type(def) == "table" and tonumber(def.index) or nil +end + -- engine/link/link.asm:930 Link_ConvertPartyStruct1to2 function Convert.toGen2(mon, gen1Data, gen2Data) @@ -195,7 +215,8 @@ function Convert.toGen2(mon, gen1Data, gen2Data) maxHp = stats.hp, types = def2.types, moves = moves, - item = nil, + -- engine/link/link.asm:1102 + item = Convert.heldItemFromCatchRate(mon.catchRate or (def1 and def1.catchRate), gen2Data), status = status, -- engine/link/link.asm:1067 happiness = Convert.DEFAULT_HAPPINESS, @@ -216,6 +237,10 @@ function Convert.toGen2(mon, gen1Data, gen2Data) { to = Convert.DEFAULT_HAPPINESS }) entry(report.changed, "caught_level", ("MET AT LEVEL %d"):format(level), { to = level }) + if out.item then + entry(report.changed, "item", + ("HOLDS %s"):format(displayName(gen2Data and gen2Data.items, out.item)), { to = out.item }) + end return out, report end @@ -303,11 +328,11 @@ function Convert.toGen1(mon, gen2Data, gen1Data) { from = mon.status, to = status }) end + local catchRate = Convert.catchRateFromHeldItem(mon.item, gen2Data) or 0 if mon.item then - -- engine/link/link.asm:756, :1078 TimeCapsule_ReplaceTeruSama - entry(report.lost, "item", - ("HELD ITEM LOST: %s"):format(displayName(gen2Data and gen2Data.items, - mon.item)), { item = mon.item }) + entry(report.changed, "item", + ("HELD ITEM KEPT AS CATCH RATE %d: %s"):format(catchRate, displayName(gen2Data and gen2Data.items, + mon.item)), { item = mon.item, to = catchRate }) end if tonumber(mon.happiness) then entry(report.lost, "happiness", "FRIENDSHIP LOST", @@ -329,7 +354,8 @@ function Convert.toGen1(mon, gen2Data, gen1Data) statExp = statExp, stats = stats, hp = hp, - catchRate = def1.catchRate, + -- engine/link/link.asm:823 + catchRate = catchRate, status = status, moves = moves, nickname = mon.nickname, diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 8b73fc40..289e9faf 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -868,6 +868,12 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, gotText) end end +-- pokeyellow scripts/OaksLab.asm:1037 +function Commands.set_catch_rate(ctx, slot, value) + local mon = ctx.save.party and ctx.save.party[slot] + if mon then mon.catchRate = value end +end + function Commands.give_money(ctx, amount) ctx.save.money = math.max(0, ctx.save.money + amount) end diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index 6bfd854b..c43ed6a8 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -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 diff --git a/src/ui/game3/rs/bag_menu.lua b/src/ui/game3/rs/bag_menu.lua index 7222097d..39932c83 100644 --- a/src/ui/game3/rs/bag_menu.lua +++ b/src/ui/game3/rs/bag_menu.lua @@ -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 diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index d9235adf..ebdbc223 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -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 diff --git a/tests/drivers/crystal_sfx_prewarm_2732.lua b/tests/drivers/crystal_sfx_prewarm_2732.lua new file mode 100644 index 00000000..17e9e4db --- /dev/null +++ b/tests/drivers/crystal_sfx_prewarm_2732.lua @@ -0,0 +1,153 @@ +-- ../pokecrystal/engine/overworld/scripting.asm:467 +-- ../pokecrystal/engine/battle_anims/anim_commands.asm:1191 +-- tools/run_driver.sh crystal tests/drivers/crystal_sfx_prewarm_2732.lua +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 diff --git a/tests/drivers/em_mr_stone_head_2737.lua b/tests/drivers/em_mr_stone_head_2737.lua new file mode 100644 index 00000000..b810a430 --- /dev/null +++ b/tests/drivers/em_mr_stone_head_2737.lua @@ -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 diff --git a/tests/drivers/em_poison_whiteout_2736.lua b/tests/drivers/em_poison_whiteout_2736.lua new file mode 100644 index 00000000..9dd392b5 --- /dev/null +++ b/tests/drivers/em_poison_whiteout_2736.lua @@ -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 diff --git a/tests/drivers/game3_rs_anim_ports_2735.lua b/tests/drivers/game3_rs_anim_ports_2735.lua new file mode 100644 index 00000000..405c3e3f --- /dev/null +++ b/tests/drivers/game3_rs_anim_ports_2735.lua @@ -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 diff --git a/tests/drivers/game3_rs_move_menu_fit_2729.lua b/tests/drivers/game3_rs_move_menu_fit_2729.lua new file mode 100644 index 00000000..8f318cab --- /dev/null +++ b/tests/drivers/game3_rs_move_menu_fit_2729.lua @@ -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 diff --git a/tests/drivers/game3_transition_shade_2731.lua b/tests/drivers/game3_transition_shade_2731.lua new file mode 100644 index 00000000..8d1ba29a --- /dev/null +++ b/tests/drivers/game3_transition_shade_2731.lua @@ -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 diff --git a/tests/drivers/rs_bag_berry_2727.lua b/tests/drivers/rs_bag_berry_2727.lua new file mode 100644 index 00000000..09533325 --- /dev/null +++ b/tests/drivers/rs_bag_berry_2727.lua @@ -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 diff --git a/tests/drivers/save_editor_unown_form_2726.lua b/tests/drivers/save_editor_unown_form_2726.lua new file mode 100644 index 00000000..b0782a31 --- /dev/null +++ b/tests/drivers/save_editor_unown_form_2726.lua @@ -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 diff --git a/tests/engine/android_old_shell_restart_bug2738.lua b/tests/engine/android_old_shell_restart_bug2738.lua new file mode 100644 index 00000000..08cadc98 --- /dev/null +++ b/tests/engine/android_old_shell_restart_bug2738.lua @@ -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") diff --git a/tests/engine/box_bank_items_test.lua b/tests/engine/box_bank_items_test.lua new file mode 100644 index 00000000..df260965 --- /dev/null +++ b/tests/engine/box_bank_items_test.lua @@ -0,0 +1,198 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Migration=require("src.box.Migration") +local Items=require("src.box.Items") +local Store=require("src.box.Store") +local Catalog=require("src.box.Catalog") +local Serializer=require("src.core.SaveSerializer") +local CacheFs=require("src.import.CacheFs") +local GameVersion=require("src.core.GameVersion") +local oldRead,tables=CacheFs.readAt,{} +local function put(v,p,value) tables[GameVersion.cachePrefix(v)..p]=Serializer.encode(value) end +local base={hp=35,attack=55,defense=30,speed=90,special=50,specialAttack=50,specialDefense=40} +for _,v in ipairs(GameVersion.ORDER) do + local gen=GameVersion.generation(v) + if gen==3 then + local root="data/generated/gba/pokemon/" + put(v,root.."names.lua",{[25]="PIKACHU",[151]="MEW",[251]="CELEBI"}) + put(v,root.."national.lua",{toNational={[25]=25,[151]=151,[251]=251},toSpecies={[25]=25,[151]=151,[251]=251}}) + put(v,root.."meta.lua",{[25]={genderRatio=127,growthRate=0},[151]={genderRatio=255,growthRate=0},[251]={genderRatio=255,growthRate=0}}) + put(v,root.."stats.lua",{[25]={hp=35,atk=55,def=40,spe=90,spa=50,spd=50},[151]={hp=100,atk=100,def=100,spe=100,spa=100,spd=100}, + [251]={hp=100,atk=100,def=100,spe=100,spa=100,spd=100}}) + put(v,root.."move_names.lua",{[84]="THUNDERSHOCK"}) + put(v,root.."battle_moves.lua",{moves={[84]={pp=30}}}) + put(v,root.."abilities.lua",{[25]={9,31},[151]={28,0},[251]={30,0}}) + put(v,root.."ability_names.lua",{[9]="STATIC",[28]="SYNCHRONIZE",[30]="NATURAL CURE",[31]="LIGHTNINGROD"}) + put(v,"data/generated/gba/items/pack.lua",{items={[3]={name="GREAT BALL",pocket="POKE_BALLS",importance=0}, + [4]={name="POKé BALL",pocket="POKE_BALLS",importance=0},[200]={name="LEFTOVERS",pocket="ITEMS",importance=0}, + [259]={name="MACH BIKE",pocket="KEY_ITEMS",importance=1},[122]={name="ORANGE MAIL",pocket="ITEMS",importance=0}}}) + else + local pokemon={PIKACHU={id="PIKACHU",name="PIKACHU",dex=25,growthRate="MEDIUM_FAST",genderRatio=127,catchRate=190,baseStats=base}, + MEW={id="MEW",name="MEW",dex=151,growthRate="MEDIUM_SLOW",genderRatio=255,catchRate=45,baseStats=base}} + if gen==2 then pokemon.CELEBI={id="CELEBI",name="CELEBI",dex=251,growthRate="MEDIUM_SLOW",genderRatio=255,baseStats=base} end + put(v,"data/generated/pokemon.lua",pokemon) + put(v,"data/generated/moves.lua",{THUNDERSHOCK={name="THUNDERSHOCK",pp=30}}) + if gen==1 then + put(v,"data/generated/items.lua",{POKE_BALL={id="POKE_BALL",name="POKé BALL",index=4}, + BICYCLE={id="BICYCLE",name="BICYCLE",index=6,keyItem=true},TM_01={id="TM_01",name="TM01",index=201}}) + else + put(v,"data/generated/items.lua",{generation=2,pockets={"ITEM","KEY_ITEM","BALL","TM_HM"}, + POKE_BALL={id="POKE_BALL",name="POKé BALL",index=5,pocket="BALL"}, + LEFTOVERS={id="LEFTOVERS",name="LEFTOVERS",index=0x92,pocket="ITEM"}, + BITTER_BERRY={id="BITTER_BERRY",name="BITTER BERRY",index=0x53,pocket="ITEM"}, + BERRY={id="BERRY",name="BERRY",index=0xad,pocket="ITEM"}, + LIGHT_BALL={id="LIGHT_BALL",name="LIGHT BALL",index=0xa3,pocket="ITEM"}, + TM_DYNAMICPUNCH={id="TM_DYNAMICPUNCH",name="TM01",index=0xbf,pocket="TM_HM"}, + FLOWER_MAIL={id="FLOWER_MAIL",name="FLOWER MAIL",index=0x9e,pocket="ITEM"}, + timeCapsule={[0x19]="LEFTOVERS",[0x2d]="BITTER_BERRY",[0xbe]="BERRY",[0xff]="BERRY"}}) + end + end +end +CacheFs.readAt=function(path) return tables[path] end;Catalog.reset() + +local function entry(v,id,species,exp) + local gen=GameVersion.generation(v) + local mon={species=species or "PIKACHU",nickname="PIKA",ot="ALICE",otName="ALICE",otId=42,level=50, + exp=exp or 125500,experience=exp or 125500,happiness=150,catchRate=190, + dvs={attack=7,defense=3,speed=9,special=4},statExp={hp=65535,attack=65535,defense=65535,speed=65535,special=65535}, + moves={{id="THUNDERSHOCK",pp=15,ppUps=0,maxPp=30}}} + return {id=id or 1,version=v,generation=gen,mon=mon,display=Catalog.describe(v,mon)} +end + +for _,from in ipairs({"red","yellow","gold","crystal"}) do + for k=0,24 do + local out=assert(Migration.convert(entry(from,k+1,nil,125500+k),"emerald")) + T.eq(out.nature,k,from.." EXP "..(125500+k).." gives nature "..k) + T.eq(out.personality%25,k,from.." personality carries the EXP nature") + end +end + +do + local e=entry("gold",7) + local a,lines=assert(Migration.convert(e,"firered")) + local b=assert(Migration.convert(e,"firered")) + T.eq(Serializer.encode(a.ivs),Serializer.encode(b.ivs),"IV roll is deterministic for one record") + local perfect=0 + for _,iv in pairs(a.ivs) do + T.check(iv>=0 and iv<=31,"IV within 0-31") + if iv==31 then perfect=perfect+1 end + end + T.check(perfect>=3,"at least three perfect IVs") + for key,ev in pairs(a.evs) do T.eq(ev,0,"EV "..key.." reset") end + T.eq(a.abilityNum,1,"species with a second ability takes the second slot") + T.eq(a.pokeball,4,"Poké Ball") + T.eq(a.modernFatefulEncounter,false,"ordinary species has no fateful flag") + T.eq(lines.natureExp,125500,"preview exposes source EXP for the nature helper") + local other=assert(Migration.convert(entry("gold",8),"firered")) + T.check(Serializer.encode(other.ivs)~=Serializer.encode(a.ivs),"different records roll different IVs") + local shiny=entry("gold",9);shiny.mon.dvs={attack=15,defense=10,speed=10,special=10} + local s=assert(Migration.convert(shiny,"emerald")) + T.check(require("src.core.game3.pokemon").isShiny(s),"DV shininess survives with an EXP nature") +end + +for _,case in ipairs({{"red","MEW",151},{"gold","MEW",151},{"gold","CELEBI",251}}) do + local out=assert(Migration.convert(entry(case[1],3,case[2]),"emerald")) + local perfect=0;for _,iv in pairs(out.ivs) do if iv==31 then perfect=perfect+1 end end + T.check(perfect>=5,case[2].." from "..case[1].." gets five perfect IVs") + T.eq(out.abilityNum,0,case[2].." keeps its only ability") + T.eq(out.modernFatefulEncounter,case[3]==151,case[2].." fateful flag matches the Mew obedience check") +end + +local steps=Migration.natureSteps(125503) +T.eq(steps[1].nature,"Adamant","nature helper lists the current nature first") +T.eq(steps[1].add,0,"current nature needs no EXP") +T.eq(steps[2].nature,"Naughty","next nature is one EXP away") +T.eq(steps[2].add,1,"next nature needs one EXP") + +do + local red=entry("red",11);red.mon.catchRate=0x19 + local gold=assert(Migration.convert(red,"gold")) + T.eq(gold.item,"LEFTOVERS","catch rate 25 becomes LEFTOVERS through the Teru-sama table") + red.mon.catchRate=0xa3 + T.eq(assert(Migration.convert(red,"crystal")).item,"LIGHT_BALL","Yellow Pikachu catch rate becomes LIGHT BALL") + red.mon.catchRate=0 + T.eq(assert(Migration.convert(red,"gold")).item,nil,"catch rate 0 holds nothing") + local held=entry("gold",12);held.mon.item="LEFTOVERS" + local back=assert(Migration.convert(held,"red")) + T.eq(back.catchRate,0x92,"Gen 2 held item becomes the Gen 1 catch rate byte") + local again=entry("red",13);again.mon=back;again.display=Catalog.describe("red",back) + T.eq(assert(Migration.convert(again,"gold")).item,"LEFTOVERS","held item survives a 2 to 1 to 2 round trip") + held.mon.item="FLOWER_MAIL";held.mon.mail={message="HI"} + T.check(not Migration.convert(held,"red"),"mail still refuses Gen 1") +end + +do + local state=Store.new() + local red={version="red",inventory={POKE_BALL=5,BICYCLE=1,TM_01=1},bagOrder={"POKE_BALL","BICYCLE","TM_01"}} + local rows=Items.bag(red,"red") + T.eq(#rows,2,"key items are not offered for storage") + T.check(not Items.deposit(state,red,"red","BICYCLE",1),"key items refuse storage") + local s1,save1=assert(Items.deposit(state,red,"red","POKE_BALL",3)) + T.eq(save1.inventory.POKE_BALL,2,"deposit removes from the bag") + T.eq(Items.locker(s1,"gen12")[1].count,3,"deposit adds to the shared Gen 1 and 2 stash") + T.check(Store.validate(Serializer.decode(Serializer.encode(s1)))~=nil,"locker survives validation") + T.check(not Items.deposit(state,red,"red","POKE_BALL",9),"cannot deposit more than the bag has") + local key=Items.locker(s1,"gen12")[1].key + local crystal={version="crystal",inventory={}} + local s2,save2=assert(Items.withdraw(s1,crystal,"crystal",key,2)) + T.eq(save2.inventory.POKE_BALL,2,"Gen 1 balls withdraw into a Gen 2 bag") + T.eq(Items.locker(s2,"gen12")[1].count,1,"withdraw removes from the stash") + local s3=assert(Items.deposit(s1,red,"red","TM_01",1)) + local tm + for _,row in ipairs(Items.locker(s3,"gen12")) do if row.name=="TM01" then tm=row.key end end + T.check(not Items.withdraw(s3,crystal,"crystal",tm,1),"Gen 1 TM01 does not become Gen 2 TM01") + local gold={version="gold",inventory={LEFTOVERS=2}} + local s4=assert(Items.deposit(state,gold,"gold","LEFTOVERS",2)) + local left=Items.locker(s4,"gen12")[1].key + T.check(not Items.withdraw(s4,{version="red",inventory={}},"red",left,1),"Gen 2-only items refuse a Gen 1 bag") + + s4.nextId=3 + s4.boxes[1].mons[1]=entry("gold",1) + s4.boxes[1].mons[2]=entry("red",2) + local s5=assert(Items.give(s4,{box=1,slot=1},left)) + T.eq(s5.boxes[1].mons[1].mon.item,"LEFTOVERS","give sets the held item") + T.eq(Items.locker(s5,"gen12")[1].count,1,"give uses one from the stash") + T.check(not Items.give(s4,{box=1,slot=2},left),"Gen 1 Pokémon cannot hold items") + local s6=assert(Items.takeHeld(s5,{box=1,slot=1})) + T.eq(s6.boxes[1].mons[1].mon.item,nil,"take clears the held item") + T.eq(Items.locker(s6,"gen12")[1].count,2,"take returns the item to the stash") + local mail=Store.copy(s4);mail.boxes[1].mons[1].mon.item="FLOWER_MAIL" + T.check(not Items.takeHeld(mail,{box=1,slot=1}),"mail stays on its Pokémon") +end + +do + local state=Store.new() + local em={version="emerald",bag={pockets={ITEMS={},KEY_ITEMS={{id=259,qty=1}},POKE_BALLS={{id=3,qty=4}},TM_CASE={},BERRY_POUCH={}}}} + local rows=Items.bag(em,"emerald") + T.eq(#rows,1,"Gen 3 key items are not offered for storage") + local s1,save1=Items.deposit(state,em,"emerald",3,2) + T.check(s1~=nil,"Gen 3 deposit works: "..tostring(save1)) + if s1 then + T.eq(save1.bag.pockets.POKE_BALLS[1].qty,2,"Gen 3 deposit removes from the ball pocket") + s1.nextId=2 + local out=assert(Migration.convert(entry("gold",1),"emerald")) + s1.boxes[1].mons[1]={id=1,version="emerald",generation=3,mon=out,display=Catalog.describe("emerald",out)} + local key=Items.balls(s1)[1].key + local s2=assert(Items.swapBall(s1,{box=1,slot=1},key)) + T.eq(s2.boxes[1].mons[1].mon.pokeball,3,"ball swap writes the ball id") + T.eq(Items.balls(s2)[1].count,1,"ball swap uses exactly one ball") + T.eq(#Items.balls(s2),1,"the old Poké Ball is not refunded") + T.check(not Items.swapBall(s2,{box=1,slot=1},key),"same ball is refused") + local egg=Store.copy(s1);egg.boxes[1].mons[1].mon.isEgg=true + T.check(not Items.swapBall(egg,{box=1,slot=1},key),"eggs cannot change ball") + T.check(not Items.swapBall(s1,{box=1,slot=1},"NOPE"),"unknown ball refused") + end +end + +do + local ctx={save={party={{species="PIKACHU",catchRate=190}}}} + require("src.script.Commands").set_catch_rate(ctx,1,0xA3) + T.eq(ctx.save.party[1].catchRate,0xA3,"Yellow starter Pikachu carries the LIGHT BALL catch rate byte") + local body=assert(io.open("data/scripts/oaks_lab_yellow.lua")):read("*a") + T.check(body:find('{ "give_pokemon", "PIKACHU", 5 }\n rows[#rows + 1] = { "set_catch_rate", 1, 0xA3 }',1,true)~=nil, + "Oak's Lab stamps the catch rate right after giving Pikachu") +end + +CacheFs.readAt=oldRead;Catalog.reset() +T.finish("Box bank rules and items") diff --git a/tests/engine/box_migration_test.lua b/tests/engine/box_migration_test.lua index 2fa8cd56..01d5c368 100644 --- a/tests/engine/box_migration_test.lua +++ b/tests/engine/box_migration_test.lua @@ -63,7 +63,7 @@ for _,from in ipairs(GameVersion.ORDER) do if GameVersion.generation(to)==3 then local total=0;for _,n in pairs(out.evs) do total=total+n end T.check(total<=510,"Gen3 effort fits total EV limit") - T.eq(out.nature,0,"new personality has disclosed Hardy nature") + if GameVersion.generation(from)<3 then T.eq(out.nature,125500%25,"nature comes from EXP mod 25") end local codec=require("src.save_convert.Gen3Save").forVersion(to) local round=codec.toPortMon(assert(codec.decodeBoxMon(codec.encodeBoxMon(codec.fromPortMon(out,{},false)))),false) T.eq(round.personality,out.personality,"converted PK3 personality survives native encryption") diff --git a/tests/engine/box_ui_test.lua b/tests/engine/box_ui_test.lua index 74700d0f..3e2e0bdd 100644 --- a/tests/engine/box_ui_test.lua +++ b/tests/engine/box_ui_test.lua @@ -67,13 +67,13 @@ for _, size in ipairs({ { 360, 780 }, { 390, 844 }, { 1024, 768 }, { 1360, 860 } T.eq(imp._boxState.view, "pc", "choosing a game opens its Game PC") T.eq(imp._boxPopup, nil, "choice closes the popup") click(imp, "box-tools-menu") - T.eq(#imp._boxPopup.options, 10, "tools retain every page except the separate Filters control") + T.eq(#imp._boxPopup.options, 11, "tools retain every page except the separate Filters control") keyboardChoice(imp, "end") T.check(imp._boxPopup.scroll > 0, "keyboard reveals tools below the visible rows") keyboardChoice(imp, "return") - T.eq(imp._boxState.toolsPage, "Migration", "last tool is reachable without a button wall") - click(imp, "box-help-Migration") - T.eq(imp._boxPopup.message, UI.HELP.Migration, "context help explains the selected tool") + T.eq(imp._boxState.toolsPage, "Items", "last tool is reachable without a button wall") + click(imp, "box-help-Items") + T.eq(imp._boxPopup.message, UI.HELP.Items, "context help explains the selected tool") T.check(#imp._boxPopup.message < 150, "context explanation stays short") local bounds = controls["box-popup-close"] T.check(bounds.x >= 0 and bounds.y >= 0 and bounds.x + bounds.w <= size[1] @@ -119,6 +119,18 @@ click(imp, "box-popup-option-1") T.check(imp._boxPrompt ~= nil and not imp._boxPopup, "rename opens the existing name prompt") keyboardChoice(imp, "escape") +imp._boxState.notice, imp._boxState.noticeKind = "That bag pocket is full.", "error"; settle(imp) +T.eq(imp._boxToast and imp._boxToast.kind, "error", "a failed operation shows a red toast") +T.eq(imp._boxState.notice, nil, "notices no longer print inline") +T.check(controls["box-toast"] ~= nil, "the toast is drawn and tappable") +local toastRect = controls["box-toast"] +draw(imp, { x = toastRect.x + toastRect.w / 2, y = toastRect.y + toastRect.h / 2 }); settle(imp) +T.eq(imp._boxToast, nil, "tapping the toast dismisses it") +imp._boxState.notice, imp._boxState.noticeKind = "Pokémon deposited.", "ok"; settle(imp) +T.eq(imp._boxToast.kind, "ok", "a success shows a green toast") +clock = clock + 10; settle(imp) +T.eq(imp._boxToast, nil, "a success toast times out") + local changes = 0 UI.open(imp, "Pick", { { id = 1, label = "One" } }, nil, function() changes = changes + 1 end) settle(imp) @@ -134,6 +146,21 @@ T.eq(imp._boxPopup.index, 11, "controller navigation crosses the visible row bou T.check(imp._boxPopup.scroll > 0, "controller reveals the selected option") imp:gamepadpressed(nil, "a"); settle(imp) T.eq(changes, 11, "controller A applies the revealed option") +UI.open(imp, "Box", UI.values((function() local out = {}; for i = 1, 25 do out[i] = i end; return out end)()), 1, + function(value) changes = value end) +settle(imp) +local row = controls["box-popup-option-2"] +local fx, fy = row.x + row.w / 2, row.y + row.h / 2 +View.touchpressed(imp, "finger", fx, fy) +for step = 1, 8 do View.touchmoved(imp, "finger", fx, fy - step * 30) end +settle(imp) +T.check(imp._boxPopup.scroll > 0, "a finger drag scrolls a long popup list") +local scrolled = imp._boxPopup.scroll +View.touchmoved(imp, "finger", fx, fy); settle(imp) +T.check(imp._boxPopup.scroll < scrolled, "dragging back scrolls the popup list up") +View.touchreleased(imp, "finger", fx, fy); settle(imp) +T.check(imp._boxPopup ~= nil, "a drag does not pick an option") +keyboardChoice(imp, "escape") UI.open(imp, "Box", {}, nil); settle(imp) imp:gamepadpressed(nil, "b"); settle(imp) T.eq(imp._boxPopup, nil, "controller B closes help") diff --git a/tests/engine/chipaudio_prewarm_queue_2732.lua b/tests/engine/chipaudio_prewarm_queue_2732.lua new file mode 100644 index 00000000..e2a82493 --- /dev/null +++ b/tests/engine/chipaudio_prewarm_queue_2732.lua @@ -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() diff --git a/tests/engine/crystal_buena_cache_2639.lua b/tests/engine/crystal_buena_cache_2639.lua index 1b8da38e..d358f928 100644 --- a/tests/engine/crystal_buena_cache_2639.lua +++ b/tests/engine/crystal_buena_cache_2639.lua @@ -31,7 +31,7 @@ for _, revision in ipairs(Version.revisions("crystal")) do end for _, version in ipairs({ "gold", "silver" }) do - T.eq(Contract.formatFor(version), "rom-cache-v14:", version .. " marker stays unchanged") + T.check(not Contract.formatFor(version):find("crystal", 1, true), version .. " marker has no Crystal password category") files[Version.cachePrefix(version) .. Contract.MARKER_PATH] = Contract.markerFor(version) files[Version.cachePrefix(version) .. "data/generated/events.lua"] = nil T.check(Contract.isReady(version, fs), version .. " does not require Crystal password output") diff --git a/tests/engine/game3_object_spawn_elevation_2737_test.lua b/tests/engine/game3_object_spawn_elevation_2737_test.lua new file mode 100644 index 00000000..24d71060 --- /dev/null +++ b/tests/engine/game3_object_spawn_elevation_2737_test.lua @@ -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") diff --git a/tests/engine/game3_rs_anim_names_test.lua b/tests/engine/game3_rs_anim_names_test.lua new file mode 100644 index 00000000..4a2b10a0 --- /dev/null +++ b/tests/engine/game3_rs_anim_names_test.lua @@ -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") diff --git a/tests/engine/game3_rs_bag_row_layout_2727.lua b/tests/engine/game3_rs_bag_row_layout_2727.lua new file mode 100644 index 00000000..74e1c035 --- /dev/null +++ b/tests/engine/game3_rs_bag_row_layout_2727.lua @@ -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") diff --git a/tests/engine/game3_rs_battle_menu_frames_2729.lua b/tests/engine/game3_rs_battle_menu_frames_2729.lua new file mode 100644 index 00000000..1a81c8a8 --- /dev/null +++ b/tests/engine/game3_rs_battle_menu_frames_2729.lua @@ -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") diff --git a/tests/engine/game3_rse_field_poison_script_2736.lua b/tests/engine/game3_rse_field_poison_script_2736.lua new file mode 100644 index 00000000..5edcd094 --- /dev/null +++ b/tests/engine/game3_rse_field_poison_script_2736.lua @@ -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") diff --git a/tests/engine/game3_transition_world_canvas_2731_test.lua b/tests/engine/game3_transition_world_canvas_2731_test.lua new file mode 100644 index 00000000..118fc14d --- /dev/null +++ b/tests/engine/game3_transition_world_canvas_2731_test.lua @@ -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() diff --git a/tests/engine/gen2_sfx_prewarm_bug2732.lua b/tests/engine/gen2_sfx_prewarm_bug2732.lua new file mode 100644 index 00000000..494bbe95 --- /dev/null +++ b/tests/engine/gen2_sfx_prewarm_bug2732.lua @@ -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() diff --git a/tests/engine/host_restart_android_bug575.lua b/tests/engine/host_restart_android_bug575.lua index 8eba0487..dca731dd 100644 --- a/tests/engine/host_restart_android_bug575.lua +++ b/tests/engine/host_restart_android_bug575.lua @@ -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") diff --git a/tests/engine/hostshell_restart_single_spawn_2740.lua b/tests/engine/hostshell_restart_single_spawn_2740.lua new file mode 100644 index 00000000..991b2dc5 --- /dev/null +++ b/tests/engine/hostshell_restart_single_spawn_2740.lua @@ -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") diff --git a/tests/engine/launcher_settings_persist_2740.lua b/tests/engine/launcher_settings_persist_2740.lua new file mode 100644 index 00000000..e3bed5c1 --- /dev/null +++ b/tests/engine/launcher_settings_persist_2740.lua @@ -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") diff --git a/tests/engine/save_editor_unown_form_2726.lua b/tests/engine/save_editor_unown_form_2726.lua new file mode 100644 index 00000000..2b899ad8 --- /dev/null +++ b/tests/engine/save_editor_unown_form_2726.lua @@ -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() diff --git a/tests/online_convert.lua b/tests/online_convert.lua index 3e446a27..44aa2cb5 100644 --- a/tests/online_convert.lua +++ b/tests/online_convert.lua @@ -76,9 +76,13 @@ local gen2Data = { SHADOW_BALL = { id = "SHADOW_BALL", name = "SHADOW BALL", pp = 15 }, }, items = { - LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS" }, - METAL_COAT = { id = "METAL_COAT", name = "METAL COAT" }, - FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL" }, + LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", index = 0x92 }, + METAL_COAT = { id = "METAL_COAT", name = "METAL COAT", index = 0x8f }, + FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", index = 0x9e }, + BITTER_BERRY = { id = "BITTER_BERRY", name = "BITTER BERRY", index = 0x53 }, + BERRY = { id = "BERRY", name = "BERRY", index = 0xad }, + TM_DOUBLE_TEAM = { id = "TM_DOUBLE_TEAM", name = "TM32", index = 0xe1 }, + timeCapsule = { [0x19] = "LEFTOVERS", [0x2d] = "BITTER_BERRY", [0xbe] = "BERRY", [0xff] = "BERRY" }, }, } @@ -207,7 +211,7 @@ do T.eq(out.dvs.hp, Mon.hpDV(src.dvs), "1->2 derives the HP DV") T.eq(out.happiness, 70, "1->2 stamps happiness 70") T.eq(out.pokerus, 0, "1->2 zeroes pokerus") - T.eq(out.item, nil, "1->2 carries no held item") + T.eq(out.item, "BITTER_BERRY", "1->2 reads catch rate 45 through the Teru-sama table") T.eq(out.caughtLevel, 30, "1->2 meets at the current level") T.eq(out.isEgg, false, "1->2 is never an egg") T.eq(out.nickname, "SPROUT", "1->2 keeps the nickname") @@ -224,6 +228,13 @@ do "1->2 splits one Special into two equal stats when the bases agree") T.eq(out.moves[1].maxPp, 35, "1->2 gives moves a maxPp from move data") T.eq(#report.lost, 0, "1->2 loses nothing") + T.eq(kinds(report.changed).item.to, "BITTER_BERRY", "1->2 reports the held item") + T.eq(Convert.toGen2(gen1Mon("TESTMON", 30, { catchRate = 0xe1 }), gen1Data, gen2Data).item, + "TM_DOUBLE_TEAM", "1->2 keeps a catch rate outside the table as that item id") + T.eq(Convert.toGen2(gen1Mon("TESTMON", 30, { catchRate = 0x19 }), gen1Data, gen2Data).item, + "LEFTOVERS", "1->2 maps catch rate 25 to LEFTOVERS") + T.eq(Convert.toGen2(gen1Mon("TESTMON", 30, { catchRate = 0 }), gen1Data, gen2Data).item, + nil, "1->2 catch rate 0 holds nothing") T.check(kinds(report.changed).happiness ~= nil, "1->2 reports the friendship") end @@ -305,8 +316,8 @@ do T.eq(out.level, 30, "2->1 keeps the level") T.eq(out.exp, Growth.expForLevel("MEDIUM_SLOW", 30), "2->1 recomputes exp on the Gen 1 curve") - T.eq(out.catchRate, 45, - "2->1 stamps the Gen 1 species' own catch rate") + T.eq(out.catchRate, 0, + "2->1 writes the empty held item byte into the catch rate slot") T.eq(out.stats.special, Stats.calc(gen1Data.pokemon.TESTMON, 30, out.dvs, out.statExp).special, "2->1 folds Special back through the Gen 1 base Special") @@ -325,13 +336,16 @@ do local src = gen2Mon("TESTMON", 40, { item = "LEFTOVERS", pokerus = 0xf1 }) local out, report = Convert.toGen1(src, gen2Data, gen1Data) T.check(out ~= nil, "2->1 accepts a mon holding an ordinary item") - T.eq(out.item, nil, "2->1 drops the held item") - T.eq(out.catchRate, 45, - "2->1 does not put the item byte in the catch rate slot") + T.eq(out.item, nil, "2->1 has no held item field") + T.eq(out.catchRate, 0x92, + "2->1 puts the held item byte in the catch rate slot") + T.eq(kinds(report.changed).item.text, "HELD ITEM KEPT AS CATCH RATE 146: LEFTOVERS", + "2->1 names the item it kept") local lost = kinds(report.lost) - T.eq(lost.item.text, "HELD ITEM LOST: LEFTOVERS", - "2->1 names the item it dropped") + T.check(lost.item == nil, "2->1 does not report the item as lost") T.check(lost.pokerus ~= nil, "2->1 reports pokerus lost") + local back = Convert.toGen2(out, gen1Data, gen2Data) + T.eq(back.item, "LEFTOVERS", "2->1->2 round trip returns the held item") end do @@ -414,7 +428,7 @@ do T.check(back ~= nil, "round trip comes back") T.eq(back.level, src.level, "round trip keeps the level") T.eq(back.exp, src.exp, "round trip keeps the exp") - T.eq(back.catchRate, src.catchRate, "round trip keeps the catch rate") + T.eq(back.catchRate, 0x53, "round trip stores the Teru-sama replacement item as the catch rate") for _, key in ipairs({ "hp", "attack", "defense", "speed", "special" }) do T.eq(back.stats[key], src.stats[key], "round trip keeps stat " .. key) T.eq(back.dvs[key], src.dvs[key], "round trip keeps DV " .. key) @@ -476,8 +490,8 @@ do local lines, ok = Convert.preview(gen2Mon("TESTMON", 30, { item = "LEFTOVERS" }), 2, 1, gen2Data, gen1Data) T.check(ok, "preview of a legal 2->1 mon is ok") - T.check(has(lines, "HELD ITEM LOST: LEFTOVERS"), - "preview names the lost held item") + T.check(has(lines, "HELD ITEM KEPT AS CATCH RATE 146: LEFTOVERS"), + "preview names the kept held item") local bad, badOk = Convert.preview(gen2Mon("JOHTOMON", 30), 2, 1, gen2Data, gen1Data) @@ -536,9 +550,9 @@ do T.eq(results[2].index, 2, "slot 2 carries its index") T.eq(results[3].reason, "move_too_new", "slot 3 refusal reason") T.eq(results[3].info.move, "SHADOW_BALL", "slot 3 names the move") - T.check(results[4].ok, "slot 4 is legal with the item dropped") - T.check(has(results[4].preview, "HELD ITEM LOST: METAL COAT"), - "slot 4 preview names the dropped METAL COAT") + T.check(results[4].ok, "slot 4 is legal with the item kept") + T.check(has(results[4].preview, "HELD ITEM KEPT AS CATCH RATE 143: METAL COAT"), + "slot 4 preview names the kept METAL COAT") T.eq(#Convert.refusals(results), 2, "two refusals collected") local up, upResults = Convert.partyToGen2( diff --git a/tests/quick.list b/tests/quick.list index 4488e44d..6764250e 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -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 diff --git a/tools/gen_anim_names.py b/tools/gen_anim_names.py index 2800e322..f7c6703a 100644 --- a/tools/gen_anim_names.py +++ b/tools/gen_anim_names.py @@ -2,21 +2,32 @@ """Generate src/import/gba/anim_names_.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"]) diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 7e8bdcbe..7d2fc21e 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -705,6 +705,7 @@ REQUIRED_SYMBOLS = { # Battle + pokemon runtime data "MoveNames", "TypeNames", "TypeMatchups", "TMHMMoves", "GrowthRates", "ItemAttributes", "MoveDescriptions", "ItemDescriptions", + "TimeCapsule_CatchRateItems", # Wild encounters (data/wild/*) "JohtoGrassWildMons", "JohtoWaterWildMons", "KantoGrassWildMons", "KantoWaterWildMons", diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json index 3de190e2..9293a363 100644 --- a/tools/rom_manifest_crystal.json +++ b/tools/rom_manifest_crystal.json @@ -19724,6 +19724,10 @@ 19, 21910 ], + "TimeCapsule_CatchRateItems": [ + 10, + 18309 + ], "TimeFishGroups": [ 36, 26223 diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 184ab9aa..e3a7afdb 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -17516,6 +17516,10 @@ 5, 22206 ], + "TimeCapsule_CatchRateItems": [ + 10, + 18134 + ], "TimeFishGroups": [ 36, 27614 diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index db2328d8..788e18fa 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -17516,6 +17516,10 @@ 5, 22206 ], + "TimeCapsule_CatchRateItems": [ + 10, + 18134 + ], "TimeFishGroups": [ 36, 27614 diff --git a/tools/save-editor/InspectorBody.lua b/tools/save-editor/InspectorBody.lua index c484701e..cfa0f2cc 100644 --- a/tools/save-editor/InspectorBody.lua +++ b/tools/save-editor/InspectorBody.lua @@ -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) diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 734c7b91..1e4c5ec4 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -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) diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index b9fd735c..e1994a6f 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -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) diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index ec1e38e1..54dec590 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -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 diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 7be6e4dd..0981219b 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -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