Merge pull request #2741 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-10-07 06:09:52 -04:00
committed by GitHub
82 changed files with 5759 additions and 1037 deletions
+1
View File
@@ -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" }
+4
View File
@@ -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
+7 -2
View File
@@ -179,8 +179,13 @@ bundled game, in that case.
in-app. If neither transport exists, the worker reports `needs_full` and
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
`needs_full`). Applying a downloaded payload on Android restarts
in-process with `quit("restart")` only when the running `love.run` sets
`POKEPORT_LOOP_RESTART`; an older APK shell's stepper would hand that
string to `os.exit`, so there `HostShell.restart` falls back to
`love.system.restartApp` (or a plain quit) and EXIT GAME rebuilds the
launcher in the same Lua state instead. iOS uses a bare `quit()`, which
its `love.cpp` turns into a restart.
- **Android full updates are user-confirmed and certificate-bound.** The app
uses a private `FileProvider` cache path plus
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
+56 -5
View File
@@ -525,10 +525,56 @@ local function makeLauncher(launcherOpts)
})
end
local deferredLaunchRequest
local function rebuildLauncherInProcess(opts)
if require("src.core.RequireGuard").repair() then
print("boot: restored love.filesystem searcher (see #2001)")
end
local currentVersion = require("src.core.GameVersion").get()
SessionLifecycle.endGameSession(Game)
Game = nil
pcall(function() require("src.online.Trade").hostIsLive = nil end)
local syncEngine = package.loaded["src.sync.SyncEngine"]
if type(syncEngine) == "table" and type(syncEngine._shared) == "table" then
pcall(syncEngine._shared.protectPlaythrough, syncEngine._shared, nil, nil)
end
autopilot = nil
driverCo = nil
local SaveData = require("src.core.SaveData")
local cartId = SaveData.getCart()
SaveData.setCart(nil)
require("src.core.GameSpeed").setAllowed(nil)
SessionLifecycle.endMountedSession(currentVersion)
SaveData.refreshSlotResolution(currentVersion)
if cartId then SaveData.refreshSlotResolution("cart_" .. cartId) end
applySavedOrientation()
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
end
Importer = makeLauncher({ initialTab = opts.tab, invite = opts.invite })
if Importer.ignoreReturningPointer then
Importer:ignoreReturningPointer()
end
if type(opts.request) == "table" then deferredLaunchRequest = opts.request end
end
local function returnToLauncher(opts)
if not Game or quitToLauncher then return end
quitToLauncher = true
opts = opts or {}
if not require("src.core.HostShell").canRestart() then
return rebuildLauncherInProcess(opts)
end
quitToLauncher = true
local handoff = { tab = opts.tab, invite = opts.invite, request = opts.request }
local okEncode, body = pcall(require("src.core.SaveSerializer").encode, handoff)
pcall(love.filesystem.write, RELAUNCH_MARKER, okEncode and body or "return {}\n")
@@ -538,6 +584,7 @@ local function returnToLauncher(opts)
autopilot = nil
driverCo = nil
endProcessOnce()
if opts.restarting then return end
require("src.core.HostShell").restart()
end
@@ -673,8 +720,6 @@ local function autoUpdateMods(request, tab)
end
end
local deferredLaunchRequest
local function launcherBusy()
return launcherSplash ~= nil or Importer ~= nil and (Importer._updateAll ~= nil
or Importer._modInstall ~= nil or Importer._cartInstall ~= nil
@@ -1617,8 +1662,13 @@ function love.quit()
and (mobile or not launchedIntoGame)
end)
if wouldReturnToLauncher then
returnToLauncher()
return true
local HostShell = require("src.core.HostShell")
if HostShell.restarting and HostShell.canRestart() then
returnToLauncher({ restarting = true })
else
returnToLauncher()
return true
end
end
endProcessOnce()
end
@@ -1658,6 +1708,7 @@ local function idlePresentationCap(idleFor)
end
function love.run()
_G.POKEPORT_LOOP_RESTART = true
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
+261
View File
@@ -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
+1
View File
@@ -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]
+100 -30
View File
@@ -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<b.add end)
return rows
end
function Migration.preview(state,refs,version)
local preview={revision=state.revision,destination=version,rows={},allowed=true}
local seen={}
+24
View File
@@ -428,6 +428,30 @@ function Service:migrate(refs, version, preview)
return self:commit(state)
end
function Service:depositItem(source, id, count)
local save, why, body = self:read(source)
if not save then return nil, why end
local nextState, nextSave = require("src.box.Items").deposit(self.state, save, source.version, id, count)
if not nextState then return nil, nextSave end
return self:commit(nextState, source, body, nextSave)
end
function Service:withdrawItem(source, key, count)
local save, why, body = self:read(source)
if not save then return nil, why end
local nextState, nextSave = require("src.box.Items").withdraw(self.state, save, source.version, key, count)
if not nextState then return nil, nextSave end
return self:commit(nextState, source, body, nextSave)
end
for _, operation in ipairs({ "give", "takeHeld", "swapBall" }) do
Service[operation] = function(self, ...)
local state, why = require("src.box.Items")[operation](self.state, ...)
if not state then return nil, why end
return self:commit(state)
end
end
function Service:restoreOriginal(ref, index)
local state, why = require("src.box.Migration").restore(self.state, ref, index)
if not state then return nil, why end
+1
View File
@@ -87,6 +87,7 @@ function Store.validate(state)
return nil, "Gift progress is invalid."
end
end
if not require("src.box.Items").validate(state.items) then return nil, "The item locker is invalid." end
if state.presets ~= nil and type(state.presets) ~= "table" then return nil, "Team presets are invalid." end
if state.organizerProfiles ~= nil then
if type(state.organizerProfiles) ~= "table" or #state.organizerProfiles > 20 then return nil, "Auto Box presets are invalid." end
+14
View File
@@ -23,6 +23,18 @@ function Transaction.assetValid(path, body)
return be(17) == 1136 and be(21) == 432
end
local function forgetOptions(journal)
for _, job in ipairs(journal.jobs) do
if job.path == "options.lua" then
local SaveData = package.loaded["src.core.SaveData"]
if type(SaveData) == "table" and SaveData.invalidateOptionsCache then
SaveData.invalidateOptionsCache()
end
return
end
end
end
local function write(fs, path, body)
local dir = path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then
@@ -105,6 +117,7 @@ local function abort(fs, journal, body)
local engine = type(sync) == "table" and sync._shared
if type(engine) == "table" then engine:noteSaveWritten() end
end
if wrote then forgetOptions(journal) end
local kept, err = quarantine(fs, body)
if not kept then return nil, err end
return true, false, "A save changed while a Box transfer was pending. The transfer was undone and its record kept at " .. kept .. "."
@@ -188,6 +201,7 @@ function Transaction.recover(fs)
if not ok or fs.getInfo(job.path .. ".tmp") then return nil, err or "Could not clear a stale save recovery file." end
end
end
forgetOptions(journal)
local ok, err = fs.remove(Transaction.PATH)
if not ok or fs.getInfo(Transaction.PATH) then
return nil, err or "The completed Box transfer journal could not be cleared."
+175 -25
View File
@@ -75,6 +75,9 @@ local statFrames, statUnderruns, statRestarts = 0, 0, 0
local statDepthMin, statDepthSum, statDepthFrames = nil, 0, 0
local statWorkerJit, statWorkerXrt = nil, nil
local statXrtSum, statXrtCount, statXrtMax = 0, 0, nil
local statSyncRenders, statSyncMs, statSyncMsMax = 0, 0, 0
local statSyncLast
local statPrewarmHits = 0
local lastCosted
-- ---------------------------------------------------------------------------
@@ -91,8 +94,18 @@ local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def
local fxReady = {} -- key -> SoundData | false (renders to nothing)
local fxPending = {} -- key -> true while a request is in flight
local fxReadyCount = 0
local fxReadyOrder = {}
local fxOrderCount = 0
local FX_READY_MAX = 64
local drainEffects, dropEffects
local fxPinned = {}
local fxPinnedCount = 0
local FX_PINNED_MAX = 24
local pinRequests = false
local fxQueue = {}
local fxInFlight = {}
local fxInFlightCount = 0
local FX_IN_FLIGHT_MAX = 2
local drainEffects, dropEffects, pumpEffects
local function ensureWorker()
if workerReady ~= nil then return workerReady end
@@ -397,6 +410,14 @@ function ChipAudio.stats()
xrtAvg = statXrtCount > 0 and (statXrtSum / statXrtCount) or nil,
xrtMax = statXrtMax,
buffers = statXrtCount,
syncRenders = statSyncRenders,
syncRenderMs = statSyncMs,
syncRenderMsMax = statSyncMsMax,
syncRenderLast = statSyncLast,
prewarmHits = statPrewarmHits,
prewarmQueued = #fxQueue,
prewarmInFlight = fxInFlightCount,
prewarmReady = fxReadyCount,
}
local function num(value, places)
if type(value) ~= "number" then return "-" end
@@ -404,18 +425,25 @@ function ChipAudio.stats()
end
stats.line = string.format(
"rate=%d worker=%s depth=%s/%d min=%s avg=%s underruns=%d restarts=%d "
.. "xrt=%s/%s/%s n=%d",
.. "xrt=%s/%s/%s n=%d sync=%d/%sms max=%sms last=%s prewarm=%d q=%d",
stats.rate, worker, depth and tostring(depth) or "-", MUSIC_BUFFER_COUNT,
statDepthMin and tostring(statDepthMin) or "-", num(average, 1),
statUnderruns, statRestarts,
num(statWorkerXrt, 3), num(stats.xrtAvg, 3), num(statXrtMax, 3),
statXrtCount)
statXrtCount, statSyncRenders, num(statSyncMs, 1), num(statSyncMsMax, 1),
tostring(statSyncLast or "-"), statPrewarmHits, #fxQueue + fxInFlightCount)
return stats
end
function ChipAudio.resetSyncStats()
statSyncRenders, statSyncMs, statSyncMsMax = 0, 0, 0
statSyncLast = nil
statPrewarmHits = 0
end
function ChipAudio.update()
if suspended then return end
if fxCh then drainEffects() end
if fxCh then pumpEffects() end
local m = currentMusic
if not m then return end
if m.threaded then
@@ -708,19 +736,59 @@ local function effectKey(data, header, options)
}, "|")
end
local function storeReady(key, sd)
if fxReady[key] ~= nil then return end
if fxPinned[key] then
fxReady[key] = sd
fxReadyCount = fxReadyCount + 1
return
end
while fxOrderCount >= FX_READY_MAX and #fxReadyOrder > 0 do
local oldest = table.remove(fxReadyOrder, 1)
if fxReady[oldest] ~= nil and not fxPinned[oldest] then
fxReady[oldest] = nil
fxReadyCount = fxReadyCount - 1
fxOrderCount = fxOrderCount - 1
end
end
if #fxReadyOrder > FX_READY_MAX * 2 then
local live = {}
for _, k in ipairs(fxReadyOrder) do
if fxReady[k] ~= nil and not fxPinned[k] then live[#live + 1] = k end
end
fxReadyOrder = live
end
fxReady[key] = sd
fxReadyCount = fxReadyCount + 1
fxOrderCount = fxOrderCount + 1
fxReadyOrder[#fxReadyOrder + 1] = key
end
local function pinEffect(key)
if fxPinned[key] or fxPinnedCount >= FX_PINNED_MAX then return end
fxPinned[key] = true
fxPinnedCount = fxPinnedCount + 1
if fxReady[key] ~= nil then fxOrderCount = fxOrderCount - 1 end
end
function drainEffects()
if not fxCh then return end
while true do
local result = fxCh:pop()
if not result then return end
if result.epoch == fxEpoch and fxPending[result.key] then
fxPending[result.key] = nil
if result.error then
require("src.core.Logger").warn("chip audio prewarm: %s",
tostring(result.error))
elseif fxReadyCount < FX_READY_MAX then
fxReady[result.key] = result.sd or false
fxReadyCount = fxReadyCount + 1
if result.epoch == fxEpoch then
if fxInFlight[result.key] then
fxInFlight[result.key] = nil
fxInFlightCount = fxInFlightCount - 1
end
if fxPending[result.key] then
fxPending[result.key] = nil
if result.error then
require("src.core.Logger").warn("chip audio prewarm: %s",
tostring(result.error))
else
storeReady(result.key, result.sd or false)
end
end
end
end
@@ -729,17 +797,53 @@ end
function dropEffects()
fxEpoch = fxEpoch + 1
fxReady, fxPending, fxReadyCount = {}, {}, 0
fxReadyOrder, fxQueue, fxInFlight, fxInFlightCount = {}, {}, {}, 0
fxOrderCount, fxPinned, fxPinnedCount = 0, {}, 0
if fxCh then fxCh:clear() end
end
local function musicHasHeadroom()
local m = currentMusic
if not (m and m.threaded and m.started) or m.finished or musicHeld then
return true
end
local depth = queuedBuffers(m.source)
if not depth then return true end
if outCh then depth = depth + outCh:getCount() end
return depth >= MUSIC_PREROLL * 2
end
function pumpEffects()
drainEffects()
while #fxQueue > 0 and fxInFlightCount < FX_IN_FLIGHT_MAX do
if not workerAlive() then
fxQueue = {}
return
end
if fxInFlightCount > 0 and not musicHasHeadroom() then return end
local entry = table.remove(fxQueue, 1)
if fxPending[entry.key] then
local pushed = pcall(cmdCh.push, cmdCh, entry.request)
if pushed then
fxInFlight[entry.key] = true
fxInFlightCount = fxInFlightCount + 1
else
fxPending[entry.key] = nil
end
end
end
end
local function requestEffect(data, header, options)
if not ensureWorker() or not workerAlive() then return false end
local key = effectKey(data, header, options)
if not key then return false end
drainEffects()
if fxReady[key] ~= nil or fxPending[key] then return true end
fxCh = fxCh or love.thread.getChannel("chipaudio_fx")
local pushed = pcall(cmdCh.push, cmdCh, {
drainEffects()
if pinRequests then pinEffect(key) end
if fxReady[key] ~= nil or fxPending[key] then return true end
fxPending[key] = true
fxQueue[#fxQueue + 1] = { key = key, request = {
cmd = "effect", key = key, epoch = fxEpoch, header = header,
options = {
frequencyOffset = options.frequencyOffset,
@@ -753,9 +857,8 @@ local function requestEffect(data, header, options)
channelPitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo(),
sampleRate = sampleRate(),
})
if not pushed then return false end
fxPending[key] = true
} }
pumpEffects()
return true
end
@@ -769,16 +872,46 @@ local function takeEffect(data, header, options)
if sd ~= nil then
fxReady[key] = nil
fxReadyCount = fxReadyCount - 1
if not fxPinned[key] then fxOrderCount = fxOrderCount - 1 end
return sd
end
-- about to render it here; the worker's copy would arrive unused
fxPending[key] = nil
if fxPending[key] then
fxPending[key] = nil
for i, entry in ipairs(fxQueue) do
if entry.key == key then
table.remove(fxQueue, i)
break
end
end
end
return nil
end
local function renderEffect(data, header, options)
local function nowMs()
local timer = love and love.timer
if timer and timer.getTime then return timer.getTime() * 1000 end
return os.clock() * 1000
end
local function renderEffect(data, header, options, label)
local sd = takeEffect(data, header, options)
if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end
if sd == nil then
local began = nowMs()
sd = ChipSynth.renderEffectData(data, header, options)
local ms = nowMs() - began
statSyncRenders = statSyncRenders + 1
statSyncMs = statSyncMs + ms
if ms > statSyncMsMax then statSyncMsMax = ms end
statSyncLast = label
if STATS then
require("src.core.Logger").info("chipaudio: sync render %s %.1fms",
tostring(label), ms)
end
local FixedStep = require("src.core.FixedStep")
if ms >= FixedStep.STEP * 1000 then FixedStep:discardCatchup() end
else
statPrewarmHits = statPrewarmHits + 1
end
if not sd then return nil end
return love.audio.newSource(sd, "static")
end
@@ -801,7 +934,8 @@ end
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name]
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames),
name)
end
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
@@ -810,7 +944,8 @@ end
function ChipAudio.newCry(data, species, resolved)
local cry = cryDef(data, species, resolved)
if not cry then return nil end
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry))
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry),
"cry:" .. tostring(species))
end
-- Same arguments as newSfx / newCry; returns true when a render was queued
@@ -830,11 +965,26 @@ function ChipAudio.prewarmCry(data, species, resolved)
return requestEffect(data, header, cryOptions(cry))
end
function ChipAudio.pumpEffects()
if suspended or not fxCh then return end
pumpEffects()
end
function ChipAudio.prewarmPinned(fn, ...)
local was = pinRequests
pinRequests = true
local ok, err = pcall(fn, ...)
pinRequests = was
if not ok then error(err, 0) end
end
-- test hook: prewarm bookkeeping
function ChipAudio._effectStateForTest()
local pending = 0
for _ in pairs(fxPending) do pending = pending + 1 end
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch }
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch,
queued = #fxQueue, inFlight = fxInFlightCount,
pinned = fxPinnedCount, unpinnedReady = fxOrderCount }
end
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
+22
View File
@@ -1234,6 +1234,7 @@ function Game2:load(opts)
pcall(Font.load, self.data)
end
Strings.load(self.data)
self:prewarmSessionSfx()
-- The boot skeleton (Game2.new built it, before any bus existed) announced
-- here rather than at its construction, which is the same spot in the boot
@@ -1341,6 +1342,25 @@ function Game2:load(opts)
end
end
Game2.SESSION_SFX = {
-- engine/overworld/scripting.asm:467
"Sfx_Item", "Sfx_KeyItem", "Sfx_GetTm",
"Sfx_ReadText2", "Sfx_Menu", "Sfx_Bump", "Sfx_EnterDoor",
"Sfx_ExitBuilding", "Sfx_JumpOverLedge", "Sfx_WarpTo", "Sfx_WarpFrom",
"Sfx_Save", "Sfx_Transaction", "Sfx_Wrong", "Sfx_SwitchPokemon",
"Sfx_GetBadge", "Sfx_Strength",
}
function Game2:prewarmSessionSfx()
local Sound = require("src.core.Sound")
local ChipAudio = require("src.core.ChipAudio")
ChipAudio.prewarmPinned(function()
for _, name in ipairs(Game2.SESSION_SFX) do
pcall(Sound.prewarmSfx, self.data, name)
end
end)
end
function Game2:inFillBoot()
-- Entire pre-world cinema (copyright / title / Oak / name / nested NamingScreen)
-- draws in GB letterbox space.
@@ -1376,6 +1396,8 @@ end
function Game2:update(dt)
require("src.core.DeferredWrite").tick()
local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then ChipAudio.pumpEffects() end
-- _UpdateSound is a VBlank job, so it runs at 60Hz off real time whatever the
-- logic multiplier is (audio/engine.asm:84, home/vblank.asm:141-143).
local step = FixedStep.STEP
+2 -1
View File
@@ -368,7 +368,8 @@ end
function Game3:writeOptions()
if type(self.options) ~= "table" then return end
if SaveData.saveOptions then pcall(SaveData.saveOptions, self.options) end
local write = SaveData.saveSessionOptions or SaveData.saveOptions
if write then pcall(write, self.options) end
end
Game3.persistOptions = Game3.writeOptions
+19 -10
View File
@@ -236,21 +236,30 @@ local function windowsModulePath()
return api and api.modulePath() or nil
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
-- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- Android (#575) and iOS restart in-process: love.quit joins every worker
-- first, so no physfs handle outlives lua_close. iOS turns every quit into
-- DONE_RESTART in love.cpp, so a bare quit() is the restart there.
-- quit("restart") re-inits physfs, which an AppImage refuses ("already initialized"), so Linux execs the binary.
function HostShell.loopRestarts()
return rawget(_G, "POKEPORT_LOOP_RESTART") == true
end
function HostShell.canRestart()
local osName = love and love.system and love.system.getOS and love.system.getOS()
return osName ~= "Android" or HostShell.loopRestarts()
end
HostShell.restarting = false
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
HostShell.restarting = true
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Android" then
love.event.quit("restart")
if HostShell.loopRestarts() then
love.event.quit("restart")
return
end
if love.system.restartApp and love.system.restartApp() then return end
love.event.quit()
return
end
if osName == "iOS" then
+36 -2
View File
@@ -871,7 +871,36 @@ end
-- Both take an optional fs (write/getInfo/read) defaulting to
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
SaveData.LAUNCHER_KEYS = {
"autoReimport", "splashVideo", "splashMute", "themeVideoBg", "reduceMotion",
"boxMusicVol", "boxCryVol", "romSources", "lastVersion", "activeCart", "modSort",
}
function SaveData.launcherKeys()
local out = {}
for _, key in ipairs(SaveData.LAUNCHER_KEYS) do out[#out + 1] = key end
local ok, SecretGames = pcall(require, "src.import.SecretGames")
if ok and type(SecretGames) == "table" and type(SecretGames.GAMES) == "table" then
for _, key in pairs(SecretGames.GAMES) do out[#out + 1] = key end
end
return out
end
function SaveData.invalidateOptionsCache()
for _, slot in pairs(optionsCache) do slot.rev = slot.rev + 1 end
end
local writeOptions
function SaveData.saveOptions(opts, fs)
return writeOptions(opts, fs, false)
end
function SaveData.saveSessionOptions(opts, fs)
return writeOptions(opts, fs, true)
end
writeOptions = function(opts, fs, launcherFromDisk)
fs = persistFs(fs)
-- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a
-- PARTIAL table (just the keys it changed) would silently drop every key it
@@ -893,6 +922,11 @@ function SaveData.saveOptions(opts, fs)
-- a partial write would push the global value into the cart's bucket.
local cartGlobalValues = cartGlobals(onDisk)
applyCartOverlay(onDisk)
if launcherFromDisk and type(opts) == "table" and type(onDisk) == "table" then
for _, key in ipairs(SaveData.launcherKeys()) do
opts[key] = deepCopy(onDisk[key])
end
end
local isFull = type(opts) == "table"
if isFull then
for k in pairs(SaveData.defaultOptions()) do
@@ -2720,7 +2754,7 @@ end)
function SaveData.saveLiveOptions(data)
if type(data) ~= "table" or type(data.options) ~= "table" then return nil end
data.options = rememberPlaythroughId(data, data.options)
return SaveData.saveOptions(data.options)
return SaveData.saveSessionOptions(data.options)
end
-- Game progress only; options are written separately via saveOptions.
@@ -2740,7 +2774,7 @@ function SaveData.save(data, mods)
SaveData.saveLiveOptions(data)
else
local opts, changed = rememberPlaythroughId(data)
if changed then SaveData.saveOptions(opts) end
if changed then SaveData.saveSessionOptions(opts) end
end
if activeCart and type(data.meta) == "table" then
data.meta.cartId = activeCart
@@ -39,5 +39,17 @@ return function()
end
K.destroy(t)
end),
-- pokeruby/src/battle/anim/sfx.c:194
sub_812B2B8 = K.wrap(function(t, vm)
local isContest = Context.isContest(vm)
local pan = isContest and 63 or (P.atkId(vm) % 2 == 0 and -64 or 63)
local species
if isContest then species = tonumber(P.species(vm, P.atk(vm))) or 0
else species = partySpecies(K.battleState(), P.atkId(vm)) end
if species ~= 0 then
require("src.core.game3.audio").playCry(species, { mode = 4, pan = pan, volume = 125 })
end
K.destroy(t)
end),
}
end
+5 -1
View File
@@ -2198,6 +2198,10 @@ local function is_rs_battle()
return P.font.nativeLayout == "rs"
end
function Ui.drawMenuFrames(panelMode)
BattleChrome.drawMenuFrames(panelMode, is_rs_battle() and "rs" or nil)
end
local function c5to8(x)
return (x * 8 + math.floor(x / 4)) / 255
end
@@ -2804,7 +2808,7 @@ function Ui.draw(w, h)
panelMode = "moves"
end
BattleChrome.drawPanel(panelMode)
BattleChrome.drawMenuFrames(panelMode)
Ui.drawMenuFrames(panelMode)
if Ui._mode == "menu" then
draw_action_menu(st)
+1
View File
@@ -2008,6 +2008,7 @@ function BattleTransition.drawWorld(canvas, vw, vh)
local fx = BattleTransition._fx
G.push("all")
G.origin()
G.setCanvas(canvas)
if fx and fx.def.redraw then
local scratch = BattleTransition._scratch
if not scratch or scratch:getWidth() ~= vw or scratch:getHeight() ~= vh then
+1
View File
@@ -360,6 +360,7 @@ local function presentPlanes(game)
local vw, vh = Renderer:worldViewSize()
drawFieldPlane(game, vw, vh, Renderer)
love.graphics.pop()
love.graphics.setCanvas(Renderer.worldCanvas)
local Transition = package.loaded["src.core.game3.battle_transition"]
if Transition and Transition.isActive and Transition.isActive() then
Transition.drawWorld(Renderer.worldCanvas, vw, vh)
+5
View File
@@ -2111,6 +2111,11 @@ function Field.respawnAtHeal(opts)
Flags.setVar(Space.store, Space.vm and Space.vm.ctx or nil, Ctx.VAR_LAST_TALKED, healerId)
end
end
if whiteOut and healRow then
-- pokeemerald/src/overworld.c:1563
local Fade = lazyReq("src.ui.game3.fade")
if Fade.active or (Fade.t or 0) > 0 then Fade.begin(Fade.MODE.FROM_BLACK, 1) end
end
if whiteOut and not healRow then
-- pokefirered/src/overworld.c:1558
local home = HealLocations.get(1)
+23 -1
View File
@@ -264,6 +264,8 @@ local function newEventObject(src, neighbor, prepared)
eo.originMapGroup = tonumber(def.originMapGroup or def.mapGroup) or mg
eo.originMapNum = tonumber(def.originMapNum or def.mapNum) or mn
end
-- pokeemerald/src/event_object_movement.c:1301
eo.spawnElevation = true
return eo
end
@@ -737,7 +739,11 @@ function Objects.snapshot()
local protos = {}
for _, def in ipairs(Objects._defs or {}) do
local lid = tonumber(def.localId or def.index) or 0
if lid > 0 then protos[lid] = newEventObject(def) end
if lid > 0 then
local p = newEventObject(def)
Objects.spawnElevation(p)
protos[lid] = p
end
end
local list = {}
for _, lid in ipairs(Objects._order) do
@@ -1045,6 +1051,20 @@ function Objects.updateElevation(eo)
cx, cy, eo.cellX, eo.cellY)
end
-- pokeemerald/src/event_object_movement.c:7737
function Objects.spawnElevation(eo)
eo.spawnElevation = nil
if eo.fixedPriority then return end
local Coll = Collision()
if not (Coll and Coll.nextElevation) then return end
local cx, cy = eo.cellX, eo.cellY
if eo.moving then cx, cy = eo.targetX, eo.targetY end
local cur, prev = Coll.nextElevation(eo.mapDef or Coll._mapDef, eo.currentElevation or 0,
cx, cy, eo.cellX, eo.cellY)
eo.currentElevation = cur
if prev then eo.elevation = prev end
end
local function beginStep(eo, tx, ty)
eo.moving = true
eo.progress = 0
@@ -1815,6 +1835,7 @@ function Objects.update(game)
for _, lid in ipairs(Objects._order) do
local eo = Objects._byId[lid]
if eo then
if eo.spawnElevation then Objects.spawnElevation(eo) end
if eo.bowFrames and eo.bowFrames > 0 then
eo.bowFrames = eo.bowFrames - 1
if eo.bowFrames <= 0 then eo.bowFrames = nil end
@@ -2139,6 +2160,7 @@ function Objects.tickPool(pool, game, ctx)
for _, lid in ipairs(pool.order or {}) do
local eo = pool.byId[lid]
if eo then
if eo.spawnElevation then Objects.spawnElevation(eo) end
if eo.bowFrames and eo.bowFrames > 0 then
eo.bowFrames = eo.bowFrames - 1
if eo.bowFrames <= 0 then eo.bowFrames = nil end
@@ -47,6 +47,8 @@ return {
eggHatchText = "Text_EggHatchHuh",
-- pokeemerald/src/field_poison.c:76
poisonFaintText = "gText_PkmnFainted_FldPsn",
-- pokeemerald/src/field_control_avatar.c:551
fieldPoisonScript = "EventScript_FieldPoison",
-- pokeemerald/src/event_object_movement.c:1927
invalidGfx = "OBJ_EVENT_GFX_NINJA_BOY",
-- pokeemerald/data/scripts/field_move_scripts.inc:60
+2
View File
@@ -44,6 +44,8 @@ return {
eggHatchText = "UnknownString_81B2C68",
-- pokeruby/src/field_poison.c:81
poisonFaintText = "fieldPoisonText_PokemonFainted",
-- pokeruby/src/field_control_avatar.c:580
fieldPoisonScript = "gUnknown_081A14B8",
-- pokeruby/src/event_object_movement.c:1767
invalidGfx = "OBJ_EVENT_GFX_LITTLE_BOY_1",
fieldMoveScripts = true,
+28 -22
View File
@@ -84,26 +84,6 @@ local function field_white_out_event(session, game)
end
local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field")
Field.lock()
local Rse = require("src.core.game3.rse.init")
if Rse.isRse(session) then
local Pike = require("src.core.game3.rse.frontier.pike")
local Pyramid = require("src.core.game3.rse.frontier.pyramid")
local Hill = require("src.core.game3.rse.trainer_hill")
if Pike.inBattlePike(session) or Pyramid.inPyramid(session) or Hill.inChallenge(session) then
-- pokeemerald/data/scripts/field_poison.inc:23
local Space = package.loaded["src.core.game3.scripting.space"]
or require("src.core.game3.scripting.space")
local key = Space.scriptKey("EventScript_FrontierFieldWhiteOut")
if key and Space.startScript(key) then
ev.phase = "frontier_script"
ev.tick = function()
local vm = Space.vm
if not vm or not vm.active then onDone() end
end
return
end
end
end
local BattleBridge = require("src.core.game3.battle_bridge")
local save = game and game.save
local name = session.name or session.playerName or ""
@@ -147,6 +127,23 @@ local function field_white_out_event(session, game)
return ev
end
local function field_poison_script_event(scriptName)
local ev = { type = "field_poison_script", script = scriptName }
ev.run = function(onDone)
local Space = package.loaded["src.core.game3.scripting.space"]
or require("src.core.game3.scripting.space")
local key = Space.scriptKey(scriptName)
if not (key and Space.startScript(key)) then
error("field poison script did not start: " .. tostring(scriptName))
end
ev.tick = function()
local vm = Space.vm
if not vm or not vm.active then onDone() end
end
end
return ev
end
--- Evaluate step counters upon completing a grid step (Walk, Run, Bike, Surf).
function StepEvents.onStepTaken(session, game)
if not session then return end
@@ -241,6 +238,8 @@ function StepEvents.onStepTaken(session, game)
psnSteps = 0
local anyPoisonDamage = false
local faintedMons = {}
local profileField = require("src.core.game3.profile").forSession(session).field
local poisonScript = profileField and profileField.fieldPoisonScript
for slotIdx, mon in ipairs(party) do
local isEgg = mon.isEgg or (type(mon.egg) == "boolean" and mon.egg)
@@ -251,7 +250,9 @@ function StepEvents.onStepTaken(session, game)
if not isEgg and isPsn and hp > 0 then
anyPoisonDamage = true
mon.hp = math.max(0, hp - 1)
if mon.hp == 0 then
if mon.hp == 0 and poisonScript then
faintedMons[#faintedMons + 1] = { slot = slotIdx, mon = mon }
elseif mon.hp == 0 then
-- pokefirered/src/field_poison.c:36
Pokemon.adjustFriendship(mon, Pokemon.FRIENDSHIP_EVENT_FAINT_OUTSIDE_BATTLE,
{ mapSec = Pokemon.currentMapSec(session) })
@@ -273,6 +274,11 @@ function StepEvents.onStepTaken(session, game)
-- pokefirered/src/field_control_avatar.c:727 FLDPSN_FNT
poisonFainted = #faintedMons > 0
if poisonFainted and poisonScript then
-- pokeemerald/src/field_control_avatar.c:551
push_event(field_poison_script_event(poisonScript))
faintedMons = {}
end
for _, fainted in ipairs(faintedMons) do
push_event({
type = "poison_faint",
@@ -289,7 +295,7 @@ function StepEvents.onStepTaken(session, game)
})
end
-- pokefirered/src/field_poison.c:76
if poisonFainted then push_event(field_white_out_event(session, game)) end
if poisonFainted and not poisonScript then push_event(field_white_out_event(session, game)) end
end
end
session.vars[psnVar] = psnSteps
+2 -2
View File
@@ -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)
+16 -10
View File
@@ -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)
+190 -15
View File
@@ -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
+46 -2
View File
@@ -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
+4 -3
View File
@@ -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:",
+60 -9
View File
@@ -23,6 +23,54 @@ local SaveData = require("src.core.SaveData")
local LauncherSettings = {}
local function copyValue(v)
if type(v) ~= "table" then return v end
local out = {}
for k, val in pairs(v) do out[k] = copyValue(val) end
return out
end
local function sameValue(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
for k, v in pairs(a) do
if not sameValue(v, b[k]) then return false end
end
for k in pairs(b) do
if a[k] == nil then return false end
end
return true
end
local function restoreInto(dst, key, value)
local cur = dst[key]
if type(cur) == "table" and type(value) == "table" then
for k in pairs(cur) do cur[k] = nil end
for k, v in pairs(value) do cur[k] = copyValue(v) end
else
dst[key] = copyValue(value)
end
end
function LauncherSettings.persist(opts, base)
local changed = {}
for k, v in pairs(opts) do
if not sameValue(v, base[k]) then changed[#changed + 1] = k end
end
for k in pairs(base) do
if opts[k] == nil then changed[#changed + 1] = k end
end
if #changed == 0 then return true end
SaveData.invalidateOptionsCache()
local fresh = SaveData.loadOptions()
for _, k in ipairs(changed) do fresh[k] = copyValue(opts[k]) end
if SaveData.saveOptions(fresh) ~= nil then
for _, k in ipairs(changed) do base[k] = copyValue(opts[k]) end
return true
end
for _, k in ipairs(changed) do restoreInto(opts, k, base[k]) end
return false
end
local function bgLocked(opts)
return opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended"
@@ -850,7 +898,6 @@ function LauncherSettings.open(hooks, version)
})
end
local RomSources = require("src.import.RomSources")
local forgotRoms = false
launcher.rows[#launcher.rows + 1] = {
label = Strings("Auto Re-import"),
value = function()
@@ -875,7 +922,6 @@ function LauncherSettings.open(hooks, version)
doneText = Strings("Saved ROMs forgotten."),
action = function()
RomSources.forgetAll(opts)
forgotRoms = true
return true
end,
}
@@ -888,17 +934,22 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
return {
local base = copyValue(opts)
local model = {
opts = opts,
version = version,
sections = sections,
save = function()
if not forgotRoms then
opts.romSources = SaveData.loadOptions().romSources
end
SaveData.saveOptions(opts)
end,
}
model.save = function()
if LauncherSettings.persist(opts, base) then
model.saveError = nil
return true
end
model.saveError = Strings("Settings could not be saved. Check the log.")
model.flash = nil
return false
end
return model
end
return LauncherSettings
+16 -4
View File
@@ -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.
+8
View File
@@ -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
+16 -4
View File
@@ -257,7 +257,7 @@ local function sha1(data)
end
local function readExternalPath(path)
local file, openError = io.open(path, "rb")
local file, openError = require("src.core.SaveData").openNative(path, "rb")
if not file then return nil, openError end
local data = file:read("*a")
file:close()
@@ -2458,7 +2458,18 @@ function RomImporter:_offerReimport(version, cand)
end
local name = GameVersion.info(version).displayName
local lines
if cand.pick then
if cand.missing then
local why = cand.missing ~= "changed"
and Strings("The saved ROM at %s could not be found or opened.",
RomSources.shortPath(cand.path))
or Strings("The saved ROM at %s no longer matches this game.",
RomSources.shortPath(cand.path))
lines = {
Strings("%s needs its ROM imported again.", name),
why,
Strings("Choose the ROM file to re-import it?"),
}
elseif cand.pick then
lines = {
Strings("%s needs its ROM imported again.", name),
Strings("Choose the ROM file to re-import it?"),
@@ -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)
+29 -5
View File
@@ -1,5 +1,6 @@
local GameVersion = require("src.core.GameVersion")
local SaveData = require("src.core.SaveData")
local Logger = require("src.core.Logger")
local RomSources = {}
@@ -236,13 +237,22 @@ end
local function readSource(rec)
if rec.kept then return RomSources.readKept(rec.path) end
local file = io.open(rec.path, "rb")
local file = SaveData.openNative(rec.path, "rb")
if not file then return nil end
local data = file:read("*a")
file:close()
return data
end
local function unwrapArchive(data, rec)
local RomArchive = require("src.import.RomArchive")
if not RomArchive.kind(data) then return data end
local bytes, why = RomArchive.unwrap(data, rec.path:match("[^/\\]+$"), {
prefer = function(candidate) return sha1(candidate) == rec.sha1 end,
})
return bytes, why
end
function RomSources.candidate(version, mobile, opts)
local rec = RomSources.get(version, opts)
if not rec or type(rec.sha1) ~= "string" then return nil end
@@ -251,11 +261,25 @@ function RomSources.candidate(version, mobile, opts)
return nil
end
local ok, data = pcall(readSource, rec)
local okHash, digest = false, nil
if ok and data then okHash, digest = pcall(sha1, data) end
if not okHash or digest ~= rec.sha1 then
local reason
if not ok or type(data) ~= "string" then
reason = "missing"
else
local okUnwrap, bytes, why = pcall(unwrapArchive, data, rec)
if not okUnwrap or not bytes then
reason = "archive"
Logger.warn("re-import source %s: %s", tostring(rec.path),
tostring(okUnwrap and why or bytes))
else
local okHash, digest = pcall(sha1, bytes)
if not okHash or digest ~= rec.sha1 then reason = "changed" end
end
end
if reason then
Logger.warn("re-import source for %s is unusable (%s): %s",
tostring(version), reason, tostring(rec.path))
if rec.kept then return { pick = true, broken = true } end
return nil
return { pick = true, missing = reason, path = rec.path }
end
return { path = rec.path, kept = rec.kept == true }
end
+1 -1
View File
@@ -6,7 +6,7 @@ local Rs = {}
local function tables(game, revision)
local V = VersionsRse.new(game, revision.build)
local sym, count = V.sym, V.count
V.CACHE_VERSION = 4
V.CACHE_VERSION = 5
V.NATIVE_VERSION = 2
V.OW_VERSION = 1
V.ANIM_VERSION = 1
+3 -10
View File
@@ -27,16 +27,9 @@ return function(V)
SURF_OPPONENT = {gfx = sym("gBattleAnimBackgroundImage_Surf"), pal = sym("gBattleAnimBackgroundPalette_Surf"), map = sym("gUnknown_08E70968")},
}}
V.ANIM_NAMES, V.ANIM_TAG_NAMES = Names, Names.tagNames
local function byPrefix(prefix)
local out = {}
for n, id in pairs(require("src.core.game3.constants").of(V.GAME).battle.byName) do
if n:sub(1, #prefix) == prefix then out[id] = n:sub(8) end
end
return out
end
V.BATTLE_ANIM_STATUS_NAMES = byPrefix("B_ANIM_STATUS_")
V.BATTLE_ANIM_GENERAL_NAMES = {[0] = "CASTFORM_CHANGE", "STATS_CHANGE", "SUBSTITUTE_FADE", "SUBSTITUTE_APPEAR", "POKEBLOCK_THROW", "ITEM_KNOCKOFF", "TURN_TRAP", "ITEM_EFFECT", "SMOKEBALL_ESCAPE", "HANGED_ON", "RAIN_CONTINUES", "SUN_CONTINUES", "SANDSTORM_CONTINUES", "HAIL_CONTINUES", "LEECH_SEED_DRAIN", "MON_HIT", "ITEM_STEAL", "SNATCH_MOVE", "FUTURE_SIGHT_HIT", "DOOM_DESIRE_HIT", "FOCUS_PUNCH_SETUP", "INGRAIN_HEAL", "WISH_HEAL"}
V.BATTLE_ANIM_SPECIAL_NAMES = {[0] = "LVL_UP", "SWITCH_OUT_PLAYER_MON", "SWITCH_OUT_OPPONENT_MON", "BALL_THROW", "BALL_THROW_WITH_TRAINER", "SUBSTITUTE_TO_MON", "MON_TO_SUBSTITUTE"}
V.BATTLE_ANIM_STATUS_NAMES = Names.statusNames
V.BATTLE_ANIM_GENERAL_NAMES = Names.generalNames
V.BATTLE_ANIM_SPECIAL_NAMES = Names.specialNames
local templates, callbacks, tasks = {}, {}, {}
local function functionOffset(name)
if S.hasFunc(name) then return S.funcOff(name) end
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,7 +1,8 @@
local K = require("src.import.gba.rse.boot_gfx")
local A = require("src.import.gba.rs.assets")
local M = {SUB = "rse/bag", FILES = {"bg.png", "bg_female.png", "bag_male.png", "bag_female.png", "ball.png", "select.png",
"arrows_vertical.png", "arrows_horizontal.png", "hm.png", "number.png", "indicator_0.png", "indicator_1.png", "list_cursor.png"}}
"arrows_vertical.png", "arrows_horizontal.png", "hm.png", "number.png", "indicator_0.png", "indicator_1.png",
"indicator_0_female.png", "indicator_1_female.png", "list_cursor.png"}}
for i = 0, 4 do for _, g in ipairs({"male", "female"}) do M.FILES[#M.FILES + 1] = "label_" .. i .. "_" .. g .. ".png" end end
M.REQUIRED = K.required(M.SUB, M.FILES)
local N, H = "item_menu.o:", "menu_helpers.o:"
@@ -39,11 +40,15 @@ function M.run(rom, cache, opts)
local words = {}; for _, word in ipairs(g[2]) do words[#words + 1] = string.char(word % 256, math.floor(word / 256)) end
local pixels, gw, gh = K.bakeText(gfx, table.concat(words), g[3], g[4], {linear = true, mapWidth = g[3]})
local rec = {png = c:png(g[1] .. ".png", gw, gh, pixels, male, true), w = gw, h = gh, words = g[2], paletteBank = math.floor(g[2][1] / 4096)}
if g[1]:find("indicator", 1, true) then man.indicators[tonumber(g[1]:sub(-1))] = rec else man.glyphs[g[1]] = rec end
if g[1]:find("indicator", 1, true) then
rec.female = c:png(g[1] .. "_female.png", gw, gh, pixels, female, true)
man.indicators[tonumber(g[1]:sub(-1))] = rec
else man.glyphs[g[1]] = rec end
end
man.indicatorGeometry = {x = 40, y = 72, spacing = 8, pockets = 5}
man.hm, man.number = man.glyphs.hm.png, man.glyphs.number.png
man.indicators.idle, man.indicators.selected = man.indicators[0].png, man.indicators[1].png
man.indicators.idleFemale, man.indicators.selectedFemale = man.indicators[0].female, man.indicators[1].female
local tableOff = c:off("gSubspriteTables_842F6C0") + 15 * 8
local count, parts, tiles = c:u8(tableOff), {}, c:raw("OutlineCursorTiles_12")
local sub = assert(c:ptr(tableOff + 4))
+3 -2
View File
@@ -6,7 +6,7 @@ local M = {REQUIRED = Base.REQUIRED}
function M.run(rom, cache, opts)
local c = A.context(rom, cache, opts, "pokemon/battle_anims")
local templates = {}
for name in pairs(Names.templates) do
for name, canonical in pairs(Names.templates) do
local off = c:off(name)
local row = c:readTemplate(name)
local affine, tableOff = {}, c:ptr(off + 16)
@@ -21,7 +21,8 @@ function M.run(rom, cache, opts)
end
row.affineAnims, row.romOffset = affine, off
row.images = nil
templates[name] = row
assert(templates[canonical] == nil, "RS template canonical name collides: " .. canonical)
templates[canonical] = row
end
local result = Base.run(rom, cache, opts)
local path = c:path("pack.lua")
+2 -2
View File
@@ -882,8 +882,8 @@ end
function ManagerState:restartGame()
if self.game.restartWithMods then
self.game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
else
require("src.core.HostShell").restart()
end
end
+32 -6
View File
@@ -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,
+6
View File
@@ -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
+14 -2
View File
@@ -493,10 +493,22 @@ BattleChrome.RSE_MENU_FRAMES = {
moves = { { 1, 15, 18, 4 }, { 21, 15, 8, 4 } },
}
-- pokeruby/graphics/interface/menu_map.bin
BattleChrome.RS_MENU_FRAMES = {
menu = { { 18, 15, 11, 4 } },
moves = { { 1, 15, 20, 4 }, { 23, 15, 6, 4 } },
}
function BattleChrome.menuFrameRects(mode, layout)
local set = layout == "rs" and BattleChrome.RS_MENU_FRAMES or BattleChrome.RSE_MENU_FRAMES
return set[mode]
end
-- pokeemerald/src/battle_bg.c:744
function BattleChrome.drawMenuFrames(mode)
-- pokeruby/src/battle_bg.c:266
function BattleChrome.drawMenuFrames(mode, layout)
if not BattleChrome.isRse() then return end
local rects = BattleChrome.RSE_MENU_FRAMES[mode]
local rects = BattleChrome.menuFrameRects(mode, layout)
if not rects then return end
local Chrome = require("src.ui.game3.chrome")
for _, r in ipairs(rects) do
+24 -5
View File
@@ -383,6 +383,12 @@ end
local function drawText(str, x, y, colors, width)
Font.draw(str, x, y, {font = "normal", colors = colors, maxWidth = width or 120, linePitch = 16})
end
local function quantity(qty, multX, y, colors)
local count = tostring(qty)
drawText("×", multX, y, colors)
-- pokeruby/src/text.c:3474
drawText(count, 232 - Font.measure(count, {font = "normal"}), y, colors)
end
local function frame(left, top, width, height) Chrome.stdFrame(left + 1, top + 1, width - 1, height - 1) end
function M.draw(Bag)
@@ -409,7 +415,10 @@ function M.draw(Bag)
local labels = m.labels[Bag.pocketIdx]
image(fem and labels.female or labels.male, 32, 80)
if m.indicators then
for p = 1, 5 do image(p == Bag.pocketIdx and m.indicators.selected or m.indicators.idle, 32 + p * 8, 72) end
-- pokeruby/src/item_menu.c:779
local selected, idle = m.indicators.selected, m.indicators.idle
if fem then selected, idle = m.indicators.selectedFemale, m.indicators.idleFemale end
for p = 1, 5 do image(p == Bag.pocketIdx and selected or idle, 32 + p * 8, 72) end
end
local rows = Bag.list()
local scroll = Bag.scroll
@@ -428,16 +437,26 @@ function M.draw(Bag)
local hm = pocket == "TM_CASE" and Items.isHm(row.id)
image(hm and m.hm or m.number, 112, y)
local number = pocket == "TM_CASE" and Items.tmNumber(row.id) or Items.berryNumber(row.id)
drawText(hm and tostring(number) or string.format("%02d", number or 0), 129, y, rowColors)
local move = pocket == "TM_CASE" and Pokemon.moveFromTmItem(row.id)
drawText(move and Pokemon.moveName(move) or row.name, 136, y, rowColors, hm and 96 or 78)
if not hm then drawText("×" .. tostring(row.qty), 232 - Font.measure("×" .. tostring(row.qty), {font = "normal"}), y, rowColors) end
local name = move and Pokemon.moveName(move) or row.name
if hm then
-- pokeruby/src/item_menu.c:1249
drawText(tostring(number), 129, y, rowColors)
drawText(name, 136, y, rowColors, 96)
else
-- pokeruby/src/item_menu.c:1091
local multX = 112 + 120 - ((pocket == "TM_CASE" and 2 or 3) + 1) * 6
drawText(string.format("%02d", number or 0), 120, y, rowColors)
drawText(name, 136, y, rowColors, multX - 136)
quantity(row.qty, multX, y, rowColors)
end
elseif pocket == "KEY_ITEMS" then
drawText(row.name, 112, y, rowColors, 96)
if Bag._session and Items.toNumericId(Bag._session.registeredItem) == Items.toNumericId(row.id) then image(m.select, 208, y) end
else
-- pokeruby/src/item_menu.c:1166
drawText(row.name, 112, y, rowColors, 102)
drawText("×" .. tostring(row.qty), 232 - Font.measure("×" .. tostring(row.qty), {font = "normal"}), y, rowColors)
quantity(row.qty, 214, y, rowColors)
end
end
end
+74
View File
@@ -377,6 +377,75 @@ function BattleState.trainerArt(data, classId)
return path, (classDef and classDef.trueColor) and true or false
end
local BATTLE_SFX = {
"Sfx_Damage", "Sfx_SuperEffective", "Sfx_NotVeryEffective",
"Sfx_ExpBar", "Sfx_HitEndOfExpBar", "Sfx_DexFanfare5079",
"Sfx_Faint", "Sfx_Kinesis", "Sfx_CaughtMon", "Sfx_Run",
"Sfx_SwitchPokemon", "Sfx_Potion",
}
local function animSounds(anims, order, key, out, seen)
if not key or seen[key] then return end
seen[key] = true
for _, row in ipairs(anims.scripts[key] or {}) do
if row[1] == "sound" then
local name = order[(row[3] or 0) + 1]
if name then out[#out + 1] = name end
else
for i = 2, #row do
local target = row[i]
if type(target) == "string" and anims.scripts[target] then
animSounds(anims, order, target, out, seen)
end
end
end
end
end
local function moveIdOf(move)
if type(move) == "table" then return move.id end
return move
end
function BattleState.battleSfxNames(data, anims, battle)
local audio = data and data.audio
local sfx = audio and audio.sfx
if not sfx then return {} end
local order = audio.sfxOrder or {}
local names, listed = {}, {}
local function add(name)
if name and sfx[name] and not listed[name] then
listed[name] = true
names[#names + 1] = name
end
end
local function addMoves(mon)
if not (mon and anims and anims.scripts and anims.moves) then return end
for _, move in ipairs(mon.moves or {}) do
local out = {}
animSounds(anims, order, anims.moves[moveIdOf(move)], out, {})
for _, name in ipairs(out) do add(name) end
end
end
battle = battle or {}
addMoves(battle.enemy)
addMoves(battle.player)
for _, name in ipairs(BATTLE_SFX) do add(name) end
if anims and anims.scripts and anims.ids then
local ids = {}
for id in pairs(anims.ids) do ids[#ids + 1] = id end
table.sort(ids)
for _, id in ipairs(ids) do
local out = {}
animSounds(anims, order, anims.ids[id], out, {})
for _, name in ipairs(out) do add(name) end
end
end
for _, mon in ipairs(battle.enemyParty or {}) do addMoves(mon) end
for _, mon in ipairs(battle.party or {}) do addMoves(mon) end
return names
end
-- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts)
opts = opts or {}
@@ -629,6 +698,11 @@ function BattleState.new(game, opts)
for _, mon in ipairs({ enemy or false, player or false }) do
if mon and mon.species then pcall(Sound.prewarmCry, data, mon.species) end
end
local okNames, names = pcall(BattleState.battleSfxNames, data, self.anims,
self.battle)
if okNames then
for _, name in ipairs(names) do pcall(Sound.prewarmSfx, data, name) end
end
return self
end
+153
View File
@@ -0,0 +1,153 @@
-- ../pokecrystal/engine/overworld/scripting.asm:467
-- ../pokecrystal/engine/battle_anims/anim_commands.asm:1191
-- tools/run_driver.sh crystal <identity> tests/drivers/crystal_sfx_prewarm_2732.lua <shotdir>
local U = require("tests.drivers.util")
local Sound = require("src.core.Sound")
local ChipAudio = require("src.core.ChipAudio")
local Mon = require("src.battle.gen2.Mon")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR")
or ".bazinga/BSA/10-07-26-00-userreported/shots/crystal_sfx_prewarm_2732"
return function(game)
local fails = 0
local function say(line) print("[2732] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "PASS " or "FAIL ") .. line)
return cond
end
local function finish()
say("stats " .. tostring(ChipAudio.stats().line))
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
love.event.quit(fails == 0 and 0 or 1)
while true do U.wait(60) end
end
if jit and jit.off then jit.off() end
local function drained(seconds)
local stop = love.timer.getTime() + seconds
while love.timer.getTime() < stop do
local st = ChipAudio.stats()
if (st.prewarmQueued or 0) + (st.prewarmInFlight or 0) == 0 then
return true
end
U.wait(1)
end
return false
end
local events = {}
local function watch(fnName)
local real = Sound[fnName]
Sound[fnName] = function(data, name, ...)
local before = ChipAudio.stats().syncRenders or 0
local src = real(data, name, ...)
local after = ChipAudio.stats().syncRenders or 0
events[#events + 1] = { kind = fnName, name = name,
sync = after - before, ms = ChipAudio.stats().syncRenderMsMax }
return src
end
end
watch("play")
watch("playStereo")
U.wait(45)
local world = game.world
if not ok(world and world.map, "the crystal world booted") then finish() end
if not ok(ChipAudio.stats().syncRenders ~= nil,
"ChipAudio.stats() counts main-thread sfx renders") then
finish()
end
ok(drained(20), "session sfx prewarm drained")
world:warpToMapId("ROUTE_29", 48, 3, "up")
U.wait(30)
local ball
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.itemball then ball = npc end
end
if not ok(ball ~= nil, "Route 29 has its POTION item ball") then finish() end
world:warpToMapId("ROUTE_29", ball.cellX, ball.cellY + 1, "up")
U.wait(30)
ChipAudio.resetSyncStats()
events = {}
world:interact()
local itemEvent
for _ = 1, 600 do
for _, e in ipairs(events) do
if e.name == "Sfx_Item" then itemEvent = e end
end
if itemEvent then break end
U.wait(1)
end
if ok(itemEvent ~= nil, "the item ball script played Sfx_Item") then
ok(itemEvent.sync == 0, "Sfx_Item started with 0 sync renders (got "
.. itemEvent.sync .. ")")
end
U.wait(60)
U.still(game, SHOT_DIR .. "/2732_01_found_potion_chime.png")
for _ = 1, 40 do
if not world:busy() then break end
U.tap(game, "a")
U.wait(6)
end
local cyndaquil = Mon.new(game.data, "CYNDAQUIL", 20)
game.save.party = { cyndaquil }
ok(world:startBattle({ wild = Mon.new(game.data, "SENTRET", 12) }),
"a wild battle started")
local screen
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
if not ok(screen ~= nil, "the battle screen came up") then finish() end
ok(drained(20), "battle sfx prewarm drained")
ChipAudio.resetSyncStats()
events = {}
for _ = 1, 300 do
if screen.phase == "menu" then break end
if screen.anim == nil then U.tap(game, "a") end
U.wait(4)
end
if not ok(screen.phase == "menu", "the battle menu came up") then finish() end
local introSounds = #events
U.tap(game, "a")
U.wait(10)
ok(screen.phase == "moves", "FIGHT opened the move list")
local moveName = cyndaquil.moves[1] and cyndaquil.moves[1].id
U.tap(game, "a")
local moveEvent
for _ = 1, 600 do
for i = introSounds + 1, #events do
local e = events[i]
if e.kind == "playStereo" then moveEvent = moveEvent or e end
end
if moveEvent then break end
U.wait(1)
end
if ok(moveEvent ~= nil, "the turn's first move animation played a sound ("
.. tostring(moveName) .. " chosen)") then
ok(moveEvent.sync == 0, ("%s started with 0 sync renders (got %d)")
:format(moveEvent.name, moveEvent.sync))
end
U.still(game, SHOT_DIR .. "/2732_02_move_anim_sound.png")
for _ = 1, 200 do
if not (game.stack:top() or {}).battle then break end
if screen.anim == nil then U.tap(game, "a") end
U.wait(4)
end
local stereo, played = 0, 0
for _, e in ipairs(events) do
played = played + 1
if e.kind == "playStereo" then stereo = stereo + 1 end
end
local st = ChipAudio.stats()
ok(stereo > 0, "the turn played " .. stereo .. " anim sounds of " .. played)
ok((st.syncRenders or 0) == 0, "0 sync sfx renders across the battle (last "
.. tostring(st.syncRenderLast) .. ")")
finish()
end
+29
View File
@@ -0,0 +1,29 @@
local U = require("tests.drivers.util")
local F = require("tests.drivers.em_fa_util")
local S = F.new("em_mr_stone_head_2737", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_mr_stone_head_2737")
return function(game)
if not F.boot(game, S) then return S.finish() end
local Runtime = require("src.core.game3.runtime")
local Objects = require("src.core.game3.objects")
local FieldView = require("src.core.game3.field_view")
local session = Runtime.getSession()
if not S.check(session.version == "emerald", "driver runs Emerald") then return S.finish() end
F.setVar("VAR_DEVON_CORP_3F_STATE", 3)
S.check(F.goTo(game, "EM_RUSTBORO_CITY_DEVON_CORP_3F", 13, 5, "right"), "Devon Corp 3F loads")
U.wait(20)
local stone
for _, lid in ipairs(Objects._order or {}) do
local eo = Objects._byId[lid]
if eo and eo.cellX == 17 and eo.cellY == 5 then stone = eo end
end
if not S.check(stone ~= nil, "Mr. Stone spawned at 17,5") then return S.finish() end
S.note("stone elevation=" .. tostring(stone.elevation) .. " current=" .. tostring(stone.currentElevation))
S.check(stone.elevation == 4, "Mr. Stone elevation follows chair metatile")
local _, over = FieldView.applyDrawOrder({
{ kind = "npc", eventObject = stone, elevation = stone.elevation, y = stone.py },
}, {}, {}, 0)
S.check(#over == 1, "Mr. Stone draws over the chair back layer")
S.check(S.still(game, "2737_01_mr_stone_head_over_chair.png"), "Mr. Stone office screenshot")
S.finish()
end
+181
View File
@@ -0,0 +1,181 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_poison_whiteout_2736"
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
print((failures == 0 and "PASS" or "FAIL") .. " em_poison_whiteout_2736 failures=" .. failures)
love.event.quit(failures == 0 and 0 or 1)
U.wait(10)
end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
if not check(game.boot ~= nil, "boot reached") then return finish() end
game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 })
U.wait(60)
local Runtime = require("src.core.game3.runtime")
local Map = require("src.core.game3.map")
local MapIds = require("src.core.game3.map_ids")
local Player = require("src.core.game3.player")
local Message = require("src.ui.game3.message")
local Party = require("src.core.game3.party")
local Field = require("src.core.game3.field")
local Space = require("src.core.game3.scripting.space")
local Flags = require("src.core.game3.scripting.flags")
local Warp = require("src.core.game3.warp")
local StepEvents = require("src.core.game3.step_events")
local Sem = require("src.core.game3.field_semantics")
local HealLocations = require("src.core.game3.heal_locations")
local RomText = require("src.core.game3.rom_text")
local session = Runtime.getSession()
if not check(session ~= nil, "field session exists") then return finish() end
local C = require("src.core.game3.constants").active(session)
local id = require("src.core.game3.profile").forSession(session).id
local isEm = id == "emerald"
local tag = id .. ": "
local whiteKey = isEm and "gText_PlayerWhitedOut" or "UnknownString_81A1141"
local route = MapIds.forConst("MAP_ROUTE103", id)
for _ = 1, 600 do
if not (Space.vm and Space.vm:isRunning()) and not Message.isOpen() and not Warp.isBusy() then break end
if Message.isOpen() then U.tap(game, "a") end
U.wait(2)
end
local romWhite = RomText.box(whiteKey, { playerName = "BRENDAN" })
check(type(romWhite) == "string" and romWhite:find("whited out", 1, true) ~= nil,
tag .. whiteKey .. " resolves from the ROM cache")
local function setup(mons, money)
Map.load(nil, game, route, { x = 10, y = 10, facing = "down" })
session.x, session.y, session.facing = 10, 10, "down"
Field.unlock()
U.wait(20)
session.party = {}
for i, row in ipairs(mons) do
Party.giveMon(session, C:require("species", row.species), 5)
local mon = session.party[i]
if row.psn then
mon.hp = 1
mon.status = "PSN"
end
end
session.money = money
session.vars[Sem.var(session, "poisonSteps")] = 3
session.poisonSteps = 3
end
local function step()
local sx, sy = Player.cellX, Player.cellY
for _, dir in ipairs({ "down", "left", "right", "up" }) do
U.hold(game, dir, 20)
U.wait(10)
if Player.cellX ~= sx or Player.cellY ~= sy then return true end
end
return false
end
local function pump(name, shotName, expectWhiteout)
local seen = { faint = 0, white = false, rom = false }
local lastFaint
for _ = 1, 1500 do
local p = Message.isOpen() and Message.currentPage and Message.currentPage() or ""
if p:find("fainted", 1, true) and p ~= lastFaint then
seen.faint = seen.faint + 1
lastFaint = p
end
if p:find("whited out", 1, true) and not seen.white then
seen.white = true
seen.rom = romWhite:find(p, 1, true) ~= nil
Message.skipReveal()
U.wait(4)
if shotName then U.shot(game, DIR .. "/" .. shotName) end
end
local settled = not Message.isOpen() and not Warp.isBusy() and not StepEvents.busy()
and not (Space.vm and Space.vm:isRunning())
if settled and (not expectWhiteout or Map.current ~= route) then break end
if Message.isOpen() and Message.isTyping() then Message.skipReveal() end
if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end
U.wait(2)
end
print("INFO " .. name .. " map=" .. tostring(Map.current) .. " money=" .. tostring(session.money))
return seen
end
local Fade = require("src.ui.game3.fade")
local function fadedIn()
for _ = 1, 300 do
if not Fade.active and (Fade.t or 0) <= 0 then return true end
U.wait(2)
end
return false
end
local function healed()
for _, mon in ipairs(session.party) do
if (mon.hp or 0) <= 0 or (mon.hp or 0) ~= (mon.maxHp or mon.hp) or mon.status then return false end
end
return true
end
local okRun, err = pcall(function()
setup({ { species = "SPECIES_TORCHIC", psn = true }, { species = "SPECIES_MUDKIP" } }, 3000)
check(Map.current == route, tag .. "survive case on " .. tostring(route))
check(step(), tag .. "survive case took a poisoned step")
local s = pump("survive", nil, false)
check(s.faint == 1, tag .. "survive case shows one fainted message (" .. s.faint .. ")")
check(not s.white, tag .. "survive case shows no whited out text")
check(Map.current == route, tag .. "survive case stays on " .. tostring(route))
check(session.money == 3000, tag .. "survive case money unchanged (" .. tostring(session.money) .. ")")
local m1 = session.party[1]
check(m1 and (m1.hp or 0) == 0 and not m1.status, tag .. "fainted mon has 0 HP and poison cleared")
check(not Field.isLocked or not Field.isLocked(), tag .. "survive case releases the field")
setup({ { species = "SPECIES_TORCHIC", psn = true } }, 3000)
local oldale = C:require("heal_locations", "HEAL_LOCATION_OLDALE_TOWN")
Field.setRespawn(oldale)
local oldaleMap = HealLocations.get(oldale).map
check(step(), tag .. "whiteout case took a poisoned step")
local w = pump("whiteout", "2736_01_" .. id .. "_whited_out_text.png", true)
check(w.faint == 1, tag .. "fainted message shown before whiteout")
check(w.white, tag .. "whited out message shown")
check(w.rom, tag .. "whited out page comes from ROM " .. whiteKey)
check(Map.current == oldaleMap, tag .. "respawned at last heal location " .. tostring(oldaleMap)
.. " (" .. tostring(Map.current) .. ")")
check(session.money == 1500, tag .. "money halved 3000 -> 1500 (" .. tostring(session.money) .. ")")
check(healed(), tag .. "party healed")
check(fadedIn(), tag .. "screen fades in from black after respawn (fade t=" .. tostring(Fade.t) .. ")")
U.wait(30)
U.shot(game, DIR .. "/2736_02_" .. id .. "_after_respawn.png")
setup({ { species = "SPECIES_TORCHIC", psn = true } }, 0)
Field.setRespawn(oldale)
local target = oldaleMap
if isEm then
local lavaridge = C:require("heal_locations", "HEAL_LOCATION_LAVARIDGE_TOWN")
target = HealLocations.get(lavaridge).map
Flags.setFlag(Space.store, nil, C:require("flags", "FLAG_WHITEOUT_TO_LAVARIDGE"), true)
end
check(step(), tag .. "zero money case took a poisoned step")
local z = pump("zero_money", isEm and "2736_03_emerald_lavaridge_whited_out.png" or nil, true)
check(z.white and z.rom, tag .. "zero money whited out text from ROM")
check(Map.current == target, tag .. (isEm and "FLAG_WHITEOUT_TO_LAVARIDGE respawns at " or "respawns at ")
.. tostring(target) .. " (" .. tostring(Map.current) .. ")")
check(session.money == 0, tag .. "zero money stays 0 (" .. tostring(session.money) .. ")")
check(healed(), tag .. "party healed after zero money whiteout")
check(fadedIn(), tag .. "screen fades in after zero money respawn")
end)
check(okRun, tag .. "no crash " .. tostring(okRun and "" or err))
return finish()
end
+243
View File
@@ -0,0 +1,243 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/BSA/10-07-26-00-userreported/shots/game3_rs_anim_ports_2735"
return function(game)
local fails = 0
local function result(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
end
for _ = 1, 600 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "RUBY" })
U.wait(180)
local Runtime = require("src.core.game3.runtime")
local Party = require("src.core.game3.party")
local BattleBridge = require("src.core.game3.battle_bridge")
local Anim = require("src.core.game3.battle.anim")
local Ui = require("src.core.game3.battle.ui")
local AnimSprites = require("src.core.game3.battle.anim_sprites")
local AnimTasks = require("src.core.game3.battle.anim_tasks")
local AnimCallbacks = require("src.core.game3.battle.anim_callbacks")
local Secondary = require("src.core.game3.battle.effects.secondary")
local session = Runtime.getSession()
session.party = {}
Party.giveMon(session, 6, 36)
local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 41, level = 30 }, { fade = false })
result(ok == true, "wild battle started " .. tostring(err or ""))
if not ok then love.event.quit(1) return end
local ready = false
for _ = 1, 3000 do
if Ui._mode == "menu" and not Anim.busy() then ready = true break end
if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end
end
result(ready, "battle reached the action menu")
if not ready then love.event.quit(1) return end
U.wait(20)
local fallbacks, stubs, spawned = 0, 0, {}
local get, spawn = AnimCallbacks.get, AnimTasks.spawn
AnimCallbacks.get = function(name)
local fn = get(name)
if fn == AnimCallbacks.SimpleFadeOut and name then fallbacks = fallbacks + 1 end
return fn
end
AnimTasks.spawn = function(name, ...)
spawned[tostring(name)] = true
if not AnimTasks.REGISTRY[tostring(name)] then stubs = stubs + 1 end
return spawn(name, ...)
end
local function sprites(cb)
local out = {}
AnimSprites.forEachActive(function(s)
if s._cbName == cb and s.visible ~= false and not s.invisible then out[#out + 1] = s end
end)
return out
end
local function pos(s) return s.x + (s.ox or 0), s.y + (s.oy or 0) end
local function sideOpts(side)
local other = side == "player" and "enemy" or "player"
return { attackerSide = side, targetSide = other, isReversed = side == "enemy",
attackerSpecies = side == "player" and 6 or 41, targetSpecies = side == "player" and 41 or 6 }
end
local function reset()
for _, s in ipairs({ "player", "enemy" }) do
local p = Anim.present(s)
if p then p.visible, p.ox, p.oy, p.sx, p.sy, p.rotation = true, 0, 0, 1, 1, 0 end
end
for _ = 1, 20 do U.wait(1) end
end
local function run(label, launch, onFrame)
fallbacks, stubs, spawned = 0, 0, {}
local launched = launch()
local f = 0
while f < 900 do
onFrame(f)
U.wait(1)
f = f + 1
if f > 4 and not Anim.busy() then break end
end
result(launched ~= false, label .. " launched")
result(fallbacks == 0, label .. " used no SimpleFadeOut fallback callbacks (" .. fallbacks .. ")")
result(stubs == 0, label .. " spawned no unbound tasks (" .. stubs .. ")")
result(f < 900, label .. " finished in " .. f .. " frames")
reset()
end
local function purplePixels(path)
local f = io.open(path, "rb")
if not f then return -1 end
local bytes = f:read("*a")
f:close()
local img = love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
local w, h = img:getDimensions()
local n = 0
for y = 0, math.floor(h * 0.6) do
for x = math.floor(w * 0.45), w - 1 do
local r, g, b = img:getPixel(x, y)
r, g, b = r * 255, g * 255, b * 255
if b >= 150 and b - g >= 40 and b - r >= 20 then n = n + 1 end
end
end
return n
end
local function targetDrawn()
local p = Anim.present("enemy")
return p.visible ~= false and not p.invisible and not p.battlerInvisible and not p.blinkHidden
and (p.alpha or 1) == 1 and (p.scale or 1) == 1
end
local function rockLayers()
local monZ = require("src.core.game3.battle.anim_coords").monBehindZ("enemy") + 5
local front, behind, xFront = 0, 0, false
AnimSprites.forEachActive(function(s)
if s._cbName == "RockTomb" then
if s.z > monZ then front = front + 1 elseif s.z < monZ then behind = behind + 1 end
elseif s._cbName == "RedX" then
xFront = s.z > monZ
end
end)
return front, behind, xFront
end
local vm
local rockFall, xOk, xShot, rockShot = 0, nil, false, false
local rockStart = {}
local rockPx, xPx, xDrawn = -1, -1, false
local frontRocks, behindRocks, xFront = 0, 0, false
run("ROCK_TOMB", function() return Anim.launchMove(317, sideOpts("player")) end, function()
vm = Anim.vm()
for _, s in ipairs(sprites("RockTomb")) do
local _, y = pos(s)
rockStart[s] = rockStart[s] or y
rockFall = math.max(rockFall, y - rockStart[s])
if not rockShot and y - rockStart[s] > 20 then
local path = DIR .. "/2733_01_rock_tomb_rocks_falling.png"
rockShot = U.still(game, path)
rockPx = purplePixels(path)
end
end
local xs = sprites("RedX")
if #xs > 0 then
local x, y = pos(xs[1])
local tx, ty = vm:battlerCenter("target")
xOk = math.abs(x - tx) <= 8 and math.abs(y - ty) <= 16
if not xShot then
U.wait(4)
local path = DIR .. "/2733_02_rock_tomb_red_x_on_target.png"
xDrawn = targetDrawn()
frontRocks, behindRocks, xFront = rockLayers()
xShot = U.still(game, path)
xPx = purplePixels(path)
end
end
end)
result(rockFall >= 32, "ROCK_TOMB rocks drop onto the target (fell " .. rockFall .. " px)")
result(xOk == true, "ROCK_TOMB red X sits on the target picture")
result(xDrawn, "ROCK_TOMB target picture is still drawn under the red X")
result(frontRocks == 3 and behindRocks == 1 and xFront,
"ROCK_TOMB red X and three rocks layer in front of the target, one rock behind (" .. frontRocks .. "/" .. behindRocks .. ")")
result(rockPx > 1000 and xPx >= 0 and xPx < rockPx * 0.15,
"ROCK_TOMB red X and rock pile cover the target like the cart (" .. xPx .. " of " .. rockPx .. " purple px)")
local ringOk, noteMoved, uShot = nil, false, false
local noteStart = {}
run("UPROAR", function() return Anim.launchMove(253, sideOpts("enemy")) end, function()
vm = Anim.vm()
local rings = sprites("UproarRing")
if #rings > 0 and ringOk == nil then
local x, y = pos(rings[1])
local ax, ay = vm:battlerCenter("attacker")
ringOk = math.abs(x - ax) <= 8 and math.abs(y - ay) <= 16
end
for _, s in ipairs(sprites("JaggedMusicNote")) do
local x, y = pos(s)
noteStart[s] = noteStart[s] or { x, y }
if math.abs(x - noteStart[s][1]) + math.abs(y - noteStart[s][2]) >= 12 then noteMoved = true end
end
if not uShot and #rings > 0 and noteMoved then
uShot = U.still(game, DIR .. "/2734_01_uproar_ring_notes_on_user.png")
end
end)
result(ringOk == true, "UPROAR ring is centered on the user")
result(noteMoved, "UPROAR notes fly outward")
run("SUPERSONIC", function() return Anim.launchMove(48, sideOpts("enemy")) end, function() end)
local ducks, dShot = 0, false
run("STATUS_CONFUSION", function()
local o = sideOpts("player")
o.targetSide = "player"
return Anim.launchStatus("CONFUSION", o)
end, function(f)
local n = #sprites("ConfusionDuck")
ducks = math.max(ducks, n)
if not dShot and n > 0 and f >= 12 then
dShot = U.still(game, DIR .. "/2735_01_confusion_ducks_over_head.png")
end
end)
result(ducks >= 2, "STATUS_CONFUSION ducks circle the head (" .. ducks .. ")")
local needleFrom, needleTo, nShot = nil, nil, false
run("LEECH_LIFE", function() return Anim.launchMove(141, sideOpts("enemy")) end, function()
local n = sprites("LeechLifeNeedle")
if #n > 0 then
local x, y = pos(n[1])
needleFrom = needleFrom or { x, y }
needleTo = { x, y }
if not nShot and math.abs(x - needleFrom[1]) + math.abs(y - needleFrom[2]) >= 6 then
nShot = U.still(game, DIR .. "/2735_02_leech_life_needle_flying.png")
end
end
end)
local travel = needleFrom and (math.abs(needleTo[1] - needleFrom[1]) + math.abs(needleTo[2] - needleFrom[2])) or 0
result(travel >= 8, "LEECH_LIFE needle travels before the drain (" .. travel .. " px)")
local sShot = false
run("STATS_CHANGE", function()
local o = sideOpts("enemy")
o.targetSide = "enemy"
o.animArg = Secondary.statAnimArg("attack", -1)
return Anim.launchGeneral("STATS_CHANGE", o)
end, function(f)
if not sShot and f == 20 then sShot = U.still(game, DIR .. "/2735_03_stats_change_growl_glow.png") end
end)
result(spawned.StatsChange == true, "STATS_CHANGE runs the StatsChange task")
local hShot = false
run("HARDEN_STATS_CHANGE", function()
local o = sideOpts("player")
o.targetSide = "player"
o.animArg = Secondary.statAnimArg("defense", 1)
return Anim.launchGeneral("STATS_CHANGE", o)
end, function(f)
if not hShot and f == 20 then hShot = U.still(game, DIR .. "/2735_04_stats_change_harden_glow.png") end
end)
AnimCallbacks.get, AnimTasks.spawn = get, spawn
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,103 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/drv2729"
return function(game)
local fails = 0
local function result(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
end
local function finish() love.event.quit(fails == 0 and 0 or 1) end
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "TAI", gender = 0 })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local Party = require("src.core.game3.party")
local BattleBridge = require("src.core.game3.battle_bridge")
local Battle = require("src.core.game3.battle")
local Ui = require("src.core.game3.battle.ui")
local version = os.getenv("POKEPORT_VERSION") or "sapphire"
local C = require("src.core.game3.constants").of(version)
local session = Runtime.getSession()
result(session ~= nil, "field session exists")
if not session then finish() return end
local function sp(name) return C.species.byName["SPECIES_" .. name] end
local function mv(name) return C.moves.byName["MOVE_" .. name] end
session.party = {}
Party.giveMon(session, sp("TREECKO"), 11, "TREECKO")
local m = session.party[1]
m.moves = { mv("POUND"), mv("LEER"), mv("ABSORB"), mv("QUICK_ATTACK") }
m.pp = { 35, 30, 20, 30 }
m.maxPp = { 35, 30, 20, 30 }
local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = sp("WURMPLE"), level = 3 }, {})
result(ok == true, "battle started " .. tostring(err or ""))
if not ok then finish() return end
local lastTap, f = 0, 0
local function at_command()
return Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu"
end
for _ = 1, 6000 do
if at_command() then break end
f = f + 1
if Ui.dialogPending and Ui.dialogPending() and f - lastTap >= 14 then
lastTap = f
U.tap(game, "a")
else
U.wait(1)
end
end
result(at_command(), "reached command menu")
if not at_command() then finish() return end
U.wait(30)
local BattleChromeA = require("src.ui.game3.battle_chrome")
local menu = BattleChromeA.menuFrameRects("menu", "rs")
result(menu and menu[1][1] - 1 == 17, "action menu frame left border at tile 17")
U.still(game, DIR .. "/2729_00_action_menu.png")
U.tap(game, "a")
U.wait(30)
result(Ui._mode == "moves", "move menu open")
U.tap(game, "down")
U.wait(6)
U.tap(game, "right")
U.wait(10)
result(Ui._moveIndex == 4, "cursor on QUICK ATTACK")
local BattleChrome = require("src.ui.game3.battle_chrome")
local Moves = require("src.core.game3.battle.moves")
local P = require("src.core.game3.profile").forSession(session)
local F = require(P.font.module)
local drawn
local orig = BattleChrome.drawMenuFrames
BattleChrome.drawMenuFrames = function(mode, layout)
drawn = BattleChrome.menuFrameRects(mode, layout)
return orig(mode, layout)
end
for _ = 1, 2000 do
if drawn then break end
U.wait(1)
end
BattleChrome.drawMenuFrames = orig
local left = drawn and drawn[1]
result(left ~= nil, "move menu frame drawn")
if left then
local limit = (left[1] + left[3]) * 8
result(limit == 168, "left move frame content ends at x=168 (got " .. limit .. ")")
for i = 1, 4 do
local label = Moves.displayName(m.moves[i])
local x = 8 + 80 * ((i - 1) % 2)
local right = x + F.measure(label)
result(right <= limit, string.format("move %d %s right edge %d within %d", i, label, right, limit))
end
end
U.still(game, DIR .. "/2729_01_quick_attack_move_menu.png")
Battle.abort("run")
U.wait(30)
finish()
end
@@ -0,0 +1,156 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/BSA/10-07-26-00-userreported/shots/game3_transition_shade_2731"
local VERSION = os.getenv("POKEPORT_VERSION") or "emerald"
local function loadPng(path)
local f = path and io.open(path, "rb")
if not f then return nil end
local s = f:read("*a")
f:close()
return love.image.newImageData(love.filesystem.newFileData(s, "x.png"))
end
local function diffCount(a, b)
if not (a and b) then return -1 end
local w, h = math.min(a:getWidth(), b:getWidth()), math.min(a:getHeight(), b:getHeight())
local n = 0
for y = 0, h - 1, 4 do
for x = 0, w - 1, 4 do
local r1, g1, b1 = a:getPixel(x, y)
local r2, g2, b2 = b:getPixel(x, y)
if math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) > 0.06 then n = n + 1 end
end
end
return n
end
local function grayShare(img)
if not img then return 0 end
local w, h = img:getWidth(), img:getHeight()
local n, g = 0, 0
for y = math.floor(h * 0.3), math.floor(h * 0.7), 4 do
for x = math.floor(w * 0.3), math.floor(w * 0.7), 4 do
local r, gg, b = img:getPixel(x, y)
n = n + 1
if math.max(r, gg, b) - math.min(r, gg, b) < 0.08 and r > 0.2 and r < 0.55 then g = g + 1 end
end
end
return n > 0 and g / n or 0
end
return function(game)
local fails = 0
local function result(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
end
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "NICK", gender = 0 })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local Party = require("src.core.game3.party")
local BattleBridge = require("src.core.game3.battle_bridge")
local Battle = require("src.core.game3.battle")
local BT = require("src.core.game3.battle_transition")
local Map = require("src.core.game3.map")
local MapIds = require("src.core.game3.map_ids")
local C = require("src.core.game3.constants").of(VERSION)
local session = Runtime.getSession()
local Ids = BT.ids()
local rse = VERSION ~= "firered" and VERSION ~= "leafgreen"
local W = rse and require("src.core.game3.field_weather_rse") or nil
local cases
if rse then
cases = {
{ map = "MAP_ROUTE104", x = 20, y = 40, tid = "POKEBALLS_TRAIL", shade = false },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "POKEBALLS_TRAIL", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "ANGLED_WIPES", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "SLICE", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, tid = "WHITE_BARS_FADE", shade = true },
{ map = "MAP_PETALBURG_WOODS", x = 14, y = 32, pick = "WHITE_BARS_FADE", lead = 5, foe = 6, shade = true },
{ map = "MAP_ROUTE120", x = 22, y = 61, tid = "POKEBALLS_TRAIL", rain = true },
{ map = "MAP_ROUTE120", x = 22, y = 61, tid = "WHITE_BARS_FADE", rain = true },
}
else
cases = {
{ map = "MAP_ROUTE1", x = 10, y = 20, tid = "POKEBALLS_TRAIL" },
}
end
for i, c in ipairs(cases) do
local mapId = MapIds.forConst(c.map, VERSION)
local tag = string.format("%s %s", c.map, c.tid or ("pick " .. c.pick))
local stem = string.format("%s/2731_%02d_%s_%s_%s", DIR, i, VERSION, c.map:lower():gsub("^map_", ""),
(c.tid or c.pick):lower())
local ok, err = pcall(function() Map.load(nil, game, mapId, { x = c.x, y = c.y, facing = "down" }) end)
result(ok and mapId ~= nil, "load " .. tag .. " " .. tostring(err or ""))
U.wait(120)
if c.rain then
W.setNextWeather(W.WEATHER.RAIN)
end
U.wait(240)
session.party = {}
Party.giveMon(session, C.species.byName.SPECIES_MUDKIP, c.lead or 20, "MUDKIP")
if W then
local snap = W.snapshot()
print(string.format("[driver] %s weather %s colorMapIndex %s", tag, tostring(snap.curr), tostring(snap.colorMapIndex)))
if c.shade or c.rain then
result(snap.colorMapIndex ~= 0, tag .. " weather color map active")
end
end
local base = stem .. "_base.png"
U.still(game, base)
local opts = {}
if c.tid then opts.transitionId = Ids.ID[c.tid] end
BattleBridge.startWild(Runtime._mod, game, { species = C.species.byName.SPECIES_WURMPLE, level = c.foe or 5 }, opts)
if c.pick then
result(BT._transitionId == Ids.ID[c.pick], tag .. " level compare picked " .. c.pick
.. " (got " .. tostring(BT._transitionId) .. ")")
end
local peakShot
local mainShots = {}
local frames, mainAt = 0, nil
while frames < 900 do
U.wait(1)
frames = frames + 1
if BT._phase == "intro" and BT._intro and BT._intro.blend >= 14 and not peakShot then
peakShot = stem .. "_gray_flash.png"
U.still(game, peakShot)
end
if BT._phase == "main" and not mainAt then mainAt = frames end
if mainAt and (frames - mainAt == 8 or frames - mainAt == 20 or frames - mainAt == 32) then
local p = string.format("%s_main_%02d.png", stem, frames - mainAt)
U.still(game, p)
mainShots[#mainShots + 1] = p
end
if Battle.isActive() then break end
end
local b = loadPng(base)
local peakImg = loadPng(peakShot)
local dPeak = diffCount(b, peakImg)
local dMain, best = -1, nil
for _, p in ipairs(mainShots) do
local d = diffCount(b, loadPng(p))
if d > dMain then dMain, best = d, p end
end
for _, p in ipairs(mainShots) do
if p ~= best then os.remove(p) end
end
local gray = grayShare(peakImg)
print(string.format("[driver] %s introDiff %d mainDiff %d grayShare %.2f", tag, dPeak, dMain, gray))
result(dPeak > 2000, tag .. " intro flash visible (diff " .. dPeak .. ")")
result(gray > 0.8, string.format("%s intro flash is gray (%.2f)", tag, gray))
result(dMain > 2000, tag .. " main transition visible (diff " .. dMain .. ")")
Battle.abort("run")
for _ = 1, 600 do
if not Battle.isActive() then break end
U.wait(1)
end
U.wait(60)
end
love.event.quit(fails == 0 and 0 or 1)
end
+135
View File
@@ -0,0 +1,135 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/drv2727"
local function until_(n, fn)
for _ = 1, n do
if fn() then return true end
U.wait(1)
end
return fn()
end
local function loadShot(path)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
end
local function hex(r, g, b)
return string.format("%02x%02x%02x", math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))
end
local function cellColors(img, cellX, cellY)
local scale = img:getWidth() / 240
local seen = {}
for y = 0, 7 do for x = 0, 7 do
local px, py = math.floor((cellX + x + 0.5) * scale), math.floor((cellY + y + 0.5) * scale)
seen[hex(img:getPixel(px, py))] = true
end end
return seen
end
return function(game)
local pass = true
local function check(cond, label)
print((cond and "PASS " or "FAIL ") .. label)
if not cond then pass = false end
end
until_(900, function() return game.phase == "boot" and game.boot end)
local ok, err = xpcall(function()
local Runtime = require("src.core.game3.runtime")
local Warp = require("src.core.game3.warp")
local Message = require("src.ui.game3.message")
pcall(function() game:_handleBootAction({ action = "new_game", name = "MAY", gender = 1 }) end)
U.wait(60)
until_(600, function()
if Message.isOpen and Message.isOpen() then U.tap(game, "b") end
return not (Warp.isBusy() or (Message.isOpen and Message.isOpen()))
end)
local s = Runtime.getSession()
local C = require("src.core.game3.constants").active(s)
local Bag = require("src.core.game3.bag")
Bag.add(s.bag, C:require("items", "ITEM_POTION"), 3)
Bag.add(s.bag, C:require("items", "ITEM_LIECHI_BERRY"), 1)
Bag.add(s.bag, C:require("items", "ITEM_RAZZ_BERRY"), 10)
Bag.add(s.bag, C:require("items", "ITEM_TM39_ROCK_TOMB"), 1)
Bag.add(s.bag, C:require("items", "ITEM_HM01_CUT"), 1)
local BagMenu = require("src.ui.game3.bag_menu")
local Font = require("src.ui.game3.frlg_font")
local realDraw = Font.draw
local calls = {}
Font.draw = function(str, x, y, opts)
calls[#calls + 1] = {s = str, x = x, y = y}
return realDraw(str, x, y, opts)
end
local function find(str, y)
for i = #calls, 1, -1 do
local c = calls[i]
if c.s == str and (y == nil or c.y == y) then return c.x, c.y end
end
end
local function rightEdge(str, y)
local x = find(str, y)
return x and x + Font.measure(str, {font = "normal"})
end
local function capture(gender, tag, pocket, idx)
s.gender, s.playerGender = gender, gender
BagMenu.show(s, { session = s, pocket = pocket })
until_(120, function() return not BagMenu._open end)
U.wait(10)
calls = {}
local path = string.format("%s/2727_%s_%s_%s.png", DIR, idx, tag, pocket:lower())
U.still(game, path)
local img = loadShot(path)
BagMenu.close()
U.wait(20)
return img
end
for _, who in ipairs({{1, "female", "0"}, {0, "male", "1"}}) do
local gender, tag, base = who[1], who[2], who[3]
local img = capture(gender, tag, "BERRY_POUCH", base .. "1")
local lx, ly = find("36")
check(lx == 120, tag .. " LIECHI No36 number at x=120")
check(ly ~= nil and find("×", ly) == 208, tag .. " LIECHI multiply sign at x=208")
check(ly ~= nil and rightEdge("1", ly) == 232, tag .. " LIECHI count right-aligned to 232")
local _, ry = find("10")
check(ry ~= nil and rightEdge("10", ry) == 232 and find("×", ry) == 208, tag .. " RAZZ x10 count right-aligned to 232")
if img then
local selectedCell, stripeLeak, idleStripe = 40 + 3 * 8, false, false
for p = 0, 4 do
local cx = 40 + p * 8
local seen = cellColors(img, cx, 72)
if cx ~= selectedCell and (seen["6bb5d6"] or seen["297ba5"]) then idleStripe = true end
if seen["297ba5"] then stripeLeak = true end
end
if gender == 1 then
check(not stripeLeak and not idleStripe, "female indicator dots have no blue stripe pixels")
else
check(idleStripe, "male indicator dots sit on blue stripes")
end
else check(false, tag .. " berry shot readable") end
capture(gender, tag, "TM_CASE", base .. "2")
local tx, ty = find("39")
check(tx == 120, tag .. " TM39 number at x=120")
check(ty ~= nil and find("ROCK TOMB", ty) == 136, tag .. " TM39 name at x=136")
check(ty ~= nil and find("×", ty) == 214, tag .. " TM multiply sign at x=214")
check(ty ~= nil and rightEdge("1", ty) == 232, tag .. " TM count right-aligned to 232")
local _, hy = find("CUT")
check(hy ~= nil and find("1", hy) == 129, tag .. " HM01 number at x=129")
check(hy ~= nil and find("×", hy) == nil, tag .. " HM row has no quantity")
capture(gender, tag, "ITEMS", base .. "3")
check(find("×", 16) == 214, tag .. " item multiply sign at x=214")
check(rightEdge("3", 16) == 232, tag .. " item count right-aligned to 232")
end
Font.draw = realDraw
end, debug.traceback)
if not ok then print("FAIL driver error: " .. tostring(err)); pass = false end
love.event.quit(pass and 0 or 1)
U.wait(10)
end
@@ -0,0 +1,146 @@
local U = require("tests.drivers.util")
return function(game)
U.wait(20)
package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
local App, Paint, Kit, path, tmp, oldPaint
local dim, safe, osName
local failures = 0
local function pass(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
end
local ok, err = xpcall(function()
App = require("tools.save-editor.App")
local Gen, Ops, MonOps = require("Gen"), require("Ops"), require("MonOps")
local Touch, History = require("TouchEditor"), require("History")
local Serializer = require("src.core.SaveSerializer")
local version = require("src.core.GameVersion").get()
local dir = assert(os.getenv("POKEPORT_SHOT_DIR"))
Kit, Paint = require("Kit"), require("src.ui.kit.Button")
dim, safe, osName = love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS
local controls = {}
oldPaint = Paint.draw
Paint.draw = function(owner, x, y, w, h, label, opts, hot, focused)
if owner == Kit then controls[opts.id or label] = { x = x, y = y, w = w, h = h, label = label } end
return oldPaint(owner, x, y, w, h, label, opts, hot, focused)
end
tmp = os.tmpname()
path = tmp .. "-unown-2726.lua"
local file = assert(io.open(path, "wb"))
file:write(Serializer.encode(Gen.newGame(version))); file:close()
App.load(path, { version = version, embedded = true })
local S = App.getState()
assert(S.save and not S.loadError and not S.missingCache, S.status)
local g = Gen.ofState(S)
local mon = MonOps.create(S.data, g == 3 and MonOps.SPECIES_UNOWN_G3 or "UNOWN", 25, g)
if g == 3 then
mon.otId, mon.otSecretId = S.save.player and S.save.player.trainerId or 0, S.save.player and S.save.player.secretId or 0
end
S.save.party = { mon }
Ops.selectParty(S, 1)
assert(Ops.setUnownForm(S, mon, g == 3 and 5 or 6), "seed form F")
S.dirty = false
local W, H, platform = 390, 844, "Android"
local function frame(name)
controls = {}
love.graphics.getDimensions = function() return W, H end
love.window.getSafeArea = function() return 0, 0, W, H end
love.system.getOS = function() return platform end
local canvas = love.graphics.newCanvas(W, H, { msaa = 0 })
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.origin()
love.graphics.setScissor()
love.graphics.setShader()
require("src.render.GameViewport").reset()
love.graphics.clear(0, 0, 0, 1)
App.draw()
love.graphics.setCanvas()
love.graphics.pop()
if name then
local pixels = canvas:newImageData()
local bytes = pixels:encode("png"):getString()
pixels:release()
local out = assert(io.open(dir .. "/" .. version .. "-" .. name .. ".png", "wb"))
out:write(bytes); out:close()
end
canvas:release()
love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName
end
local function tap(id)
local r = assert(controls[id], "missing control " .. id)
App.mousepressed(r.x + r.w / 2, r.y + r.h / 2, 1)
frame()
frame()
end
local function current() return Ops.unownForm(S, S.save.party[1]) end
local function letterName() return Ops.unownFormName(S, current()) end
local function pick(letter)
local p = assert(S.editPopup, "form popup open")
p.query, p.index = "", nil
for i, o in ipairs(p.options) do if o[1] == letter then p.index = i end end
assert(p.index, "option present")
Touch.commit(S, Kit)
frame()
end
S.tab, S.monSection, S.mobileInspector = "party", "main", false
frame(); frame("2726_01_roster_unown_f_phone")
pass(letterName() == "F", version .. " roster seeded with UNOWN F")
S.mobileInspector = true
frame(); frame()
local r = controls["choice-form"]
for _ = 1, 40 do
if r and r.y >= H * 0.3 and r.y + r.h <= H * 0.7 then break end
S.inspectorScroll = (S.inspectorScroll or 0) + 40
frame()
r = controls["choice-form"]
end
frame("2726_02_form_picker_row_phone")
pass(r ~= nil, version .. " phone inspector shows the Unown form picker")
pass(r and r.label == "Unown form: F", version .. " picker reads the current letter (" .. tostring(r and r.label) .. ")")
tap("choice-form")
pass(S.editPopup and S.editPopup.id == "form", version .. " tapping the picker opens the touch chooser")
pass(S.editPopup and #S.editPopup.options == (g == 3 and 28 or 26), version .. " chooser lists every form")
frame("2726_03_form_chooser_open_phone")
local target = g == 3 and 27 or 17
pick(target)
pass(current() == target, version .. " chooser sets the form to " .. letterName())
pass(S.dirty, version .. " form edit marks the save dirty")
frame()
frame("2726_04_form_set_" .. letterName():gsub("%?", "qmark") .. "_phone")
pass(History.undo(S) and letterName() == "F", version .. " undo restores F")
pass(History.redo(S) and current() == target, version .. " redo reapplies")
Ops.selectParty(S, 1)
W, H, platform = 1100, 720, "Linux"
S.inspectorScroll = 0
frame(); frame()
for _ = 1, 40 do
r = controls["choice-form"]
if r and r.y >= 0 and r.y + r.h <= H then break end
S.inspectorScroll = (S.inspectorScroll or 0) + 80
frame()
end
frame("2726_05_desktop_docked_picker_and_roster")
pass(controls["choice-form"] ~= nil, version .. " desktop docked inspector shows the picker")
assert(App.save(), "save")
App.unload()
App.load(path, { version = version, embedded = true })
S = App.getState()
mon = S.save.party[1]
pass(Ops.unownForm(S, mon) == target, version .. " reopened save keeps the form")
end, debug.traceback)
if App then App.unload() end
if Paint and oldPaint then Paint.draw = oldPaint end
if dim then love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName end
if path then
os.remove(path)
for _, backup in ipairs(require("tests.fs_io").globPrefix(path .. ".bak-")) do os.remove(backup) end
end
if tmp then os.remove(tmp) end
if not ok then print("FAIL save_editor_unown_form_2726: " .. tostring(err)) end
love.event.quit((ok and failures == 0) and 0 or 1)
end
@@ -0,0 +1,128 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local HostShell = require("src.core.HostShell")
local quits = {}
love.event = {
quit = function(...)
quits[#quits + 1] = { n = select("#", ...), arg = (...) }
end,
}
local osName = "Android"
local restartApp = nil
love.system = love.system or {}
love.system.getOS = function() return osName end
local exits = {}
local realExit = os.exit
os.exit = function(code)
exits[#exits + 1] = code
if code ~= nil and type(code) ~= "number" and type(code) ~= "boolean" then
error("bad argument #1 to 'exit' (number expected, got " .. type(code) .. ")")
end
end
local function oldShellStep(a)
if love.system and love.system.getOS() == "Android" then
os.exit(a or 0)
end
return a or 0
end
local function driveOldShell()
local errs = {}
for _, q in ipairs(quits) do
local ok, err = pcall(oldShellStep, q.arg)
if not ok then errs[#errs + 1] = tostring(err) end
end
return errs
end
local function reset()
for i = #quits, 1, -1 do quits[i] = nil end
for i = #exits, 1, -1 do exits[i] = nil end
end
_G.POKEPORT_LOOP_RESTART = nil
love.system.restartApp = nil
HostShell.restart()
eq(#quits, 1, "Android restart on an older shell still quits once")
eq(quits[1] and quits[1].n, 0, "with a bare quit(), never quit(\"restart\")")
local errs = driveOldShell()
eq(#errs, 0, "the old stepper's os.exit(a or 0) never errors: " .. table.concat(errs, "; "))
for _, code in ipairs(exits) do
check(type(code) == "number", "old stepper exits with a number, got " .. type(code))
end
check(HostShell.canRestart ~= nil and not HostShell.canRestart(),
"an older Android shell cannot restart in-process")
reset()
local restartCalls = 0
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
HostShell.restart()
eq(restartCalls, 1, "an older shell with the restartApp bridge relaunches through it")
eq(#quits, 0, "and sends no quit event")
reset()
love.system.restartApp = function() return false end
HostShell.restart()
eq(#quits, 1, "a failed restartApp falls back to one quit")
eq(#driveOldShell(), 0, "which the old stepper exits on cleanly")
for _, mod in ipairs({ "src.core.Game", "src.core.Game3" }) do
reset()
love.system.restartApp = nil
local ok, M = pcall(require, mod)
if ok and type(M) == "table" and type(M.restartWithMods) == "function" then
local stub = setmetatable({}, { __index = function() return function() end end })
pcall(M.restartWithMods, stub)
for _, q in ipairs(quits) do
check(type(q.arg) ~= "string", mod .. ":restartWithMods sends no string quit on an older shell")
end
eq(#driveOldShell(), 0, mod .. ":restartWithMods survives the old stepper")
end
end
reset()
local ManagerState = require("src.mods.ManagerState")
ManagerState.restartGame({ game = {} })
for _, q in ipairs(quits) do
check(type(q.arg) ~= "string", "ManagerState:restartGame sends no string quit on an older shell")
end
eq(#driveOldShell(), 0, "ManagerState:restartGame survives the old stepper")
reset()
_G.POKEPORT_LOOP_RESTART = true
check(HostShell.canRestart and HostShell.canRestart(),"a shell whose love.run sets the flag restarts in-process")
HostShell.restart()
eq(quits[1] and quits[1].arg, "restart", "and HostShell.restart sends quit(\"restart\")")
_G.POKEPORT_LOOP_RESTART = nil
osName = "iOS"
check(HostShell.canRestart and HostShell.canRestart(),"iOS keeps its bare-quit restart")
osName = "OS X"
check(HostShell.canRestart and HostShell.canRestart(),"desktop keeps quit(\"restart\")")
local f = assert(io.open("main.lua", "rb"))
local mainSrc = f:read("*a")
f:close()
local runBody = mainSrc:match("\nfunction love%.run%(%)(.-)\nend\n") or ""
local flagAt = runBody:find("_G.POKEPORT_LOOP_RESTART = true", 1, true)
local loadAt = runBody:find("love.load(", 1, true)
check(flagAt ~= nil and loadAt ~= nil and flagAt < loadAt,
"love.run advertises restart support before love.load chainloads a payload")
local returnBody = mainSrc:match("local function returnToLauncher%(opts%)(.-)\nend\n") or ""
local gateAt = returnBody:find("canRestart()", 1, true)
local inProcAt = returnBody:find("rebuildLauncherInProcess(opts)", 1, true)
local restartAt = returnBody:find('require("src.core.HostShell").restart()', 1, true)
check(gateAt ~= nil and inProcAt ~= nil and restartAt ~= nil and gateAt < restartAt
and inProcAt < restartAt,
"EXIT GAME rebuilds the launcher in-process when the shell cannot restart")
os.exit = realExit
T.finish("android_old_shell_restart_bug2738")
+198
View File
@@ -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")
+1 -1
View File
@@ -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")
+31 -4
View File
@@ -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")
@@ -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()
+1 -1
View File
@@ -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")
@@ -0,0 +1,98 @@
-- pokeemerald/src/event_object_movement.c:7737, pokeemerald/src/event_object_movement.c:4931
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local GameVersion = require("src.core.GameVersion")
GameVersion.set("emerald")
local Collision = require("src.core.game3.collision")
local Objects = require("src.core.game3.objects")
local FieldView = require("src.core.game3.field_view")
local W, H = 19, 9
local elev = {}
for y = 0, H - 1 do
for x = 0, W - 1 do elev[y * W + x] = 3 end
end
elev[5 * W + 17] = 4
elev[5 * W + 2] = 15
elev[6 * W + 4] = 0
local layout = { width = W, height = H, pair = "spawn_elev_test" }
function layout:elevAt(x, y) return elev[y * W + x] end
function layout:collAt() return 0 end
function layout:midAt() return 0x200 end
function layout:collArray()
local c = {}
for i = 1, W * H do c[i] = 0 end
return c
end
local mapDef = { midLayout = layout, pair = layout.pair, warps = {} }
Collision.bindMap({ save = { position = {} } }, "SPAWN_ELEV_TEST", mapDef)
local defs = {
{ localId = 1, x = 17, y = 5, elevation = 3, graphicsId = 7, movementType = 0x0A },
{ localId = 2, x = 2, y = 5, elevation = 3, graphicsId = 7, movementType = 0x0A },
{ localId = 3, x = 4, y = 6, elevation = 3, graphicsId = 7, movementType = 0x0A },
}
local pool = Objects.spawnFromDefs(defs, mapDef, nil)
local stone, multi, trans = pool.byId[1], pool.byId[2], pool.byId[3]
check(stone and multi and trans, "objects spawned")
eq(stone.elevation, 3, "template elevation before first update")
local savedById, savedOrder, savedTracks = Objects._byId, Objects._order, Objects._tracks
Objects._byId, Objects._order, Objects._tracks = pool.byId, pool.order, {}
Objects.update({ save = { position = {} } })
eq(stone.elevation, 4, "spawn_elevation_follows_metatile")
eq(stone.currentElevation, 4, "spawn current elevation follows metatile")
eq(multi.elevation, 3, "multi-level tile keeps template elevation")
eq(trans.elevation, 3, "transition tile keeps previous elevation")
eq(trans.currentElevation, 0, "transition tile sets current elevation 0")
check(not stone.spawnElevation, "spawn update runs once")
local under, over = FieldView.applyDrawOrder({
{ kind = "npc", eventObject = stone, elevation = stone.elevation, y = 5 * 16 },
}, {}, {}, 0)
eq(#over, 1, "mr_stone_draws_over_bg1")
eq(#under, 0, "mr stone not under top layer")
local savedMapId, savedDefs = Objects._mapId, Objects._defs
Objects._mapId, Objects._defs = "SPAWN_ELEV_TEST", defs
local snap = Objects.snapshot()
eq(#snap.list, 0, "snapshot has no row for a spawn-resolved elevation")
Objects._mapId, Objects._defs = savedMapId, savedDefs
Objects._byId, Objects._order, Objects._tracks = savedById, savedOrder, savedTracks
local NW, NH = 5, 5
local nelev = {}
for y = 0, NH - 1 do
for x = 0, NW - 1 do nelev[y * NW + x] = 3 end
end
nelev[1 * NW + 1] = 4
local nlayout = { width = NW, height = NH, pair = "spawn_elev_neighbor" }
function nlayout:elevAt(x, y) return nelev[y * NW + x] end
function nlayout:collAt() return 0 end
function nlayout:midAt() return 0x200 end
function nlayout:collArray()
local c = {}
for i = 1, NW * NH do c[i] = 0 end
return c
end
local nmapDef = { midLayout = nlayout, pair = nlayout.pair, warps = {} }
local npool = Objects.spawnFromDefs({
{ localId = 1, x = 1, y = 1, elevation = 3, graphicsId = 7, movementType = 0x0A },
{ localId = 2, x = 3, y = 3, elevation = 3, graphicsId = 7, movementType = 0x0A },
}, nmapDef, nil)
local raised, flat = npool.byId[1], npool.byId[2]
eq(raised.elevation, 3, "neighbor template elevation before first tick")
eq(layout:elevAt(1, 1), 3, "active map cell under the neighbor NPC is elevation 3")
Objects.tickPool(npool, { save = { position = {} } }, nil)
eq(raised.elevation, 4, "neighbor_pool_spawn_elevation_follows_own_metatile")
eq(raised.currentElevation, 4, "neighbor pool current elevation follows own metatile")
eq(flat.elevation, 3, "neighbor pool flat tile keeps template elevation")
check(not raised.spawnElevation, "neighbor pool spawn update runs once")
GameVersion.set("firered")
T.finish("game3_object_spawn_elevation_2737_test")
+78
View File
@@ -0,0 +1,78 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Names = require("src.import.gba.rs.anim_names")
local Emerald = require("src.import.gba.anim_names_emerald")
local AnimCallbacks = require("src.core.game3.battle.anim_callbacks")
local AnimTasks = require("src.core.game3.battle.anim_tasks")
local Versions = require("src.import.gba.versions")
local function count(t)
local n = 0
for _ in pairs(t or {}) do n = n + 1 end
return n
end
eq(count(Names.templates), 329, "329 RS sprite templates")
eq(count(Names.callbacks), 269, "269 RS sprite callbacks")
eq(count(Names.tasks), 208, "208 RS visual/sound tasks")
local missCb, missTask = {}, {}
for key, name in pairs(Names.callbacks) do
local bound = AnimCallbacks.get(name) ~= AnimCallbacks.SimpleFadeOut
or AnimTasks.REGISTRY["_noGfx_" .. name] ~= nil
if not bound then missCb[#missCb + 1] = key .. ">" .. name end
end
for key, name in pairs(Names.tasks) do
if not AnimTasks.REGISTRY[name] then missTask[#missTask + 1] = key .. ">" .. name end
end
table.sort(missCb)
table.sort(missTask)
eq(table.concat(missCb, " "), "", "every RS callback resolves to a runtime callback")
eq(table.concat(missTask, " "), "", "every RS task resolves to a runtime task")
local emTemplates = {}
for _, v in pairs(Emerald.templates) do emTemplates[v] = true end
local seen, dup = {}, {}
for key, name in pairs(Names.templates) do
if seen[name] then dup[#dup + 1] = name end
seen[name] = key
check(emTemplates[name], "RS template " .. key .. " carries an Emerald template name (" .. name .. ")")
end
eq(#dup, 0, "RS template canonical names are unique")
local spot = {
callbacks = {
sub_80DC8F4 = "LeechLifeNeedle", sub_80DC068 = "RedX", sub_80DD8E8 = "RockTomb",
sub_80D1318 = "UproarRing", sub_80D2D68 = "JaggedMusicNote", AnimConfusionDuck = "ConfusionDuck",
sub_80CA9A8 = "sub_80CA9A8",
},
tasks = {
sub_807BB88 = "StatsChange", sub_80D2CF8 = "UproarDistortion", sub_80E26BC = "ShakeBattleTerrain",
sub_80CE7E0 = "DoubleTeam", sub_812B2B8 = "sub_812B2B8",
},
templates = {
gBattleAnimSpriteTemplate_83DA8F4 = "gRedXSpriteTemplate",
gBattleAnimSpriteTemplate_83DADA8 = "gRockTombRockSpriteTemplate",
gBattleAnimSpriteTemplate_83D79A4 = "gUproarRingSpriteTemplate",
gBattleAnimSpriteTemplate_83D7CC8 = "gJaggedMusicNoteSpriteTemplate",
gBattleAnimSpriteTemplate_83DAB10 = "gLeechLifeNeedleSpriteTemplate",
},
}
for section, rows in pairs(spot) do
for key, want in pairs(rows) do eq(Names[section][key], want, section .. " " .. key) end
end
local V = Versions.forGame("sapphire")
for i, n in pairs({ [0] = "PSN", "CONFUSION", "BRN", "INFATUATION", "SLP", "PRZ", "FRZ", "CURSED", "NIGHTMARE" }) do
eq(V.BATTLE_ANIM_STATUS_NAMES[i], "STATUS_" .. n, "RS status anim " .. i)
end
eq(V.BATTLE_ANIM_GENERAL_NAMES[7], "HELD_ITEM_EFFECT", "RS general anim 7 uses the runtime name")
eq(V.BATTLE_ANIM_GENERAL_NAMES[9], "FOCUS_BAND", "RS general anim 9 uses the runtime name")
eq(count(V.ANIM_CALLBACK_NAMES), 269, "RS callback address map built from syms")
eq(count(V.ANIM_TASK_NAMES), 208, "RS task address map built from syms")
eq(count(V.ANIM_TEMPLATE_NAMES), 329, "RS template address map built from syms")
T.finish("game3_rs_anim_names_test")
@@ -0,0 +1,113 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local eq = T.eq
local draws, images = {}, {}
_G.love = _G.love or {}
love.graphics = {
setColor = function() end,
rectangle = function() end,
newQuad = function() return {} end,
draw = function(img) images[#images + 1] = img.path end,
}
local manifest = {
layout = "rs",
layers = {bg = {png = "bg.png", variants = {female = "bg_female.png"}}},
sprites = {male = {anims = {}}, female = {anims = {}}},
labels = {{male = "l_m", female = "l_f"}, {male = "l_m", female = "l_f"}, {male = "l_m", female = "l_f"},
{male = "l_m", female = "l_f"}, {male = "l_m", female = "l_f"}},
indicators = {idle = "indicator_0.png", selected = "indicator_1.png",
idleFemale = "indicator_0_female.png", selectedFemale = "indicator_1_female.png"},
hm = "hm.png", number = "number.png", select = "select.png",
}
package.loaded["src.ui.game3.rse.scene_kit"] = {
manifest = function() return manifest end,
image = function(path)
if path and path:find("indicator", 1, true) then
return {path = path, getDimensions = function() return 8, 8 end}
end
end,
playSe = function() end, resetCaches = function() end,
}
package.loaded["src.ui.game3.frlg_font"] = {
STDPAL = {{1, 1, 1, 1}, {1, 0, 0, 1}, [8] = {0, 0, 0, 1}},
COLOR = {NORMAL = {}},
sync = function() end,
measure = function(s) return #s * 6 end,
draw = function(s, x, y, opts) draws[#draws + 1] = {s = s, x = x, y = y, w = opts and opts.maxWidth} end,
}
package.loaded["src.ui.game3.chrome"] = {stdFrame = function() end, dialogueFrame = function() end}
package.loaded["src.ui.game3.rs.menu_cursor"] = {draw = function() end}
package.loaded["src.core.game3.rom_text"] = {plain = function(key) return key end, overrides = {}}
package.loaded["src.core.game3.trig"] = {sin = function() return 0 end}
package.loaded["src.core.game3.bag"] = {}
local HM = {HM01 = 1}
local TM = {TM39 = 39, HM01 = 1}
local BERRY = {LIECHI = 36}
package.loaded["src.core.game3.items_data"] = {
isHm = function(id) return HM[id] ~= nil end,
tmNumber = function(id) return TM[id] end,
berryNumber = function(id) return BERRY[id] end,
toNumericId = function(id) return id end,
}
package.loaded["src.core.game3.pokemon"] = {
moveFromTmItem = function(id) return id end,
moveName = function(id) return ({TM39 = "ROCK TOMB", HM01 = "CUT"})[id] end,
}
local M = require("src.ui.game3.rs.bag_menu")
local function render(pocket, rows, gender, pocketIdx)
draws, images = {}, {}
local Bag = {
open = true, mode = "list", scroll = 0, cursor = 1, pocketIdx = pocketIdx or 1,
_session = {gender = gender},
list = function() return rows end,
currentPocket = function() return pocket end,
}
Bag._rsBag = {pos = {}, frame = 0, gridPos = 1, lastMode = "list"}
M.draw(Bag)
return draws
end
local function at(list, s, y)
for _, d in ipairs(list) do if d.s == s and (y == nil or d.y == y) then return d end end
end
T.suite("rs bag row layout")
local tm = render("TM_CASE", {{id = "TM39", name = "TM39", qty = 1, description = ""},
{id = "HM01", name = "HM01", qty = 1, description = ""}}, 0, 4)
eq(at(tm, "39", 16) and at(tm, "39", 16).x, 120, "TM number x")
eq(at(tm, "ROCK TOMB", 16) and at(tm, "ROCK TOMB", 16).x, 136, "TM name x")
eq(at(tm, "ROCK TOMB", 16) and at(tm, "ROCK TOMB", 16).w, 78, "TM name width")
eq(at(tm, "×", 16) and at(tm, "×", 16).x, 214, "TM multiply sign x")
eq(at(tm, "1", 16) and at(tm, "1", 16).x, 226, "TM count right-aligned to 232")
eq(at(tm, "1", 32) and at(tm, "1", 32).x, 129, "HM number x")
eq(at(tm, "CUT", 32) and at(tm, "CUT", 32).x, 136, "HM name x")
eq(at(tm, "×", 32), nil, "HM row has no quantity")
local berry = render("BERRY_POUCH", {{id = "LIECHI", name = "LIECHI", qty = 10, description = ""}}, 1, 5)
eq(at(berry, "36", 16) and at(berry, "36", 16).x, 120, "berry number x")
eq(at(berry, "LIECHI", 16) and at(berry, "LIECHI", 16).w, 72, "berry name width")
eq(at(berry, "×", 16) and at(berry, "×", 16).x, 208, "berry multiply sign x")
eq(at(berry, "10", 16) and at(berry, "10", 16).x, 220, "berry count right-aligned to 232")
local items = render("ITEMS", {{id = "POTION", name = "POTION", qty = 5, description = ""}}, 0, 1)
eq(at(items, "POTION", 16) and at(items, "POTION", 16).x, 112, "item name x")
eq(at(items, "×", 16) and at(items, "×", 16).x, 214, "item multiply sign x")
eq(at(items, "5", 16) and at(items, "5", 16).x, 226, "item count right-aligned to 232")
render("ITEMS", {}, 1, 2)
local fem = table.concat(images, ",")
eq(fem, "indicator_0_female.png,indicator_1_female.png,indicator_0_female.png,indicator_0_female.png,indicator_0_female.png",
"female indicator dots use female bake")
render("ITEMS", {}, 0, 2)
eq(table.concat(images, ","), "indicator_0.png,indicator_1.png,indicator_0.png,indicator_0.png,indicator_0.png",
"male indicator dots use male bake")
T.finish("rs bag row layout")
@@ -0,0 +1,62 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.fixture_data.game3_map_sections").install()
local T = require("tests.harness")
local eq = T.eq
require("tests.game3_cache").stubSpeciesNames()
require("src.core.GameVersion").set("firered")
package.loaded["src.core.game3.audio"] = {
playSe = function() end, playCry = function() end,
playSong = function() end, stopAll = function() end,
}
package.loaded["src.core.game3.rom_text"] = {
plain = function(key) return key end, box = function(key) return key end,
ascii = function(key) return key end, has = function() return true end,
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
count = function() return 0 end, list = function() return {} end,
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
}
local drawn = {}
package.loaded["src.ui.game3.chrome"] = {
_frameType = 0,
userFrame = function(_, x, y, w, h) drawn[#drawn + 1] = { x, y, w, h } end,
}
local BattleChrome = require("src.ui.game3.battle_chrome")
local Ui = require("src.core.game3.battle.ui")
local nativeLayout = "rs"
package.loaded["src.core.game3.profile"] = {
forSession = function() return { font = { nativeLayout = nativeLayout } } end,
}
BattleChrome._manifest = { layout = "rse" }
local function frames(mode)
drawn = {}
Ui.drawMenuFrames(mode)
return drawn
end
local function same(a, b)
if #a ~= #b then return false end
for i = 1, #a do
for j = 1, 4 do if a[i][j] ~= b[i][j] then return false end end
end
return true
end
nativeLayout = "rs"
local mv = frames("moves")
eq(same(mv, { { 1, 15, 20, 4 }, { 23, 15, 6, 4 } }), true, "RS move menu frames follow menu_map.bin")
eq((mv[1] and (mv[1][1] + mv[1][3]) * 8) or -1, 168, "RS left move box content ends at x=168")
eq(same(frames("menu"), { { 18, 15, 11, 4 } }), true, "RS action menu frame follows menu_map.bin")
nativeLayout = nil
eq(same(frames("moves"), { { 1, 15, 18, 4 }, { 21, 15, 8, 4 } }), true, "Emerald move menu frames unchanged")
eq(same(frames("menu"), { { 16, 15, 13, 4 } }), true, "Emerald action menu frame unchanged")
T.finish("game3 RS battle menu frames #2729")
@@ -0,0 +1,108 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local noop = function() end
local profileField
local started = {}
local vm = { active = false }
package.loaded["src.core.game3.pokemon"] = {
FRIENDSHIP_EVENT_WALKING = 0,
FRIENDSHIP_EVENT_FAINT_OUTSIDE_BATTLE = 1,
adjustFriendship = noop,
currentMapSec = function() return 0 end,
displayMonName = function(mon) return mon.name or "MON" end,
}
package.loaded["src.core.game3.rom_text"] = { box = function(key) return key end }
package.loaded["src.core.game3.field_semantics"] = {
var = function(_, name) return name end,
getVar = function() return 0 end,
setVar = noop,
}
package.loaded["src.core.game3.field_modules"] = { enabled = function() return false end }
package.loaded["src.core.game3.profile"] = {
family = function() return profileField and "rse" or "frlg" end,
forSession = function() return { field = profileField } end,
}
package.loaded["src.core.game3.capabilities"] = { gate = function() return false end }
package.loaded["src.core.game3.rs.rematch"] = { enabled = function() return false end }
package.loaded["src.core.game3.faraway_island"] = { updateStepCounter = noop }
package.loaded["src.core.game3.mystery_gift"] = { incrementNewsStepCounter = noop }
package.loaded["src.core.game3.rse.init"] = { call = noop, isRse = function() return profileField ~= nil end }
package.loaded["src.core.game3.player"] = { cellX = 0, cellY = 0 }
package.loaded["src.core.game3.forced_movement"] = {
isForced = function() return false end,
isForcedMovementTile = function() return false end,
}
package.loaded["src.core.game3.se_ids"] = { SE_FIELD_POISON = 1 }
package.loaded["src.core.game3.audio"] = { playSe = noop, fadeOutBgm = noop }
package.loaded["src.core.game3.special_scene_rse"] = { countSSTidalStep = function() return false end }
package.loaded["src.core.game3.safari"] = { takeStep = function() return false end }
package.loaded["src.core.game3.daycare"] = { step = function() return nil end }
package.loaded["src.core.game3.scripting.space"] = {
vm = vm,
scriptKey = function(name) return name end,
startScript = function(key)
started[#started + 1] = key
vm.active = true
return true
end,
}
local StepEvents = require("src.core.game3.step_events")
local function poisonedSession()
return {
vars = { poisonSteps = 3 },
party = {
{ name = "TORCHIC", hp = 1, status = "PSN" },
{ name = "MUDKIP", hp = 20 },
},
}
end
local function types()
local out = {}
for i, ev in ipairs(StepEvents._queue) do out[i] = ev.type end
return table.concat(out, ",")
end
for _, case in ipairs({
{ name = "emerald", field = require("src.core.game3.profiles.emerald.field"), script = "EventScript_FieldPoison" },
{ name = "rs", field = require("src.core.game3.profiles.rs.field"), script = "gUnknown_081A14B8" },
}) do
StepEvents.flush()
started = {}
vm.active = false
profileField = case.field
eq(case.field.fieldPoisonScript, case.script, case.name .. " profile names the cart field poison script")
local session = poisonedSession()
StepEvents.onStepTaken(session, {})
local mon = session.party[1]
eq(mon.hp, 0, case.name .. ": poison step takes the last HP")
eq(mon.status, "PSN", case.name .. ": status is left for the script's faint check")
eq(types(), "field_poison_script", case.name .. ": only the field poison script is queued")
StepEvents.update(1 / 60, {})
eq(started[1], case.script, case.name .. ": the step starts " .. case.script)
check(StepEvents.busy(), case.name .. ": step events stay busy while the script runs")
StepEvents.update(1 / 60, {})
check(StepEvents.busy(), case.name .. ": still busy on the next frame")
vm.active = false
for _ = 1, 8 do StepEvents.update(1 / 60, {}) end
check(not StepEvents.busy(), case.name .. ": step events release once the script ends")
end
StepEvents.flush()
started = {}
profileField = nil
local session = poisonedSession()
StepEvents.onStepTaken(session, {})
eq(types(), "poison_faint,poison_white_out", "firered keeps the Lua faint + white out events")
eq(session.party[1].status, nil, "firered clears status at faint time")
eq(#started, 0, "firered starts no script")
StepEvents.flush()
T.finish("game3_rse_field_poison_script_2736")
@@ -0,0 +1,95 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local G = love.graphics
local canvasA = G.newCanvas(240, 160)
local canvasB = G.newCanvas(240, 160)
canvasA.name, canvasB.name = "A", "B"
local function noop() end
local Renderer = { canvas = G.newCanvas(240, 160), worldCanvas = canvasA }
function Renderer:init() end
function Renderer:setUISize() end
function Renderer:beginFrame() end
function Renderer:worldViewSize() return 240, 160 end
function Renderer:beginWorldPass()
self.worldActive = true
G.setCanvas(self.worldCanvas)
end
function Renderer:exchangeWorldCanvas(current, replacement)
if self.worldCanvas ~= current then return false end
self.worldCanvas = replacement
return true
end
function Renderer:endWorldPass() G.setCanvas(self.canvas) end
function Renderer:endFrame() end
local seen = {}
local Transition = {
isActive = function() return true end,
drawWorld = function(canvas)
seen.handed = canvas
seen.bound = G.getCanvas()
end,
}
local stubs = {
["src.render.Renderer"] = Renderer,
["src.render.PaletteFX"] = { mode = "gbc" },
["src.render.Tilt"] = { active = function() return false end },
["src.ui.game3.help_system"] = { isOpen = function() return false end },
["src.core.game3.battle"] = { isActive = function() return false end },
["src.core.game3.bg"] = { hasVisible = function() return false end },
["src.core.game3.oam"] = {
resetFrame = noop, setLayer = function() return "world" end, animateSprites = noop,
buildOamBuffer = noop, flush = noop, flushPriority = noop,
},
["src.core.game3.field_view"] = {
draw = function(_, _, _, opts)
local cur = G.getCanvas()
if opts.exchangeCanvas and opts.exchangeCanvas(cur, canvasB) then
G.setCanvas(canvasB)
end
end,
},
["src.core.game3.battle_transition"] = Transition,
}
local saved = {}
for name, mod in pairs(stubs) do
saved[name] = package.loaded[name]
package.loaded[name] = mod
end
package.loaded["src.core.game3.display"] = nil
local Display = require("src.core.game3.display")
Display.setUiRenderer(noop)
Display.planesBroken = false
Display.present({ options = {} }, 240, 160)
check(not Display.planesBroken, "plane present ran without falling back")
check(Renderer.worldCanvas == canvasB, "field pass exchanged the world canvas")
check(seen.handed == canvasB, "drawWorld is handed the exchanged world canvas")
check(seen.bound == canvasB, "world canvas still bound after the field pass pop")
for name in pairs(stubs) do package.loaded[name] = saved[name] end
package.loaded["src.core.game3.display"] = nil
local BT = require("src.core.game3.battle_transition")
local Pal = require("src.core.game3.pal_fade")
local drawnOn = {}
local rect = G.rectangle
G.rectangle = function() drawnOn[#drawnOn + 1] = G.getCanvas() end
BT._active, BT._opts, BT._fx = true, {}, nil
BT._pal = Pal.new()
BT._pal.slots[0].y = 8
G.setCanvas(canvasA)
BT.drawWorld(canvasB, 240, 160)
G.rectangle = rect
check(#drawnOn > 0, "intro overlay drew")
check(drawnOn[1] == canvasB, "drawWorld draws the intro overlay into the canvas it is handed")
check(G.getCanvas() == canvasA, "drawWorld restores the caller's canvas")
BT._active, BT._pal, BT._worldDrawn = false, nil, false
T.finish()
+248
View File
@@ -0,0 +1,248 @@
-- engine/battle_anims/anim_commands.asm:1191
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 sfx prewarm 2732")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local ffi = require("ffi")
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local gen = cache .. "/data/generated/"
local audioFile = io.open(gen .. "audio.lua", "r")
local animFile = io.open(gen .. "battle_anims.lua", "r")
local progFile = io.open(cache .. "/assets/generated/audio/programs.bin", "rb")
if not (audioFile and animFile and progFile) then
for _, f in ipairs({ audioFile or false, animFile or false, progFile or false }) do
if f then f:close() end
end
print("[skip] no crystal cache at " .. cache)
os.exit(0)
end
audioFile:close()
animFile:close()
local progBytes = progFile:read("*a")
progFile:close()
local audio = assert(loadfile(gen .. "audio.lua"))()
local anims = assert(loadfile(gen .. "battle_anims.lua"))()
local constants = loadfile(gen .. "constants.lua")
constants = constants and constants() or nil
if audio.runtime ~= true or not audio.sfx or not audio.sfx.Sfx_Item then
print("[skip] crystal cache has no runtime audio")
os.exit(0)
end
love.filesystem.write(audio.programFile, progBytes)
local SoundData = {}
SoundData.__index = SoundData
function SoundData:setSample(index, a, b)
if b == nil then
self.buf[index * self.channels] = a * 32767
else
self.buf[index * self.channels + (a - 1)] = b * 32767
end
end
function SoundData:getSample(index, channel)
return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767
end
function SoundData:getFFIPointer() return self.buf end
function SoundData:getSampleCount() return self.samples end
function SoundData:getSampleRate() return self.rate end
function SoundData:getBitDepth() return 16 end
function SoundData:getChannelCount() return self.channels end
love.sound.newSoundData = function(samples, rate, _, channels)
return setmetatable({ samples = samples, rate = rate, channels = channels,
buf = ffi.new("int16_t[?]", samples * channels) }, SoundData)
end
local Source = {}
Source.__index = Source
function Source:play() self.plays = (self.plays or 0) + 1 end
function Source:stop() self.playing = false end
function Source:isPlaying() return self.playing == true end
function Source:setVolume() end
function Source:setPitch(p) self.pitch = p end
function Source:getPitch() return self.pitch or 1 end
function Source:getDuration() return self.sd.samples / self.sd.rate end
function Source:tell() return 0 end
love.audio = {
newSource = function(sd) return setmetatable({ sd = sd }, Source) end,
}
love.timer.getTime = os.clock
local channels = {}
local function channel(name)
local ch = channels[name]
if ch then return ch end
local items = {}
ch = {}
function ch:push(v) items[#items + 1] = v end
function ch:pop() return table.remove(items, 1) end
function ch:clear() for i = #items, 1, -1 do items[i] = nil end end
function ch:getCount() return #items end
channels[name] = ch
return ch
end
love.thread = {
newThread = function()
return { start = function() end, getError = function() return nil end }
end,
getChannel = channel,
}
local ChipSynth = require("src.core.ChipSynth")
local ChipAudio = require("src.core.ChipAudio")
local Sound = require("src.core.Sound")
local UI = require("src.ui.gen2.BattleState")
local inWorker = false
local mainRenders = 0
local realRender = ChipSynth.renderEffectData
ChipSynth.renderEffectData = function(...)
if not inWorker then mainRenders = mainRenders + 1 end
return realRender(...)
end
local function workerStep()
local cmdCh, fxCh = channel("chipaudio_cmd"), channel("chipaudio_fx")
local did = false
inWorker = true
while true do
local req = cmdCh:pop()
if not req then break end
if req.cmd == "effect" then
did = true
local sd = ChipSynth.renderEffectData({ audio = req.audio }, req.header,
req.options or {})
fxCh:push({ key = req.key, epoch = req.epoch, sd = sd })
end
end
inWorker = false
return did
end
local function pump()
if ChipAudio.pumpEffects then ChipAudio.pumpEffects() end
ChipAudio.update()
end
local function drain()
for _ = 1, 400 do
pump()
if not workerStep() then
pump()
local st = ChipAudio._effectStateForTest()
if (st.queued or 0) == 0 and (st.inFlight or 0) == 0 then return end
end
end
end
local function resetStats()
mainRenders = 0
if ChipAudio.resetSyncStats then ChipAudio.resetSyncStats() end
end
local data = { audio = audio, gen2BattleAnims = anims,
gen2Constants = constants }
if jit and jit.off then jit.off() end
local before = os.clock()
realRender(data, audio.sfx.Sfx_Item, { frequencyOffset = 0, frameTicks = 0x100 })
local syncMs = (os.clock() - before) * 1000
check(Sound.prewarmSfx(data, "Sfx_Item"), "Sfx_Item prewarm queued")
drain()
resetStats()
before = os.clock()
check(Sound.play(data, "Sfx_Item") ~= nil, "Sfx_Item plays")
local warmMs = (os.clock() - before) * 1000
print(("Sfx_Item first play, jit off: sync render %.1f ms, prewarmed %.1f ms")
:format(syncMs, warmMs))
eq(mainRenders, 0, "prewarmed Sfx_Item: no main-thread render")
local enemy = { species = "RATTATA", moves = {
{ id = "TACKLE" }, { id = "TAIL_WHIP" }, { id = "BITE" } } }
local player = { species = "CYNDAQUIL", moves = {
{ id = "EMBER" }, { id = "SMOKESCREEN" }, { id = "QUICK_ATTACK" },
{ id = "LEER" } } }
local battle = { wild = true, enemy = enemy, player = player,
party = { player }, enemyParty = { enemy } }
local s = UI.new({ data = data, options = {}, save = {} }, { battle = battle })
drain()
resetStats()
local played = {}
local realStereo = Sound.playStereo
Sound.playStereo = function(d, name)
played[#played + 1] = name
return realStereo(d, name)
end
for _, turn in ipairs({ { player, "player" }, { enemy, "enemy" } }) do
for _, move in ipairs(turn[1].moves) do
if s:animForMove(move.id, turn[2]) then
for _ = 1, 2000 do
if not s.anim:step() then break end
end
end
s:playHitSound(10)
end
end
Sound.playStereo = realStereo
check(#played >= 5, "the turn's anims played their sounds (" .. #played .. ")")
eq(mainRenders, 0, "zero main-thread ChipSynth renders during the turn")
local stats = ChipAudio.stats()
eq(stats.syncRenders, 0, "zero main-thread sfx renders during the turn"
.. (stats.syncRenderLast and (" (last " .. stats.syncRenderLast .. ")") or ""))
check((stats.prewarmHits or 0) > 0, "plays took prewarmed PCM")
check(stats.line and stats.line:find("sync=0/", 1, true) ~= nil,
"stats.line carries the sync counters")
resetStats()
check(Sound.play(data, "Sfx_GetTm") ~= nil, "cold Sfx_GetTm plays")
eq(ChipAudio.stats().syncRenders, 1, "a cold play is counted as a sync render")
check((ChipAudio.stats().syncRenderMsMax or 0) > 0, "its render time is recorded")
local Game2 = require("src.core.Game2")
Game2.prewarmSessionSfx({ data = data })
drain()
local function mon(species, moves)
local m = { species = species, moves = {} }
for _, id in ipairs(moves) do m.moves[#m.moves + 1] = { id = id } end
return m
end
local party = {
mon("TYPHLOSION", { "FLAMETHROWER", "THUNDERPUNCH", "EARTHQUAKE", "SWIFT" }),
mon("FERALIGATR", { "SURF", "ICE_PUNCH", "CRUNCH", "SLASH" }),
mon("MEGANIUM", { "RAZOR_LEAF", "BODY_SLAM", "SYNTHESIS", "REFLECT" }),
mon("AMPHAROS", { "THUNDER", "FIRE_PUNCH", "THUNDER_WAVE", "LIGHT_SCREEN" }),
mon("PIDGEOT", { "FLY", "WING_ATTACK", "MIRROR_MOVE", "QUICK_ATTACK" }),
mon("GYARADOS", { "HYDRO_PUMP", "HYPER_BEAM", "DRAGON_RAGE", "BITE" }),
}
local enemyParty = {
mon("ALAKAZAM", { "PSYCHIC", "RECOVER", "REFLECT", "FUTURE_SIGHT" }),
mon("EXEGGUTOR", { "SOLARBEAM", "PSYCHIC", "SLEEP_POWDER", "EXPLOSION" }),
mon("SLOWBRO", { "SURF", "AMNESIA", "PSYCHIC", "CURSE" }),
mon("JYNX", { "ICE_BEAM", "LOVELY_KISS", "PSYCHIC", "DREAM_EATER" }),
mon("MR__MIME", { "BARRIER", "PSYBEAM", "SUBSTITUTE", "ENCORE" }),
mon("XATU", { "PSYCHIC", "FLY", "CONFUSE_RAY", "NIGHT_SHADE" }),
}
local trainer = { player = party[1], enemy = enemyParty[1],
party = party, enemyParty = enemyParty }
local names = UI.battleSfxNames(data, anims, trainer)
check(#names + #Game2.SESSION_SFX > 64, "6v6 list plus session set exceeds the cap ("
.. #names .. " + " .. #Game2.SESSION_SFX .. ")")
for _, name in ipairs(names) do Sound.prewarmSfx(data, name) end
drain()
for _, name in ipairs({ "Sfx_KeyItem", "Sfx_ReadText2", "Sfx_Menu", "Sfx_Save" }) do
resetStats()
check(Sound.play(data, name) ~= nil, name .. " plays")
eq(mainRenders, 0, name .. " stays prewarmed after a 6v6 battle prewarm")
end
S.finish()
@@ -25,6 +25,7 @@ local restartCalls = 0
love.system = love.system or {}
love.system.getOS = function() return osName end
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
_G.POKEPORT_LOOP_RESTART = true
HostShell.restart()
eq(restartCalls, 0, "Android never schedules the blocked restartApp relaunch")
@@ -0,0 +1,85 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
_G.love = require("tests.love_stub")
local spawns = 0
package.loaded["src.core.WinApi"] = {
spawn = function() spawns = spawns + 1 return true end,
dirOf = function(p) return p:match("^(.*)\\") or p end,
modulePath = function() return "C:\\Games\\gen1recomp.exe" end,
}
package.loaded["src.core.SessionLifecycle"] = setmetatable({}, {
__index = function() return function() end end,
})
love.system.getOS = function() return "Windows" end
love.filesystem.isFused = function() return true end
love.filesystem.getSource = function() return "C:\\Games\\gen1recomp.exe" end
local queue = {}
love.event = love.event or {}
love.event.quit = function(...) queue[#queue + 1] = { n = select("#", ...), arg = (...) } end
local loaded, err = pcall(dofile, "main.lua")
check(loaded, "main.lua loads headless: " .. tostring(err))
local HostShell = require("src.core.HostShell")
local function upvalue(fn, name)
for i = 1, 200 do
local n = debug.getupvalue(fn, i)
if n == nil then return nil end
if n == name then return i end
end
end
local function setLocal(name, value)
local fn = love.quit
local i = upvalue(fn, name)
if i then
debug.setupvalue(fn, i, value)
return true
end
local ret = upvalue(fn, "returnToLauncher")
local _, rtl = debug.getupvalue(fn, ret)
i = upvalue(rtl, name)
if i then debug.setupvalue(rtl, i, value) return true end
return false
end
local function runQuits()
local exited = false
while #queue > 0 and not exited do
table.remove(queue, 1)
if not love.quit() then exited = true end
end
return exited
end
local function reset()
spawns = 0
for i = #queue, 1, -1 do queue[i] = nil end
HostShell.restarting = false
setLocal("quitToLauncher", false)
setLocal("processEnded", false)
love.filesystem.remove("relaunch_to_launcher.txt")
end
reset()
check(setLocal("Game", {}), "a game session is on screen")
HostShell.restart()
eq(spawns, 1, "restartWithMods spawns the new process")
check(runQuits(), "the old process exits")
eq(spawns, 1, "and love.quit does not spawn a second instance")
check(love.filesystem.getInfo("relaunch_to_launcher.txt") ~= nil,
"the relaunch still lands in the launcher")
reset()
setLocal("Game", {})
love.event.quit()
check(runQuits(), "closing a game window ends this process")
eq(spawns, 1, "and relaunches the launcher exactly once")
T.finish("hostshell_restart_single_spawn_2740")
@@ -0,0 +1,175 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.data = love.data or {}
love.data.hash = function(_, data) return "h" .. #data .. ":" .. data:sub(1, 24) end
love.data.encode = function(_, _, digest) return (digest:gsub(".", function(c)
return ("%02x"):format(c:byte())
end)) end
local SaveData = require("src.core.SaveData")
local Serializer = require("src.core.SaveSerializer")
local function disk()
return Serializer.decode(love.filesystem.read("options.lua") or "") or {}
end
local function externalWrite(patch)
local tree = disk()
for k, v in pairs(patch) do tree[k] = v end
love.filesystem.write("options.lua", Serializer.encode(tree))
end
SaveData.saveOptions(SaveData.defaultOptions())
local stale = SaveData.loadOptions()
externalWrite({ autoReimport = true, splashVideo = false, themeVideoBg = false,
reduceMotion = true })
stale.textSpeed = 1
SaveData.saveLiveOptions({ version = "red", options = stale })
local after = disk()
eq(after.autoReimport, true, "Gen 1 in-game save keeps autoReimport")
eq(after.splashVideo, false, "Gen 1 in-game save keeps Splash Video OFF")
eq(after.themeVideoBg, false, "Gen 1 in-game save keeps Theme Video BG OFF")
eq(after.reduceMotion, true, "Gen 1 in-game save keeps Reduce Motion")
eq(after.textSpeed, 1, "and the in-game change still lands")
local Game3 = require("src.core.Game3")
SaveData.saveOptions(SaveData.defaultOptions())
local stale3 = SaveData.loadOptions()
externalWrite({ autoReimport = true, splashVideo = false, themeVideoBg = false })
stale3.musicVol = 3
Game3.writeOptions({ options = stale3 })
after = disk()
eq(after.autoReimport, true, "Gen 3 writeOptions keeps autoReimport")
eq(after.splashVideo, false, "Gen 3 writeOptions keeps Splash Video OFF")
eq(after.themeVideoBg, false, "Gen 3 writeOptions keeps Theme Video BG OFF")
eq(after.musicVol, 3, "and the Gen 3 change still lands")
local LauncherSettings = require("src.import.LauncherSettings")
local function findRow(model, label)
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do
if row.label == label then return row end
end
end
end
SaveData.saveOptions({ lastVersion = "red", splashVideo = true, reduceMotion = false })
local model = LauncherSettings.open(nil, "red")
externalWrite({ lastVersion = "blue", autoReimport = true })
local splash = findRow(model, "Splash Video")
check(splash.step(), "Splash Video row steps")
model.save()
after = disk()
eq(after.splashVideo, false, "the settings panel writes its own change")
eq(after.lastVersion, "blue", "and keeps a launcher key another writer changed")
eq(after.autoReimport, true, "and keeps autoReimport another writer set")
local realWrite = love.filesystem.write
love.filesystem.write = function(name, ...)
if tostring(name):match("^options%.lua") then return false, "sharing violation" end
return realWrite(name, ...)
end
local motion = findRow(model, "Reduce Motion")
eq(motion.value(), "OFF", "Reduce Motion starts OFF")
motion.step()
local saved = model.save()
love.filesystem.write = realWrite
check(saved == false, "a failed options write reports failure")
eq(motion.value(), "OFF", "and the row shows what the file holds, not the lost value")
check(type(model.saveError) == "string", "and the panel carries a visible save error")
eq(disk().reduceMotion, false, "the file still holds OFF")
local RomSources = require("src.import.RomSources")
local RomArchive = require("src.import.RomArchive")
local rom = ("R"):rep(1048576)
local romSha = love.data.encode("string", "hex", love.data.hash("sha1", rom))
local mounts = {}
local fs = love.filesystem
local baseMount, baseUnmount, baseItems, baseInfo, baseRead =
fs.mount, fs.unmount, fs.getDirectoryItems, fs.getInfo, fs.read
fs.newFileData = function(data, name) return { data = data, name = name } end
fs.mount = function(fd, point)
if type(point) == "string" and point:match("^_rom_") then
mounts[point] = fd
return true
end
return baseMount(fd, point)
end
fs.unmount = function(fd)
for point, m in pairs(mounts) do if m == fd then mounts[point] = nil return true end end
return baseUnmount(fd)
end
local function inner(path)
local point, rest = tostring(path):match("^(_rom_[%w_]+)/?(.*)$")
if not point or not mounts[point] then return nil end
return point, rest
end
fs.getDirectoryItems = function(path)
local point, rest = inner(path)
if point then
if rest == "" then
return { point:match("probe") and "probe" or "Pokemon Red.gb" }
end
return {}
end
return baseItems(path)
end
fs.getInfo = function(path, ...)
local point, rest = inner(path)
if point then
if rest == "" then return { type = "directory" } end
return { type = "file", size = rest == "probe" and 2 or #rom }
end
return baseInfo(path, ...)
end
fs.read = function(path, ...)
local point, rest = inner(path)
if point then return rest == "probe" and "ok" or rom end
return baseRead(path, ...)
end
RomArchive._resetForTests()
local tmp = os.tmpname()
local zipPath = tmp .. "-red.zip"
local f = assert(io.open(zipPath, "wb"))
f:write("PK\3\4" .. ("z"):rep(4096))
f:close()
RomSources.remember("red", { sha1 = romSha, path = zipPath, at = 1 })
local cand = RomSources.candidate("red", false)
check(cand ~= nil and not cand.pick and cand.path == zipPath,
"a zip source whose inner ROM matches the record is a re-import candidate")
local rawPath = tmp .. "-red.gb"
f = assert(io.open(rawPath, "wb"))
f:write(rom)
f:close()
RomSources.remember("red", { sha1 = romSha, path = rawPath, at = 1 })
cand = RomSources.candidate("red", false)
check(cand ~= nil and not cand.pick and cand.path == rawPath, "a raw source still matches")
os.remove(rawPath)
cand = RomSources.candidate("red", false)
check(cand ~= nil and cand.pick == true, "a moved or deleted source asks for the ROM instead of nothing")
eq(cand and cand.missing, "missing", "and says the file is gone")
f = assert(io.open(rawPath, "wb"))
f:write(("X"):rep(1048576))
f:close()
cand = RomSources.candidate("red", false)
check(cand ~= nil and cand.pick == true and cand.missing == "changed",
"a source that no longer matches asks for the ROM too")
os.remove(rawPath)
os.remove(zipPath)
os.remove(tmp)
T.finish("launcher_settings_persist_2740")
@@ -0,0 +1,220 @@
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
love = require("tests.love_stub")
local T = require("tests.harness")
local bit = require("bit")
local Version = require("src.core.GameVersion")
local Serializer = require("src.core.SaveSerializer")
local K = require("tests.save_compat._codec")
local D = require("tests.save_compat._gen3_decode")
local G3 = require("tests.fixtures.save.gen3_build")
local G2 = require("tests.fixtures.save.gen2_build")
local Gen2Save = require("src.save_convert.Gen2Save")
local PokemonG3 = require("src.core.game3.pokemon")
local Unown = require("src.core.gen2.Unown")
local Gen, Ops, MonOps = require("Gen"), require("Ops"), require("MonOps")
local State = require("State")
local Legality = require("Legality")
local function shinyOf(p, tid, sid)
return bit.bxor(bit.bxor(tid, sid), bit.bxor(math.floor(p / 65536), p % 65536)) < 8
end
do
local rng = 2726
local function rand(n)
rng = (rng * 1103515245 + 12345) % 2147483648
return rng % n
end
local bad = 0
for _ = 1, 120 do
local pid = rand(65536) * 65536 + rand(65536)
local tid, sid = rand(65536), rand(65536)
local nature = pid % 25
for letter = 0, 27 do
for _, shiny in ipairs({ false, true }) do
local p = MonOps.unownPid(pid, letter, nature, shiny, tid, sid)
if not (p and PokemonG3.unownLetter(p) == letter and p % 25 == nature and shinyOf(p, tid, sid) == shiny) then
bad = bad + 1
if bad <= 5 then
T.check(false, ("unownPid pid=%08x tid=%d sid=%d letter=%d shiny=%s -> %s")
:format(pid, tid, sid, letter, tostring(shiny), tostring(p)))
end
end
end
end
end
T.eq(bad, 0, "gen3 unownPid keeps nature and shininess for every letter")
for tsv89 = 0, 3 do
for letter = 0, 27 do
local p = MonOps.unownPid(0x12345678, letter, 7, true, tsv89 * 256, 0)
T.check(p and PokemonG3.unownLetter(p) == letter, ("shiny letter %d reachable with tsv bits %d"):format(letter, tsv89))
end
end
local rewritten = 0
for _ = 1, 300 do
local pid = rand(65536) * 65536 + rand(65536)
local tid, sid = rand(65536), rand(65536)
if MonOps.unownPid(pid, PokemonG3.unownLetter(pid), pid % 25, shinyOf(pid, tid, sid), tid, sid) ~= pid then
rewritten = rewritten + 1
end
local low = rand(65536)
local spid = bit.bxor(low, bit.bxor(tid, sid), rand(8)) * 65536 + low
if MonOps.unownPid(spid, PokemonG3.unownLetter(spid), spid % 25, true, tid, sid) ~= spid then
rewritten = rewritten + 1
end
end
T.eq(rewritten, 0, "gen3 unownPid leaves a PID that already fits unchanged (plain and shiny)")
end
local function g3state(version, mons)
Version.set(version)
local w = G3.base(version)
G3.setParty(w, mons)
local s = State.new()
s.save, s.version = assert(K.import(3, version, G3.emit(w))), version
s.save.pcItems = s.save.pcItems or {}
Gen.ensureBoxes(s.save)
return s
end
local UNOWN_F = 0x00000105
assert(PokemonG3.unownLetter(UNOWN_F) == 5)
do
local s = g3state("firered", { G3.mon({ species = 201, pid = UNOWN_F, tid = 0x1234, sid = 0x5678 }) })
local mon = s.save.party[1]
T.eq(PokemonG3.unownLetter(mon.personality), 5, "fixture unown is F")
local natureBefore = mon.personality % 25
MonOps.setNature(s.data, mon, (natureBefore + 3) % 25, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setNature keeps unown letter")
T.eq(mon.personality % 25, (natureBefore + 3) % 25, "setNature still sets the nature")
MonOps.setShiny(s.data, mon, true, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setShiny(true) keeps unown letter")
T.check(shinyOf(mon.personality, 0x1234, 0x5678), "setShiny(true) makes the PID shiny")
MonOps.setShiny(s.data, mon, false, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setShiny(false) keeps unown letter")
T.check(not shinyOf(mon.personality, 0x1234, 0x5678), "setShiny(false) clears shiny")
MonOps.setAbility(s.data, mon, 0, 3)
T.eq(PokemonG3.unownLetter(mon.personality), 5, "setAbility keeps unown letter")
local before = mon.personality
MonOps.setAbility(s.data, mon, 0, 3)
T.eq(mon.personality, before, "setAbility to the current slot keeps the Unown PID")
MonOps.setShiny(s.data, mon, false, 3)
T.eq(mon.personality, before, "setShiny to the current state keeps the Unown PID")
MonOps.setNature(s.data, mon, before % 25, 3)
T.eq(mon.personality, before, "setNature to the current nature keeps the Unown PID")
end
for _, v in ipairs({ "firered", "emerald" }) do
local fam = v == "emerald" and "emerald" or "frlg"
local s = g3state(v, { G3.mon({ species = 201, pid = UNOWN_F, tid = 0x1234, sid = 0x5678, ability = 1,
ivs = { 31, 2, 17, 4, 25, 6 }, moves = { 237, 0, 0, 0 }, pp = { 15, 0, 0, 0 } }) })
local mon = s.save.party[1]
local before = assert(D.deep(assert(K.export(3, v, Serializer.decode(Serializer.encode(s.save)), nil)), fam)).party[1]
T.eq(Ops.setUnownForm(s, mon, 27), true, v .. " setUnownForm accepted ?")
T.eq(s.status, "UNOWN is now ?", v .. " status names the form")
T.eq(#s.undoStack, 1, v .. " one history entry")
T.eq(PokemonG3.unownLetter(mon.personality), 27, v .. " editor mon is ?")
local out = assert(K.export(3, v, Serializer.decode(Serializer.encode(s.save)), nil))
local after = assert(D.deep(out, fam)).party[1]
T.check(after.checksumOk, v .. " substruct checksum valid after PID change")
T.eq(after.species, 201, v .. " species decrypts as UNOWN")
T.eq(PokemonG3.unownLetter(after.pid), 27, v .. " written PID spells ?")
T.eq(after.pid % 25, UNOWN_F % 25, v .. " nature kept")
T.eq(after.shiny, false, v .. " still not shiny")
T.eq(after.ability, 1, v .. " abilityNum bit kept")
T.eq(table.concat(after.ivs, ","), table.concat(before.ivs, ","), v .. " IVs kept")
T.eq(table.concat(after.moves, ","), table.concat(before.moves, ","), v .. " moves kept")
T.eq(after.exp, before.exp, v .. " exp kept")
local back = assert(K.import(3, v, out))
T.eq(PokemonG3.unownLetter(back.party[1].personality), 27, v .. " re-import reads ?")
T.eq(Ops.setUnownForm(s, mon, 27), false, v .. " same form is a no-op")
end
do
local s = g3state("firered", { G3.mon({ species = 4 }) })
T.eq(Ops.setUnownForm(s, s.save.party[1], 3), false, "non-unown refuses a form")
T.eq(s.status, "Only an Unown has a form", "and says why")
end
local UNOWN_DEF = {
id = "UNOWN", name = "UNOWN", dex = 201, index = 201, types = { "PSYCHIC" },
baseStats = { hp = 48, attack = 72, defense = 48, speed = 48, specialAttack = 72, specialDefense = 48 },
catchRate = 225, baseExp = 61, growthRate = "MEDIUM_FAST", genderRatio = 255,
}
local g2data = {
pokemon = { UNOWN = UNOWN_DEF, CYNDAQUIL = { id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155 } },
items = K.gen2Data.items, maps = K.gen2Data.maps, moves = {},
}
local function g2state(version, dvs)
Version.set(version)
local bytes = G2.build({ version = version, party = { G2.mon({ species = 201, nick = "UNOWN", dvs = dvs }) } })
local s = State.new()
s.data = g2data
s.save, s.version = assert(Gen2Save.decode(bytes, version, g2data)), version
Gen.ensureBoxes(s.save)
return s
end
local function dvsOf(t) return { attack = t[1], defense = t[2], speed = t[3], special = t[4] } end
for _, v in ipairs({ "gold", "crystal" }) do
local start = { 9, 4, 13, 6 }
for letter = 1, 26 do
local s = g2state(v, start)
local mon = s.save.party[1]
T.eq(mon.species, "UNOWN", v .. " fixture decodes as UNOWN")
local hp = mon.dvs.hp
local ok = Ops.setUnownForm(s, mon, letter)
if letter == Unown.letterFromDVs(dvsOf(start)) then
T.eq(ok, false, v .. " setting the current letter is a no-op")
else
T.eq(ok, true, v .. " set letter " .. letter)
end
T.eq(Unown.letterFromDVs(mon.dvs), letter, v .. " DVs spell letter " .. letter)
T.eq(Unown.monLetter(mon), letter, v .. " stored letter " .. letter)
T.eq(mon.dvs.hp, hp, v .. " HP DV kept for " .. letter)
for i, k in ipairs({ "attack", "defense", "speed", "special" }) do
T.eq(mon.dvs[k] % 2, start[i] % 2, v .. " bit 0 of " .. k .. " kept")
T.eq(math.floor(mon.dvs[k] / 8), math.floor(start[i] / 8), v .. " bit 3 of " .. k .. " kept")
end
local report = Legality.mon(s, mon)
local issues = Legality.highlights(report, mon)
T.check(issues.fields.form == nil and issues.fields.shiny == nil, v .. " no form/shiny issue for " .. letter)
local out = assert(Gen2Save.encode(s.save, v, nil, g2data))
local L = Gen2Save.layoutFor(v)
T.eq(Gen2Save.checksumValid(out, L), true, v .. " checksum valid for " .. letter)
local dvHi, dvLo = out:byte(L.wPartyMons + 0x15 + 1), out:byte(L.wPartyMons + 0x16 + 1)
T.eq(Unown.letterFromDVs(dvsOf({ math.floor(dvHi / 16), dvHi % 16, math.floor(dvLo / 16), dvLo % 16 })), letter,
v .. " DV bytes at +0x15 spell " .. letter)
local back = assert(Gen2Save.decode(out, v, g2data))
T.eq(Unown.monLetter(back.party[1]), letter, v .. " re-read letter " .. letter)
end
local s = g2state(v, { 2, 10, 10, 10 })
local mon = s.save.party[1]
T.eq(Unown.monLetter(mon), 9, v .. " shiny fixture is I")
T.eq(require("src.battle.gen2.Mon").vanillaShiny(mon.dvs), true, v .. " and shiny")
Ops.setUnownForm(s, mon, 22)
T.eq(Unown.monLetter(mon), 22, v .. " shiny I -> V")
T.eq(mon.shiny, true, v .. " V stays shiny")
Ops.setUnownForm(s, mon, 1)
T.eq(Unown.monLetter(mon), 1, v .. " shiny V -> A")
T.eq(mon.shiny, false, v .. " A is not shiny")
T.eq(s.status, "UNOWN is now A (no longer shiny: only I and V can be shiny)", v .. " status warns about shininess")
local out, why = Gen2Save.encode(s.save, v, nil, g2data)
T.check(out ~= nil, v .. " a former shiny now A still writes: " .. tostring(why))
for _, letter in ipairs({ 9, 22 }) do
local s2 = g2state(v, { 9, 8, 12, 14 })
local m2 = s2.save.party[1]
T.eq(require("src.battle.gen2.Mon").vanillaShiny(m2.dvs), false, v .. " plain fixture not shiny")
Ops.setUnownForm(s2, m2, letter)
T.eq(Unown.monLetter(m2), letter, v .. " plain -> " .. letter)
T.eq(m2.shiny, false, v .. " plain never becomes shiny at " .. letter)
end
end
T.finish()
+31 -17
View File
@@ -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(
+6
View File
@@ -4,6 +4,7 @@ tests/engine/amnesia_rest_share_anim_bug1881.lua
tests/engine/android_asymmetric_display.lua
tests/engine/android_exit_to_launcher_test.lua
tests/engine/android_host_extension_test.lua
tests/engine/android_old_shell_restart_bug2738.lua
tests/engine/android_secondary_present.lua
tests/engine/android_shortcuts_payload_test.lua
tests/engine/applying_attack_sfx_wait_bug1998.lua
@@ -114,7 +115,9 @@ tests/engine/game3_em_bike_doors_2637.lua
tests/engine/game3_em_catch_prompt_2637.lua
tests/engine/game3_frontier_obedience_2638.lua
tests/engine/game3_post_dex_background_2637.lua
tests/engine/game3_rs_anim_names_test.lua
tests/engine/game3_rs_bag_cursor_width_2709.lua
tests/engine/game3_rs_bag_row_layout_2727.lua
tests/engine/game3_rs_item_use_text_2699_test.lua
tests/engine/game3_rs_starter_choose_scale_2703.lua
tests/engine/game3_rs_summary_memo_location_2707.lua
@@ -389,6 +392,7 @@ tests/engine/host_shell_bridge_request.lua
tests/engine/host_shell_fetch_errors.lua
tests/engine/host_shell_postlog.lua
tests/engine/host_shell_request_headers.lua
tests/engine/hostshell_restart_single_spawn_2740.lua
tests/engine/hostshell_spawn_self.lua
tests/engine/hostshell_win_http_nonascii.lua
tests/engine/hostshell_win_restart_2721.lua
@@ -446,6 +450,7 @@ tests/engine/launcher_page_scroll.lua
tests/engine/launcher_patch_notes.lua
tests/engine/launcher_return_pointer_bug2079.lua
tests/engine/launcher_secret_emerald_test.lua
tests/engine/launcher_settings_persist_2740.lua
tests/engine/launcher_skins_options_reads_bug2038.lua
tests/engine/launcher_skins_tab.lua
tests/engine/launcher_skins_ux.lua
@@ -620,6 +625,7 @@ tests/engine/save_convert_toggle_objects.lua
tests/engine/save_editor_berry_powder_2698.lua
tests/engine/save_editor_buena_points_2640.lua
tests/engine/save_editor_custom_maps.lua
tests/engine/save_editor_unown_form_2726.lua
tests/engine/save_export_map_window_bug1691.lua
tests/engine/save_export_portable_bug752.lua
tests/engine/save_export_same_map_moved_2468.lua
+312
View File
@@ -2,21 +2,32 @@
"""Generate src/import/gba/anim_names_<game>.lua for an RSE-family pret build.
usage: tools/gen_anim_names.py --game emerald [--pret ../pokeemerald] [--ref ../pokefirered] [--check]
tools/gen_anim_names.py --game ruby [--pret ../pokeruby] [--ref ../pokeemerald] [--check]
"""
import argparse
import difflib
import hashlib
import importlib.util
import os
import re
import sys
from collections import Counter
ROM_LO = 0x08000000
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GAMES = {
"emerald": {"repo": "pokeemerald", "elf": "pokeemerald.elf", "rom": "pokeemerald.gba"},
"ruby": {"repo": "pokeruby", "elf": "pokeruby.elf", "rom": "pokeruby.gba"},
}
REF = {"repo": "pokefirered", "elf": "pokefirered.elf", "rom": "pokefirered.gba"}
RS_REF = {"repo": "pokeemerald", "elf": "pokeemerald.elf", "rom": "pokeemerald.gba"}
RS_NATIVE_GLOB = ("rs_callbacks.lua", "rs_early_callbacks.lua", "rs_tasks.lua", "rs_sound_tasks.lua")
PTR_FIELDS = {
0x02: ((1, "template"),), 0x03: ((1, "task"),), 0x1F: ((1, "task"),),
0x0E: ((1, "branch"),), 0x13: ((1, "branch"),), 0x24: ((1, "branch"),),
0x21: ((4, "branch"),), 0x12: ((2, "branch"),), 0x11: ((1, "branch"), (5, "branch")),
}
FIXED = {
0x00: 3, 0x01: 3, 0x04: 2, 0x05: 1, 0x06: 1, 0x07: 1, 0x08: 1, 0x09: 3,
@@ -247,6 +258,305 @@ def render_indexed(name, d, indent=" "):
return rows
def decode_ops(bd, o, errors):
ops, pos = [], o
for _ in range(8192):
op = bd.u8(pos)
n = op_len(bd, pos)
raw = bytearray(bd.b[pos:pos + n])
ptrs = []
for at, kind in PTR_FIELDS.get(op, ()):
ptrs.append((kind, bd.u32(pos + at)))
raw[at:at + 4] = b"\0\0\0\0"
ops.append((bytes(raw), ptrs))
if op in (0x08, 0x0F, 0x13):
return ops
pos += n
errors.append("script at 0x%X did not terminate" % o)
return ops
def parse_lua_maps(path, sections):
out, cur = {s: {} for s in sections}, None
with open(path) as f:
for line in f:
m = re.match(r"^ (\w+) = \{$", line)
if m:
cur = m.group(1) if m.group(1) in out else None
continue
if line.startswith(" }"):
cur = None
continue
if cur:
m = re.match(r'^ (?:\["([^"]+)"\]|(\w+)) = "([^"]*)",$', line)
if m:
out[cur][m.group(1) or m.group(2)] = m.group(3)
return out
def native_names():
names = set()
base = os.path.join(ROOT, "src", "core", "game3", "battle", "anim_port")
for fn in RS_NATIVE_GLOB:
with open(os.path.join(base, fn)) as f:
for m in re.finditer(r"\b(sub_[0-9A-Fa-f]{7})\s*=", f.read()):
names.add(m.group(1))
return names
def status_names(pret, count):
out = {}
with open(os.path.join(pret, "include", "constants", "battle.h")) as f:
for l in f:
m = re.match(r"#define\s+B_ANIM_(STATUS_\w+)\s+(0x[0-9A-Fa-f]+|\d+)", l)
if m and int(m.group(2), 0) < count:
out[int(m.group(2), 0)] = m.group(1)
return out
def best(counter):
top = counter.most_common(2)
if len(top) > 1 and top[0][1] == top[1][1]:
return None
return top[0][0]
def main_rs(args):
info = GAMES[args.game]
pret = args.pret or os.path.join(os.path.dirname(ROOT), info["repo"])
refp = args.ref or os.path.join(os.path.dirname(ROOT), RS_REF["repo"])
out = args.out or os.path.join(ROOT, "src", "import", "gba", "rs", "anim_names.lua")
symmod = load_syms_module()
rs = Build(pret, info, symmod)
em = Build(refp, RS_REF, symmod)
errors = []
em_names = parse_lua_maps(os.path.join(ROOT, "src", "import", "gba", "anim_names_emerald.lua"),
("templates", "callbacks", "tasks"))
rs_tables, em_tables = table_labels(pret), table_labels(refp)
roots, queue, counts = [], [], {}
for key, tname in TABLES:
rbase, ebase = rs.sym_off(tname), em.sym_off(tname)
rn = len([l for l, _ in rs_tables[tname] if l != "Move_COUNT"])
en = len([l for l, _ in em_tables[tname] if l != "Move_COUNT"])
counts[key] = rn
for i in range(rn):
ro = rs.off(rs.u32(rbase + i * 4))
if ro is None:
errors.append("%s[%d] bad pointer" % (tname, i))
continue
roots.append(ro)
if i < en:
eo = em.off(em.u32(ebase + i * 4))
if eo is not None:
queue.append((ro, eo))
tmpl_ptrs, task_ptrs = walk(rs, roots, errors)
def func_size(bd, ptr):
r = bd.func_row(ptr)
return r and r["size"]
def close(x, y):
return x is not None and y is not None and abs(x - y) <= max(8, max(x, y) // 10)
def same_size(pa, pb):
if pa[0] == "template":
ra, rb = rs.data_row(pa[1]), em.data_row(pb[1])
if not ra or not rb:
return False
return close(func_size(rs, rs.u32(ra["off"] + 20)), func_size(em, em.u32(rb["off"] + 20)))
return close(func_size(rs, pa[1]), func_size(em, pb[1]))
votes = {"template": {}, "task": {}}
seen, drift = set(), 0
while queue:
pair = queue.pop()
if pair in seen:
continue
seen.add(pair)
a, b = decode_ops(rs, pair[0], errors), decode_ops(em, pair[1], errors)
ta, tb = [x[0] for x in a], [x[0] for x in b]
if ta != tb:
drift += 1
sm = difflib.SequenceMatcher(None, ta, tb, autojunk=False)
matched, loose_a, loose_b = [], [], []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal" or (tag == "replace" and i2 - i1 == j2 - j1
and all(ta[i1 + k][0] == tb[j1 + k][0] for k in range(i2 - i1))):
matched += [(i1 + k, j1 + k) for k in range(i2 - i1)]
else:
loose_a += [i for i in range(i1, i2) if ta[i][0] in (0x02, 0x03, 0x1F)]
loose_b += [j for j in range(j1, j2) if tb[j][0] in (0x02, 0x03, 0x1F)]
ca, cb = Counter(ta[i] for i in loose_a), Counter(tb[j] for j in loose_b)
matched += [(i, j) for i in loose_a for j in loose_b if ta[i] == tb[j] and ca[ta[i]] == 1 and cb[tb[j]] == 1
and same_size(a[i][1][0], b[j][1][0])]
for ia, ib in matched:
for (ka, pa), (_, pb) in zip(a[ia][1], b[ib][1]):
if ka == "branch":
ro, eo = rs.off(pa), em.off(pb)
if ro is not None and eo is not None:
queue.append((ro, eo))
else:
votes[ka].setdefault(pa, Counter())[pb] += 1
natives = native_names()
ambiguous, resized = [], []
def rs_func_key(r):
return rs.func_key(r) if r.get("obj") else r["name"]
def em_func_canon(ptr, section):
r = em.func_row(ptr)
return r and em_names[section].get(em.func_key(r))
tasks, task_src = {}, {}
for ptr in sorted(task_ptrs):
r = rs.func_row(ptr)
if not r:
errors.append("task pointer 0x%08X has no ELF function" % ptr)
continue
key = rs_func_key(r)
canon = None
if ptr in votes["task"] and r["name"] not in natives:
eptr = best(votes["task"][ptr])
if eptr is None:
ambiguous.append("task %s: %s" % (key, ", ".join(
"%s x%d" % (em.func_row(p)["name"], c) for p, c in votes["task"][ptr].most_common())))
else:
canon = em_func_canon(eptr, "tasks")
if canon and func_size(rs, ptr) != func_size(em, eptr):
resized.append("task %s %d -> %s %d" % (key, func_size(rs, ptr), canon, func_size(em, eptr)))
tasks[key] = canon or short_task(r["name"])
task_src[key] = canon is not None
cb_votes, tmpl_canon = {}, {}
templates, callbacks, cb_src = {}, {}, {}
for ptr in sorted(tmpl_ptrs):
r = rs.data_row(ptr)
if not r:
errors.append("template pointer 0x%08X has no ELF symbol" % ptr)
continue
key = rs.data_key(r) if r.get("obj") else r["name"]
cbptr = rs.u32(r["off"] + 20)
cbr = rs.func_row(cbptr)
if not cbr:
errors.append("template %s callback 0x%08X has no ELF function" % (key, cbptr))
continue
eptr = None
if ptr in votes["template"]:
eptr = best(votes["template"][ptr])
if eptr is None:
ambiguous.append("template %s: %s" % (key, ", ".join(
"%s x%d" % (em.data_row(p)["name"], c) for p, c in votes["template"][ptr].most_common())))
if eptr is not None:
er = em.data_row(eptr)
tmpl_canon[key] = (em_names["templates"].get(em.data_key(er)), sum(votes["template"][ptr].values()))
ecb = em.u32(er["off"] + 20)
cb_votes.setdefault(cbptr, Counter())[ecb] += votes["template"][ptr][eptr]
templates[key] = key
callbacks.setdefault(rs_func_key(cbr), None)
by_canon = {}
for key, (canon, n) in tmpl_canon.items():
if canon:
by_canon.setdefault(canon, []).append((n, key))
shared, tmpl_src = [], set()
for canon, rows in by_canon.items():
rows.sort(key=lambda x: (-x[0], x[1]))
if canon in templates and canon not in [k for _, k in rows]:
shared.append("%s (RS template of that name exists): %s" % (canon, ", ".join(k for _, k in rows)))
continue
templates[rows[0][1]] = canon
tmpl_src.add(rows[0][1])
if len(rows) > 1:
shared.append("%s <- %s; kept RS name for %s" % (canon, rows[0][1], ", ".join(k for _, k in rows[1:])))
for ptr in sorted({rs.u32(rs.data_row(p)["off"] + 20) for p in tmpl_ptrs if rs.data_row(p)}):
r = rs.func_row(ptr)
if not r:
continue
key = rs_func_key(r)
canon = None
if ptr in cb_votes and r["name"] not in natives:
eptr = best(cb_votes[ptr])
if eptr is None:
ambiguous.append("callback %s: %s" % (key, ", ".join(
"%s x%d" % (em.func_row(p)["name"], c) for p, c in cb_votes[ptr].most_common())))
else:
canon = em_func_canon(eptr, "callbacks")
if canon and func_size(rs, ptr) != func_size(em, eptr):
resized.append("callback %s %d -> %s %d" % (key, func_size(rs, ptr), canon, func_size(em, eptr)))
callbacks[key] = canon or short_cb(r["name"])
cb_src[key] = canon is not None
tags = tag_names(pret)
if sorted(tags) != list(range(len(tags))):
errors.append("ANIM_TAG_ indices are not contiguous")
status = status_names(pret, counts["status"])
shared_names = {}
for key, tname in TABLES[2:]:
lst = [re.match(r"B_ANIM_(\w+)", c) for _, c in em_tables[tname]]
if len(lst) != counts[key] or not all(lst):
errors.append("%s does not line up with %s" % (tname, RS_REF["repo"]))
shared_names[key] = dict(enumerate(m.group(1) for m in lst if m))
if sorted(status) != list(range(counts["status"])):
errors.append("B_ANIM_STATUS_ constants do not cover gBattleAnims_StatusConditions")
rs_only_cb = sorted(k for k, v in callbacks.items() if not cb_src.get(k) and k not in natives)
rs_only_task = sorted(k for k, v in tasks.items() if not task_src.get(k) and k not in natives)
rs_only_tmpl = sorted(k for k in templates if k not in tmpl_src)
print("%s: %d script roots, %d paired scripts (%d drifted), %d templates, %d callbacks, %d tasks" % (
args.game, len(roots), len(seen), drift, len(templates), len(callbacks), len(tasks)))
print("aligned: callbacks=%d tasks=%d templates=%d natives=%d" % (
sum(1 for v in cb_src.values() if v), sum(1 for v in task_src.values() if v),
len(templates) - len(rs_only_tmpl), len(natives)))
print("rs-only callbacks: %s" % (", ".join(rs_only_cb) or "-"))
print("rs-only tasks: %s" % (", ".join(rs_only_task) or "-"))
for s in ambiguous:
print(" ambiguous " + s)
for s in shared:
print(" shared " + s)
for s in resized:
print(" resized " + s)
if errors:
print("ERRORS (%d):" % len(errors))
for e in errors[:80]:
print(" " + e)
sys.exit(1)
lines = [
"-- tools/gen_anim_names.py --game ruby from %s, %s/data/battle_anim_scripts.s aligned with %s/data/battle_anim_scripts.s"
% (info["elf"], info["repo"], RS_REF["repo"]),
"return {",
' game = "rs",',
]
lines += render_map("templates", templates)
lines += render_map("callbacks", callbacks)
lines += render_map("tasks", tasks)
lines += [" rsOnly = {"]
lines += render_list("callbacks", rs_only_cb, " ")
lines += render_list("tasks", rs_only_task, " ")
lines += render_list("templates", rs_only_tmpl, " ")
lines += [" },"]
lines += render_indexed("tagNames", tags)
lines += render_indexed("statusNames", status)
lines += render_indexed("generalNames", shared_names["general"])
lines += render_indexed("specialNames", shared_names["special"])
lines += ["}", ""]
text = "\n".join(lines)
if args.check:
cur = open(out).read() if os.path.exists(out) else ""
if cur != text:
print("STALE %s" % out)
sys.exit(1)
print("OK %s" % out)
return
with open(out, "w") as f:
f.write(text)
print("wrote %s" % out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--game", default="emerald", choices=sorted(GAMES))
@@ -255,6 +565,8 @@ def main():
ap.add_argument("--out")
ap.add_argument("--check", action="store_true")
args = ap.parse_args()
if args.game == "ruby":
return main_rs(args)
info = GAMES[args.game]
pret = args.pret or os.path.join(os.path.dirname(ROOT), info["repo"])
+1
View File
@@ -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",
+4
View File
@@ -19724,6 +19724,10 @@
19,
21910
],
"TimeCapsule_CatchRateItems": [
10,
18309
],
"TimeFishGroups": [
36,
26223
+4
View File
@@ -17516,6 +17516,10 @@
5,
22206
],
"TimeCapsule_CatchRateItems": [
10,
18134
],
"TimeFishGroups": [
36,
27614
+4
View File
@@ -17516,6 +17516,10 @@
5,
22206
],
"TimeCapsule_CatchRateItems": [
10,
18134
],
"TimeFishGroups": [
36,
27614
+17 -4
View File
@@ -152,8 +152,16 @@ local function drawSection(S, Kit, x, y, w, h, report, issues)
cy = cy + row + 2 * gap
hint(id)
end
local function choice(id, label, value, options, apply)
cy = cy + Touch.choice(S, Kit, id, label, value, options, cx, cy, inner, apply, nil, issues.fields[id]) + 2 * gap
local function choice(id, label, value, options, apply, help)
cy = cy + Touch.choice(S, Kit, id, label, value, options, cx, cy, inner, apply, help, issues.fields[id]) + 2 * gap
end
local function unownForm(help)
local letter = Ops.unownForm(S, mon)
if letter == nil then return false end
choice("form", "Unown form", letter, Ops.unownFormOptions(S), function(v)
return Ops.setUnownForm(S, mon, v)
end, help)
return true
end
local function props(list)
for _, d in ipairs(list) do
@@ -308,13 +316,18 @@ local function drawSection(S, Kit, x, y, w, h, report, issues)
button("Shiny: " .. (shiny and "ON" or "OFF"), function()
Ops.setShiny(S, mon, not shiny)
end, "warn", "Changes shininess and personality. Review Checks after editing.", "shiny")
unownForm("Keeps nature and shininess. Changes the personality value.")
elseif g == 2 then
local d = P.find(S, "pokerus")
choice("pokerus", "Pokérus", mon.pokerus or 0, Named.property(S, d), function(v)
return Ops.setPokerus(S, mon, v)
end)
text("Gender, shininess and Unown form follow DVs.")
for _, id in ipairs({ "gender", "shiny", "form" }) do hint(id) end
local isUnown = unownForm("Rewrites the middle bits of the DVs. Only I and V can be shiny.")
text(isUnown and "Gender and shininess follow DVs. Picking a form rewrites the middle DV bits."
or "Gender and shininess follow DVs.")
hint("gender")
hint("shiny")
if not isUnown then hint("form") end
end
button("Fix all errors", function()
Ops.fixMonErrors(S, mon)
+139 -11
View File
@@ -126,12 +126,81 @@ function MonOps.recalc(data, mon, gen)
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
end
MonOps.SPECIES_UNOWN_G3 = 201
MonOps.UNOWN_FORMS_G3 = 28
local function spliceLetterBits(half, lo2, hi2)
local bit = require("bit")
return bit.bor(bit.band(half, 0xFCFC), lo2, hi2 * 256)
end
-- include/pokemon.h:364 GET_UNOWN_LETTER, src/pokemon.c:2318 CreateMonWithGenderNatureLetter
function MonOps.unownPid(pid, letter, nature, shiny, tid, sid)
local bit = require("bit")
local PokemonG3 = require("src.core.game3.pokemon")
pid = (tonumber(pid) or 0) % 4294967296
letter = math.floor(tonumber(letter) or 0) % MonOps.UNOWN_FORMS_G3
nature = nature ~= nil and tonumber(nature) or nil
local tsv = bit.bxor((tonumber(tid) or 0) % 65536, (tonumber(sid) or 0) % 65536)
local oldLow, oldHigh = pid % 65536, math.floor(pid / 65536)
local function accept(high, low)
local p = high * 65536 + low
if p == 0 then return nil end
if nature ~= nil and p % 25 ~= nature then return nil end
local isShiny = bit.bxor(tsv, bit.bxor(high, low)) < 8
if shiny ~= nil and isShiny ~= (shiny and true or false) then return nil end
if PokemonG3.unownLetter(p) ~= letter then return nil end
return p
end
local same = accept(oldHigh, oldLow)
if same then return same end
local tsv89 = math.floor(tsv / 256) % 4
for d = 0, 4095 do
local low0 = bit.bxor(oldLow, (d % 64) * 4 + math.floor(d / 64) * 1024)
if shiny then
for b1 = 0, 3 do
local b3 = bit.bxor(b1, tsv89)
for b0 = 0, 3 do
for b2 = 0, 3 do
if (b3 * 64 + b2 * 16 + b1 * 4 + b0) % MonOps.UNOWN_FORMS_G3 == letter then
local low = spliceLetterBits(low0, b0, b1)
local base = bit.bxor(low, tsv)
for k2 = 0, 1 do
local high = bit.bor(bit.band(bit.bxor(base, k2 * 4), 0xFFFC), b2)
local p = accept(high, low)
if p then return p end
end
end
end
end
end
else
for v = letter, 255, MonOps.UNOWN_FORMS_G3 do
local b0, b1 = v % 4, math.floor(v / 4) % 4
local b2, b3 = math.floor(v / 16) % 4, math.floor(v / 64)
local p = accept(spliceLetterBits(oldHigh, b2, b3), spliceLetterBits(low0, b0, b1))
if p then return p end
end
end
end
return nil
end
local function unownReq(mon)
if tonumber(mon.speciesId or mon.species) ~= MonOps.SPECIES_UNOWN_G3 then return nil end
return require("src.core.game3.pokemon").unownLetter(mon.personality)
end
function MonOps.generatePid(species, otId, otSecretId, reqs)
reqs = reqs or {}
local PokemonG3 = require("src.core.game3.pokemon")
local bit = require("bit")
local spId = tonumber(species) or (PokemonG3.speciesFromName and PokemonG3.speciesFromName(tostring(species))) or 1
if spId == MonOps.SPECIES_UNOWN_G3 and reqs.unownLetter ~= nil then
local p = MonOps.unownPid(reqs.basePid, reqs.unownLetter, reqs.nature, reqs.shiny, otId, otSecretId)
if p then return p end
end
otId = bit.band(tonumber(otId) or 0, 0xFFFF)
otSecretId = bit.band(tonumber(otSecretId) or 0, 0xFFFF)
local trainerXor = bit.bxor(otId, otSecretId)
@@ -224,6 +293,8 @@ function MonOps.setNature(data, mon, natureId, gen)
ability = currentAbility,
gender = mon.gender,
shiny = isShiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
MonOps.recalc(data, mon, gen)
end
@@ -241,6 +312,8 @@ function MonOps.setAbility(data, mon, abilitySlot, gen)
ability = abilitySlot,
gender = mon.gender,
shiny = isShiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
mon.abilityNum = abilitySlot
local PokemonG3 = require("src.core.game3.pokemon")
@@ -261,6 +334,8 @@ function MonOps.setGender(data, mon, gender, gen)
ability = currentAbility,
gender = gender,
shiny = isShiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
local PokemonG3 = require("src.core.game3.pokemon")
mon.gender = PokemonG3.gender(mon.speciesId or mon.species, mon.personality)
@@ -281,6 +356,8 @@ function MonOps.setShiny(data, mon, shiny, gen)
ability = currentAbility,
gender = mon.gender,
shiny = shiny,
unownLetter = unownReq(mon),
basePid = mon.personality,
})
mon.isShiny = shiny
MonOps.recalc(data, mon, gen)
@@ -534,6 +611,20 @@ function MonOps.syncHpDv(dvs)
return dvs
end
local function deriveGen2(data, mon)
local Mon = require("src.battle.gen2.Mon")
local Unown = require("src.core.gen2.Unown")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data and data.pokemon and data.pokemon[mon.species]
if def then
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
end
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
function MonOps.setDv(data, mon, key, value, gen)
if type(mon) ~= "table" then return end
mon.dvs = mon.dvs or { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 }
@@ -552,21 +643,58 @@ function MonOps.setDv(data, mon, key, value, gen)
mon.ivs.spa = math.min(31, (mon.dvs.special or 0) * 2 + 1)
mon.ivs.spd = math.min(31, (mon.dvs.special or 0) * 2 + 1)
elseif gen == 2 or (mon.stats and mon.stats.specialAttack) then
local Mon = require("src.battle.gen2.Mon")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data and data.pokemon and data.pokemon[mon.species]
if def then
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
local Unown = require("src.core.gen2.Unown")
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
deriveGen2(data, mon)
end
MonOps.recalc(data, mon, gen)
end
local DV_KEYS = { "attack", "defense", "speed", "special" }
-- engine/gfx/load_pics.asm:1 GetUnownLetter, engine/gfx/color.asm:8 CheckShininess
function MonOps.unownDvs(dvs, letter, keepShiny)
local Mon = require("src.battle.gen2.Mon")
letter = math.floor(tonumber(letter) or 1)
if letter < 1 or letter > 26 then return nil end
local lo, hi = (letter - 1) * 10, math.min(255, letter * 10 - 1)
local best, bestKey
for p = lo, hi do
local out = {}
for i, k in ipairs(DV_KEYS) do
local d = tonumber(dvs[k]) or 0
local mid = math.floor(p / 4 ^ (4 - i)) % 4
out[k] = d - (math.floor(d / 2) % 4) * 2 + mid * 2
end
local cost = 0
for _, k in ipairs(DV_KEYS) do cost = cost + math.abs(out[k] - (tonumber(dvs[k]) or 0)) end
local key = (Mon.vanillaShiny(out) == (keepShiny and true or false) and 0 or 1000) + cost
if not bestKey or key < bestKey then best, bestKey = out, key end
end
return best
end
function MonOps.setUnownForm(data, mon, letter, gen)
if type(mon) ~= "table" then return nil end
if gen == 3 then
local Summary = require("src.core.game3.summary_data")
local p = MonOps.unownPid(mon.personality, letter, (tonumber(mon.personality) or 0) % 25,
Summary.isShiny(mon), mon.otId, mon.otSecretId)
if not p then return nil end
mon.personality = p
mon.isShiny = nil
MonOps.recalc(data, mon, gen)
return true
end
local Mon = require("src.battle.gen2.Mon")
mon.dvs = mon.dvs or { attack = 0, defense = 0, speed = 0, special = 0 }
local wasShiny = mon.shiny == true or Mon.vanillaShiny(mon.dvs)
local out = MonOps.unownDvs(mon.dvs, letter, wasShiny)
if not out then return nil end
for k, v in pairs(out) do mon.dvs[k] = v end
deriveGen2(data, mon)
MonOps.recalc(data, mon, gen)
return true, wasShiny and not mon.shiny
end
local EV_KEYS = { "hp", "atk", "def", "spe", "spa", "spd" }
function MonOps.setEv(data, mon, key, value, gen)
+48
View File
@@ -2241,6 +2241,54 @@ function Ops.setShiny(S, mon, shiny)
return Ops.mark(S, ("%s %s"):format(mon.species, shiny and "is now shiny!" or "is no longer shiny"))
end
Ops.UNOWN_FORM_NAMES_G3 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?"
function Ops.unownForm(S, mon)
if not mon or mon.isEgg or mon.egg then return nil end
local g = Gen.ofState(S)
if g == 3 and tonumber(mon.speciesId or mon.species) == MonOps.SPECIES_UNOWN_G3 then
return require("src.core.game3.pokemon").unownLetter(mon.personality)
elseif g == 2 then
return require("src.core.gen2.Unown").monLetter(mon)
end
return nil
end
function Ops.unownFormName(S, letter)
if letter == nil then return nil end
if Gen.ofState(S) == 3 then
local i = letter + 1
return Ops.UNOWN_FORM_NAMES_G3:sub(i, i)
end
return require("src.core.gen2.Unown").name(letter)
end
function Ops.unownFormOptions(S)
local out = {}
if Gen.ofState(S) == 3 then
for i = 0, MonOps.UNOWN_FORMS_G3 - 1 do out[#out + 1] = { i, Ops.unownFormName(S, i) } end
else
for i = 1, 26 do out[#out + 1] = { i, Ops.unownFormName(S, i) } end
end
return out
end
function Ops.setUnownForm(S, mon, letter)
local current = Ops.unownForm(S, mon)
if current == nil then return Ops.say(S, "Only an Unown has a form") end
letter = math.floor(tonumber(letter) or -1)
local name = Ops.unownFormName(S, letter)
if not name or name == "" then return Ops.say(S, "Unknown Unown form") end
if letter == current then return Ops.say(S, ("UNOWN is already %s"):format(name)) end
local g = Gen.ofState(S)
local ok, lostShiny = MonOps.setUnownForm(S.data, mon, letter, g)
if not ok then return Ops.say(S, ("UNOWN %s cannot keep this nature and shininess"):format(name)) end
if lostShiny then
return Ops.mark(S, ("UNOWN is now %s (no longer shiny: only I and V can be shiny)"):format(name))
end
return Ops.mark(S, ("UNOWN is now %s"):format(name))
end
function Ops.setPokerus(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
+22 -8
View File
@@ -11,17 +11,28 @@ local MonEditor = {}
-- species, cached for the process: the old panel called newImage every frame,
-- which re-decoded a PNG sixty times a second.
local spriteCache = {}
function MonEditor.sprite(S, species)
function MonEditor.spriteKey(S, species, mon)
if not species then return nil end
if spriteCache[species] ~= nil then return spriteCache[species] or nil end
local letter = mon and require("Ops").unownForm(S, mon)
if letter == nil then return species end
return tostring(species) .. ":" .. letter
end
function MonEditor.sprite(S, species, mon)
if not species then return nil end
local key = MonEditor.spriteKey(S, species, mon)
local letter = key ~= species and require("Ops").unownForm(S, mon) or nil
if spriteCache[key] ~= nil then return spriteCache[key] or nil end
if Gen.ofState(S) == 3 then
local okP, Pokemon = pcall(require, "src.core.game3.pokemon")
if okP and Pokemon then
local spId = tonumber(species) or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(species)))
if spId then
local pic = (Pokemon.frontPic and Pokemon.frontPic(spId)) or (Pokemon.icon and Pokemon.icon(spId))
local picId = letter ~= nil and Pokemon.picSpecies(spId, mon.personality) or spId
local pic = (Pokemon.frontPic and Pokemon.frontPic(picId)) or (Pokemon.icon and Pokemon.icon(picId))
if pic and pic.image then
spriteCache[species] = pic.image
spriteCache[key] = pic.image
return pic.image
end
end
@@ -29,19 +40,22 @@ function MonEditor.sprite(S, species)
end
local def = S.data and S.data.pokemon and (S.data.pokemon[species] or (type(species) == "number" and S.data.pokemon[species]))
local path = def and def.spriteFront
if letter ~= nil and Gen.ofState(S) == 2 then
path = require("src.core.gen2.Unown").formSprite(S.data.pokemon, letter) or path
end
if not path or not love.graphics.newImage then
spriteCache[species] = false
spriteCache[key] = false
return nil
end
local ok, img = pcall(love.graphics.newImage, path)
spriteCache[species] = ok and img or false
spriteCache[key] = ok and img or false
return ok and img or nil
end
-- Draw a species sprite fitted into a box, or a dashed placeholder when the
-- cache has no art for it (a modded species, or a headless run).
function MonEditor.drawSprite(S, Kit, species, x, y, size)
local img = MonEditor.sprite(S, species)
function MonEditor.drawSprite(S, Kit, species, x, y, size, mon)
local img = MonEditor.sprite(S, species, mon)
if img and love.graphics.draw and img.getDimensions then
local iw, ih = img:getDimensions()
if iw > 0 and ih > 0 then
+1 -1
View File
@@ -84,7 +84,7 @@ local function drawRoster(S, Kit, x, y, listW, h)
local rpad = 12 * s
local icon = 44 * s
MonEditor.drawSprite(S, Kit, mon.species, cx + rpad, ry + (rowH - icon) / 2, icon)
MonEditor.drawSprite(S, Kit, mon.species, cx + rpad, ry + (rowH - icon) / 2, icon, mon)
-- right cluster first, so the name knows how much room it has left
local rightW = 52 * s