fixes to id sync and also box

This commit is contained in:
bryanthaboi
2026-10-06 11:00:16 -04:00
parent 581262bc2f
commit 927f3ecc6c
99 changed files with 13369 additions and 509 deletions
+59
View File
@@ -0,0 +1,59 @@
{
"image": "scenery-v1.png",
"rects": {
"Platform": [
31,
147,
392,
218
],
"Block": [
473,
94,
338,
274
],
"Steps": [
868,
103,
357,
263
],
"Pillar": [
101,
451,
215,
353
],
"Arch": [
442,
440,
378,
364
],
"Tree": [
877,
447,
326,
357
],
"Flowers": [
49,
919,
344,
254
],
"Rock": [
450,
914,
364,
248
],
"Fence": [
873,
936,
344,
216
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
+23 -5
View File
@@ -330,6 +330,12 @@ local function openEditor(version, slotId)
Importer.saveNotice[version] = { ok = false, text = text }
end
local SaveData = require("src.core.SaveData")
local recovered, recoveryError, recoveryNotice = require("src.box.Transaction").recover(SaveData.persistenceFs())
if not recovered then refuse(recoveryError); return end
if recoveryNotice then
require("src.core.Logger").warn("%s", recoveryNotice)
if Importer and Importer._boxState then Importer._boxState.notice = recoveryNotice end
end
local path = SaveData.slotDiskPath(version, slotId)
if not path then
refuse("Could not resolve that save slot on disk.")
@@ -558,6 +564,15 @@ function bootGame(version, cartId, opts)
if require("src.core.RequireGuard").repair() then
print("boot: restored love.filesystem searcher (see #2001)")
end
local recovered, recoveryError, recoveryNotice = require("src.box.Transaction").recover(
require("src.core.SaveData").persistenceFs())
if recoveryNotice then require("src.core.Logger").warn("%s", recoveryNotice) end
if not recovered then
Importer = makeLauncher({ initialTab = "box" })
Importer._boxState = nil
require("src.import.BoxPanel").refresh(Importer).notice = recoveryError
return
end
pcall(function()
require("src.online.Trade").hostIsLive = function() return true end
end)
@@ -1316,6 +1331,8 @@ end
love.handlers = love.handlers or {}
function love.handlers.audiosuspend()
local BoxCry = package.loaded["src.box.Cry"]
if BoxCry then pcall(BoxCry.setSuspended, true) end
local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
local Sound = package.loaded["src.core.Sound"]
@@ -1325,6 +1342,8 @@ function love.handlers.audiosuspend()
end
function love.handlers.audioreset()
local BoxCry = package.loaded["src.box.Cry"]
if BoxCry then pcall(BoxCry.setSuspended, false) end
local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then
pcall(ChipAudio.setSuspended, false)
@@ -1473,9 +1492,7 @@ function love.mousepressed(x, y, button, istouch)
-- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
-- love.touch / mouse.isDown, so dropping the synthesized istouch press is
-- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
if istouch and (love.system.getOS() == "Android"
or love.system.getOS() == "iOS") then return end
return Importer:mousepressed(x, y, button)
return Importer:mousepressed(x, y, button, istouch)
end
if editorMode and EditorApp.mousepressed then
-- The editor owns the real touch lifecycle on Android and iOS.
@@ -1505,7 +1522,7 @@ function love.mousereleased(x, y, button, istouch)
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousereleased(x, y, button)
end
if Importer then return end
if Importer then return Importer:mousereleased(x, y, button, istouch) end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
@@ -1527,7 +1544,8 @@ function love.mousemoved(x, y, dx, dy, istouch)
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
return Studio.mousemoved(x, y)
end
if editorMode or Importer then return end
if Importer then return Importer:mousemoved(x, y, dx, dy, istouch) end
if editorMode then return end
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
return
+111
View File
@@ -0,0 +1,111 @@
local Catalog = require("src.box.Catalog")
local Sprites = require("src.online.OnlineSprites")
local MonAnim = require("src.core.game3.mon_anim")
local Animation = {}
local data
local live = setmetatable({}, { __mode = "k" })
local function animationData()
if data ~= nil then return data or nil end
data = false
local body = Sprites.readBytes("emerald", "data/generated/gba/pokemon/front_anims.lua")
if not body then return nil end
local chunk = (loadstring or load)(body, "@box-emerald-animation")
if not chunk then return nil end
if setfenv then setfenv(chunk, {}) end
local ok, front = pcall(chunk)
if not ok or type(front) ~= "table" or not (front.species and front.lists
and front.functions and front.animIds and front.animDelays) then return nil end
local pack = { version = "emerald" }
function pack.functionName(id) return front.functions[id] end
function pack.frontAnimId(species) return front.animIds[species] end
function pack.delay(species) return front.animDelays[species] or 0 end
function pack.anims(species)
local row = front.species[species]
if not row then return nil end
local result = {}
for i, id in ipairs(row) do
local list = front.lists[id]
result[i - 1] = list and list.cmds or nil
end
return result
end
data = pack
return pack
end
function Animation.release(animation)
if not animation or animation.released then return end
for _, image in pairs(animation.frames) do
if image.release then pcall(image.release, image) end
end
animation.frames, animation.released = {}, true
live[animation] = nil
end
function Animation.reset()
for animation in pairs(live) do Animation.release(animation) end
data = nil
end
function Animation.new(entry)
if not entry or entry.mon.isEgg or entry.display.egg then return nil end
local art, version, mon = Catalog.art(entry)
if version ~= "emerald" or not art.front or not mon then return nil end
local pack = animationData()
local species = tonumber(mon.species)
if not pack or not species or pack.frontAnimId(species) == nil then return nil end
local ok, sprite = pcall(function()
local s = MonAnim.newSprite(species, { animationData = pack, affineMode = "off", data = { [0] = species, [1] = 1 } })
MonAnim.summary(s, species, false)
return s
end)
if not ok then return nil end
local animation = { entry = entry, art = art, sprite = sprite, elapsed = 0, frames = {} }
live[animation] = true
if MonAnim.hasTwoFramesAnimation(species, "emerald") and love and love.image and love.graphics then
local Pokemon = require("src.core.game3.pokemon")
local pic = Pokemon.picSpecies(species, mon.personality)
local path = (entry.display.shiny and MonAnim.Data.SHEET_SHINY or MonAnim.Data.SHEET):format(pic)
local rgba = Sprites.readBytes("emerald", path)
local size = 64 * 64 * 4
if rgba then
for frame = 1, math.min(15, math.floor(#rgba / size) - 1) do
local made, image = pcall(function()
local pixels = love.image.newImageData(64, 64, "rgba8", rgba:sub(size * frame + 1, size * (frame + 1)))
local img = love.graphics.newImage(pixels)
if pixels.release then pixels:release() end
img:setFilter("nearest", "nearest")
return img
end)
if made then animation.frames[frame] = image end
end
end
end
return animation
end
function Animation.update(animation, dt)
if not animation or animation.released or MonAnim.done(animation.sprite) then return end
animation.elapsed = animation.elapsed + math.max(0, math.min(dt or 0, 0.25))
while animation.elapsed >= 1 / 60 and not MonAnim.done(animation.sprite) do
MonAnim.step(animation.sprite, true)
animation.elapsed = animation.elapsed - 1 / 60
end
end
function Animation.draw(animation, x, y, size)
if not animation or animation.released then return false end
local image = animation.frames[animation.sprite.frame] or animation.art.front
if not image then return false end
local scale = size / 64
if scale >= 1 then scale = math.floor(scale) end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1)
MonAnim.draw(animation.sprite, image, math.floor(x + size / 2), math.floor(y + size / 2), { scale = scale })
love.graphics.setColor(r, g, b, a)
return true
end
require("src.render.Assets").register({ invalidate = Animation.reset, release = Animation.reset })
return Animation
+315
View File
@@ -0,0 +1,315 @@
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local Store = require("src.box.Store")
local Catalog = {}
local cached = {}
local artCache = {}
local function read(version, path)
local body = CacheFs.readAt(GameVersion.cachePrefix(version) .. path)
if not body then return nil end
local chunk = (loadstring or load)(body, "@" .. version .. "/" .. path)
if not chunk then return nil end
if setfenv then setfenv(chunk, {}) end
local ok, value = pcall(chunk)
return ok and type(value) == "table" and value or nil
end
function Catalog.reset()
local cry = package.loaded["src.box.Cry"]
if cry then cry.reset() end
local animation = package.loaded["src.box.Animation"]
if animation then animation.reset() end
local themes = package.loaded["src.box.Themes"]
if type(themes) == "table" and themes.reset then themes.reset() end
cached, artCache = {}, {}
require("src.online.OnlineSprites").reset()
end
function Catalog.get(version)
if cached[version] then return cached[version] end
local generation = GameVersion.generation(version)
local data = { generation = generation }
if generation == 3 then
local root = "data/generated/gba/pokemon/"
data.names = read(version, root .. "names.lua")
data.national = read(version, root .. "national.lua") or {}
data.moves = read(version, root .. "move_names.lua") or {}
data.items = (read(version, "data/generated/gba/items/pack.lua") or {}).items or {}
data.types = read(version, root .. "types.lua") or {}
data.typeNames = read(version, root .. "type_names.lua") or {}
data.stats = read(version, root .. "stats.lua") or {}
data.meta = read(version, root .. "meta.lua") or {}
data.abilities = read(version, root .. "abilities.lua") or {}
data.abilityNames = read(version, root .. "ability_names.lua") or {}
data.battleMoves = (read(version, root .. "battle_moves.lua") or {}).moves or {}
local sections = read(version, "data/generated/gba/region_map/map_sections.lua") or {}
data.locations = sections.sections or sections
data.ready = data.names ~= nil
else
data.pokemon = read(version, "data/generated/pokemon.lua")
data.moves = read(version, "data/generated/moves.lua") or {}
data.items = read(version, "data/generated/items.lua") or {}
data.byNational = {}
for key, def in pairs(data.pokemon or {}) do
if type(def) == "table" and type(def.dex) == "number" then
data.byNational[def.dex] = key
end
end
data.ready = data.pokemon ~= nil
end
cached[version] = data
return data
end
local function label(records, key)
local value = records[key]
if type(value) == "table" then return value.name or value.id or tostring(key) end
return value or tostring(key or "")
end
function Catalog.describe(version, mon)
local data = Catalog.get(version)
local def = data.pokemon and data.pokemon[mon.species]
local species = data.names and data.names[tonumber(mon.species)]
or (def and def.name) or tostring(mon.species or "Unknown")
local national = def and def.dex or (data.national and data.national.toNational or {})[tonumber(mon.species)]
local moves = {}
for _, move in ipairs(mon.moves or {}) do
local key = type(move) == "table" and (move.moveId or move.id or move.move or move.name) or move
if key and key ~= 0 then moves[#moves + 1] = tostring(label(data.moves, key)) end
end
local item = mon.heldItem or mon.item
local shiny = mon.isShiny == true or mon.shiny == true
if data.generation == 3 then shiny = require("src.core.game3.pokemon").isShiny(mon) end
if data.generation == 2 and mon.dvs then
shiny = require("src.battle.gen2.Mon").vanillaShiny(mon.dvs)
end
local types = def and def.types or data.types and data.types[tonumber(mon.species)] or mon.types or {}
if data.generation == 3 and type(types) == "table" then
local names = {}
for _, id in ipairs(types) do names[#names + 1] = tostring(data.typeNames[id] or id) end
types = names
end
if type(types) == "table" then
local unique, seen = {}, {}
for _, value in ipairs(types) do
if not seen[value] then unique[#unique + 1], seen[value] = value, true end
end
types = unique
end
local display = { name = mon.nickname or species, species = species, national = national,
level = mon.level or 0, types = type(types) == "table" and table.concat(types, " / ") or tostring(types),
moves = table.concat(moves, ", "), item = item and item ~= 0 and tostring(label(data.items, item)) or "",
shiny = shiny, egg = mon.isEgg == true }
require("src.box.Metadata").describe(data, mon, display, def)
return display
end
local function moveKey(move)
return type(move) == "table" and (move.moveId or move.id or move.move or move.name) or move
end
local function whole(value, lo, hi)
return type(value) == "number" and value == math.floor(value) and value >= lo and value <= hi
end
local function clamp(value, lo, hi)
if type(value) ~= "number" or value ~= value then return lo end
return math.max(lo, math.min(hi, math.floor(value)))
end
local function clampAll(values, lo, hi)
for key, value in pairs(values) do values[key] = clamp(value, lo, hi) end
end
local function placeholder(name)
return type(name) ~= "string" or name == "" or name:match("^%?+$") ~= nil or name == "TERU-SAMA"
end
function Catalog.knownItem(data, item)
local row = data.items and data.items[item]
if type(row) == "table" then return not placeholder(row.name) end
return data.generation == 3 and type(row) == "string" and not placeholder(row)
end
function Catalog.check(version, mon)
local data = Catalog.get(version)
if type(mon) ~= "table" then return "This Pokémon's data is missing.", {} end
local known
if data.generation == 3 then
local name = data.names and data.names[tonumber(mon.species)]
local national = (data.national.toNational or {})[tonumber(mon.species)]
known = not placeholder(name) and whole(national, 1, 386)
else
local def = data.pokemon and data.pokemon[mon.species]
known = type(def) == "table" and whole(def.dex, 1, data.generation == 1 and 151 or 251)
end
if not known then
return "This Pokémon is not in the original games' Pokédex.", {}
end
local fixes = {}
local function add(label, apply) fixes[#fixes + 1] = { label = label, apply = apply } end
local moves = mon.moves
if moves ~= nil and type(moves) ~= "table" then return "This Pokémon's moves are unreadable.", {} end
local legalMoves = 0
for _, move in ipairs(moves or {}) do
local key = moveKey(move)
if key and key ~= 0 and data.moves[key] then legalMoves = legalMoves + 1 end
end
local badMoves = 0
for _, move in ipairs(moves or {}) do
local key = moveKey(move)
if key and key ~= 0 and not data.moves[key] then badMoves = badMoves + 1 end
end
if badMoves > 0 or legalMoves > 4 then
if legalMoves == 0 then return "None of this Pokémon's moves exist in the original games.", {} end
add(badMoves > 0 and ("remove %d unknown move%s"):format(badMoves, badMoves == 1 and "" or "s")
or "keep only 4 moves", function(m)
local keptMoves, keptPP, pp = {}, {}, type(m.pp) == "table" and #m.pp == #m.moves and m.pp or nil
for i, move in ipairs(m.moves) do
local key = moveKey(move)
if key and key ~= 0 and data.moves[key] and #keptMoves < 4 then
keptMoves[#keptMoves + 1] = move
if pp then keptPP[#keptPP + 1] = pp[i] end
end
end
m.moves = keptMoves
if pp then m.pp = keptPP end
if type(m.cartExtra) == "table" then m.cartExtra.moveSlots = nil end
end)
end
local item = mon.heldItem or mon.item
if item and item ~= 0 and not Catalog.knownItem(data, item) then
add("remove unknown held item", function(m)
if type(m.heldItem) == "number" then m.heldItem = 0 else m.heldItem = nil end
m.item = nil
end)
end
if mon.level ~= nil and not whole(mon.level, 1, 100) then
add("set level within 1-100", function(m) m.level = clamp(m.level, 1, 100) end)
end
local function group(values, lo, hi)
if values == nil then return true, 0 end
if type(values) ~= "table" then return false, 0 end
local ok, total = true, 0
for _, value in pairs(values) do
if whole(value, lo, hi) then total = total + value else ok = false end
end
return ok, total
end
if data.generation == 3 then
if not group(mon.ivs, 0, 31) then
add("cap IVs at 31", function(m) if type(m.ivs) == "table" then clampAll(m.ivs, 0, 31) else m.ivs = nil end end)
end
local evsOk, evTotal = group(mon.evs, 0, 255)
if not evsOk or evTotal > 510 then
add("cap EVs at 255 each, 510 total", function(m)
if type(m.evs) ~= "table" then m.evs = nil return end
clampAll(m.evs, 0, 255)
local keys, total = {}, 0
for key, value in pairs(m.evs) do keys[#keys + 1], total = key, total + value end
table.sort(keys, function(a, b) return m.evs[a] > m.evs[b] end)
local i = 1
while total > 510 do
local key = keys[(i - 1) % #keys + 1]
local cut = math.min(m.evs[key], total - 510)
m.evs[key], total, i = m.evs[key] - cut, total - cut, i + 1
end
end)
end
if mon.abilityNum ~= nil and not whole(mon.abilityNum, 0, 1) then
add("reset ability slot", function(m) m.abilityNum = 0 end)
end
if mon.friendship ~= nil and not whole(mon.friendship, 0, 255) then
add("cap friendship at 255", function(m)
m.friendship = clamp(m.friendship, 0, 255)
if m.happiness ~= nil then m.happiness = m.friendship end
end)
end
if mon.pokeball ~= nil and not whole(mon.pokeball, 0, 12) then
add("change ball to Poké Ball", function(m) m.pokeball = 4 end)
end
if mon.metLevel ~= nil and not whole(mon.metLevel, 0, 100) then
add("set met level within 0-100", function(m) m.metLevel = clamp(m.metLevel, 0, 100) end)
end
else
if not group(mon.dvs, 0, 15) then
add("cap DVs at 15", function(m) if type(m.dvs) == "table" then clampAll(m.dvs, 0, 15) else m.dvs = nil end end)
end
if not group(mon.statExp, 0, 65535) then
add("cap stat experience", function(m)
if type(m.statExp) == "table" then clampAll(m.statExp, 0, 65535) else m.statExp = nil end
end)
end
end
return nil, fixes
end
function Catalog.repair(version, mon)
local fatal, fixes = Catalog.check(version, mon)
if fatal then return nil, fatal end
local fixed = Store.copy(mon)
for _, fix in ipairs(fixes) do fix.apply(fixed) end
return fixed, fixes
end
function Catalog.compatible(entry, version)
if entry.generation ~= GameVersion.generation(version) then
return nil, "This Pokémon must return to a game in its original generation."
end
local data = Catalog.get(version)
if not data.ready then return nil, "Import that game's ROM before withdrawing." end
local fatal, fixes = Catalog.check(version, entry.mon)
if fatal then return nil, fatal end
if #fixes > 0 then return nil, "This Pokémon is illegal: " .. fixes[1].label .. ".", fixes end
return true
end
function Catalog.art(entry)
local display, mon = entry.display, entry.mon
local letter = display.national == 201 and entry.generation == 2
and (mon.unownLetter or require("src.core.gen2.Unown").letterFromDVs(mon.dvs)) or ""
local key = table.concat({ entry.version, tostring(mon.species), tostring(display.national),
tostring(mon.personality), tostring(letter), tostring(display.shiny), tostring(display.egg) }, "|")
local hit = artCache[key]
if hit then return hit.art or nil, hit.version, hit.mon end
local order = {}
for i, version in ipairs(GameVersion.ORDER) do
order[#order + 1] = { version = version, generation = GameVersion.generation(version), index = i }
end
table.sort(order, function(a, b)
if (a.version == "emerald") ~= (b.version == "emerald") then return a.version == "emerald" end
return a.generation > b.generation or (a.generation == b.generation and a.index > b.index)
end)
local sprites = require("src.online.OnlineSprites")
for _, row in ipairs(order) do
local data = Catalog.get(row.version)
local species
if data.generation == 3 then
species = (data.national.toSpecies or {})[display.national]
else species = data.byNational and data.byNational[display.national] end
if row.version == entry.version and species == nil then species = mon.species end
if data.ready and species and (not display.egg or data.generation >= 2) then
local artMon = Store.copy(mon)
artMon.species, artMon.isShiny, artMon.shiny = species, display.shiny, display.shiny
if data.generation == 3 and entry.generation == 2 and display.national == 201 then
local Unown = require("src.core.gen2.Unown")
local letter = Unown.index(mon.unownLetter or Unown.letterFromDVs(mon.dvs)) or 1
local value = letter - 1
artMon.personality = value % 4 + (math.floor(value / 4) % 4) * 256
+ (math.floor(value / 16) % 4) * 65536
end
if display.egg and data.generation == 2 then artMon.species = "BOX_EGG" end
local art = sprites.ensure(row.version, artMon)
if art and (art.front or art.icon) then
artCache[key] = { art = art, version = row.version, mon = artMon }
return art, row.version, artMon
end
end
end
artCache[key] = {}
end
return Catalog
+55
View File
@@ -0,0 +1,55 @@
local Store = require("src.box.Store")
local Collection = {}
function Collection.dashboard(state, save)
local result = { total = 0, species = 0, eggs = 0, shiny = 0, forms = {}, duplicates = {}, caught = 0,
held = {}, byGame = {}, missing = {} }
for b = 1, Store.BOXES do
for _, entry in pairs(state.boxes[b].mons) do
local d = entry.display
result.total = result.total + 1
if d.egg then result.eggs = result.eggs + 1
else
local dex = d.national
if dex then result.held[dex] = (result.held[dex] or 0) + 1 end
if d.shiny then result.shiny = result.shiny + 1 end
if dex == 201 then
local letter
if entry.generation == 2 then
local Unown = require("src.core.gen2.Unown")
local index = Unown.index(entry.mon.unownLetter)
or type(entry.mon.dvs) == "table" and Unown.letterFromDVs(entry.mon.dvs)
letter = index and index - 1
elseif type(entry.mon.personality) == "number" then
letter = require("src.core.game3.pokemon").unownLetter(entry.mon.personality)
end
if letter then result.forms[tostring(letter)] = true end
end
end
result.byGame[entry.version] = (result.byGame[entry.version] or 0) + 1
end
end
for dex, count in pairs(result.held) do
result.species = result.species + 1
if count > 1 then result.duplicates[#result.duplicates + 1] = { dex = dex, count = count } end
end
table.sort(result.duplicates, function(a, b) return a.dex < b.dex end)
local pokedex = save and (save.dex or save.pokedex) or {}
local caught = {}
local data = save and save.version and require("src.box.Catalog").get(save.version)
local function national(key)
if not data then return tostring(key) end
local def = data.pokemon and data.pokemon[key]
return tostring(def and def.dex or data.national and (data.national.toNational or {})[tonumber(key)] or key)
end
for _, field in ipairs({ "caught", "owned" }) do
for key, value in pairs(pokedex[field] or {}) do if value then caught[national(key)] = true end end
end
local imported = save and save.modData and save.modData.cartImport
for _, dex in ipairs(imported and imported.dexOwned or {}) do caught[tostring(dex)] = true end
for _ in pairs(caught) do result.caught = result.caught + 1 end
for dex = 1, 386 do if not result.held[dex] then result.missing[#result.missing + 1] = dex end end
return result
end
return Collection
+163
View File
@@ -0,0 +1,163 @@
local CacheFs = require("src.import.CacheFs")
local Catalog = require("src.box.Catalog")
local GameVersion = require("src.core.GameVersion")
local Cry = {}
local packs, sources, sourceCount, playing = {}, {}, 0, nil
local suspended = false
local MAX_SOURCES = 24
local MAX_PACKS = 2
local packOrder = {}
local function read(version, path)
return CacheFs.readAt(GameVersion.cachePrefix(version) .. path)
end
local function readTable(version, path)
local body = read(version, path)
if not body then return nil end
local chunk = (loadstring or load)(body, "@box-cry/" .. version .. "/" .. path)
if not chunk then return nil end
if setfenv then setfenv(chunk, {}) end
local ok, value = pcall(chunk)
return ok and type(value) == "table" and value or nil
end
function Cry.stop()
if playing then pcall(playing.stop, playing) end
playing = nil
end
local function clearSources()
Cry.stop()
for _, source in pairs(sources) do
pcall(source.stop, source)
if source.release then pcall(source.release, source) end
end
sources, sourceCount = {}, 0
end
function Cry.reset()
clearSources()
packs, packOrder = {}, {}
end
function Cry.setSuspended(value)
suspended = value == true
Cry.reset()
end
local function pack(version)
if packs[version] ~= nil then return packs[version] or nil end
local result
if GameVersion.generation(version) == 3 then
local root = "data/generated/gba/audio/"
local index = readTable(version, root .. "index.lua")
local bytes = index and read(version, root .. "samples.bin")
if index and bytes then
result = { index = index, bytes = bytes,
samples = index.samples or readTable(version, root .. "samples.lua") or {} }
end
else
local audio = readTable(version, "data/generated/audio.lua")
local bytes = audio and audio.programFile and read(version, audio.programFile)
if audio and bytes then
audio.programBytes = bytes
result = { audio = audio }
end
end
packs[version] = result or false
packOrder[#packOrder + 1] = version
while #packOrder > MAX_PACKS do packs[table.remove(packOrder, 1)] = nil end
return result
end
local function voicedPikachu(version, data, species)
if species ~= "PIKACHU" or not data.audio.pikaCries then return nil end
local bytes = read(version, "assets/generated/audio/pika_cries/cry_01.wav")
if not bytes then return nil end
local file = love.filesystem.newFileData(bytes, "box-pikachu.wav")
local sound = love.sound.newSoundData(file)
file:release()
if sound:getChannelCount() == 1 then
local stereo = love.sound.newSoundData(sound:getSampleCount(), sound:getSampleRate(), 16, 2)
for i = 0, sound:getSampleCount() - 1 do
local value = sound:getSample(i)
stereo:setSample(i, 1, value); stereo:setSample(i, 2, value)
end
sound:release(); sound = stereo
end
local source = love.audio.newSource(sound, "static")
sound:release()
return source
end
local function render(version, species)
local data = pack(version)
if not data then return nil end
if GameVersion.generation(version) < 3 then
local voiced, source = pcall(voicedPikachu, version, data, species)
if voiced and source then return source end
return require("src.core.ChipAudio").newCry(data, species)
end
local index = data.index
local id = (index.cryIds or {})[species] or (index.cryIds or {})[tostring(species)]
local row = id ~= nil and ((index.cries or {})[id] or (index.cries or {})[tostring(id)])
local sample = row and (data.samples[row.sampleId] or data.samples[tostring(row.sampleId)])
if not sample then return nil end
local Sample = require("src.core.game3.m4a_sample")
local pcm = Sample.loadPcm(data.bytes, sample)
if not pcm then return nil end
local config = require("src.core.game3.profile").of(version).audio or {}
local params = Sample.cryParams(0, config.cryDefaultVolume, config.cryModeOverrides)
local sound = Sample.renderCry(pcm, Sample.waveRate(sample.freq), params,
{ pan = 0, outRate = require("src.core.game3.m4a_mix").SAMPLE_RATE })
if not sound then return nil end
local source = love.audio.newSource(sound, "static")
sound:release()
return source
end
function Cry.play(entry)
Cry.stop()
if suspended or not entry or not entry.mon or entry.mon.isEgg or entry.display and entry.display.egg
or not (love and love.audio and love.audio.newSource and love.sound and love.sound.newSoundData) then return nil end
local chip = package.loaded["src.core.ChipAudio"]
if chip and chip.isSuspended() then return nil end
local options = require("src.core.SaveData").loadOptions()
local volume = math.max(0, math.min(7, tonumber(options.boxCryVol) or 7)) / 7
if volume == 0 then return nil end
local order = { entry.version }
for i = #GameVersion.ORDER, 1, -1 do
local version = GameVersion.ORDER[i]
if version ~= entry.version then order[#order + 1] = version end
end
for _, version in ipairs(order) do
local data = Catalog.get(version)
local national = entry.display and entry.display.national
local species = version == entry.version and entry.mon.species
or data.generation == 3 and (data.national.toSpecies or {})[national]
or data.byNational and data.byNational[national]
if data.ready and species then
local key = version .. ":" .. tostring(species)
local source = sources[key]
if not source then
local ok, result = pcall(render, version, species)
if ok and result then
if sourceCount >= MAX_SOURCES then clearSources() end
source, sources[key], sourceCount = result, result, sourceCount + 1
end
end
if source then
local gain = volume
if version == "yellow" and species == "PIKACHU" then
gain = gain * math.max(0, math.min(7, tonumber(options.pikaVol) or 7)) / 7
end
local ok = pcall(function() source:setVolume(gain); source:play() end)
if ok then playing = source; return source, version end
end
end
end
end
require("src.render.Assets").register({ invalidate = Cry.reset, release = Cry.reset })
return Cry
+113
View File
@@ -0,0 +1,113 @@
local Store = require("src.box.Store")
local Context = require("src.box.GameContext")
local Events = {}
local WESTERN = { [2]=true,[3]=true,[4]=true,[5]=true,[7]=true }
local ALL = { [1]=true,[2]=true,[3]=true,[4]=true,[5]=true,[7]=true }
Events.ROWS = {
{ id="eon",name="Eon Ticket",item="ITEM_EON_TICKET",versions={ruby=ALL,sapphire=ALL,emerald=ALL},
card="rse_eon_ticket",route="Southern Island",method="Record mixing" },
{ id="aurora",name="AuroraTicket",item="ITEM_AURORA_TICKET",versions={firered=ALL,leafgreen=ALL,emerald=WESTERN},
card="aurora_ticket",rseCard="rse_aurora_ticket",route="Birth Island",method="Wonder Card" },
{ id="mystic",name="MysticTicket",item="ITEM_MYSTIC_TICKET",
versions={firered={[1]=true,[2]=true},leafgreen={[1]=true,[2]=true},emerald={[1]=true,[2]=true}},
card="mystic_ticket",rseCard="rse_mystic_ticket",route="Navel Rock",method="Wonder Card" },
{ id="oldSeaMap",name="Old Sea Map",item="ITEM_OLD_SEA_MAP",versions={emerald={[1]=true}},
card="rse_old_sea_map",route="Faraway Island",method="Wonder Card" },
}
function Events.allowed(id,version,language)
for _,row in ipairs(Events.ROWS) do
if row.id==id then return row.versions[version] and row.versions[version][language] and row or nil end
end
end
function Events.list(version,language)
local out={}
for _,row in ipairs(Events.ROWS) do if Events.allowed(row.id,version,language) then out[#out+1]=row end end
return out
end
local function session(save)
local store={version=save.version,flags={},vars={}}
for id,on in pairs(save.flags or {}) do store.flags[tonumber(id) or id]=on end
for id,value in pairs(save.vars or {}) do store.vars[tonumber(id) or id]=value end
return {version=save.version,store=store,bag=save.bag,storage=save.storage,modData=save.modData or {}}
end
local function apply(s,save)
local snap=require("src.core.game3.scripting.flags").serialize(s.store)
save.flags,save.vars,save.bag,save.storage,save.modData=snap.flags,snap.vars,s.bag,s.storage,s.modData
end
function Events.status(save,row)
return Context.withItems(save.version,function()
local Gift=require("src.core.game3.mystery_gift")
local s=session(Store.copy(save))
local item=require("src.core.game3.constants").of(save.version):require("items",row.item)
if require("src.core.game3.bag").has(s.bag or {pockets={}},item,1) then return "Collected" end
local received=row.id=="eon" and (save.version=="ruby" or save.version=="sapphire") and "FLAG_SYS_HAS_EON_TICKET"
or row.id=="eon" and "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND"
or row.id=="aurora" and "FLAG_RECEIVED_AURORA_TICKET"
or row.id=="mystic" and "FLAG_RECEIVED_MYSTIC_TICKET" or "FLAG_RECEIVED_OLD_SEA_MAP"
if Gift.getFlag(s,require("src.core.game3.constants").of(save.version):require("flags",received)) then return "Collected" end
if row.id=="eon" then return "Available" end
local family=save.version=="emerald" and "rse" or "frlg"
local key=family=="rse" and (row.rseCard or row.card) or row.card
for _,candidate in ipairs(Gift.builtins(family)) do
if candidate.key==key then
for _,flag in ipairs(candidate.card.gift and candidate.card.gift.haveFlags or {}) do
if Gift.getFlag(s,flag) then return "Event already completed" end
end
end
end
local card=Gift.getSavedCard(s)
if card and card.gift and card.gift.item==item then return "Ticket pending" end
return "Available"
end)
end
function Events.receive(save,id,language)
local row=Events.allowed(id,save.version,language)
if not row then return nil,"This event was not historically distributed for this game and language." end
local status,why=Events.status(save,row)
if not status then return nil,why end
if status~="Available" then return nil,"This event has already been received." end
return Context.withItems(save.version,function()
local out=Store.copy(save)
local s=session(out)
local C=require("src.core.game3.constants").of(save.version)
local item=C:require("items",row.item)
if id=="eon" then
local leader={name="Box Events",giftItem=item}
local result
if save.version=="ruby" or save.version=="sapphire" then
result=require("src.core.game3.link.rs_record_mix").receiveGift(s,leader,2)
else result=require("src.core.game3.rse.record_mixing_gift").mixImport({leader,{}},s,2) end
if not result or result.item~=item then return nil,"Make room in the Key Items pocket before receiving the Eon Ticket." end
else
local Gift=require("src.core.game3.mystery_gift")
local existing=Gift.getSavedCard(s)
if existing then
local flag=Gift.receivedGiftFlag(existing.flagId,s)
if flag and not Gift.getFlag(s,flag) then return nil,"Collect your existing Wonder Card gift before receiving another card." end
end
local family=save.version=="emerald" and "rse" or "frlg"
local key=family=="rse" and (row.rseCard or row.card) or row.card
local card
for _,candidate in ipairs(Gift.builtins(family)) do if candidate.key==key then card=candidate.card end end
if not card or not Gift.receiveCard(s,card) then return nil,"The game's ticket delivery card could not be prepared." end
end
apply(s,out);return out
end)
end
function Events.collect(save,id,language)
local row=Events.allowed(id,save.version,language)
if not row or id=="eon" then return nil,"Choose a pending ticket card." end
return Context.withItems(save.version,function()
local Gift=require("src.core.game3.mystery_gift")
local out=Store.copy(save);local s=session(out)
local card=Gift.getSavedCard(s)
local item=require("src.core.game3.constants").of(save.version):require("items",row.item)
if not card or not card.gift or card.gift.item~=item then return nil,"Receive this ticket's card first." end
local result=Gift.deliverGift(s,card)
if result~=Gift.DELIVER_GIVEN then
return nil,result==Gift.DELIVER_NO_ROOM and "The Key Items pocket is full." or "This ticket has already been collected."
end
Gift.claimCard(s,card);apply(s,out);return out
end)
end
return Events
+185
View File
@@ -0,0 +1,185 @@
local Store = require("src.box.Store")
local Catalog = require("src.box.Catalog")
local Serializer = require("src.core.SaveSerializer")
local Pack = require("src.save_convert.gen3_port.imagepack")
local Gen3 = require("src.save_convert.Gen3Save")
local GCI = { SIZE = 0x76000, BLOCK = 0x2000, BODY = 0x1FF0 }
local function be32(s, off)
local a,b,c,d = s:byte(off+1,off+4)
return a*16777216+b*65536+c*256+d
end
local function word(value)
return string.char(math.floor(value/16777216)%256,math.floor(value/65536)%256,math.floor(value/256)%256,value%256)
end
local function le32(s, off)
local a,b,c,d = s:byte(off+1,off+4)
return a+b*256+c*65536+d*16777216
end
local function little(value)
return word(value):reverse()
end
function GCI.checksum(block)
local sum = 0
for i = 5, 0x1FFC, 2 do sum = (sum + block:byte(i)*256 + block:byte(i+1)) % 65536 end
return sum*65536 + (0xF004-sum)%65536
end
function GCI.decode(bytes)
if type(bytes) ~= "string" then return nil, "Choose a Pokémon Box GCI file." end
local header, raw = "", bytes
if #bytes == GCI.SIZE + 64 then
local code = bytes:sub(1,4)
if code ~= "GPXE" and code ~= "GPXP" and code ~= "GPXJ" then return nil, "This GCI belongs to another game." end
if bytes:sub(5,6) ~= "01" or bytes:byte(57)*256+bytes:byte(58) ~= 59 then
return nil, "The GCI header does not describe a 59-block Nintendo Pokémon Box save."
end
header, raw = bytes:sub(1,64), bytes:sub(65)
elseif #bytes ~= GCI.SIZE then return nil, "A Pokémon Box save has 0x76000 data bytes plus an optional 64-byte GCI header." end
local banks, extra = {}, {}
for bank = 0, 1 do
local rows, count, valid = {}, nil, true
for i = 0, 22 do
local at = (1+bank*23+i)*GCI.BLOCK
local block = raw:sub(at+1,at+GCI.BLOCK)
local id, counter = be32(block,4), be32(block,8)
if id >= 23 or rows[id] or GCI.checksum(block) ~= be32(block,0)
or count ~= nil and counter ~= count then valid = false end
rows[id], count = { offset = at, block = block }, counter
end
if valid then banks[#banks+1] = { rows = rows, count = count, bank = bank } end
end
if #banks == 0 then return nil, "Both Pokémon Box save banks are damaged." end
table.sort(banks, function(a,b)
local distance=(a.count-b.count)%4294967296
return distance>0 and distance<2147483648
end)
local active = banks[1]
for i = 0, 11 do
local at = (47+i)*GCI.BLOCK
local block = raw:sub(at+1,at+GCI.BLOCK)
local id = be32(block,4)
if id >= 12 or extra[id] or GCI.checksum(block) ~= be32(block,0) then
return nil, "Pokémon Box's showcase/photo blocks are damaged."
end
extra[id] = { offset = at, block = block }
end
local pieces = {}
for i = 0, 22 do pieces[#pieces+1] = active.rows[i].block:sub(13,12+GCI.BODY) end
pieces[#pieces+1] = string.rep("\0",23*(GCI.BLOCK-GCI.BODY))
for i = 0, 11 do pieces[#pieces+1] = extra[i].block:sub(13,12+GCI.BODY) end
pieces[#pieces+1] = string.rep("\0",12*(GCI.BLOCK-GCI.BODY))
return { header = header, raw = raw, logical = table.concat(pieces), active = active, extra = extra,
recovered = #banks == 1, japanese = header:sub(1,4) == "GPXJ" or raw:byte(1) == 0x83 }
end
function GCI.import(state, bytes)
if Store.count(state) ~= 0 then return nil, "Import a native Box save into an empty warehouse to preserve every existing Pokémon." end
local data, why = GCI.decode(bytes)
if not data then return nil, why end
if data.japanese then return nil, "Japanese Box saves use a different name character set; import an English or European Box save." end
local nextState, codec = Store.copy(state), Gen3.forVersion("ruby")
local versions = { [1] = "sapphire", [2] = "ruby", [3] = "emerald", [4] = "firered", [5] = "leafgreen" }
for b = 1, Store.BOXES do
local box = nextState.boxes[b]
local rawName = data.logical:sub(0x1EC38+(b-1)*9+1,0x1EC38+b*9)
local first = rawName:byte(1)
box.name = (first == 0 or first == 0xFF) and "BOX "..b or codec.decodeString(rawName,0,9)
box.gciNameRaw, box.gciNameOriginal = rawName, box.name
box.gciWallpaper = data.logical:byte(0x1ED19+b)
for slot = 1, Store.SLOTS do
local at = 8+((b-1)*60+slot-1)*84
local raw = data.logical:sub(at+1,at+80)
local mon = math.floor(raw:byte(20)/2)%2 == 1 and codec.decodeBoxMon(raw) or nil
if mon then
if not mon.checksumOk or mon.isBadEgg then return nil, "A native Pokémon record is damaged at Box "..b..", slot "..slot.."." end
local version = versions[mon.metGame] or "ruby"
local port = codec.toPortMon(mon,false)
box.mons[slot] = { id = nextState.nextId, version = version, generation = 3, mon = port,
depositorId = le32(data.logical,at+80), display = Catalog.describe(version,port),
gciRaw = raw, gciOriginal = Serializer.encode(port) }
nextState.nextId = nextState.nextId+1
end
end
end
nextState.gciTemplate = Pack.pack(bytes)
nextState.revision = state.revision+1
return nextState, data.recovered and "Imported using the complete surviving save bank." or nil
end
function GCI.export(state)
local template = Pack.unpack(state.gciTemplate)
if not template then return nil, "Import an original Pokémon Box save once to supply its native file template." end
local data, why = GCI.decode(template)
if not data then return nil, why end
local codec = Gen3.forVersion("ruby")
local edits = {}
local function put(off, bytes) edits[#edits+1] = { off, bytes } end
for b = 1, Store.BOXES do
local box = state.boxes[b]
local name
if box.name == box.gciNameOriginal then name = box.gciNameRaw
else
name = codec.encodeString(box.name,8)..string.char(0xFF)
if codec.decodeString(name,0,9) ~= box.name then return nil, "A Box name uses characters the native file cannot represent." end
end
put(0x1EC38+(b-1)*9,name)
put(0x1ED19+b-1,string.char(box.gciWallpaper or 0))
for slot = 1, Store.SLOTS do
local entry = box.mons[slot]
local raw, depositor = string.rep("\0",80), 0
if entry then
if entry.generation ~= 3 then return nil, "Native Pokémon Box files accept Gen 3 records. Convert or withdraw earlier-generation Pokémon first." end
if entry.gciRaw and entry.gciOriginal == Serializer.encode(entry.mon) then raw = entry.gciRaw
else
local compatible, err = Catalog.compatible(entry, entry.version)
if not compatible then return nil, err end
local native = codec.fromPortMon(entry.mon, {}, false)
if native.species <= 0 or native.species > 412 then return nil, "A species cannot be represented by native Pokémon Box." end
if native.heldItem > 346 then return nil, "This held item is outside original Pokémon Box's Ruby/Sapphire item range." end
for _, move in ipairs(native.moves) do
if move < 0 or move > 354 then return nil, "A move is outside the native Gen 3 move range." end
end
if entry.mon.nickname and native.nickname ~= entry.mon.nickname
or (entry.mon.otName or entry.mon.ot) and native.otName ~= (entry.mon.otName or entry.mon.ot) then
return nil, "A Pokémon name would be truncated by native export."
end
local rawNick = native.nicknameRaw and codec.decodeString(native.nicknameRaw,0,10,native.language) == native.nickname
local rawOt = native.otNameRaw and codec.decodeString(native.otNameRaw,0,7,native.language) == native.otName
if not native.nicknameBytes and not rawNick
and codec.decodeString(codec.encodeString(native.nickname,10,0xFF,native.language),0,10,native.language) ~= native.nickname
or not rawOt and codec.decodeString(codec.encodeString(native.otName,7,0xFF,native.language),0,7,native.language) ~= native.otName then
return nil, "A Pokémon name uses characters the native format cannot represent."
end
raw = codec.encodeBoxMon(native)
end
depositor = entry.depositorId or 0
end
put(8+((b-1)*60+slot-1)*84,raw..little(depositor))
end
end
table.sort(edits, function(a,b) return a[1] < b[1] end)
local pieces, at = {}, 0
local source = data.logical
for _, edit in ipairs(edits) do
pieces[#pieces+1] = source:sub(at+1, edit[1])
pieces[#pieces+1] = edit[2]
at = edit[1] + #edit[2]
end
pieces[#pieces+1] = source:sub(at+1)
local logical = table.concat(pieces)
local raw, bank = {}, 1-data.active.bank
local count = (data.active.count+1)%4294967296
local first = (1+bank*23)*GCI.BLOCK
raw[1] = data.raw:sub(1,first)
for id = 0, 22 do
local at = first+id*GCI.BLOCK
local old = data.raw:sub(at+1,at+GCI.BLOCK)
local block = string.rep("\0",4)..word(id)..word(count)
..logical:sub(id*GCI.BODY+1,(id+1)*GCI.BODY)..old:sub(0x1FFD)
raw[#raw+1] = word(GCI.checksum(block))..block:sub(5)
end
raw[#raw+1] = data.raw:sub(first+23*GCI.BLOCK+1)
return data.header..table.concat(raw)
end
return GCI
+49
View File
@@ -0,0 +1,49 @@
local Catalog = require("src.box.Catalog")
local Context = {}
local function capture(t, nested)
local rows = {}
for k, v in pairs(t) do
rows[k] = nested and type(v) == "table" and { table = v, rows = capture(v) } or { value = v }
end
return rows
end
local function restore(t, rows)
for k in pairs(t) do t[k] = nil end
for k, row in pairs(rows) do
if row.rows then restore(row.table, row.rows); t[k] = row.table else t[k] = row.value end
end
end
local function upvalue(fn, wanted)
for i = 1, math.huge do
local name, value = debug.getupvalue(fn, i)
if name == nil then return nil end
if name == wanted then return value end
end
end
function Context.withItems(version, operation)
local Items = require("src.core.game3.items_data")
local tables = {}
for _, key in ipairs({ "POCKET", "POCKET_RESULT", "POCKET_ORDER", "BAG_POCKET_ORDER",
"CAPACITY", "PACK_POCKET", "CONTAINERS", "BAG_MODEL" }) do
if type(Items[key]) == "table" then tables[#tables + 1] = { Items[key], capture(Items[key], key == "BAG_MODEL") } end
end
local labels = upvalue(Items.applyProfile, "LABEL_KEYS")
if type(labels) == "table" then tables[#tables + 1] = { labels, capture(labels) } end
local fields = capture(Items)
local ok, a, b = pcall(function()
local data = Catalog.get(version)
if not data.ready then return nil, "Import this game's ROM before receiving an event." end
Items.applyProfile(version)
Items.installPack({ items = data.items })
return operation()
end)
for _, row in ipairs(tables) do restore(row[1], row[2]) end
restore(Items, fields)
if not ok then return nil, tostring(a) end
return a, b
end
return Context
+94
View File
@@ -0,0 +1,94 @@
local Catalog = require("src.box.Catalog")
local Store = require("src.box.Store")
local Rng = require("src.core.game3.rng")
local GameVersion = require("src.core.GameVersion")
local Gifts = {}
Gifts.AWARDS = {
{ name = "Swablu", national = 333, threshold = 0, moves = { 64, 45, 206 } },
{ name = "Zigzagoon", national = 263, threshold = 100, moves = { 33, 45, 39, 245 } },
{ name = "Skitty", national = 300, threshold = 500, moves = { 45, 33, 39, 6 } },
{ name = "Pichu", national = 172, threshold = 1499, moves = { 84, 204, 57 } },
}
local MET_GAME = { sapphire = 1, ruby = 2, emerald = 3, firered = 4, leafgreen = 5 }
function Gifts.trainerId(save)
local tid = tonumber(save.trainerId or save.player and save.player.trainerId)
local sid = tonumber(save.secretId or save.player and save.player.secretId)
if tid == nil or sid == nil then return nil end
return tid % 65536 + (sid % 65536) * 65536
end
function Gifts.identity(source, save)
return table.concat({ source.version, source.path, tostring(Gifts.trainerId(save)),
tostring(save.meta and save.meta.playthroughId or save.name or save.playerName or "") }, "|")
end
function Gifts.flags(save)
return require("src.core.game3.save_mon").boxFlags(save) or 0
end
function Gifts.setFlags(save, value)
require("src.core.game3.save_mon").setBoxFlags(save, value)
end
function Gifts.progress(state, source, save)
if GameVersion.generation(source.version) ~= 3 then return nil, "Original Box gift eggs require a Gen 3 save." end
local tid = Gifts.trainerId(save)
if not tid then return nil, "This save needs a recorded trainer ID and secret ID for Box gifts." end
local count = 0
for _, box in ipairs(state.boxes) do
for _, entry in pairs(box.mons) do
if entry.generation == 3 and entry.depositorId == tid then count = count + 1 end
end
end
local key = Gifts.identity(source, save)
local peak = math.max(count, state.progress and state.progress[key] or 0)
local flags = Gifts.flags(save)
local nextAward = flags % 2 == 0 and 1 or math.floor(flags / 2) % 4 + 2
local award = Gifts.AWARDS[nextAward]
return { current = count, peak = peak, flags = flags, key = key,
nextAward = nextAward, award = award, eligible = award ~= nil and peak >= award.threshold }
end
function Gifts.recordProgress(state, source, save)
local progress = Gifts.progress(state, source, save)
if progress then state.progress = state.progress or {}; state.progress[progress.key] = progress.peak end
end
function Gifts.create(version, index, seed)
local award, data = Gifts.AWARDS[index], Catalog.get(version)
if not award or not data.ready then return nil, "Import this game's complete species and move data first." end
local species = (data.national.toSpecies or {})[award.national]
local meta = species and data.meta[species]
if not meta or meta.eggCycles == nil or meta.growthRate == nil then return nil, "The gift species data is incomplete." end
if seed == nil then seed = math.floor((love and love.timer and love.timer.getTime() or os.clock()) * 1000000) end
seed = math.floor(seed) % 4294967296
local function random()
seed = (Rng.mulU32(seed, 0x41C64E6D) + 0x6073) % 4294967296
return math.floor(seed / 65536)
end
local high, low = random(), random()
local first, second = random(), random()
local ivs = { hp = first % 32, atk = math.floor(first / 32) % 32,
def = math.floor(first / 1024) % 32, spe = second % 32,
spa = math.floor(second / 32) % 32, spd = math.floor(second / 1024) % 32 }
local mon = { species = species, speciesId = species, speciesNumbering = "internal",
personality = high * 65536 + low, ivs = ivs, evs = {}, contest = {},
exp = require("src.core.game3.summary_data").expForLevel(meta.growthRate, 5), level = 5,
nickname = "EGG", otName = "AZUSA", ot = "AZUSA", otId = 0, otSecretId = 0,
language = 1, otGender = 1, isEgg = true, friendship = meta.eggCycles,
eggCycles = meta.eggCycles, pokeball = 4, metLocation = 255, metLevel = 0,
metGame = MET_GAME[version], moves = Store.copy(award.moves), pp = {},
ribbons = 0, markings = 0, pokerus = 0, heldItem = 0, ppBonusesPacked = 0,
cartExtra = { nicknameBytes = { 0x60, 0x6F, 0x8B, 0xFF }, nicknameLanguage = 1 } }
local pair = data.abilities[species] or {}
mon.abilityNum = pair[2] and pair[2] ~= 0 and mon.personality % 2 or 0
for i, move in ipairs(mon.moves) do
local battle = data.battleMoves[move]
if not battle or battle.pp == nil then return nil, "The gift move data is incomplete." end
mon.pp[i] = battle.pp
end
return mon, seed
end
return Gifts
+154
View File
@@ -0,0 +1,154 @@
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" }
local function number(value) return tonumber(value) end
local function name(records, key)
local value = records and records[key]
if type(value) == "table" then return value.name or tostring(key) end
return value or (key ~= nil and tostring(key) or nil)
end
function Metadata.describe(data, mon, d, def)
local gen, pid = data.generation, number(mon.personality) or 0
local meta = gen == 3 and data.meta[tonumber(mon.species)] or def
d.trainer = mon.otName or mon.ot
d.trainerId, d.secretId = mon.otId or mon.trainerId, mon.otSecretId
d.friendship = mon.friendship or mon.happiness
d.experience = mon.exp or mon.experience
if gen == 3 then
local Summary = require("src.core.game3.summary_data")
local nature = pid % 25
d.nature, d.natureId = NATURES[nature + 1], nature
local ratio = meta and meta.genderRatio
if ratio ~= nil then
d.gender = ratio == 255 and "Genderless" or ratio == 0 and "Male"
or ratio == 254 and "Female" or (pid % 256 < ratio and "Female" or "Male")
end
local pair = data.abilities[tonumber(mon.species)] or {}
local slot = tonumber(mon.abilityNum)
if slot == nil then slot = pid % 2 end
local ability = pair[slot + 1]
if not ability or ability == 0 then ability = pair[1] end
d.ability = ability and name(data.abilityNames, ability) or nil
local growth = meta and meta.growthRate
if mon.level == nil and growth ~= nil and d.experience ~= nil then
d.level = 1
while d.level < 100 and Summary.expForLevel(growth, d.level + 1) <= d.experience do
d.level = d.level + 1
end
end
local base = data.stats[tonumber(mon.species)]
if base and d.level > 0 then
local iv, ev, stats = mon.ivs or {}, mon.evs or {}, {}
for _, row in ipairs({ { "hp", "hp" }, { "atk", "attack" }, { "def", "defense" },
{ "spe", "speed" }, { "spa", "spAtk" }, { "spd", "spDef" } }) do
local key, out = row[1], row[2]
local value = math.floor((2 * (base[key] or 0) + (iv[key] or 0)
+ math.floor((ev[key] or 0) / 4)) * d.level / 100)
if key == "hp" then value = d.national == 292 and 1 or value + d.level + 10
else
local natureKey = ({ spe = "spd", spa = "spAtk", spd = "spDef" })[key] or key
local percent = math.floor(Summary.natureStatModifier(nature, natureKey) * 100 + 0.5)
value = math.floor((value + 5) * percent / 100)
end
stats[out] = value
end
d.stats = stats
end
local location = data.locations[tonumber(mon.metLocation)]
d.location = mon.metLocationName or (type(location) == "table" and location.name)
or (mon.metLocation ~= nil and "Location " .. tostring(mon.metLocation) or nil)
d.ball = name(data.items, mon.pokeball or mon.ball)
d.metLevel, d.metGame = mon.metLevel, mon.metGame
d.markings = tonumber(mon.markings) or 0
elseif gen == 2 then
if def and def.genderRatio ~= nil then
local g = require("src.battle.gen2.Mon").vanillaGender(def, mon.dvs)
d.gender = g == "male" and "Male" or g == "female" and "Female" or "Genderless"
end
if def and def.baseStats then
d.stats = require("src.battle.gen2.Mon").stats(def.baseStats, mon.dvs, d.level, mon.statExp)
end
if mon.caughtLocation ~= nil then d.location = "Landmark " .. tostring(mon.caughtLocation) end
d.metLevel = mon.caughtLevel
elseif def and def.baseStats then
d.stats = require("src.pokemon.Stats").calc(def, d.level, mon.dvs or {}, mon.statExp)
end
d.stats = d.stats or mon.stats or {}
d.hp = d.stats.hp or mon.maxHp
d.attack = d.stats.attack or mon.attack
d.defense = d.stats.defense or mon.defense
d.speed = d.stats.speed or mon.speed
d.special = d.stats.special
d.spAtk = d.stats.spAtk or d.stats.specialAttack or mon.spAtk
d.spDef = d.stats.spDef or d.stats.specialDefense or mon.spDef
d.moveDetails = {}
local originalSlots = mon.cartExtra and mon.cartExtra.moveSlots
local slotCursor = 1
for i, move in ipairs(mon.moves or {}) do
local key = type(move) == "table" and (move.moveId or move.id or move.move or move.name) or move
if key and key ~= 0 then
local nativeSlot = i
if gen == 3 and originalSlots and originalSlots.moves then
while slotCursor <= 4 and (originalSlots.moves[slotCursor] or 0) == 0 do slotCursor = slotCursor + 1 end
if originalSlots.moves[slotCursor] == key then nativeSlot = slotCursor; slotCursor = slotCursor + 1 end
end
local battle = gen == 3 and data.battleMoves[key] or data.moves[key]
local basePP = type(battle) == "table" and battle.pp
local bonus = type(move) == "table" and (move.ppUps or move.ppUp or move.ppBonuses)
or type(mon.ppBonuses) == "table" and mon.ppBonuses[i]
bonus = tonumber(bonus) or math.floor((tonumber(mon.ppBonusesPacked) or 0) / 4^(nativeSlot - 1)) % 4
local maxPP = type(move) == "table" and move.maxPp
or basePP and basePP + math.floor(basePP * bonus / 5)
d.moveDetails[#d.moveDetails + 1] = { name = name(data.moves, key),
pp = type(move) == "table" and move.pp or (mon.pp or {})[i], maxPP = maxPP, ppUps = bonus }
end
end
d.contest, d.ribbons, d.pokerus = mon.contest, mon.ribbons, mon.pokerus
d.championRibbon, d.fateful = mon.championRibbon, mon.modernFatefulEncounter
return d
end
function Metadata.summary(entry)
local d, mon, gen = entry.display, entry.mon, entry.generation
local rows = {}
local function add(label, value)
rows[#rows + 1] = { label, value == nil and "Not recorded" or tostring(value) }
end
add("Original Trainer", d.trainer)
add("Trainer ID", d.trainerId)
if gen == 3 then add("Secret ID", d.secretId) end
add("Experience", d.experience)
if gen >= 2 then add("Gender", d.gender); add("Friendship / egg cycles", d.friendship) end
if gen == 3 then
add("Nature", d.nature); add("Ability", d.ability); add("Poké Ball", d.ball)
add("Met game", d.metGame); add("Markings", d.markings)
end
if gen >= 2 then add("Met location", d.location); add("Met level", d.metLevel); add("Pokérus", d.pokerus) end
for _, move in ipairs(d.moveDetails or {}) do
add(move.name, tostring(move.pp or "?") .. " / " .. tostring(move.maxPP or "?") .. " PP · " .. move.ppUps .. " PP Ups")
end
for _, field in ipairs({ { "Stats", d.stats }, { "IVs", mon.ivs }, { "DVs", mon.dvs },
{ gen == 3 and "EVs" or "Stat experience", gen == 3 and mon.evs or mon.statExp }, { "Contest", d.contest } }) do
if type(field[2]) == "table" then
local values = {}
for key, value in pairs(field[2]) do values[#values + 1] = key .. " " .. tostring(value) end
table.sort(values); add(field[1], #values > 0 and table.concat(values, " · ")
or (field[1] == "EVs" or field[1] == "Stat experience") and "All zero" or "Not recorded")
end
end
if gen == 3 then
local Ribbons = require("src.core.game3.rse.ribbons")
local names = {}
for _, key in ipairs(Ribbons.COUNTED) do
local count = Ribbons.get(mon, key)
if count > 0 then names[#names + 1] = key .. (count > 1 and " ×" .. count or "") end
end
add("Ribbons", #names > 0 and table.concat(names, ", ") or "None")
add("Fateful encounter", d.fateful)
end
return rows
end
return Metadata
+307
View File
@@ -0,0 +1,307 @@
local Store = require("src.box.Store")
local Catalog = require("src.box.Catalog")
local GameVersion = require("src.core.GameVersion")
local Serializer = require("src.core.SaveSerializer")
local Mon = require("src.battle.gen2.Mon")
local Pokemon = require("src.core.game3.pokemon")
local Unown = require("src.core.gen2.Unown")
local bit = require("bit")
local Migration = {}
local KEYS = { {"hp","hp"},{"atk","attack"},{"def","defense"},{"spe","speed"},{"spa","special"},{"spd","special"} }
local MET_GAME = { sapphire=1,ruby=2,emerald=3,firered=4,leafgreen=5 }
local function clamp(n,lo,hi) return math.max(lo,math.min(hi,math.floor(tonumber(n) or lo))) end
local function normalized(name) return tostring(name):upper():gsub("[%s_%p]","") end
local function label(value,key) return type(value)=="table" and (value.name or key) or value or key end
local function mapped(records,name)
local want,found=normalized(name),nil
for key,value in pairs(records) do
if normalized(label(value,key))==want then
if found~=nil and found~=key then return nil end
found=key
end
end
return found
end
local function nameFits(text,generation,limit)
if type(text)~="string" or text:find("%c") then return false end
if generation==3 then
local codec=require("src.save_convert.Gen3Save").forVersion("emerald")
return codec.decodeString(codec.encodeString(text,limit,0xFF),0,limit)==text
end
local chars=generation==1 and require("src.save_convert.data.charmap").byToken
or require("src.save_convert.Gen2Layout").charmap
local tokens={}
for key,value in pairs(chars) do
local glyph=generation==1 and key or value
local code=generation==1 and value or key
if type(glyph)=="string" and not glyph:find("[<>@{}]") and code~=0x50 then tokens[#tokens+1]=glyph end
end
table.sort(tokens,function(a,b) return #a>#b end)
local at,count=1,0
while at<=#text do
local found
for _,token in ipairs(tokens) do
if #token>0 and text:sub(at,at+#token-1)==token then found=token;break end
end
if not found then return false end
at,count=at+#found,count+1
if count>limit then return false end
end
return true
end
local function gender3(ratio,pid)
return ratio==255 and "Genderless" or ratio==0 and "Male" or ratio==254 and "Female"
or pid%256<ratio and "Female" or "Male"
end
local function gender2(def,dvs)
local gender=Mon.vanillaGender(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 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 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
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
and (bit.bxor(tid,high,low)<8)==shiny
and (not letter or Pokemon.unownLetter(pid)==letter) then found=pid;break end
end
if found or shiny and highVariant==7 then break end
end
pidCache[key]=found
return found or nil
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),
speed=math.floor((iv.spe or 0)/2),special=math.floor(((iv.spa or 0)+(iv.spd or 0))/4)}
local shiny=Pokemon.isShiny({personality=mon.personality,otId=mon.otId,otSecretId=mon.otSecretId})
local source=Catalog.get(mon._boxVersion)
local ratio=source.meta[mon.species] and source.meta[mon.species].genderRatio
if ratio==nil then return nil,"The source gender data is missing." end
local gender=gender3(ratio,mon.personality)
local letter=national==201 and Pokemon.unownLetter(mon.personality)+1
if letter and letter>26 then return nil,"Gen 2 cannot represent Unown ! or ?." end
local best,score
for n=0,65535 do
local d={attack=math.floor(n/4096),defense=math.floor(n/256)%16,speed=math.floor(n/16)%16,special=n%16}
if Mon.vanillaShiny(d)==shiny and (generation==1 or gender2(def,d)==gender)
and (not letter or Unown.letterFromDVs(d)==letter) then
local distance=0
for key,value in pairs(target) do distance=distance+math.abs(d[key]-value) end
if score==nil or distance<score then best,score=d,distance;if distance==0 then break end end
end
end
if not best then return nil,"Gen 2 cannot preserve this combination of gender, shininess and form." end
best.hp=Mon.hpDV(best)
return best
end
local function expAt(data,species,level)
if data.generation==3 then
return require("src.core.game3.summary_data").expForLevel(data.meta[species].growthRate,level)
end
return require("src.pokemon.Growth").expForLevel(data.pokemon[species].growthRate,level)
end
function Migration.convert(entry,version)
local from,to=Catalog.get(entry.version),Catalog.get(version)
local g1,g2=entry.generation,GameVersion.generation(version)
if not g2 or g1==g2 then return nil,"Choose a different generation; same-generation Pokémon can be withdrawn directly." end
if not from.ready or not to.ready then return nil,"Import both games' ROMs before converting." end
local mon=entry.mon
if mon.isBadEgg then return nil,"A Bad Egg cannot be migrated." end
if mon.isEgg and g2==1 then return nil,"Gen 1 cannot represent eggs." end
if mon.mail~=nil and mon.mail~=255 then return nil,"Remove held mail before converting." end
local d=Catalog.describe(entry.version,mon)
if type(mon.otId)~="number" or mon.otId%1~=0 or mon.otId<0 or mon.otId>65535 then return nil,"The original trainer ID is missing or invalid." end
if d.level<1 or d.level>100 or d.level%1~=0 then return nil,"The source level is invalid." end
if g1==3 and (not from.meta[mon.species] or from.meta[mon.species].growthRate==nil) then return nil,"Import complete source species data first." end
local national=d.national
local species=g2==3 and (to.national.toSpecies or {})[national] or to.byNational[national]
if not species then return nil,"The destination cannot represent this species." end
local def=g2==3 and to.meta[species] or to.pokemon[species]
if not def or def.growthRate==nil or g2==3 and (not to.stats[species] or def.genderRatio==nil)
or g2<3 and not def.baseStats then return nil,"Import complete destination species data first." end
local nickname=mon.nickname or d.species
if g2<3 and normalized(nickname)==normalized(d.species) then nickname=nil end
local ot=mon.otName or mon.ot or ""
if nickname~=nil and not nameFits(nickname,g2,10) or not nameFits(ot,g2,7) then
return nil,"The destination cannot represent this nickname or trainer name without changing it."
end
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 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
end
local moves,pps,ups={},{},{}
for _,move in ipairs(mon.moves or {}) do
local key=type(move)=="table" and (move.moveId or move.id or move.move or move.name) or move
if key and key~=0 then
if not from.moves[key] then return nil,"A move is unknown in the source game." end
local id=mapped(to.moves,label(from.moves[key],key))
local row=id and (g2==3 and to.battleMoves[id] or to.moves[id])
if not row or not row.pp then return nil,"The destination cannot represent move "..label(from.moves[key],key).."." end
local detail=d.moveDetails[#pps+1]
local bonus=detail and detail.ppUps or 0
local max=row.pp+bonus*(g2==3 and math.floor(row.pp/5) or math.min(7,math.floor(row.pp/5)))
local pp=clamp(detail and detail.pp or max,0,max)
moves[#moves+1]=g2==3 and id or {id=id,pp=pp,ppUps=bonus,maxPp=max}
pps[#pps+1],ups[#ups+1]=pp,bonus
end
end
if #moves>4 then return nil,"The destination can represent at most four moves." end
local out={species=species,nickname=nickname,ot=ot,otName=ot,otId=clamp(mon.otId,0,65535),
level=clamp(d.level,1,100),moves=moves,isEgg=mon.isEgg or nil,item=targetItem,traded=mon.traded,
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
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
if type(mon.dvs[key])~="number" or mon.dvs[key]%1~=0 or mon.dvs[key]<0 or mon.dvs[key]>15 then return nil,"The source DVs are invalid." end
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))
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.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
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.")
elseif g1==3 then
local input=Store.copy(mon);input._boxVersion=entry.version
local dvs,why=dvsFor(input,def,national,g2)
if not dvs then return nil,why end
out.dvs,out.statExp=dvs,{}
for _,pair in ipairs(KEYS) do
local effort=math.floor(clamp((mon.evs or {})[pair[1]],0,255)/4)*4
out.statExp[pair[2]]=math.max(out.statExp[pair[2]] or 0,effort^2)
end
out.happiness=g2==2 and clamp(mon.friendship or mon.happiness or 70,0,255) or nil
out.caughtLevel,out.caughtLocation,out.caughtGender=g2==2 and out.level or nil,g2==2 and 0 or nil,g2==2 and (mon.otGender or 0) or nil
note("IVs → nearest DVs preserving shininess"..(g2==2 and ", gender and Unown letter." or " as a latent DV pattern."))
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.")
else
local prepared=Store.copy(mon);prepared.species=mon.species
local Convert=require("src.online.Convert")
local native,why=(g2==2 and Convert.toGen2 or Convert.toGen1)(prepared,from,to)
if not native then return nil,"Time Capsule conversion refused: "..tostring(why) end
out.dvs,out.statExp=Store.copy(native.dvs),Store.copy(native.statExp)
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
end
local oldExp=tonumber(mon.exp or mon.experience)
local oldBase=expAt(from,mon.species,out.level)
local newBase=expAt(to,species,out.level)
local experience=newBase
if oldExp and out.level<100 then
local oldNext,newNext=expAt(from,mon.species,out.level+1),expAt(to,species,out.level+1)
local fraction=math.max(0,math.min(1,(oldExp-oldBase)/math.max(1,oldNext-oldBase)))
experience=newBase+math.floor(fraction*(newNext-newBase))
end
if g2==2 then out.experience=experience else out.exp=experience end
note("Level "..out.level.." retained; experience "..tostring(oldExp or "unrecorded").." → "..experience.." (same progress within the level).")
local statuses={SLP="sleep",PSN="poison",BRN="burn",PAR="paralyze",FRZ="freeze"}
local reverse={sleep="SLP",poison="PSN",toxic="PSN",burn="BRN",paralyze="PAR",freeze="FRZ"}
out.status=g2==2 and (statuses[mon.status] or mon.status) or (reverse[mon.status] or mon.status)
out.sleep=mon.sleep or mon.sleepTurns
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
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")..".")
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.preview(state,refs,version)
local preview={revision=state.revision,destination=version,rows={},allowed=true}
local seen={}
if type(refs)~="table" or #refs==0 then return nil,"Select warehouse Pokémon to preview." end
for _,ref in ipairs(refs) do
local entry=Store.at(state,ref.box,ref.slot)
if not entry or seen[entry.id] then return nil,"A selected Pokémon is missing or repeated." end
seen[entry.id]=true
local converted,lines=Migration.convert(entry,version)
preview.rows[#preview.rows+1]={id=entry.id,name=entry.display.name,original=Serializer.encode(entry),
converted=converted,lines=converted and lines or {lines}}
if not converted then preview.allowed=false end
end
return preview
end
function Migration.apply(state,refs,version,preview)
if type(preview)~="table" or preview.revision~=state.revision or preview.destination~=version then
return nil,"Storage changed. Preview the migration again."
end
local fresh,why=Migration.preview(state,refs,version)
if not fresh then return nil,why end
if not fresh.allowed then return nil,"The destination cannot represent every selected Pokémon." end
if Serializer.encode(preview)~=Serializer.encode(fresh) then return nil,"The preview changed. Review it again." end
local nextState=Store.copy(state)
for i,ref in ipairs(refs) do
local entry=Store.at(nextState,ref.box,ref.slot)
local snapshot=Store.copy(entry);snapshot.archives=nil
entry.archives=entry.archives or {};entry.archives[#entry.archives+1]=snapshot
entry.version,entry.generation,entry.mon=version,GameVersion.generation(version),fresh.rows[i].converted
entry.display=Catalog.describe(version,entry.mon)
entry.gciRaw,entry.gciOriginal=nil,nil
end
nextState.revision=state.revision+1
return nextState
end
function Migration.restore(state,ref,index)
local entry=Store.at(state,ref.box,ref.slot)
local original=entry and entry.archives and entry.archives[index]
if not original then return nil,"Choose an archived original that is still in Box." end
local nextState=Store.copy(state)
local archives=Store.copy(entry.archives);table.remove(archives,index)
local current=Store.copy(entry);current.archives=nil;archives[#archives+1]=current
local restored=Store.copy(original);restored.archives=archives
restored.display=Catalog.describe(restored.version,restored.mon)
nextState.boxes[ref.box].mons[ref.slot]=restored
nextState.revision=state.revision+1
return nextState
end
return Migration
+333
View File
@@ -0,0 +1,333 @@
local Store = require("src.box.Store")
local Records = require("src.box.Records")
local Catalog = require("src.box.Catalog")
local GameVersion = require("src.core.GameVersion")
local Organizer = {}
Organizer.SORTS = { "species", "name", "level", "type", "nature", "ability", "trainer", "shiny", "egg", "friendship" }
Organizer.LABELS = { species = "Pokédex number", name = "Nickname", level = "Level", type = "Type",
nature = "Nature", ability = "Ability", trainer = "Trainer", shiny = "Shiny", egg = "Egg", friendship = "Friendship" }
Organizer.FIELDS = { "shiny", "egg", "species", "type", "levelMin", "levelMax", "nature", "ability",
"gender", "trainer", "game", "mark", "tag", "name", "move", "item" }
Organizer.FIELD_LABELS = { shiny = "Shiny", egg = "Egg", species = "Species", type = "Type",
levelMin = "Level at least", levelMax = "Level at most", nature = "Nature", ability = "Ability",
gender = "Gender", trainer = "Trainer", game = "Game", mark = "Marked", tag = "Tag contains",
name = "Nickname", move = "Move", item = "Held item" }
function Organizer.available(field, version)
if not version then return true end
local generation = GameVersion.generation(version)
if field == "tag" then return false end
if generation < 3 and (field == "mark" or field == "nature" or field == "ability") then return false end
if generation == 1 and (field == "gender" or field == "egg" or field == "item") then return false end
return true
end
local function integer(n, lo, hi)
return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi
end
local function contains(list, value)
for _, item in ipairs(list) do if item == value then return true end end
return false
end
local function array(list, max)
if type(list) ~= "table" or #list > max then return false end
for key in pairs(list) do if not integer(key, 1, #list) then return false end end
return true
end
function Organizer.validate(config, count)
count = count or Store.BOXES
if type(config) ~= "table" or (config.mode ~= "sort" and config.mode ~= "rules")
or not contains(Organizer.SORTS, config.sort)
or type(config.descending) ~= "boolean"
or config.box ~= nil and not integer(config.box, 1, count) then
return nil, "Choose a valid sort and box."
end
if config.mode == "sort" then
if config.rules ~= nil then
local ruleConfig = Store.copy(config)
ruleConfig.mode, ruleConfig.box = "rules", nil
return Organizer.validate(ruleConfig)
end
return true
end
if config.box ~= nil or not array(config.rules, 24) or #config.rules == 0
or (config.fallback ~= "keep" and config.fallback ~= "sort") then
return nil, "Add a rule and choose what happens to the rest."
end
for index, rule in ipairs(config.rules) do
if type(rule) ~= "table" or not integer(rule.firstBox, 1, count)
or not integer(rule.lastBox, rule.firstBox, count) then
return nil, "Rule " .. index .. " needs a destination available in this collection."
end
if not array(rule.conditions, 8) or #rule.conditions == 0
or (rule.match ~= "all" and rule.match ~= "any") then
return nil, "Rule " .. index .. " needs a condition."
end
for _, condition in ipairs(rule.conditions) do
if type(condition) ~= "table" or not contains(Organizer.FIELDS, condition.field) then
return nil, "A rule condition is invalid."
end
local field, value = condition.field, condition.value
if field == "shiny" or field == "egg" then
if type(value) ~= "boolean" then return nil, "Choose yes or no for this condition." end
elseif field == "levelMin" or field == "levelMax" then
if not integer(value, 0, 255) then return nil, "Choose a level from 0 to 255." end
elseif type(value) ~= "string" or #value == 0 or #value > 128 or value:find("%c") then
return nil, "Choose a value for this condition."
elseif field == "game" and not GameVersion.VERSIONS[value]
or field == "mark" and not contains({ "circle", "square", "triangle", "heart" }, value) then
return nil, "Choose a valid game or marking."
end
end
end
return true
end
local function normalized(value) return require("src.box.Search").normalize(value) end
local function matches(entry, condition)
local d, field, wanted = entry.display, condition.field, condition.value
if field == "shiny" or field == "egg" then return (d[field] == true) == wanted end
if field == "levelMin" then return tonumber(d.level) ~= nil and tonumber(d.level) >= wanted end
if field == "levelMax" then return tonumber(d.level) ~= nil and tonumber(d.level) <= wanted end
if field == "mark" then
local mask = ({ circle = 1, square = 2, triangle = 4, heart = 8 })[wanted]
local value = tonumber(entry.generation == 3 and entry.mon.markings or entry.markings) or 0
return math.floor(value / mask) % 2 == 1
end
if field == "tag" then return normalized(entry.tags):find(normalized(wanted), 1, true) ~= nil end
if field == "type" then
for item in tostring(d.types or ""):gmatch("[^/]+") do
if normalized(item:match("^%s*(.-)%s*$")) == normalized(wanted) then return true end
end
return false
end
if field == "move" then
for item in tostring(d.moves or ""):gmatch("[^,]+") do
if normalized(item:match("^%s*(.-)%s*$")) == normalized(wanted) then return true end
end
return false
end
local value = field == "game" and entry.version or d[field]
return value ~= nil and normalized(value) == normalized(wanted)
end
local function ruleMatches(entry, rule)
for _, condition in ipairs(rule.conditions) do
local hit = matches(entry, condition)
if rule.match == "any" and hit then return true end
if rule.match == "all" and not hit then return false end
end
return rule.match == "all"
end
local function sortValue(entry, field)
local d = entry.display
if field == "species" then
local national = tonumber(d.national)
return national and national > 0 and national or nil
end
if field == "type" then return d.types and d.types ~= "" and normalized(d.types) or nil end
if field == "shiny" or field == "egg" then return d[field] and 1 or 0 end
if field == "level" or field == "friendship" then return tonumber(d[field]) end
return d[field] and d[field] ~= "" and normalized(d[field]) or nil
end
local function compare(a, b, config)
local av, bv = sortValue(a.entry, config.sort), sortValue(b.entry, config.sort)
if av == nil or bv == nil then
if av ~= bv then return av ~= nil end
elseif av ~= bv then
if config.descending then return av > bv end
return av < bv
end
for _, key in ipairs({ "species", "name", "level" }) do
av, bv = sortValue(a.entry, key), sortValue(b.entry, key)
if av == nil or bv == nil then
if av ~= bv then return av ~= nil end
elseif av ~= bv then return av < bv end
end
return a.box < b.box or a.box == b.box and a.slot < b.slot
end
function Organizer.warehouse(state)
local layout = { count = Store.BOXES, capacity = Store.SLOTS, rows = {}, names = {} }
for b, box in ipairs(state.boxes) do
layout.names[b] = box.name
for slot = 1, Store.SLOTS do
if box.mons[slot] then layout.rows[#layout.rows + 1] = { box = b, slot = slot, entry = box.mons[slot] } end
end
end
return layout
end
function Organizer.game(save, version)
if not GameVersion.VERSIONS[version] then return nil, "Choose a supported game." end
local generation = GameVersion.generation(version)
if not Catalog.get(version).ready then return nil, "Import this game's ROM before organizing its PC." end
local layout = { count = generation == 1 and 12 or 14, capacity = generation == 3 and 30 or 20,
rows = {}, names = {}, compact = generation < 3 }
local boxes = Records.boxes(save, generation)
if type(boxes) ~= "table" then return nil, "This save's PC could not be read." end
for b, box in pairs(boxes) do
if not integer(b, 1, layout.count) or type(box) ~= "table" then
return nil, "This save has a PC box this tool cannot represent."
end
end
for b = 1, layout.count do
local list = Records.list(save, generation, b)
if type(list) ~= "table" then return nil, "A PC box could not be read." end
local seen = {}
for key, mon in pairs(list) do
local slot = tonumber(key)
if not integer(slot, 1, layout.capacity) or type(mon) ~= "table" or seen[slot] then
return nil, "A PC record is invalid or appears twice in one slot."
end
seen[slot] = true
end
local box = boxes[b]
layout.names[b] = generation == 3 and box and box.name or save.boxNames and save.boxNames[b] or "BOX " .. b
for slot = 1, layout.capacity do
local mon = list[slot] or list[tostring(slot)]
if mon then layout.rows[#layout.rows + 1] = { box = b, slot = slot,
entry = { version = version, generation = generation, mon = mon, display = Catalog.describe(version, mon) } } end
end
end
return layout
end
function Organizer.analyze(layout, config)
local valid, why = Organizer.validate(config, layout.count)
if not valid then return nil, why end
local counts, reserved, rest = {}, {}, 0
for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do
counts[i] = 0
for b = rule.firstBox, rule.lastBox do reserved[b] = true end
end
for _, row in ipairs(layout.rows) do
local found
for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do
if ruleMatches(row.entry, rule) then counts[i], found = counts[i] + 1, true; break end
end
if not found then rest = rest + 1 end
end
local free = 0
for b = 1, layout.count do if not reserved[b] then free = free + 1 end end
return { ruleCounts = counts, remaining = rest, reserved = reserved, freeBoxes = free, total = #layout.rows }
end
function Organizer.plan(layout, config)
local valid, why = Organizer.validate(config, layout.count)
if not valid then return nil, why end
local boxes, groups, rest, reserved = {}, {}, {}, {}
local report = { total = #layout.rows, moved = 0, ruleCounts = {}, boxes = {}, assignments = {} }
for b = 1, layout.count do
boxes[b] = {}
report.boxes[b] = { name = layout.names[b], before = 0, after = 0, capacity = layout.capacity, moved = 0 }
end
for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do
groups[i], report.ruleCounts[i] = {}, 0
for b = rule.firstBox, rule.lastBox do reserved[b] = true end
end
for _, row in ipairs(layout.rows) do
report.boxes[row.box].before = report.boxes[row.box].before + 1
local chosen
if config.mode == "rules" then
for i, rule in ipairs(config.rules) do
if ruleMatches(row.entry, rule) then chosen = i; break end
end
end
if chosen then
groups[chosen][#groups[chosen] + 1] = row
report.ruleCounts[chosen] = report.ruleCounts[chosen] + 1
elseif config.mode == "rules" and config.fallback == "keep"
or config.mode == "sort" and config.box and row.box ~= config.box then
boxes[row.box][row.slot] = row
else rest[#rest + 1] = row end
end
local function place(rows, firstBox, lastBox, skipReserved)
table.sort(rows, function(a, b) return compare(a, b, config) end)
local index = 1
for b = firstBox, lastBox do
if not skipReserved or not reserved[b] then
for slot = 1, layout.capacity do
if not boxes[b][slot] and rows[index] then boxes[b][slot], index = rows[index], index + 1 end
end
end
end
return index > #rows
end
if config.mode == "rules" then
for i, rule in ipairs(config.rules) do
if not place(groups[i], rule.firstBox, rule.lastBox) then
return nil, "Rule " .. i .. " needs more room in its destination. Widen its box range."
end
end
end
if not place(rest, config.box or 1, config.box or layout.count, config.mode == "rules") then
return nil, "The remaining Pokémon need more room outside the rule boxes. Keep their boxes or free a destination."
end
for b = 1, layout.count do
if layout.compact and (config.mode == "rules" or not config.box or config.box == b) then
local dense = {}
for slot = 1, layout.capacity do if boxes[b][slot] then dense[#dense + 1] = boxes[b][slot] end end
boxes[b] = dense
end
for slot = 1, layout.capacity do
local row = boxes[b][slot]
if row then
report.boxes[b].after = report.boxes[b].after + 1
local moved = row.box ~= b or row.slot ~= slot
if moved then report.moved, report.boxes[b].moved = report.moved + 1, report.boxes[b].moved + 1 end
report.assignments[#report.assignments + 1] = { fromBox = row.box, fromSlot = row.slot,
box = b, slot = slot, entry = row.entry, moved = moved }
end
end
end
return report, boxes
end
function Organizer.applyWarehouse(state, config)
local report, boxes = Organizer.plan(Organizer.warehouse(state), config)
if not report then return nil, boxes end
local nextState = Store.copy(state)
for b = 1, Store.BOXES do
nextState.boxes[b].mons = {}
for slot, row in pairs(boxes[b]) do nextState.boxes[b].mons[slot] = Store.copy(row.entry) end
end
nextState.revision = state.revision + 1
return nextState, report
end
function Organizer.applyGame(save, version, config)
local layout, why = Organizer.game(save, version)
if not layout then return nil, why end
local valid, err = Organizer.validate(config, layout.count)
if not valid then return nil, err end
if config.mode == "rules" then
for _, rule in ipairs(config.rules) do
for _, condition in ipairs(rule.conditions) do
if not Organizer.available(condition.field, version) then
return nil, GameVersion.info(version).label .. "'s Game PC doesn't record "
.. Organizer.FIELD_LABELS[condition.field] .. ". Choose another condition."
end
end
end
end
local report, boxes = Organizer.plan(layout, config)
if not report then return nil, boxes end
local nextSave, generation = Store.copy(save), GameVersion.generation(version)
if generation == 3 then
nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} }
nextSave.storage.boxes = nextSave.storage.boxes or {}
for b = 1, layout.count do
if config.mode == "rules" or not config.box or config.box == b then
local box = nextSave.storage.boxes[b] or { name = layout.names[b], wallpaper = 0 }
box.mons = {}; nextSave.storage.boxes[b] = box
for slot, row in pairs(boxes[b]) do box.mons[slot] = Store.copy(row.entry.mon) end
end
end
else
nextSave.boxes, nextSave.box = Records.boxes(nextSave, generation), nil
for b = 1, layout.count do
if config.mode == "rules" or not config.box or config.box == b then
nextSave.boxes[b] = {}
for slot, row in pairs(boxes[b]) do nextSave.boxes[b][slot] = Store.copy(row.entry.mon) end
end
end
end
return nextSave, report
end
return Organizer
+194
View File
@@ -0,0 +1,194 @@
local Store = require("src.box.Store")
local Records = {}
function Records.boxes(save, generation)
if generation == 3 then
return save.storage and save.storage.boxes or {}
end
return save.boxes or (save.box and { save.box }) or {}
end
function Records.list(save, generation, box)
local row = Records.boxes(save, generation)[box]
if type(row) ~= "table" then return {} end
return generation == 3 and (row.mons or {}) or row
end
function Records.at(save, generation, ref)
if type(ref) ~= "table" then return nil end
local list = ref.where == "party" and (save.party or {})
or Records.list(save, generation, ref.box)
return list[ref.index] or list[tostring(ref.index)]
end
function Records.candidates(save, generation)
local out = {}
for i, mon in ipairs(save.party or {}) do
out[#out + 1] = { where = "party", index = i, mon = mon, source = "Party" }
end
local count, size = generation == 1 and 12 or 14, generation == 3 and 30 or 20
for b = 1, count do
local row = Records.boxes(save, generation)[b]
local name = generation == 3 and row and row.name
or save.boxNames and save.boxNames[b] or "BOX " .. b
for i = 1, size do
local mon = Records.list(save, generation, b)[i]
or Records.list(save, generation, b)[tostring(i)]
if type(mon) == "table" then
out[#out + 1] = { where = "box", box = b, index = i, mon = mon, source = name }
end
end
end
return out
end
function Records.key(ref)
return table.concat({ ref.where or "box", ref.box or 0, ref.index or ref.slot or 0 }, ":")
end
function Records.identity(generation, mon)
if generation == 3 then
if type(mon.personality) ~= "number" then return nil end
return "3|" .. mon.personality .. "|" .. tostring(mon.otId or 0) .. "|" .. tostring(mon.otSecretId or 0)
end
if type(mon.dvs) ~= "table" or mon.otId == nil then return nil end
local dvs = mon.dvs
local ot = mon.otName or mon.ot
if ot == nil then ot = require("src.core.TrainerIdentity").monName(mon) end
return table.concat({ generation, mon.otId, ot or "",
dvs.attack or 0, dvs.defense or 0, dvs.speed or 0, dvs.special or 0 }, "|")
end
local function catalog(version)
return require("src.box.Catalog").get(version)
end
function Records.enterBox(version, generation, mon)
if type(mon) ~= "table" then return mon end
if generation == 2 then
require("src.core.gen2.Boxes").enterBox(mon, catalog(version))
elseif generation == 3 then
pcall(require("src.core.game3.storage").fullHealMon, mon, { version = version })
end
return mon
end
function Records.leaveBox(version, generation, mon)
if type(mon) ~= "table" then return mon end
if generation == 1 then
local data = catalog(version)
require("src.pokemon.Stats").ensure(data.pokemon and data.pokemon[mon.species], mon)
elseif generation == 2 then
mon.status, mon.statusTurns = nil, nil
if mon.isEgg then mon.hp = 0 else mon.hp = mon.maxHp or mon.hp end
end
return mon
end
function Records.registerReceived(save, generation, mon)
if mon.isEgg then return end
if generation == 3 then
save.dex = save.dex or {}
require("src.core.game3.dex").handleSetPokedexFlag(save.dex, mon.species, true, mon.personality)
else
save.pokedex = save.pokedex or {}
local dex = save.pokedex
dex.seen = dex.seen or {}
local key = generation == 2 and "caught" or "owned"
dex[key] = dex[key] or {}
dex.seen[mon.species], dex[key][mon.species] = true, true
if generation == 2 and mon.species == "UNOWN" then
local Unown = require("src.core.gen2.Unown")
local letter = Unown.monLetter(mon)
save.unownDex = save.unownDex or {}
local found = false
for _, value in ipairs(save.unownDex) do if value == letter then found = true end end
if letter and not found then save.unownDex[#save.unownDex + 1] = letter end
end
end
end
function Records.remove(save, generation, refs)
local nextSave = Store.copy(save)
local seen, party = {}, false
for _, ref in ipairs(refs) do
local key = Records.key(ref)
if seen[key] or not Records.at(nextSave, generation, ref) then return nil, "A selected Pokémon is missing." end
seen[key] = true
local mon = Records.at(nextSave, generation, ref)
local mail = tonumber(mon.mail)
if generation == 3 and mail and mail ~= 255 then return nil, "Remove held mail before depositing." end
if generation == 2 and (require("src.core.gen2.Mail").monHoldsMail(mon)
or ref.where == "party" and nextSave.mail and nextSave.mail.party
and nextSave.mail.party[ref.index]) then return nil, "Remove held mail before depositing." end
local list
if ref.where == "party" then list, party = nextSave.party, true
else
if generation ~= 3 and not nextSave.boxes then
nextSave.boxes = { nextSave.box or {} }; nextSave.box = nil
end
list = Records.list(nextSave, generation, ref.box)
end
list[ref.index], list[tostring(ref.index)] = nil, nil
end
local function compact(list)
local keys, values = {}, {}
for key, value in pairs(list) do
if type(value) == "table" then keys[#keys + 1] = tonumber(key) end
end
table.sort(keys)
for _, key in ipairs(keys) do values[#values + 1] = list[key] or list[tostring(key)] end
for key in pairs(list) do list[key] = nil end
for i, value in ipairs(values) do list[i] = value end
return keys
end
if party then
local oldSlots = compact(nextSave.party)
if generation == 2 and nextSave.mail and type(nextSave.mail.party) == "table" then
local oldMail, nextMail = nextSave.mail.party, {}
for index, oldIndex in ipairs(oldSlots) do nextMail[index] = oldMail[oldIndex] end
nextSave.mail.party = nextMail
end
local healthy = 0
for _, mon in ipairs(nextSave.party) do
if not mon.isEgg and (tonumber(mon.hp) or 0) > 0 then healthy = healthy + 1 end
end
if healthy == 0 then return nil, "Keep at least one Pokémon able to battle in the party." end
end
if generation < 3 then
for _, list in pairs(nextSave.boxes or {}) do compact(list) end
end
return nextSave
end
function Records.insert(save, generation, box, entries)
local count, capacity = generation == 1 and 12 or 14, generation == 3 and 30 or 20
if type(box) ~= "number" or box < 1 or box > count or box ~= math.floor(box) then
return nil, "Choose a valid destination PC box."
end
local nextSave = Store.copy(save)
local boxes
if generation == 3 then
nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} }
nextSave.storage.boxes = nextSave.storage.boxes or {}
boxes = nextSave.storage.boxes
boxes[box] = boxes[box] or { name = "BOX " .. box, mons = {} }
boxes[box].mons = boxes[box].mons or {}
else
nextSave.boxes = nextSave.boxes or { nextSave.box or {} }
nextSave.box = nil
boxes = nextSave.boxes
boxes[box] = boxes[box] or {}
end
local list = generation == 3 and boxes[box].mons or boxes[box]
for _, entry in ipairs(entries) do
local free
for i = 1, capacity do if not (list[i] or list[tostring(i)]) then free = i; break end end
if not free then return nil, "That PC box does not have enough room." end
list[free] = Store.copy(entry.mon)
Records.registerReceived(nextSave, generation, list[free])
end
return nextSave
end
return Records
+66
View File
@@ -0,0 +1,66 @@
local Search = {}
local FOLD = { ["É"]="e",["é"]="e",["È"]="e",["è"]="e",["Ê"]="e",["ê"]="e",
["À"]="a",["à"]="a",["Á"]="a",["á"]="a",["Ä"]="a",["ä"]="a",
["Í"]="i",["í"]="i",["Ï"]="i",["ï"]="i",["Ó"]="o",["ó"]="o",["Ö"]="o",["ö"]="o",
["Ú"]="u",["ú"]="u",["Ü"]="u",["ü"]="u",["Ñ"]="n",["ñ"]="n",["Ç"]="c",["ç"]="c" }
function Search.normalize(value)
return (tostring(value or ""):gsub("[%z\1-\127\194-\244][\128-\191]*",function(glyph)
return FOLD[glyph] or glyph:lower()
end))
end
function Search.compile(query)
local terms = {}
local token, quoted = "", false
for ch in Search.normalize(query):gmatch(".") do
if ch == '"' then quoted = not quoted
elseif ch:match("%s") and not quoted then
if token ~= "" then terms[#terms + 1], token = token, "" end
else token = token .. ch end
end
if token ~= "" then terms[#terms + 1] = token end
return function(entry, boxName)
if #terms == 0 then return true end
local d = entry.display
local fields = { name = d.name, species = d.species, dex = d.national,
type = d.types, move = d.moves, item = d.item, game = entry.version, box = boxName,
trainer = d.trainer, gender = d.gender, nature = d.nature, ability = d.ability,
location = d.location, tag = entry.tags }
local all = Search.normalize(table.concat({ tostring(d.name or ""), tostring(d.species or ""),
tostring(d.national or ""), tostring(d.types or ""), tostring(d.moves or ""),
tostring(d.item or ""), tostring(d.level or ""), entry.version, tostring(boxName or ""),
tostring(d.trainer or ""), tostring(d.nature or ""), tostring(d.ability or ""),
tostring(d.gender or ""), tostring(entry.tags or "") }, " "))
for _, raw in ipairs(terms) do
local term, negate = raw, false
if term:sub(1, 1) == "-" then term, negate = term:sub(2), true end
local matched
local property, operator, number = term:match("^(%a+)([<>=]+)(%d+)$")
local numeric = { level = d.level, lv = d.level, hp = d.hp, attack = d.attack,
defense = d.defense, speed = d.speed, special = d.special, spatk = d.spAtk,
spdef = d.spDef, friendship = d.friendship }
if property and numeric[property] ~= nil then
local value, wanted = tonumber(numeric[property]) or 0, tonumber(number)
matched = operator == "<" and value < wanted or operator == ">" and value > wanted
or operator == "<=" and value <= wanted or operator == ">=" and value >= wanted
or operator == "=" and value == wanted
elseif term == "shiny" then matched = d.shiny == true
elseif term == "egg" then matched = d.egg == true
elseif term:match("^mark:") then
local mark = ({ circle = 1, square = 2, triangle = 4, heart = 8 })[term:sub(6)]
local value = tonumber(entry.generation == 3 and entry.mon.markings or entry.markings) or 0
matched = mark ~= nil and math.floor(value / mark) % 2 == 1
else
local key, value = term:match("^(%a+):(.+)$")
local text = key and fields[key]
if key == "gender" and text ~= nil then matched = Search.normalize(text) == value
elseif text ~= nil then matched = Search.normalize(text):find(value, 1, true) ~= nil
else matched = all:find(term, 1, true) ~= nil end
end
if (matched and negate) or (not matched and not negate) then return false end
end
return true
end
end
return Search
+481
View File
@@ -0,0 +1,481 @@
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local Serializer = require("src.core.SaveSerializer")
local Store = require("src.box.Store")
local Transaction = require("src.box.Transaction")
local Records = require("src.box.Records")
local Catalog = require("src.box.Catalog")
local Service = {}
Service.__index = Service
local function sourceCart(source)
return source.cartId or type(source.path) == "string" and source.path:match("^saves/cart_([%w%._%-]+)/slot%d+%.lua$") or nil
end
function Service.open(fs)
fs = fs or SaveData.persistenceFs()
if not fs then return nil, "Save storage is unavailable." end
local ok, why, notice = Transaction.recover(fs)
if not ok then return nil, why end
local state, err, body = Store.load(fs)
if not state then return nil, err end
local repaired, changed = pcall(require("src.save_convert.Gen3Save").repairJapaneseNames, state)
if body and repaired and changed then
local after = Serializer.encode(state)
if Transaction.commit(fs, { { path = Store.PATH, before = body, after = after } }) then body = after end
end
for _, box in ipairs(state.boxes) do
for _, entry in pairs(box.mons) do entry.display = Catalog.describe(entry.version, entry.mon) end
end
return setmetatable({ fs = fs, state = state, body = body, recoveryNotice = notice }, Service)
end
function Service.sources()
local out = {}
for _, version in ipairs(GameVersion.ORDER) do
for _, slot in ipairs(SaveData.listSlots(version)) do
if slot.exists then
out[#out + 1] = { version = version, slotId = slot.id,
path = "saves/" .. version .. "/" .. slot.id .. ".lua",
label = GameVersion.info(version).label .. " · " .. tostring(slot.name or slot.id) }
end
end
end
for _, cartId in ipairs(SaveData.cartsWithSlots()) do
for _, slot in ipairs(SaveData.listCartSlots(cartId)) do
if slot.exists and not SaveData.slotSealBroken(cartId, slot.id) then
local body = SaveData.readCartSlotSource(cartId, slot.id)
local save = body and Serializer.decode(body)
if type(save) == "table" and GameVersion.VERSIONS[save.version] then
out[#out + 1] = { version = save.version, slotId = slot.id, cartId = cartId,
path = "saves/cart_" .. cartId .. "/" .. slot.id .. ".lua",
label = cartId .. " · " .. tostring(slot.name or slot.id) }
end
end
end
end
return out
end
function Service:read(source)
if type(source) ~= "table" or not GameVersion.VERSIONS[source.version] then return nil, "Choose a save." end
if source.cartId and SaveData.slotSealBroken(source.cartId, source.slotId) then
return nil, "This save's seal is broken."
end
local checked, pending = pcall(require("src.online.Trade").pendingSentAt, source.path)
if not checked then return nil, "Online trade status could not be read." end
if #pending > 0 then return nil, "Finish this save's pending online trade before using Box." end
local body = self.fs.read(source.path)
local save, why
if body then save, why = Serializer.decode(body) end
if type(save) ~= "table" then return nil, why or "That save could not be read." end
if save.version and save.version ~= source.version then return nil, "The save's game does not match." end
if save.generation and save.generation ~= GameVersion.generation(source.version) then
return nil, "The save's generation does not match."
end
if GameVersion.generation(source.version) == 3 then pcall(require("src.save_convert.Gen3Save").repairJapaneseNames, save) end
return save, nil, body
end
function Service:commit(nextState, source, before, nextSave)
if source then
nextSave.meta = type(nextSave.meta) == "table" and nextSave.meta or {}
local options = SaveData.loadOptions(self.fs)
local cart = sourceCart(source)
local scope = cart and "cart_" .. cart or source.version
local mapped = options.playthroughIds and options.playthroughIds[scope]
local id = nextSave.meta.playthroughId or mapped and mapped[source.slotId] or SaveData.newPlaythroughId()
nextSave.meta.playthroughId = id
if cart then nextSave.meta.cartId = cart end
nextSave.meta.savedAt = os.time()
nextState.syncMembers = nextState.syncMembers or {}
nextState.syncMembers[source.version .. "/" .. id] = { version = source.version,
playthroughId = id, path = source.path, cartId = cart, slotId = source.slotId }
end
nextState.savedAt = os.time()
local valid, why = Store.validate(nextState)
if not valid then return nil, why end
local after = Serializer.encode(nextState)
local jobs = { { path = Store.PATH, before = self.body, after = after } }
if source then
jobs[#jobs + 1] = { path = source.path, before = before, after = Serializer.encode(nextSave) }
end
local ok, err = Transaction.commit(self.fs, jobs)
if not ok then return nil, err end
self.state, self.body = nextState, after
return true
end
function Service:audit(source, refs, kind)
if type(refs) ~= "table" or type(source) ~= "table" then return {} end
local save = kind == "pc" and self:read(source) or nil
if kind == "pc" and not save then return {} end
local generation = GameVersion.generation(source.version)
local out = {}
for _, ref in ipairs(refs) do
local mon, version
if kind == "pc" then
mon, version = Records.at(save, generation, ref), source.version
else
local entry = Store.at(self.state, ref.box, ref.slot)
if entry and entry.generation == generation then mon, version = entry.mon, source.version end
end
if mon then
local fatal, fixes = Catalog.check(version, mon)
if fatal then return {} end
if #fixes > 0 then out[#out + 1] = { name = Catalog.describe(version, mon).name, fixes = fixes } end
end
end
return out
end
function Service:deposit(source, refs, box, expectedBody, repair)
if type(refs) ~= "table" or #refs == 0 then return nil, "Select Pokémon to deposit." end
local save, why, body = self:read(source)
if not save then return nil, why end
if expectedBody and body ~= expectedBody then return nil, "That save changed. Reload before transferring." end
local generation = GameVersion.generation(source.version)
local nextSave, err = Records.remove(save, generation, refs)
if not nextSave then return nil, err end
local nextState = Store.copy(self.state)
for _, ref in ipairs(refs) do
local slot = Store.free(nextState, box)
if not slot then return nil, "That Box does not have enough room." end
local mon = Records.at(save, generation, ref)
if not mon then return nil, "A selected Pokémon is missing." end
if repair then
local fixed, fixWhy = Catalog.repair(source.version, mon)
if not fixed then return nil, fixWhy end
mon = fixed
end
if ref.where == "party" then mon = Records.enterBox(source.version, generation, Store.copy(mon)) end
local legal, why = Catalog.compatible({ generation = generation, mon = mon }, source.version)
if not legal then return nil, why end
local identity, returned = Records.identity(generation, mon), nil
if identity then
local matches = {}
for id, departure in pairs(nextState.departures or {}) do
if departure.identity == identity and departure.entry.generation == generation then matches[#matches + 1] = id end
end
if #matches == 1 then returned = matches[1] end
end
local entry = returned and Store.copy(nextState.departures[returned].entry) or { id = nextState.nextId }
entry.version, entry.generation, entry.slotId, entry.cartId = source.version, generation, source.slotId, sourceCart(source)
entry.mon, entry.display = Store.copy(mon), Catalog.describe(source.version, mon)
if returned then nextState.departures[returned] = nil else nextState.nextId = nextState.nextId + 1 end
if generation == 3 then entry.depositorId = require("src.box.Gifts").trainerId(save) end
nextState.boxes[box].mons[slot] = entry
end
nextState.revision = nextState.revision + 1
require("src.box.Gifts").recordProgress(nextState, source, nextSave)
return self:commit(nextState, source, body, nextSave)
end
function Service:withdraw(source, refs, pcBox, repair)
if type(refs) ~= "table" or #refs == 0 then return nil, "Select Pokémon to withdraw." end
local save, why, body = self:read(source)
if not save then return nil, why end
local nextState, entries, ids = Store.copy(self.state), {}, {}
for _, ref in ipairs(refs) do
local entry = Store.at(nextState, ref.box, ref.slot)
if not entry or ids[entry.id] then return nil, "A selected Box Pokémon is missing." end
if entry.generation ~= GameVersion.generation(source.version) then
local converted, convertWhy = require("src.box.Migration").convert(entry, source.version)
if not converted then return nil, tostring(entry.display and entry.display.name or "This Pokémon") .. ": " .. tostring(convertWhy) end
local snapshot = Store.copy(entry); snapshot.archives = nil
entry.archives = entry.archives or {}; entry.archives[#entry.archives + 1] = snapshot
entry.version, entry.generation, entry.mon = source.version, GameVersion.generation(source.version), converted
entry.display, entry.gciRaw, entry.gciOriginal = Catalog.describe(source.version, converted), nil, nil
end
if repair and entry.generation == GameVersion.generation(source.version) then
local fixed, fixWhy = Catalog.repair(source.version, entry.mon)
if not fixed then return nil, fixWhy end
entry.mon, entry.display = fixed, Catalog.describe(entry.version, fixed)
end
local ok, err = Catalog.compatible(entry, source.version)
if not ok then return nil, err end
ids[entry.id] = true
entries[#entries + 1] = entry
nextState.departures = nextState.departures or {}
nextState.departures[entry.id] = { entry = Store.copy(entry), identity = Records.identity(entry.generation, entry.mon), path = source.path }
nextState.boxes[ref.box].mons[ref.slot] = nil
end
local nextSave, err = Records.insert(save, GameVersion.generation(source.version), pcBox, entries)
if not nextSave then return nil, err end
nextState.revision = nextState.revision + 1
return self:commit(nextState, source, body, nextSave)
end
function Service:movePC(source, refs, target, expectedBody)
if type(refs) ~= "table" or #refs == 0 then return nil, "Select Pokémon to move." end
local save, why, body = self:read(source)
if not save then return nil, why end
if expectedBody and body ~= expectedBody then return nil, "That save changed. Reload before moving." end
local generation = GameVersion.generation(source.version)
local count, capacity = generation == 1 and 12 or 14, generation == 3 and 30 or 20
local function whole(n, hi) return type(n) == "number" and n == math.floor(n) and n >= 1 and n <= hi end
if type(target) ~= "table" or target.where ~= "party"
and not (whole(target.box, count) and whole(target.index, capacity)) then
return nil, "Choose a valid destination PC box."
end
local mons = {}
for i, ref in ipairs(refs) do
local mon = Records.at(save, generation, ref)
if not mon then return nil, "A selected Pokémon is missing." end
mons[i] = Store.copy(mon)
if target.where == "party" and ref.where ~= "party" then
Records.leaveBox(source.version, generation, mons[i])
elseif target.where ~= "party" and ref.where == "party" then
Records.enterBox(source.version, generation, mons[i])
end
end
local nextSave, err = Records.remove(save, generation, refs)
if not nextSave then return nil, err end
if target.where == "party" then
nextSave.party = nextSave.party or {}
if #nextSave.party + #mons > 6 then return nil, "The party is full." end
for _, mon in ipairs(mons) do
if generation == 3 and (mon.level == nil or mon.hp == nil or mon.maxHp == nil or mon.attack == nil) then
local d = Catalog.describe(source.version, mon)
if not d.hp then return nil, "Import complete species data before moving this Pokémon to the party." end
mon.level, mon.maxHp, mon.hp = d.level, d.hp, d.hp
for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do mon[key] = d[key] end
end
nextSave.party[#nextSave.party + 1] = mon
if generation == 2 and type(nextSave.mail) == "table" and type(nextSave.mail.party) == "table" then
nextSave.mail.party[#nextSave.party] = nil
end
end
elseif generation == 3 then
nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} }
nextSave.storage.boxes = nextSave.storage.boxes or {}
local box = nextSave.storage.boxes[target.box] or { name = "BOX " .. target.box, mons = {} }
nextSave.storage.boxes[target.box] = box
box.mons = box.mons or {}
for _, mon in ipairs(mons) do
local free
for step = 0, 29 do
local i = (target.index - 1 + step) % 30 + 1
if not (box.mons[i] or box.mons[tostring(i)]) then free = i; break end
end
if not free then return nil, "That PC box does not have enough room." end
box.mons[free] = mon
end
else
if not nextSave.boxes then nextSave.boxes = { nextSave.box or {} }; nextSave.box = nil end
local list = nextSave.boxes[target.box] or {}
nextSave.boxes[target.box] = list
if #list + #mons > 20 then return nil, "That PC box does not have enough room." end
local at = math.min(target.index, #list + 1)
for i, mon in ipairs(mons) do table.insert(list, at + i - 1, mon) end
end
return self:commit(Store.copy(self.state), source, body, nextSave)
end
function Service:move(fromBox, fromSlot, toBox, toSlot)
local state, why = Store.move(self.state, fromBox, fromSlot, toBox, toSlot)
if not state then return nil, why end
return self:commit(state)
end
function Service:rename(box, name)
local state, why = Store.rename(self.state, box, name)
if not state then return nil, why end
return self:commit(state)
end
function Service:previewOrganize(target, source, config)
if self.fs.read(Store.PATH) ~= self.body then return nil, "Box changed. Reload before organizing." end
local Organizer = require("src.box.Organizer")
local result, report, body
if target == "box" then
result, report = Organizer.applyWarehouse(self.state, config)
elseif target == "pc" then
local save, why
save, why, body = self:read(source)
if not save then return nil, why end
result, report = Organizer.applyGame(save, source.version, config)
else return nil, "Choose Box storage or Game PC." end
if not result then return nil, report end
return { target = target, source = target == "pc" and Store.copy(source) or nil,
config = Store.copy(config), boxBefore = self.body, gameBefore = body, report = report }
end
function Service:applyOrganize(preview)
if type(preview) ~= "table" or type(preview.config) ~= "table" then return nil, "Preview the arrangement first." end
if preview.boxBefore ~= self.body or self.fs.read(Store.PATH) ~= self.body then
return nil, "Box changed since the preview. Preview it again."
end
local Organizer = require("src.box.Organizer")
local nextState, nextSave, report
if preview.target == "box" then
nextState, report = Organizer.applyWarehouse(self.state, preview.config)
if not nextState then return nil, report end
elseif preview.target == "pc" then
local save, why, body = self:read(preview.source)
if not save then return nil, why end
if body ~= preview.gameBefore then return nil, "The game save changed since the preview. Preview it again." end
nextSave, report = Organizer.applyGame(save, preview.source.version, preview.config)
if not nextSave then return nil, report end
nextState = Store.copy(self.state); nextState.revision = nextState.revision + 1
else return nil, "Choose Box storage or Game PC." end
if report.moved == 0 then return nil, "Everything is already in place." end
return self:commit(nextState, preview.target == "pc" and preview.source or nil, preview.gameBefore, nextSave)
end
function Service:saveOrganizer(name, target, config, index)
if type(name) ~= "string" or #name == 0 or #name > 64 or name:find("%c") then
return nil, "Name this preset with 1 to 64 bytes."
end
local valid, why = require("src.box.Organizer").validate(config)
if not valid then return nil, why end
if target ~= "box" and target ~= "pc" then return nil, "Choose Box storage or Game PC." end
local nextState = Store.copy(self.state)
nextState.organizerProfiles = nextState.organizerProfiles or {}
if index == nil then index = #nextState.organizerProfiles + 1 end
if type(index) ~= "number" or index ~= math.floor(index) or index < 1
or index > 20 or index > #nextState.organizerProfiles + 1 then return nil, "All 20 preset slots are full." end
nextState.organizerProfiles[index] = { name = name, target = target, config = Store.copy(config) }
nextState.revision = nextState.revision + 1
return self:commit(nextState)
end
function Service:deleteOrganizer(index)
local profiles = self.state.organizerProfiles or {}
if type(index) ~= "number" or index ~= math.floor(index) or not profiles[index] then return nil, "Choose a saved preset." end
local nextState = Store.copy(self.state)
table.remove(nextState.organizerProfiles, index)
nextState.revision = nextState.revision + 1
return self:commit(nextState)
end
function Service:giftProgress(source)
local save, why = self:read(source)
if not save then return nil, why end
return require("src.box.Gifts").progress(self.state, source, save)
end
function Service:claimEgg(source, seed)
local save, why, body = self:read(source)
if not save then return nil, why end
local Gifts = require("src.box.Gifts")
local progress, err = Gifts.progress(self.state, source, save)
if not progress then return nil, err end
if not progress.eligible then return nil, "The next gift egg has not been unlocked." end
local box, slot
for b = 1, Store.BOXES do
local free = Store.free(self.state, b)
if free then box, slot = b, free; break end
end
if not box then return nil, "Make room in Box storage before claiming this egg." end
local mon, createErr = Gifts.create(source.version, progress.nextAward, seed)
if not mon then return nil, createErr end
local nextState, nextSave = Store.copy(self.state), Store.copy(save)
nextState.boxes[box].mons[slot] = { id = nextState.nextId, version = source.version, generation = 3,
slotId = source.slotId, cartId = source.cartId, depositorId = Gifts.trainerId(save),
mon = mon, display = Catalog.describe(source.version, mon), gift = progress.nextAward }
nextState.nextId, nextState.revision = nextState.nextId + 1, nextState.revision + 1
Gifts.setFlags(nextSave, progress.flags - progress.flags % 8 + 1 + (progress.nextAward - 1) * 2)
Gifts.recordProgress(nextState, source, nextSave)
return self:commit(nextState, source, body, nextSave)
end
function Service:importGCI(bytes)
local state, why = require("src.box.GCI").import(self.state, bytes)
if not state then return nil, why end
local ok, err = self:commit(state)
return ok, err or why
end
function Service:exportGCI()
local bytes, why = require("src.box.GCI").export(self.state)
if not bytes then return nil, why end
local path = "box/exports/pokemon-box-" .. self.state.revision .. (#bytes == 0x76040 and ".gci" or ".sav")
if not self.fs.createDirectory("box/exports") then return nil, "Could not create the export folder." end
local ok, err = self.fs.write(path, bytes)
if not ok or self.fs.read(path) ~= bytes then return nil, err or "The exported file could not be verified." end
return true, path
end
function Service:saveStage(index, stage)
local state, why = require("src.box.Showcase").save(self.state, index, stage)
if not state then return nil, why end
return self:commit(state)
end
function Service:exportStage(index, box)
local path, why = require("src.box.Showcase").export(self.state, index, self.fs)
if not path then return nil, why end
if box then return self:theme(box, "Showcase", path, self.state.stages[index].music) end
return true, path
end
function Service:event(source, id, collect)
local save, why, body = self:read(source)
if not save then return nil, why end
local Events = require("src.box.Events")
local nextSave, err = (collect and Events.collect or Events.receive)(save, id, 2)
if not nextSave then return nil, err end
local nextState = Store.copy(self.state)
nextState.revision = nextState.revision + 1
return self:commit(nextState, source, body, nextSave)
end
function Service:migrate(refs, version, preview)
local state, why = require("src.box.Migration").apply(self.state, refs, version, preview)
if not state then return nil, why end
return self:commit(state)
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
return self:commit(state)
end
for _, operation in ipairs({ "moveGroup", "arrange", "theme", "mark", "preset" }) do
Service[operation] = function(self, ...)
local state, why = Store[operation](self.state, ...)
if not state then return nil, why end
return self:commit(state)
end
end
function Service:deployPreset(source, index)
local preset = self.state.presets and self.state.presets[index]
if not preset then return nil, "Choose a saved team." end
local save, why, body = self:read(source)
if not save then return nil, why end
if #(save.party or {}) + #preset.ids > 6 then return nil, "The party needs room for the entire team." end
local nextSave, nextState = Store.copy(save), Store.copy(self.state)
nextSave.party = nextSave.party or {}
for _, id in ipairs(preset.ids) do
local row = Store.find(nextState, id)
if not row then return nil, "A preset member is currently outside Box storage." end
local ok, err = Catalog.compatible(row.entry, source.version)
if not ok then return nil, err end
if row.entry.mon.isEgg then return nil, "Hatch eggs before deploying a team." end
local mon = Store.copy(row.entry.mon)
local d = Catalog.describe(source.version, mon)
if row.entry.generation == 3 then
if not d.hp then return nil, "Import complete species data before deploying this team." end
mon.level, mon.maxHp, mon.hp = d.level, d.hp, d.hp
for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do mon[key] = d[key] end
elseif next(d.stats) then
mon.stats, mon.maxHp = Store.copy(d.stats), d.hp
mon.hp = math.min(tonumber(mon.hp) or d.hp, d.hp)
if row.entry.generation == 2 then Records.leaveBox(source.version, 2, mon) end
else return nil, "Import complete species data before deploying this team." end
nextSave.party[#nextSave.party + 1] = mon
Records.registerReceived(nextSave, row.entry.generation, mon)
nextState.departures = nextState.departures or {}
nextState.departures[row.entry.id] = { entry = Store.copy(row.entry),
identity = Records.identity(row.entry.generation, mon), path = source.path }
nextState.boxes[row.box].mons[row.slot] = nil
end
nextState.revision = nextState.revision + 1
return self:commit(nextState, source, body, nextSave)
end
return Service
+314
View File
@@ -0,0 +1,314 @@
local Store = require("src.box.Store")
local Theme = require("src.ui.kit.Theme")
local Catalog = require("src.box.Catalog")
local Sprites = require("src.online.OnlineSprites")
local Showcase = {}
Showcase.BACKGROUNDS = { "Forest", "Sky", "Brick", "Sunset", "Ocean", "Midnight", "Meadow", "Lavender", "Sand", "Snow" }
Showcase.PATTERNS = { "Plain", "Checks", "Dots", "Stripes", "Crosses", "Diamonds", "Bricks", "Waves", "Stars", "Grid" }
Showcase.PIECES = { "Platform", "Block", "Steps", "Pillar", "Arch", "Tree", "Flowers", "Rock", "Fence" }
Showcase.MUSIC = { "Silent", "Gentle", "Bright", "Waltz", "March", "Dream", "Waves", "Night", "Playful" }
local COLORS = { Forest={46,92,61},Sky={77,128,173},Brick={112,77,87},Sunset={153,82,64},
Ocean={36,107,128},Midnight={31,36,66},Meadow={92,128,61},Lavender={110,87,148},
Sand={166,143,97},Snow={153,171,184} }
local function contains(list,value) for _,v in ipairs(list) do if v==value then return true end end end
local function finite(value,lo,hi)
return type(value)=="number" and value==value and value>=lo and value<=hi
end
function Showcase.new()
return {name="Stage",background="Forest",pattern="Plain",music="Silent",pieces={}}
end
function Showcase.validate(stage, nextId)
if type(stage)~="table" or type(stage.name)~="string" or #stage.name>64
or not contains(Showcase.BACKGROUNDS,stage.background) or not contains(Showcase.PATTERNS,stage.pattern)
or not contains(Showcase.MUSIC,stage.music) or type(stage.pieces)~="table" or #stage.pieces>1500 then
return nil,"A showcase stage is invalid."
end
for _,piece in ipairs(stage.pieces) do
if type(piece)~="table" or not finite(piece.x,0,1) or not finite(piece.y,0,1)
or not finite(piece.scale,0.25,4) or not finite(piece.rotation,-math.pi,math.pi)
or piece.flip~=nil and type(piece.flip)~="boolean"
or piece.flipY~=nil and type(piece.flipY)~="boolean" then return nil,"A stage placement is invalid." end
if piece.entryId then
if not finite(piece.entryId,1,nextId-1) or piece.entryId%1~=0 then return nil,"A stage Pokémon reference is invalid." end
elseif not contains(Showcase.PIECES,piece.kind) then return nil,"A showcase piece is invalid." end
end
return stage
end
function Showcase.reorder(stage, index, destination)
if not stage.pieces[index] or destination < 1 or destination > #stage.pieces then return nil end
local piece = table.remove(stage.pieces, index)
table.insert(stage.pieces, destination, piece)
return destination
end
local sceneryImage, sceneryRects, sceneryQuads
function Showcase.drawProp(kind, x, y, size)
local g = love and love.graphics
if not g or not g.newImage or not g.newQuad then return false end
if sceneryImage == false then return false end
if not sceneryImage then
local ok, img = pcall(g.newImage, "assets/box/showcase/scenery-v1.png")
if not ok then sceneryImage = false; return false end
sceneryImage = img
sceneryImage:setFilter("nearest", "nearest")
local bytes = love.filesystem.read("assets/box/showcase/scenery-v1.json")
local ok2, data = pcall(function() return bytes and require("src.link.Json").decode(bytes) end)
local rects = ok2 and type(data) == "table" and data.rects
sceneryRects, sceneryQuads = type(rects) == "table" and rects or {}, {}
end
local rect = sceneryRects[kind]
if type(rect) ~= "table" then return false end
for i = 1, 4 do if type(rect[i]) ~= "number" then return false end end
if not sceneryQuads[kind] then sceneryQuads[kind] = g.newQuad(rect[1],rect[2],rect[3],rect[4],sceneryImage:getDimensions()) end
local scale = size / math.max(rect[3],rect[4])
Theme.col({255,255,255},1)
g.draw(sceneryImage, sceneryQuads[kind], x + (size - rect[3] * scale) / 2,
y + (size - rect[4] * scale) / 2, 0, scale, scale)
return true
end
function Showcase.hit(stage, x, y, w, h, mx, my)
for index = #stage.pieces, 1, -1 do
local p = stage.pieces[index]
local size = math.min(w/8,h/2)*p.scale
local dx,dy = mx-x-p.x*w,my-y-p.y*h
local c,s = math.cos(p.rotation),math.sin(p.rotation)
if math.abs(dx*c+dy*s)<=size/2 and math.abs(-dx*s+dy*c)<=size/2 then return index end
end
end
function Showcase.save(state,index,stage)
if type(index)~="number" or index%1~=0 or index<1 or index>5 then return nil,"Choose one of five stages." end
local valid,why=Showcase.validate(stage,state.nextId)
if not valid then return nil,why end
local nextState=Store.copy(state)
nextState.stages=nextState.stages or {};nextState.stages[index]=Store.copy(stage)
nextState.revision=state.revision+1
return nextState
end
function Showcase.add(stage,refs,state)
local draft=Store.copy(stage)
for _,ref in ipairs(refs) do
local entry=Store.at(state,ref.box,ref.slot)
if not entry then return nil,"A selected Pokémon is missing." end
if #draft.pieces>=1500 then return nil,"The stage has no more room." end
local index=#draft.pieces
draft.pieces[#draft.pieces+1]={entryId=entry.id,x=0.12+(index%8)*0.105,y=0.28+(math.floor(index/8)%3)*0.25,
scale=1,rotation=0,flip=false}
end
return draft
end
function Showcase.resolve(state,stage)
local out,entries={},{}
for _,box in ipairs(state.boxes) do for _,entry in pairs(box.mons) do entries[entry.id]=entry end end
for _,piece in ipairs(stage.pieces) do
local entry=piece.entryId and entries[piece.entryId]
out[#out+1]={piece=piece,entry=entry,missing=piece.entryId~=nil and entry==nil}
end
return out
end
local musicSource, musicTrack, musicOwner, musicVolume, musicFailed, musicHeld
local function audioSuspended()
local chip=package.loaded["src.core.ChipAudio"]
return chip and chip.isSuspended and chip.isSuspended() or false
end
local function startPending()
if musicSource or not musicTrack or musicTrack=="Silent" then return true end
if musicFailed then return nil,musicFailed end
if audioSuspended() then return "pending" end
local sound,why=require("src.box.ShowcaseMusic").request(musicTrack)
if not sound then
if why=="pending" then return "pending" end
musicFailed="Choose a stage music track."
return nil,musicFailed
end
local ok,source=pcall(love.audio.newSource,sound,"static")
if not ok or not source then
musicFailed="Audio is unavailable."
return nil,musicFailed
end
musicSource,musicHeld=source,false
if musicVolume==nil then musicVolume=require("src.core.SaveData").loadOptions().boxMusicVol end
Showcase.setVolume(musicVolume)
if not pcall(function() musicSource:setLooping(true);musicSource:play() end) then
Showcase.stopMusic()
return nil,"Audio is unavailable."
end
return true
end
function Showcase.stopMusic()
if musicSource then
pcall(musicSource.stop,musicSource)
if musicSource.release then pcall(musicSource.release,musicSource) end
musicSource=nil
elseif musicTrack then
local music=package.loaded["src.box.ShowcaseMusic"]
if music then music.cancel(musicTrack) end
end
musicTrack,musicOwner,musicFailed,musicHeld=nil,nil,nil,nil
end
function Showcase.update()
if musicSource then
local held=audioSuspended()
if held~=musicHeld then
musicHeld=held
if held then pcall(musicSource.pause,musicSource) else pcall(musicSource.play,musicSource) end
end
elseif musicTrack and not musicFailed then startPending() end
end
function Showcase.currentMusic()
Showcase.update()
return musicTrack, musicOwner, musicSource
end
function Showcase.ensureMusic(track, owner)
if musicTrack==track and musicOwner~=owner and not musicSource then
musicOwner=owner
elseif musicTrack~=track or musicOwner~=owner then
Showcase.stopMusic()
if track~="Silent" and not (love and love.sound and love.sound.newSoundData and love.audio) then return nil,"Audio is unavailable." end
musicTrack,musicOwner=track,owner
end
return startPending()
end
function Showcase.setVolume(level)
musicVolume=math.max(0,math.min(7,tonumber(level) or 7))
if musicSource then musicSource:setVolume(musicVolume/7) end
end
function Showcase.playMusic(stage) return Showcase.ensureMusic(stage.music,"editor") end
function Showcase.boxMusic(state, box)
if not box or box.theme~="Showcase" then return "Silent" end
if box.showcaseMusic then return box.showcaseMusic end
local exported=tonumber(tostring(box.wallpaper):match("^box/showcase/%d*(%d)%.png$"))
local stage=exported and state.stages and state.stages[exported]
return stage and stage.music or "Silent"
end
function Showcase.draw(state,stage,x,y,w,h)
if not (w>0 and h>0) then return end
local color=COLORS[stage.background] or COLORS.Forest
Theme.fillRounded(x,y,w,h,color,1,0)
local g=love.graphics
local sx,sy,sw,sh=g.getScissor()
if sx then
local xx,yy=math.max(x,sx),math.max(y,sy)
g.setScissor(xx,yy,math.max(0,math.min(x+w,sx+sw)-xx),math.max(0,math.min(y+h,sy+sh)-yy))
else g.setScissor(x,y,w,h) end
local cell=w/20
Theme.col({255,255,255},0.1)
for row=0,math.ceil(h/cell)-1 do
for col=0,19 do
local xx,yy=x+col*cell,y+row*cell
local pw,ph=math.min(cell,w-col*cell),math.min(cell,h-row*cell)
if stage.pattern=="Checks" and (row+col)%2==0 then g.rectangle("fill",xx,yy,pw,ph)
elseif stage.pattern=="Dots" then g.circle("fill",xx+pw/2,yy+ph/2,cell/8)
elseif stage.pattern=="Stripes" and col%2==0 then g.rectangle("fill",xx,yy,pw/2,ph)
elseif stage.pattern=="Crosses" then
g.line(xx+cell/2,yy+cell*.3,xx+cell/2,yy+cell*.7)
g.line(xx+cell*.3,yy+cell/2,xx+cell*.7,yy+cell/2)
elseif stage.pattern=="Diamonds" then g.polygon("line",xx+pw/2,yy,xx+pw,yy+ph/2,xx+pw/2,yy+ph,xx,yy+ph/2)
elseif stage.pattern=="Bricks" then
g.line(xx,yy,xx+pw,yy)
if (col+row)%2==0 then g.line(xx,yy,xx,yy+ph) end
elseif stage.pattern=="Waves" then g.line(xx,yy+ph/2,xx+pw/2,yy+ph/3,xx+pw,yy+ph/2)
elseif stage.pattern=="Stars" then g.circle("fill",xx+pw/2,yy+ph/2,cell/16);g.line(xx+pw/2,yy+ph/4,xx+pw/2,yy+3*ph/4)
elseif stage.pattern=="Grid" then
g.line(xx,yy,xx+pw,yy);g.line(xx,yy,xx,yy+ph)
end
end
end
for _,row in ipairs(Showcase.resolve(state,stage)) do
local piece=row.piece
local size=math.min(w/8,h/2)*piece.scale
local cx,cy=x+piece.x*w,y+piece.y*h
g.push();g.translate(cx,cy);g.rotate(piece.rotation);g.scale(piece.flip and -1 or 1,piece.flipY and -1 or 1)
if row.entry then
local art=Catalog.art(row.entry)
Theme.col({0,0,0},0.25);g.ellipse("fill",0,size*0.37,size*0.33,size*0.06)
if art then
if not Sprites.drawFront(art,-size/2,-size/2,size) then Sprites.drawIcon(art,-size/2,-size/2,size) end
else
Theme.fillRounded(-size/5,-size/4,size*0.4,size*0.55,{255,255,255},0.5,8)
end
elseif row.missing then
Theme.col({255,255,255},0.65);g.rectangle("line",-size/3,-size/3,size*2/3,size*2/3)
g.line(-size/3,-size/3,size/3,size/3);g.line(size/3,-size/3,-size/3,size/3)
else
Theme.col({217,219,230},1)
if Showcase.drawProp(piece.kind,-size/2,-size/2,size) then
elseif piece.kind=="Platform" then g.rectangle("fill",-size/2,-size/10,size,size/5)
elseif piece.kind=="Block" then g.rectangle("fill",-size/3,-size/3,size*2/3,size*2/3)
elseif piece.kind=="Pillar" then g.rectangle("fill",-size/8,-size/2,size/4,size)
elseif piece.kind=="Arch" then
g.rectangle("fill",-size/2,-size/2,size,size/6)
g.rectangle("fill",-size/2,-size/2,size/6,size);g.rectangle("fill",size/3,-size/2,size/6,size)
else
for step=1,4 do g.rectangle("fill",-size/2+(step-1)*size/4,size/2-step*size/4,size/4,step*size/4) end
end
end
g.pop()
end
if sx then g.setScissor(sx,sy,sw,sh) else g.setScissor() end
end
local function digest(bytes)
local hex
if love and love.data and love.data.hash then
local ok,raw=pcall(love.data.hash,"sha256",bytes)
if ok and type(raw)=="string" then hex=raw:gsub(".",function(c) return string.format("%02x",c:byte()) end) end
end
if not hex then
local bit=require("bit")
local parts={}
for seed=1,3 do
local h=bit.bxor(2166136261,seed*16777619)
for i=1,#bytes do
h=bit.bxor(h,bytes:byte(i))
h=bit.tobit(h*403+bit.lshift(h,24))
end
parts[seed]=bit.tohex(h)
end
hex=table.concat(parts)
end
local out={}
for i=0,2 do out[#out+1]=string.format("%08d",tonumber(hex:sub(i*6+1,i*6+6),16)) end
return table.concat(out)
end
function Showcase.export(state,index,fs)
local stage=state.stages and state.stages[index]
if not stage then return nil,"Save the stage before exporting." end
if not love.graphics.newCanvas then return nil,"PNG export requires the running launcher." end
local canvas=love.graphics.newCanvas(1136,432)
love.graphics.push("all")
local ok,err=pcall(function()
love.graphics.setCanvas(canvas);love.graphics.origin();love.graphics.setScissor();love.graphics.clear(0,0,0,0)
Showcase.draw(state,stage,0,0,1136,432)
end)
love.graphics.pop()
if not ok then canvas:release();return nil,tostring(err) end
local data=canvas:newImageData();canvas:release()
local bytes=data:encode("png"):getString();data:release()
local path="box/showcase/"..digest(bytes)..index..".png"
if not fs.createDirectory("box/showcase") then return nil,"Could not create the showcase folder." end
local written,why=fs.write(path,bytes)
if not written or fs.read(path)~=bytes then return nil,why or "Could not verify the exported PNG." end
Showcase.prune(state,fs,path)
return path
end
function Showcase.prune(state,fs,keep)
if not fs.getDirectoryItems or not fs.remove then return 0 end
if fs.getInfo and fs.getInfo(require("src.box.Transaction").PATH) then return 0 end
local used={[keep or ""]=true}
local body=fs.read and fs.read(Store.PATH)
local persisted=body and require("src.core.SaveSerializer").decode(body)
for _,source in ipairs({state,type(persisted)=="table" and persisted or {}}) do
for _,box in ipairs(type(source.boxes)=="table" and source.boxes or {}) do
if type(box)=="table" and box.wallpaper then used[box.wallpaper]=true end
end
end
local ok,items=pcall(fs.getDirectoryItems,"box/showcase")
local removed=0
for _,name in ipairs(ok and type(items)=="table" and items or {}) do
local path="box/showcase/"..name
if name:match("^%d+%.png$") and not used[path] and fs.remove(path) then
require("src.box.Themes").forget(path)
removed=removed+1
end
end
return removed
end
return Showcase
+478
View File
@@ -0,0 +1,478 @@
local Music = {}
local RATE = 22050
local TAU = math.pi * 2
local sin, exp, floor, abs, max, min, random = math.sin, math.exp, math.floor, math.abs, math.max, math.min, math.random
local INST = {
flute = { wave = "sine", h2 = 0.25, a = 0.07, d = 0.3, s = 0.75, r = 0.18, vib = 0.14, vibRate = 5 },
chip = { wave = "pulse", duty = 0.25, a = 0.004, d = 0.1, s = 0.7, r = 0.04, vib = 0.1, vibRate = 6.5, gate = 0.85 },
chiparp = { wave = "pulse", duty = 0.125, a = 0.002, d = 0.06, s = 0.4, r = 0.02, gate = 0.7 },
tri = { wave = "tri", a = 0.003, d = 0.08, s = 0.85, r = 0.03, gate = 0.85 },
accordion = { wave = "pulse", duty = 0.4, detune = 14, a = 0.03, d = 0.12, s = 0.85, r = 0.06, lp = 2800, vib = 0.04, vibRate = 5.5 },
brass = { wave = "saw", detune = 7, a = 0.03, d = 0.2, s = 0.75, r = 0.08, lp = 2200, vib = 0.07, vibRate = 5.2, gate = 0.9 },
tuba = { wave = "saw", a = 0.02, d = 0.15, s = 0.7, r = 0.06, lp = 500, gate = 0.7 },
bell = { wave = "fm", ratio = 3.5, index = 2.2, idxDecay = 0.5, a = 0.002, decay = 1.1, r = 0.8 },
epiano = { wave = "fm", ratio = 1, index = 1.4, idxDecay = 0.3, a = 0.004, decay = 1.0, r = 0.12 },
pad = { wave = "tri", detune = 10, a = 0.8, d = 0.6, s = 0.8, r = 0.9, lp = 1500 },
harp = { wave = "harp", a = 0.002, decay = 0.9, bright = 5, r = 0.3, gate = 2.5 },
nylon = { wave = "harp", a = 0.003, decay = 1.3, bright = 3, r = 0.25, gate = 2 },
ocarina = { wave = "tri", a = 0.05, d = 0.2, s = 0.8, r = 0.12, lp = 2400, vib = 0.16, vibRate = 5 },
muted = { wave = "saw", a = 0.05, d = 0.25, s = 0.65, r = 0.1, lp = 850, vib = 0.12, vibRate = 4.6, gate = 0.9 },
upright = { wave = "tri", a = 0.004, decay = 0.45, r = 0.05, lp = 700, gate = 0.95 },
sub = { wave = "sine", a = 0.02, d = 0.3, s = 0.8, r = 0.2 },
square = { wave = "pulse", duty = 0.5, a = 0.002, d = 0.08, s = 0.5, r = 0.03, lp = 3500, gate = 0.55 },
}
local SONGS = {
Gentle = {
bpm = 76, spb = 2,
chords = "F:8 Dm:8 Bb:8 C:8 F:8 Am:8 Bb:4 C:4 F:8",
melody = "a4:2 c5:2 f5:3 e5:1 d5:2 c5:2 a4:4 bb4:2 d5:2 f5:2 d5:2 c5:6 -:2 "
.. "a4:2 c5:2 f5:3 g5:1 a5:2 g5:1 f5:1 e5:4 d5:2 f5:2 e5:2 g5:2 f5:6 -:2",
lead = { inst = "flute", vol = 0.32, pan = 0.1, send = 0.35 },
parts = {
{ role = "chord", inst = "harp", pattern = "12343212", oct = 1, vol = 0.12, pan = -0.35, send = 0.3 },
{ role = "chord", inst = "pad", pattern = "c---c---", vol = 0.05, pan = 0.25 },
{ role = "bass", inst = "sub", pattern = "r---f---", vol = 0.3 },
},
echo = { steps = 3, fb = 0.4, mix = 0.35 },
},
Bright = {
bpm = 144, spb = 4,
chords = "D:16 A:16 Bm:16 G:16 D:16 A:16 G:8 A:8 D:16",
melody = "f#5:2 a5:2 d6:4 c#6:2 b5:2 a5:4 e5:2 a5:2 c#6:4 b5:2 a5:2 e5:4 "
.. "f#5:2 b5:2 d6:3 c#6:1 b5:2 a5:2 f#5:4 g5:4 f#5:2 e5:2 d5:6 -:2 "
.. "f#5:2 a5:2 d6:4 e6:2 f#6:2 e6:2 d6:2 c#6:4 a5:2 b5:2 c#6:2 e6:2 c#6:2 a5:2 "
.. "b5:2 d6:2 g5:4 a5:2 c#6:2 e6:4 d6:8 -:4 d5:2 e5:2",
lead = { inst = "chip", oct = -1, vol = 0.2, pan = 0.1, send = 0.3 },
parts = {
{ role = "chord", inst = "chiparp", pattern = "1324132413241324", vol = 0.09, pan = -0.3, send = 0.2 },
{ role = "bass", inst = "tri", pattern = "r-.ro.r.r-.ro.r.", vol = 0.35 },
{ role = "drum", pattern = "K...s..kk.k.S...", vol = 0.45 },
{ role = "drum", pattern = "h.h.h.hoh.h.h.h.", vol = 0.22, pan = 0.35 },
},
echo = { steps = 3, fb = 0.3, mix = 0.2 },
},
Waltz = {
bpm = 132, spb = 2,
chords = "G:6 G:6 C:6 C:6 D7:6 D7:6 G:6 G:6 Em:6 Em:6 Am:6 Am:6 D:6 D7:6 G:12",
melody = "d5:2 g5:2 b5:2 d6:4 b5:2 c6:2 e6:2 c6:2 g5:6 a5:2 c6:2 f#5:2 a5:4 d5:2 "
.. "g5:2 b5:2 d6:2 g6:4 -:2 g6:2 f#6:1 e6:1 b5:2 e6:4 d6:2 c6:2 e6:1 c6:1 a5:2 e5:4 c6:2 "
.. "b5:2 a5:2 f#5:2 a5:2 c6:2 f#5:2 g5:6 -:6",
lead = { inst = "accordion", oct = -1, vol = 0.2, pan = 0.15, send = 0.15 },
parts = {
{ role = "bass", inst = "tuba", pattern = "r-....f-....", vol = 0.35 },
{ role = "chord", inst = "square", pattern = "..c.c.", oct = 1, vol = 0.045, pan = -0.3, send = 0.1 },
{ role = "drum", pattern = "..u.u.", vol = 0.12, pan = 0.4 },
},
echo = { steps = 2, fb = 0.25, mix = 0.18 },
},
March = {
bpm = 112, spb = 4,
chords = "Bb:16 Eb:16 Bb:16 F:16 Bb:16 Eb:16 F7:16 Bb:16",
melody = "f5:3 f5:1 bb5:4 d6:3 c6:1 bb5:4 g5:3 bb5:1 eb6:4 d6:2 c6:2 bb5:4 "
.. "d6:3 c6:1 bb5:4 f5:3 g5:1 a5:2 bb5:2 c6:8 a5:2 bb5:2 c6:4 "
.. "f5:3 f5:1 bb5:4 d6:3 c6:1 bb5:4 eb6:3 d6:1 c6:2 bb5:2 g5:4 bb5:2 c6:2 "
.. "d6:4 c6:2 a5:2 f5:2 a5:2 c6:2 eb6:2 bb5:8 -:4 f5:3 f5:1",
lead = { inst = "brass", oct = -1, vol = 0.22, pan = 0.1, send = 0.15 },
parts = {
{ role = "bass", inst = "tuba", pattern = "r-......f-......", vol = 0.35 },
{ role = "chord", inst = "brass", pattern = "....c-......c-..", vol = 0.04, pan = -0.3 },
{ role = "drum", pattern = "S..ss.s.S..ss.ss", vol = 0.28, pan = 0.2 },
{ role = "drum", pattern = "k.......k.......", vol = 0.5 },
},
echo = { steps = 4, fb = 0.2, mix = 0.15 },
},
Dream = {
bpm = 72, spb = 2,
chords = "Ebmaj7:8 F:8 Ebmaj7:8 F:8 Cm7:8 Abmaj7:8 Bbsus4:8 Bb:8",
melody = "g5:2 bb5:2 d6:4 c6:2 a5:2 f5:4 g5:2 bb5:2 d6:2 f6:2 c6:6 -:2 "
.. "eb6:2 d6:2 bb5:4 c6:2 eb6:2 g6:4 f6:4 eb6:4 d6:6 -:2",
lead = { inst = "bell", vol = 0.25, send = 0.5 },
parts = {
{ role = "chord", inst = "pad", pattern = "c-------", vol = 0.07, send = 0.3 },
{ role = "chord", inst = "bell", pattern = "..3...4.", oct = 1, vol = 0.06, pan = -0.5, send = 0.6 },
{ role = "bass", inst = "sub", pattern = "r-------", vol = 0.25 },
},
echo = { steps = 3, fb = 0.5, mix = 0.45 },
},
Waves = {
bpm = 60, spb = 3,
chords = "A:6 D:6 A:6 E:6 F#m:6 D:6 Bm:6 E:6 A:6 D:6 E:6 A:6",
melody = "e5:3 c#5:2 e5:1 f#5:4 a5:2 e5:3 c#5:2 a4:1 b4:6 c#5:2 e5:1 a5:3 f#5:3 e5:2 d5:1 "
.. "d5:2 c#5:1 b4:3 g#4:3 b4:3 a4:2 c#5:1 e5:3 f#5:2 a5:1 d6:3 c#6:2 b5:1 g#5:3 a5:6",
lead = { inst = "ocarina", vol = 0.28, pan = 0.15, send = 0.35 },
parts = {
{ role = "chord", inst = "nylon", pattern = "123432", vol = 0.13, pan = -0.3, send = 0.2 },
{ role = "bass", inst = "sub", pattern = "r--f--", vol = 0.25 },
{ role = "drum", pattern = "w-----------", vol = 0.35 },
{ role = "drum", pattern = "..r..r", vol = 0.12, pan = 0.5 },
},
echo = { steps = 2, fb = 0.35, mix = 0.25 },
},
Night = {
bpm = 92, spb = 2, swing = 0.33,
chords = "Am7:8 Dm7:8 G7:8 Cmaj7:8 Fmaj7:8 Bm7b5:8 E7:8 Am7:8",
melody = "e5:2 g5:1 a5:1 c6:2 b5:1 a5:1 f5:4 -:2 a5:1 c6:1 b5:2 a5:1 g5:1 f5:2 d5:2 e5:6 -:2 "
.. "a5:2 c6:1 e6:1 d6:2 c6:2 b5:2 d6:1 f6:1 e6:2 d6:1 c6:1 b5:2 g#5:2 d6:2 c6:1 b5:1 a5:6 -:2",
lead = { inst = "muted", oct = -1, vol = 0.3, pan = 0.15, send = 0.3 },
parts = {
{ role = "bass", inst = "upright", pattern = "r-t-f-w-", vol = 0.4 },
{ role = "chord", inst = "epiano", pattern = "c--...c-", vol = 0.07, pan = -0.25, send = 0.2 },
{ role = "drum", pattern = "u.uuu.uu", vol = 0.2, pan = 0.3 },
{ role = "drum", pattern = "..x...x.", vol = 0.2, pan = -0.2 },
},
echo = { steps = 3, fb = 0.3, mix = 0.2 },
},
Playful = {
bpm = 126, spb = 4,
chords = "C:16 A7:16 D7:16 G7:16 C:16 C7:16 F:8 G7:8 C:16",
melody = "e5:2 g5:2 c6:2 g5:2 e5:1 f5:1 g5:2 -:4 c#6:2 e6:2 a5:2 e6:2 d6:1 c#6:1 a5:2 -:4 "
.. "d6:2 f#5:2 a5:2 c6:2 b5:1 a5:1 f#5:2 d5:2 -:2 g5:1 f#5:1 g5:2 b5:2 d6:2 f6:4 d6:2 b5:2 "
.. "e5:2 g5:2 c6:2 g5:2 e5:1 f5:1 g5:2 c6:4 bb5:2 g5:2 e5:2 g5:2 bb5:2 c6:2 bb5:2 g5:2 "
.. "a5:2 c6:2 f6:2 c6:2 g5:2 b5:2 d6:2 f6:2 e6:4 c6:2 g5:2 c6:4 -:4",
lead = { inst = "square", vol = 0.14, pan = 0.1, send = 0.15 },
parts = {
{ role = "bass", inst = "tri", pattern = "r.......f.......", vol = 0.35 },
{ role = "chord", inst = "square", pattern = "....c.......c...", oct = 1, vol = 0.04, pan = -0.3 },
{ role = "drum", pattern = "b..b..b.....b.b.", vol = 0.2, pan = 0.4 },
},
echo = { steps = 3, fb = 0.25, mix = 0.15 },
},
}
local LETTER = { c = 0, d = 2, e = 4, f = 5, g = 7, a = 9, b = 11 }
local QUALITY = {
[""] = { 0, 4, 7 }, m = { 0, 3, 7 }, ["7"] = { 0, 4, 7, 10 }, m7 = { 0, 3, 7, 10 }, maj7 = { 0, 4, 7, 11 },
dim = { 0, 3, 6 }, dim7 = { 0, 3, 6, 9 }, m7b5 = { 0, 3, 6, 10 }, sus4 = { 0, 5, 7 },
}
local function split(name)
local letter, accidental, rest = name:match("^(%a)([#b]?)(.*)$")
return (LETTER[letter:lower()] + (accidental == "#" and 1 or accidental == "b" and -1 or 0)) % 12, rest
end
local function midi(name)
local pc, octave = split(name)
return pc + 12 * (tonumber(octave) + 1)
end
local function seq(text)
local out = {}
for name, len in text:gmatch("(%S+):(%d+)") do out[#out + 1] = { name, tonumber(len) } end
return out
end
local function held(inst, t)
if t < inst.a then return t / inst.a end
if inst.decay then return exp(-(t - inst.a) / inst.decay) end
if t < inst.a + inst.d then return 1 - (1 - inst.s) * (t - inst.a) / inst.d end
return inst.s
end
local function gains(pan)
local angle = ((pan or 0) + 1) * math.pi / 4
return math.cos(angle) * 1.414, math.sin(angle) * 1.414
end
local function osc(inst, p)
p = p % 1
if inst.wave == "tri" then return 4 * abs(p - 0.5) - 1 end
if inst.wave == "saw" then return 2 * p - 1 end
return p < (inst.duty or 0.5) and 0.7 or -0.7
end
local function voice(mix, inst, note, t0, gate, vol, pan, send)
local L, R, SL, SR, n, pause = mix.L, mix.R, mix.SL, mix.SR, mix.n, mix.pause
local work = mix.work
local freq = 440 * 2 ^ ((note - 69) / 12)
local gl, gr = gains(pan)
send = send or 0
local start = floor(t0 * RATE)
local released = held(inst, gate)
local k = inst.lp and (1 - exp(-TAU * inst.lp / RATE))
local spread = inst.detune and 2 ^ (inst.detune / 1200)
local wave = inst.wave
local ph, ph2, y = 0, 0, 0
for j = 0, floor((gate + inst.r) * RATE) - 1 do
local t = j / RATE
local env = t < gate and held(inst, t) or released * max(0, 1 - (t - gate) / inst.r)
local f = freq
if inst.vib then f = f * 2 ^ (inst.vib / 12 * sin(TAU * inst.vibRate * t) * min(1, t / 0.3)) end
local inc = f / RATE
ph = ph + inc
local x
if wave == "sine" then
x = sin(TAU * ph) + (inst.h2 or 0) * sin(TAU * 2 * ph)
elseif wave == "fm" then
x = sin(TAU * ph + inst.index * exp(-t / inst.idxDecay) * sin(TAU * inst.ratio * ph))
elseif wave == "harp" then
local e, a = exp(-t * inst.bright), 1
x = 0
for h = 1, 5 do
x = x + sin(TAU * h * ph) * a / h
a = a * e
end
else
x = osc(inst, ph)
if spread then
ph2 = ph2 + inc * spread
x = (x + osc(inst, ph2)) * 0.5
end
end
if k then
y = y + k * (x - y)
x = y
end
x = x * env * vol
local i = (start + j) % n + 1
L[i] = L[i] + x * gl
R[i] = R[i] + x * gr
if send > 0 then
SL[i] = SL[i] + x * gl * send
SR[i] = SR[i] + x * gr * send
end
work = work + 1
if work >= 1024 then
work = 0
pause()
end
end
mix.work = work
end
local DRUM_LENGTH = { k = 0.35, s = 0.25, h = 0.08, o = 0.4, x = 0.05, u = 0.3, b = 0.12, r = 0.15 }
local function hit(mix, kind, t0, dur, vol, pan)
local length = kind == "w" and dur * 1.3 or DRUM_LENGTH[kind]
if not length then return end
local L, R, n, pause = mix.L, mix.R, mix.n, mix.pause
local work = mix.work
local gl, gr = gains(pan)
local start = floor(t0 * RATE)
local lp, lp2, prev, ph = 0, 0, 0, 0
for j = 0, floor(length * RATE) - 1 do
local t = j / RATE
local noise = random() * 2 - 1
local x, xr
if kind == "k" then
ph = ph + (48 + 120 * exp(-t / 0.025)) / RATE
x = sin(TAU * ph) * exp(-t / 0.11)
elseif kind == "s" then
lp = lp + 0.35 * (noise - lp)
x = (noise - lp) * exp(-t / 0.07) * 0.8 + sin(TAU * 190 * t) * exp(-t / 0.04) * 0.5
elseif kind == "h" or kind == "o" or kind == "x" then
x = (noise - prev) * 0.3 * exp(-t / (kind == "o" and 0.12 or kind == "h" and 0.02 or 0.01))
prev = noise
elseif kind == "u" then
lp = lp + 0.5 * (noise - lp)
x = lp * 0.5 * min(1, t / 0.008) * exp(-t / 0.08)
elseif kind == "b" then
x = (sin(TAU * 1050 * t) + 0.5 * sin(TAU * 1650 * t)) * exp(-t / 0.025) * 0.5
elseif kind == "r" then
x = (noise - prev) * 0.25 * (t < 0.012 and t / 0.012 or exp(-(t - 0.012) / 0.035))
prev = noise
else
local env = sin(math.pi * t / length) ^ 2 * 3
lp = lp + 0.05 * (noise - lp)
lp2 = lp2 + 0.05 * ((random() * 2 - 1) - lp2)
x, xr = lp * env, lp2 * env
end
local i = (start + j) % n + 1
L[i] = L[i] + x * vol * gl
R[i] = R[i] + (xr or x) * vol * gr
work = work + 1
if work >= 1024 then
work = 0
pause()
end
end
mix.work = work
end
local function render(song, pause)
local sd = 60 / song.bpm / song.spb
local chords, total = {}, 0
for _, token in ipairs(seq(song.chords)) do
local pc, quality = split(token[1])
chords[#chords + 1] = { start = total, pc = pc, iv = QUALITY[quality] }
total = total + token[2]
end
local function at(step)
return (step + ((song.swing and step % 2 == 1) and song.swing or 0)) * sd
end
local function chordAt(step)
for i = #chords, 1, -1 do
if chords[i].start <= step then return chords[i], chords[i % #chords + 1] end
end
end
local n = floor(total * sd * RATE)
local mix = { L = {}, R = {}, SL = {}, SR = {}, n = n, pause = pause, work = 0 }
for i = 1, n do
mix.L[i], mix.R[i], mix.SL[i], mix.SR[i] = 0, 0, 0, 0
if i % 8192 == 0 then pause() end
end
local lead, step = song.lead, 0
local leadInst = INST[lead.inst]
for _, token in ipairs(seq(song.melody)) do
if token[1] ~= "-" then
local t0 = at(step)
voice(mix, leadInst, midi(token[1]) + 12 * (lead.oct or 0), t0,
(at(step + token[2]) - t0) * (leadInst.gate or 0.92), lead.vol, lead.pan, lead.send)
end
step = step + token[2]
end
for _, part in ipairs(song.parts) do
local pattern, size = part.pattern, #part.pattern
local inst = INST[part.inst]
for s = 0, total - 1 do
local ch = pattern:sub(s % size + 1, s % size + 1)
if ch ~= "." and ch ~= "-" then
local len = 1
while len < size and pattern:sub((s + len) % size + 1, (s + len) % size + 1) == "-" do len = len + 1 end
local t0 = at(s)
local dur = at(s + len) - t0
if part.role == "drum" then
hit(mix, ch:lower(), t0, dur, part.vol * (ch:match("%u") and 1.4 or 1), part.pan)
else
local chord, nextChord = chordAt(s)
local notes = {}
if part.role == "bass" then
local root = 36 + chord.pc
if ch == "w" then
local approach = 36 + (nextChord.pc + 11) % 12
if abs(approach + 12 - (root + 7)) < abs(approach - (root + 7)) then approach = approach + 12 end
notes[1] = approach
else
notes[1] = ch == "f" and root + 7 or ch == "o" and root + 12 or ch == "t" and root + chord.iv[2] or root
end
elseif ch == "c" then
for i, iv in ipairs(chord.iv) do notes[i] = 48 + chord.pc + iv end
else
local i = tonumber(ch) - 1
notes[1] = 48 + chord.pc + chord.iv[i % #chord.iv + 1] + 12 * floor(i / #chord.iv)
end
for _, note in ipairs(notes) do
voice(mix, inst, note + 12 * (part.oct or 0), t0, dur * (inst.gate or 0.92), part.vol, part.pan, part.send)
end
end
end
end
end
local echo = song.echo
if echo then
local d = floor(echo.steps * sd * RATE)
local L, R, SL, SR = mix.L, mix.R, mix.SL, mix.SR
for i = 1, n do
local g, wl, wr = echo.mix, 0, 0
for tap = 1, 3 do
local j = (i - 1 - tap * d) % n + 1
if tap % 2 == 1 then
wl, wr = wl + g * SR[j], wr + g * SL[j]
else
wl, wr = wl + g * SL[j], wr + g * SR[j]
end
g = g * echo.fb
end
L[i], R[i] = L[i] + wl, R[i] + wr
if i % 4096 == 0 then pause() end
end
end
mix.SL, mix.SR = nil, nil
local peak = 1e-6
for i = 1, n do
peak = max(peak, abs(mix.L[i]), abs(mix.R[i]))
if i % 16384 == 0 then pause() end
end
return mix, 0.55 / peak, total
end
local function build(song, pause, job)
local mix, scale = render(song, pause)
local sound = love.sound.newSoundData(mix.n, RATE, 16, 2)
job.sound = sound
local L, R = mix.L, mix.R
for i = 0, mix.n - 1 do
sound:setSample(i, 1, L[i + 1] * scale)
sound:setSample(i, 2, R[i + 1] * scale)
if i % 8192 == 8191 then pause() end
end
return sound
end
local function now()
return love and love.timer and love.timer.getTime and love.timer.getTime() or os.clock()
end
Music.BUDGET = 0.004
Music.MAX_CACHED = 2
local cache, order, jobs, failed = {}, {}, {}, {}
local function free(sound)
if sound and sound.release then pcall(sound.release, sound) end
end
local function drop(name)
local job = jobs[name]
jobs[name] = nil
if job then free(job.sound) end
end
local function remember(name, sound)
cache[name] = sound
order[#order + 1] = name
while #order > Music.MAX_CACHED do
local old = table.remove(order, 1)
free(cache[old])
cache[old] = nil
end
end
function Music.request(name, budget)
if cache[name] then return cache[name] end
if failed[name] then return nil, failed[name] end
local song = SONGS[name]
if not song then return nil, "unknown" end
local job = jobs[name]
if not job then
job = { deadline = 0 }
local function pause()
if now() >= job.deadline then coroutine.yield() end
end
job.co = coroutine.create(function() return build(song, pause, job) end)
jobs[name] = job
end
job.deadline = now() + (budget or Music.BUDGET)
local ok, result = coroutine.resume(job.co)
if not ok then
drop(name)
failed[name] = tostring(result)
return nil, failed[name]
end
if coroutine.status(job.co) ~= "dead" then return nil, "pending" end
jobs[name] = nil
remember(name, result)
return result
end
function Music.cancel(name)
if name then
drop(name)
else
for key in pairs(jobs) do drop(key) end
end
end
function Music.reset()
Music.cancel()
for _, sound in pairs(cache) do free(sound) end
cache, order, failed = {}, {}, {}
end
require("src.render.Assets").register({ invalidate = Music.reset, release = Music.reset })
return Music
+333
View File
@@ -0,0 +1,333 @@
local GameVersion = require("src.core.GameVersion")
local Serializer = require("src.core.SaveSerializer")
local Store = { PATH = "box/storage.lua", FORMAT = 1, BOXES = 25, SLOTS = 60 }
Store.THEMES = { "Launcher", "Forest", "Sky", "Brick", "Sunset", "Ocean", "Showcase" }
function Store.copy(value, seen)
if type(value) ~= "table" then return value end
seen = seen or {}
if seen[value] then return seen[value] end
local out = {}; seen[value] = out
for k, v in pairs(value) do out[Store.copy(k, seen)] = Store.copy(v, seen) end
return out
end
local function integer(n, lo, hi)
return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi
end
local function archivesValid(entry)
if entry.archives == nil then return true end
if type(entry.archives) ~= "table" then return false end
for index, original in pairs(entry.archives) do
if not integer(index, 1, #entry.archives) or type(original) ~= "table"
or original.id ~= entry.id or not GameVersion.VERSIONS[original.version]
or original.generation ~= GameVersion.generation(original.version)
or type(original.mon) ~= "table" or type(original.display) ~= "table"
or original.archives ~= nil then return false end
end
return true
end
function Store.new()
local state = { format = Store.FORMAT, revision = 0, nextId = 1, boxes = {} }
for b = 1, Store.BOXES do state.boxes[b] = { name = "BOX " .. b, mons = {} } end
return state
end
function Store.validate(state)
if type(state) ~= "table" or state.format ~= Store.FORMAT
or not integer(state.revision, 0, 2^53 - 1)
or not integer(state.nextId, 1, 2^53 - 1) or type(state.boxes) ~= "table" then
return nil, "The Box storage format is invalid."
end
local ids = {}
for b, box in pairs(state.boxes) do
if not integer(b, 1, Store.BOXES) or type(box) ~= "table"
or type(box.name) ~= "string" or #box.name > 128 or type(box.mons) ~= "table" then
return nil, "A Box header is invalid."
end
if box.theme ~= nil then
local found = false
for _, theme in ipairs(Store.THEMES) do if box.theme == theme then found = true end end
if not found then return nil, "A Box theme is invalid." end
end
if box.wallpaper ~= nil and (type(box.wallpaper) ~= "string"
or not box.wallpaper:match("^box/showcase/%d+%.png$")) then
return nil, "A Box wallpaper is invalid."
end
if box.showcaseMusic ~= nil then
local found = false
for _, track in ipairs(require("src.box.Showcase").MUSIC) do if track == box.showcaseMusic then found = true end end
if not found or box.theme ~= "Showcase" then return nil, "A Box music track is invalid." end
end
for slot, entry in pairs(box.mons) do
if not integer(slot, 1, Store.SLOTS) or type(entry) ~= "table"
or not integer(entry.id, 1, state.nextId - 1) or ids[entry.id]
or not GameVersion.VERSIONS[entry.version] or type(entry.mon) ~= "table"
or entry.generation ~= GameVersion.generation(entry.version)
or type(entry.display) ~= "table" or not archivesValid(entry) then
return nil, "A stored Pokémon record is invalid."
end
ids[entry.id] = true
if entry.markings ~= nil and not integer(entry.markings, 0, 15)
or entry.depositorId ~= nil and not integer(entry.depositorId, 0, 4294967295)
or entry.tags ~= nil and (type(entry.tags) ~= "string" or #entry.tags > 128) then
return nil, "A Pokémon's local labels are invalid."
end
end
end
for b = 1, Store.BOXES do
if not state.boxes[b] then return nil, "A Box is missing." end
end
if state.progress ~= nil and type(state.progress) ~= "table" then return nil, "Gift progress is invalid." end
for key, value in pairs(state.progress or {}) do
if type(key) ~= "string" or #key > 1024 or not integer(value, 0, Store.BOXES * Store.SLOTS) then
return nil, "Gift progress is invalid."
end
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
for index, profile in pairs(state.organizerProfiles) do
if not integer(index, 1, #state.organizerProfiles) or type(profile) ~= "table"
or type(profile.name) ~= "string" or #profile.name == 0 or #profile.name > 64 or profile.name:find("%c")
or (profile.target ~= "box" and profile.target ~= "pc")
or not require("src.box.Organizer").validate(profile.config) then return nil, "An Auto Box preset is invalid." end
end
end
if state.stages ~= nil and type(state.stages) ~= "table" then return nil, "Showcase stages are invalid." end
if state.departures ~= nil and type(state.departures) ~= "table" then return nil, "Departed entries are invalid." end
if state.syncMembers ~= nil and type(state.syncMembers) ~= "table" then return nil, "Box sync links are invalid." end
for key, member in pairs(state.syncMembers or {}) do
if type(key) ~= "string" or type(member) ~= "table" or not GameVersion.VERSIONS[member.version]
or type(member.playthroughId) ~= "string" or not member.playthroughId:match("^[%w%._:%-]+$")
or #member.playthroughId > 64 or key ~= member.version .. "/" .. member.playthroughId
or type(member.path) ~= "string" or not member.path:match("^saves/[%w%._%-]+/slot%d+%.lua$") then
return nil, "A Box sync link is invalid."
end
local scope, slot = member.path:match("^saves/([%w%._%-]+)/(slot%d+)%.lua$")
if member.cartId ~= nil and (type(member.cartId) ~= "string" or #member.cartId > 64
or not member.cartId:match("^[%w%._%-]+$"))
or scope ~= (member.cartId and "cart_" .. member.cartId or member.version)
or member.slotId ~= nil and member.slotId ~= slot
or member.deleted ~= nil and type(member.deleted) ~= "boolean" then
return nil, "A Box sync link points to a different save."
end
end
for id, departure in pairs(state.departures or {}) do
local entry = type(departure) == "table" and departure.entry
if not integer(id, 1, state.nextId - 1) or ids[id] or type(entry) ~= "table" or entry.id ~= id
or not GameVersion.VERSIONS[entry.version] or entry.generation ~= GameVersion.generation(entry.version)
or type(entry.mon) ~= "table" or type(entry.display) ~= "table"
or type(departure.path) ~= "string" or not archivesValid(entry)
or departure.identity ~= nil and type(departure.identity) ~= "string" then return nil, "A departed Pokémon reference is invalid." end
end
for index, stage in pairs(state.stages or {}) do
if not integer(index, 1, 5) then return nil, "A showcase stage slot is invalid." end
local valid, why = require("src.box.Showcase").validate(stage, state.nextId)
if not valid then return nil, why end
end
for key, preset in pairs(state.presets or {}) do
if not integer(key, 1, 1000000) or type(preset) ~= "table"
or type(preset.name) ~= "string" or #preset.name > 64
or type(preset.ids) ~= "table" or #preset.ids < 1 or #preset.ids > 6 then
return nil, "A team preset is invalid."
end
local used = {}
for _, id in ipairs(preset.ids) do
if not integer(id, 1, state.nextId - 1) or used[id] then return nil, "A team reference is invalid." end
used[id] = true
end
end
return state
end
function Store.load(fs)
local body = fs.read(Store.PATH)
if not body then
if fs.getInfo(Store.PATH) then return nil, "Box storage could not be read." end
local backup = fs.read(Store.PATH .. ".bak")
if backup then
local state = Serializer.decode(backup)
if not Store.validate(state) then return nil, "Box storage is missing and its backup could not be read." end
local ok = fs.write(Store.PATH, backup)
if not ok or fs.read(Store.PATH) ~= backup then return nil, "Box storage could not be restored from its backup." end
return state, nil, backup
end
if fs.getInfo(Store.PATH .. ".bak") or fs.getInfo(Store.PATH .. ".box-bak") then
return nil, "Box storage is missing. Its backup was kept in the box folder."
end
return Store.new(), nil, nil
end
local state, err = Serializer.decode(body)
if not state then return nil, "Box storage could not be decoded: " .. tostring(err) end
local valid, why = Store.validate(state)
if not valid then return nil, why end
return valid, nil, body
end
function Store.at(state, box, slot)
if not integer(box, 1, Store.BOXES) or not integer(slot, 1, Store.SLOTS) then return nil end
return state.boxes[box].mons[slot]
end
function Store.free(state, box)
if not integer(box, 1, Store.BOXES) then return nil end
for slot = 1, Store.SLOTS do if not state.boxes[box].mons[slot] then return slot end end
end
function Store.count(state)
local n = 0
for b = 1, Store.BOXES do for _ in pairs(state.boxes[b].mons) do n = n + 1 end end
return n
end
function Store.move(state, fromBox, fromSlot, toBox, toSlot)
local entry = Store.at(state, fromBox, fromSlot)
if not entry or not integer(toBox, 1, Store.BOXES)
or not integer(toSlot, 1, Store.SLOTS) then return nil, "Choose valid Box slots." end
local nextState = Store.copy(state)
nextState.boxes[fromBox].mons[fromSlot], nextState.boxes[toBox].mons[toSlot] =
nextState.boxes[toBox].mons[toSlot], nextState.boxes[fromBox].mons[fromSlot]
nextState.revision = state.revision + 1
return nextState
end
function Store.rename(state, box, name)
if not integer(box, 1, Store.BOXES) or type(name) ~= "string"
or #name == 0 or #name > 128 or name:find("%c") then
return nil, "Enter a Box name of 1 to 128 bytes."
end
local nextState = Store.copy(state)
nextState.boxes[box].name = name
nextState.revision = state.revision + 1
return nextState
end
function Store.find(state, id)
for b = 1, Store.BOXES do
for slot, entry in pairs(state.boxes[b].mons) do
if entry.id == id then return { box = b, slot = slot, entry = entry } end
end
end
end
function Store.moveGroup(state, refs, box, slot)
if type(refs) ~= "table" or #refs == 0 or not integer(box, 1, Store.BOXES)
or not integer(slot, 1, Store.SLOTS) or slot + #refs - 1 > Store.SLOTS then
return nil, "Choose a destination with room for the entire group."
end
local nextState, group, origins, vacated, displaced = Store.copy(state), {}, {}, {}, {}
for _, ref in ipairs(refs) do
local entry = Store.at(state, ref.box, ref.slot)
local key = tostring(ref.box) .. ":" .. tostring(ref.slot)
if not entry or origins[key] then return nil, "A selected Box Pokémon is missing or repeated." end
origins[key], group[#group + 1] = true, Store.copy(entry)
nextState.boxes[ref.box].mons[ref.slot] = nil
end
local targets = {}
for i = 1, #refs do
local target = slot + i - 1
targets[box .. ":" .. target] = true
local entry = nextState.boxes[box].mons[target]
if entry then displaced[#displaced + 1] = entry end
nextState.boxes[box].mons[target] = group[i]
end
for _, ref in ipairs(refs) do
if not targets[ref.box .. ":" .. ref.slot] then vacated[#vacated + 1] = ref end
end
for i, entry in ipairs(displaced) do
local ref = vacated[i]
nextState.boxes[ref.box].mons[ref.slot] = entry
end
nextState.revision = state.revision + 1
return nextState
end
function Store.arrange(state, box, sort)
if not integer(box, 1, Store.BOXES) then return nil, "Choose a Box." end
local nextState, rows = Store.copy(state), Store.search(state, "", sort)
nextState.boxes[box].mons = {}
local index = 0
for _, row in ipairs(rows) do
if row.box == box then index = index + 1; nextState.boxes[box].mons[index] = Store.copy(row.entry) end
end
nextState.revision = state.revision + 1
return nextState
end
function Store.theme(state, box, theme, wallpaper, music)
if not integer(box, 1, Store.BOXES) then return nil, "Choose a Box." end
local nextState = Store.copy(state)
nextState.boxes[box].theme, nextState.boxes[box].wallpaper = theme, wallpaper
nextState.boxes[box].showcaseMusic = theme == "Showcase" and music or nil
nextState.revision = state.revision + 1
return Store.validate(nextState)
end
function Store.mark(state, refs, markings, tags)
if not integer(markings, 0, 15) or type(refs) ~= "table" or #refs == 0 then
return nil, "Choose Pokémon and valid markings."
end
if tags ~= nil and (type(tags) ~= "string" or #tags > 128 or tags:find("%c")) then
return nil, "Enter tags of at most 128 bytes."
end
local nextState, seen = Store.copy(state), {}
for _, ref in ipairs(refs) do
local entry = Store.at(nextState, ref.box, ref.slot)
if not entry or seen[entry.id] then return nil, "A selected Pokémon is missing or repeated." end
seen[entry.id] = true
if entry.generation == 3 then
entry.mon.markings, entry.display.markings = markings, markings
else entry.markings = markings end
if tags ~= nil then entry.tags = tags end
end
nextState.revision = state.revision + 1
return nextState
end
function Store.preset(state, name, refs)
if type(name) ~= "string" or #name == 0 or #name > 64 or name:find("%c")
or type(refs) ~= "table" or #refs == 0 or #refs > 6 then
return nil, "Name a team of one to six Pokémon."
end
local nextState, ids, seen = Store.copy(state), {}, {}
for _, ref in ipairs(refs) do
local entry = Store.at(state, ref.box, ref.slot)
if not entry or seen[entry.id] then return nil, "A team member is missing or repeated." end
seen[entry.id], ids[#ids + 1] = true, entry.id
end
nextState.presets = nextState.presets or {}
if #nextState.presets >= 100 then return nil, "All 100 team preset slots are full." end
nextState.presets[#nextState.presets + 1] = { name = name, ids = ids }
nextState.revision = state.revision + 1
return nextState
end
function Store.search(state, query, sort)
local out = {}
local matches = require("src.box.Search").compile(query)
for b = 1, Store.BOXES do
for slot, entry in pairs(state.boxes[b].mons) do
if matches(entry, state.boxes[b].name) then
out[#out + 1] = { box = b, slot = slot, entry = entry }
end
end
end
table.sort(out, function(a, b)
local x, y = a.entry.display, b.entry.display
local av, bv
if sort == "level" then av, bv = tonumber(x.level) or 0, tonumber(y.level) or 0
elseif sort == "species" then av, bv = tonumber(x.national) or 0, tonumber(y.national) or 0
elseif sort == "name" or sort == "nature" or sort == "ability" or sort == "trainer" or sort == "gender" then
av, bv = tostring(x[sort] or ""):lower(), tostring(y[sort] or ""):lower()
elseif sort ~= "slot" then av, bv = tonumber(x[sort]) or -1, tonumber(y[sort]) or -1 end
if av and av ~= bv then return av < bv end
return a.box < b.box or (a.box == b.box and a.slot < b.slot)
end)
return out
end
return Store
+94
View File
@@ -0,0 +1,94 @@
local Theme = require("src.ui.kit.Theme")
local Themes = {}
local COLORS = { Forest = { 38, 107, 56 }, Sky = { 51, 92, 148 },
Brick = { 107, 66, 79 }, Sunset = { 130, 66, 43 }, Ocean = { 26, 99, 117 } }
local images, count, retired = {}, 0, {}
Themes.MAX_IMAGES = 4
local function stamp()
local kit = package.loaded["src.ui.kit.Kit"]
return kit and kit.time
end
local function retire(image)
if image and image.release then retired[#retired + 1] = { image = image, at = stamp() } end
end
local function flush(all)
local now, kept = stamp(), {}
for _, row in ipairs(retired) do
if all or row.at ~= now then pcall(row.image.release, row.image) else kept[#kept + 1] = row end
end
retired = kept
end
function Themes.forget(path)
local image = images[path]
if image == nil then return end
images[path] = nil
if image then
count = count - 1
retire(image)
end
end
function Themes.reset()
for _, image in pairs(images) do retire(image or nil) end
images, count = {}, 0
end
function Themes.release()
Themes.reset()
flush(true)
end
function Themes.draw(box, x, y, w, h, scale)
if #retired > 0 then flush(false) end
if box.theme == "Showcase" and box.wallpaper and love.graphics.newImage then
local image = images[box.wallpaper]
if image == nil then
if count >= Themes.MAX_IMAGES then Themes.reset() end
local ok, value = pcall(function()
local fs = require("src.core.SaveData").persistenceFs()
local bytes = fs and fs.read and fs.read(box.wallpaper)
if type(bytes) ~= "string" then return false end
local file = love.filesystem.newFileData(bytes, box.wallpaper)
local loaded = love.graphics.newImage(file)
if file.release then file:release() end
return loaded
end)
image = ok and value or false
images[box.wallpaper] = image
if image then count = count + 1 end
end
if image then
local old = love.graphics.getScissor and { love.graphics.getScissor() }
local left, top, right, bottom = x, y, x + w, y + h
if old and #old == 4 then
left, top = math.max(left, old[1]), math.max(top, old[2])
right, bottom = math.min(right, old[1] + old[3]), math.min(bottom, old[2] + old[4])
end
if love.graphics.setScissor then love.graphics.setScissor(left, top, math.max(0, right-left), math.max(0, bottom-top)) end
Theme.col({ 255, 255, 255 }, 0.3)
love.graphics.draw(image, x, y, 0, w / image:getWidth(), h / image:getHeight())
if love.graphics.setScissor then
if old and #old == 4 then love.graphics.setScissor(unpack(old)) else love.graphics.setScissor() end
end
return
end
end
local color = COLORS[box.theme]
if not color then return end
Theme.fillRounded(x, y, w, h, color, 0.35, 8)
local cell = 32 * (scale or 1)
for row = 0, math.ceil(h / cell) - 1 do
for col = 0, math.ceil(w / cell) - 1 do
if (row + col) % 2 == 0 then
Theme.fillRounded(x + col * cell, y + row * cell, math.min(cell - 2, w-col*cell),
math.min(cell - 2, h-row*cell), color, 0.16, box.theme == "Brick" and 2 or 8)
end
end
end
end
require("src.render.Assets").register({ invalidate = Themes.reset, release = Themes.release })
return Themes
+234
View File
@@ -0,0 +1,234 @@
local Serializer = require("src.core.SaveSerializer")
local Store = require("src.box.Store")
local GameVersion = require("src.core.GameVersion")
local Transaction = { PATH = "box/pending.lua" }
local function allowed(path)
if path == Store.PATH then return true end
if type(path) ~= "string" then return false end
local scope = path:match("^saves/([%w%._%-]+)/slot%d+%.lua$")
return scope ~= nil and (GameVersion.VERSIONS[scope] ~= nil
or (#scope <= 69 and scope:match("^cart_%w[%w%._%-]*$") ~= nil))
end
function Transaction.assetValid(path, body)
if type(path) ~= "string" or not path:match("^box/showcase/%d+%.png$")
or type(body) ~= "string" or #body < 24 or #body > 2 * 1024 * 1024
or body:sub(1, 8) ~= "\137PNG\r\n\26\n" then return false end
local function be(i)
local a, b, c, d = body:byte(i, i + 3)
return ((a * 256 + b) * 256 + c) * 256 + d
end
return be(17) == 1136 and be(21) == 432
end
local function write(fs, path, body)
local dir = path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then
local ok, err = fs.createDirectory(dir)
if not ok then return nil, err or "Could not create the save directory." end
end
local ok, err = fs.write(path, body)
if not ok then return nil, err or "Could not write the save." end
if fs.read(path) ~= body then return nil, "The save could not be verified after writing." end
return true
end
local function validJournal(journal)
if type(journal) ~= "table" or (journal.format ~= 1 and journal.format ~= 2 and journal.format ~= 3)
or type(journal.jobs) ~= "table" or #journal.jobs < 1
or #journal.jobs > (journal.format ~= 1 and 512 or 2) then return nil, "Invalid Box transfer journal." end
local seen = {}
for _, job in ipairs(journal.jobs) do
if type(job) ~= "table" then return nil, "Invalid Box transfer target." end
local asset = journal.format == 2 and job.kind == "asset"
local options = journal.format == 2 and job.path == "options.lua"
local deleting = journal.format == 2 and job.deleted == true and job.path ~= Store.PATH and not asset and not options
if type(job) ~= "table" or not (allowed(job.path) or asset or options) or seen[job.path]
or (not deleting and type(job.after) ~= "string") or deleting and job.after ~= nil
or (job.before ~= nil and type(job.before) ~= "string") then
return nil, "Invalid Box transfer target."
end
seen[job.path] = true
if asset then
if not Transaction.assetValid(job.path, job.after) then return nil, "Invalid Box wallpaper." end
elseif not deleting then
local data = Serializer.decode(job.after)
if type(data) ~= "table" then return nil, "Invalid transfer save data." end
if job.path == Store.PATH and not Store.validate(data) then return nil, "Invalid Box data." end
end
end
if journal.format ~= 3 and not seen[Store.PATH] then return nil, "The transfer does not include Box storage." end
return journal
end
local function quarantine(fs, body)
local stamp = os.time()
for n = 1, 1000 do
local path = ("box/conflicts/pending-%d-%d.lua"):format(stamp, n)
if not fs.getInfo(path) then
local ok, err = write(fs, path, body)
if not ok then return nil, err end
ok, err = fs.remove(Transaction.PATH)
if not ok or fs.getInfo(Transaction.PATH) then
return nil, err or "The unfinished Box transfer record could not be set aside."
end
return path
end
end
return nil, "Too many unfinished Box transfer records are waiting in box/conflicts."
end
local function abort(fs, journal, body)
local wrote = false
for _, job in ipairs(journal.jobs) do
local current = fs.read(job.path)
if not job.conflict and current ~= job.before then
local ok, why
if job.before == nil then
ok, why = fs.remove(job.path)
if ok and fs.getInfo(job.path) then ok = nil end
else ok, why = write(fs, job.path, job.before) end
if not ok then return nil, why or "Could not undo the unfinished Box transfer." end
wrote = true
end
if not job.conflict and job.after ~= nil and fs.read(job.path .. ".bak") == job.after then
local ok, why
if job.before == nil then ok, why = fs.remove(job.path .. ".bak")
else ok, why = write(fs, job.path .. ".bak", job.before) end
if not ok then return nil, why or "Could not undo the unfinished Box transfer." end
end
end
if wrote and journal.format ~= 2 then
local sync = package.loaded["src.sync.SyncEngine"]
local engine = type(sync) == "table" and sync._shared
if type(engine) == "table" then engine:noteSaveWritten() end
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 .. "."
end
local function touched(fs, journal)
if type(journal) ~= "table" or type(journal.jobs) ~= "table" then return false end
for _, job in pairs(journal.jobs) do
if type(job) == "table" and type(job.path) == "string" then
local ok, current = pcall(fs.read, job.path)
if not ok then return true end
if current ~= job.before then
if job.deleted == true and current == nil then return true end
if type(job.after) == "string" and type(current) == "string"
and job.after:sub(1, #current) == current then return true end
end
end
end
return false
end
function Transaction.recover(fs)
if not fs or not fs.read or not fs.getInfo then return nil, "Save storage is unavailable." end
local body = fs.read(Transaction.PATH)
if not body then
if fs.getInfo(Transaction.PATH) then return nil, "The pending Box transfer could not be read." end
return true, false
end
local journal = Serializer.decode(body)
if journal == nil then
local kept, err = quarantine(fs, body)
if not kept then return nil, err end
return true, false, "An unfinished Box transfer record was unreadable and was set aside at " .. kept .. "."
end
local valid, why = validJournal(journal)
if not valid then
if touched(fs, journal) then return nil, why end
local kept, err = quarantine(fs, body)
if not kept then return nil, err end
return true, false, why .. " The unfinished Box transfer record was set aside at " .. kept .. "."
end
local conflict = false
for _, job in ipairs(journal.jobs) do
local current = fs.read(job.path)
if current == nil and fs.getInfo(job.path) then return nil, "A transfer save could not be read." end
local interrupted = journal.format ~= 1 and type(current) == "string" and type(job.after) == "string"
and job.after:sub(1, #current) == current
if current ~= job.before and current ~= job.after and not interrupted
and (journal.format ~= 1 or current == nil or Serializer.decode(current) ~= nil) then
job.conflict, conflict = true, true
end
end
if conflict then return abort(fs, journal, body) end
for _, job in ipairs(journal.jobs) do
if fs.read(job.path) ~= job.after then
if job.before then
local ok, err = write(fs, job.path .. ".box-bak", job.before)
if not ok then return nil, err end
end
if job.deleted then
local ok, err = fs.remove(job.path)
if not ok or fs.getInfo(job.path) then return nil, err or "Could not remove the synced save." end
else
local ok, err = write(fs, job.path, job.after)
if not ok then return nil, err end
end
end
if job.deleted then
for _, suffix in ipairs({ ".bak", ".tmp" }) do
if fs.getInfo(job.path .. suffix) then
local ok, err = fs.remove(job.path .. suffix)
if not ok or fs.getInfo(job.path .. suffix) then return nil, err or "Could not remove a deleted save's recovery file." end
end
end
elseif fs.read(job.path .. ".bak") ~= job.after then
local ok, err = write(fs, job.path .. ".bak", job.after)
if not ok then return nil, err end
end
if not job.deleted and fs.getInfo(job.path .. ".tmp") then
local ok, err = fs.remove(job.path .. ".tmp")
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
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."
end
local stored = fs.read(Store.PATH)
if stored and fs.read(Store.PATH .. ".bak") == stored
and fs.getInfo(Store.PATH .. ".box-bak") then
fs.remove(Store.PATH .. ".box-bak")
end
if journal.format == 1 or journal.format == 3 then
local sync = package.loaded["src.sync.SyncEngine"]
local engine = type(sync) == "table" and sync._shared
if type(engine) == "table" then engine:noteSaveWritten() end
end
return true, true
end
local function commit(fs, jobs, format)
local ok, why = Transaction.recover(fs)
if not ok then return nil, why end
local journal = { format = format, jobs = jobs }
local valid, err = validJournal(journal)
if not valid then return nil, err end
for _, job in ipairs(jobs) do
if fs.read(job.path) ~= job.before then return nil, "That save changed. Reload before transferring." end
end
local body = Serializer.encode(journal)
ok, err = write(fs, Transaction.PATH, body)
if not ok and fs.read(Transaction.PATH) ~= body then
fs.remove(Transaction.PATH)
return nil, err
end
local applied
ok, applied, err = Transaction.recover(fs)
if not ok then return nil, "Box transfer is pending: " .. tostring(applied) end
if not applied then return nil, err or "The Box transfer was not applied." end
return true
end
function Transaction.commit(fs, jobs) return commit(fs, jobs, 1) end
function Transaction.commitSync(fs, jobs) return commit(fs, jobs, 2) end
function Transaction.commitIdentity(fs, jobs) return commit(fs, jobs, 3) end
return Transaction
+4 -2
View File
@@ -179,11 +179,13 @@ local cachedBanks
local function loadBanks(data)
local audio = data.audio
local WorkerFs = require("src.core.WorkerFs")
local key = (WorkerFs.normalize(audio.programPrefix) or "") .. tostring(audio.programFile)
local key = audio.programBytes and audio
or (WorkerFs.normalize(audio.programPrefix) or "") .. tostring(audio.programFile)
if cachedProgramFile == key and cachedBanks then
return cachedBanks
end
local raw, readError = WorkerFs.read(audio.programPrefix, audio.programFile)
local raw, readError = audio.programBytes
if not raw then raw, readError = WorkerFs.read(audio.programPrefix, audio.programFile) end
if not raw then error("could not read sound programs: " .. tostring(readError)) end
local banks = {}
for index, bank in ipairs(audio.bankOrder) do
+3
View File
@@ -2672,6 +2672,9 @@ function SaveData.load(version)
SaveData.runMigrations(data)
data.options = SaveData.loadOptions()
local loadInfo = type(data.version) == "string" and GameVersion.info(data.version)
if loadInfo and loadInfo.generation == 3 then
pcall(function() require("src.save_convert.Gen3Save").repairJapaneseNames(data) end)
end
if loadInfo and loadInfo.generation == 2 then
data.options = require("src.core.gen2.Save").loadOptions()
end
+322 -199
View File
@@ -1,102 +1,55 @@
local bit = require("bit")
local Identity = require("src.core.TrainerIdentity")
local Store = require("src.box.Store")
local Transaction = require("src.box.Transaction")
local GameVersion = require("src.core.GameVersion")
local TrainerIdSync = {}
local function u16(v)
local n = tonumber(v)
if not n or n ~= n or n < 0 or n == math.huge then return nil end
return math.floor(n) % 65536
end
function TrainerIdSync.generationOf(save, version)
if type(save) ~= "table" then return nil end
if save.engine == "game3" or save.generation == 3 then return 3 end
if save.generation == 2 then return 2 end
local GameVersion = require("src.core.GameVersion")
local info = GameVersion.info(type(save.version) == "string" and save.version or version)
if not info then info = GameVersion.info(version) end
or GameVersion.info(version)
return info and info.generation or 1
end
function TrainerIdSync.playerIdOf(save, gen)
if type(save) ~= "table" then return nil end
if gen == 3 then return u16(save.trainerId) end
return u16(type(save.player) == "table" and save.player.id or nil)
return Identity.u16(gen == 3 and save.trainerId or save.player and save.player.id)
end
local function reseat(oldId, sid, newId)
if type(sid) ~= "number" then return sid end
return bit.band(bit.bxor(sid, oldId, newId), 0xFFFF)
end
local function rawOtId(raw)
if type(raw) ~= "string" or #raw < 28 then return nil end
return tonumber(raw:sub(25, 28), 16)
end
local function setRawOtId(raw, id)
return raw:sub(1, 24) .. ("%04X"):format(id) .. raw:sub(29)
end
local function syncMon(mon, gen, newId, ids)
if type(mon) ~= "table" then return false end
if gen == 1 and mon.otId == nil and type(mon.cartRaw) == "string" then
local id = rawOtId(mon.cartRaw)
if id and ids[id] and id ~= newId then
mon.cartRaw = setRawOtId(mon.cartRaw, newId)
return true
end
return false
end
local id = u16(mon.otId)
if not id or not ids[id] then return false end
local changed = false
if id ~= newId then
if gen == 3 then mon.otSecretId = reseat(id, mon.otSecretId, newId) end
mon.otId = newId
changed = true
end
if gen ~= 3 and mon.traded then
mon.traded = nil
changed = true
end
return changed
function TrainerIdSync.addOwner(owners, profile, gen)
for g = 1, 2 do owners[Identity.key(g, profile.id, profile.name)] = profile end
owners[Identity.key(3, profile.id, profile.name, gen == 3 and profile.sid or 0)] = profile
end
local function each(list, fn)
if type(list) ~= "table" then return end
for _, v in pairs(list) do fn(v) end
for _, value in pairs(type(list) == "table" and list or {}) do fn(value) end
end
local function monsOf(save, gen)
function TrainerIdSync.monsOf(save, gen)
local out, seen = {}, {}
local function add(mon)
if type(mon) == "table" and not seen[mon] then
seen[mon] = true
out[#out + 1] = mon
end
if type(mon) == "table" and not seen[mon] then seen[mon], out[#out + 1] = true, mon end
end
each(save.party, add)
if gen == 3 then
local boxes = type(save.storage) == "table" and save.storage.boxes or nil
each(boxes, function(box) each(type(box) == "table" and box.mons, add) end)
each(type(save.storage) == "table" and save.storage.boxes,
function(box) each(type(box) == "table" and box.mons, add) end)
each(save.savedPlayerParty, add)
local tower = type(save.frontier) == "table" and save.frontier.towerPlayer or nil
local tower = type(save.frontier) == "table" and save.frontier.towerPlayer
each(type(tower) == "table" and tower.party, add)
for key, mod in pairs(type(save.modData) == "table" and save.modData or {}) do
if type(key) == "string" and key:match("_daycare$") and type(mod) == "table" then
local dc = mod.daycare
if type(dc) == "table" then
add(dc[1])
add(dc[2])
each(dc.mons, add)
end
local r5 = mod.route5Daycare
if type(r5) == "table" then add(r5.mon) end
if type(dc) == "table" then add(dc[1]); add(dc[2]); each(dc.mons, add) end
if type(mod.route5Daycare) == "table" then add(mod.route5Daycare.mon) end
end
end
else
each(save.boxes, function(box) each(box, add) end)
each(save.boxes or (save.box and { save.box }),
function(box) each(box, add) end)
if type(save.daycare) == "table" then add(save.daycare.mon) end
if type(save.orphaned) == "table" then each(save.orphaned.mons, add) end
if gen == 2 and type(save.dayCare) == "table" then
@@ -109,183 +62,353 @@ local function monsOf(save, gen)
return out
end
local function syncExtras(save, gen, oldId, oldSid, newId, newSid, ids)
local function syncMon(mon, gen, target, owners, localOwner, report)
if not Identity.owner(mon, gen, owners, localOwner) then
if (mon.otId ~= nil or mon.cartRaw ~= nil) and Identity.monName(mon) == nil then
report.unknown = report.unknown + 1
end
return false
end
local changed = false
if gen == 3 then
local pid, why = Identity.personality(mon, target)
if not pid then return nil, why end
if pid ~= mon.personality then
mon.personality, changed = pid, true
report.personalities = report.personalities + 1
if tonumber(mon.species) == 308 then report.spinda = report.spinda + 1 end
end
if mon.otSecretId ~= target.sid then mon.otSecretId, changed = target.sid, true end
end
local id = Identity.u16(mon.otId) or Identity.rawId(mon, gen)
if id ~= target.id or mon.otId ~= nil and mon.otId ~= target.id then
if gen == 1 and type(mon.cartRaw) == "string" then
mon.cartRaw = mon.cartRaw:sub(1, 24) .. ("%04X"):format(target.id) .. mon.cartRaw:sub(29)
elseif gen == 2 and type(mon.cartRaw) == "string" then
require("src.save_convert.Gen2Save").rewriteCarrierId(mon, target.id)
end
if mon.otId ~= nil or gen ~= 1 or not mon.cartRaw then mon.otId = target.id end
changed = true
end
local renamed, why = Identity.rename(mon, target.name, gen)
if renamed == nil then return nil, why end
changed = changed or renamed
if mon.traded then mon.traded, changed = nil, true end
if changed then report.mons = report.mons + 1 end
return changed
end
local function syncRawDaycare(save, gen, target, owners, localOwner, report)
local dc = gen == 1 and type(save.daycare) == "table" and save.daycare or nil
local raw = dc and dc.mon == nil and type(dc.cartRaw) == "string" and dc.cartRaw or nil
if not raw or #raw < 112 or raw:find("[^%x]") then return false end
local view = { cartOt = raw:sub(25, 46), cartRaw = raw:sub(47) }
local changed, why = syncMon(view, gen, target, owners, localOwner, report)
if changed == nil then return nil, why end
if changed then dc.cartRaw = raw:sub(1, 24) .. view.cartOt .. view.cartRaw end
return changed
end
local function syncMail(mail, gen, target, owners, localOwner)
if type(mail) ~= "table" then return false end
local name = gen == 2 and mail.author or mail.playerName
local id = gen == 2 and mail.authorId or mail.trainerId
local full = Identity.packed(id)
if not full or type(name) ~= "string" then return false end
local sid = math.floor(full / 65536)
if gen == 3 and full < 65536 and localOwner and localOwner.id == full
and localOwner.name == name then sid = localOwner.sid end
if not owners[Identity.key(gen, full % 65536, name, sid)] then return false end
local nextId = gen == 2 and target.id or target.id + target.sid * 65536
if id == nextId and name == target.name then return false end
if gen == 2 then mail.authorId, mail.author = nextId, target.name
else mail.trainerId, mail.playerName = nextId, target.name end
return true
end
local function syncExtras(save, gen, target, owners, old)
local changed = false
if gen == 2 then
local hof = type(save.hallOfFame) == "table" and save.hallOfFame.teams or nil
each(hof, function(team)
each(type(team) == "table" and team.mons, function(mon)
local id = type(mon) == "table" and u16(mon.otId)
if id and ids[id] and id ~= newId then mon.otId = newId; changed = true end
end)
end)
local mail = type(save.mail) == "table" and save.mail or nil
for _, list in ipairs({ mail and mail.party, mail and mail.box }) do
each(list, function(m)
local id = type(m) == "table" and u16(m.authorId)
if id and ids[id] and id ~= newId then m.authorId = newId; changed = true end
end)
local mail = type(save.mail) == "table" and save.mail or {}
for _, key in ipairs({ "party", "box" }) do
each(mail[key], function(m) changed = syncMail(m, gen, target, owners, old) or changed end)
end
elseif gen == 3 then
each(save.hallOfFameTeams, function(team)
each(team, function(mon)
local id = type(mon) == "table" and u16(mon.trainerId)
if id and ids[id] and id ~= newId then
mon.otSecretId = reseat(id, mon.otSecretId, newId)
mon.trainerId = newId
changed = true
each(save.mail, function(m) changed = syncMail(m, gen, target, owners, old) or changed end)
for key, mod in pairs(save.modData or {}) do
if type(key) == "string" and key:match("_daycare$") and type(mod) == "table" then
local dc = type(mod.daycare) == "table" and mod.daycare or {}
each(dc.mail, function(m)
if type(m) == "table" then changed = syncMail(m.message, gen, target, owners, old) or changed end
end)
local r5 = type(mod.route5Daycare) == "table" and mod.route5Daycare
if r5 and type(r5.mail) == "table" then
changed = syncMail(r5.mail.message, gen, target, owners, old) or changed
end
end)
end)
local base = type(save.secretBases) == "table" and save.secretBases[1] or nil
local tid = type(base) == "table" and base.trainerId or nil
if type(tid) == "table" and oldId ~= newId
and tid[1] == oldId % 256 and tid[2] == math.floor(oldId / 256) then
tid[1], tid[2] = newId % 256, math.floor(newId / 256)
if type(newSid) == "number" and tid[3] == (oldSid or 0) % 256
and tid[4] == math.floor((oldSid or 0) / 256) then
tid[3], tid[4] = newSid % 256, math.floor(newSid / 256)
end
changed = true
end
local base = type(save.secretBases) == "table" and save.secretBases[1]
local tid = type(base) == "table" and base.trainerId
if type(tid) == "table" and tid[1] == old.id % 256 and tid[2] == math.floor(old.id / 256)
and tid[3] == old.sid % 256 and tid[4] == math.floor(old.sid / 256)
and (base.trainerName == nil or base.trainerName == old.name) then
if old.id ~= target.id or old.sid ~= target.sid or old.name ~= target.name then
base.trainerId = { target.id % 256, math.floor(target.id / 256),
target.sid % 256, math.floor(target.sid / 256) }
if base.trainerName ~= nil then base.trainerName = target.name end
changed = true
end
end
end
return changed
end
function TrainerIdSync.rewriteSave(save, gen, newId, ids)
if type(save) ~= "table" then return false end
local changed = false
local oldId = TrainerIdSync.playerIdOf(save, gen)
local oldSid, newSid
local function reportNew() return { mons = 0, personalities = 0, spinda = 0, unknown = 0 } end
function TrainerIdSync.rewriteSave(save, gen, target, owners, report)
local old = Identity.profile(save, gen)
if not old or type(target) ~= "table" then return nil, "The save has no complete trainer identity." end
report = report or reportNew()
local changed = old.id ~= target.id or old.name ~= target.name or gen == 3 and old.sid ~= target.sid
for _, mon in ipairs(TrainerIdSync.monsOf(save, gen)) do
local updated, why = syncMon(mon, gen, target, owners, old, report)
if updated == nil then return nil, why end
changed = updated or changed
end
local daycare, why = syncRawDaycare(save, gen, target, owners, old, report)
if daycare == nil then return nil, why end
changed = daycare or changed
changed = syncExtras(save, gen, target, owners, old) or changed
if gen == 3 then
oldSid = save.secretId
if oldId and oldId ~= newId then
newSid = reseat(oldId, oldSid, newId)
save.secretId = newSid
save.trainerId = newId
changed = true
end
elseif oldId ~= newId then
save.player = type(save.player) == "table" and save.player or {}
save.player.id = newId
changed = true
end
for _, mon in ipairs(monsOf(save, gen)) do
if syncMon(mon, gen, newId, ids) then changed = true end
end
if oldId and syncExtras(save, gen, oldId, oldSid, newId, newSid or oldSid, ids) then
changed = true
end
save.trainerId, save.secretId, save.name = target.id, target.sid, target.name
if save.playerName ~= nil then save.playerName = target.name end
else save.player.id, save.player.name = target.id, target.name end
return changed
end
local function scopes(SaveData)
local GameVersion = require("src.core.GameVersion")
local out = {}
for _, version in ipairs(GameVersion.ORDER) do
out[#out + 1] = { key = version, version = version }
end
local ok, ids = pcall(SaveData.cartsWithSlots)
if ok and type(ids) == "table" then
local okOpts, options = pcall(SaveData.loadOptions)
local reg = okOpts and type(options) == "table" and type(options.carts) == "table"
and options.carts or {}
for _, id in ipairs(ids) do
local row = type(reg[id]) == "table" and reg[id] or nil
out[#out + 1] = { key = "cart_" .. id, cart = id, version = row and row.base or nil }
end
for _, version in ipairs(GameVersion.ORDER) do out[#out + 1] = { key = version, version = version } end
local options = SaveData.loadOptions()
for _, id in ipairs(SaveData.cartsWithSlots()) do
local row = type(options.carts) == "table" and options.carts[id]
out[#out + 1] = { key = "cart_" .. id, cart = id, version = type(row) == "table" and row.base }
end
return out
end
function TrainerIdSync.newJob(source, SaveData)
SaveData = SaveData or require("src.core.SaveData")
local entries = {}
local fs = SaveData.persistenceFs()
local job = setmetatable({ SaveData = SaveData, fs = fs, source = source, entries = {},
phase = "read", at = 0, owners = {}, written = 0, failed = {}, report = reportNew(), jobs = {} },
{ __index = TrainerIdSync })
local ok, why = Transaction.recover(fs)
if not ok then job.error, job.phase = why, "done"; return job end
job.warehouse, why, job.boxBody = Store.load(fs)
if not job.warehouse then job.error, job.phase = why, "done"; return job end
for _, scope in ipairs(scopes(SaveData)) do
local okList, slots = pcall(function()
if scope.cart then return SaveData.listCartSlots(scope.cart) end
return SaveData.listSlots(scope.version)
end)
for _, slot in ipairs(okList and type(slots) == "table" and slots or {}) do
if slot.exists then
entries[#entries + 1] = { scope = scope.key, cart = scope.cart,
version = scope.version, slot = slot.id }
local slots = scope.cart and SaveData.listCartSlots(scope.cart) or SaveData.listSlots(scope.version)
for _, slot in ipairs(slots) do
local path = "saves/" .. scope.key .. "/" .. slot.id .. ".lua"
if slot.exists or fs.getInfo(path) or fs.getInfo(path .. ".bak") or fs.getInfo(path .. ".tmp") then
job.entries[#job.entries + 1] = { scope = scope.key, cart = scope.cart,
version = scope.version, slot = slot.id, path = path }
end
end
end
return setmetatable({
SaveData = SaveData, source = source, entries = entries,
phase = "read", at = 0, ids = {}, written = 0, failed = {},
}, { __index = TrainerIdSync })
job.optionsBody = fs.read("options.lua")
return job
end
function TrainerIdSync:progress()
local n = #self.entries
if n == 0 then return 1 end
local done = self.phase == "read" and self.at or n + self.at
if self.phase == "done" then return 1 end
return math.min(1, done / (2 * n))
return math.min(0.99, ((self.phase == "read" and 0 or #self.entries) + self.at) / math.max(1, 2 * #self.entries + 1))
end
function TrainerIdSync:_check(e)
if e.cart and self.SaveData.slotSealBroken(e.cart, e.slot) then return nil, "A save's seal is broken." end
local ok, pending = pcall(require("src.online.Trade").pendingSentAt, e.path)
if not ok or type(pending) ~= "table" then return nil, "Online trade status could not be read." end
if #pending > 0 then return nil, "Finish the pending online trade before using ID Sync." end
return true
end
function TrainerIdSync:_read(e)
local SaveData = self.SaveData
local ok, source = pcall(function()
if e.cart then return SaveData.readCartSlotSource(e.cart, e.slot) end
return SaveData.readSlotSource(e.version, e.slot)
end)
local save = ok and source and SaveData.decode(source) or nil
if type(save) ~= "table" then return end
e.save = save
e.gen = TrainerIdSync.generationOf(save, e.version)
local id = TrainerIdSync.playerIdOf(save, e.gen)
if id then self.ids[id] = true end
if e.scope == self.source.scope and e.slot == self.source.slot then
self.newId = id
local ok, why = self:_check(e)
if not ok then return nil, why end
e.before = self.fs.read(e.path)
e.save = e.before and self.SaveData.decode(e.before)
if type(e.save) ~= "table" then return nil, "A save could not be read: " .. e.path end
e.version = e.save.version or e.version
e.gen = TrainerIdSync.generationOf(e.save, e.version)
e.profile = Identity.profile(e.save, e.gen)
if not GameVersion.VERSIONS[e.version] or not e.profile
or e.gen ~= GameVersion.generation(e.version) then
return nil, "A save has an incomplete or mismatched trainer profile: " .. e.path
end
TrainerIdSync.addOwner(self.owners, e.profile, e.gen)
if e.scope == self.source.scope and e.slot == self.source.slot then self.target = Store.copy(e.profile) end
return true
end
function TrainerIdSync:_write(e)
if type(e.save) ~= "table" then return end
if not TrainerIdSync.rewriteSave(e.save, e.gen, self.newId, self.ids) then return end
local meta = type(e.save.meta) == "table" and e.save.meta or {}
e.save.meta = meta
meta.savedAt = os.time()
local SaveData = self.SaveData
local ok, wrote, err = pcall(function()
if e.cart then return SaveData.writeCartSlot(e.cart, e.slot, e.save) end
return SaveData.writeSlot(e.version, e.slot, e.save)
end)
if ok and wrote then
self.written = self.written + 1
else
self.failed[#self.failed + 1] = tostring(ok and err or wrote)
function TrainerIdSync:_target()
if not self.target then return nil, "The selected save has no complete trainer identity." end
if self.target.sid == nil then
for _, e in ipairs(self.entries) do
if e.gen == 3 then self.target.sid = bit.bxor(e.profile.sid, e.profile.id, self.target.id); break end
end
self.target.sid = self.target.sid or 0
end
local checked = {}
for _, e in ipairs(self.entries) do checked[e.gen] = true end
local function checkEntry(entry)
checked[entry.generation] = true
for _, original in ipairs(entry.archives or {}) do checked[original.generation] = true end
end
if self.boxBody then
for _, box in ipairs(self.warehouse.boxes) do each(box.mons, checkEntry) end
each(self.warehouse.departures, function(d) checkEntry(d.entry) end)
end
for gen in pairs(checked) do
local codes, why
if gen < 3 then codes, why = Identity.nameCodes(self.target.name, gen)
else
local Codec = require("src.save_convert.Gen3Save")
local encoded = Codec.encodeString(self.target.name, 8)
codes = encoded:find("\255", 1, true) ~= nil
and Codec.decodeString(encoded, 0, 8) == self.target.name
if not codes then why = "The trainer name cannot be represented in a Gen 3 save." end
end
if not codes then return nil, why end
end
self.newId = self.target.id
return true
end
function TrainerIdSync:_plan(e)
local changed, why = TrainerIdSync.rewriteSave(e.save, e.gen, self.target, self.owners, self.report)
if changed == nil then return nil, e.path .. ": " .. why end
e.changed = changed
return true
end
function TrainerIdSync:_box()
if not self.boxBody then return true end
local Catalog, Records, Gifts = require("src.box.Catalog"), require("src.box.Records"), require("src.box.Gifts")
local function entrySync(entry)
local changed, why = syncMon(entry.mon, entry.generation, self.target, self.owners, nil, self.report)
if changed == nil then return nil, why end
if changed then entry.display = Catalog.describe(entry.version, entry.mon) end
for _, original in ipairs(entry.archives or {}) do
local ok, err = entrySync(original)
if not ok then return nil, err end
end
if entry.generation == 3 and entry.depositorId ~= nil then
for _, e in ipairs(self.entries) do
if e.gen == 3 and entry.depositorId == e.profile.id + e.profile.sid * 65536
and (entry.slotId == nil or entry.slotId == e.slot and entry.version == e.version and entry.cartId == e.cart) then
entry.depositorId = self.target.id + self.target.sid * 65536
break
end
end
end
return true
end
for _, box in ipairs(self.warehouse.boxes) do
for _, entry in pairs(box.mons) do
local ok, why = entrySync(entry)
if not ok then return nil, why end
end
end
for _, departure in pairs(self.warehouse.departures or {}) do
local ok, why = entrySync(departure.entry)
if not ok then return nil, why end
departure.identity = Records.identity(departure.entry.generation, departure.entry.mon)
end
for _, e in ipairs(self.entries) do
local source = { version = e.version, path = e.path }
local original = self.SaveData.decode(e.before)
local oldKey, newKey = Gifts.identity(source, original), Gifts.identity(source, e.save)
local progress = self.warehouse.progress
if progress and progress[oldKey] ~= nil and oldKey ~= newKey then
progress[newKey] = math.max(progress[newKey] or 0, progress[oldKey])
progress[oldKey] = nil
end
end
return true
end
function TrainerIdSync:_commit()
local ok, why
local changed = false
for _, e in ipairs(self.entries) do
if self.boxBody or e.changed then
local meta = type(e.save.meta) == "table" and e.save.meta or {}
e.save.meta = meta
if self.boxBody then
local opts = self.SaveData.loadOptions()
local mapped = opts.playthroughIds and opts.playthroughIds[e.scope]
local id = meta.playthroughId or mapped and mapped[e.slot] or self.SaveData.newPlaythroughId()
if meta.playthroughId ~= id or e.cart and meta.cartId ~= e.cart then e.changed = true end
meta.playthroughId = id
if e.cart then meta.cartId = e.cart end
self.warehouse.syncMembers = self.warehouse.syncMembers or {}
self.warehouse.syncMembers[e.version .. "/" .. meta.playthroughId] = {
version = e.version, playthroughId = meta.playthroughId, path = e.path, cartId = e.cart, slotId = e.slot }
end
if e.changed then
meta.savedAt = os.time()
self.jobs[#self.jobs + 1] = { path = e.path, before = e.before, after = self.SaveData.encode(e.save) }
changed = true
end
end
end
ok, why = self:_box()
if not ok then return nil, why end
if self.boxBody and self.SaveData.encode(self.warehouse) ~= self.boxBody then
local members = 0
for _ in pairs(self.warehouse.syncMembers or {}) do members = members + 1 end
if members > 240 then return nil, "ID Sync exceeds the 240-save cloud Box limit." end
self.warehouse.revision, self.warehouse.savedAt = self.warehouse.revision + 1, os.time()
self.jobs[#self.jobs + 1] = { path = Store.PATH, before = self.boxBody, after = self.SaveData.encode(self.warehouse) }
self.boxUpdated, changed = true, true
end
if self.fs.read("options.lua") ~= self.optionsBody or self.fs.read(Store.PATH) ~= self.boxBody then
return nil, "Save settings or Box changed. Run ID Sync again."
end
for _, e in ipairs(self.entries) do
ok, why = self:_check(e)
if not ok then return nil, why end
if self.fs.read(e.path) ~= e.before then return nil, "A save changed. Run ID Sync again." end
end
if not changed then return true end
ok, why = Transaction.commitIdentity(self.fs, self.jobs)
if not ok then return nil, why end
for _, e in ipairs(self.entries) do if e.changed then self.written = self.written + 1 end end
return true
end
function TrainerIdSync:step()
if self.phase == "done" then return true end
self.at = self.at + 1
local e = self.entries[self.at]
if self.phase == "read" then
if e then self:_read(e) end
if self.at >= #self.entries then
if not self.newId then
self.error = "the selected save has no trainer ID"
self.phase = "done"
return true
end
self.phase, self.at = "write", 0
local ok, why
if self.phase == "read" or self.phase == "plan" then
self.at = self.at + 1
local e = self.entries[self.at]
if e then
if self.phase == "read" then ok, why = self:_read(e) else ok, why = self:_plan(e) end
else ok = true end
if ok and self.at >= #self.entries then
if self.phase == "read" then
ok, why = self:_target()
if ok then self.phase, self.at = "plan", 0 end
else self.phase = "commit" end
end
return false
end
if e then
self:_write(e)
e.save = nil
end
if self.at >= #self.entries then
self.phase = "done"
return true
else
ok, why = self:_commit()
if ok then self.phase = "done"; return true end
end
if not ok then self.error, self.phase = why, "done"; return true end
return false
end
+168
View File
@@ -0,0 +1,168 @@
local bit = require("bit")
local Identity = {}
local U32 = 4294967296
function Identity.u16(value)
local n = tonumber(value)
if not n or n ~= n or n < 0 or n == math.huge or n ~= math.floor(n) then return nil end
return n % 65536
end
function Identity.packed(id, sid)
local n = tonumber(id)
if not n or n ~= n or n < 0 or n >= U32 or n ~= math.floor(n) then return nil end
return n % 65536 + (Identity.u16(sid) or math.floor(n / 65536)) * 65536
end
function Identity.nameOf(save, gen)
if type(save) ~= "table" then return nil end
local player = type(save.player) == "table" and save.player or {}
local name = gen == 3 and (save.name or save.playerName or player.name) or player.name
return type(name) == "string" and name ~= "" and name or nil
end
function Identity.monName(mon)
if type(mon) ~= "table" then return nil end
local name = mon.otName or mon.ot or mon.originalTrainer
if type(name) == "string" then return name end
if type(mon.cartOt) == "string" and not mon.cartOt:find("[^%x]") then
local map, out = require("src.save_convert.data.charmap").byByte, {}
for token in mon.cartOt:gmatch("%x%x") do
local code = tonumber(token, 16)
if code == 0x50 then break end
local glyph = map[code]
if not glyph or glyph:sub(1, 1) == "<" then return nil end
out[#out + 1] = glyph
end
return table.concat(out)
end
end
function Identity.rawId(mon, gen)
local raw = type(mon) == "table" and mon.cartRaw
local at = gen == 1 and 25 or gen == 2 and 13
if not at or type(raw) ~= "string" or #raw < at + 3 then return nil end
return tonumber(raw:sub(at, at + 3), 16)
end
function Identity.profile(save, gen)
if type(save) ~= "table" then return nil end
local name = Identity.nameOf(save, gen)
local id = gen == 3 and save.trainerId or save.player and save.player.id
local packed = Identity.packed(id, gen == 3 and save.secretId or 0)
if not packed or not name then return nil end
local sid = math.floor(packed / 65536)
if gen == 3 and save.secretId == nil and tonumber(id) < 65536 then
local function scan(list)
for _, mon in pairs(type(list) == "table" and list or {}) do
if type(mon) == "table" and Identity.u16(mon.otId) == packed
and Identity.monName(mon) == name and Identity.u16(mon.otSecretId) ~= nil then
return Identity.u16(mon.otSecretId)
end
end
end
sid = scan(save.party)
if sid == nil then
local boxes = type(save.storage) == "table" and save.storage.boxes
for _, box in pairs(type(boxes) == "table" and boxes or {}) do
if type(box) == "table" then sid = sid or scan(box.mons) end
end
end
sid = sid or 0
end
return { id = packed % 65536, sid = gen == 3 and sid or nil, name = name }
end
function Identity.key(gen, id, name, sid)
return table.concat({ gen, id, #name, name, gen == 3 and (sid or 0) or 0 }, "|")
end
function Identity.owner(mon, gen, owners, localOwner)
local id = Identity.u16(mon.otId) or Identity.rawId(mon, gen)
if id == nil then return nil end
local name = Identity.monName(mon)
local packed = gen == 3 and Identity.packed(mon.otId, mon.otSecretId)
local sid = packed and math.floor(packed / 65536) or 0
if name == nil and localOwner and not mon.traded and id == localOwner.id
and (gen ~= 3 or sid == (localOwner.sid or 0)) then name = localOwner.name end
if name == nil then return nil end
if gen == 3 and mon.otSecretId == nil and localOwner and not mon.traded and id == localOwner.id
and name == localOwner.name and tonumber(mon.otId) == id then sid = localOwner.sid or 0 end
return owners[Identity.key(gen, id, name, sid)]
end
local function unown(pid)
return (bit.band(pid, 3) + bit.band(bit.rshift(pid, 6), 12)
+ bit.band(bit.rshift(pid, 12), 48) + bit.band(bit.rshift(pid, 18), 192)) % 28
end
local function shiny(pid, id, sid)
return bit.bxor(id, sid, pid % 65536, math.floor(pid / 65536)) < 8
end
-- pokefirered/src/pokemon.c:6062 IsShinyOtIdPersonality
function Identity.personality(mon, target)
local pid = tonumber(mon.personality)
if not pid or pid ~= math.floor(pid) or pid < 0 or pid >= U32 then
return nil, "A Gen 3 Pokémon has no valid personality value."
end
local originalId = Identity.packed(mon.otId, mon.otSecretId) or 0
local old = shiny(pid, originalId % 65536, math.floor(originalId / 65536))
if mon.isShiny ~= nil then old = not not mon.isShiny end
if shiny(pid, target.id, target.sid) == old then return pid end
local low, high = pid % 65536, math.floor(pid / 65536)
if not old then
local nextHigh = high + 25600 <= 65535 and high + 25600 or high - 25600
return nextHigh * 65536 + low
end
local isUnown = tonumber(mon.species) == 201
local isWurmple = tonumber(mon.species) == 290
local limit = isUnown and 65536 or 256
for offset = 0, limit - 1 do
local nextLow = isUnown and (low + offset) % 65536
or (low % 256 + ((math.floor(low / 256) + offset) % 256) * 256)
for key = 0, 7 do
local nextHigh = bit.bxor(target.id, target.sid, nextLow, key)
local nextPid = nextHigh * 65536 + nextLow
if nextPid % 25 == pid % 25 and nextPid % 2 == pid % 2
and (not isUnown or unown(nextPid) == unown(pid))
and (not isWurmple or (nextHigh % 10 < 5) == (high % 10 < 5)) then return nextPid end
end
end
return nil, "Shininess and this Pokémon's personality traits cannot be preserved together."
end
function Identity.nameCodes(name, gen)
local map = gen == 2 and require("src.save_convert.Gen2Layout").charmap
or require("src.save_convert.data.charmap").byByte
local reverse = {}
for code, token in pairs(map) do
if type(token) == "string" and token ~= "" and code ~= 0x50 then reverse[token] = code end
end
local codes, pos = {}, 1
while pos <= #name do
local best, code
for token, byte in pairs(reverse) do
if name:sub(pos, pos + #token - 1) == token and (not best or #token > #best) then
best, code = token, byte
end
end
if not best then return nil, "The trainer name cannot be represented in a Gen " .. gen .. " save." end
codes[#codes + 1], pos = code, pos + #best
end
if #codes > 7 then return nil, "The trainer name is longer than the game's seven-character limit." end
return codes
end
function Identity.rename(mon, name, gen)
local changed, found = false, false
for _, key in ipairs({ "ot", "otName", "originalTrainer" }) do
if mon[key] ~= nil then
found = true
if mon[key] ~= name then mon[key], changed = name, true end
end
end
if not found then mon[gen == 3 and "otName" or "ot"], changed = name, true end
if gen == 1 and type(mon.cartOt) == "string" then
local codes, err = Identity.nameCodes(name, gen)
if not codes then return nil, err end
local tokens = {}
for _, code in ipairs(codes) do tokens[#tokens + 1] = ("%02X"):format(code) end
local prefix = table.concat(tokens) .. "50"
local raw = prefix .. mon.cartOt:sub(#prefix + 1)
if raw ~= mon.cartOt then mon.cartOt, changed = raw, true end
end
return changed
end
return Identity
+1 -24
View File
@@ -247,30 +247,7 @@ end
-- pokefirered/src/new_game.c:56
function Catching.playerSecretId(session)
if type(session) ~= "table" then return 0 end
local sec = tonumber(session.secretId or session.otSecretId)
local tid = tonumber(session.trainerId or session.id or session.playerId)
if not sec and tid then
local function scan(list)
for _, m in pairs(list or {}) do
local ms = (type(m) == "table") and tonumber(m.otSecretId) or nil
if ms and tonumber(m.otId) == tid then return ms end
end
return nil
end
sec = scan(session.party)
local storage = session.storage
for _, box in pairs((not sec) and storage and storage.boxes or {}) do
sec = sec or scan(type(box) == "table" and box.mons or nil)
end
end
if not sec then
local okR, Rng = pcall(require, "src.core.game3.rng")
sec = (okR and Rng and Rng.Random and Rng.Random()) or math.random(0, 0xFFFF)
end
sec = math.floor(sec) % 0x10000
session.secretId = sec
return sec
return Pokemon.playerSecretId(session)
end
-- pokefirered/src/battle_script_commands.c:9617
+9 -12
View File
@@ -1280,25 +1280,22 @@ function Engine.moveLimitations(b, ad)
end
local function player_identity(st)
local id, nm = st.playerTrainerId, st.playerOtName or st.playerName
if id == nil then
local sess = st.session
if not sess then
local Runtime = package.loaded["src.core.game3.runtime"]
local ok, sess = pcall(function() return Runtime and Runtime.getSession and Runtime.getSession() end)
if ok and sess then
id = sess.trainerId or sess.id or sess.playerId or 12345
nm = sess.name or sess.playerName or nm or "RED"
end
local ok, current = pcall(function() return Runtime and Runtime.getSession and Runtime.getSession() end)
if ok then sess = current end
end
return id, nm
sess = sess or {}
return { trainerId = st.playerTrainerId or sess.trainerId or sess.id or sess.playerId,
secretId = st.playerSecretId or sess.secretId,
name = st.playerOtName or st.playerName or sess.name or sess.playerName }
end
-- pokefirered/src/pokemon.c:5965
function Engine.isTradedMon(st, mon)
if not mon or mon.otId == nil then return false end
local pid, pname = player_identity(st or {})
if pid == nil then return false end
return tonumber(mon.otId) ~= tonumber(pid)
or (mon.otName ~= nil and pname ~= nil and tostring(mon.otName) ~= tostring(pname))
return require("src.core.game3.pokemon").isTradedMon(mon, player_identity(st or {}))
end
-- src/battle_message.c:1180
+1
View File
@@ -152,4 +152,5 @@ function M.receive(s, packets, mine)
giftItem = true, gift = receiveGift(s, players[1], mine)}
end
M.receiveTower = receiveTower
M.receiveGift = receiveGift
return M
+22 -18
View File
@@ -3052,8 +3052,8 @@ function F.Anim_ShakeGlowBlue_Fast(sprite) shakeGlow(sprite, 10, 2, 2) end
function F.Anim_ShakeGlowBlue(sprite) shakeGlow(sprite, 20, 1, 2) end
function F.Anim_ShakeGlowBlue_Slow(sprite) shakeGlow(sprite, 80, 1, 2) end
function MonAnim.animFunction(id)
local name = Data.functionName(id)
function MonAnim.animFunction(id, animationData)
local name = (animationData or Data).functionName(id)
local fn = name and F[name]
if not fn then error("mon_anim: no port for anim function " .. tostring(id) .. " (" .. tostring(name) .. ")") end
return fn, name
@@ -3150,6 +3150,7 @@ end
function MonAnim.newSprite(species, opts)
opts = opts or {}
local animationData = opts.animationData or Data
local sprite = {
species = tonumber(species) or 0,
data = { [0] = 0, 0, 0, 0, 0, 0, 0, 0 },
@@ -3163,7 +3164,8 @@ function MonAnim.newSprite(species, opts)
centerToCornerVecX = -32,
paletteNum = opts.paletteNum or 0,
blendCoeff = 0, blendColor = 0,
anims = Data.anims(species) or { [0] = { { frame = 0, duration = 0 }, { op = "end" } } },
anims = animationData.anims(species) or { [0] = { { frame = 0, duration = 0 }, { op = "end" } } },
animationData = opts.animationData,
animNum = 0, animCmdIndex = 0, animDelayCounter = 0, animLoopCounter = 0,
animBeginning = true, animEnded = false, animPaused = false,
frame = 0,
@@ -3217,7 +3219,7 @@ local function Task_HandleMonAnimation(sprite, task)
sprite.data[1] = 1
sprite.data[0] = 0
for i = 2, 7 do sprite.data[i] = 0 end
sprite.callback = MonAnim.animFunction(task.animId)
sprite.callback = MonAnim.animFunction(task.animId, sprite.animationData)
sIsSummaryAnim = false
task.state = task.state + 1
end
@@ -3242,7 +3244,7 @@ function MonAnim.startSummary(sprite, frontAnimId)
sIsSummaryAnim = true
sprite.summary = true
sprite.animId = frontAnimId
sprite.callback = MonAnim.animFunction(frontAnimId)
sprite.callback = MonAnim.animFunction(frontAnimId, sprite.animationData)
end
-- pokeemerald/src/pokemon_animation.c:956
@@ -3253,18 +3255,18 @@ function MonAnim.launchBack(sprite, backAnimSet, nature)
sprite.animId = t.animId
end
local function speciesConst(name)
local function speciesConst(name, version)
local GameVersion = require("src.core.GameVersion")
return require("src.core.game3.constants").of(GameVersion.get()):id("species", name)
return require("src.core.game3.constants").of(version or GameVersion.get()):id("species", name)
end
-- pokeemerald/src/pokemon.c:6988
function MonAnim.hasTwoFramesAnimation(species)
function MonAnim.hasTwoFramesAnimation(species, version)
species = tonumber(species)
return species ~= speciesConst("SPECIES_CASTFORM")
and species ~= speciesConst("SPECIES_DEOXYS")
and species ~= speciesConst("SPECIES_SPINDA")
and species ~= speciesConst("SPECIES_UNOWN")
return species ~= speciesConst("SPECIES_CASTFORM", version)
and species ~= speciesConst("SPECIES_DEOXYS", version)
and species ~= speciesConst("SPECIES_SPINDA", version)
and species ~= speciesConst("SPECIES_UNOWN", version)
end
local function playCry(opts, species, pan)
@@ -3339,9 +3341,10 @@ end
-- pokeemerald/src/pokemon.c:6858
function MonAnim.summary(sprite, species, oneFrame)
if not oneFrame and MonAnim.hasTwoFramesAnimation(species) then MonAnim.startSpriteAnim(sprite, 1) end
local delay = Data.delay(species)
local animId = Data.frontAnimId(species)
local animationData = sprite.animationData or Data
if not oneFrame and MonAnim.hasTwoFramesAnimation(species, animationData.version) then MonAnim.startSpriteAnim(sprite, 1) end
local delay = animationData.delay(species)
local animId = animationData.frontAnimId(species)
sprite.summary = true
if delay ~= 0 then
local t = createTask(sprite, Task_PokemonSummaryAnimateAfterDelay, 0)
@@ -3472,9 +3475,10 @@ function MonAnim.draw(sprite, image, cx, cy, opts)
opts = opts or {}
local t = MonAnim.transform(sprite)
if t.invisible then return end
local x, y = cx + t.x2, cy + t.y2
local scale = opts.scale or 1
local x, y = cx + t.x2 * scale, cy + t.y2 * scale
local r, g, b, a = love.graphics.getColor()
love.graphics.draw(image, x, y, t.rotation, t.sx, t.sy, 32, 32)
love.graphics.draw(image, x, y, t.rotation, t.sx * scale, t.sy * scale, 32, 32)
if (sprite.blendCoeff or 0) > 0 then
if silhouette == nil then
local okS, sh = pcall(love.graphics.newShader, [[
@@ -3488,7 +3492,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
local br, bg, bb, k = MonAnim.blendRgb(sprite)
love.graphics.setShader(silhouette)
love.graphics.setColor(br, bg, bb, math.min(1, k) * a)
love.graphics.draw(image, x, y, t.rotation, t.sx, t.sy, 32, 32)
love.graphics.draw(image, x, y, t.rotation, t.sx * scale, t.sy * scale, 32, 32)
love.graphics.setShader()
end
end
+1 -1
View File
@@ -256,7 +256,7 @@ function Party.giveMon(session, species, level, nickname, opts)
otName = session.name or session.playerName or "RED",
otId = session.trainerId or session.id or session.playerId or 12345,
-- pokefirered/src/pokemon.c:1796 CreateBoxMon OT_ID_PLAYER_ID
otSecretId = tonumber(session.secretId) or nil,
otSecretId = Pokemon.playerSecretId(session),
-- pokefirered/src/pokemon.c:1822
otGender = Party.otGender(session),
pokeball = 4, -- Poké Ball
+38 -3
View File
@@ -793,6 +793,7 @@ end
local function player_identity(player)
player = player or {}
local id = player.trainerId or player.id or player.playerId
local sid = player.secretId
local name = player.name or player.playerName or player.otName
if id == nil or name == nil then
local Runtime = package.loaded["src.core.game3.runtime"]
@@ -801,17 +802,18 @@ local function player_identity(player)
end)
if ok and sess then
id = id or sess.trainerId or sess.id or sess.playerId
sid = sid or sess.secretId
name = name or sess.name or sess.playerName
end
end
return tonumber(id), name
return require("src.core.TrainerIdentity").packed(id, sid), name
end
-- pokefirered/src/pokemon.c:5974 IsOtherTrainer
function Pokemon.isOtherTrainer(otId, otName, player)
local playerId, playerName = player_identity(player)
if playerId == nil then return false end
if tonumber(otId) ~= playerId then return true end
if require("src.core.TrainerIdentity").packed(otId) ~= playerId then return true end
local mine = tostring(playerName or "")
local theirs = tostring(otName or "")
for i = 1, #theirs do
@@ -820,10 +822,43 @@ function Pokemon.isOtherTrainer(otId, otName, player)
return false
end
-- pokefirered/src/new_game.c:56
function Pokemon.playerSecretId(session)
if type(session) ~= "table" then return 0 end
local Identity = require("src.core.TrainerIdentity")
local sec = Identity.u16(session.secretId) or Identity.u16(session.otSecretId)
local tid = Identity.u16(session.trainerId or session.id or session.playerId)
local name = session.name or session.playerName
local function scan(list)
for _, m in pairs(type(list) == "table" and list or {}) do
local otName = type(m) == "table" and (m.otName or m.ot)
if type(m) == "table" and Identity.u16(m.otId) == tid
and not (type(name) == "string" and type(otName) == "string" and otName ~= name) then
local packed = Identity.packed(m.otId, m.otSecretId)
if packed and (Identity.u16(m.otSecretId) or packed >= 65536) then
return math.floor(packed / 65536)
end
end
end
end
if sec == nil and tid then
sec = scan(session.party)
local storage = session.storage
for _, box in pairs((sec == nil) and type(storage) == "table" and type(storage.boxes) == "table"
and storage.boxes or {}) do
sec = sec or scan(type(box) == "table" and box.mons or nil)
end
end
sec = sec or 0
session.secretId = sec
return sec
end
-- pokefirered/src/pokemon.c:5965 IsTradedMon
function Pokemon.isTradedMon(mon, player)
if type(mon) ~= "table" or mon.otId == nil then return false end
return Pokemon.isOtherTrainer(mon.otId, mon.otName or mon.ot, player)
local id = require("src.core.TrainerIdentity").packed(mon.otId, mon.otSecretId)
return Pokemon.isOtherTrainer(id, mon.otName or mon.ot, player)
end
local function friendship_bonuses(mon, friendship, ctx)
+16 -3
View File
@@ -84,6 +84,19 @@ local function pcHas(sess, item)
return false
end
local function writeGiftState(sess, kind, name, value)
if sess.store then
local id = constant(sess, kind, name)
if id then
local Flags = require("src.core.game3.scripting.flags")
local setter = kind == "flags" and Flags.setFlag or Flags.setVar
setter(sess.store, nil, id, value)
end
else
pcall(kind == "flags" and Rse.setFlag or Rse.setVar, name, value, sess)
end
end
-- pokeemerald/src/record_mixing.c:969
function Gift.mixImport(players, sess, myIndex)
sess = sessionOf(sess)
@@ -97,13 +110,13 @@ function Gift.mixImport(players, sess, myIndex)
sess.bag = sess.bag or Bag.new()
local got = not Bag.has(sess.bag, item, 1) and not pcHas(sess, item) and Bag.add(sess.bag, item, 1)
if got then
pcall(Rse.setVar, "VAR_TEMP_RECORD_MIX_GIFT_ITEM", item, sess)
writeGiftState(sess, "vars", "VAR_TEMP_RECORD_MIX_GIFT_ITEM", item)
if item == constant(sess, "items", "ITEM_EON_TICKET") then
pcall(Rse.setFlag, "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", true, sess)
writeGiftState(sess, "flags", "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", true)
end
return { item = item, from = p0.name or "" }
end
pcall(Rse.setVar, "VAR_TEMP_RECORD_MIX_GIFT_ITEM", 0, sess)
writeGiftState(sess, "vars", "VAR_TEMP_RECORD_MIX_GIFT_ITEM", 0)
return { item = 0 }
end
+23
View File
@@ -88,6 +88,24 @@ local function healIdFor(w)
return false
end
-- pokeemerald/include/global.h:956
function M.boxFlags(save)
local md = type(save) == "table" and type(save.modData) == "table" and save.modData or {}
local ci, gift = md.cartImport, md.boxGift
if type(ci) == "table" and tonumber(ci.boxFlags) then return tonumber(ci.boxFlags) % 256 end
if type(gift) == "table" and tonumber(gift.flags) then return tonumber(gift.flags) % 256 end
return nil
end
function M.setBoxFlags(save, value)
value = (tonumber(value) or 0) % 256
save.modData = type(save.modData) == "table" and save.modData or {}
local ci = save.modData.cartImport
if type(ci) == "table" and ci.boxFlags ~= nil then ci.boxFlags = value end
if type(save.modData.boxGift) ~= "table" then save.modData.boxGift = {} end
save.modData.boxGift.flags = value
end
function M.finishCartImport(save)
local ci = type(save.modData) == "table" and save.modData.cartImport
if type(ci) ~= "table" or not Pokemon.isInternalSpecies(1) then return end
@@ -120,6 +138,11 @@ function M.finishCartImport(save)
else
ci.lastHealLocation = nil
end
if tonumber(ci.boxFlags) then
local flags = M.boxFlags(save)
ci.boxFlags = nil
M.setBoxFlags(save, flags)
end
if ci.lastHealLocation == nil then save.modData.cartImport = nil end
end
+71 -8
View File
@@ -70,6 +70,9 @@ local function is_own_mon(session, mon)
if type(otName) == "string" and type(name) == "string" and otName ~= name then return false end
local otId, tid = tonumber(mon.otId), tonumber(session.trainerId)
if otId and tid and (otId % 0x10000) ~= (tid % 0x10000) then return false end
local Identity = require("src.core.TrainerIdentity")
local sid = Identity.u16(session.secretId)
if mon.otSecretId ~= nil and sid and Identity.u16(mon.otSecretId) ~= sid then return false end
return true
end
@@ -83,21 +86,80 @@ local function repair_own_mon(session, mon)
mon.metLocation = met
end
local secret = tonumber(session.secretId)
if secret and tonumber(mon.otSecretId) ~= secret then
mon.otSecretId = secret
if secret and mon.otSecretId == nil then
local otId = tonumber(mon.otId)
mon.otSecretId = otId and otId >= 0x10000 and math.floor(otId / 0x10000) % 0x10000 or secret
end
end
function Schema.repairOwnMons(session)
for _, mon in ipairs(session.party or {}) do
repair_own_mon(session, mon)
local function each_held_mon(session, fn)
local seen = {}
local function visit(mon)
if type(mon) ~= "table" or seen[mon] then return end
seen[mon] = true
fn(mon)
end
local function visit_list(list)
if type(list) ~= "table" then return end
for _, mon in pairs(list) do visit(mon) end
end
visit_list(session.party)
local storage = session.storage
for _, box in pairs(storage and storage.boxes or {}) do
for _, mon in pairs(type(box) == "table" and box.mons or {}) do
repair_own_mon(session, mon)
visit_list(type(box) == "table" and box.mons or nil)
end
local function visit_held(mon)
if type(mon) == "table" and (tonumber(mon.species or mon.speciesId) or 0) ~= 0 then visit(mon) end
end
local function visit_held_list(list)
if type(list) ~= "table" then return end
for _, mon in pairs(list) do visit_held(mon) end
end
local md = type(session.modData) == "table" and session.modData or {}
local okD, Daycare = pcall(require, "src.core.game3.daycare")
local root = okD and md[Daycare.saveKey(session)] or nil
-- pokefirered/include/global.h:549
local dcs = { type(root) == "table" and root.daycare or false, rawget(session, "daycare") or false }
for _, dc in ipairs(dcs) do
if type(dc) == "table" then
for i = 1, Daycare.DAYCARE_MON_COUNT do visit_held(dc[i]) end
visit_held_list(dc.mons)
end
end
-- pokefirered/src/daycare.c:1563
local r5s = { type(root) == "table" and root.route5Daycare or false, rawget(session, "route5Daycare") or false }
for _, r5 in ipairs(r5s) do
if type(r5) == "table" then visit_held(r5.mon) end
end
-- pokefirered/src/load_save.c:160
visit_held_list(session.savedPlayerParty)
visit_held_list(md.savedPlayerParty)
end
function Schema.repairOwnMons(session)
require("src.core.game3.pokemon").playerSecretId(session)
each_held_mon(session, function(mon) repair_own_mon(session, mon) end)
end
function Schema.reseatLegacySecretIds(session)
local Identity = require("src.core.TrainerIdentity")
local sid = Identity.u16(session.secretId)
local tid = Identity.u16(session.trainerId)
local name = session.name or session.playerName
if not sid or not tid or type(name) ~= "string" then return 0 end
local moved = 0
local function reseat(mon)
if type(mon) ~= "table" or mon.otSecretId == nil then return end
local otName = mon.otName or mon.ot or mon.originalTrainer
if otName ~= name or Identity.u16(mon.otId) ~= tid then return end
if Identity.u16(mon.otSecretId) == sid then return end
local pid = Identity.personality(mon, { id = tid, sid = sid })
if not pid then return end
mon.personality, mon.otSecretId, mon.otId = pid, sid, tid
moved = moved + 1
end
each_held_mon(session, reseat)
return moved
end
-- pokefirered/src/union_room_chat.c:1430
@@ -308,7 +370,6 @@ function Schema.toSaveTable(session)
move_overlay = session.move_overlay or {},
trainerId = session.trainerId,
secretId = session.secretId,
secretId = session.secretId,
rng = session.rng,
vsSeeker = session.vsSeeker,
roamer = session.roamer,
@@ -456,7 +517,9 @@ function Schema.fromSaveTable(save)
rules.resetStateOnContinue(session)
rules.useContinueGameWarp(session)
Schema.ensureMonBalls(session)
local legacySecret = save.secretId == nil and session.secretId == nil
Schema.repairOwnMons(session)
if legacySecret then Schema.reseatLegacySecretIds(session) end
if rules.repairSaveState then rules.repairSaveState(session) end
Schema.repairRoamer(session)
if type(save.options) == "table" then
+253
View File
@@ -0,0 +1,253 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Icons = require("src.ui.kit.Icons")
local UI = require("src.import.BoxUI")
local Details = {}
local PAL = Theme.PAL
Details.SECTIONS = { "Overview", "Moves", "Stats", "Origin" }
Details.HELP = {
Overview = "the basics for this pokémon. marks are just labels you can add. held items stay with it when you move it.",
Moves = "PP is how many uses the move has left. PP Ups increase its maximum. the bar shows what’s left right now.",
Stats = "current stats, built-in potential and training effort sit in separate columns. — means it wasn’t recorded.",
Origin = "who caught it and where it came from. trainer IDs distinguish trainers with the same name. missing details say Not recorded.",
}
function Details.help(entry, section)
if section ~= "Stats" then return Details.HELP[section] end
return entry.generation == 3
and "IVs are the pokémon’s built-in potential. EVs are effort from training. — means it wasn’t recorded."
or "DVs are the pokémon’s built-in potential. Stat Exp is effort from training. Gen 2 shares its Special DV and effort between both special stats."
end
local OVERVIEW = { Experience = true, Gender = true, ["Friendship / egg cycles"] = true,
Nature = true, Ability = true, Markings = true, ["Pokérus"] = true }
local STAT_FIELDS = { Stats = true, IVs = true, DVs = true, EVs = true, ["Stat experience"] = true, Contest = true }
local STAT_ORDER = { "hp", "attack", "defense", "spAtk", "spDef", "speed", "special" }
local ALIASES = { atk = "attack", def = "defense", spe = "speed", spa = "spAtk", spd = "spDef",
specialAttack = "spAtk", specialDefense = "spDef" }
local LABELS = { hp = "HP", attack = "Attack", defense = "Defense", speed = "Speed",
spAtk = "Sp. Attack", spDef = "Sp. Defense", special = "Special" }
local FIELD_ICONS = { Experience = "award", Gender = "user-round", Nature = "paintbrush", Ability = "shield-check",
Friendship = "heart", ["Egg cycles"] = "heart", ["Held item"] = "backpack", Markings = "check",
["Pokérus"] = "heart", ["Original Trainer"] = "user-round", ["Trainer ID"] = "users", ["Secret ID"] = "lock",
["Poké Ball"] = "package", ["Met game"] = "flag", ["Met location"] = "map-pin", ["Met level"] = "award",
["Fateful encounter"] = "flag", Tags = "file-pen-line" }
local function human(key)
return LABELS[key] or tostring(key):gsub("(%l)(%u)", "%1 %2"):gsub("_", " "):gsub("^%l", string.upper)
end
local function normalized(values)
local out = {}
for key, value in pairs(values or {}) do if not ALIASES[key] then out[key] = value end end
for key, value in pairs(values or {}) do
if ALIASES[key] then
if out[ALIASES[key]] == nil then out[ALIASES[key]] = value
elseif out[ALIASES[key]] ~= value then out[key] = value end
end
end
return out
end
local function number(value)
local text = tostring(value)
local sign, whole, tail = text:match("^(-?)(%d+)(.*)$")
if not whole then return text end
return sign .. whole:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "") .. tail
end
local function shown(value)
if value == nil then return "Not recorded" end
if value == true then return "Yes" elseif value == false then return "No" end
return tostring(value)
end
local function statRows(entry)
local mon, gen = entry.mon, entry.generation
local stats = normalized(entry.display.stats)
local genes = normalized(gen == 3 and mon.ivs or mon.dvs)
local effort = normalized(gen == 3 and mon.evs or mon.statExp)
local derived
if gen <= 2 and genes.hp == nil and genes.attack ~= nil and genes.defense ~= nil
and genes.speed ~= nil and genes.special ~= nil then
genes.hp = require("src.battle.gen2.Mon").hpDV(genes); derived = true
end
if gen == 2 then
for _, key in ipairs({ "spAtk", "spDef" }) do
genes[key] = genes.special ~= nil and genes.special or genes[key]
effort[key] = effort.special ~= nil and effort.special or effort[key]
end
end
local keys, seen = {}, {}
local function add(key)
if not seen[key] then keys[#keys + 1], seen[key] = key, true end
end
for _, key in ipairs(STAT_ORDER) do
if stats[key] ~= nil or genes[key] ~= nil or effort[key] ~= nil then
if gen ~= 2 or key ~= "special" or stats.special ~= nil then add(key) end
end
end
local extras = {}
for _, values in ipairs({ stats, genes, effort }) do
for key in pairs(values) do
if not seen[key] and not (gen == 2 and key == "special") then extras[key] = true end
end
end
local sorted = {}; for key in pairs(extras) do sorted[#sorted + 1] = key end
table.sort(sorted, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(sorted) do add(key) end
local rows = {}
local effortRecorded = type(gen == 3 and mon.evs or mon.statExp) == "table"
for _, key in ipairs(keys) do rows[#rows + 1] = { label = human(key), value = stats[key], gene = genes[key],
effort = effort[key] ~= nil and effort[key] or effortRecorded and 0 or nil } end
return rows, derived
end
function Details.model(entry)
local d, mon = entry.display, entry.mon
local model = { overview = {}, origin = {}, moves = d.moveDetails or {}, ribbons = {}, contest = {} }
local moveNames = {}; for _, move in ipairs(model.moves) do moveNames[move.name] = true end
for _, field in ipairs(require("src.box.Metadata").summary(entry)) do
local label, value = field[1], field[2]
if not STAT_FIELDS[label] and not moveNames[label] and label ~= "Ribbons" then
local group = OVERVIEW[label] and model.overview or model.origin
if label == "Experience" and d.experience ~= nil then value = number(d.experience)
elseif label == "Friendship / egg cycles" then label = d.egg and "Egg cycles" or "Friendship"
elseif label == "Markings" and tonumber(value) then
local marks = {}; for i, mark in ipairs({ "Circle", "Square", "Triangle", "Heart" }) do
if math.floor(tonumber(value) / 2 ^ (i - 1)) % 2 == 1 then marks[#marks + 1] = mark end
end
value = #marks > 0 and table.concat(marks, " · ") or "None"
elseif label == "Pokérus" and tonumber(value) then
local status = tonumber(value)
value = status == 0 and "None" or "Strain " .. math.floor(status / 16) .. "\n"
.. (status % 16 == 0 and "Recovered" or status % 16 .. " days left")
elseif label == "Fateful encounter" then value = shown(d.fateful)
elseif (label == "Trainer ID" or label == "Secret ID") and tonumber(value) then
value = string.format("%05d", tonumber(value))
elseif label == "Met game" and tonumber(value) then
for _, layout in ipairs({ "rs", "emerald", "frlg" }) do
local version = require("src.save_convert.gen3_layouts." .. layout).GAME_CODES[tonumber(value)]
if version then value = require("src.core.GameVersion").info(version).label; break end
end
end
group[#group + 1] = { label = label, value = value, icon = FIELD_ICONS[label] }
end
end
model.overview[#model.overview + 1] = { label = "Held item", value = d.item == nil and "Not recorded" or d.item ~= "" and d.item or "None", icon = "backpack" }
if entry.tags and entry.tags ~= "" then model.overview[#model.overview + 1] = { label = "Tags", value = entry.tags, icon = "file-pen-line" } end
model.stats, model.derivedHP = statRows(entry)
local contestKeys = {}; for key in pairs(mon.contest or {}) do contestKeys[#contestKeys + 1] = key end
table.sort(contestKeys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(contestKeys) do model.contest[#model.contest + 1] = { label = human(key), value = tostring(mon.contest[key]), icon = "award" } end
if entry.generation == 3 then
local Ribbons = require("src.core.game3.rse.ribbons")
for _, key in ipairs(Ribbons.COUNTED) do
local count = Ribbons.get(mon, key)
if count > 0 then model.ribbons[#model.ribbons + 1] = { label = human(key), value = count > 1 and "×" .. count or "Earned", icon = "award" } end
end
model.hasRibbons = true
end
return model
end
local function fields(rows, x, y, w, m)
local gap, pad = 10 * m.s, 12 * m.s
local cols = w >= 580 * m.s and 3 or w >= 270 * m.s and 2 or 1
local cw = (w - (cols - 1) * gap) / cols
for first = 1, #rows, cols do
local height = 0
for i = first, math.min(#rows, first + cols - 1) do
local row = rows[i]
local labelH = math.max(18 * m.s, Kit.wrapHeight("micro", row.label, cw - 2 * pad - 24 * m.s))
height = math.max(height, 2 * pad + labelH + 8 * m.s + Kit.wrapHeight("button", row.value, cw - 2 * pad))
end
for i = first, math.min(#rows, first + cols - 1) do
local row, cx = rows[i], x + (i - first) * (cw + gap)
Theme.card(cx, y, cw, height, { shadow = true })
Icons.draw(row.icon or "book-open", cx + pad, y + pad, 18 * m.s, PAL.blue, 1)
local labelH = math.max(18 * m.s, Kit.textWrapped("micro", row.label, cx + pad + 24 * m.s,
y + pad, cw - 2 * pad - 24 * m.s, PAL.muted))
Kit.textWrapped("button", row.value, cx + pad, y + pad + labelH + 8 * m.s, cw - 2 * pad,
row.value == "Not recorded" and PAL.muted or PAL.heading)
end
y = y + height + gap
end
return y
end
local function heading(imp, label, help, x, y, w, m)
local h = math.max(Kit.tapMin(), Kit.textHeight("button"))
Kit.textBold("button", label, x, y + (h - Kit.textHeight("button")) / 2, PAL.heading)
if help then UI.help(imp, label, x + w - h, y, h, help) end
return y + h + 8 * m.s
end
local function statTable(entry, model, x, y, w, m)
local gen = entry.generation
local compact = w < 580 * m.s
local pad, rowH = 12 * m.s, (compact and 30 or 38) * m.s
local headerH = (compact and 28 or 34) * m.s
Theme.card(x, y, w, 2 * pad + headerH + #model.stats * rowH, { shadow = true })
local inner, labelW = w - 2 * pad, (w - 2 * pad) * .37
local numberW = (inner - labelW) / 3
local headers = { "Value", gen == 3 and "IV" or "DV", gen == 3 and "EV" or "Stat Exp" }
Kit.text("micro", "Stat", x + pad, y + pad + 6 * m.s, PAL.muted)
for i, label in ipairs(headers) do Kit.textCenter("micro", label, x + pad + labelW + (i - 1) * numberW,
y + pad + 6 * m.s, numberW, PAL.muted) end
local cy = y + pad + headerH
for i, row in ipairs(model.stats) do
if i % 2 == 1 then Theme.fillRounded(x + 4 * m.s, cy, w - 8 * m.s, rowH, PAL.raised, .8, 5 * m.s) end
Kit.text("small", row.label, x + pad, cy + (rowH - Kit.textHeight("small")) / 2, PAL.text)
for column, key in ipairs({ "value", "gene", "effort" }) do
local value = row[key] == nil and "—" or tostring(row[key])
Kit.textCenterBold(column == 1 and "button" or "small", value, x + pad + labelW + (column - 1) * numberW,
cy + (rowH - Kit.textHeight(column == 1 and "button" or "small")) / 2, numberW,
column == 1 and PAL.heading or row[key] == nil and PAL.muted or PAL.blue)
end
cy = cy + rowH
end
y = cy + pad + 10 * m.s
Kit.text("micro", "— not recorded" .. (model.derivedHP and " · HP DV is derived" or ""), x, y, PAL.muted)
return y + Kit.textHeight("micro") + 10 * m.s
end
local function moves(rows, x, y, w, m)
local pad, gap = 14 * m.s, 10 * m.s
for _, move in ipairs(rows) do
local h = 2 * pad + Kit.textHeight("button") + Kit.textHeight("micro") + 20 * m.s
local ppW = math.min(116 * m.s, w * .34)
local titleW = w - 2 * pad - 30 * m.s - ppW - gap
local titleH = Kit.wrapHeight("button", tostring(move.name), titleW)
if titleH > Kit.textHeight("button") then h = h + titleH - Kit.textHeight("button") end
Theme.card(x, y, w, h, { shadow = true })
Icons.draw("book-open", x + pad, y + pad, 22 * m.s, PAL.blue, 1)
Kit.textWrapped("button", tostring(move.name), x + pad + 30 * m.s, y + pad, titleW, PAL.heading)
Kit.textRight("button", (move.pp == nil and "—" or move.pp) .. " / " .. (move.maxPP == nil and "—" or move.maxPP),
x + w - pad, y + pad, PAL.heading)
Kit.text("micro", tostring(move.ppUps or 0) .. " PP Ups", x + pad + 30 * m.s, y + h - pad - Kit.textHeight("micro"), PAL.muted)
Kit.textRight("micro", "PP", x + w - pad, y + h - pad - Kit.textHeight("micro"), PAL.muted)
local trackW, trackH = w - 2 * pad, 3 * m.s
Theme.fillRounded(x + pad, y + h - 7 * m.s, trackW, trackH, PAL.line, .4, trackH)
if type(move.pp) == "number" and type(move.maxPP) == "number" and move.maxPP > 0 then
Theme.fillRounded(x + pad, y + h - 7 * m.s, trackW * math.max(0, math.min(1, move.pp / move.maxPP)), trackH,
move.pp == 0 and PAL.red or PAL.blue, 1, trackH)
end
y = y + h + gap
end
if #rows == 0 then y = fields({ { label = "Moves", value = "None recorded", icon = "book-open" } }, x, y, w, m) end
return y
end
local modelCache = setmetatable({}, { __mode = "k" })
function Details.draw(imp, entry, section, x, y, w, m)
local hit = modelCache[entry]
if not hit or hit.display ~= entry.display or hit.mon ~= entry.mon or hit.tags ~= entry.tags then
hit = { display = entry.display, mon = entry.mon, tags = entry.tags, model = Details.model(entry) }
modelCache[entry] = hit
end
local top, model = y, hit.model
if section == "Overview" then y = fields(model.overview, x, y, w, m)
elseif section == "Moves" then y = moves(model.moves, x, y, w, m)
elseif section == "Stats" then
y = statTable(entry, model, x, y, w, m)
if #model.contest > 0 then y = heading(imp, "Contest", nil, x, y, w, m); y = fields(model.contest, x, y, w, m) end
else
y = fields(model.origin, x, y, w, m)
if model.hasRibbons then
y = heading(imp, "Ribbons", nil, x, y, w, m)
y = fields(#model.ribbons > 0 and model.ribbons or { { label = "Ribbons", value = "None", icon = "award" } }, x, y, w, m)
end
end
return y - top
end
return Details
+399
View File
@@ -0,0 +1,399 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local UI = require("src.import.BoxUI")
local Store = require("src.box.Store")
local Organizer = require("src.box.Organizer")
local Catalog = require("src.box.Catalog")
local GameVersion = require("src.core.GameVersion")
local PAL = Theme.PAL
local Panel = {}
local function newRule()
return { match = "all", firstBox = 1, lastBox = 1, conditions = { { field = "shiny", value = true } } }
end
local function conditionLabel(condition)
local value = condition.value
if type(value) == "boolean" then value = value and "Yes" or "No" end
return (Organizer.FIELD_LABELS[condition.field] or condition.field) .. ": " .. tostring(value or "Choose")
end
local function ruleLabel(rule, index)
return index .. ". " .. conditionLabel(rule.conditions[1]) .. (#rule.conditions > 1 and " + more" or "")
.. " → Box " .. rule.firstBox .. (rule.lastBox ~= rule.firstBox and "–" .. rule.lastBox or "")
end
local valueCache = setmetatable({}, { __mode = "k" })
local function computeValues(field, layout, target, source)
if field == "shiny" or field == "egg" then return { true, false }, { [true] = "Yes", [false] = "No" } end
if field == "levelMin" or field == "levelMax" then
local out = {}; for level = 0, 255 do out[#out + 1] = level end; return out
end
if field == "game" then
local labels = {}; for _, version in ipairs(GameVersion.ORDER) do labels[version] = GameVersion.info(version).label end
return GameVersion.ORDER, labels
end
if field == "mark" then return { "circle", "square", "triangle", "heart" } end
if field == "gender" then return { "Male", "Female", "Genderless" } end
local out, seen = {}, {}
local function add(value)
if type(value) == "string" and value ~= "" and not seen[value:lower()] then
seen[value:lower()], out[#out + 1] = true, value
end
end
for _, row in ipairs(layout.rows) do
local d = row.entry.display
if field == "type" or field == "move" then
for value in tostring(field == "type" and d.types or d.moves or ""):gmatch(field == "type" and "[^/]+" or "[^,]+") do
add(value:match("^%s*(.-)%s*$"))
end
elseif field == "tag" then add(row.entry.tags)
else add(d[field]) end
end
if field == "species" then
for _, version in ipairs(target == "pc" and source and { source.version } or GameVersion.ORDER) do
local data = Catalog.get(version)
for _, value in pairs(data.names or {}) do add(value) end
for key, def in pairs(data.pokemon or {}) do add(type(def) == "table" and (def.name or key) or key) end
end
end
table.sort(out, function(a, b) return a:lower() < b:lower() end)
return out
end
local function values(field, layout, target, source)
local byLayout = valueCache[layout]
if not byLayout then byLayout = {}; valueCache[layout] = byLayout end
local key = tostring(field) .. "|" .. tostring(target) .. "|" .. tostring(source and source.version)
local hit = byLayout[key]
if not hit then
local out, labels = computeValues(field, layout, target, source)
hit = { out, labels }; byLayout[key] = hit
end
return hit[1], hit[2]
end
local function clampBoxes(config, count)
local out = Store.copy(config)
if out.box and (out.box < 1 or out.box > count) then out.box = nil end
for _, rule in ipairs(out.rules or {}) do
local first = math.max(1, math.min(count, rule.firstBox or 1))
rule.firstBox, rule.lastBox = first, math.max(first, math.min(count, rule.lastBox or first))
end
return out
end
local function findProfile(profiles, mode, key)
if not key then return nil end
local hinted = profiles[key.index]
if hinted and hinted.config and hinted.config.mode == mode and hinted.name == key.name and hinted.target == key.target then
return key.index
end
for i, p in ipairs(profiles) do
if p.config and p.config.mode == mode and p.name == key.name and p.target == key.target then return i end
end
end
local function card(x, y, w, h, title, number, m, accent)
Theme.card(x,y,w,h,{stroke=accent or PAL.line,strokeA=accent and .8 or .35})
local pad = 14*m.s
if number then
Theme.strokeRounded(x+pad,y+pad,26*m.s,26*m.s,PAL.line,.65,1,13*m.s)
Kit.textCenterBold("micro",tostring(number),x+pad,y+pad+5*m.s,26*m.s,PAL.heading)
end
Kit.textBold("small",title,x+pad+(number and 38*m.s or 0),y+pad+3*m.s,PAL.heading)
end
local function changed(o) o.preview,o.previewPage,o.signature=nil,1,nil end
local function previewColumns(w,m,compact,n)
return compact and n or math.max(2,math.min(6,math.floor(w/(46*m.s))))
end
local function previewActions(imp,s,o,layout,report,why,x,y,w,m,api,compact)
local gap,h=8*m.s,math.max(Kit.tapMin(),m.btnH)
Kit.textBold("micro","AFTER APPLYING",x,y,PAL.muted);y=y+Kit.textHeight("micro")+gap
local boxes=report and report.boxes
local n=layout.count
local cols=previewColumns(w,m,compact,n)
local gridGap=compact and 2*m.s or gap
local cw=(w-(cols-1)*gridGap)/cols
local bh=compact and 30*m.s or 54*m.s
for b=1,n do
local cx=x+(b-1)%cols*(cw+gridGap)
local cy=y+math.floor((b-1)/cols)*(bh+gap)
local box=boxes and boxes[b]
local count=box and box.after or 0
local color=PAL.muted
if o.analysis and o.analysis.reserved[b] then color=PAL.blue end
Theme.card(cx,cy,cw,bh,{fill=PAL.rowBg,strokeA=.35})
if not compact then
Kit.textBold("micro",tostring(b),cx+5*m.s,cy+5*m.s,PAL.heading)
Kit.text("micro",count.."/"..layout.capacity,cx+5*m.s,cy+25*m.s,PAL.muted)
end
local barY=compact and cy or cy+bh-8*m.s
Theme.fillRounded(cx+2,barY,math.max(1,cw-4),compact and bh or 3*m.s,PAL.raised,1,2)
if count>0 then Theme.fillRounded(cx+2,barY,math.max(1,(cw-4)*count/layout.capacity),
compact and bh or 3*m.s,color,1,2) end
end
y=y+math.ceil(n/cols)*(bh+gap)
local status=why or (report and (report.total.." Pokémon · "..report.moved.." moves") or "Choose your rules.")
y=y+Kit.textWrapped("micro",status,x,y,w,why and PAL.yellow or PAL.muted,3)+gap
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
if p then
local moves={}
for _,row in ipairs(p.report.assignments) do
if row.moved then moves[#moves+1]={label=tostring(row.entry.display.name).." · Box "..row.fromBox.."/"..row.fromSlot..
" → "..row.box.."/"..row.slot,entry=row.entry} end
end
UI.open(imp,"Preview · "..p.report.moved.." moves",moves)
end
end,false,why~=nil,"eye")
api.button(imp,x+(w+gap)/2,y,(w-gap)/2,h,"organize-apply",
compact and "Apply" or o.target=="pc" and "Apply to game" or "Apply to Box",function()
if api.perform(imp,function() return s.service:applyOrganize(o.preview) end,
o.target=="pc" and "Game PC arranged. Saved and backed up." or "Box arranged. Previous layout backed up.") then changed(o) end
end,false,not o.preview or o.preview.report.moved==0 or why~=nil,"save")
return y+h
end
function Panel.drawSticky(imp,m)
local f=imp._boxOrganizerFooter
if not f or imp.tab~="box" or not imp._boxState or imp._boxState.toolsPage~="Arrange" then return end
local overlay=Kit._overlay;Kit._overlay=true
Theme.fillRounded(f.x-8*m.s,f.y-10*m.s,f.w+16*m.s,f.h+20*m.s,PAL.surface,1,10)
Theme.fill(f.x-8*m.s,f.y-10*m.s,f.w+16*m.s,1,PAL.line,.55)
previewActions(imp,f.s,f.o,f.layout,f.report,f.why,f.x,f.y,f.w,m,f.api,true)
Kit._overlay=overlay
end
function Panel.draw(imp,s,x,y,w,m,api,mode)
local top,gap,h,pad=y,10*m.s,math.max(Kit.tapMin(),m.btnH),14*m.s
s.organizers=s.organizers or {}
local pick=s.organizers[mode] or {target="box"}
s.organizers[mode]=pick
local function stateFor(target)
local key=mode..":"..target
s.organizers[key]=s.organizers[key] or {target=target,ruleIndex=1,conditionIndex=1,
config={mode=mode,sort="species",descending=false,fallback="keep",rules={newRule()}}}
return s.organizers[key]
end
local o=stateFor(pick.target=="pc" and "pc" or "box")
local source=s.sources[s.sourceIndex]
local context=o.target..":"..tostring(source and source.path)
if o.context~=context then o.context=context;changed(o) end
if o.preview and o.preview.boxBefore~=s.service.body then changed(o) end
local config=o.config
local input=o.target=="pc" and s.pcSave or s.service.state
if o.layoutInput~=input or o.layoutContext~=context then
o.layoutInput,o.layoutContext=input,context
if o.target=="pc" then
if source and s.pcSave then o.layout,o.layoutError=Organizer.game(s.pcSave,source.version)
else o.layout,o.layoutError=nil,"Choose a readable game save above." end
else o.layout,o.layoutError=Organizer.warehouse(s.service.state),nil end
end
local layout,why=o.layout,o.layoutError
local function btn(id,label,fn,bx,bw,active,disabled,icon)
UI.button(imp,bx or x,y,bw or w,h,"organize-"..id,label,fn,
{face=active and "tab" or "selection",active=active,enabled=not disabled,icon=icon})
end
local function targetCard(info,color)
local half=(w-2*pad-gap)/2
local infoH=Kit.wrapHeight("micro",info,w-2*pad,3)
local cardH=52*m.s+h+6*m.s+infoH+pad
card(x,y,w,cardH,"Which boxes?",1,m)
y=y+52*m.s
btn("target-pc","Game PC",function() pick.target="pc" end,x+pad,half,o.target=="pc",not source,"monitor")
btn("target-box","Box storage",function() pick.target="box" end,x+pad+half+gap,half,o.target=="box",false,"package")
y=y+h+6*m.s
Kit.textWrapped("micro",info,x+pad,y,w-2*pad,color or PAL.muted,3)
return cardH
end
if not layout then return targetCard(tostring(why or ""),PAL.yellow)+gap end
if not o.signature or o.layoutSource~=(o.target=="pc" and s.pcSave or s.service.state) then
o.preview=nil;o.signature=true;o.layoutSource=o.target=="pc" and s.pcSave or s.service.state
o.effective=clampBoxes(config,layout.count)
o.analysis,o.analysisError=Organizer.analyze(layout,o.effective)
local planned, result=Organizer.plan(layout,o.effective)
o.plannedBoxes=planned
o.planError=not planned and result or nil
end
local report=o.preview and o.preview.report or o.plannedBoxes
local wide=w>=740*m.s
local fullX,fullW=x,w
local rightX,rightW
if wide then w=(w-2*gap)*.6;rightX=x+w+2*gap;rightW=fullW-w-2*gap end
local view=imp._tabRegionRect
local footerH
if not wide and view and view.h>260*m.s then
local status=o.planError or (report and (report.total.." Pokémon · "..report.moved.." moves") or "")
footerH=Kit.textHeight("micro")+3*gap+30*m.s+Kit.wrapHeight("micro",status,fullW,3)+h+10*m.s
local fy=math.min(view.y+view.h,m.H-40*m.s)-footerH
imp._boxOrganizerFooter={s=s,o=o,layout=layout,report=report,why=o.planError,
x=fullX,y=fy,w=fullW,h=footerH,api=api}
Kit.occlude(fullX,fy-10*m.s,fullW,footerH+20*m.s)
end
local function dropdown(id,label,choices,current,apply,bx,bw,icon)
UI.dropdown(imp,bx or x,y,bw or w,h,"organize-"..id,label,choices,current,function(v) apply(v);changed(o) end,icon)
end
local profiles=s.service.state.organizerProfiles or {}
local options={}
for i,p in ipairs(profiles) do
if p.config.mode==mode then
options[#options+1]={label=p.name,icon="save",action=function()
local t=stateFor(p.target=="pc" and "pc" or "box")
pick.target=t.target
t.config,t.profile=Store.copy(p.config),{index=i,name=p.name,target=p.target};t.ruleIndex=1;changed(t)
end}
end
end
local profileIndex=findProfile(profiles,mode,o.profile)
local function stalePreset()
s.notice,o.profile="That preset changed. Choose it again.",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)
local at=update and findProfile(s.service.state.organizerProfiles or {},mode,o.profile) or nil
if update and not at then return stalePreset() end
local target,config=o.target,o.config
if api.perform(imp,function() return s.service:saveOrganizer(name,target,config,at) end,"Preset saved.") then
o.profile={index=at or #s.service.state.organizerProfiles,name=name,target=target};changed(o)
end
end)
end
options[#options+1]={label="Save as new preset",icon="plus",disabled=#profiles>=20,action=function() savePreset(false) end}
if profileIndex then
options[#options+1]={label="Update preset",icon="save",action=function() savePreset(true) end}
options[#options+1]={label="Delete preset",icon="trash",action=function()
local at=findProfile(s.service.state.organizerProfiles or {},mode,o.profile)
if not at then return stalePreset() end
if api.perform(imp,function() return s.service:deleteOrganizer(at) end,"Preset removed.") then o.profile=nil end
end}
end
dropdown("presets",profileIndex and profiles[profileIndex].name or "Presets",options,nil,function() end,x,w,"save")
y=y+h+gap
local cardH=targetCard(layout.count.." boxes · "..layout.capacity.." slots each · "..#layout.rows.." Pokémon")
y=top+h+gap+cardH+gap
local innerX,innerW=x+pad,w-2*pad
local boxes,labels={},{}
for b=1,layout.count do boxes[b],labels[b]=b,"Box "..b end
if mode=="sort" then
card(x,y,w,120*m.s,"Choose boxes",2,m)
y=y+50*m.s
local scopes={{id=0,label="All boxes",icon="grid-2x2"}}
for b=1,layout.count do scopes[#scopes+1]={id=b,label=labels[b],icon="package"} end
dropdown("scope",config.box and labels[config.box] or "All boxes",scopes,config.box or 0,
function(v) config.box=v~=0 and v or nil end,innerX,innerW,"package")
y=y+h+gap+pad
else
config.rules=config.rules or {newRule()}
local titleY=y
card(x,y,w,54*m.s,"Rules",2,m);y=y+62*m.s
Kit.text("micro","Checked from the top. First match wins.",innerX,y,PAL.muted)
y=y+Kit.textHeight("micro")+gap
for index,rule in ipairs(config.rules) do
local expanded=o.ruleIndex==index
local counts=o.analysis and o.analysis.ruleCounts[index] or 0
local start=y
local lines=#rule.conditions
local rh=expanded and (h*(lines+2)+gap*(lines+3)+88*m.s) or h+22*m.s
local needed=math.max(1,math.ceil(counts/layout.capacity))
local oversized=rule.lastBox-rule.firstBox+1>needed
if expanded and oversized then rh=rh+h+gap end
Theme.card(innerX,y,innerW,rh,{fill=PAL.rowBg,strokeA=expanded and .65 or .35})
UI.button(imp,innerX+8*m.s,y+6*m.s,innerW-16*m.s,h,"organize-rule-"..index,
ruleLabel(rule,index).." · "..counts.." match",function() o.ruleIndex=expanded and 0 or index end,
{face="bare",align="left",trailingIcon=expanded and "chevron-up" or "chevron-down"})
y=y+h+gap+8*m.s
if expanded then
local usableX,usableW=innerX+12*m.s,innerW-24*m.s
local cw=(usableW-gap)/2
for ci,c in ipairs(rule.conditions) do
local suffix=index==1 and ci==1 and "" or "-"..index.."-"..ci
local fields={}
for _,field in ipairs(Organizer.FIELDS) do
if Organizer.available(field,o.target=="pc" and source.version or nil) then fields[#fields+1]=field end
end
dropdown("field"..suffix,Organizer.FIELD_LABELS[c.field],UI.values(fields,Organizer.FIELD_LABELS,"list-filter"),c.field,function(v)
c.field=v;local choices=values(v,layout,o.target,source);c.value=choices[1]~=nil and choices[1] or ""
end,usableX,cw,"list-filter")
local choices,valueLabels=values(c.field,layout,o.target,source)
local opts=UI.values(choices,valueLabels,"check")
if type(c.value)=="string" then
opts[#opts+1]={label="Type a value…",icon="pencil",action=function()
require("src.import.BoxPrompt").open(imp,Organizer.FIELD_LABELS[c.field],c.value,128,function(v) c.value=v;changed(o) end)
end}
end
dropdown("value"..suffix,valueLabels and valueLabels[c.value] or tostring(c.value),opts,c.value,
function(v) c.value=v end,usableX+cw+gap,cw,"check")
y=y+h+gap
end
local actions={
{label="Add condition",icon="plus",disabled=lines>=8,action=function()
rule.conditions[#rule.conditions+1]={field="shiny",value=true};changed(o)
end},
{label="Remove last condition",icon="minus",disabled=lines==1,action=function() table.remove(rule.conditions);changed(o) end},
{label="Match all conditions",icon="check",action=function() rule.match="all";changed(o) end},
{label="Match any condition",icon="check",action=function() rule.match="any";changed(o) end},
{label="Move earlier",icon="arrow-up",disabled=index==1,action=function()
config.rules[index],config.rules[index-1]=config.rules[index-1],rule;o.ruleIndex=index-1;changed(o)
end},
{label="Move later",icon="arrow-down",disabled=index==#config.rules,action=function()
config.rules[index],config.rules[index+1]=config.rules[index+1],rule;o.ruleIndex=index+1;changed(o)
end},
{label="Remove rule",icon="trash",disabled=#config.rules==1,action=function()
table.remove(config.rules,index);o.ruleIndex=math.max(1,index-1);changed(o)
end}}
dropdown("rule-options"..(index==1 and "" or "-"..index),lines>1 and ("Conditions: "..rule.match) or "Add condition / rule options",
actions,nil,function() end,usableX,usableW,"plus");y=y+h+gap
Kit.text("micro","SEND TO",usableX,y,PAL.muted);y=y+Kit.textHeight("micro")+5*m.s
dropdown("first-box"..(index==1 and "" or "-"..index),labels[rule.firstBox] or "Box "..rule.firstBox,UI.values(boxes,labels,"package"),rule.firstBox,
function(v) rule.firstBox,rule.lastBox=v,math.max(v,rule.lastBox) end,usableX,cw,"package")
dropdown("last-box"..(index==1 and "" or "-"..index),"through "..(labels[rule.lastBox] or "Box "..rule.lastBox),UI.values(boxes,labels,"package"),rule.lastBox,
function(v) rule.lastBox,rule.firstBox=v,math.min(v,rule.firstBox) end,usableX+cw+gap,cw,"package")
y=y+h+gap
if oversized then
UI.button(imp,usableX,y,usableW,h,"organize-shrink-"..index,
counts.." match · Use only Box "..rule.firstBox.."–"..(rule.firstBox+needed-1),function()
rule.lastBox=rule.firstBox+needed-1;changed(o)
end,{icon="undo-2",face="invert"})
y=y+h+gap
end
end
y=math.max(y,start+rh)+gap
end
btn("add-rule","Add another rule",function()
config.rules[#config.rules+1]=newRule();o.ruleIndex=#config.rules;changed(o)
end,innerX,innerW,false,#config.rules>=24,"plus");y=y+h+gap
if y-titleY<54*m.s then y=titleY+54*m.s end
local remaining=o.analysis and o.analysis.remaining or 0
local noRoom=config.fallback=="sort" and o.analysis and remaining>o.analysis.freeBoxes*layout.capacity
card(x,y,w,50*m.s+2*Kit.textHeight("micro")+2*gap+h+pad,"Everyone else",3,m,noRoom and PAL.yellow or nil);y=y+50*m.s
Kit.text("micro",remaining.." Pokémon don't match any rule.",innerX,y,PAL.muted)
y=y+Kit.textHeight("micro")+gap
local cw=(innerW-gap)/2
btn("fallback-keep","Leave them where they are",function() config.fallback="keep";changed(o) end,
innerX,cw,config.fallback=="keep",false,"map-pin")
btn("fallback-sort","Fill other boxes",function() config.fallback="sort";changed(o) end,
innerX+cw+gap,cw,config.fallback=="sort",false,"package")
y=y+h+gap
Kit.text("micro",noRoom and "No room left. Shrink a rule's range or keep their boxes."
or config.fallback=="keep" and "Same box, same slot." or (o.analysis and o.analysis.freeBoxes or 0).." boxes no rule uses.",
innerX,y,noRoom and PAL.yellow or PAL.muted);y=y+Kit.textHeight("micro")+pad+gap
end
card(x,y,w,116*m.s,"Order inside each box",mode=="sort" and 3 or 4,m)
y=y+50*m.s
local cw=(innerW-gap)/2
dropdown("sort",Organizer.LABELS[config.sort],UI.values(Organizer.SORTS,Organizer.LABELS,"arrow-up-down"),config.sort,
function(v) config.sort=v end,innerX,cw,"arrow-up-down")
dropdown("direction",config.descending and "High → Low" or "Low → High",
UI.values({false,true},{[false]="Low → High",[true]="High → Low"}),config.descending,
function(v) config.descending=v end,innerX+cw+gap,cw,"arrow-up-down")
y=y+h+pad+gap
if wide then
local cols=previewColumns(rightW-2*pad,m,false,layout.count)
card(rightX,top,rightW,math.ceil(layout.count/cols)*62*m.s+100*m.s,"",nil,m)
local bottom=previewActions(imp,s,o,layout,report,o.planError,rightX+pad,top+pad,rightW-2*pad,m,api,false)
y=math.max(y,bottom+gap);imp._boxOrganizerFooter=nil
else
if footerH then
y=y+footerH+gap
else y=previewActions(imp,s,o,layout,report,o.planError,x,y,w,m,api,true)+gap end
end
return y-top
end
return Panel
+876
View File
@@ -0,0 +1,876 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local GameVersion = require("src.core.GameVersion")
local Service = require("src.box.Service")
local Store = require("src.box.Store")
local Records = require("src.box.Records")
local Catalog = require("src.box.Catalog")
local Sprites = require("src.online.OnlineSprites")
local Icons = require("src.ui.kit.Icons")
local Animation = require("src.box.Animation")
local UI = require("src.import.BoxUI")
local PAL = Theme.PAL
local BoxPanel = {}
local function LV() return require("src.import.LauncherView") end
local function readPC(s)
s.pcRows, s.pcBody, s.pcSave = {}, nil, nil
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
s.pcSave, s.pcBody = save, body
local generation = GameVersion.generation(source.version)
for _, row in ipairs(Records.candidates(save, generation)) do
row.entry = { version = source.version, generation = generation, mon = row.mon,
display = Catalog.describe(source.version, row.mon) }
row.key = Records.key(row)
s.pcRows[#s.pcRows + 1] = row
end
end
function BoxPanel.refresh(imp)
UI.close(imp)
require("src.ui.kit.Transition").clear("box")
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(),
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,
filters = previous.filters, view = previous.view or "box", multi = previous.multi ~= false,
toolsPage = previous.toolsPage ~= "Themes" and previous.toolsPage or nil, toolsOpen = previous.toolsOpen,
organizers = previous.organizers, stageDraft = previous.stageDraft, stageDrafts = previous.stageDrafts,
stageUndo = previous.stageUndo, stageIndex = previous.stageIndex, stagePiece = previous.stagePiece,
stageSection = previous.stageSection }
local was = previous.sources and previous.sources[previous.sourceIndex]
if was then
for i, source in ipairs(s.sources) do
if source.path == was.path and source.slotId == was.slotId then s.sourceIndex = i; break end
end
end
local current = s.sources[s.sourceIndex]
local total = current and GameVersion.generation(current.version) == 1 and 12 or 14
if type(s.pcBox) ~= "number" or s.pcBox < 0 or s.pcBox > total then s.pcBox = 1 end
imp._boxState = s
readPC(s)
if service and previous.service and previous.service.body == service.body and previous.selectedBox then
s.selectedBox = previous.selectedBox
end
if s.pcBody and previous.pcBody == s.pcBody and previous.selectedPC and was and current
and was.path == current.path and was.slotId == current.slotId then
s.selectedPC = previous.selectedPC
end
return s
end
local function state(imp)
return imp._boxState or BoxPanel.refresh(imp)
end
local function animateInspection(s)
Animation.release(s.animation)
s.animationEntry, s.animation = s.inspect, Animation.new(s.inspect)
end
local function checkDisk(s, dt)
s._diskCheck = (s._diskCheck or 1) + math.max(0, dt or 0)
local fs = s.service and s.service.fs
if s._diskCheck < 1 or not fs or not fs.getInfo or not fs.read then return end
s._diskCheck = 0
local ok, info = pcall(fs.getInfo, Store.PATH)
local sig = ok and type(info) == "table" and (tostring(info.size) .. ":" .. tostring(info.modtime)) or "none"
if sig == s._diskSig and s._diskBody == s.service.body then return end
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
end
s.diskStale = stale or nil
end
function BoxPanel.update(imp, dt)
local s = imp._boxState
if not s then return end
checkDisk(s, dt)
s.artTime = (s.artTime or 0) + math.max(0, dt or 0)
if s.animationEntry ~= s.inspect or s.animation and s.animation.released then animateInspection(s) end
Animation.update(s.animation, dt)
local Showcase = require("src.box.Showcase")
if not s.toolsPage or s.toolsPage == "Pokémon" then
local warehouse = s.service and s.service.state
Showcase.ensureMusic(warehouse and Showcase.boxMusic(warehouse, warehouse.boxes[s.box]) or "Silent", "box")
else
local _, owner = Showcase.currentMusic()
if owner == "box" then Showcase.stopMusic() end
end
end
local function selected(t)
local out = {}
for _, ref in pairs(t) do out[#out + 1] = ref end
table.sort(out, function(a, b)
if a.where ~= b.where then return (a.where or "box") < (b.where or "box") end
if (a.box or 0) ~= (b.box or 0) then return (a.box or 0) < (b.box or 0) end
return (a.index or a.slot or 0) < (b.index or b.slot or 0)
end)
return out
end
local function perform(imp, operation, text)
local s = state(imp)
local called, ok, why = pcall(operation)
if not called then why, ok = ok, nil end
s._latestBackup = nil
if not ok then
s.notice = tostring(why or "Box operation failed.")
local beforeBox, beforePC = s.service and s.service.body, s.pcBody
if s.service then
local recovered, err = Service.open(s.service.fs)
s.service = recovered
if not recovered then s.notice = s.notice .. " " .. tostring(err) end
end
readPC(s)
if not s.service or s.service.body ~= beforeBox or s.pcBody ~= beforePC then
Animation.release(s.animation)
s.animation, s.animationEntry = nil, nil
s.selectedBox, s.selectedPC, s.moving, s.movingPC = {}, {}, nil, nil
s.inspect, s.inspectKind, s.inspectRef, s.summary = nil, nil, nil, nil
end
return false
end
s.notice = text
require("src.box.Cry").stop()
Animation.release(s.animation)
s.animation, s.animationEntry = nil, nil
s.selectedBox, s.selectedPC, s.moving, s.movingPC, s.inspect = {}, {}, nil, nil, nil
s._filteredBox, s._filteredPC, s.motionFrom, s._giftProgress = nil, nil, nil, nil
require("src.ui.kit.Transition").clear("box")
readPC(s)
if imp.savesChanged then
local source = s.sources[s.sourceIndex]
if source then imp:savesChanged(source.cartId and "cart_" .. tostring(source.cartId) or source.version) end
end
return true
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 s.service then return false end
return perform(imp, function() return s.service:importGCI(bytes) end, "Native Pokémon Box save imported.")
end
local function button(imp, x, y, w, h, id, text, action, active, disabled, icon)
icon = icon or ({ summary = "eye", ["summary-back"] = "arrow-left", reload = "rotate-ccw",
["view-box"] = "package", ["view-pc"] = "monitor", multi = "check",
deposit = "download", withdraw = "upload", ["select-visible"] = "check", ["clear-selection"] = "x",
move = "arrow-left-right", rename = "pencil" })[id]
return LV().btn(imp, x, y, w, h, "box-" .. id, Strings(text), {
kind = active and "primary" or "ghost", font = "small",
face = id == "multi" and "selection" or nil, active = active,
enabled = not disabled, action = action, icon = icon,
})
end
local function choose(imp, x, y, w, h, id, text, previous, nextOne, pick)
local bw, gap = Kit.tapMin(), 4 * Kit.scale
button(imp, x, y, bw, h, id .. "-prev", "", previous, false, false, "chevron-left")
button(imp, x + w - bw, y, bw, h, id .. "-next", "", nextOne, false, false, "chevron-right")
if pick then
UI.button(imp, x + bw + gap, y, w - 2 * (bw + gap), h, id .. "-picker", text, pick,
{ face = "bare", trailingIcon = "chevron-down" })
else
Kit.textCenterBold("small", Kit.ellipsize("small", text, w - 2 * (bw + gap)),
x + bw + gap, y + (h - Kit.textHeight("small")) / 2, w - 2 * (bw + gap), PAL.heading)
end
end
local function rowsForPC(s)
local cached = s._filteredPC
if cached and cached.rows == s.pcRows and cached.box == s.pcBox and cached.query == s.query then
return cached.result
end
local out, bySlot = {}, {}
local matches = require("src.box.Search").compile(s.query)
for _, row in ipairs(s.pcRows) do
if (s.pcBox == 0 and row.where == "party") or (row.where == "box" and row.box == s.pcBox) then
if s.query ~= "" then
if matches(row.entry, row.source) then out[#out + 1] = row end
else bySlot[row.index] = row end
end
end
if s.query == "" then
local source = s.sources[s.sourceIndex]
local capacity = s.pcBox == 0 and 6 or source and GameVersion.generation(source.version) == 3 and 30 or 20
for i = 1, capacity do
local ref = { where = s.pcBox == 0 and "party" or "box", box = s.pcBox, index = i }
ref.key = Records.key(ref)
out[i] = bySlot[i] or ref
end
end
s._filteredPC = { rows = s.pcRows, box = s.pcBox, query = s.query, result = out }
return out
end
local function rowsForBox(s)
local cached = s._filteredBox
if cached and cached.state == s.service.state and cached.box == s.box
and cached.query == s.query and cached.sort == s.sort then return cached.result end
local out = {}
if s.query ~= "" then
out = Store.search(s.service.state, s.query, s.sort)
elseif s.sort ~= "slot" then
for _, row in ipairs(Store.search(s.service.state, "", s.sort)) do
if row.box == s.box then out[#out + 1] = row end
end
else
for slot = 1, Store.SLOTS do
out[#out + 1] = { box = s.box, slot = slot, entry = Store.at(s.service.state, s.box, slot) }
end
end
s._filteredBox = { state = s.service.state, box = s.box, query = s.query, sort = s.sort, result = out }
return out
end
local function count(selection)
local n = 0
for _ in pairs(selection) do n = n + 1 end
return n
end
local function panel(x, y, w, h, accent)
Theme.fillRounded(x + 4, y + 7, w, h, PAL.inverse, 0.5, Theme.cardRadius())
Theme.card(x, y, w, h, { shadow = true, stroke = accent or PAL.line, strokeA = 0.65, strokeW = 2 })
end
local function art(entry, x, y, size, small, s)
local image, version = Catalog.art(entry)
if not image then
Kit.textCenterBold("small", "?", x, y + (size - Kit.textHeight("small")) / 2, size, PAL.muted)
return
end
local sw, sh = size * 0.58, math.max(3, size * 0.085)
if love.graphics.ellipse then
Theme.col(PAL.inverse, 0.35)
love.graphics.ellipse("fill", x + size / 2, y + size * 0.86, sw / 2, sh / 2)
else Theme.fillRounded(x + (size - sw) / 2, y + size * 0.82, sw, sh, PAL.inverse, 0.35, sh / 2) end
if small then
local frame = version == "emerald" and math.floor((s.artTime or 0) * 4) or 0
if Sprites.drawIcon(image, x, y, size, frame) then return end
end
if not small and s.animationEntry == entry and Animation.draw(s.animation, x, y, size) then return end
if not Sprites.drawFront(image, x, y, size) then Sprites.drawIcon(image, x, y, size) end
end
local function checkBadge(x, y, size, checked, accent)
if checked then
Theme.fillRounded(x, y, size, size, accent or PAL.green, 1, 4)
Icons.draw("check", x + 2, y + 2, size - 4, PAL.inverse, 1)
else
Theme.fillRounded(x, y, size, size, PAL.bg, 0.95, 4)
Theme.strokeRounded(x, y, size, size, PAL.line, 0.6, 1, 4)
end
end
local function inspectEntry(s, kind, row)
s.inspect, s.inspectKind, s.inspectRef = row.entry, kind, row
animateInspection(s)
require("src.box.Cry").play(row.entry)
end
local function openSummary(s, imp)
if imp then UI.change(imp, s, "summary", true) else s.summary = true end
Kit.blur()
animateInspection(s)
require("src.box.Cry").play(s.inspect)
end
local function selectEntry(s, kind, row)
local key = kind == "pc" and row.key or (row.box .. ":" .. row.slot)
local selection = kind == "pc" and s.selectedPC or s.selectedBox
if s.multi == false then
local was = selection[key] ~= nil
s.selectedPC, s.selectedBox = {}, {}
if was then return end
selection = kind == "pc" and s.selectedPC or s.selectedBox
end
if selection[key] then selection[key] = nil
elseif kind == "pc" then selection[key] = { where = row.where, box = row.box, index = row.index }
else selection[key] = { box = row.box, slot = row.slot } end
inspectEntry(s, kind, row)
end
local function gridMetrics(w, m)
local gap = math.max(3, math.floor(4 * m.s))
local cols = w >= 620 * m.s and 12 or w >= 390 * m.s and 6 or 4
local cellW = (w - (cols - 1) * gap) / cols
local cellH = math.max(Kit.tapMin() + 6, math.min(cellW, (cols == 12 and 50 or 68) * m.s))
return cols, gap, cellW, cellH, cols == 12 and Store.SLOTS or cols * 6
end
local function visibleRows(s, kind, w, m)
local rows = kind == "pc" and rowsForPC(s) or rowsForBox(s)
local _, _, _, _, perPage = gridMetrics(w, m)
local first, last, page = Kit.pageBounds(kind == "pc" and s.pagePC or s.pageBox, #rows, perPage)
return rows, first, last, page, perPage
end
local function grid(imp, s, kind, x, y, w, m)
local rows, first, last, page, perPage = visibleRows(s, kind, w, m)
local cols, gap, cellW, cellH = gridMetrics(w, m)
local pageKey = kind == "pc" and "pagePC" or "pageBox"
s[pageKey] = page
local selection = kind == "pc" and s.selectedPC or s.selectedBox
for i = first, last do
local row, index = rows[i], i - first
local entry = row.entry
local cx = x + (index % cols) * (cellW + gap)
local cy = y + math.floor(index / cols) * (cellH + gap)
local key = kind == "pc" and row.key or (row.box .. ":" .. row.slot)
local chosen = selection[key] ~= nil
local focused = s.inspect == entry and entry ~= nil
local emptyTarget = not entry and (kind == "box" and s.moving or kind == "pc" and s.movingPC)
local id = "box-" .. kind .. "-" .. key
local hot = entry and Kit.hover(cx, cy, cellW, cellH)
local ring = Kit._ringShown and Kit.focusId == id
if chosen or focused or hot or ring then
local color = chosen and PAL.green or (focused or ring) and PAL.blue or PAL.line
Theme.col(color, chosen and 0.3 or 0.15)
if love.graphics.ellipse then
love.graphics.ellipse("fill", cx + cellW / 2, cy + cellH * 0.7, cellW * 0.43, cellH * 0.28)
else
Theme.fillRounded(cx + 4, cy + cellH * 0.4, cellW - 8, cellH * 0.56, color, 0.2, 12)
end
end
LV().btn(imp, cx, cy, cellW, cellH, id, "", {
face = "bare", ring = false,
enabled = entry ~= nil or not not emptyTarget, action = function()
if kind == "box" and s.moving then
local from = s.moving
return perform(imp, function() return s.service:moveGroup(from, row.box, row.slot) end,
"Pokémon moved.")
end
if kind == "pc" and s.movingPC then
local from, source = s.movingPC, s.sources[s.sourceIndex]
s.movingPC = nil
return perform(imp, function()
return s.service:movePC(source, from, { where = row.where, box = row.box, index = row.index }, s.pcBody)
end, "Pokémon moved.")
end
selectEntry(s, kind, row)
end,
})
if entry then
local size = math.min(cellW - 2, cellH - 2)
art(entry, cx + (cellW - size) / 2, cy + (cellH - size) / 2, size, true, s)
local badge = math.max(12, math.min(18 * m.s, cellW * 0.27))
checkBadge(cx + cellW - badge - 4, cy + 4, badge, chosen)
if entry.display.shiny then Icons.draw("award", cx + 3, cy + 2, badge, PAL.yellow, 1) end
else
Kit.textCenter("micro", tostring(row.slot or row.index), cx,
cy + (cellH - Kit.textHeight("micro")) / 2, cellW, PAL.faint)
end
end
local nrows = math.max(1, math.ceil((last - first + 1) / cols))
local height = nrows * (cellH + gap)
if #rows == 0 then
Kit.textWrapped("small", Strings("No Pokémon match this view."), x + 8, y + 10, w - 16, PAL.muted, 2)
end
if #rows > perPage then
local h = Kit.tapMin()
choose(imp, x, y + height, w, h, kind .. "-page",
("%d–%d / %d"):format(first, last, #rows),
function() s[pageKey] = math.max(1, page - 1) end,
function() s[pageKey] = math.min(math.ceil(#rows / perPage), page + 1) end)
height = height + h + gap
end
return height
end
local TYPE_COLORS = {
FIRE = PAL.railFireRed, WATER = PAL.buttonBlue, GRASS = PAL.railLeafGreen,
ELECTRIC = PAL.yellow, POISON = PAL.buttonPurple, PSYCHIC = PAL.buttonPurple,
ICE = PAL.blue, BUG = PAL.green, DRAGON = PAL.buttonPurple,
}
local function typeBadges(types, x, y, w, m, measure)
local seen, names = {}, {}
for name in tostring(types):gmatch("[^/]+") do
name = name:match("^%s*(.-)%s*$"):upper()
if name ~= "" and not seen[name] then names[#names + 1], seen[name] = name, true end
end
local h, gap, cx, cy = Kit.textHeight("micro") + 8 * m.s, 4 * m.s, x, y
for _, name in ipairs(names) do
local bw = math.min(w, Kit.textWidth("micro", name) + 16 * m.s)
if cx > x and cx + bw > x + w then cx, cy = x, cy + h + gap end
local color = TYPE_COLORS[name] or PAL.raised
if not measure then
Kit.tag(cx, cy, bw, h, name, color, {
fill = true, bold = true, ink = (color == PAL.yellow or color == PAL.green) and PAL.inverse or PAL.heading,
})
end
cx = cx + bw + gap
end
return #names == 0 and 0 or cy + h - y
end
local function detail(imp, s, x, y, w, m, compact, inspectOnly)
local entry, pad = s.inspect, 12 * m.s
local gap = 6 * m.s
if not entry then
local h = compact and 58 * m.s or 304 * m.s
panel(x, y, w, h)
if not compact then
Icons.draw("package", x + (w - 64 * m.s) / 2, y + 54 * m.s, 64 * m.s, PAL.muted, 0.65)
end
Kit.textWrapped("small", Strings("Select a Pokémon to see it here."), x + pad,
y + (compact and 12 or 148) * m.s, w - 2 * pad, PAL.muted, 2)
return h
end
local name = tostring(entry.display.species)
local nickname = entry.display.name ~= entry.display.species and tostring(entry.display.name) or nil
local size = compact and math.max(128, 108 * m.s) or math.min(w - 2 * pad, 140 * m.s)
local tw = compact and w - 2 * pad - size - gap or w - 2 * pad
local item = entry.display.item ~= "" and entry.display.item or Strings("No held item")
local fieldH = Kit.textHeight("button") + typeBadges(entry.display.types, 0, 0, tw, m, true)
+ Kit.wrapHeight("micro", item, tw, 2) + 3 * gap
if nickname then fieldH = fieldH + Kit.wrapHeight("micro", nickname, tw, 2) + gap end
local headerH = 48 * m.s
local top = headerH + 8 * m.s
local h = math.max((compact and 166 or 315) * m.s,
top + (compact and math.max(size, fieldH + gap + Kit.tapMin())
or size + gap + fieldH + Kit.textHeight("micro") + gap + Kit.tapMin()) + pad)
if inspectOnly then
h = compact and top + math.max(size, 3 * m.s + fieldH) + pad or h - Kit.tapMin() - gap
end
panel(x, y, w, h, PAL.blue)
Theme.fillRounded(x + 2, y + 2, w - 4, headerH, PAL.raised, 1, 7)
local dex = entry.display.national and ("#" .. ("%03d"):format(entry.display.national)) or ""
local nameH, dexH = Kit.textHeight("button"), Kit.textHeight("micro")
local headerY = y + 2 + (headerH - nameH - dexH - 2 * m.s) / 2
Kit.textCenterBold("button", Kit.ellipsize("button", name, w - 2 * pad), x + pad, headerY, w - 2 * pad, PAL.heading)
Kit.textCenter("micro", dex, x + pad, headerY + nameH + 2 * m.s, w - 2 * pad, PAL.muted)
local ax = compact and x + pad or x + (w - size) / 2
local ay = y + top
Theme.fillRounded(ax, ay, size, size, PAL.bg, 1, 7)
art(entry, ax, ay, size, false, s)
local tx = compact and ax + size + gap or x + pad
local ty = compact and ay + 3 * m.s or ay + size + gap
Kit.textBold("button", "Lv. " .. tostring(entry.display.level), tx, ty, PAL.heading)
ty = ty + Kit.textHeight("button") + gap
ty = ty + typeBadges(entry.display.types, tx, ty, tw, m) + gap
ty = ty + Kit.textWrapped("micro", item, tx, ty, tw, PAL.text, 2) + gap
if nickname then ty = ty + Kit.textWrapped("micro", nickname, tx, ty, tw, PAL.heading, 2) + gap end
if not compact then
local origin = GameVersion.info(entry.version).label
Kit.text("micro", origin .. (entry.display.egg and " · Egg" or entry.display.shiny and " · Shiny" or ""), tx, ty, PAL.muted)
end
local by = y + h - Kit.tapMin() - pad
if not inspectOnly then button(imp, compact and tx or x + pad, by, compact and tw or w - 2 * pad, Kit.tapMin(),
"summary", "Summary", function() openSummary(s, imp) end) end
return h
end
local function summary(imp, s, x, y, w, m)
local entry = s.inspect
if not entry then return 0 end
local top, gap = y, 8 * m.s
local Details = require("src.import.BoxDetails")
s.summarySection = s.summarySection or "Overview"
local sectionH = math.max(m.btnH, Kit.tapMin())
UI.dropdown(imp, x, y, w - sectionH - gap, sectionH, "summary-section", s.summarySection,
UI.values(Details.SECTIONS, nil, "book-open"), s.summarySection,
function(value) UI.change(imp, s, "summarySection", value) end, "book-open")
UI.button(imp, x + w - sectionH, y, sectionH, sectionH, "summary-section-next", "", function()
local sections, at = Details.SECTIONS, 1
for i, name in ipairs(sections) do if name == s.summarySection then at = i end end
UI.change(imp, s, "summarySection", sections[at % #sections + 1])
end, { face = "invert", icon = "arrow-right" })
y = y + sectionH + gap
if w >= 740 * m.s then
local cardW = math.min(w * .25, 280 * m.s)
local cardH = detail(imp, s, x, y, cardW, m, false, true)
local fx, fw = x + cardW + 2 * gap, w - cardW - 2 * gap
local fieldsH = Details.draw(imp, entry, s.summarySection, fx, y, fw, m)
y = y + math.max(cardH, fieldsH) + gap
else
y = y + detail(imp, s, x, y, w, m, true, true) + gap
y = y + Details.draw(imp, entry, s.summarySection, x, y, w, m) + gap
end
local context = s.inspectKind == "pc" and rowsForPC(s) or rowsForBox(s)
local neighbors, position = {}, 1
for _, row in ipairs(context) do
if row.entry then
neighbors[#neighbors + 1] = row
if row.entry == entry then position = #neighbors end
end
end
local navW = (w - 2 * gap) / 3
button(imp, x + navW + gap, y, navW, m.btnH, "summary-prev", "Previous", function()
local row = neighbors[position - 1]
if row then inspectEntry(s, s.inspectKind or "box", row) end
end, false, position <= 1, "chevron-left")
button(imp, x, y, navW, m.btnH, "summary-back", "Box", function() UI.change(imp, s, "summary", nil, -1) end)
button(imp, x + 2 * (navW + gap), y, navW, m.btnH, "summary-next", "Next", function()
local row = neighbors[position + 1]
if row then inspectEntry(s, s.inspectKind or "box", row) end
end, false, position >= #neighbors, "chevron-right")
return y + m.btnH - top
end
local function boxName(s)
if s.view ~= "pc" then return s.service.state.boxes[s.box].name end
if s.pcBox == 0 then return Strings("Party") end
local source = s.sources[s.sourceIndex]
local name = s.pcSave and (GameVersion.generation(source.version) == 3
and s.pcSave.storage and s.pcSave.storage.boxes and s.pcSave.storage.boxes[s.pcBox]
and s.pcSave.storage.boxes[s.pcBox].name
or s.pcSave.boxNames and s.pcSave.boxNames[s.pcBox])
return name or "PC BOX " .. s.pcBox
end
local function changeBox(imp, s, delta)
if s.view == "pc" then
local source = s.sources[s.sourceIndex]
local total = source and GameVersion.generation(source.version) == 1 and 12 or 14
UI.change(imp, s, "pcBox", (s.pcBox + delta) % (total + 1), delta); s.pagePC = 1
else
UI.change(imp, s, "box", (s.box - 1 + delta) % Store.BOXES + 1, delta); s.pageBox, s.railFirst = 1, nil
end
end
local function thumbnail(imp, s, x, y, w, h, m, kind, box)
local active = s.view == kind and (kind == "pc" or s.box == box)
local accent = kind == "pc" and PAL.blue or PAL.green
panel(x, y, w, h, active and accent or nil)
LV().btn(imp, x, y, w, h, "box-thumb-" .. kind .. "-" .. (box or 0), "", {
face = "invert", fill = active and PAL.raised or PAL.surface, ring = false,
action = function()
if s.view ~= kind then UI.change(imp, s, "view", kind, kind == "pc" and 1 or -1)
elseif box then UI.change(imp, s, "box", box, box >= s.box and 1 or -1) end
s.summary = nil; if box then s.box, s.pageBox = box, 1 end
end,
})
if active then Theme.strokeRounded(x + 1, y + 1, w - 2, h - 2, accent, 1, 3, 8) end
local source = s.sources[s.sourceIndex]
local label = kind == "pc" and (source and GameVersion.info(source.version).label .. " PC" or "Game PC") or s.service.state.boxes[box].name
Kit.textCenterBold("micro", Kit.ellipsize("micro", label, w - 10), x + 5, y + 5 * m.s, w - 10, active and accent or PAL.heading)
local entries, occupied, capacity = {}, 0, Store.SLOTS
if kind == "pc" then
capacity = s.pcBox == 0 and 6 or s.sources[s.sourceIndex]
and GameVersion.generation(s.sources[s.sourceIndex].version) == 3 and 30 or 20
for _, row in ipairs(s.pcRows) do
if (s.pcBox == 0 and row.where == "party") or row.where == "box" and row.box == s.pcBox then
entries[row.index] = row.entry; occupied = occupied + 1
end
end
else
entries = s.service.state.boxes[box].mons
for _ in pairs(entries) do occupied = occupied + 1 end
end
local cols = kind == "pc" and 6 or 10
local pad, gy = 8 * m.s, y + 27 * m.s
local cw = (w - 2 * pad) / cols
local gh = h - 50 * m.s
local ch = gh / math.ceil(capacity / cols)
for i = 1, capacity do
local gx = x + pad + ((i - 1) % cols) * cw
local yy = gy + math.floor((i - 1) / cols) * ch
local entry = entries[i]
if entry then
local size = math.min(cw, ch)
art(entry, gx + (cw - size) / 2, yy, size, true, s)
else
Theme.fillRounded(gx + cw / 2 - 1, yy + ch / 2 - 1, 2, 2, PAL.line, 0.25, 1)
end
end
Kit.textCenter("micro", occupied .. " / " .. capacity, x, y + h - 20 * m.s, w, PAL.muted)
end
local function boxRail(imp, s, x, y, w, m)
local gap, h = 8 * m.s, 120 * m.s
local n = w >= 760 * m.s and 5 or w >= 500 * m.s and 4 or 2
local storageN = n - 1
local first = s.railFirst or math.floor((s.box - 1) / storageN) * storageN + 1
first = math.max(1, math.min(first, Store.BOXES - storageN + 1))
s.railFirst = first
Kit.textBold("small", Strings("Your boxes"), x, y, PAL.heading)
local bw = Kit.tapMin()
button(imp, x + w - 2 * bw - gap, y, bw, bw, "rail-prev", "",
function() s.railFirst = math.max(1, first - storageN) end, false, first == 1, "chevron-left")
button(imp, x + w - bw, y, bw, bw, "rail-next", "",
function() s.railFirst = math.min(Store.BOXES - storageN + 1, first + storageN) end,
false, first + storageN > Store.BOXES, "chevron-right")
y = y + bw + gap
local tw = (w - (n - 1) * gap) / n
thumbnail(imp, s, x, y, tw, h, m, "pc")
for i = 1, storageN do thumbnail(imp, s, x + i * (tw + gap), y, tw, h, m, "box", first + i - 1) end
return bw + gap + h + gap
end
local function locations(s, pc, party)
local options = {}
if pc then
local source = s.sources[s.sourceIndex]
local total = source and GameVersion.generation(source.version) == 1 and 12 or 14
if party then options[#options + 1] = { id = 0, label = "Party", icon = "users" } end
for b = 1, total do options[#options + 1] = { id = b, label = "PC box " .. b, icon = "monitor" } end
else
for b, box in ipairs(s.service.state.boxes) do
local n = 0; for _ in pairs(box.mons) do n = n + 1 end
options[#options + 1] = { id = b, label = box.name .. " · " .. n .. "/60", icon = "package" }
end
end
return options
end
local function controls(imp, s, x, y, w, m, gridW)
local gap, h = 6 * m.s, math.max(Kit.tapMin(), 32 * m.s)
local kind = s.view == "pc" and "pc" or "box"
local selection = kind == "pc" and s.selectedPC or s.selectedBox
local source, n = s.sources[s.sourceIndex], count(selection)
local top = y
local target = kind == "pc" and s.service.state.boxes[s.box].name
or (s.pcBox == 0 and "Choose a PC box" or "PC box " .. s.pcBox)
local game = source and GameVersion.info(source.version).label or "game"
UI.dropdown(imp, x, y, w, h, "transfer-target", "To: " .. (kind == "box" and game .. " · " or "") .. target,
locations(s, kind == "box", false), kind == "pc" and s.box or s.pcBox, function(value)
if kind == "pc" then s.box, s.railFirst = value, nil else s.pcBox = value end
end, kind == "pc" and "package" or "monitor")
y = y + h + gap
local label = kind == "pc" and ("Store %d in Box"):format(n) or ("Send %d to game"):format(n)
local compact = w < 560 * m.s
local multiW, selectW = 86 * m.s, 145 * m.s
local primaryW = compact and w - multiW - gap or w - multiW - selectW - h - 3 * gap
button(imp, x, y, primaryW, h, kind == "pc" and "deposit" or "withdraw", label, function()
local refs = selected(selection)
local function transfer(repair)
perform(imp, function()
if kind == "pc" then return s.service:deposit(source, refs, s.box, s.pcBody, repair) end
return s.service:withdraw(source, refs, s.pcBox, repair)
end, repair and (kind == "pc" and "Pokémon repaired and deposited." or "Pokémon repaired and withdrawn.")
or (kind == "pc" and "Pokémon deposited." or "Pokémon withdrawn."))
end
local illegal = s.service:audit(source, refs, kind)
if #illegal == 0 then return transfer(false) end
local options = {}
for _, row in ipairs(illegal) do
local fixes = {}
for _, fix in ipairs(row.fixes) do fixes[#fixes + 1] = fix.label end
options[#options + 1] = { id = "info-" .. #options, label = tostring(row.name) .. ": " .. table.concat(fixes, ", "),
icon = "triangle-alert", disabled = true }
end
options[#options + 1] = { id = "repair", label = kind == "pc" and "Repair and store" or "Repair and send", icon = "check" }
options[#options + 1] = { id = "cancel", label = "Cancel", icon = "x" }
UI.open(imp, #illegal == 1 and "Repair illegal Pokémon?" or ("Repair %d illegal Pokémon?"):format(#illegal),
options, nil, function(id) if id == "repair" then transfer(true) end end)
end, true, n == 0 or not source or kind == "box" and s.pcBox == 0)
button(imp, x + primaryW + gap, y, 86 * m.s, h, "multi", "Multi",
function() s.multi = s.multi == false end, s.multi ~= false)
local bx = x + primaryW + multiW + 2 * gap
if compact then y = y + h + gap; bx = x end
local clearW = h
button(imp, bx, y, compact and w - clearW - gap or selectW, h, "select-visible", "Select visible", function()
local rows, first, last = visibleRows(s, kind, gridW, m)
for i = first, last do
local row = rows[i]
if row.entry then
local key = kind == "pc" and row.key or row.box .. ":" .. row.slot
selection[key] = kind == "pc" and { where = row.where, box = row.box, index = row.index }
or { box = row.box, slot = row.slot }
end
end
s.multi = true
end)
button(imp, x + w - clearW, y, clearW, h, "clear-selection", "", function()
s.selectedPC, s.selectedBox = {}, {}
end, false, count(s.selectedPC) + count(s.selectedBox) == 0)
return y + h + gap - top
end
local function drawBody(imp, s, x, y, w, _, m)
s.view = s.view or "box"
local top, gap, pad = y, 8 * m.s, 10 * m.s
local rowH = math.max(Kit.tapMin(), 32 * m.s)
Kit.textBold("button", Strings(s.summary and "SUMMARY" or "BOX"), x, y, PAL.heading)
if s.service then
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
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")
if queryAffectsPage and s.query and s.query ~= "" then
local filtered = s.filterQuery == s.query
local label = filtered and "Filters applied" or ("Search: " .. s.query)
Icons.draw(filtered and "list-filter" or "search", x, y, 14 * m.s, PAL.blue, 1)
Kit.text("micro", Kit.ellipsize("micro", label, w - 24 * m.s), x + 22 * m.s, y, PAL.muted)
y = y + Kit.textHeight("micro") + gap
end
if s.service and s.summary then return y - top + summary(imp, s, x, y, w, m) end
local source = s.sources[s.sourceIndex]
local wide = w >= 740 * m.s
local tools = require("src.import.BoxTools")
local inlineTools = wide and s.service and not s.summary
local sourceW = inlineTools and (w - gap) * .58 or w
local headerH = inlineTools and math.max(m.btnH, rowH) or rowH
local options = {}; for i, save in ipairs(s.sources) do options[i] = { id = i, label = save.label, icon = "gamepad-2" } end
UI.dropdown(imp, x, y, sourceW - headerH - gap, headerH, "choose-save", source and "Game: " .. source.label or "Choose a game",
options, s.sourceIndex, function(index)
if index == s.sourceIndex then return end
UI.change(imp, s, "sourceIndex", index)
s.pcBox, s.pagePC, s.selectedPC, s.movingPC = 1, 1, {}, nil
if s.inspectKind == "pc" then s.inspect, s.inspectKind, s.inspectRef = nil, nil, nil end
if not s.toolsPage then s.view = "pc" end
readPC(s)
end, "gamepad-2")
button(imp, x + sourceW - headerH, y, headerH, headerH, "reload", "", function() BoxPanel.refresh(imp) end)
if not s.service then return y + headerH - top end
if inlineTools then tools.navigation(imp, s, x + sourceW + gap, y, w - sourceW - gap, m) end
y = y + headerH + gap
local toolsH, toolsPage = tools.draw(imp, s, x, y, w, m, {
button = button, perform = perform, selected = selected, selectEntry = selectEntry,
openSummary = function(target) openSummary(target, imp) end,
navigationDone = inlineTools,
})
y = y + toolsH
if toolsPage then return y - top end
local optionW = 112 * m.s
local qx, qy, qw = x, y, w - optionW - 2 * rowH - 3 * gap
local query = Kit.textfield("box-search", qx, qy, qw, rowH, s.query, Strings("Search Pokémon"))
if query ~= s.query then s.query, s.pageBox, s.pagePC = query, 1, 1 end
UI.button(imp, x + qw + gap, y, rowH, rowH, "sort", "", function()
local tools = require("src.import.BoxTools")
UI.open(imp, "Sort", UI.values(tools.SORTS, tools.SORT_LABELS, "arrow-up-down"), s.sort,
function(value) s.sort, s.pageBox = value, 1 end)
end, { icon = "arrow-up-down" })
UI.button(imp, x + qw + rowH + 2 * gap, y, rowH, rowH, "filters", "", function()
UI.change(imp, s, "toolsPage", "Filters")
end, { icon = "list-filter" })
local actionOptions = {
{ label = "Rename this Box", icon = "pencil", action = function()
require("src.import.BoxPrompt").open(imp, "Box name", s.service.state.boxes[s.box].name, 32,
function(value) perform(imp, function() return s.service:rename(s.box, value) end, "Box renamed.") end)
end },
{ label = "Select whole Box", icon = "check", action = function()
s.selectedBox, s.multi = {}, true
for slot in pairs(s.service.state.boxes[s.box].mons) do s.selectedBox[s.box .. ":" .. slot] = { box = s.box, slot = slot } end
end },
{ label = "Box theme", icon = "paintbrush", action = function()
local box = s.service.state.boxes[s.box]
local options = UI.values(Store.THEMES, nil, "paintbrush")
for _, option in ipairs(options) do option.disabled = option.id == "Showcase" and not box.wallpaper end
for index, stage in pairs(s.service.state.stages or {}) do
options[#options + 1] = { label = "Stage " .. index .. " · " .. stage.name, icon = "paintbrush", action = function()
perform(imp, function() return s.service:exportStage(index, s.box) end, "Showcase wallpaper saved.")
end }
end
UI.open(imp, "Box theme", options, box.theme or "Launcher", function(theme)
perform(imp, function() return s.service:theme(s.box, theme, theme == "Showcase" and box.wallpaper or nil) end, "Theme saved.")
end)
end },
}
UI.dropdown(imp, x + w - optionW, y, optionW, rowH, "options", "Options", actionOptions, nil, nil, "ellipsis")
y = qy + rowH + gap
local kind = s.view == "pc" and "pc" or "box"
local selection = kind == "pc" and s.selectedPC or s.selectedBox
local mainY = y
local detailW = wide and math.min(w * 0.22, 230 * m.s) or w
local gx, gw = wide and x + detailW + gap or x, wide and w - detailW - gap or w
local detailH
if wide then detailH = detail(imp, s, x, y, detailW, m, false)
else y = y + detail(imp, s, x, y, w, m, true) + gap end
local stageY = y
local gridW = gw - 2 * pad
local rows, first, last = visibleRows(s, kind, gridW, m)
local cols, cg, _, ch, perPage = gridMetrics(gridW, m)
local gridH = math.max(1, math.ceil((last - first + 1) / cols)) * (ch + cg)
if #rows > perPage then gridH = gridH + Kit.tapMin() + cg end
local stageH = 2 * pad + (wide and rowH + gap or 2 * (rowH + gap)) + gridH
panel(gx, stageY, gw, stageH, kind == "box" and PAL.green or PAL.blue)
if kind == "box" then
require("src.box.Themes").draw(s.service.state.boxes[s.box], gx + pad, stageY + pad,
gw - 2 * pad, stageH - 2 * pad, m.s)
end
local tabW = wide and 140 * m.s or (gw - 2 * pad - gap) / 2
button(imp, gx + pad, y + pad, tabW, rowH, "view-box", "Box storage", function() UI.change(imp, s, "view", "box", -1) end, kind == "box")
button(imp, gx + pad + tabW + gap, y + pad, tabW, rowH, "view-pc", "Game PC", function() UI.change(imp, s, "view", "pc", 1) end, kind == "pc")
local selectorX, selectorW = gx + pad, gridW
if wide then selectorX = selectorX + 2 * (tabW + gap); selectorW = gridW - 2 * (tabW + gap) end
local selectorY = y + pad + (wide and 0 or rowH + gap)
local movingKey = kind == "pc" and "movingPC" or "moving"
if s[movingKey] or count(selection) > 0 then
local moving = s[movingKey]
local moveW = math.min(150 * m.s, selectorW * .3)
if moving then
local glow = 0.45 + 0.4 * math.sin(Kit.time * 5)
Theme.strokeRounded(selectorX - 3, selectorY - 3, moveW + 6, rowH + 6, PAL.yellow, glow, 3 * m.s)
end
button(imp, selectorX, selectorY, moveW, rowH, "move", moving and "Move where?" or "Move", function()
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."
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,
})
used = used + cancelW + gap
end
selectorX, selectorW = selectorX + used, selectorW - used
end
local suffix = " · " .. count(selection) .. " selected"
local titleW = selectorW - 2 * (Kit.tapMin() + 4 * Kit.scale)
local title = Kit.ellipsize("small", boxName(s), math.max(0, titleW - Kit.textWidth("small", suffix))) .. suffix
choose(imp, selectorX, selectorY, selectorW, rowH, "active-box", title,
function() changeBox(imp, s, -1) end, function() changeBox(imp, s, 1) end, function()
local pc = s.view == "pc"
UI.open(imp, pc and "Game PC" or "Box storage", locations(s, pc, true), pc and s.pcBox or s.box, function(value)
UI.change(imp, s, pc and "pcBox" or "box", value)
s.pageBox, s.pagePC, s.railFirst = 1, 1, nil
end)
end)
y = selectorY + rowH + gap
grid(imp, s, kind, gx + pad, y, gridW, m)
y = math.max(stageY + stageH, wide and mainY + detailH or 0) + gap
y = y + controls(imp, s, x, y, w, m, gridW)
y = y + boxRail(imp, s, x, y, w, m)
return y - top
end
function BoxPanel.draw(imp, x, y, w, h, m)
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)
end
imp._boxOrganizerFooter = nil
return UI.pages(imp, state(imp), x, y, w, h, m, drawBody)
end
function BoxPanel.textinput(imp, text)
if imp._boxPopup then
if Kit.focus == "box-chooser-filter" then Kit.textinput(text) end
return true
end
if imp.tab ~= "box" or Kit.focus ~= "box-search" then return false end
Kit.textinput(text)
return true
end
function BoxPanel.keypressed(imp, key)
if imp.tab ~= "box" or Kit.focus ~= "box-search" then return false end
Kit.keypressed(key)
return true
end
return BoxPanel
+48
View File
@@ -0,0 +1,48 @@
local Kit = require("src.ui.kit.Kit")
local Prompt = {}
function Prompt.close(imp, confirmed)
local own = imp._boxPromptKeyboard
if own then
imp._boxPromptKeyboard = nil
local VK = Kit.VirtualKeyboard
if VK.active and VK.onDone == own then VK.close(false) end
end
local prompt = imp._boxPrompt
if not prompt then return end
imp._boxPrompt = nil
Kit.blur()
imp:_disarmTextInput()
if confirmed then prompt.callback(prompt.text) end
end
function Prompt.open(imp, title, text, maxLen, callback)
Kit.blur()
local onDone
onDone = function(value, confirmed)
if imp._boxPromptKeyboard == onDone then imp._boxPromptKeyboard = nil end
if confirmed then callback(value) end
end
local opts = { title = title, text = text or "", maxLen = maxLen, onDone = onDone }
if Kit.VirtualKeyboard.open(opts) then imp._boxPromptKeyboard = onDone; return end
imp._boxPrompt = { title = title, text = text or "", maxLen = maxLen, callback = callback }
imp:_armTextInput(text)
end
function Prompt.openKeyboard(imp)
local prompt = imp._boxPrompt
if not prompt then return false end
local onDone
onDone = function(newText, confirmed)
if imp._boxPromptKeyboard == onDone then imp._boxPromptKeyboard = nil end
if confirmed and imp._boxPrompt == prompt then prompt.text = newText end
end
if not Kit.VirtualKeyboard.open({ text = prompt.text, title = prompt.title,
maxLen = prompt.maxLen, onDone = onDone }) then
return false
end
imp._boxPromptKeyboard = onDone
return true
end
return Prompt
+690
View File
@@ -0,0 +1,690 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Store = require("src.box.Store")
local Collection = require("src.box.Collection")
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.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",
hp = "HP", attack = "Attack", defense = "Defense", speed = "Speed", special = "Special",
spAtk = "Sp. Attack", spDef = "Sp. Defense", nature = "Nature", ability = "Ability",
gender = "Gender", trainer = "Trainer", friendship = "Friendship" }
Tools.CONTROL_ICONS = { ["stage-slot"] = "save", ["stage-background"] = "paintbrush", ["stage-pattern"] = "grid-2x2",
["stage-music"] = "music", ["stage-new-piece"] = "package", ["stage-x"] = "arrow-left-right",
["stage-y"] = "arrow-up-down", ["stage-size"] = "expand", ["stage-rotation"] = "rotate-ccw",
sort = "arrow-up-down", ["arrange-sort"] = "arrow-up-down", ["migration-destination"] = "gamepad-2" }
local ACTION_ICONS = { ["migration-preview"] = "eye", ["exports-folder"] = "folder", arrange = "arrow-up-down",
["apply-marks"] = "check", compare = "arrow-left-right", ["stage-wallpaper"] = "paintbrush", ["stage-flip"] = "arrow-left-right" }
local function search(s, query, sort)
local cache = s._toolsSearch
if not cache or cache.state ~= s.service.state or cache.size >= 8 then
cache = { state = s.service.state, results = {}, size = 0 }; s._toolsSearch = cache
end
local key = tostring(query) .. "\0" .. tostring(sort)
if not cache.results[key] then
cache.results[key], cache.size = Store.search(s.service.state, query, sort), cache.size + 1
end
return cache.results[key]
end
local function same(a, b)
if a == b then return true end
if type(a) ~= "table" or type(b) ~= "table" then return false end
for k, v in pairs(a) do if not same(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 userExport(s, path, index)
local fs = s.service.fs
local bytes = fs.read(path)
if not bytes then return nil, "Could not read the exported PNG." end
if not fs.createDirectory("box/exports") then return nil, "Could not create the export folder." end
local out = "box/exports/showcase-" .. index .. "-" .. os.time() .. ".png"
local ok, why = fs.write(out, bytes)
if not ok or fs.read(out) ~= bytes then return nil, why or "Could not verify the exported PNG." end
local used = false
for _, box in ipairs(s.service.state.boxes) do if box.wallpaper == path then used = true end end
if not used and fs.remove and fs.remove(path) then require("src.box.Themes").forget(path) end
return out
end
local function findEntry(s, id)
local cache = s._toolsIndex
if not cache or cache.state ~= s.service.state then
cache = { state = s.service.state, byId = {} }; s._toolsIndex = cache
for b = 1, Store.BOXES do
for slot, entry in pairs(s.service.state.boxes[b].mons) do
cache.byId[entry.id] = { box = b, slot = slot, entry = entry }
end
end
end
return cache.byId[id]
end
function Tools.navigation(imp, s, x, y, w, m)
local gap, h = 8 * m.s, math.max(Kit.tapMin(), m.btnH)
local navigation = { { id = "Pokémon", label = "Boxes", icon = "package" } }
for _, page in ipairs(Tools.PAGES) do navigation[#navigation + 1] = { id = page, label = page, icon = UI.ICONS[page] } end
local helpKey = s.toolsPage or "Box"
UI.dropdown(imp, x, y, UI.HELP[helpKey] and w - h - gap or w, h, "tools-menu", "Tools: " .. (s.toolsPage or "Boxes"), navigation,
s.toolsPage or "Pokémon", function(value)
require("src.box.Showcase").stopMusic(); s.stagePlaying = nil
UI.change(imp, s, "toolsPage", value ~= "Pokémon" and value or nil, value == "Pokémon" and -1 or 1)
s.toolsListPage = 1
end, UI.ICONS[s.toolsPage] or "package")
if UI.HELP[helpKey] then UI.help(imp, helpKey, x + w - h, y, h) end
return h + gap
end
function Tools.draw(imp, s, x, y, w, m, api)
local top, gap, h = y, 8 * m.s, math.max(Kit.tapMin(), m.btnH)
local function button(id, label, action, disabled, width, bx, active, icon)
icon = icon or ACTION_ICONS[id] or (id:match("save") and "save" or id:match("remove") and "trash"
or id:match("export") and "upload" or id:match("import") and "download"
or id:match("select") and "check" or id:match("restore") and "undo-2"
or id:match("name") and "pencil" or UI.ICONS[s.toolsPage] or "sliders-horizontal")
api.button(imp, bx or x, y, width or w, h, "tools-" .. id, label, action, active, disabled, icon)
end
local function text(value, muted)
y = y + Kit.textWrapped("small", value, x, y, w, muted and PAL.muted or PAL.text) + gap
end
local function nextRow() y = y + h + gap end
if not api.navigationDone then y = y + Tools.navigation(imp, s, x, y, w, m) end
local page = s.toolsPage
if not page then return y - top, false end
local refs = api.selected(s.selectedBox)
local function operation(fn, message) api.perform(imp, fn, message) end
local function namePrompt(title, initial, callback)
require("src.import.BoxPrompt").open(imp, title, initial, 64, callback)
end
local function cycle(id, label, values, current, apply, column)
local icon = Tools.CONTROL_ICONS[id] or UI.ICONS[page]
local function display(value)
if Tools.SORT_LABELS[value] then return Tools.SORT_LABELS[value] end
if require("src.core.GameVersion").VERSIONS[value] then return require("src.core.GameVersion").info(value).label end
if id == "stage-x" or id == "stage-y" then return math.floor(value * 100 + .5) .. "%" end
if id == "stage-rotation" then return math.floor(value * 180 / math.pi + .5) .. "°" end
return tostring(value)
end
local options = {}; for _, value in ipairs(values) do options[#options + 1] = { id = value, label = display(value), icon = icon } end
local cw = column and (w - gap) / 2 or w
UI.dropdown(imp, x + (column == 2 and cw + gap or 0), y, cw, h, "tools-" .. id,
label .. ": " .. display(current), options, current, apply, icon)
if column ~= 1 then nextRow() end
end
local function actions(items)
local cw = (w - gap) / 2
for i, item in ipairs(items) do
button(item[1], item[2], item[3], item[4], cw, x + (i - 1) % 2 * (cw + gap), item[5], item[6])
if i % 2 == 0 or i == #items then nextRow() end
end
end
local function sections(key, names)
s[key] = s[key] or names[1]
cycle(key, "View", names, s[key], function(value) UI.change(imp, s, key, value) end)
return s[key]
end
local function tiles(rows)
local cols = w >= 700 * m.s and 4 or 2
local cw, cardH = (w - (cols - 1) * gap) / cols, 86 * m.s
for i, row in ipairs(rows) do
local cx = x + (i - 1) % cols * (cw + gap)
Theme.card(cx, y, cw, cardH, { shadow = true })
Icons.draw(row.icon or "package", cx + 12 * m.s, y + 12 * m.s, 22 * m.s, PAL.blue, 1)
Kit.textBold("button", tostring(row.value), cx + 42 * m.s, y + 12 * m.s, PAL.heading)
Kit.text("small", row.label, cx + 12 * m.s, y + cardH - 29 * m.s, PAL.muted)
if i % cols == 0 or i == #rows then y = y + cardH + gap end
end
end
local function picker()
local options = {}
for b = 1, Store.BOXES do
local box = s.service.state.boxes[b]
local occupied = 0
for _ in pairs(box.mons) do occupied = occupied + 1 end
options[#options + 1] = { id = b, label = box.name .. " · " .. occupied .. "/60", icon = "package" }
end
UI.dropdown(imp, x, y, w, h, "tools-box-picker", "Box: " .. s.service.state.boxes[s.box].name,
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
if page == "Migration" then
local Migration=require("src.box.Migration")
local GameVersion=require("src.core.GameVersion")
local versions={}
for _,version in ipairs(GameVersion.ORDER) do
if require("src.box.Catalog").get(version).ready then versions[#versions+1]=version end
end
local selectionKey={}
for _,ref in ipairs(refs) do selectionKey[#selectionKey+1]=ref.box..":"..ref.slot end
selectionKey=table.concat(selectionKey,",")
if s.migrationPreviewKey~=selectionKey or s.migrationPreviewState~=s.service.state then
s.migrationPreview,s.migrationPreviewKey,s.migrationPreviewState=nil,selectionKey,s.service.state
end
if #versions>0 then
s.migrationVersion=s.migrationVersion or versions[1]
cycle("migration-destination","Destination",versions,s.migrationVersion,function(value)
s.migrationVersion,s.migrationPreview=value,nil
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
end,#refs==0);nextRow()
local preview=s.migrationPreview
if preview then
local options, ready = {}, 0
for i, row in ipairs(preview.rows) do
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
end
button("migration-apply","Convert selected",function()
operation(function() return s.service:migrate(refs,s.migrationVersion,preview) end,"Migration saved with archived originals.")
s.migrationPreview=nil
end,not preview.allowed or preview.revision~=s.service.state.revision or s.diskStale);nextRow()
end
else text("Import both games first.",true) end
if #refs==1 then
local entry=Store.at(s.service.state,refs[1].box,refs[1].slot)
local originals = entry and entry.archives or {}
if #originals > 0 then
s.archiveIndex = math.min(s.archiveIndex or 1, #originals)
local options = {}; for i, original in ipairs(originals) do options[i] = { id = i,
label = GameVersion.info(original.version).label .. " · " .. original.display.name, icon = "undo-2" } end
UI.dropdown(imp, x, y, w, h, "migration-archive", "Archived original", options, s.archiveIndex,
function(value) s.archiveIndex = value end, "undo-2"); nextRow()
button("migration-restore","Restore original",function()
operation(function() return s.service:restoreOriginal(refs[1],s.archiveIndex) end,"Original restored.")
s.migrationPreview=nil
end);nextRow()
end
end
UI.help(imp, "MigrationPolicy", x + w - h, y, h); nextRow()
elseif page == "Events" then
local Events=require("src.box.Events")
local source=s.sources[s.sourceIndex]
local rows=source and Events.list(source.version,2) or {}
if #rows==0 then text("No tickets for this game.",true) end
local cache=s._eventStatus
if not cache or cache.body~=s.pcBody or cache.version~=(source and source.version) then
cache={body=s.pcBody,version=source and source.version,statuses={}};s._eventStatus=cache
for _,row in ipairs(rows) do
local status,why
if s.pcSave then status,why=Events.status(s.pcSave,row) end
cache.statuses[row.id]={status=status,why=why}
end
end
local options = {}; for _, row in ipairs(rows) do options[#options + 1] = { id = row.id, label = row.name, icon = "mail" } end
if #rows > 0 then
local chosen = rows[1]
for _, row in ipairs(rows) do if row.id == s.eventChoice then chosen = row end end
s.eventChoice = chosen.id
UI.dropdown(imp, x, y, w, h, "event-picker", chosen.name, options, chosen.id,
function(value) s.eventChoice = value end, "mail"); nextRow()
local row = chosen
local status=cache.statuses[row.id].status or "Unavailable"
text(row.route .. " · " .. status)
local pending=status=="Ticket pending"
button("event-"..row.id,pending and "Collect ticket" or row.method=="Record mixing"
and "Receive ticket" or "Receive card",function()
operation(function() return s.service:event(source,row.id,pending) end,(pending or row.method=="Record mixing")
and "Ticket collected. Normal ship requirements still apply." or "Wonder Card received. Collect its ticket here or from the delivery person in-game.")
end,status~="Available" and not pending);nextRow()
if cache.statuses[row.id].why then
UI.help(imp, "Why is this locked?", x, y, h, cache.statuses[row.id].why); nextRow()
end
end
elseif page == "Files" then
local export = function()
local ok, path = s.service:exportGCI()
s.notice = ok and "Exported and verified: " .. path or path
end
actions({ { "gci-import", "Import Box save", function() imp:chooseBoxImport() end },
{ "gci-export", "Export GCI", export, not s.service.state.gciTemplate } })
button("exports-folder", "Exports folder", function()
if love.system and love.system.openURL then
if s.service.fs.createDirectory then s.service.fs.createDirectory("box/exports") end
love.system.openURL("file://"..love.filesystem.getSaveDirectory().."/box/exports")
end
end, imp.android or imp.ios or imp.isNX); nextRow()
local sync = imp._syncEngine and imp:_syncEngine()
local busy = sync and sync:busy() or false
local cached = s._latestBackup
if not cached or cached.sync ~= sync or cached.busy ~= busy then
cached = { sync = sync, busy = busy, path = not busy and sync and sync.box and sync.box:latestBackup() or nil }
s._latestBackup = cached
end
local backup = cached.path
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
end, not backup or sync:busy(), w - h - gap)
UI.help(imp, "Backup", x + w - h, y, h); nextRow()
elseif page == "Showcase" then
local Showcase = require("src.box.Showcase")
local fullX, fullW = x, w
local beside = w >= 740 * m.s
if beside then w = (w - 2 * gap) * .48 end
s.stageIndex = s.stageIndex or 1
local function loadStage(index)
Showcase.stopMusic()
s.stagePlaying = nil
s.stageDrafts=s.stageDrafts or {}
if s.stageDraft then s.stageDrafts[s.stageIndex]=s.stageDraft end
s.stageIndex, s.stagePiece = index, 1
s.stageDraft = s.stageDrafts[index] or Store.copy(s.service.state.stages and s.service.state.stages[index] or Showcase.new())
s.stageUndo = {}
end
if not s.stageDraft then loadStage(s.stageIndex) end
local function edit(fn)
local before = Store.copy(s.stageDraft)
fn(s.stageDraft)
if same(before, s.stageDraft) then return end
s.stageUndo = s.stageUndo or {}
s.stageUndo[#s.stageUndo+1] = before
if #s.stageUndo>20 then table.remove(s.stageUndo,1) end
end
cycle("stage-slot", "Saved stage", {1,2,3,4,5}, s.stageIndex, loadStage)
local draft = s.stageDraft
text(draft.name)
local previewH = math.max(190*m.s, math.min(300 * m.s, w*.5))
require("src.import.ShowcaseEditor").draw(imp,s,x,y,w,previewH,m,edit)
local previewBottom = y + previewH + gap
if beside then x, y, w = fullX + w + 2 * gap, top, fullW - w - 2 * gap
else y = previewBottom end
local function addOptions()
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
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
end}
end
return out
end
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
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
UI.dropdown(imp,x+(w+gap)/2,y,(w-gap)/2,h,"stage-add-scenery","Add scenery",scenery,nil,nil,"plus")
nextRow()
local function layerOptions()
local out={}
for i=#s.stageDraft.pieces,1,-1 do
local p=s.stageDraft.pieces[i]
local row=p.entryId and findEntry(s,p.entryId)
out[#out+1]={id=i,label=i.." · "..(row and row.entry.display.name or p.kind or "Outside Box"),
icon="package",entry=row and row.entry}
end
return out
end
UI.dropdown(imp,x,y,w-2*(h+gap),h,"stage-layers","Layers · front to back",layerOptions,s.stagePiece,
function(v) s.stagePiece=v end,"copy")
local layer=s.stagePiece
if layer and s.stageDraft.pieces[layer] then
button("stage-back","",function() edit(function(d) s.stagePiece=Showcase.reorder(d,layer,layer-1) end) end,
layer==1,h,x+w-2*h-gap,false,"arrow-down")
button("stage-front","",function() edit(function(d) s.stagePiece=Showcase.reorder(d,layer,layer+1) end) end,
layer==#s.stageDraft.pieces,h,x+w-h,false,"arrow-up")
end
nextRow()
local stageSection = sections("stageSection", { "Scene", "Placement", "Export" })
if stageSection == "Scene" then
cycle("stage-background", "Background", Showcase.BACKGROUNDS, draft.background, function(value) edit(function(d) d.background=value end) end, 1)
cycle("stage-pattern", "Pattern", Showcase.PATTERNS, draft.pattern, function(value) edit(function(d) d.pattern=value end) end, 2)
cycle("stage-music", "Music", Showcase.MUSIC, draft.music, function(value)
edit(function(d) d.music=value end); Showcase.stopMusic(); s.stagePlaying = nil
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
end,draft.music=="Silent",w,nil,false,s.stagePlaying and "square" or "play");nextRow()
elseif stageSection == "Placement" then
local function pieceLabel(i)
local placement = draft.pieces[i]
local row = placement.entryId and findEntry(s, placement.entryId)
return i .. " · " .. (row and row.entry.display.name or placement.kind or "Outside Box")
end
local function pieces()
local out = {}
for i in ipairs(draft.pieces) do out[i] = { id = i, label = pieceLabel(i), icon = "package" } end
return out
end
s.stagePiece = math.min(s.stagePiece or 1, math.max(1, #draft.pieces))
UI.dropdown(imp, x, y, w, h, "stage-piece", draft.pieces[s.stagePiece] and pieceLabel(s.stagePiece) or "Choose a placement",
pieces, s.stagePiece, function(value) s.stagePiece = value end, "package"); nextRow()
local piece=draft.pieces[s.stagePiece or 1]
if piece then
local positions={};for i=0,25 do positions[#positions+1]=i/25 end
cycle("stage-x","X",positions,piece.x,function(value) edit(function() piece.x=value end) end, 1)
cycle("stage-y","Y",positions,piece.y,function(value) edit(function() piece.y=value end) end, 2)
cycle("stage-size","Size",{0.25,0.5,0.75,1,1.5,2,3,4},piece.scale,function(value) edit(function() piece.scale=value end) end, 1)
cycle("stage-rotation","Turn",{-math.pi,-math.pi/2,0,math.pi/2,math.pi},piece.rotation,function(value) edit(function() piece.rotation=value end) end, 2)
actions({ { "stage-flip", "Flip horizontally", function() edit(function() piece.flip=not piece.flip end) end }, { "stage-remove", "Remove", function()
edit(function(d) table.remove(d.pieces,s.stagePiece or 1) end);s.stagePiece=math.max(1,math.min(s.stagePiece,#draft.pieces))
end } })
else text("Add a pokémon or some furniture first.", true) end
elseif stageSection == "Export" then
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
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.")
end, not s.service.state.stages or not s.service.state.stages[s.stageIndex] } })
end
button("stage-save", "Save stage", function()
operation(function() return s.service:saveStage(s.stageIndex,draft) end,"Showcase saved.")
end, false, (w - gap) / 2)
UI.dropdown(imp, x + (w + gap) / 2, y, (w - gap) / 2, h, "stage-options", "Options", {
{ label = "Save and use as Box theme", icon = "paintbrush", action = function()
operation(function()
local ok,why=s.service:saveStage(s.stageIndex,s.stageDraft)
if not ok then return nil,why end
return s.service:exportStage(s.stageIndex,s.box)
end,"Stage saved as this Box's theme.")
end },
{ label = "Undo last edit", icon = "undo-2", disabled = not s.stageUndo or #s.stageUndo == 0,
action = function() s.stageDraft = table.remove(s.stageUndo); s.stagePiece = 1 end },
{ label = "Rename stage", icon = "pencil", action = function()
namePrompt("Stage name", draft.name, function(value) edit(function(d) d.name = value end) end)
end },
}, nil, nil, "ellipsis"); nextRow()
y, x, w = math.max(y, previewBottom), fullX, fullW
elseif page == "Gifts" then
local source = s.sources[s.sourceIndex]
local cache = s._giftProgress
if not cache or cache.source ~= source or cache.state ~= s.service.state or cache.body ~= s.pcBody then
local progress, why = s.service:giftProgress(source)
cache = { source = source, state = s.service.state, body = s.pcBody, progress = progress, why = why }
s._giftProgress = cache
end
local progress, why = cache.progress, cache.why
if not progress then text(why, true)
else
text(progress.current .. " stored by this trainer · peak " .. progress.peak)
local giftCols = w >= 700 * m.s and 4 or 2
local giftW = (w - (giftCols - 1) * gap) / giftCols
for index, award in ipairs(require("src.box.Gifts").AWARDS) do
local status = index < progress.nextAward and "Claimed" or index == progress.nextAward
and progress.eligible and "Ready" or "Locked"
local cx, cardH = x + (index - 1) % giftCols * (giftW + gap), 104 * m.s
Theme.card(cx, y, giftW, cardH, { shadow = true })
Icons.draw(status == "Locked" and "lock" or status == "Claimed" and "check" or "package",
cx + 12 * m.s, y + 12 * m.s, 24 * m.s, status == "Ready" and PAL.green or PAL.blue, 1)
Kit.textBold("small", award.name .. " egg", cx + 12 * m.s, y + 43 * m.s, PAL.heading)
Kit.text("micro", award.threshold .. " stored · " .. status, cx + 12 * m.s, y + 73 * m.s, PAL.muted)
if index % giftCols == 0 or index == #require("src.box.Gifts").AWARDS then y = y + cardH + gap end
end
button("claim-egg", progress.award and "Claim " .. progress.award.name .. " egg" or "All gift eggs claimed", function()
operation(function() return s.service:claimEgg(s.sources[s.sourceIndex]) end, "Gift egg stored. Withdraw it to hatch in the game.")
end, not progress.eligible); nextRow()
end
elseif page == "List" then
local query = Kit.textfield("box-search", x, y, w, h, s.query, "Search Pokémon")
if query ~= s.query then s.query, s.toolsListPage = query, 1 end
nextRow()
cycle("sort", "Sort", Tools.SORTS, s.sort, function(value) s.sort, s.toolsListPage = value, 1 end)
local rows = search(s, s.query, s.sort)
local first, last, index = Kit.pageBounds(s.toolsListPage or 1, #rows, 8)
s.toolsListPage = index
for i = first, last do
local row, d = rows[i], rows[i].entry.display
local chosen = s.selectedBox[row.box .. ":" .. row.slot] ~= nil
local bh = h + Kit.textHeight("micro") + gap
local spriteW = 56 * m.s
api.button(imp, x + spriteW, y, w - spriteW, bh, "list-" .. row.entry.id,
(chosen and "Selected · " or "") .. d.name .. " · Lv. " .. d.level
.. " · " .. s.service.state.boxes[row.box].name .. " / " .. row.slot, function()
api.selectEntry(s, "box", row)
end, chosen, false, "package")
local art = require("src.box.Catalog").art(row.entry)
if art then require("src.online.OnlineSprites").drawIcon(art, x + 3 * m.s,
y + (bh - 48 * m.s) / 2, 48 * m.s, math.floor((s.artTime or 0) * 4)) end
Kit.text("micro", Kit.ellipsize("micro", table.concat({ d.gender or "—", d.nature or "—", d.ability or "—",
"HP " .. tostring(d.hp or "?"), "Atk " .. tostring(d.attack or "?"), "Def " .. tostring(d.defense or "?"),
"Spe " .. tostring(d.speed or "?") }, " · "), w - spriteW - 20 * m.s),
x + spriteW + 10 * m.s, y + h, chosen and PAL.inverse or PAL.muted)
y = y + bh + gap
end
if #rows == 0 then text("No Pokémon match this view.", true) end
cycle("list-page", "Page", (function()
local pages = {}; for i = 1, math.max(1, math.ceil(#rows / 8)) do pages[i] = i end; return pages
end)(), index, function(value) s.toolsListPage = value end)
actions({ { "summary", "Summary", function() api.openSummary(s) end, not s.inspect, false, "eye" },
{ "compare", "Compare selected", function() s.comparison = not s.comparison end, #refs == 0 or #refs > 6 } })
if s.comparison and #refs > 0 and #refs <= 6 then
local fields = { {"species","Species"},{"level","Level"},{"types","Types"},{"gender","Gender"},
{"nature","Nature"},{"ability","Ability"},{"hp","HP"},{"attack","Attack"},{"defense","Defense"},
{"speed","Speed"},{"special","Special"},{"spAtk","Sp. Atk"},{"spDef","Sp. Def"},
{"friendship","Friendship"},{"trainer","Trainer"},{"item","Held item"},{"moves","Moves"} }
local entries = {}
for _,ref in ipairs(refs) do
local entry=Store.at(s.service.state,ref.box,ref.slot)
if entry then entries[#entries+1]=entry end
end
local labelW=math.min(w*.22,110*m.s)
local columns=math.max(1,math.min(6,math.floor((w-labelW-gap)/(105*m.s))))
for start=1,#entries,columns do
local count=math.min(columns,#entries-start+1)
local cellW=(w-labelW-gap)/count
local headerH=0
for column=1,count do
local entry=entries[start+column-1]
headerH=math.max(headerH,Kit.textWrapped("small",entry.display.name,
x+labelW+gap+(column-1)*cellW,y,cellW-gap,PAL.heading))
end
y=y+headerH+gap
for _,field in ipairs(fields) do
local values,applicable={},false
for column=1,count do
local value=entries[start+column-1].display[field[1]]
if value~=nil then applicable=true end
values[column]=value==nil and "N/A" or value=="" and "None" or tostring(value)
end
if applicable then
local rowH=Kit.textWrapped("small",field[2],x,y,labelW,PAL.muted)
for column=1,count do
rowH=math.max(rowH,Kit.textWrapped("small",values[column],
x+labelW+gap+(column-1)*cellW,y,cellW-gap,PAL.text))
end
y=y+rowH+gap
end
end
y=y+gap
end
end
elseif page == "Filters" then
if (s.filterQuery or "") ~= s.query then s.filters = nil end
s.filters = s.filters or {}
local function query()
local parts = {}
for _, key in ipairs({ "type", "gender", "nature", "ability", "trainer", "mark" }) do
local value = s.filters[key]
if value and value ~= "Any" then
for piece in value:lower():gmatch('[^"]+') do parts[#parts + 1] = key .. ':"' .. piece .. '"' end
end
end
if s.filters.shiny then parts[#parts + 1] = "shiny" end
if s.filters.egg then parts[#parts + 1] = "egg" end
s.query, s.pageBox, s.pagePC = table.concat(parts, " "), 1, 1
s.filterQuery = s.query
end
local choiceCache = s._filterChoices
if not choiceCache or choiceCache.state ~= s.service.state then
choiceCache = { state = s.service.state }; s._filterChoices = choiceCache
end
local function choices(key)
if choiceCache[key] then return choiceCache[key] end
local found, out = {}, { "Any" }
for _, row in ipairs(search(s, "", "slot")) do
local value = row.entry.display[key == "type" and "types" or key]
if value then
local values = key == "type" and (function()
local types = {}; for item in value:gmatch("[^/]+") do types[#types + 1] = item:match("^%s*(.-)%s*$") end
return types
end)() or { tostring(value) }
for _, v in ipairs(values) do if v ~= "" and not found[v] then found[v], out[#out + 1] = true, v end end
end
end
table.sort(out, function(a, b) if a == "Any" then return true elseif b == "Any" then return false end; return a < b end)
choiceCache[key] = out
return out
end
for index, key in ipairs({ "type", "gender", "nature", "ability", "trainer" }) do
cycle("filter-" .. key, key:gsub("^%l", string.upper), choices(key), s.filters[key] or "Any",
function(value) s.filters[key] = value; query() end, index % 2 == 1 and 1 or 2)
end
cycle("filter-mark", "Marked", { "Any", "circle", "square", "triangle", "heart" }, s.filters.mark or "Any",
function(value) s.filters.mark = value; query() end, 2)
for index, key in ipairs({ "shiny", "egg" }) do
button("filter-" .. key, key:gsub("^%l", string.upper) .. " only", function()
s.filters[key] = not s.filters[key]; query()
end, false, (w - gap) / 2, x + (index - 1) * (w + gap) / 2, s.filters[key], "check")
end
nextRow()
actions({ { "filter-clear", "Clear", function() s.filters, s.query, s.filterQuery = {}, "", "" end, false, false, "x" },
{ "filter-view", "Show Pokémon", function() UI.change(imp, s, "toolsPage", nil, -1); s.view = "box" end } })
elseif page == "Arrange" then
s.arrangeSection = s.arrangeSection or "Sort"
local section = s.arrangeSection
local bw = (w - h - 2 * gap) / 2
UI.button(imp, x, y, bw, h, "tools-arrange-sort-tab", "Sort a box",
function() UI.change(imp, s, "arrangeSection", "Sort") end,{face="tab",active=section=="Sort",icon="arrow-up-down"})
UI.button(imp, x + bw + gap, y, bw, h, "tools-arrange-auto-tab", "Auto Box",
function() UI.change(imp, s, "arrangeSection", "Auto Box") end,{face="tab",active=section=="Auto Box",icon="arrow-left-right"})
UI.dropdown(imp, x + w - h, y, h, h, "tools-arrangeSection", "",
UI.values({ "Move", "Sort", "Auto Box", "Labels" }, nil, "ellipsis"), section,
function(v) UI.change(imp, s, "arrangeSection", v) end, "ellipsis")
nextRow()
if section == "Move" then
actions({ { "select-box", "Select whole Box", function()
s.selectedBox, s.multi = {}, true
for slot in pairs(s.service.state.boxes[s.box].mons) do
s.selectedBox[s.box .. ":" .. slot] = { box = s.box, slot = slot }
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."
end, #refs == 0 } })
picker()
elseif section == "Sort" or section == "Auto Box" then
y = y + require("src.import.BoxOrganizer").draw(imp, s, x, y, w, m, api, section == "Sort" and "sort" or "rules")
elseif section == "Labels" then
s.editMarkings = s.editMarkings or 0
for i, label in ipairs({ "Circle", "Square", "Triangle", "Heart" }) do
local mask = 2^(i - 1)
local checked = math.floor(s.editMarkings / mask) % 2 == 1
button("mark-" .. i, label, function()
s.editMarkings = s.editMarkings + (checked and -mask or mask)
end, false, (w - gap) / 2, x + (i - 1) % 2 * ((w - gap) / 2 + gap), checked, "check")
if i % 2 == 0 then nextRow() end
end
actions({ { "apply-marks", "Apply marks", function()
operation(function() return s.service:mark(refs, s.editMarkings) end, "Markings saved.")
end, #refs == 0 }, { "tags", "Set tags", function()
namePrompt("Local tags", "", function(value)
operation(function() return s.service:mark(refs, s.editMarkings, value) end, "Tags saved.")
end)
end, #refs == 0, false, "pencil" } })
end
elseif page == "Dashboard" then
local dash = s._dashboard
if not dash or dash.state ~= s.service.state or dash.save ~= s.pcSave then
dash = { state = s.service.state, save = s.pcSave, report = Collection.dashboard(s.service.state, s.pcSave) }
s._dashboard = dash
end
local report = dash.report
tiles({ { value = report.total, label = "Stored", icon = "package" }, { value = report.species, label = "Species", icon = "book-open" },
{ value = report.eggs, label = "Eggs", icon = "heart" }, { value = report.shiny, label = "Shiny", icon = "award" } })
text(report.caught .. " caught in the linked game", true)
local games = {}; for game in pairs(report.byGame) do games[#games + 1] = game end; table.sort(games)
for _, game in ipairs(games) do text(require("src.core.GameVersion").info(game).label .. ": " .. report.byGame[game]) end
local forms = {}; for form in pairs(report.forms) do forms[#forms + 1] = form end; table.sort(forms)
text("Unown forms held: " .. (#forms == 0 and "None" or table.concat(forms, ", ")))
local duplicateOptions = {}
if not s._nationalNames then
s._nationalNames = {}
local Catalog, GameVersion = require("src.box.Catalog"), require("src.core.GameVersion")
local versions = { "emerald" }; for i = #GameVersion.ORDER, 1, -1 do versions[#versions + 1] = GameVersion.ORDER[i] end
for _, version in ipairs(versions) do
local data = Catalog.get(version)
if data.names then
for dex, species in pairs(data.national.toSpecies or {}) do
s._nationalNames[dex] = s._nationalNames[dex] or data.names[species]
end
else
for _, def in pairs(data.pokemon or {}) do
if def.dex then s._nationalNames[def.dex] = s._nationalNames[def.dex] or def.name end
end
end
end
end
local function speciesName(dex) return s._nationalNames[dex] or "Species #" .. dex end
for _, row in ipairs(report.duplicates) do
duplicateOptions[#duplicateOptions + 1] = { id = row.dex, label = speciesName(row.dex) .. " · " .. row.count .. " held", icon = "copy" }
end
UI.dropdown(imp, x, y, w, h, "duplicates", "Duplicates · " .. #report.duplicates, duplicateOptions, nil,
function(dex) UI.change(imp, s, "toolsPage", "List"); s.query = "dex:" .. dex end, "copy"); nextRow()
local missing = {}; for _, dex in ipairs(report.missing) do missing[#missing + 1] = { id = dex, label = speciesName(dex), icon = "book-open" } end
UI.dropdown(imp, x, y, w, h, "missing", "Missing species · " .. #missing, missing, nil,
function(dex) UI.open(imp, speciesName(dex), {}); imp._boxPopup.message = "you don’t have this species in Box yet." end, "book-open"); nextRow()
elseif page == "Teams" then
button("save-team", "Save selected team (" .. #refs .. ")", function()
namePrompt("Team name", "", function(value)
operation(function() return s.service:preset(value, refs) end, "Team preset saved.")
end)
end, #refs == 0 or #refs > 6); nextRow()
local presets = s.service.state.presets or {}
s.teamIndex = math.min(s.teamIndex or 1, math.max(1, #presets))
if #presets > 0 then
local options = {}; for index, preset in ipairs(presets) do options[index] = { id = index, label = preset.name, icon = "users" } end
UI.dropdown(imp, x, y, w, h, "team-picker", presets[s.teamIndex].name, options, s.teamIndex,
function(value) s.teamIndex = value end, "users"); nextRow()
local index, preset = s.teamIndex, presets[s.teamIndex]
local members, missing = {}, false
for _, id in ipairs(preset.ids) do
local row = findEntry(s, id)
members[#members + 1] = row and row.entry.display.name or "Outside Box (#" .. id .. ")"
if not row then missing = true end
end
text(table.concat(members, " · "))
actions({ { "select-team-" .. index, "Select team", function()
s.selectedBox = {}
for _, id in ipairs(preset.ids) do
local row = Store.find(s.service.state, id)
if row then s.selectedBox[row.box .. ":" .. row.slot] = { box = row.box, slot = row.slot } end
end
end, missing }, { "deploy-team-" .. index, "Send team", function()
operation(function() return s.service:deployPreset(s.sources[s.sourceIndex], index) end, "Team deployed.")
end, missing or not s.sources[s.sourceIndex], false, "arrow-right" } })
end
end
return y - top, true
end
return Tools
+179
View File
@@ -0,0 +1,179 @@
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 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" }
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.",
Arrange = "sort and Auto Box work on Box storage or the linked game's PC. top rule wins. preview where they'll go, then apply to save.",
Themes = "just the look of this box. use a saved Showcase stage if you want your own wallpaper.",
Dashboard = "what’s in Box right now. caught is from the linked game’s pokédex, so the numbers can be different.",
Teams = "save a group of up to six. send team moves them into the linked game’s party, so leave enough room.",
Gifts = "this trainer unlocks eggs by storing more pokémon. claim one here, then send it to a game to hatch it.",
Showcase = "make a little scene with your pokémon. edits are a draft until you save. saved stages can become wallpapers.",
Files = "bring in a GameCube Box save or export one back out. exports need its original template and Gen 3 pokémon.",
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.",
}
local HELP_TITLES = { Box = "Box storage",
Backup = "Restore backup", MigrationPolicy = "Conversion rules" }
local function view() return require("src.import.LauncherView") end
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
function UI.close(imp)
imp._boxPopup = nil; Kit.blur(); Kit._drag = nil
Kit.mouseClicked, Kit.wheelY = false, 0
end
function UI.open(imp, title, options, current, apply)
Kit.blur(); Kit._drag = nil
imp._boxPopup = { title = title, options = options, current = current, apply = apply,
scroll = 0, index = 1, query = "", reveal = true }
if #options == 0 then imp._boxPopup.message = title == "Choose a game"
and "save a game first. your party and PC boxes will show up here." or "nothing here yet." end
if current ~= nil then
for i, option in ipairs(options) do if option.id == current then imp._boxPopup.index = i end end
end
if options[imp._boxPopup.index] and options[imp._boxPopup.index].disabled then
for i, option in ipairs(options) do
if not option.disabled then imp._boxPopup.index = i; break end
end
end
end
function UI.help(imp, key, x, y, size, message)
UI.button(imp, x, y, size, size, "help-" .. key, "", function()
UI.open(imp, HELP_TITLES[key] or key, {}, nil)
imp._boxPopup.message = message or UI.HELP[key]
end, { face = "bare", icon = "circle-help" })
end
function UI.dropdown(imp, x, y, w, h, id, label, options, current, apply, icon)
UI.button(imp, x, y, w, h, id, label, function()
UI.open(imp, label, type(options) == "function" and options() or options, current, apply)
end,
{ face = "invert", align = "left", icon = icon or "sliders-horizontal", trailingIcon = "chevron-down" })
end
function UI.values(values, labels, icon)
local options = {}
for i, value in ipairs(values) do options[i] = { id = value, label = labels and labels[value] or tostring(value), icon = icon } end
return options
end
function UI.change(imp, s, key, value, direction)
if s[key] == value then return false end
local old = {}; for k, v in pairs(s) do if k ~= "motionFrom" then old[k] = v end end
s[key] = value; s.motionFrom = old; Kit.blur()
Transition.start("box", "tab", { dir = direction or 1 })
imp._tabScroll = imp._tabScroll or {}; imp._tabScroll.box = 0
return true
end
function UI.pages(imp, s, x, y, w, h, m, draw)
local tr = Transition.get("box")
if not tr or not s.motionFrom then s.motionFrom = nil; return draw(imp, s, x, y, w, h, m) end
local blocked = Kit.blockClicks; Kit.blockClicks = true
Kit.pushClip(x, y, w, math.max(h, 1))
local dir = tr.dir >= 0 and 1 or -1
local oldH, newH
local ok, err = xpcall(function()
oldH = draw(imp, s.motionFrom, x - dir * tr.p * w, y, w, h, m)
newH = draw(imp, s, x + dir * (1 - tr.p) * w, y, w, h, m)
end, debug.traceback)
Kit.popClip(); Kit.blockClicks = blocked
if not ok then error(err, 0) end
return math.max(oldH or 0, newH or 0)
end
local function filtered(p)
if p.filteredQuery ~= p.query then
p.rows = {}
for _, option in ipairs(p.options) do
local fold=require("src.box.Search").normalize
if p.query == "" or fold(option.label):find(fold(p.query), 1, true) then p.rows[#p.rows + 1] = option end
end
p.filteredQuery = p.query
end
return p.rows
end
local function choose(imp, p, option)
if imp._boxPopup ~= p or not option or option.disabled then return false end
UI.close(imp)
if option.action then option.action() elseif p.apply then p.apply(option.id) end
return true
end
function UI.keypressed(imp, key)
local p = imp._boxPopup
if not p then return false end
if key == "escape" then UI.close(imp)
elseif key == "return" or key == "kpenter" then
if p.message then UI.close(imp) else choose(imp, p, filtered(p)[p.index]) end
elseif not p.message and (key == "up" or key == "down" or key == "home" or key == "end") then
local rows = filtered(p)
if key == "home" then p.index = 1 elseif key == "end" then p.index = #rows
else
local step = key == "up" and -1 or 1
for _ = 1, math.max(1, #rows) do
p.index = (p.index - 1 + step) % math.max(1, #rows) + 1
if not (rows[p.index] and rows[p.index].disabled) then break end
end
end
p.reveal = true
elseif Kit.focus == "box-chooser-filter" then Kit.keypressed(key) end
return true
end
function UI.drawPopup(imp, m)
local p = imp._boxPopup
local pad, gap = 16 * m.s, 8 * m.s
local row = math.max(Kit.tapMin(), m.btnH)
local width = math.min((p.message and 390 or 460) * m.s, m.W - 2 * m.pad)
local searchable = not p.message and #p.options > 8
local inner = width - 2 * pad
local content = p.message and Kit.wrapHeight("small", Strings(p.message), inner, 6)
or math.max(row, math.min(7, #p.options) * (row + gap) - gap)
local x, y, w, h = view().modalPanel(m, width, 2 * pad + row + gap + content + (searchable and row + gap or 0), { scrim = .76, slide = true })
if Kit.press(0, 0, m.W, m.H) and not Kit.hit(x, y, w, h) then UI.close(imp); return end
UI.button(imp, x + w - pad - row, y + pad, row, row, "popup-close", "", function() UI.close(imp) end,
{ face = "bare", icon = "x" })
Kit.textBold("button", Strings(Kit.ellipsize("button", p.title, w - 2 * pad - row - gap)), x + pad,
y + pad + (row - Kit.textHeight("button")) / 2, PAL.heading)
local bx, by, bw = x + pad, y + pad + row + gap, w - 2 * pad
if p.message then Kit.textWrapped("small", Strings(p.message), bx, by, bw, PAL.text, 6); return end
if searchable then
local query = Kit.textfield("box-chooser-filter", bx, by, bw, row, p.query, Strings("Find an option"))
if query ~= p.query then p.query, p.index, p.scroll, p.reveal = query, 1, 0, true end
by = by + row + gap
end
local rows = filtered(p)
local bh = math.max(1, y + h - pad - by)
local total = math.max(row, #rows * (row + gap) - gap)
local extent = Kit.scrollExtent(total, bh)
if p.reveal then
local at = (p.index - 1) * (row + gap)
p.scroll = Kit.scrollClamp(math.max(at + row - bh, math.min(p.scroll, at)), extent); p.reveal = nil
end
p.scroll = Kit.scrollInput(p.scroll, extent, bx, by, bw, bh)
local cy = Kit.scrollBegin(bx, by, bw, bh, p.scroll, extent)
local first = math.max(1, math.floor(p.scroll / (row + gap)) + 1)
local last = math.min(#rows, math.ceil((p.scroll + bh) / (row + gap)))
for i = first, last do
local option = rows[i]
local active = p.current ~= nil and option.id == p.current or option.active
UI.button(imp, bx, cy + (i - 1) * (row + gap), bw - Kit.scrollGutter(), row,
"popup-option-" .. i, (option.entry or option.prop) and (" " .. option.label) or option.label, function() choose(imp, p, option) end,
{ face = "selection", align = "left", active = active,
enabled = not option.disabled, icon = not (option.entry or option.prop) and (option.icon or "check") or nil,
trailingIcon = active and "check" or nil, ring = p.index == i })
local iy=cy+(i-1)*(row+gap)
if option.entry then
local art=require("src.box.Catalog").art(option.entry)
if art then require("src.online.OnlineSprites").drawIcon(art,bx+8*m.s,iy+4*m.s,row-8*m.s,0) end
elseif option.prop then require("src.box.Showcase").drawProp(option.prop,bx+8*m.s,iy+4*m.s,row-8*m.s) end
end
if #rows == 0 then Kit.text("small", Strings("Nothing matches."), bx, by, PAL.muted) end
Kit.scrollEnd(bx, by, bw, bh, p.scroll, extent)
end
return UI
+20
View File
@@ -817,6 +817,20 @@ function LauncherSettings.open(hooks, version)
return true
end,
},
{
label = Strings("Showcase Music"),
value = function() return volLabel(opts.boxMusicVol) end,
step = function(dir)
opts.boxMusicVol = stepVolume(opts.boxMusicVol, dir or 1)
require("src.box.Showcase").setVolume(opts.boxMusicVol)
return true
end,
},
{
label = Strings("Showcase Cry"),
value = function() return volLabel(opts.boxCryVol) end,
step = function(dir) opts.boxCryVol = stepVolume(opts.boxCryVol, dir or 1); return true end,
},
},
}
local Window = require("src.import.LauncherWindow")
@@ -831,6 +845,12 @@ function LauncherSettings.open(hooks, version)
select = function(value) Window.observe(0); return Window.apply(value) end,
})
end
if hooks and hooks.openExtras then
table.insert(launcher.rows, 1, {
label = Strings("Extras"), actionLabel = Strings("Open"),
action = function() hooks.openExtras(); return false end,
})
end
local RomSources = require("src.import.RomSources")
local forgotRoms = false
launcher.rows[#launcher.rows + 1] = {
+149 -39
View File
@@ -28,6 +28,7 @@
-- * Layout is explicit pixels off Layout.metrics. No percentages.
local Kit = require("src.ui.kit.Kit")
local Icons = require("src.ui.kit.Icons")
local CartShape = require("src.import.CartShape")
local Theme = require("src.ui.kit.Theme")
local Layout = require("src.ui.kit.Layout")
@@ -48,6 +49,7 @@ local COMMUNITY_URL = "https://bois.icu"
-- synthesizes for the same tap.
local ACT_DEDUP = 0.35
local LONG_PRESS_SECONDS = 0.60
local CONSUMED_TOUCH_MAX = 30
-- Finger travel past this (px) is a drag, not a tap.
local TAP_SLOP2 = 16 * 16
local MIN_SKIN_ROWS = 4
@@ -123,6 +125,9 @@ function LauncherView.detach(imp)
end
Transition.reset()
Kit.occlude(nil)
if imp then
imp._consumedTouch, imp._consumedTouchUntil = nil, nil
end
if imp and imp._themeVideo then
imp._themeVideo:release()
imp._themeVideo = nil
@@ -216,6 +221,7 @@ function LauncherView.update(imp, dt)
-- synthesized echo.
local now = love.timer.getTime()
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
or LauncherView.consumedTouchLive(imp)
if not touching and now >= (imp._suppressMouseUntil or 0)
and now >= (imp._suppressClickUntil or 0) then
local mx, my = love.mouse.getPosition()
@@ -272,6 +278,71 @@ function LauncherView.wheelmoved(imp, dx, dy)
imp._wheelY = (imp._wheelY or 0) + (dy or 0)
end
function LauncherView.consumePress(imp)
imp._prevMouseDown = true
end
function LauncherView.consumeTouch(imp, id)
imp._consumedTouch = imp._consumedTouch or {}
imp._consumedTouch[tostring(id)] = love.timer.getTime()
LauncherView.consumePress(imp)
end
function LauncherView.forgetConsumedTouch(imp, id)
local held = imp._consumedTouch
if not held then return end
if id == nil then
imp._consumedTouch = nil
else
held[tostring(id)] = nil
end
end
local liveTouchIds = {}
local function pruneConsumedTouch(imp)
local held = imp._consumedTouch
if not held or next(held) == nil then return end
local now = love.timer.getTime()
local getTouches = love.touch and love.touch.getTouches
local ids = getTouches and getTouches() or nil
if ids then
for k in pairs(liveTouchIds) do liveTouchIds[k] = nil end
for i = 1, #ids do liveTouchIds[tostring(ids[i])] = true end
end
local dropped = false
for tid, at in pairs(held) do
local age = now - (type(at) == "number" and at or 0)
if age > CONSUMED_TOUCH_MAX
or (ids and not liveTouchIds[tid] and age > ACT_DEDUP) then
held[tid] = nil
dropped = true
end
end
if dropped and next(held) == nil then
imp._consumedTouchUntil = now + ACT_DEDUP
end
end
function LauncherView.releaseConsumedTouch(imp, id)
local held = imp._consumedTouch
local tid = tostring(id)
if not (held and held[tid]) then return false end
held[tid] = nil
local untilT = love.timer.getTime() + ACT_DEDUP
imp._suppressMouseUntil = untilT
imp._consumedTouchUntil = untilT
return true
end
function LauncherView.consumedTouchLive(imp)
pruneConsumedTouch(imp)
if imp._consumedTouch ~= nil and next(imp._consumedTouch) ~= nil then
return true
end
local untilT = imp._consumedTouchUntil
return untilT ~= nil and love.timer.getTime() < untilT
end
function LauncherView.touchpressed(imp, id, x, y)
if not imp._flex then return end
-- Leftover finger from EXIT GAME / Close editor: do not start a tap.
@@ -390,6 +461,7 @@ function LauncherView.mousepressed(imp, x, y)
if not imp._flex then return end
local now = love.timer.getTime()
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
or LauncherView.consumedTouchLive(imp)
if touching or now < (imp._suppressMouseUntil or 0)
or now < (imp._suppressClickUntil or 0) then
return
@@ -1502,11 +1574,10 @@ local GAME_TABS = {
}
local HEADER_TABS = {
{ id = "box", key = "tab-box", glyph = true, beta = true },
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "online", key = "tab-online", glyph = true, beta = true },
{ id = "skins", key = "tab-skins", glyph = true, beta = true },
{ id = "importers", key = "tab-importers", glyph = true },
}
LauncherView.HEADER_TABS = HEADER_TABS
@@ -1523,12 +1594,12 @@ local function overlayBeta(tx, ty, w, tabH, m)
drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh)
end
local TAB_ICONS = { mods = "puzzle", find = "search", online = "globe",
skins = "paintbrush", importers = "download" }
local TAB_LABELS = { mods = "MODS", find = "FIND", online = "ONLINE",
skins = "SKINS", importers = "EXTRA" }
local TAB_ICONS = { box = "package", mods = "puzzle", online = "globe",
skins = "paintbrush" }
local TAB_LABELS = { box = "BOX", mods = "MODS", online = "ONLINE",
skins = "SKINS" }
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", icon = TAB_ICONS[t.id] }
t.opts = { face = "tab", font = "tab", icon = TAB_ICONS[t.id], iconScale = t.id == "box" and 1.15 or nil }
end
local function headerTabMetrics(m)
@@ -1611,7 +1682,10 @@ local function headerChrome(imp)
}
for _, t in ipairs(HEADER_TABS) do
local id = t.id
c.tab[id] = function() imp:_switchTab(id) end
c.tab[id] = function()
local target = id == "mods" and (imp.tab == "find" and "find" or imp._modsInnerTab or "mods") or id
imp:_switchTab(target)
end
end
for _, g in ipairs(GAME_TABS) do
local id = g.id
@@ -1739,15 +1813,12 @@ local function buildHeader(imp, m)
chrome0.game.ring = nil
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
do
local cw = math.floor(7 * m.s)
local size = math.floor(18 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
if love.graphics.polygon then
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
love.graphics.polygon("fill",
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
love.graphics.setColor(1, 1, 1, 1)
end
Icons.draw("chevron-down", ccx - size / 2, ccy - size / 2, size,
gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
love.graphics.setColor(1, 1, 1, 1)
end
Kit.textCenter("micro", Strings("GAMES"), tx, ty + tabH + 3 * m.s, dropW, PAL.muted)
tx = tx + dropW + tabGap
@@ -1759,7 +1830,7 @@ local function buildHeader(imp, m)
ty = ty + tabH + labelH + tabRowGap
end
local o = t.opts
o.active = imp.tab == t.id
o.active = imp.tab == t.id or t.id == "mods" and imp.tab == "find"
o.image = t.icon
o.action = chrome.tab[t.id]
btn(imp, tx, ty, w, tabH, t.key, "", o)
@@ -3827,14 +3898,14 @@ local function modalAmount()
return p
end
local function modalPanel(m, w, h)
local function modalPanel(m, w, h, opts)
-- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark
-- still read through the settings panel and the screen looked like two
-- layouts fighting rather than one panel on top ("the settings is covering
-- the logo"); at this weight the page behind is present but plainly out of
-- play, which is what a modal is supposed to say.
local amt = modalAmount()
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93 * amt)
Theme.fill(0, 0, m.W, m.H, PAL.bg, (opts and opts.scrim or 0.93) * amt)
Kit.blockClicks = true
local pw = math.floor(math.min(w, m.W - 2 * m.pad))
local ph = math.floor(math.min(h, m.H - 2 * m.pad))
@@ -3842,11 +3913,14 @@ local function modalPanel(m, w, h)
local py = math.floor((m.H - ph) / 2)
modalRect.x, modalRect.y, modalRect.w, modalRect.h = px, py, pw, ph
if amt < 1 and not modalTransform and love.graphics and love.graphics.push then
local s = 0.96 + 0.04 * amt
love.graphics.push()
love.graphics.translate(m.W / 2, m.H / 2)
love.graphics.scale(s, s)
love.graphics.translate(-m.W / 2, -m.H / 2)
if opts and opts.slide then love.graphics.translate(0, (1 - amt) * 32 * m.s)
else
local s = 0.96 + 0.04 * amt
love.graphics.translate(m.W / 2, m.H / 2)
love.graphics.scale(s, s)
love.graphics.translate(-m.W / 2, -m.H / 2)
end
modalTransform = true
end
Kit.card(px, py, pw, ph, true)
@@ -3940,7 +4014,7 @@ local function modalFrame(imp, m, key, w, pad, headH, contentH, footH, gap, view
return modalBody(imp, key, x, bodyY, pw - 2 * pad,
footY - (footH > 0 and gap or 0) - bodyY, contentH)
end
return px, py, pw, footY, open
return px, py, pw, footY, open, ph
end
function LauncherView.modalTextW(m, w, pad)
@@ -4089,8 +4163,12 @@ local function buildPrompt(imp, m, spec)
local footH = spec.footnote and (Kit.textHeight("micro")
+ math.floor(8 * m.s)) or 0
local headH = Kit.textHeight("button") + math.floor(10 * m.s)
local px, py, pw, footY, open = modalFrame(imp, m, spec.modal, w, pad,
local px, py, pw, footY, open, ph = modalFrame(imp, m, spec.modal, w, pad,
headH, hintH + fieldH, m.btnH + footH, math.floor(12 * m.s))
if not imp._modalHeld and Kit.press(0, 0, m.W, m.H)
and not Kit.hit(px, py, pw, ph) then
spec.cancel(); return
end
Kit.text("button", Kit.ellipsize("button", spec.title, pw - 2 * pad),
px + pad, py + pad, PAL.heading)
local top, _, mark, done = open()
@@ -5772,9 +5850,9 @@ local function buildSettingsModal(imp, m)
rx - bandW + stepW + gap / 2, ctlY + (m.btnH - Kit.textHeight("small")) / 2,
vw, rowEnabled and PAL.heading or PAL.muted)
end
control(rx - bandW, stepW, key .. "-prev", "‹", {
control(rx - bandW, stepW, key .. "-prev", "", { icon = "chevron-left",
action=function() if row.step and row.step(-1) then model.save() end end})
control(rx - stepW, stepW, key .. "-next", "›", {
control(rx - stepW, stepW, key .. "-next", "", { icon = "chevron-right",
action=function() if row.step and row.step(1) then model.save() end end})
end
end
@@ -6023,7 +6101,7 @@ end
local Sync = {}
Sync.HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. Keep your own backups too."
Sync.HINT = "Cloud sync keeps your game saves, Box collection and mod list on our server so another device can pick them up. Keep your own backups too."
function Sync.title(imp, m, w, pad, fit)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
@@ -6124,6 +6202,7 @@ function LauncherView.syncSideText(meta)
bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " "
.. Strings("seen")
end
if tonumber(summary.boxCount) then bits[#bits + 1] = tostring(summary.boxCount) .. " " .. Strings("Pokémon") end
local when = tonumber(meta.savedAt)
if when then
bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when)
@@ -6137,7 +6216,7 @@ function Sync.conflict(imp, m, eng)
local pad = math.floor(18 * m.s)
local w = Sync.width(m, math.floor(520 * m.s))
local innerW = w - 2 * pad - Kit.scrollGutter()
local lead = row.overlap
local lead = row.box and Strings(row.reviewMessage or "Choose one Box collection and its linked saves. The other collection is kept in a recovery backup.") or row.overlap
and Strings("These saves were played at the same time.")
or Strings("This save also changed on another device.")
local mine = LauncherView.syncSideText(row.localMeta)
@@ -6145,8 +6224,8 @@ function Sync.conflict(imp, m, eng)
local fit = Sync.fit(m,
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
4, 4, {
{ font = "small", str = lead, w = innerW, max = 2 },
row.box and 3 or 4, row.box and 3 or 4, {
{ font = "small", str = lead, w = innerW, max = row.box and 4 or 2 },
{ font = "micro", str = mine, w = innerW, max = 2 },
{ font = "micro", str = theirs, w = innerW, max = 2 },
})
@@ -6171,9 +6250,10 @@ function Sync.conflict(imp, m, eng)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-other",
Strings("Keep the other device"), { kind = "accent",
action = function() imp:_syncResolve(key, "remote") end }, fit)
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-both",
Strings("Keep both"), {
action = function() imp:_syncResolve(key, "both") end }, fit)
if not row.box then
cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-both",
Strings("Keep both"), { action = function() imp:_syncResolve(key, "both") end }, fit)
end
done()
Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
@@ -6312,7 +6392,7 @@ function Sync.home(imp, m, eng)
local linked = eng:linked()
local codes = linked and eng.codes or nil
local body = linked
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
and Strings("This device is linked. Game saves and Box sync when the launcher opens, a few seconds after each save or Box transfer, and every few minutes while the app is running.")
or Strings(Sync.HINT)
local innerW = w - 2 * pad - Kit.scrollGutter()
local codesH = codes
@@ -6479,6 +6559,7 @@ end
-- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below.
local MODAL_KEYS = {
"_boxPrompt", "_boxPopup",
"_profileRenamePrompt", "_profileSavePrompt", "_settingsText",
"_cartSave", "_bugModal", "_settings", "_rename", "_indexPrompt",
"_modConfirm", "_appPatchNotes", "_modReleaseNotes", "_findDetails",
@@ -6495,7 +6576,7 @@ LauncherView.MODAL_KEYS = MODAL_KEYS
local function modalUp(imp)
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
return (imp._settingsText or imp._settings or imp._rename
return (imp._boxPopup or imp._boxPrompt or imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
@@ -6531,6 +6612,18 @@ local function buildModals(imp, m)
Kit.FileBrowser.draw(m)
return true
end
if imp._boxPrompt then
local prompt = imp._boxPrompt
buildPrompt(imp, m, {
key = "boxprompt", modal = "_boxPrompt", title = prompt.title, text = prompt.text,
okLabel = Strings("Save"),
commit = function() require("src.import.BoxPrompt").close(imp, true) end,
cancel = function() require("src.import.BoxPrompt").close(imp, false) end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._boxPopup then require("src.import.BoxUI").drawPopup(imp, m); return true end
if imp._secretPopup then buildSecretModal(imp, m) return true end
if imp._profileRenamePrompt then
buildPrompt(imp, m, {
@@ -6786,10 +6879,25 @@ local function minPanelHeight(m)
end
local function buildTabPanel(imp, x, y, w, availH, budgetH, m)
if imp.tab == "mods" then
return buildModsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "find" then
return buildFindPanel(imp, x, y, w, budgetH, m)
if imp.tab == "box" then
return require("src.import.BoxPanel").draw(imp, x, y, w, budgetH, m)
elseif imp.tab == "mods" or imp.tab == "find" then
local search = imp.tab == "find"
local gap, h = math.floor(8 * m.s), m.btnH
local bw = math.min((w - gap) / 2, math.floor(180 * m.s))
btn(imp, x, y, bw, h, "mods-inner-installed", Strings("Installed"), {
face = "tab", font = "small", icon = "puzzle", active = not search,
action = function() imp:_switchTab("mods") end,
})
btn(imp, x + bw + gap, y, bw, h, "mods-inner-search", Strings("Search"), {
face = "tab", font = "small", icon = "search", active = search,
action = function() imp:_switchTab("find") end,
})
local offset = h + gap
if search then
return offset + buildFindPanel(imp, x, y + offset, w, budgetH - offset, m)
end
return offset + buildModsPanel(imp, x, y + offset, w, budgetH - offset, m)
elseif imp.tab == "skins" then
return buildSkinsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "importers" then
@@ -6955,10 +7063,12 @@ function LauncherView.draw(imp)
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, 0)
end
if imp.tab == "box" then require("src.import.BoxOrganizer").drawSticky(imp, m) end
buildFooter(imp, m, footY)
Kit.blockClicks = Transition.active()
local held = imp._modalHeld
if held then imp[held.key] = held.value end
Toast.occlude(imp)
buildModals(imp, m)
endModalDraw(m)
if held then imp[held.key] = nil end
+233 -28
View File
@@ -1798,6 +1798,8 @@ end
function RomImporter:focus(f)
self._modPickOpenWait = nil
if not f then
require("src.import.LauncherView").forgetConsumedTouch(self)
self._touchAt = nil
self._activeTouch = nil
self._pagePress = nil
self._slotPress = nil
@@ -1867,6 +1869,9 @@ function RomImporter:focus(f)
else
self.modNotice = { ok = false, text = text }
end
elseif self.pickerPendingKind == "box" then
self.pickerPendingKind, self.pickPending = nil, nil
require("src.import.BoxPanel").importGCI(self, nil, text)
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
@@ -1955,6 +1960,11 @@ function RomImporter:focus(f)
end
local savName = findPendingSav(false, self.pickSkip)
if savName then
if self.pickerPendingKind == "box" then
self.pickerPendingKind = nil
consumePick(self, savName, "picked_save.sav", self:_importBoxFile(savName))
return
end
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
@@ -2502,6 +2512,13 @@ function RomImporter:filedropped(file)
-- everything else is treated as a ROM. The dropped file itself is passed
-- through -- installZip opens it the same way readDroppedFile does here.
local name = file:getFilename() or ""
if name:lower():match("%.gci$")
or (self.tab == "box" and name:lower():match("%.sav$")
and file.getSize and file:getSize() == 0x76000) then
self:_switchTab("box")
self:_importBoxFile(file)
return
end
if name:lower():match("%.deltaskin$") then
self:_installSkinZip(file)
return
@@ -2541,6 +2558,61 @@ function RomImporter:filedropped(file)
self:startData(data, file:getFilename(), file:getFilename())
end
function RomImporter:_importBoxFile(source)
local data, why, size
if type(source) == "string" then
local info = love.filesystem.getInfo(source, "file")
size = info and info.size or externalFileSize(source)
if size == 0x76040 or size == 0x76000 then
if info then data, why = love.filesystem.read(source)
else data, why = readExternalPath(source) end
end
else
local opened
opened, why = source:open("r")
if opened then
size = source:getSize()
if size == 0x76040 or size == 0x76000 then data, why = source:read(size) end
source:close()
end
end
if not data then
why = why or "Choose a native Box GCI (0x76040 bytes) or raw save (0x76000 bytes)."
end
return require("src.import.BoxPanel").importGCI(self, data, why)
end
function RomImporter:chooseBoxImport()
if self.workState == "working" then return end
if self.nativePicker and love.system.getPickedFile then
self.pickerPendingKind = "box"
if not pickFile("sav") then
self.pickerPendingKind = nil
require("src.import.BoxPanel").importGCI(self, nil, "Could not open the file picker.")
end
return
end
if self.android then
self.pickerPendingKind = "box"
if pickFile("sav") then self.pickPending, self.pickTimer = true, 0
else
self.pickerPendingKind = nil
require("src.import.BoxPanel").importGCI(self, nil, "Could not open the file picker.")
end
return
end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if self.isNX or self.ios or isHandheld then
require("src.ui.kit.Kit").FileBrowser.open({ title = "Select Pokémon Box save (.gci / .sav)",
mode = "box", onSelect = function(path) self:_importBoxFile(path) end })
return
end
local path = chooseImporterFile("Pokémon Box save", { "gci", "sav" })
if path and path ~= "" then self:_importBoxFile(path) end
end
-- Install a mod .zip from a picker path or a dropped file, then surface the
-- result on the mods panel (switching to it so the notice is visible). The
-- source is whatever LauncherMods.installZip accepts: an absolute path string
@@ -3451,7 +3523,10 @@ function RomImporter:_pollPickedFiles(dt)
if pickError then
love.filesystem.remove("pick_error.txt")
self.pickPending = nil
if self.pickerPendingKind == "cart" then
if self.pickerPendingKind == "box" then
self.pickerPendingKind = nil
require("src.import.BoxPanel").importGCI(self, nil, pickError)
elseif self.pickerPendingKind == "cart" then
self.pickerPendingKind = nil
self.pickerPendingVersion = nil
self._cartNotice = pickError
@@ -3513,6 +3588,7 @@ function RomImporter:update(dt)
if self._flex and not self._inputBlocked then
require("src.import.LauncherView").update(self, dt)
end
if self.tab == "box" then require("src.import.BoxPanel").update(self, dt) end
-- Drive every in-flight async fetch. These are the operations that used to
-- run synchronously inside draw and freeze the window; each pump is a
-- non-blocking channel poll, so a frame with nothing in flight costs
@@ -3703,6 +3779,9 @@ function RomImporter:update(dt)
if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then
os.remove(path)
end
elseif kind == "box" then
local imported = self:_importBoxFile(path)
if Platform.isUWP() and imported then os.remove(path) end
elseif kind == "sav" then
local target = version or self:_savedropTarget()
self:_importSave(target, path)
@@ -3734,6 +3813,8 @@ function RomImporter:update(dt)
self.modNotice = { ok = false, text = errorText }
elseif kind == "skin" then
self._skinNotice = { ok = false, text = errorText }
elseif kind == "box" then
require("src.import.BoxPanel").importGCI(self, nil, errorText)
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
elseif kind == "cart" then
@@ -3886,16 +3967,26 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "mods", "find", "skins", "importers" }
local order = {}
for _, tab in ipairs(require("src.import.LauncherView").HEADER_TABS) do
order[#order + 1] = tab.id
end
local games = SecretGames.order(self)
for i = #games, 1, -1 do
table.insert(order, 1, games[i])
end
local idx = 1
local idx
local current = self.tab == "find" and "mods" or self.tab
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
if id == current then idx = i; break end
end
self:_switchTab(order[((idx - 1 + delta) % #order) + 1])
local target
if idx then
target = order[((idx - 1 + delta) % #order) + 1]
else
target = delta > 0 and order[1] or order[#order]
end
self:_switchTab(target == "mods" and (self._modsInnerTab or "mods") or target)
end
local STICK_NAV_ON = 0.5
@@ -4039,6 +4130,11 @@ function RomImporter:gamepadpressed(_, button)
if action == "a" or action == "start" then self:_confirmSecret() end
return
end
if self._boxPopup and action == "b" then
require("src.import.BoxUI").close(self)
return
end
if okKit and Kit.focus and action == "b" then Kit.blur(); return end
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
if action == "y" or button == "y" then
@@ -4051,6 +4147,10 @@ function RomImporter:gamepadpressed(_, button)
end
-- Select button: toggle Virtual Keyboard on handhelds
if self._boxPopup and not self._padCursorActive and not self.isNX then
local key = ({ a = "return", b = "escape", dpup = "up", dpdown = "down" })[action]
if key then require("src.import.BoxUI").keypressed(self, key); return end
end
if button == "back" or button == "select" or action == "select" then
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
@@ -4062,7 +4162,10 @@ function RomImporter:gamepadpressed(_, button)
Kit.VirtualKeyboard.close(false)
return
end
if self._indexPrompt then
if self._boxPrompt then
require("src.import.BoxPrompt").openKeyboard(self)
return
elseif self._indexPrompt then
Kit.VirtualKeyboard.open({
text = self._indexPrompt.text or "",
title = "Add a mod index",
@@ -4139,6 +4242,10 @@ function RomImporter:gamepadpressed(_, button)
end
-- Shoulder buttons: cycle tabs
if (button == "leftshoulder" or button == "rightshoulder")
and require("src.import.LauncherView").modalKey(self) ~= nil then
return
end
if button == "leftshoulder" then
self:_cycleTab(-1)
return
@@ -4184,7 +4291,8 @@ function RomImporter:gamepadpressed(_, button)
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
if self._boxPrompt then require("src.import.BoxPrompt").close(self, false); return
elseif self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
@@ -4216,7 +4324,8 @@ function RomImporter:gamepadpressed(_, button)
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
if self._boxPrompt then require("src.import.BoxPrompt").close(self, false); return
elseif self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
@@ -4513,34 +4622,60 @@ end
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
-- the overlay hosts. Touch move/press/release must still reach
-- FlexLove.touch* or scroll containers never drag on phones.
function RomImporter:mousepressed(x, y, button)
function RomImporter:mousepressed(x, y, button, istouch)
self._padCursorActive = false
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then Kit.pointerUsed() end
if button ~= 1 or not self._flex then return end
require("src.import.LauncherView").mousepressed(self, x, y)
if istouch or button ~= 1 or not self._flex then return end
local LauncherView = require("src.import.LauncherView")
if LauncherView.consumedTouchLive(self) then
LauncherView.consumePress(self)
return
end
if self.tab == "box" and require("src.import.ShowcaseEditor").pointerPressed(self,"mouse",x,y) then
LauncherView.consumePress(self)
return
end
LauncherView.mousepressed(self, x, y)
end
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then Kit.pointerUsed() end
if not self._flex then return end
require("src.import.LauncherView").forgetConsumedTouch(self, id)
if self.tab == "box" and require("src.import.ShowcaseEditor").pointerPressed(self,id,x,y) then
require("src.import.LauncherView").consumeTouch(self, id)
return
end
require("src.import.LauncherView").touchpressed(
self, id, x, y, dx, dy, pressure)
end
function RomImporter:touchmoved(id, x, y, dx, dy, pressure)
if not self._flex then return end
if self.tab == "box" and require("src.import.ShowcaseEditor").pointerMoved(self,id,x,y) then return end
require("src.import.LauncherView").touchmoved(
self, id, x, y, dx, dy, pressure)
end
function RomImporter:touchreleased(id, x, y, dx, dy, pressure)
local consumed = require("src.import.LauncherView").releaseConsumedTouch(self, id)
if not self._flex then return end
if self.tab == "box" and require("src.import.ShowcaseEditor").pointerReleased(self,id) then return end
if consumed then return end
require("src.import.LauncherView").touchreleased(
self, id, x, y, dx, dy, pressure)
end
function RomImporter:mousemoved(x,y,_,_,istouch)
if istouch then return end
if self.tab=="box" then return require("src.import.ShowcaseEditor").pointerMoved(self,"mouse",x,y) end
end
function RomImporter:mousereleased(_,_,button,istouch)
if button==1 and not istouch and self.tab=="box" then return require("src.import.ShowcaseEditor").pointerReleased(self,"mouse") end
end
-- Switch the active tab (chips, shoulder buttons). The find search caret and
-- the soft keyboard drop with the panel they belonged to; each tab's scroll
-- offset persists inside the view's per-tab scroll container.
@@ -4657,14 +4792,36 @@ end
function RomImporter:_switchTab(id)
if id == "bug" then return self:_openBugPanel() end
if GameVersion.VERSIONS[id] and not SecretGames.shown(self, id) then return end
if self.tab == id and id == "box" and self._boxState then return end
local tabTransition
if self.tab and self.tab ~= id then
local at = tabOrder()
Transition.start("tabs", "tab", {
dir = ((at[id] or 0) >= (at[self.tab] or 0)) and 1 or -1,
local from = self.tab == "find" and "mods" or self.tab
local to = id == "find" and "mods" or id
local dir = from == "mods" and to == "mods" and (id == "find" and 1 or -1)
or ((at[to] or 0) >= (at[from] or 0) and 1 or -1)
tabTransition = {
dir = dir,
from = self.tab, to = id,
})
}
end
if self.tab == "box" then
require("src.import.BoxUI").close(self)
Transition.clear("box")
require("src.import.BoxPrompt").close(self, false)
require("src.box.Cry").stop()
if self._boxState then
require("src.box.Animation").release(self._boxState.animation)
self._boxState.animation, self._boxState.animationEntry = nil, nil
end
require("src.box.Showcase").stopMusic()
require("src.ui.kit.Kit").blur()
if self._boxState then self._boxState.moving = nil end
require("src.import.LauncherView").forgetConsumedTouch(self)
end
self.tab = id
if id == "mods" or id == "find" then self._modsInnerTab = id end
if id == "box" then require("src.import.BoxPanel").refresh(self) end
if id ~= "find" then self._findVisibleEntries = nil end
self._findSearchFocus = false
self._skinUrlFocus = false
@@ -4678,6 +4835,7 @@ function RomImporter:_switchTab(id)
self:_setModScope(id)
self:_queueReimport(id)
end
if tabTransition then Transition.start("tabs", "tab", tabTransition) end
end
-- ------- skins tab (touch skins + the desktop Skin Studio)
@@ -5025,6 +5183,8 @@ function RomImporter:_pumpSync(dt)
self:_syncNoteDownload(row)
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 eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
if not self._syncModal and not self._syncConflictShown then
self._syncConflictShown = true
@@ -5036,6 +5196,16 @@ function RomImporter:_pumpSync(dt)
end
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."
elseif self._boxState then
require("src.import.BoxPrompt").close(self, false)
require("src.import.BoxPanel").refresh(self)
end
return
end
local version = type(row) == "table" and row.version
if type(version) ~= "string" or not GameVersion.VERSIONS[version] then return end
local cart = type(row.cart) == "string" and row.cart ~= "" and row.cart or nil
@@ -5277,6 +5447,10 @@ function RomImporter:_openSettings()
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
hooks.openExtras = function()
self:_closeSettings()
self:_switchTab("importers")
end
local version = self.tab
if self.onEditTouchControls then
local version = self.tab
@@ -5603,6 +5777,16 @@ function RomImporter:keypressed(key)
if Kit.VirtualKeyboard.keypressed(key) then return end
end
end
if self._boxPrompt then
local prompt = self._boxPrompt
if key == "backspace" then prompt.text = utf8Back(prompt.text)
elseif key == "return" or key == "kpenter" then require("src.import.BoxPrompt").close(self, true)
elseif key == "escape" then require("src.import.BoxPrompt").close(self, false)
end
return
end
if self._boxPopup and require("src.import.BoxUI").keypressed(self, key) then return end
if self.tab == "box" and require("src.import.BoxPanel").keypressed(self, key) then return end
if self._secretPopup then
if key == "return" or key == "kpenter" or key == "space" then
self:_confirmSecret()
@@ -6446,7 +6630,7 @@ function RomImporter:askIdSync(version)
end
if not slot then
self._idSyncResult = { title = Strings("ID Sync"),
body = Strings("Select a save for this game first. Its trainer ID is the one every other save takes.") }
body = Strings("Pick your save first. Every save will use its trainer name and ID.") }
return
end
local SaveData = require("src.core.SaveData")
@@ -6457,16 +6641,19 @@ function RomImporter:askIdSync(version)
return SaveData.readSlotSource(scope, slot.id)
end)
local save = okRead and source and SaveData.decode(source) or nil
local id = save and TrainerIdSync.playerIdOf(save,
TrainerIdSync.generationOf(save, version)) or nil
local who = slot.label or slot.name or slot.id
local profile = save and require("src.core.TrainerIdentity").profile(save,
TrainerIdSync.generationOf(save, version))
if not profile then
self._idSyncResult = { title = Strings("ID Sync"), body = Strings("This save needs a trainer name and ID before it can sync.") }
return
end
self._modConfirm = {
title = Strings("ID Sync"),
yesLabel = Strings("Sync"),
lines = {
Strings("Sync trainer ID with all other saves?"),
id and Strings("Every save takes %s's ID No. %05d. Pokemon caught in any of your saves, or traded between them, are synced too.", who, id)
or Strings("Every save takes %s's ID. Pokemon caught in any of your saves, or traded between them, are synced too.", who),
Strings("Use %s · ID %05d across your saves?", profile.name, profile.id),
Strings("Your Pokemon get the same OT name and ID, including the secret ID in Gen 3. Party, PC, daycare and launcher Box are included."),
Strings("Shiny stays shiny. Personality only changes if needed; a Spinda's spots can change with it."),
},
onYes = function() self:_startIdSync(scope, slot.id) end,
}
@@ -6480,7 +6667,7 @@ function RomImporter:_startIdSync(scope, slotId)
return
end
self._idSync = job
self._busy = { title = Strings("Syncing trainer ID"), detail = "", progress = 0 }
self._busy = { title = Strings("Syncing trainer identity"), detail = "", progress = 0 }
end
function RomImporter:_pumpIdSync()
@@ -6493,22 +6680,28 @@ function RomImporter:_pumpIdSync()
local n = math.min(job.at, #job.entries)
self._busy.detail = job.phase == "read"
and Strings("Reading saves %d / %d", n, #job.entries)
or Strings("Updating saves %d / %d", n, #job.entries)
or Strings("Preparing saves %d / %d", n, #job.entries)
end
return
end
self._idSync = nil
self:_clearBusy()
self.slots = {}
self._boxState = nil
if not ok or job.error then
self._idSyncResult = { title = Strings("ID Sync failed"), body = tostring(ok and job.error or done) }
return
end
local body = Strings("Trainer ID %05d is now on every save. %d save(s) updated.", job.newId, job.written)
if #job.failed > 0 then
body = body .. "\n\n" .. Strings("%d save(s) could not be written: %s", #job.failed, job.failed[1])
local body = Strings("%s · ID %05d is now on your saves. %d save(s) updated.", job.target.name, job.newId, job.written)
if job.boxUpdated then body = body .. "\n" .. Strings("Launcher Box updated too.") end
if job.report.personalities > 0 then
body = body .. "\n" .. Strings("Shiny status kept. %d personality value(s) adjusted.", job.report.personalities)
end
local eng = job.written > 0 and self:_syncEngine() or nil
if job.report.spinda > 0 then body = body .. "\n" .. Strings("%d Spinda spot pattern(s) changed.", job.report.spinda) end
if job.report.unknown > 0 then
body = body .. "\n" .. Strings("%d record(s) had no OT name, so their ownership couldn't be checked.", job.report.unknown)
end
local eng = (job.written > 0 or job.boxUpdated) and self:_syncEngine() or nil
if eng and eng.state.enabled and eng:linked() then
if eng:busy() then
eng:noteSaveWritten()
@@ -6579,13 +6772,15 @@ function RomImporter:playArena(version, cartId, spec)
end
function RomImporter:_blurPanelFields()
local Kit = require("src.ui.kit.Kit")
if Kit.focus == "box-search" then Kit.blur() end
if self._pcPicker or self._pinModal then return end
if self._onlineFocus then
self:_commitOnlineField()
return
end
if not (self._findSearchFocus or self._skinUrlFocus) then return end
if self._indexPrompt or self._rename or self._settingsText
if self._boxPrompt or self._indexPrompt or self._rename or self._settingsText
or self._profileSavePrompt or self._profileRenamePrompt
or (self._syncModal and self._syncFocus) then
return
@@ -6622,6 +6817,16 @@ end
function RomImporter:textinput(text)
if self._hostPick then return end
if self.tab == "box" then
local Kit = require("src.ui.kit.Kit")
if Kit.VirtualKeyboard.active and Kit.VirtualKeyboard.textinput(text) then return end
if self._boxPrompt then
local prompt = self._boxPrompt
prompt.text = utf8Cap(prompt.text .. text, prompt.maxLen)
return
end
if require("src.import.BoxPanel").textinput(self, text) then return end
end
if self._syncModal and self._syncFocus then
self:_syncTypeInto(self._syncFocus, text)
return
+130
View File
@@ -0,0 +1,130 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local UI = require("src.import.BoxUI")
local Store = require("src.box.Store")
local Showcase = require("src.box.Showcase")
local Editor = {}
local PAL = Theme.PAL
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
local function angle(x, y) return math.atan2(y, x) end
local function wrap(v) return (v + math.pi) % (2 * math.pi) - math.pi end
local function record(s)
s.stageUndo = s.stageUndo or {}
s.stageUndo[#s.stageUndo + 1] = Store.copy(s.stageDraft)
if #s.stageUndo > 20 then table.remove(s.stageUndo, 1) end
end
local function start(s, kind, index, mx, my, rect)
local p = s.stageDraft.pieces[index]
if not p then s.stageGesture = nil; return end
s.stageGesture = { kind = kind, x = mx, y = my, px = p.x, py = p.y,
scale = p.scale, rotation = p.rotation, index = index,
cx = rect.x + p.x * rect.w, cy = rect.y + p.y * rect.h,
draft = s.stageDraft, stage = s.stageIndex }
end
function Editor.begin(s, kind, mx, my, rect)
if not s.stageDraft.pieces[s.stagePiece] then return end
start(s, kind, s.stagePiece, mx, my, rect)
end
local function current(s, mx, my, rect)
local t = s.stageGesture
if not t or t.draft == s.stageDraft then return t end
if t.stage ~= s.stageIndex then s.stageGesture = nil; return nil end
start(s, t.kind, t.index, mx, my, rect)
return s.stageGesture
end
function Editor.move(s, mx, my, rect)
local t = current(s, mx, my, rect)
local p = t and s.stageDraft.pieces[t.index]
if not p then return end
local x, y, scale, rotation = p.x, p.y, p.scale, p.rotation
if t.kind == "move" then
x, y = clamp(t.px + (mx - t.x) / rect.w, 0, 1), clamp(t.py + (my - t.y) / rect.h, 0, 1)
elseif t.kind == "resize" then
scale = clamp(t.scale * math.exp((mx - t.x - my + t.y) / math.max(70, rect.w * .2)), .25, 4)
elseif t.kind == "rotate" then
rotation = wrap(t.rotation + angle(mx - t.cx, my - t.cy) - angle(t.x - t.cx, t.y - t.cy))
end
if x == p.x and y == p.y and scale == p.scale and rotation == p.rotation then return end
if not t.recorded then record(s); t.recorded = true end
p.x, p.y, p.scale, p.rotation = x, y, scale, rotation
end
function Editor.finish(s) s.stageGesture = nil end
function Editor.pointerPressed(imp,id,x,y)
local s=imp._boxState
if imp.tab~="box" or not s or s.toolsPage~="Showcase" or not s.stageRect
or imp._boxPopup or imp._modalUpNow or Kit.blockClicks then return false end
local function inside(r)
local mx,my=Kit.mouseX,Kit.mouseY
Kit.mouseX,Kit.mouseY=x,y
local hit=Kit.hit(r.x,r.y,r.w,r.h)
Kit.mouseX,Kit.mouseY=mx,my
return hit
end
if imp._tabRegionRect and not inside(imp._tabRegionRect) then return false end
for _,handle in ipairs(s.stageHandles or {}) do
if inside(handle) then
if handle.kind=="resize" or handle.kind=="rotate" then
Editor.begin(s,handle.kind,x,y,s.stageRect);s._stagePointer=id
else handle.action() end
return true
end
end
if not inside(s.stageRect) then return false end
local r=s.stageRect
s.stagePiece=Showcase.hit(s.stageDraft,r.x,r.y,r.w,r.h,x,y)
if s.stagePiece then Editor.begin(s,"move",x,y,r);s._stagePointer=id end
return true
end
function Editor.pointerMoved(imp,id,x,y)
local s=imp._boxState
if not s or s._stagePointer~=id then return false end
Editor.move(s,x,y,s.stageRect)
return true
end
function Editor.pointerReleased(imp,id)
local s=imp._boxState
if not s or s._stagePointer~=id then return false end
Editor.finish(s);s._stagePointer=nil
return true
end
function Editor.draw(imp, s, x, y, w, h, m, edit)
local rect = { x = x, y = y, w = w, h = h }
Showcase.draw(s.service.state, s.stageDraft, x, y, w, h)
s.stageRect = rect
s.stageHandles = {}
local p = s.stageDraft.pieces[s.stagePiece or 0]
local toolbar
if p then
local size = math.min(w / 8, h / 2) * p.scale
local cx, cy = x + p.x * w, y + p.y * h
Theme.strokeRounded(cx - size / 2 - 4, cy - size / 2 - 4, size + 8, size + 8, PAL.blue, 1, 2, 6)
local bh, gap = math.max(Kit.tapMin(), 30 * m.s), 3 * m.s
local tw = 5 * bh + 4 * gap
local tx, ty = clamp(cx - tw / 2, x + 4, math.max(x + 4, x + w - tw - 4)),
clamp(cy + size / 2 + 10, y + 4, math.max(y + 4, y + h - bh - 4))
toolbar = { x = tx, y = ty, w = tw, h = bh }
local actions = {
{ "resize", "expand", function() Editor.begin(s, "resize", Kit.mouseX, Kit.mouseY, rect) end },
{ "flip-h", "arrow-left-right", function() edit(function() p.flip = not p.flip end) end },
{ "flip-v", "arrow-up-down", function() edit(function() p.flipY = not p.flipY end) end },
{ "rotate", "rotate-ccw", function() Editor.begin(s, "rotate", Kit.mouseX, Kit.mouseY, rect) end },
{ "fine", "sliders-horizontal", function() s.stageSection = "Placement"; s.stageFine = true end },
}
for index, action in ipairs(actions) do
s.stageHandles[index]={x=tx+(index-1)*(bh+gap),y=ty,w=bh,h=bh,kind=action[1],action=action[3]}
UI.button(imp, tx + (index - 1) * (bh + gap), ty, bh, bh, "stage-handle-" .. action[1], "", action[3],
{ icon = action[2], face = "invert" })
end
end
if not Kit.blockClicks and not imp._boxPopup then
if s.stageGesture then current(s, Kit.mouseX, Kit.mouseY, rect) end
if s.stageGesture and not s._stagePointer then
if Kit.mouseDown then Editor.move(s, Kit.mouseX, Kit.mouseY, rect) else Editor.finish(s) end
elseif Kit.press(x, y, w, h) and not (toolbar and Kit.hit(toolbar.x, toolbar.y, toolbar.w, toolbar.h)) then
local selected = Showcase.hit(s.stageDraft, x, y, w, h, Kit.mouseX, Kit.mouseY)
s.stagePiece = selected
if selected then Editor.begin(s, "move", Kit.mouseX, Kit.mouseY, rect) end
end
end
end
return Editor
+1 -2
View File
@@ -18,10 +18,9 @@ end
function Toast.occlude(imp)
local rect = type(imp) == "table" and imp._toastRect or nil
Kit.occlude(nil)
if rect and rect.shown and Toast.current(imp) then
Kit.occlude(rect[1], rect[2], rect[3], rect[4])
else
Kit.occlude(nil)
end
end
+4 -4
View File
@@ -109,10 +109,10 @@ end
function Ui.chooser(imp, x, y, w, h, key, text, onPrev, onNext)
local bw = math.max(Kit.tapMin(), math.floor(28 * Kit.scale))
local gap = math.floor(4 * Kit.scale)
LV().btn(imp, x, y, bw, h, key .. "-prev", "<",
{ kind = "ghost", font = "small", action = onPrev })
LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", ">",
{ kind = "ghost", font = "small", action = onNext })
LV().btn(imp, x, y, bw, h, key .. "-prev", "",
{ kind = "ghost", icon = "chevron-left", action = onPrev })
LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", "",
{ kind = "ghost", icon = "chevron-right", action = onNext })
local inner = math.max(0, w - 2 * bw - 2 * gap)
Kit.textCenter("small", Kit.ellipsize("small", text, inner),
x + bw + gap, y + (h - Kit.textHeight("small")) / 2, inner, PAL.heading)
+8 -3
View File
@@ -266,7 +266,7 @@ local function restore(saved)
end
end
function OnlineSprites.drawIcon(entry, x, y, size)
function OnlineSprites.drawIcon(entry, x, y, size, frame)
if type(entry) ~= "table" or not entry.icon then return false end
local img = entry.icon
if type(img.getDimensions) ~= "function" then return false end
@@ -277,8 +277,13 @@ function OnlineSprites.drawIcon(entry, x, y, size)
local quad = entry.iconQuadFor == img and entry.iconQuad or nil
if entry.iconW and entry.iconH then
local fw, fh = math.min(entry.iconW, iw), math.min(entry.iconH, ih)
quad = quad or love.graphics.newQuad(0, 0, fw, fh, iw, ih)
entry.iconQuad, entry.iconQuadFor = quad, img
local frames = math.max(1, math.floor(ih / fh))
local index = math.floor(tonumber(frame) or 0) % frames
if entry.iconQuadFor ~= img then entry.iconQuads = {} end
entry.iconQuads = entry.iconQuads or {}
quad = entry.iconQuads[index] or love.graphics.newQuad(0, index * fh, fw, fh, iw, ih)
entry.iconQuads[index] = quad
entry.iconQuadFor = img
local s = (size or fw) / math.max(fw, fh)
love.graphics.draw(img, quad, x, y, 0, s, s)
elseif ih > 16 and entry.mirror then
+9
View File
@@ -375,6 +375,15 @@ local function monFp(mon)
return table.concat(parts, ",")
end
function Gen2Save.rewriteCarrierId(mon, id)
local raw = mon.cartRaw
if type(raw) ~= "string" or #raw < 16 then mon.otId = id; return end
local unchanged = mon.cartRawFp == nil or mon.cartRawFp == monFp(mon)
mon.cartRaw = raw:sub(1, 12) .. ("%04X"):format(id) .. raw:sub(17)
mon.otId = id
if unchanged then mon.cartRawFp = monFp(mon) end
end
-- The first 32 bytes, which a box mon and a party mon share.
local function decodeSharedMon(t, o, x, crystal, size)
local moves, slots, gap, emptyPp = {}, {}, false, nil
+119 -11
View File
@@ -31,6 +31,12 @@ local function build(L)
Gen3Save.MSG.emerald = "That is an Emerald save, not Ruby/Sapphire."
end
Gen3Save.L = L
local BOX_FLAGS_OFFSET = L.FAMILY == "rs" and 0x312F or L.FAMILY == "emerald" and 0x31C7 or 0x30BB
Gen3Save.BOX_FLAGS_OFFSET = BOX_FLAGS_OFFSET
function Gen3Save.boxFlagsOf(save)
return require("src.core.game3.save_mon").boxFlags(save)
end
local U32 = 4294967296
@@ -75,8 +81,31 @@ local function build(L)
return REVERSE
end
function Gen3Save.decodeString(s, off, len)
local JAPANESE, JAPANESE_REVERSE
local function japanese()
if not JAPANESE then
JAPANESE, JAPANESE_REVERSE = {}, {}
for ch, code in pairs(require("src.ui.game3.frlg_font").JAPANESE_GLYPHS) do
if JAPANESE[code] == nil or ch < JAPANESE[code] then JAPANESE[code] = ch end
JAPANESE_REVERSE[ch] = code
end
end
return JAPANESE, JAPANESE_REVERSE
end
local function latin(c, kana) return c == 0x00 or c >= 0xA1 and c <= 0xAA or c >= 0xB1 or not kana and c >= 0xAB end
function Gen3Save.decodeString(s, off, len, language, fullwidth)
local map, out = charmap(), {}
local jp = language == L.LANGUAGE_JAPANESE and japanese() or nil
local kana = false
if jp then
for k = 0, len - 1 do
local c = u8(s, off + k)
if c == 0xFF then break end
if c >= 0x01 and c <= 0xA0 then kana = true; break end
end
end
local i = 0
while i < len do
local c = u8(s, off + i)
@@ -86,18 +115,19 @@ local function build(L)
out[#out + 1] = sym
i = i + 2
else
out[#out + 1] = map[c] or string.format("{%02X}", c)
out[#out + 1] = jp and (fullwidth or not latin(c, kana) or not map[c]) and jp[c] or map[c] or string.format("{%02X}", c)
i = i + 1
end
end
return table.concat(out)
end
function Gen3Save.encodeString(str, len, pad)
function Gen3Save.encodeString(str, len, pad, language)
local r, out, i = reverse(), {}, 1
local jr = language == L.LANGUAGE_JAPANESE and select(2, japanese()) or nil
str = str or ""
local function put(tok)
local code = r[tok]
local code = jr and jr[tok] or r[tok]
if code then
out[#out + 1] = code
elseif EXTRA_REVERSE[tok] and #out + 2 <= len then
@@ -130,6 +160,56 @@ local function build(L)
return table.concat(t)
end
local function rawName(x, key)
return type(x) == "table" and type(x[key]) == "table" and string.char(unpack(x[key])) or nil
end
local function eggOf(mon)
if mon.isEgg == true or mon.isEggFlag == true then return true end
local flags = type(mon.cartExtra) == "table" and tonumber(mon.cartExtra.flagsRaw) or nil
return flags ~= nil and band(flags, lshift(1, L.BOX_MON_FLAGS.isEgg)) ~= 0
end
function Gen3Save.repairJapaneseNames(mon)
if type(mon) ~= "table" or tonumber(mon.language) ~= L.LANGUAGE_JAPANESE then return false end
local B, changed = L.BOX_MON, false
if eggOf(mon) then
local current = mon.otName
if type(current) ~= "string" or not current:find("[\128-\255]") then return false end
local len = B.otNameLength
local bytes = rawName(mon.cartExtra, "otNameRaw")
if not bytes or Gen3Save.decodeString(bytes, 0, len, L.LANGUAGE_JAPANESE, true) ~= current then
bytes = Gen3Save.encodeString(current, len, 0xFF, L.LANGUAGE_JAPANESE)
if Gen3Save.decodeString(bytes, 0, len, L.LANGUAGE_JAPANESE, true) ~= current then return false end
end
local fixed = Gen3Save.decodeString(bytes, 0, len)
if fixed == current or fixed:find("{%x%x}") then return false end
for _, k in ipairs({ "otName", "ot" }) do
if mon[k] == current then mon[k] = fixed end
end
return true
end
local function fix(keys, rawKey, len)
local current = mon[keys[1]]
if type(current) ~= "string" or current == "" then return end
local bytes = rawName(mon.cartExtra, rawKey)
if not bytes or Gen3Save.decodeString(bytes, 0, len) ~= current then
if not current:find("{%x%x}") then return end
bytes = Gen3Save.encodeString(current, len, 0xFF)
if Gen3Save.decodeString(bytes, 0, len) ~= current then return end
end
local fixed = Gen3Save.decodeString(bytes, 0, len, L.LANGUAGE_JAPANESE)
if fixed == current then return end
for _, k in ipairs(keys) do
if mon[k] == current then mon[k] = fixed end
end
changed = true
end
if not mon.isEgg then fix({ "nickname", "name" }, "nicknameRaw", B.nicknameLength) end
fix({ "otName", "ot" }, "otNameRaw", B.otNameLength)
return changed
end
-- src/save.c:614
function Gen3Save.checksum(s, off, size)
local sum = 0
@@ -282,6 +362,7 @@ local function build(L)
local B = L.BOX_MON
local personality, otId = u32(raw, B.personality), u32(raw, B.otId)
local flags = u8(raw, B.flags)
local language = u8(raw, B.language)
local sec = decryptSecure(raw, personality, otId)
local pos = L.SUBSTRUCT_ORDER[personality % 24]
local g, a, e, m = pos[1] * L.SUBSTRUCT_SIZE, pos[2] * L.SUBSTRUCT_SIZE, pos[3] * L.SUBSTRUCT_SIZE, pos[4] * L.SUBSTRUCT_SIZE
@@ -290,13 +371,12 @@ local function build(L)
otIdRaw = otId,
otId = otId % 65536,
otSecretId = math.floor(otId / 65536),
nickname = Gen3Save.decodeString(raw, B.nickname, B.nicknameLength),
language = u8(raw, B.language),
nickname = Gen3Save.decodeString(raw, B.nickname, B.nicknameLength, language),
language = language,
flagsRaw = flags,
isBadEgg = band(flags, lshift(1, L.BOX_MON_FLAGS.isBadEgg)) ~= 0,
hasSpecies = band(flags, lshift(1, L.BOX_MON_FLAGS.hasSpecies)) ~= 0,
isEggFlag = band(flags, lshift(1, L.BOX_MON_FLAGS.isEgg)) ~= 0,
otName = Gen3Save.decodeString(raw, B.otName, B.otNameLength),
markings = u8(raw, B.markings),
checksum = u16(raw, B.checksum),
unknown = u16(raw, B.unknown),
@@ -325,6 +405,9 @@ local function build(L)
mon.ivs = {}
for i, k in ipairs(L.IV_KEYS) do mon.ivs[k] = bits(ivw, (i - 1) * 5, 5) end
mon.isEgg = bits(ivw, L.IV_EGG_BIT, 1) == 1
-- pokeemerald/src/daycare.c:866
mon.otName = Gen3Save.decodeString(raw, B.otName, B.otNameLength,
not (mon.isEgg or mon.isEggFlag) and language or nil)
mon.abilityNum = bits(ivw, L.IV_ABILITY_BIT, 1)
local rib = u32(sec, m + S3.ribbons)
mon.ribbons = rib
@@ -437,10 +520,10 @@ local function build(L)
if mon.nicknameBytes then
out:fill(B.nickname, B.nicknameLength, 0xFF)
out:bytes(B.nickname, mon.nicknameBytes:sub(1, B.nicknameLength))
elseif mon.nicknameRaw and Gen3Save.decodeString(mon.nicknameRaw, 0, B.nicknameLength) == mon.nickname then
elseif mon.nicknameRaw and Gen3Save.decodeString(mon.nicknameRaw, 0, B.nicknameLength, mon.language) == mon.nickname then
out:bytes(B.nickname, mon.nicknameRaw)
else
out:bytes(B.nickname, Gen3Save.encodeString(mon.nickname, B.nicknameLength, 0xFF))
out:bytes(B.nickname, Gen3Save.encodeString(mon.nickname, B.nicknameLength, 0xFF, mon.language))
end
out:w8(B.language, mon.language or 2)
local F = L.BOX_MON_FLAGS
@@ -448,10 +531,16 @@ local function build(L)
+ (mon.isBadEgg and 2 ^ F.isBadEgg or 0) + 2 ^ F.hasSpecies
+ ((mon.isEggFlag or mon.isEgg) and 2 ^ F.isEgg or 0)
out:w8(B.flags, flags)
if mon.otNameRaw and Gen3Save.decodeString(mon.otNameRaw, 0, B.otNameLength) == mon.otName then
local otLanguage = not (mon.isEggFlag or mon.isEgg) and mon.language or nil
if mon.otNameRaw and Gen3Save.decodeString(mon.otNameRaw, 0, B.otNameLength, otLanguage) == mon.otName then
out:bytes(B.otName, mon.otNameRaw)
else
out:bytes(B.otName, Gen3Save.encodeString(mon.otName, B.otNameLength, 0xFF))
local bytes = Gen3Save.encodeString(mon.otName, B.otNameLength, 0xFF, otLanguage)
if not otLanguage and Gen3Save.decodeString(bytes, 0, B.otNameLength) ~= mon.otName then
local jp = Gen3Save.encodeString(mon.otName, B.otNameLength, 0xFF, L.LANGUAGE_JAPANESE)
if Gen3Save.decodeString(jp, 0, B.otNameLength, L.LANGUAGE_JAPANESE, true) == mon.otName then bytes = jp end
end
out:bytes(B.otName, bytes)
end
out:w8(B.markings, mon.markings or 0)
out:w16(B.checksum, secureChecksum(plain))
@@ -694,6 +783,7 @@ local function build(L)
for _, f in ipairs(L.OPTIONS_BITS) do out.options[f[1]] = bits(out.optionsWord, f[2], f[3]) end
out.options.buttonMode = out.buttonMode
Gen3Save.readFields(sb1, L.SB1, out, key)
out.boxFlags = u8(sb1, BOX_FLAGS_OFFSET)
out.party = {}
for i = 0, math.min(out.partyCount, L.PARTY_SIZE) - 1 do
local o = L.PARTY_OFFSET + i * L.PARTY_MON_SIZE
@@ -848,6 +938,7 @@ local function build(L)
end
fields.partyCount = #(t.party or {})
Gen3Save.writeFields(sb1, L.SB1, fields, key)
if t.boxFlags ~= nil then sb1:w8(BOX_FLAGS_OFFSET, t.boxFlags) end
sb1:fill(L.PARTY_OFFSET, L.PARTY_SIZE * L.PARTY_MON_SIZE, 0)
for i = 0, L.PARTY_SIZE - 1 do
-- src/pokemon.c:1737
@@ -1355,6 +1446,7 @@ local function build(L)
fameChecker = #fame > 0 and fame or nil,
trainerTower = tower,
cartImport = {
boxFlags = c.boxFlags,
dexSeen = nationalList(seenList),
dexOwned = nationalList(ownedList),
lastHealLocation = portWarp(c.lastHealLocation),
@@ -2103,6 +2195,8 @@ local function build(L)
c.trainerTowerBest = tplTower
end
c.encryptionKey = key
local boxFlags = Gen3Save.boxFlagsOf(save)
if boxFlags ~= nil then c.boxFlags = boxFlags end
return c, blocks
end
@@ -2273,6 +2367,20 @@ function M.sniff(bytes)
return "emerald"
end
function M.repairJapaneseNames(root)
local codec, seen, changed = M.forVersion("emerald"), {}, false
local function walk(t)
if seen[t] then return end
seen[t] = true
if type(t.personality) == "number" and codec.repairJapaneseNames(t) then changed = true end
for _, v in pairs(t) do
if type(v) == "table" then walk(v) end
end
end
if type(root) == "table" then walk(root) end
return changed
end
M.FAMILY_NAMES = { frlg = "FireRed/LeafGreen", emerald = "Emerald", rs = "Ruby/Sapphire" }
function M.sniffMessage(bytes)
+2
View File
@@ -653,6 +653,8 @@ function Rse.writeSections(codec, encoded, template, save, sections)
end
end
end
local boxFlags = codec.boxFlagsOf and codec.boxFlagsOf(save)
if boxFlags ~= nil and codec.BOX_FLAGS_OFFSET then w1:w8(codec.BOX_FLAGS_OFFSET, boxFlags) end
return { sb2 = w2:str(), sb1 = w1:str(), storage = encoded.storage, notes = notes,
nativeRecordMixMail = codec.L.FAMILY == "emerald" and hasNativeMixBytes(save.mail) }
end
+4 -2
View File
@@ -66,7 +66,8 @@ local EXTRA = {
{ "sb1", 0x2F58, 0x28, "T2", "dewfordTrends", "G3-05", { kind = "capability", name = "dewfordTrend", set = "FRLG" } },
{ "sb1", 0x309C, 0xB, "T2", "giftRibbons", "G3-05", { kind = "capability", name = "ribbons", set = "FRLG" } },
{ "sb1", 0x30A7, 0x14, "T2", "externalEventData", "G3-05", { kind = "absent", patterns = { "externalEvent" } } },
{ "sb1", 0x30BB, 0x15, "T2", "externalEventFlags", "G3-05", { kind = "absent", patterns = { "externalEvent" } } },
{ "sb1", 0x30BB, 0x1, "T1", "boxFlags", nil },
{ "sb1", 0x30BC, 0x14, "T2", "externalEventFlagsRest", "G3-05", { kind = "absent", patterns = { "externalEvent" } } },
{ "sb1", 0x30E4, 0x8, "T2", "roamerFiller", "G3-05", { kind = "padding" } },
{ "sb1", 0x30EC, 0x34, "T2", "enigmaBerry", "G3-05", { kind = "absent", patterns = { "enigmaBerry%s*=", "session%.enigma", "save%.enigma" } } },
{ "sb1", 0x3120, 0x36C, "T1", "mysteryGift", nil },
@@ -125,7 +126,8 @@ local EXTRA = {
{ "sb1", 0x3150, 0x58, "T1", "linkBattleRecords", nil },
{ "sb1", 0x31A8, 0xB, "T1", "giftRibbons", nil },
{ "sb1", 0x31B3, 0x14, "T2", "externalEventData", "G3-05", { kind = "absent", patterns = { "externalEvent" } } },
{ "sb1", 0x31C7, 0x15, "T2", "externalEventFlags", "G3-05", { kind = "absent", patterns = { "externalEvent" } } },
{ "sb1", 0x31C7, 0x1, "T1", "boxFlags", nil },
{ "sb1", 0x31C8, 0x14, "T2", "externalEventFlagsRest", "G3-05", { kind = "absent", patterns = { "externalEvent" } } },
{ "sb1", 0x31F0, 0x8, "T2", "roamerFiller", "G3-05", { kind = "padding" } },
{ "sb1", 0x31F8, 0x34, "T2", "enigmaBerry", "G3-05", { kind = "absent", patterns = { "enigmaBerry%s*=", "session%.enigma", "save%.enigma" } } },
{ "sb1", 0x322C, 0x36C, "T1", "mysteryGift", nil },
+469
View File
@@ -0,0 +1,469 @@
local SaveData = require("src.core.SaveData")
local Serializer = require("src.core.SaveSerializer")
local Store = require("src.box.Store")
local Transaction = require("src.box.Transaction")
local Base64 = require("src.core.Base64")
local Version = require("src.core.GameVersion")
local Client = require("src.sync.SyncClient")
local Work = require("src.sync.SyncWork")
local SyncBox = {}
SyncBox.__index = SyncBox
SyncBox.KEY = "box/collection"
SyncBox.MAX_MEMBERS = 240
local function split(key)
if type(key) ~= "string" then return nil end
local version, id = key:match("^([^/]+)/([%w%._:%-]+)$")
if not version or not Version.VERSIONS[version] or #id > 64 then return nil end
return version, id
end
local function pathOf(entry)
if type(entry.slot) ~= "string" or not entry.slot:match("^slot%d+$") then return nil end
local scope = entry.cart and "cart_" .. entry.cart or entry.version
if type(scope) ~= "string" or not scope:match("^[%w%._%-]+$") then return nil end
return "saves/" .. scope .. "/" .. entry.slot .. ".lua"
end
local CHANGED = "Box or a linked save changed during sync. Sync again."
local function loadStore(fs)
if not Work.async() then return Store.load(fs) end
local body = fs.read(Store.PATH)
if not body then return Store.load(fs) end
local state, err = Work.call("decode", body)
if not state then return nil, "Box storage could not be decoded: " .. tostring(err) end
local valid, why = Store.validate(state)
if not valid then return nil, why end
return valid, nil, body
end
local function same(a, b)
for k, v in pairs(a) do if b[k] ~= v then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
local memo
function SyncBox.fingerprint(snapshot)
local bodies, assets = {}, {}
for key, row in pairs(snapshot.saves) do
if row.deleted then bodies[key] = false else bodies[key] = row.blob end
end
for name, encoded in pairs(snapshot.assets or {}) do assets[name] = encoded end
if memo and memo.blob == snapshot.blob and same(memo.bodies, bodies) and same(memo.assets, assets) then
return memo.value
end
local value = Work.call("fingerprint", snapshot.blob, snapshot.assets, bodies)
memo = { blob = snapshot.blob, bodies = bodies, assets = assets, value = value }
return value
end
function SyncBox.new(fs)
return setmetatable({ fs = SaveData.persistenceFs(fs), saveCache = {}, assetCache = {} }, SyncBox)
end
function SyncBox:recover()
local ok, applied, notice = Transaction.recover(self.fs)
if notice then self.recoveryNotice = notice end
return ok, applied, notice
end
function SyncBox:owns(key)
local body = self.fs.read(Store.PATH)
if body == nil then
if self.fs.getInfo(Store.PATH) then return nil, "Box storage could not be read." end
local state, why, recovered = Store.load(self.fs)
if not state then return nil, why end
if not recovered then return false end
body = recovered
end
if body ~= self.membersBody then
local valid, why = Store.validate(Work.call("decode", body))
if not valid then return nil, why end
self.membersBody, self.members = body, valid.syncMembers or {}
end
return self.members[key] ~= nil
end
function SyncBox:markDeleted(key)
local state, why, before = Store.load(self.fs)
if not state then return nil, why end
if not before or not (state.syncMembers and state.syncMembers[key]) then return true end
state.syncMembers[key].deleted = true
state.revision, state.savedAt = state.revision + 1, os.time()
return Transaction.commitSync(self.fs, { { path = Store.PATH, before = before, after = Serializer.encode(state) } })
end
function SyncBox:snapshot(entries, syncState, extraKeys)
local state, why, body = loadStore(self.fs)
if not state then return nil, why end
local byPath, byKey = {}, {}
for _, entry in ipairs(entries or {}) do
local key = entry.version .. "/" .. entry.playthroughId
local path = pathOf(entry)
byKey[key] = entry
if path then byPath[path] = entry end
end
local members, remoteOnly = Store.copy(state.syncMembers or {}), {}
local pending = syncState and syncState.pendingDeletes or {}
for key in pairs(extraKeys or {}) do
if not members[key] then
local version, id = split(key)
if not version then return nil, "A cloud Box save identity is invalid." end
local entry = byKey[key]
if entry or pending[key] then
members[key] = { version = version, playthroughId = id, path = entry and pathOf(entry) or "saves/" .. version .. "/slot0.lua",
slotId = entry and entry.slot or "slot0", cartId = entry and entry.cart }
else
remoteOnly[key] = true
end
end
end
local function legacy(path)
local entry = byPath[path]
if entry then
local key = entry.version .. "/" .. entry.playthroughId
members[key] = { version = entry.version, playthroughId = entry.playthroughId,
path = path, slotId = entry.slot, cartId = entry.cart }
end
end
local function origin(entry)
if entry.slotId then legacy(pathOf({ version = entry.version, cart = entry.cartId, slot = entry.slotId })) end
for _, original in ipairs(entry.archives or {}) do origin(original) end
end
if state.syncMembers == nil then
for _, box in ipairs(state.boxes) do
for _, entry in pairs(box.mons) do origin(entry) end
end
for _, departure in pairs(state.departures or {}) do legacy(departure.path); origin(departure.entry) end
end
local saves, keys, missing = {}, {}, {}
local todo, jobs = {}, {}
for key in pairs(members) do
local entry, cached = byKey[key], self.saveCache[key]
if entry and not (cached and cached.source == entry.blob and cached.version == entry.version
and cached.id == entry.playthroughId and cached.cart == entry.cart) then
todo[#todo + 1] = key
jobs[#jobs + 1] = { n = 4, entry.blob, entry.version, entry.playthroughId, entry.cart }
end
end
local normalized = Work.batch("normalizeSave", jobs)
for i, key in ipairs(todo) do
local entry, blob = byKey[key], normalized[i][1]
if not blob then return nil, "A save linked to Box could not be read." end
self.saveCache[key] = { source = entry.blob, version = entry.version, id = entry.playthroughId, cart = entry.cart,
blob = blob }
end
for key in pairs(remoteOnly) do keys[key] = true end
for key, member in pairs(members) do
local entry = byKey[key]
keys[key] = true
if entry then
local cached = self.saveCache[key]
if not (cached and cached.source == entry.blob and cached.version == entry.version
and cached.id == entry.playthroughId and cached.cart == entry.cart) then
local save = SaveData.decode(entry.blob)
if not save then return nil, "A save linked to Box could not be read." end
save.meta = type(save.meta) == "table" and save.meta or {}
save.meta.playthroughId = entry.playthroughId
save.version = save.version or entry.version
if entry.cart then save.meta.cartId = entry.cart end
cached = { source = entry.blob, version = entry.version, id = entry.playthroughId, cart = entry.cart,
blob = Serializer.encode(save) }
self.saveCache[key] = cached
end
member.path, member.slotId, member.cartId, member.deleted = pathOf(entry), entry.slot, entry.cart, nil
saves[key] = { version = entry.version, blob = cached.blob, meta = Store.copy(entry.meta), slot = entry.slot }
saves[key].meta.playthroughId = entry.playthroughId
elseif member.deleted or syncState.pendingDeletes and syncState.pendingDeletes[key] then
member.deleted = true
saves[key] = { version = member.version, deleted = true }
else
missing[key] = true
saves[key] = { version = member.version, deleted = true }
end
end
if next(members) then state.syncMembers = members end
local assets, published = {}, state
local wallpapers, pending, encodes = {}, {}, {}
for b, box in ipairs(state.boxes) do
local bytes = box.wallpaper and self.fs.read(box.wallpaper)
wallpapers[b] = bytes
local cached = bytes and self.assetCache[box.wallpaper]
if bytes and not (cached and cached.bytes == bytes) and not pending[box.wallpaper]
and Transaction.assetValid(box.wallpaper, bytes) then
pending[box.wallpaper] = bytes
encodes[#encodes + 1] = { n = 1, bytes, name = box.wallpaper }
end
end
local encoded = Work.batch("base64Encode", encodes)
for i, job in ipairs(encodes) do
self.assetCache[job.name] = { bytes = job[1], encoded = encoded[i][1] }
end
for b, box in ipairs(state.boxes) do
local bytes = wallpapers[b]
if box.wallpaper and bytes == nil and not self.fs.getInfo(box.wallpaper) then
if published == state then published = Store.copy(state) end
published.boxes[b].wallpaper = nil
elseif box.wallpaper then
local cached = self.assetCache[box.wallpaper]
if not (cached and cached.bytes == bytes) then
if not Transaction.assetValid(box.wallpaper, bytes) then return nil, "A Box wallpaper is missing or unreadable." end
cached = { bytes = bytes, encoded = Base64.encode(bytes) }
self.assetCache[box.wallpaper] = cached
end
assets[box.wallpaper] = cached.encoded
end
end
local snapshot = { format = 1, blob = Work.call("encode", published), assets = assets, saves = saves,
meta = { count = Store.count(state), savedAt = state.savedAt or 0 } }
return { payload = snapshot, fingerprint = SyncBox.fingerprint(snapshot), keys = keys,
missing = missing, remoteOnly = remoteOnly, exists = body ~= nil, state = state, body = body }
end
function SyncBox:remember(snapshot, entries, syncState)
local current, why = self:snapshot(entries, syncState, snapshot.saves)
if not current then return nil, why end
local before = self.fs.read(Store.PATH)
if Work.async() and before ~= current.body then return nil, CHANGED end
local after = Work.call("encode", current.state)
if before == after then return true, false end
local ok, err = Transaction.commitSync(self.fs, { { path = Store.PATH, before = before, after = after } })
return ok, err or true
end
function SyncBox.validate(snapshot)
if type(snapshot) ~= "table" or snapshot.format ~= 1 or type(snapshot.blob) ~= "string"
or #snapshot.blob > Client.MAX_BOX_BLOB or type(snapshot.assets) ~= "table"
or type(snapshot.saves) ~= "table" then return nil, "The cloud Box snapshot is invalid." end
local state = Work.call("decode", snapshot.blob, { maxBytes = Client.MAX_BOX_BLOB, maxNodes = 2000000 })
local valid, why = Store.validate(state)
if not valid then return nil, why end
local assets, references = {}, {}
for _, box in ipairs(state.boxes) do if box.wallpaper then references[box.wallpaper] = true end end
local names, decodes, badAsset = {}, {}, false
for name, encoded in pairs(snapshot.assets) do
if type(encoded) ~= "string" or #encoded > 2800000 or not references[name] then badAsset = true; break end
names[#names + 1] = name
decodes[#decodes + 1] = { n = 1, encoded }
end
local decoded = Work.batch("base64Decode", decodes)
for i, name in ipairs(names) do
local bytes = decoded[i][1]
if not Transaction.assetValid(name, bytes) then return nil, "A cloud Box wallpaper is unreadable." end
assets[name] = bytes
end
if badAsset then return nil, "A cloud Box wallpaper is invalid." end
for name in pairs(references) do if not assets[name] then return nil, "The cloud snapshot is missing a Box wallpaper." end end
local saves, count, rows, blobs, rowError = {}, 0, {}, {}, nil
for key, row in pairs(snapshot.saves) do
count = count + 1
local version, id = split(key)
if not version or count > SyncBox.MAX_MEMBERS or type(row) ~= "table" or row.version ~= version then
rowError = "A cloud Box save link is invalid."; break
end
if row.deleted then
if row.blob ~= nil then rowError = "A deleted cloud save has conflicting data."; break end
rows[#rows + 1] = { key = key, version = version, id = id, deleted = true }
else
if type(row.blob) ~= "string" or #row.blob > Client.MAX_BLOB or type(row.meta) ~= "table"
or row.meta.playthroughId ~= id then rowError = "A cloud Box game save is invalid."; break end
rows[#rows + 1] = { key = key, version = version, id = id }
blobs[#blobs + 1] = { n = 1, row.blob, row = #rows }
end
end
local decodedSaves = Work.batch("decode", blobs)
for i, job in ipairs(blobs) do rows[job.row].save = decodedSaves[i][1] end
for _, entry in ipairs(rows) do
local key, version, id = entry.key, entry.version, entry.id
if entry.deleted then
saves[key] = { version = version, id = id, deleted = true }
else
local save = entry.save
local native = save and save.engine == "game3" and type(save.name) == "string"
and type(save.party) == "table" and type(save.bag) == "table"
if type(save) ~= "table" or (type(save.player) ~= "table" and not native)
or save.version and save.version ~= version or save.generation and save.generation ~= Version.generation(version)
or save.meta and type(save.meta) ~= "table"
or save.meta and save.meta.playthroughId and save.meta.playthroughId ~= id then
return nil, "A cloud Box game save does not match its playthrough."
end
save.version = version
save.meta = save.meta or {}; save.meta.playthroughId = id
local cart = save.meta.cartId
if cart ~= nil and (type(cart) ~= "string" or #cart > 64 or not cart:match("^[%w%._%-]+$")) then
return nil, "A cloud Box cart identity is invalid."
end
saves[key] = { version = version, id = id, cart = cart, save = save }
end
end
if rowError then return nil, rowError end
for key, member in pairs(state.syncMembers or {}) do
if not saves[key] then return nil, "The cloud snapshot is missing a linked save." end
if not saves[key].deleted and saves[key].cart ~= member.cartId then return nil, "A cloud Box link points to a different cart." end
end
for _, key in ipairs(snapshot.members or {}) do if not saves[key] then return nil, "The cloud snapshot is incomplete." end end
return { state = state, saves = saves, assets = assets }
end
function SyncBox:archive(snapshot)
local valid, why = SyncBox.validate(snapshot)
if not valid then return nil, why end
local path = "box/conflicts/" .. os.time() .. "-" .. SaveData.newPlaythroughId() .. ".lua"
if not self.fs.createDirectory("box/conflicts") then return nil, "Could not make the Box sync backup directory." end
local body = Work.call("encode", snapshot)
local ok, err = self.fs.write(path, body)
if not ok or self.fs.read(path) ~= body then return nil, err or "The Box sync backup could not be verified." end
local index = Serializer.encode({ path = path })
ok, err = self.fs.write("box/last-sync-backup.lua", index)
if not ok or self.fs.read("box/last-sync-backup.lua") ~= index then return nil, err or "The Box backup index could not be verified." end
return path
end
function SyncBox:latestBackup()
local body = self.fs.read("box/last-sync-backup.lua")
local index = Serializer.decode(body)
if type(index) == "table" and type(index.path) == "string"
and index.path:match("^box/conflicts/%d+%-[%w%-]+%.lua$") and self.fs.getInfo(index.path) then return index.path end
if not self.fs.getDirectoryItems then return nil end
local latest
for _, name in ipairs(self.fs.getDirectoryItems("box/conflicts") or {}) do
if name:match("^%d+%-[%w%-]+%.lua$") and (not latest or name > latest) then latest = name end
end
return latest and "box/conflicts/" .. latest
end
function SyncBox:readBackup(path)
if type(path) ~= "string" or not path:match("^box/conflicts/%d+%-[%w%-]+%.lua$") then
return nil, "Choose a Box sync recovery backup."
end
local body = self.fs.read(path)
if not body or #body > Client.MAX_BOX_BODY * 2 then return nil, "The Box sync backup could not be read." end
local snapshot = Work.call("decode", body, { maxBytes = Client.MAX_BOX_BODY * 2, maxNodes = 4000000 })
local valid, why = SyncBox.validate(snapshot)
if not valid then return nil, why end
return snapshot, valid
end
function SyncBox:apply(snapshot, entries, syncState, expected)
local stamp
if Work.async() then
stamp = { [Store.PATH] = self.fs.read(Store.PATH) or false }
local linked = type(snapshot) == "table" and type(snapshot.saves) == "table" and snapshot.saves or {}
for _, entry in ipairs(entries) do
local path = linked[entry.version .. "/" .. entry.playthroughId] ~= nil and pathOf(entry)
if path then stamp[path] = self.fs.read(path) or false end
end
end
local function before(path)
if stamp and stamp[path] ~= nil then return stamp[path] or nil end
return self.fs.read(path)
end
local valid, why = SyncBox.validate(snapshot)
if not valid then return nil, why end
local current, err = self:snapshot(entries, syncState, snapshot.saves)
if not current then return nil, err end
if expected and current.fingerprint ~= expected then return nil, "Box or a linked save changed during sync. Sync again." end
local optionsBefore = self.fs.read("options.lua")
if optionsBefore == nil and self.fs.getInfo("options.lua") then return nil, "Local save-slot settings could not be read." end
local options = {}
if optionsBefore ~= nil then options = Work.call("decode", optionsBefore) end
if type(options) ~= "table" then return nil, "Local save-slot settings could not be read." end
local byKey = {}
for _, entry in ipairs(entries) do byKey[entry.version .. "/" .. entry.playthroughId] = entry end
local jobs, downloads, mapping = {}, {}, {}
local state = Store.copy(valid.state)
state.syncMembers = {}
for key, row in pairs(valid.saves) do
local entry = byKey[key]
local oldLink = valid.state.syncMembers and valid.state.syncMembers[key]
if entry and entry.cart ~= row.cart and not row.deleted then return nil, "A cloud save points to a different cart." end
local cart = row.cart or entry and entry.cart or oldLink and oldLink.cartId
if cart and not row.deleted then
local registered = options.carts and options.carts[cart]
local installed = self.fs.getInfo(require("src.carts.CartStore").fileFor(cart))
if not registered and not installed then return false, "Install the \"" .. cart .. "\" cart before syncing this Box collection." end
end
local scope = cart and "cart_" .. cart or row.version
local root = cart and "cartSlots" or "saveSlots"
local regKey = cart or row.version
options[root] = options[root] or {}
local reg = Store.copy(options[root][regKey] or { list = {} })
reg.list = reg.list or {}
local slot = entry and entry.slot
local created = false
if not slot and not row.deleted then
local max = 0
for _, id in ipairs(reg.list) do max = math.max(max, tonumber(id:match("^slot(%d+)$")) or 0) end
repeat max = max + 1; slot = "slot" .. max until not self.fs.getInfo("saves/" .. scope .. "/" .. slot .. ".lua")
reg.list[#reg.list + 1] = slot; reg.active = reg.active or slot; created = true
end
local path = slot and pathOf({ version = row.version, cart = cart, slot = slot })
if slot and not path then return nil, "A local save slot is invalid. Restart G1R before syncing Box." end
if oldLink and path then mapping[oldLink.path] = { path = path, slot = slot, cart = cart } end
options.playthroughIds = options.playthroughIds or {}
options.playthroughIds[scope] = options.playthroughIds[scope] or {}
if path then
jobs[#jobs + 1] = { path = path, before = before(path), save = not row.deleted and row.save or nil, deleted = row.deleted }
if row.deleted then
local list = {}; for _, id in ipairs(reg.list) do if id ~= slot then list[#list + 1] = id end end
reg.list = list
if reg.active == slot then reg.active = list[1] end
for _, field in ipairs({ "names", "hashes", "broken" }) do if reg[field] then reg[field][slot] = nil end end
options.playthroughIds[scope][slot] = nil
else
options.playthroughIds[scope][slot] = row.id
if cart then
reg.hashes = reg.hashes or {}; reg.hashes[slot] = row.save.meta.cartHash
reg.broken = reg.broken or {}; reg.broken[slot] = row.save.meta.sealBroken == true or nil
end
end
downloads[#downloads + 1] = { version = row.version, cart = cart, slot = slot,
created = created, removed = row.deleted, device = snapshot.meta and snapshot.meta.device }
end
options[root][regKey] = reg
state.syncMembers[key] = { version = row.version, playthroughId = row.id, cartId = cart,
slotId = slot or "slot0", path = path or "saves/" .. scope .. "/slot0.lua", deleted = row.deleted }
end
local function remap(entry)
if entry.slotId then
local old = pathOf({ version = entry.version, cart = entry.cartId, slot = entry.slotId })
local target = mapping[old]
if target then entry.slotId, entry.cartId = target.slot, target.cart end
end
for _, original in ipairs(entry.archives or {}) do remap(original) end
end
for _, box in ipairs(state.boxes) do for _, entry in pairs(box.mons) do remap(entry) end end
for _, departure in pairs(state.departures or {}) do
if mapping[departure.path] then departure.path = mapping[departure.path].path end
remap(departure.entry)
end
local encodes = {}
for _, job in ipairs(jobs) do if job.save then encodes[#encodes + 1] = { n = 1, job.save, job = job } end end
local encoded = Work.batch("encode", encodes)
for i, item in ipairs(encodes) do item.job.after, item.job.save = encoded[i][1], nil end
for name, bytes in pairs(valid.assets) do jobs[#jobs + 1] = { kind = "asset", path = name, before = self.fs.read(name), after = bytes } end
jobs[#jobs + 1] = { path = "options.lua", before = optionsBefore, after = Work.call("encode", options) }
jobs[#jobs + 1] = { path = Store.PATH, before = before(Store.PATH), after = Work.call("encode", state) }
local backup, backupErr = self:archive(current.payload)
if not backup then return nil, backupErr end
for path, body in pairs(stamp or {}) do
if self.fs.read(path) ~= (body or nil) then return nil, CHANGED end
end
local ok, failure = Transaction.commitSync(self.fs, jobs)
if not ok then return nil, failure end
for _, row in ipairs(downloads) do SaveData.refreshSlotResolution(row.cart and "cart_" .. row.cart or row.version) end
local Themes = package.loaded["src.box.Themes"]
if type(Themes) == "table" and Themes.forget then
for _, job in ipairs(jobs) do
if job.kind == "asset" then Themes.forget(job.path) end
end
end
require("src.box.Catalog").reset()
downloads[#downloads + 1] = { box = true, backup = backup }
return downloads
end
return SyncBox
+98 -15
View File
@@ -1,4 +1,5 @@
local Json = require("src.link.Json")
local Work = require("src.sync.SyncWork")
local SyncClient = {}
SyncClient.__index = SyncClient
@@ -7,7 +8,10 @@ SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL")
or "https://sync.147.182.215.255.sslip.io"
SyncClient.MAX_BLOB = 2 * 1024 * 1024
SyncClient.MAX_RESPONSE = 4 * 1024 * 1024
SyncClient.MAX_BOX_BODY = 64 * 1024 * 1024
SyncClient.MAX_BOX_BLOB = 32 * 1024 * 1024
SyncClient.TIMEOUT = 25
SyncClient.ASYNC_DECODE = 64 * 1024
function SyncClient.normalizeCode(code)
if type(code) ~= "string" and type(code) ~= "number" then return nil end
@@ -72,8 +76,10 @@ end
function SyncClient:send(method, path, body, opts)
opts = opts or {}
local headers = { ["Accept"] = "application/json" }
local payload
if body ~= nil then
local payload = opts.encoded
if payload ~= nil then
headers["Content-Type"] = "application/json"
elseif body ~= nil then
if type(body) == "table" and next(body) == nil then
payload = "{}"
else
@@ -92,24 +98,15 @@ function SyncClient:send(method, path, body, opts)
local handle = self.transport:begin({
url = url, method = method, body = payload, headers = headers,
maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT,
maxBytes = opts.maxResponse or SyncClient.MAX_RESPONSE,
})
if handle == nil then return nil, "no network transport" end
self.responseLimits = self.responseLimits or {}
self.responseLimits[handle] = opts.maxResponse or SyncClient.MAX_RESPONSE
return handle
end
function SyncClient:poll(handle)
if handle == nil then return { status = "error", err = "no request" } end
local res = self.transport:poll(handle)
if res.status == "pending" then return { status = "pending" } end
if res.status ~= "ok" then
return { status = "error", err = res.err or "sync request failed" }
end
local raw = res.body or ""
local code = tonumber(res.code) or 0
if #raw > SyncClient.MAX_RESPONSE then
return { status = "error", code = code, err = "the reply was too large" }
end
local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE)
local function reply(code, raw, data, decodeErr)
if type(data) ~= "table" then
local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply"
if code >= 400 then
@@ -128,8 +125,94 @@ function SyncClient:poll(handle)
return { status = "ok", code = code, data = data }
end
function SyncClient:_pollDeferred(job)
if job.work then
local r = Work.poll(job.work)
if not r then return { status = "pending" } end
job.work = nil
local ok, body = pcall(Work.finish, r, "jsonEncode", { n = 1, job.snapshot })
job.snapshot = nil
if not ok then
job.failed = "Box could not be encoded for sync."
elseif #body > SyncClient.MAX_BOX_BODY then
job.failed = "The Box collection is too large to sync."
else
local handle, err = self:send("PUT", "/sync/box", nil, { encoded = body, maxResponse = SyncClient.MAX_BOX_BODY })
if handle == nil then job.failed = err or "could not start the request" else job.inner = handle end
end
end
if job.failed then return { status = "error", err = job.failed } end
return self:poll(job.inner)
end
function SyncClient:poll(handle)
if handle == nil then return { status = "error", err = "no request" } end
if type(handle) == "table" and handle.deferred then return self:_pollDeferred(handle) end
local decoding = self.decoding and self.decoding[handle]
if decoding then
local r = Work.poll(decoding.work)
if not r then return { status = "pending" } end
self.decoding[handle] = nil
local ok, data, decodeErr = pcall(Work.finish, r, "jsonDecode", { n = 2, decoding.raw, decoding.max })
if not ok then data, decodeErr = nil, data end
return reply(decoding.code, decoding.raw, data, decodeErr)
end
local res = self.transport:poll(handle)
if res.status == "pending" then return { status = "pending" } end
if res.status ~= "ok" then
return { status = "error", err = res.err or "sync request failed" }
end
local raw = res.body or ""
local code = tonumber(res.code) or 0
local maxResponse = self.responseLimits and self.responseLimits[handle] or SyncClient.MAX_RESPONSE
if #raw > maxResponse then
return { status = "error", code = code, err = "the reply was too large" }
end
local work = #raw >= SyncClient.ASYNC_DECODE and Work.submit("jsonDecode", raw, maxResponse) or nil
if work then
self.decoding = self.decoding or {}
self.decoding[handle] = { work = work, raw = raw, max = maxResponse, code = code }
return { status = "pending" }
end
local data, decodeErr = Json.decode(raw, maxResponse)
return reply(code, raw, data, decodeErr)
end
function SyncClient:release(handle)
if type(handle) == "table" and handle.deferred then
Work.forget(handle.work)
handle.work, handle.snapshot = nil, nil
if handle.inner ~= nil then self:release(handle.inner) end
return
end
local decoding = self.decoding and self.decoding[handle]
if decoding then
Work.forget(decoding.work)
self.decoding[handle] = nil
end
if handle ~= nil then self.transport:release(handle) end
if self.responseLimits then self.responseLimits[handle] = nil end
end
function SyncClient:getBox(keys)
local list = {}
for key in pairs(keys or {}) do list[#list + 1] = key end
table.sort(list)
return self:send("GET", "/sync/box", nil, { params = #list > 0 and { include = table.concat(list, ",") } or nil,
maxResponse = SyncClient.MAX_BOX_BODY })
end
function SyncClient:putBox(snapshot)
if type(snapshot) ~= "table" or type(snapshot.blob) ~= "string"
or #snapshot.blob > SyncClient.MAX_BOX_BLOB then return nil, "Box storage is too large to sync." end
if self:isLinked() then
local work = Work.submit("jsonEncode", snapshot)
if work then return { deferred = true, work = work, snapshot = snapshot } end
end
local ok, body = pcall(Json.encode, snapshot)
if not ok then return nil, "Box could not be encoded for sync." end
if #body > SyncClient.MAX_BOX_BODY then return nil, "The Box collection is too large to sync." end
return self:send("PUT", "/sync/box", nil, { encoded = body, maxResponse = SyncClient.MAX_BOX_BODY })
end
function SyncClient:create(deviceLabel)
+342 -19
View File
@@ -1,6 +1,8 @@
local SyncClient = require("src.sync.SyncClient")
local SyncState = require("src.sync.SyncState")
local SyncMods = require("src.sync.SyncMods")
local SyncBox = require("src.sync.SyncBox")
local Work = require("src.sync.SyncWork")
local SyncEngine = {}
SyncEngine.__index = SyncEngine
@@ -325,9 +327,10 @@ local function snapshot(value, seen)
return out
end
local function contents(blob)
local function contents(blob, decoded)
if type(blob) ~= "string" or blob == "" or #blob > SyncClient.MAX_BLOB then return nil end
local ok, save = pcall(saveApi().decode, blob)
local ok, save = true, decoded
if save == nil then ok, save = pcall(saveApi().decode, blob) end
if not ok or type(save) ~= "table"
or (save.meta ~= nil and type(save.meta) ~= "table") then return nil end
local native = save.engine == "game3" and save.generation == 3
@@ -347,7 +350,12 @@ local function equalValues(a, b)
end
local function sameContents(a, b)
local left, right = contents(a), contents(b)
local decoded = {}
for i, blob in ipairs({ a, b }) do
decoded[i] = type(blob) == "string" and blob ~= "" and #blob <= SyncClient.MAX_BLOB
and Work.call("decode", blob) or nil
end
local left, right = contents(a, decoded[1]), contents(b, decoded[2])
return left ~= nil and right ~= nil and equalValues(left, right)
end
@@ -374,6 +382,8 @@ function SyncEngine.new(opts)
eng.client = opts.client or SyncClient.new({
baseUrl = opts.baseUrl, transport = opts.transport })
eng.saves = opts.saves or SyncEngine.defaultSaves()
if opts.box ~= nil then eng.box = opts.box
elseif not opts.saves then eng.box = SyncBox.new(eng.fs) end
eng.modDeps = opts.modDeps
eng.now = opts.now or os.time
eng.persist = opts.persist ~= false
@@ -415,7 +425,42 @@ function SyncEngine:linked()
end
function SyncEngine:busy()
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil or self.working ~= nil
end
function SyncEngine:_step(rec)
self.working = nil
local ok, err = coroutine.resume(rec.co, self, rec.arg)
if coroutine.status(rec.co) == "suspended" then
self.working = rec
return false
end
Work.abandon(rec.co)
return true, ok, err
end
function SyncEngine:_run(fn, arg, task)
return self:_step({ co = Work.spawn(fn), arg = arg, task = task })
end
function SyncEngine:_settle(rec, ok, err)
if not ok then self:_fail(err) return false end
if rec.task and not self.pending and not self.working and #self.queue == 0 and self.phase ~= "error" then
self:_finish()
end
return true
end
function SyncEngine:_noteRecovery(notice)
notice = notice or (self.box and self.box.recoveryNotice)
if self.box then self.box.recoveryNotice = nil end
if type(notice) == "string" and notice ~= "" then self.notice = notice end
end
function SyncEngine:takeNotice()
local notice = self.shownNotice
self.shownNotice = nil
return notice
end
function SyncEngine:_persist()
@@ -424,6 +469,10 @@ function SyncEngine:_persist()
end
function SyncEngine:_fail(message)
if self.working then
Work.abandon(self.working.co)
self.working = nil
end
self.phase = "error"
self.error = tostring(message or "sync failed")
self.status = "Sync failed: " .. self.error
@@ -438,7 +487,7 @@ function SyncEngine:_finish()
for _, row in ipairs(self.conflicts) do
if row.overlap then overlap = true end
end
self.status = overlap
self.status = self.conflicts[1].box and "Box and its linked saves changed on another device." or overlap
and "These saves were played at the same time."
or "This save also changed on another device."
return
@@ -450,6 +499,9 @@ function SyncEngine:_finish()
if type(self.skipped) == "table" and #self.skipped > 0 then
self.status = self.skipped[1]
end
if self.notice then
self.status, self.shownNotice, self.notice = self.notice, self.notice, nil
end
self:_persist()
end
@@ -468,6 +520,8 @@ end
function SyncEngine:cancel()
if self.pending then self.client:release(self.pending.handle) end
if self.working then Work.abandon(self.working.co) end
self.working = nil
self.pending = nil
self.queue = {}
self.modApply = nil
@@ -485,6 +539,18 @@ end
function SyncEngine:noteSaveDeleted(key)
if type(key) ~= "string" or key == "" then return false end
if self.box then
local ok, why = self.box:markDeleted(key)
if not ok then
local known = SyncState.rev(self.state, key)
SyncState.forget(self.state, key)
if known ~= nil or self.box:owns(key) ~= false then SyncState.markDeleted(self.state, key, known or 0, self.now()) end
self:_persist()
self:_fail(why)
if self.state.enabled and self:linked() then self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE end
return nil, why
end
end
local rev = SyncState.rev(self.state, key)
SyncState.forget(self.state, key)
if rev == nil or not self:linked() then
@@ -501,6 +567,12 @@ end
function SyncEngine:update(dt)
self.clock = self.clock + (tonumber(dt) or 0)
if self.working then
local rec = self.working
local done, ok, err = self:_step(rec)
if not done then return end
if not self:_settle(rec, ok, err) then return end
end
if self.pending then
local res = self.client:poll(self.pending.handle)
if res.status == "pending" then return end
@@ -508,8 +580,8 @@ function SyncEngine:update(dt)
self.pending = nil
self.client:release(job.handle)
if res.status == "ok" then
local ok, err = pcall(job.onOk, self, res)
if not ok then self:_fail(err) end
local done, ok, err = self:_run(job.onOk, res)
if done and not ok then self:_fail(err) end
else
local handled = false
if job.onErr then
@@ -520,7 +592,7 @@ function SyncEngine:update(dt)
if not handled then self:_fail(res.err) end
end
end
if self.pending then return end
if self.pending or self.working then return end
if self.modApply then
self:_stepModApply()
return
@@ -535,15 +607,12 @@ function SyncEngine:update(dt)
self:syncNow()
end
local steps = 0
while not self.pending and #self.queue > 0
while not self.pending and not self.working and #self.queue > 0
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
steps = steps + 1
local task = table.remove(self.queue, 1)
local ok, err = pcall(task, self)
if not ok then self:_fail(err) return end
if not self.pending and #self.queue == 0 and self.phase ~= "error" then
self:_finish()
end
local done, ok, err = self:_run(task, nil, true)
if done and not self:_settle({ task = true }, ok, err) then return end
end
end
@@ -699,7 +768,10 @@ function SyncEngine:setEnabled(enabled)
end
function SyncEngine:protectPlaythrough(version, playthroughId)
local previous = self.protectedKey
self.protectedKey = SyncState.key(version, playthroughId)
if previous and not self.protectedKey and self.box and self._boxKeys and self._boxKeys[previous]
and self.state.enabled and self:linked() then self.uploadAt = self.clock end
end
function SyncEngine:noteResumed()
@@ -714,7 +786,7 @@ end
function SyncEngine:syncNow()
if not self:linked() then return false, "this device is not linked" end
if self.pending then return false, "sync is busy" end
if self.pending or self.working then return false, "sync is busy" end
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
self.queue = {}
self.conflicts = {}
@@ -748,11 +820,18 @@ function SyncEngine:_planFrom(remoteState)
end
local remote = type(remoteState.saves) == "table" and remoteState.saves or {}
local tombs = type(remoteState.deleted) == "table" and remoteState.deleted or {}
if self.box then
local recovered, recoveryError, notice = self.box:recover()
self:_noteRecovery(notice)
if not recovered then self:_fail(recoveryError); return end
end
local locals = self.saves.list() or {}
self._boxKeys, self._boxSaveRevs = {}, {}
if self.box and not self:_planBox(remoteState, locals) then return end
local seen = {}
for _, entry in ipairs(locals) do
local key = SyncState.key(entry.version, entry.playthroughId)
if key then
if key and not self._boxKeys[key] then
seen[key] = true
SyncState.clearDeleted(self.state, key)
local row = remote[key]
@@ -787,7 +866,7 @@ function SyncEngine:_planFrom(remoteState)
end
for key, pending in pairs(self.state.pendingDeletes or {}) do
local row = remote[key]
if not seen[key] then
if not seen[key] and not self._boxKeys[key] then
if row and (tonumber(row.rev) or 0) > (tonumber(pending.rev) or 0) then
SyncState.clearDeleted(self.state, key)
else
@@ -797,7 +876,7 @@ function SyncEngine:_planFrom(remoteState)
end
end
for key, row in pairs(remote) do
if not seen[key] and key ~= self.protectedKey then
if not seen[key] and not self._boxKeys[key] and key ~= self.protectedKey then
local version, id = SyncState.splitKey(key)
if version and id then
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
@@ -807,6 +886,215 @@ function SyncEngine:_planFrom(remoteState)
if #self.queue == 0 then self:_finish() end
end
function SyncEngine:_planBox(remoteState, locals)
local remote = type(remoteState.box) == "table" and remoteState.box or nil
local extra = {}
for _, key in ipairs(remote and remote.members or {}) do extra[key] = true end
local current, why = self.box:snapshot(locals, self.state, extra)
if not current then self:_fail(why); return false end
if not current.exists and not remote then return true end
if not (remoteState.capabilities and remoteState.capabilities.box == 1) then
self:_fail("The sync server needs its Box update before this collection can sync.")
return false
end
self._boxKeys = current.keys
local remoteSaves, tombs = remoteState.saves or {}, remoteState.deleted or {}
local saveChanged = false
for key in pairs(self._boxKeys) do
local row = remoteSaves[key] or tombs[key]
self._boxSaveRevs[key] = row and tonumber(row.rev) or 0
local known = self.state.pendingDeletes and self.state.pendingDeletes[key]
known = known and known.rev or SyncState.rev(self.state, key)
if row and self._boxSaveRevs[key] ~= known then saveChanged = true end
end
local changed = current.fingerprint ~= self.state.boxFingerprint
local remoteRev = remote and tonumber(remote.rev) or 0
local remoteChanged = remoteRev ~= (self.state.boxRev or 0) or saveChanged
if remote and not current.exists then
local dirty = false
for _, entry in ipairs(locals) do
local key = SyncState.key(entry.version, entry.playthroughId)
if self._boxKeys[key] then
dirty = dirty or SyncState.rev(self.state, key) == nil
or unixSeconds(entry.meta and entry.meta.savedAt) ~= unixSeconds(SyncState.stamp(self.state, key))
end
end
if dirty then self:_queueBoxComparison(current, remoteRev)
else self:_queueBoxDownload(current.fingerprint) end
elseif not remote and saveChanged then self:_queueBoxComparison(current, 0)
elseif not remote then self:_queueBoxUpload(current)
elseif changed and remoteChanged then self:_queueBoxComparison(current, remoteRev)
elseif changed then self:_queueBoxUpload(current)
elseif remoteChanged then self:_queueBoxDownload(current.fingerprint) end
return true
end
function SyncEngine:_addBoxConflict(current, remote)
self.conflicts[#self.conflicts + 1] = { box = true, key = SyncBox.KEY, version = "Box collection",
entry = current, remote = remote, remoteRev = remote.rev,
localMeta = { summary = { name = "Box", boxCount = current.payload.meta.count }, savedAt = unixSeconds(current.payload.meta.savedAt) },
remoteMeta = { summary = { name = "Box", boxCount = remote.meta and remote.meta.count }, savedAt = unixSeconds(remote.meta and remote.meta.savedAt) },
overlap = false }
self.state.pendingConflicts[#self.state.pendingConflicts + 1] = { key = SyncBox.KEY, version = "Box collection" }
end
function SyncEngine:_queueBoxComparison(current, observedRev)
self:_enqueue(function(eng)
eng.phase, eng.status = "checking", "Checking Box and linked saves..."
local handle, err = eng.client:getBox(eng._boxKeys)
eng:_request(handle, err, function(e, res)
local remote = res.data
local valid, why = SyncBox.validate(remote)
if not valid then e:_fail(why); return end
if type(remote.rev) ~= "number" or remote.rev < observedRev then e:_fail("The cloud Box revision is stale."); return end
if not next(current.missing) and SyncBox.fingerprint(remote) == current.fingerprint then
local remembered, detail = e.box:remember(remote, e.saves.list(), e.state)
if not remembered then e:_fail(detail); return end
e:_acceptBox(remote.rev, remote.saves, current.fingerprint)
else e:_addBoxConflict(current, remote) end
if not e:busy() then e:_finish() end
end)
end)
end
function SyncEngine:_acceptBox(rev, rows, fingerprint)
self.state.boxRev, self.state.boxFingerprint = rev, fingerprint
for key, row in pairs(rows or {}) do
SyncState.setRev(self.state, key, row.rev, unixSeconds(row.meta and row.meta.savedAt))
SyncState.clearDeleted(self.state, key)
end
self:_persist()
end
function SyncEngine:_queueBoxUpload(planned, expectedRev, remote)
self:_enqueue(function(eng)
if eng.protectedKey and eng._boxKeys[eng.protectedKey] then
eng.skipped = { "Box sync will finish after you return to the launcher." }; return
end
local current, why = eng.box:snapshot(eng.saves.list(), eng.state, eng._boxKeys)
if not current then eng:_fail(why); return end
if next(current.missing) then eng:_fail("A save linked to Box is missing. Receive the cloud collection before uploading."); return end
for key in pairs(current.keys) do
if not planned.keys[key] then eng:_fail("Box gained a linked save during sync. Sync again."); return end
end
local carried = {}
for key in pairs(current.remoteOnly or {}) do
local row = remote and type(remote.saves) == "table" and remote.saves[key]
if type(row) ~= "table" then eng:_fail("Box gained a linked save on another device. Sync again."); return end
carried[key] = row.deleted and { version = row.version, deleted = true }
or { version = row.version, blob = row.blob, meta = row.meta }
end
if remote then
local backup, backupError = eng.box:archive(remote)
if not backup then eng:_fail(backupError); return end
end
local payload = current.payload
for key, row in pairs(carried) do payload.saves[key] = row end
payload.baseRev = expectedRev or eng.state.boxRev or 0
for key, row in pairs(payload.saves) do
row.baseRev = remote and remote.saves[key] and remote.saves[key].rev or eng._boxSaveRevs[key] or 0
end
eng.phase, eng.status = "uploading", "Uploading Box and linked saves..."
local handle, err = eng.client:putBox(payload)
eng:_request(handle, err, function(e, res)
if not revision(res.data.rev) then e:_fail("The server did not confirm the Box revision."); return end
local rows = {}
for key, row in pairs(payload.saves) do
local rev = res.data.revs and revision(res.data.revs[key])
if not rev then e:_fail("The server did not confirm every linked save."); return end
if not carried[key] then rows[key] = { rev = rev, meta = row.meta } end
end
local remembered, detail = e.box:remember(payload, e.saves.list(), e.state)
if not remembered then e:_fail(detail); return end
if detail == true then
e.changed = true; e.lastDownloads = e.lastDownloads or {}
e.lastDownloads[#e.lastDownloads + 1] = { box = true }
end
e:_acceptBox(res.data.rev, rows, current.fingerprint)
if next(carried) and e.state.enabled then e.uploadAt = e.clock end
if not e:busy() then e:_finish() end
end, function(e, res)
if res.code == 409 then
e:_queueBoxComparison(current, tonumber(res.data and res.data.rev) or 0)
return true
end
return false
end)
end)
end
function SyncEngine:_queueBoxDownload(expected, supplied)
self:_enqueue(function(eng)
if eng.protectedKey and eng._boxKeys[eng.protectedKey] then
eng.skipped = { "Box sync will finish after you return to the launcher." }; return
end
local function apply(e, remote)
local locals = e.saves.list()
local downloads, why = e.box:apply(remote, locals, e.state, expected)
if downloads == false then e.skipped = { why }; return end
if not downloads then e:_fail(why); return end
local current, err = e.box:snapshot(e.saves.list(), e.state)
if not current then e:_fail(err); return end
e.lastDownloads = e.lastDownloads or {}
for _, row in ipairs(downloads) do e.lastDownloads[#e.lastDownloads + 1] = row end
e.changed = true
e:_acceptBox(remote.rev, remote.saves, current.fingerprint)
if not e:busy() then e:_finish() end
end
eng.phase, eng.status = "downloading", "Downloading Box and linked saves..."
local handle, err = eng.client:getBox(eng._boxKeys)
eng:_request(handle, err, function(e, res)
local remote = res.data
if supplied then
local same = remote.rev == supplied.rev
for key, row in pairs(supplied.saves) do same = same and remote.saves and remote.saves[key] and remote.saves[key].rev == row.rev end
if not same then
local current, why = e.box:snapshot(e.saves.list(), e.state, e._boxKeys)
if not current then e:_fail(why); return end
e:_queueBoxComparison(current, remote.rev or 0); return
end
end
apply(e, remote)
end)
end)
end
function SyncEngine:restoreBoxBackup(path)
if not self.box then return nil, "Box storage is unavailable." end
if self:busy() or #self.conflicts > 0 then return nil, "Finish the current sync before restoring Box." end
local recovered, recoveryError, notice = self.box:recover()
self:_noteRecovery(notice)
if not recovered then return nil, recoveryError end
local snapshot, backup = self.box:readBackup(path)
if not snapshot then return nil, backup end
local entries = self.saves.list()
local current, err = self.box:snapshot(entries, self.state, snapshot.saves)
if not current then return nil, err end
local backupState = backup.state
for key in pairs(current.keys) do
local row, member = snapshot.saves[key], backupState.syncMembers and backupState.syncMembers[key]
if not row or row.deleted and not (member and member.deleted) then
return nil, "This backup predates another linked save. Restore a newer complete collection."
end
end
for key in pairs(self._boxKeys or {}) do
if not snapshot.saves[key] then return nil, "This backup predates another linked save. Restore a newer complete collection." end
end
if self.protectedKey and current.keys[self.protectedKey] then
return nil, "Return to the launcher before restoring Box and its linked saves."
end
local downloads, failure = self.box:apply(snapshot, entries, self.state, current.fingerprint)
if not downloads then return nil, failure end
self.changed, self.lastDownloads = true, downloads
self:noteSaveWritten()
self.status = "Box and its linked saves were restored."
if self.notice then
self.status = self.notice .. " " .. self.status
self.shownNotice, self.notice = self.notice, nil
end
return true
end
function SyncEngine:_removeLocal(entry, key, tomb)
if key == self.protectedKey then return end
local slotId, cartId
@@ -828,8 +1116,20 @@ function SyncEngine:_removeLocal(entry, key, tomb)
end
end
function SyncEngine:_newBoxMember(key)
if not self.box or self._boxKeys and self._boxKeys[key] then return false end
local owned, why = self.box:owns(key)
if owned == nil or owned then
self:_fail(why or "Box gained a linked save during sync. Checking the collection again.")
if owned and self.state.enabled then self.uploadAt = self.clock end
return true
end
return false
end
function SyncEngine:_queueDelete(key, rev)
self:_enqueue(function(eng)
if eng:_newBoxMember(key) then return end
eng.phase = "uploading"
eng.status = "Removing deleted saves..."
local version, id = SyncState.splitKey(key)
@@ -872,6 +1172,7 @@ end
function SyncEngine:_queueComparison(entry, key, row)
entry, row = snapshot(entry), snapshot(row)
self:_enqueue(function(eng)
if eng:_newBoxMember(key) then return end
eng.phase = "checking"
eng.status = "Checking save contents..."
local function conflict(e, data)
@@ -890,6 +1191,7 @@ function SyncEngine:_queueComparison(entry, key, row)
if not handle then conflict(eng) return end
eng:_request(handle, err, function(e, res)
local data = type(res.data) == "table" and res.data or {}
if e:_newBoxMember(key) then return end
local fetched, observed = revision(data.rev), revision(row.rev)
if fetched and observed and fetched >= observed
and sameContents(entry.blob, data.blob) then
@@ -907,6 +1209,7 @@ end
function SyncEngine:_queueUpload(entry, key, force)
entry = snapshot(entry)
self:_enqueue(function(eng)
if eng:_newBoxMember(key) then return end
eng.phase = "uploading"
eng.status = "Uploading saves..."
local handle, err = eng.client:putSave({
@@ -941,10 +1244,12 @@ end
function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
self:_enqueue(function(eng)
if eng:_newBoxMember(key) then return end
eng.phase = "downloading"
eng.status = "Downloading saves..."
local handle, err = eng.client:getSave(version, playthroughId)
eng:_request(handle, err, function(e, res)
if e:_newBoxMember(key) then return end
local data = res.data or {}
if type(data.blob) ~= "string" or data.blob == "" then
e:_fail("the server sent no save data")
@@ -991,6 +1296,21 @@ function SyncEngine:resolveConflict(key, choice)
if row.key == key then index = i break end
end
if not index then return false, "no such conflict" end
local proposed = self.conflicts[index]
if not proposed.box and self:_newBoxMember(key) then return false, "This save is now linked to Box. Sync the complete collection." end
if proposed.box and choice ~= "local" and choice ~= "remote" then return false, "Choose one complete Box collection and its linked saves." end
local current
if proposed.box then
local why
current, why = self.box:snapshot(self.saves.list(), self.state, self._boxKeys)
if not current then return false, why end
if current.fingerprint ~= proposed.entry.fingerprint then
proposed.entry = current
proposed.localMeta = { summary = { name = "Box", boxCount = current.payload.meta.count }, savedAt = unixSeconds(current.payload.meta.savedAt) }
proposed.reviewMessage = "Box changed on this device. Review the updated collection before choosing. The other copy will be kept in a recovery backup."
return false, proposed.reviewMessage
end
end
local conflict = table.remove(self.conflicts, index)
local kept = {}
for _, row in ipairs(self.state.pendingConflicts or {}) do
@@ -998,7 +1318,10 @@ function SyncEngine:resolveConflict(key, choice)
end
self.state.pendingConflicts = kept
if choice == "local" then
if conflict.box then
if choice == "local" then self:_queueBoxUpload(current, conflict.remoteRev, conflict.remote)
else self:_queueBoxDownload(current.fingerprint, conflict.remote) end
elseif choice == "local" then
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
self:_queueUpload(conflict.entry, key, true)
elseif choice == "remote" then
+2
View File
@@ -47,6 +47,8 @@ function SyncState.sanitize(raw)
out.code1 = code(raw.code1)
out.code2 = code(raw.code2)
out.lastSyncAt = num(raw.lastSyncAt) or 0
out.boxRev = num(raw.boxRev)
out.boxFingerprint = str(raw.boxFingerprint)
if type(raw.revs) == "table" then
for key, rev in pairs(raw.revs) do
local n = num(rev)
+177
View File
@@ -0,0 +1,177 @@
local ops = require("src.sync.SyncWorkOps")(require)
local Work = {}
Work.POOL = 2
Work.MIN_BYTES = 16 * 1024
Work.ops = ops
local CMD, RESULT, QUIT = "sync_work_cmd", "sync_work_result", "sync_work_quit"
local WORKER = "src/sync/sync_worker.lua"
local workers = {}
local cmdCh, resCh, quitCh
local ready
local results = {}
local nextId = 0
local owned = setmetatable({}, { __mode = "k" })
local waits = setmetatable({}, { __mode = "k" })
local function pack(...) return { n = select("#", ...), ... } end
local function start()
if ready ~= nil then return ready end
local thread = love and love.thread
if type(thread) ~= "table" or type(thread.newThread) ~= "function"
or type(thread.getChannel) ~= "function" then
ready = false
return false
end
local ok = pcall(function()
cmdCh, resCh, quitCh = thread.getChannel(CMD), thread.getChannel(RESULT), thread.getChannel(QUIT)
quitCh:clear(); cmdCh:clear(); resCh:clear()
end)
if not ok then
cmdCh, resCh, quitCh, ready = nil, nil, nil, false
return false
end
for _ = 1, Work.POOL do
local made, th = pcall(thread.newThread, WORKER)
if made and th and pcall(function() th:start() end) then workers[#workers + 1] = th end
end
ready = #workers > 0
return ready
end
function Work.available()
return start()
end
local function drain()
if not resCh then return end
local msg = resCh:pop()
while msg do
if type(msg) == "table" and msg.id and results[msg.id] == false then results[msg.id] = msg end
msg = resCh:pop()
end
for _, th in ipairs(workers) do
local err = th.getError and th:getError()
if err then
for id, r in pairs(results) do
if r == false then results[id] = { id = id, lost = true } end
end
ready = false
break
end
end
end
function Work.submit(op, ...)
if not ops[op] or not start() then return nil end
nextId = nextId + 1
local id = nextId
if not pcall(cmdCh.push, cmdCh, { id = id, op = op, args = pack(...) }) then return nil end
results[id] = false
return id
end
function Work.poll(id)
drain()
local r = results[id]
if r == false then return nil end
results[id] = nil
return r or { id = id, lost = true }
end
function Work.forget(id)
if id ~= nil then results[id] = nil end
end
function Work.finish(r, op, args)
if not r or r.lost then return ops[op](unpack(args, 1, args.n)) end
if r.err then error(r.err, 0) end
local values = r.values or { n = 0 }
return unpack(values, 1, values.n or #values)
end
function Work.spawn(fn)
local co = coroutine.create(fn)
owned[co] = true
return co
end
function Work.abandon(co)
for id in pairs(waits[co] or {}) do Work.forget(id) end
waits[co] = nil
end
function Work.async()
local co = coroutine.running()
return co ~= nil and owned[co] == true and start() == true
end
local function heavy(args)
local bytes = 0
for i = 1, args.n do
local v = args[i]
if type(v) == "table" then return true end
if type(v) == "string" then bytes = bytes + #v end
end
return bytes >= Work.MIN_BYTES
end
local function await(co, ids)
local mine = waits[co] or {}
waits[co] = mine
for _, id in pairs(ids) do mine[id] = true end
local out = {}
while true do
local pending = false
for i, id in pairs(ids) do
if out[i] == nil then
local r = Work.poll(id)
if r then out[i] = r; mine[id] = nil else pending = true end
end
end
if not pending then return out end
coroutine.yield()
end
end
function Work.call(op, ...)
local args = pack(...)
if not heavy(args) or not Work.async() then return ops[op](...) end
local id = Work.submit(op, ...)
if not id then return ops[op](...) end
return Work.finish(await(coroutine.running(), { id })[1], op, args)
end
function Work.batch(op, list)
local out, ids = {}, {}
local async = Work.async()
for i, args in ipairs(list) do
args.n = args.n or #args
local id = async and heavy(args) and Work.submit(op, unpack(args, 1, args.n)) or nil
if id then ids[i] = id else out[i] = pack(ops[op](unpack(args, 1, args.n))) end
end
if next(ids) then
local done = await(coroutine.running(), ids)
for i, r in pairs(done) do out[i] = pack(Work.finish(r, op, list[i])) end
end
return out
end
function Work.shutdown()
if quitCh then quitCh:push(true) end
if cmdCh then
cmdCh:clear()
for _ = 1, #workers do cmdCh:push({ op = "quit" }) end
end
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
workers = {}
results = {}
cmdCh, resCh, quitCh, ready = nil, nil, nil, nil
end
pcall(function() require("src.core.SessionLifecycle").registerProcessShutdown(Work.shutdown) end)
return Work
+52
View File
@@ -0,0 +1,52 @@
return function(need)
local Json = need("src.link.Json")
local Serializer = need("src.core.SaveSerializer")
local Base64 = need("src.core.Base64")
local ops = {}
function ops.hash(body)
if love and love.data and love.data.hash then
local digest = love.data.hash("sha256", body)
return "sha256:" .. (digest:gsub(".", function(c) return ("%02x"):format(c:byte()) end))
end
return "sha512:" .. need("src.core.crypto.sha512").hex(body)
end
function ops.jsonDecode(raw, maxLength) return Json.decode(raw, maxLength) end
function ops.jsonEncode(value) return Json.encode(value) end
function ops.decode(body, limits) return Serializer.decode(body, limits) end
function ops.encode(value) return Serializer.encode(value) end
function ops.base64Encode(bytes) return Base64.encode(bytes) end
function ops.base64Decode(text) return Base64.decode(text) end
function ops.normalizeSave(blob, version, id, cart)
local save = Serializer.decode(blob)
if not save then return nil end
save.meta = type(save.meta) == "table" and save.meta or {}
save.meta.playthroughId = id
save.version = save.version or version
if cart then save.meta.cartId = cart end
return Serializer.encode(save)
end
function ops.fingerprint(blob, assets, bodies)
local state = Serializer.decode(blob)
local paths = {}
for key, member in pairs(state.syncMembers or {}) do
paths[member.path] = key
member.path, member.slotId = nil, nil
end
local function portable(entry)
entry.slotId = nil
for _, original in ipairs(entry.archives or {}) do portable(original) end
end
for _, box in ipairs(state.boxes) do for _, entry in pairs(box.mons) do portable(entry) end end
for _, departure in pairs(state.departures or {}) do
departure.path = paths[departure.path] or departure.path
portable(departure.entry)
end
return ops.hash(Serializer.encode({ box = state, assets = assets, saves = bodies }))
end
return ops
end
+43
View File
@@ -0,0 +1,43 @@
require("love.thread")
require("love.filesystem")
require("love.data")
local function need(name)
local loaded = package.loaded[name]
if loaded ~= nil then return loaded end
local mod = assert(love.filesystem.load(name:gsub("%.", "/") .. ".lua"))()
package.loaded[name] = mod
return mod
end
local ops = assert(love.filesystem.load("src/sync/SyncWorkOps.lua"))()(need)
local cmdCh = love.thread.getChannel("sync_work_cmd")
local resCh = love.thread.getChannel("sync_work_result")
local quitCh = love.thread.getChannel("sync_work_quit")
local function pack(...) return { n = select("#", ...), ... } end
while true do
local job = cmdCh:demand()
if quitCh:peek() ~= nil then break end
if type(job) == "table" then
if job.op == "quit" then break end
local fn = ops[job.op]
local args = type(job.args) == "table" and job.args or { n = 0 }
local result = fn and pack(pcall(fn, unpack(args, 1, args.n or #args)))
local reply
if not fn then
reply = { id = job.id, err = "unknown sync work " .. tostring(job.op) }
elseif not result[1] then
reply = { id = job.id, err = tostring(result[2]) }
else
local values = { n = result.n - 1 }
for i = 2, result.n do values[i - 1] = result[i] end
reply = { id = job.id, values = values }
end
if not pcall(resCh.push, resCh, reply) then
resCh:push({ id = job.id, lost = true })
end
end
end
+8 -3
View File
@@ -79,7 +79,10 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused)
local active = opts.active or opts.on
local invert = false
local fill, ink, stroke, strokeA, strokeWidth, doEmboss, doRing, glowA
if face == "selection" then
if face == "bare" then
ink = opts.ink or PAL.heading
doRing = focused
elseif face == "selection" then
-- A selected control keeps its geometry, dark face and label layout.
-- Selection is an inset accent outline, never a filled keycap.
fill = PAL.surface
@@ -139,7 +142,9 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused)
doRing = opts.ring
end
if G then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments)
if face ~= "bare" then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments)
end
if doEmboss then
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) or B.embossDisabled
Theme.emboss(x, y, w, h, es)
@@ -245,7 +250,7 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused)
Kit.textCenter(fname, shown, x, top + size + gap, w, ink)
elseif opts.icon then
local box = math.min(w, h)
local size = math.floor(box * (hasLabel and 0.42 or 0.5))
local size = math.floor(box * (hasLabel and 0.42 or 0.5) * (opts.iconScale or 1))
local gap = math.floor(7 * Kit.scale)
local shown = hasLabel
and Kit.ellipsize(
+2
View File
@@ -98,6 +98,8 @@ local function isMatchingFilter(name, isDir, mode)
return (ext == "gb" or ext == "gbc" or ext == "gba" or ext == "zip")
elseif mode == "save" then
return (ext == "sav" or ext == "lua")
elseif mode == "box" then
return (ext == "gci" or ext == "sav")
elseif mode == "mod" then
return (ext == "zip")
elseif mode == "cart" then
+61 -1
View File
@@ -61,6 +61,7 @@ local atlases = {
}
local cells = {}
Icons.NAMES = {}
Icons.ATLASES = atlases
for _, atlas in ipairs(atlases) do
for i, name in ipairs(atlas.names) do
Icons.NAMES[#Icons.NAMES + 1] = name
@@ -69,12 +70,71 @@ for _, atlas in ipairs(atlases) do
end
Icons.NAMES[#Icons.NAMES + 1] = "triangle-alert"
local vectors = {}
for _, name in ipairs({ "circle-help", "music", "play", "square", "monitor", "gamepad-2", "chart-no-axes-column" }) do
Icons.NAMES[#Icons.NAMES + 1] = name; vectors[name] = true
end
local MUSIC_NOTES = { { .3, .79 }, { .7, .68 } }
local CHART_BARS = { { .2, .45 }, { .45, .2 }, { .7, .32 } }
function Icons.has(name)
return name == "triangle-alert" or cells[name] ~= nil
return name == "triangle-alert" or vectors[name] == true or cells[name] ~= nil
end
function Icons.draw(name, x, y, size, color, alpha)
local g = love and love.graphics
if g and name == "circle-help" then
g.push("all")
g.setColor(color[1] / 255, color[2] / 255, color[3] / 255, alpha or 1)
g.setLineWidth(math.max(1, size * 0.075))
g.circle("line", x + size * .5, y + size * .5, size * .42)
g.line(x + size * .35, y + size * .36, x + size * .39, y + size * .29,
x + size * .53, y + size * .27, x + size * .64, y + size * .34,
x + size * .63, y + size * .43, x + size * .5, y + size * .51, x + size * .5, y + size * .57)
g.circle("fill", x + size * .5, y + size * .71, size * .04)
g.pop()
return
end
if g and vectors[name] then
g.push("all")
g.setColor(color[1] / 255, color[2] / 255, color[3] / 255, alpha or 1)
g.setLineWidth(math.max(1, size * .075))
if g.setLineJoin then g.setLineJoin("bevel") end
if name == "music" then
g.line(x + size * .42, y + size * .76, x + size * .42, y + size * .25,
x + size * .82, y + size * .15, x + size * .82, y + size * .65)
for i = 1, #MUSIC_NOTES do
local note = MUSIC_NOTES[i]
if g.ellipse then g.ellipse("fill", x + size * note[1], y + size * note[2], size * .12, size * .075)
else g.circle("fill", x + size * note[1], y + size * note[2], size * .09) end
end
elseif name == "play" then
g.polygon("fill", x + size * .28, y + size * .18, x + size * .84, y + size * .5, x + size * .28, y + size * .82)
elseif name == "square" then
g.rectangle("fill", x + size * .22, y + size * .22, size * .56, size * .56, size * .06)
elseif name == "monitor" then
g.rectangle("line", x + size * .1, y + size * .15, size * .8, size * .58, size * .07)
g.line(x + size * .5, y + size * .73, x + size * .5, y + size * .9)
g.line(x + size * .32, y + size * .9, x + size * .68, y + size * .9)
elseif name == "gamepad-2" then
g.line(x + size * .15, y + size * .28, x + size * .85, y + size * .28,
x + size * .94, y + size * .65, x + size * .92, y + size * .78,
x + size * .78, y + size * .78, x + size * .66, y + size * .61,
x + size * .34, y + size * .61, x + size * .22, y + size * .78,
x + size * .08, y + size * .78, x + size * .06, y + size * .65, x + size * .15, y + size * .28)
g.line(x + size * .25, y + size * .46, x + size * .42, y + size * .46)
g.line(x + size * .335, y + size * .37, x + size * .335, y + size * .55)
g.circle("fill", x + size * .73, y + size * .42, size * .035)
g.circle("fill", x + size * .81, y + size * .5, size * .035)
elseif name == "chart-no-axes-column" then
for i = 1, #CHART_BARS do
local bar = CHART_BARS[i]
g.rectangle("fill", x + size * bar[1], y + size * bar[2], size * .11, size * (.82 - bar[2]))
end
g.line(x + size * .1, y + size * .9, x + size * .9, y + size * .9)
end
g.pop()
return
end
if g and name == "triangle-alert" then
g.push("all")
g.setColor(color[1] / 255, color[2] / 255, color[3] / 255, alpha or 1)
+34 -10
View File
@@ -325,6 +325,7 @@ function Kit.beginFrame(mx, my, clicked, wheel)
Kit.mouseX, Kit.mouseY = mx or 0, my or 0
Kit.mouseClicked = clicked and true or false
Kit.wheelY = wheel or 0
Kit._fieldHit, Kit._focusDrawn = false, false
local down = false
if love and love.mouse and love.mouse.isDown then
down = love.mouse.isDown(1) and true or false
@@ -333,6 +334,7 @@ function Kit.beginFrame(mx, my, clicked, wheel)
if not down then Kit._drag = nil end
Kit.resetClip()
Kit.blockClicks = false
Kit.occlude(nil)
if love and love.timer and love.timer.getTime then
Kit.time = love.timer.getTime()
end
@@ -376,6 +378,7 @@ end
-- Anything typed while no field had focus is dropped here rather than
-- replayed into the next field that gets clicked.
function Kit.endFrame()
if Kit.focus and ((Kit.mouseClicked and not Kit._fieldHit) or not Kit._focusDrawn) then Kit.blur() end
for i = #edits, 1, -1 do edits[i] = nil end
Kit.wheelY = 0
Kit._activateId = nil
@@ -567,6 +570,10 @@ function Kit.keypressed(key)
if key == "backspace" then edits[#edits + 1] = "\b" return true
elseif key == "return" or key == "kpenter" or key == "escape" then
edits[#edits + 1] = "\r" return true
elseif key == "tab" or key == "up" or key == "down" then
Kit.blur()
Kit.navigate(key == "up" and "up" or "down")
return true
end
-- printable keys arrive through textinput; everything else falls through
return false
@@ -591,6 +598,7 @@ function Kit.gamepadpressed(button)
if FileBrowser.active then
return FileBrowser.gamepadpressed(action)
end
if Kit.focus and action == "b" then Kit.blur() return true end
if action == "dpup" then Kit.navigate("up") return true
elseif action == "dpdown" then Kit.navigate("down") return true
elseif action == "dpleft" then Kit.navigate("left") return true
@@ -605,24 +613,38 @@ function Kit.blur()
end
-- -------------------------------------------------------------- hit testing
local occluderBox = { x = 0, y = 0, w = 0, h = 0 }
local occluderBoxes = {}
Kit._occluders = 0
Kit._occluder = nil
Kit._overlay = false
function Kit.occlude(x, y, w, h)
if x == nil then
Kit._occluders = 0
Kit._occluder = nil
return
end
occluderBox.x, occluderBox.y, occluderBox.w, occluderBox.h = x, y, w, h
Kit._occluder = occluderBox
local n = Kit._occluders + 1
local o = occluderBoxes[n]
if not o then
o = {}
occluderBoxes[n] = o
end
o.x, o.y, o.w, o.h = x, y, w, h
Kit._occluders = n
Kit._occluder = occluderBoxes[1]
end
function Kit.occluded()
local o = Kit._occluder
if not o or Kit._overlay then return false end
return Kit.mouseX >= o.x and Kit.mouseX <= o.x + o.w
and Kit.mouseY >= o.y and Kit.mouseY <= o.y + o.h
if Kit._occluders == 0 or Kit._overlay then return false end
local mx, my = Kit.mouseX, Kit.mouseY
for i = 1, Kit._occluders do
local o = occluderBoxes[i]
if mx >= o.x and mx <= o.x + o.w and my >= o.y and my <= o.y + o.h then
return true
end
end
return false
end
-- A widget inside a clip region can sit at coordinates outside the visible
@@ -634,7 +656,7 @@ function Kit.hit(x, y, w, h)
and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then
return false
end
if Kit._occluder and Kit.occluded() then return false end
if Kit._occluders > 0 and Kit.occluded() then return false end
return Kit.mouseX >= x and Kit.mouseX <= x + w
and Kit.mouseY >= y and Kit.mouseY <= y + h
end
@@ -914,6 +936,7 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
value = VirtualKeyboard.text
end
if Kit.press(x, y, w, h) then Kit._fieldHit = true end
if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end
local focused = (Kit.focus == id)
if focused then
@@ -928,6 +951,7 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
value = value .. e
end
end
if focused then Kit._focusDrawn = true end
end
if G then
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
@@ -965,11 +989,11 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix, compact)
local gap = 8 * Kit.scale
idPrefix = idPrefix or "pager"
if Kit.button(x, y, bw, h, "< Prev", { kind = "ghost", font = "small",
if Kit.button(x, y, bw, h, "Prev", { kind = "ghost", font = "small", icon = "chevron-left",
enabled = page > 1, id = idPrefix .. ":prev" }) then
page = math.max(1, page - 1)
end
if Kit.button(x + bw + gap, y, bw, h, "Next >", { kind = "ghost",
if Kit.button(x + bw + gap, y, bw, h, "Next", { kind = "ghost", icon = "chevron-right",
font = "small", enabled = page < pages, id = idPrefix .. ":next" }) then
page = math.min(pages, page + 1)
end
+2
View File
@@ -12,10 +12,12 @@ Transition.DURATIONS = {
}
Transition.LAYERS = { "tabs", "online", "modal" }
Transition.LOCAL = { "box" }
Transition.PASSIVE = { "toast" }
local ALL = {}
for i = 1, #Transition.LAYERS do ALL[#ALL + 1] = Transition.LAYERS[i] end
for i = 1, #Transition.LOCAL do ALL[#ALL + 1] = Transition.LOCAL[i] end
for i = 1, #Transition.PASSIVE do ALL[#ALL + 1] = Transition.PASSIVE[i] end
local layers = {}
+124
View File
@@ -0,0 +1,124 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Serializer = require("src.core.SaveSerializer")
local Catalog = require("src.box.Catalog")
local CacheFs = require("src.import.CacheFs")
local Sprites = require("src.online.OnlineSprites")
local MonAnim = require("src.core.game3.mon_anim")
local GameVersion = require("src.core.GameVersion")
local Data = MonAnim.Data
local bodies, images, draws, quads = {}, {}, {}, 0
local previousRead, previousArt = CacheFs.readAt, Catalog.art
local previousData = Data._cache
Data._cache = { sentinel = true }
CacheFs.readAt = function(path) return bodies[path] end
local front = {
functions = { [0] = "Anim_VerticalSquishBounce" },
animIds = { [1] = 0, [308] = 0 }, animDelays = { [1] = 2 },
species = { [1] = { 0, 1 }, [308] = { 0, 1 } },
lists = {
[0] = { cmds = { { frame = 0, duration = 0 }, { op = "end" } } },
[1] = { cmds = { { frame = 0, duration = 4 }, { frame = 1, duration = 4 },
{ frame = 0, duration = 4 }, { op = "end" } } },
},
}
bodies["emerald/data/generated/gba/pokemon/front_anims.lua"] = Serializer.encode(front)
local frameBytes = 64 * 64 * 4
bodies["emerald/data/generated/gba/pokemon/front_anim/1.rgba"] = string.rep("A", frameBytes) .. string.rep("B", frameBytes)
bodies["emerald/data/generated/gba/pokemon/front_anim_shiny/1.rgba"] = string.rep("S", frameBytes) .. string.rep("T", frameBytes)
local function image(raw, w, h)
local obj = { raw = raw, w = w or 64, h = h or 64 }
function obj:getDimensions() return self.w, self.h end
function obj:setFilter(a, b) self.filter = { a, b } end
function obj:release() self.released = true end
images[#images + 1] = obj
return obj
end
love.image.newImageData = function(w, h, _, raw)
return { w = w, h = h, raw = raw, release = function(self) self.released = true end }
end
love.graphics.newImage = function(pixels) return image(pixels.raw, pixels.w, pixels.h) end
love.graphics.newQuad = function(x, y, w, h) quads = quads + 1; return { x = x, y = y, w = w, h = h } end
love.graphics.draw = function(...) draws[#draws + 1] = { ... } end
local ordinary = { front = image("ordinary") }
local shiny = { front = image("shiny") }
local spotted = { front = image("personality-specific spots") }
local artVersion = "emerald"
Catalog.art = function(entry)
return entry.mon.species == 308 and spotted or entry.display.shiny and shiny or ordinary,
artVersion, { species = entry.mon.species, personality = entry.mon.personality }
end
local function entry(species, isShiny)
return { version = "red", generation = 1, mon = { species = species, personality = 1001 },
display = { national = species, shiny = isShiny } }
end
local Animation = require("src.box.Animation")
local mon = entry(1)
local before = Serializer.encode(mon)
local animation = Animation.new(mon)
T.check(animation ~= nil, "imported Emerald metadata starts the real summary animator")
T.eq(animation.sprite.animationData.version, "emerald", "animator uses an explicit Emerald provider")
T.eq(animation.frames[1].raw:sub(1, 1), "B", "alternate pose comes from the normal Emerald sheet")
T.same(animation.frames[1].filter, { "nearest", "nearest" }, "animated pixels use nearest filtering")
local alternate, transformed = false, false
for _ = 1, 300 do
Animation.update(animation, 1 / 60)
if animation.sprite.frame == 1 then
alternate = true
love.graphics.setColor(0.2, 0.3, 0.4, 0.5)
Animation.draw(animation, 10, 20, 128)
T.eq(draws[#draws][1], animation.frames[1], "detail drawing uses the current animation pose")
T.same({ love.graphics.getColor() }, { 0.2, 0.3, 0.4, 0.5 }, "animation restores the surrounding UI colour")
break
end
end
T.check(alternate, "real sprite command stream advances into the alternate pose")
for _ = 1, 300 do
Animation.update(animation, 1 / 60)
local transform = MonAnim.transform(animation.sprite)
transformed = transformed or transform.y2 ~= 0 or transform.sx ~= 1 or transform.sy ~= 1
if MonAnim.done(animation.sprite) then break end
end
T.check(transformed, "species callback animates its position or affine scale")
T.check(MonAnim.done(animation.sprite), "summary animation finishes instead of looping forever")
local n = animation.sprite.frames
Animation.update(animation, 1)
T.eq(animation.sprite.frames, n, "finished animations remain on their resting pose")
T.eq(Serializer.encode(mon), before, "animation leaves the stored Pokémon unchanged")
T.eq(GameVersion.get(), "red", "Emerald animation does not switch the active game")
T.eq(Data._cache.sentinel, true, "Emerald animation does not replace the active animation cache")
T.eq(CacheFs.prefix, "", "Emerald animation does not remount the imported cache")
local sparkling = Animation.new(entry(1, true))
T.eq(sparkling.frames[1].raw:sub(1, 1), "T", "shiny alternate poses retain the shiny palette")
local spinda = Animation.new(entry(308))
T.check(spinda ~= nil, "one-frame species still get their species motion")
T.eq(spinda.art.front, spotted.front, "Spinda motion retains its personality-specific spots")
T.eq(next(spinda.frames), nil, "Spinda never substitutes a generic second-frame picture")
local egg = entry(1); egg.mon.isEgg = true
T.eq(Animation.new(egg), nil, "eggs keep their egg picture")
artVersion = "sapphire"
T.eq(Animation.new(mon), nil, "non-Emerald fallback artwork stays valid and static")
artVersion = "emerald"
T.eq(Animation.new(entry(999)), nil, "unsupported animation species safely stays static")
Animation.reset()
bodies["emerald/data/generated/gba/pokemon/front_anims.lua"] = nil
T.eq(Animation.new(mon), nil, "older imported caches without animation metadata safely stay static")
T.check(sparkling.released and spinda.released, "refresh releases all current animated pictures")
T.check(animation.released, "finished animation pictures are also released on refresh")
T.eq(Animation.draw(animation, 0, 0, 64), false, "released pictures cannot be drawn")
local icon = { icon = image("icon sheet", 32, 64), iconW = 32, iconH = 32 }
Sprites.drawIcon(icon, 0, 0, 48, 0)
T.eq(draws[#draws][2].y, 0, "icon animation begins at its first pose")
Sprites.drawIcon(icon, 0, 0, 48, 1)
T.eq(draws[#draws][2].y, 32, "icon animation selects its second pose")
Sprites.drawIcon(icon, 0, 0, 48, 2)
T.eq(draws[#draws][2].y, 0, "icon poses loop within the actual sheet")
T.eq(quads, 2, "steady icon animation reuses its quads")
Sprites.drawIcon(icon, 0, 0, 48)
T.eq(draws[#draws][2].y, 0, "existing callers retain the original static icon")
CacheFs.readAt, Catalog.art, Data._cache = previousRead, previousArt, previousData
Animation.reset()
T.finish()
+98
View File
@@ -0,0 +1,98 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Serializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Catalog = require("src.box.Catalog")
local Store = require("src.box.Store")
local realSprites = require("src.online.OnlineSprites")
local bodies, available, calls = {}, {}, {}
local readAt = CacheFs.readAt
CacheFs.readAt = function(path) return bodies[path] end
local function put(version, path, value)
bodies[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value)
end
put("gold", "data/generated/pokemon.lua", {
PIKACHU = { dex = 25, name = "PIKACHU" }, UNOWN = { dex = 201, name = "UNOWN" } })
put("emerald", "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU", [201] = "UNOWN" })
put("emerald", "data/generated/gba/pokemon/national.lua", {
toNational = { [25] = 25, [201] = 201 }, toSpecies = { [25] = 25, [201] = 201 } })
put("sapphire", "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU" })
put("sapphire", "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25 }, toSpecies = { [25] = 25 } })
package.loaded["src.online.OnlineSprites"] = {
reset = function() calls = {} end,
ensure = function(version, mon)
calls[#calls + 1] = { version = version, mon = Store.copy(mon) }
if available[version] then return { front = {}, icon = {} } end
return { front = false, icon = false }
end,
}
Catalog.reset()
local mon = { species = "PIKACHU", nickname = "Spark", hp = 22, level = 10,
dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, opaque = "keep" }
local entry = { version = "gold", generation = 2, mon = mon, display = Catalog.describe("gold", mon) }
local before = Serializer.encode(entry)
available.emerald = true
local art, version = Catalog.art(entry)
T.eq(version, "emerald", "highest imported generation supplies art")
T.eq(calls[1].mon.species, 25, "canonical species maps to destination art id")
T.check(calls[1].mon.isShiny, "Gen 2 shiny DVs choose shiny Gen 3 art")
T.eq(Serializer.encode(entry), before, "art lookup does not alter stored Pokémon")
local n = #calls
Catalog.art(entry)
T.eq(#calls, n, "steady draws reuse resolved art")
local order = GameVersion.ORDER
GameVersion.ORDER = { "emerald", "gold", "sapphire" }
available.sapphire = true
Catalog.reset()
art, version = Catalog.art(entry)
T.eq(version, "emerald", "Emerald preference is independent of launcher column order")
local _, _, mappedMon = Catalog.art(entry)
T.eq(mappedMon.species, 25, "cached art retains its mapped animation species")
GameVersion.ORDER = order
local namesPath = "emerald/data/generated/gba/pokemon/names.lua"
local emeraldNames = bodies[namesPath]
bodies[namesPath] = nil
Catalog.reset()
art, version = Catalog.art(entry)
T.eq(version, "sapphire", "an unimported Emerald does not displace an available imported game")
bodies[namesPath] = emeraldNames
available.emerald, available.sapphire = nil, true
Catalog.reset()
art, version = Catalog.art(entry)
T.eq(version, "sapphire", "missing high-generation art falls back to another imported source")
T.eq(Serializer.encode(entry), before, "fallback leaves native data intact")
available.sapphire, available.gold = nil, true
Catalog.reset()
art, version = Catalog.art(entry)
T.eq(version, "gold", "lower generation is used when newer art is absent")
available.emerald = true
Catalog.reset()
local unown = { species = "UNOWN", unownLetter = 3, dvs = { attack = 1, defense = 1, speed = 1, special = 1 } }
local record = { version = "gold", generation = 2, mon = unown, display = Catalog.describe("gold", unown) }
Catalog.art(record)
local mapped = calls[1].mon
T.eq(require("src.core.game3.pokemon").unownLetter(mapped.personality), 2, "Unown letter C survives art generation change")
T.eq(unown.personality, nil, "display personality is not written into the native record")
local other = Store.copy(record)
other.mon.unownLetter = 4
local _, _, otherMon = Catalog.art(other)
T.eq(require("src.core.game3.pokemon").unownLetter(otherMon.personality), 3, "different Gen 2 Unown forms do not share the first form's cached art")
available.emerald, available.gold = nil, nil
Catalog.reset()
art, version = Catalog.art(entry)
T.eq(art, nil, "missing artwork is a safe placeholder")
T.eq(GameVersion.get(), "red", "artwork lookup does not switch the active game")
T.eq(CacheFs.prefix, "", "artwork lookup does not change the cache prefix")
CacheFs.readAt = readAt
package.loaded["src.online.OnlineSprites"] = realSprites
Catalog.reset()
T.finish()
+140
View File
@@ -0,0 +1,140 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Store = require("src.box.Store")
local Catalog = require("src.box.Catalog")
local Search = require("src.box.Search")
local Collection = require("src.box.Collection")
local Serializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Service = require("src.box.Service")
local originalRead = CacheFs.readAt
local tables = {}
local function put(version, path, value) tables[GameVersion.cachePrefix(version)..path] = Serializer.encode(value) end
local root = "data/generated/gba/pokemon/"
put("emerald", root.."names.lua", { [25] = "PIKACHU" })
put("emerald", root.."national.lua", { toNational = { [25] = 25 } })
put("emerald", root.."meta.lua", { [25] = { genderRatio = 127, growthRate = 0 } })
put("emerald", root.."stats.lua", { [25] = { hp = 35, atk = 55, def = 40, spe = 90, spa = 50, spd = 50 } })
put("emerald", root.."abilities.lua", { [25] = { 9, 31 } })
put("emerald", root.."ability_names.lua", { [9] = "STATIC", [31] = "LIGHTNING ROD" })
put("emerald", root.."move_names.lua", { [84] = "THUNDERSHOCK" })
put("emerald", root.."battle_moves.lua", { moves = { [84] = { pp = 30 } } })
put("emerald", root.."types.lua", { [25] = { 13 } })
put("emerald", root.."type_names.lua", { [13] = "ELECTRIC" })
put("emerald", "data/generated/gba/items/pack.lua", { items = { [4] = { name = "POKé BALL" } } })
put("emerald", "data/generated/gba/region_map/map_sections.lua", { sections = { [7] = { name = "Route 1" } } })
put("gold", "data/generated/pokemon.lua", { PIKACHU = { name = "PIKACHU", dex = 25,
genderRatio = 127, types = { "ELECTRIC" }, baseStats = { hp = 35, attack = 55, defense = 30,
speed = 90, specialAttack = 50, specialDefense = 40 } } })
put("gold", "data/generated/moves.lua", { THUNDERSHOCK = { name = "THUNDERSHOCK", pp = 30 } })
CacheFs.readAt = function(path) return tables[path] end
Catalog.reset()
local mon = { species = 25, personality = 13, exp = 125000, otId = 42, otSecretId = 3,
otName = "Alice", abilityNum = 0, metLocation = 7, metLevel = 5, pokeball = 4,
moves = { 84 }, pp = { 15 }, ppBonusesPacked = 3, friendship = 110, markings = 5,
ivs = { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }, evs = { spe = 252 } }
local original = Store.copy(mon)
local d = Catalog.describe("emerald", mon)
T.eq(d.level, 50, "boxed Gen3 level derives from native experience")
T.eq(d.nature, "Jolly", "nature derives from personality")
T.eq(d.gender, "Female", "gender uses species ratio and personality")
T.eq(d.ability, "STATIC", "stored ability bit wins over personality parity")
T.eq(d.hp, 110, "boxed HP is calculated from native base stats and IVs")
T.eq(d.speed, 156, "Speed includes EVs and Jolly nature")
T.eq(d.spAtk, 63, "Jolly reduces Special Attack")
T.eq(d.spDef, 70, "Special Defense has the correct nature key")
T.eq(d.location, "Route 1", "location reads selected game's cache")
T.eq(d.moveDetails[1].maxPP, 48, "PP Ups extend ROM move PP")
T.eq(d.moveDetails[1].pp, 15, "current native PP is retained")
T.same(mon, original, "summary computation leaves native Pokémon untouched")
local gapMon=Store.copy(mon)
gapMon.cartExtra={moveSlots={moves={0,84,0,0},pp={0,15,0,0}}}
gapMon.ppBonusesPacked=12
T.eq(Catalog.describe("emerald",gapMon).moveDetails[1].ppUps,3,"native empty move slots retain the correct PP Up bit position")
local state = Store.new()
for i = 1, 60 do
local m = Store.copy(mon); m.nickname = "Pika " .. i
state.boxes[1].mons[i] = { id = i, version = "emerald", generation = 3, mon = m, display = Catalog.describe("emerald", m) }
end
state.nextId = 61
local e = state.boxes[1].mons[1]
T.check(Search.compile('trainer:alice nature:jolly gender:female speed>=156 mark:triangle')(e), "metadata and native markings filter together")
T.check(not Search.compile('gender:male')(e), "Male filter does not include Female")
e.display.ability = "LIGHTNING ROD"
T.check(Search.compile('ability:"lightning rod" -egg')(e), "quoted fields support multiword names")
T.check(not Search.compile('mark:heart')(e), "missing native marking is excluded")
local refs = { { box = 1, slot = 1 }, { box = 1, slot = 2 } }
local overlap = assert(Store.moveGroup(state, refs, 1, 2))
T.eq(overlap.boxes[1].mons[1].id, 3, "overlapping group swaps displaced entry into vacated origin")
T.eq(overlap.boxes[1].mons[2].id, 1, "first selected entry lands at first destination")
T.eq(overlap.boxes[1].mons[3].id, 2, "group order remains intact")
T.eq(Store.count(overlap), 60, "overlap preserves every Pokémon exactly once")
local across = assert(Store.moveGroup(state, refs, 25, 59))
T.eq(across.boxes[25].mons[59].id, 1, "group reaches Box 25 slot 59")
T.eq(across.boxes[25].mons[60].id, 2, "group reaches final warehouse slot")
T.eq(Store.count(across), 60, "cross-box group preserves count")
T.check(not Store.moveGroup(state, refs, 25, 60), "group cannot overrun final slot")
T.check(not Store.moveGroup(state, { refs[1], refs[1] }, 2, 1), "duplicate group references rejected")
T.eq(state.boxes[1].mons[1].id, 1, "move planning never changes original state")
local marked = assert(Store.mark(across, { { box = 25, slot = 59 } }, 10, "battle team"))
T.eq(marked.boxes[25].mons[59].mon.markings, 10, "Gen3 changes native marking bits")
T.eq(marked.boxes[25].mons[59].tags, "battle team", "local tags persist separately")
local codec = require("src.save_convert.Gen3Save").forVersion("emerald")
local native = codec.fromPortMon(marked.boxes[25].mons[59].mon, {}, false)
local roundTrip = codec.toPortMon(assert(codec.decodeBoxMon(codec.encodeBoxMon(native))), false)
T.eq(roundTrip.markings, 10, "markings survive encrypted native PK3 round trip")
T.eq(roundTrip.personality, mon.personality, "marking edit preserves native personality")
local older = Store.copy(marked)
older.boxes[25].mons[59] = { id = 1, version = "gold", generation = 2,
mon = { species = "PIKACHU", level = 50, moves = { { id = "THUNDERSHOCK", pp = 15, maxPp = 30 } },
dvs = { attack = 15, defense = 10, speed = 10, special = 10 } }, display = {} }
older.boxes[25].mons[59].display = Catalog.describe("gold", older.boxes[25].mons[59].mon)
local localMark = assert(Store.mark(older, { { box = 25, slot = 59 } }, 8))
T.eq(localMark.boxes[25].mons[59].markings, 8, "Gen2 uses a local marking")
T.eq(localMark.boxes[25].mons[59].mon.markings, nil, "Gen2 native mon gains no fictitious marking field")
T.check(Search.compile('mark:heart')(localMark.boxes[25].mons[59]), "local marks participate in filters")
T.eq(localMark.boxes[25].mons[59].display.nature, nil, "Gen2 summary has no fictitious nature")
local preset = assert(Store.preset(across, "Pair", { { box = 25, slot = 59 }, { box = 25, slot = 60 } }))
preset = assert(Store.moveGroup(preset, { { box = 25, slot = 59 }, { box = 25, slot = 60 } }, 8, 10))
T.eq(Store.find(preset, preset.presets[1].ids[1]).box, 8, "team references survive rearrangement")
local themed = assert(Store.theme(preset, 8, "Forest"))
local encoded = Serializer.encode(themed)
T.eq(assert(Store.validate(assert(Serializer.decode(encoded)))).boxes[8].theme, "Forest", "Box theme persists through serialization")
T.eq(assert(Serializer.decode(encoded)).presets[1].name, "Pair", "preset name persists")
T.check(not Store.theme(preset, 8, "Unknown"), "invalid theme fails before persistence")
local report = Collection.dashboard(state, { dex = { caught = { [25] = true, [1] = true }, owned = { [25] = true } },
modData = { cartImport = { dexOwned = { 4 } } } })
T.eq(report.total, 60, "dashboard counts all holdings")
T.eq(report.species, 1, "dashboard deduplicates species")
T.eq(report.caught, 3, "dashboard combines native caught history without duplicate counting")
T.eq(report.duplicates[1].count, 60, "dashboard reports duplicate holdings")
T.eq(#report.missing, 385, "missing holdings differ from caught history")
Catalog.get("emerald").national.toNational[288]=263
T.eq(Collection.dashboard(state,{version="emerald",dex={caught={[288]=true}},
modData={cartImport={dexOwned={263}}}}).caught,1,"dashboard merges internal Gen3 IDs with native national history without double counting")
local files = { [Store.PATH] = Serializer.encode(preset), ["saves/emerald/slot1.lua"] = Serializer.encode({
version = "emerald", generation = 3, party = { Store.copy(mon) }, storage = { boxes = {} } }) }
local fs = { read = function(path) return files[path] end,
getInfo = function(path) return files[path] and { type = "file" } end,
createDirectory = function() return true end,
write = function(path, body) files[path] = body; return true end,
remove = function(path) files[path] = nil; return true end }
local source = { version = "emerald", path = "saves/emerald/slot1.lua", slotId = "slot1" }
local service = assert(Service.open(fs))
T.check(service:deployPreset(source, 1), "team deploy moves actual records into party")
T.eq(#assert(Serializer.decode(files[source.path])).party, 3, "party gets exactly two team members")
T.check(assert(Serializer.decode(files[source.path])).dex.caught[25] or assert(Serializer.decode(files[source.path])).dex.caught["25"],
"receiving a non-egg Pokémon registers caught history")
T.eq(Store.count(service.state), 58, "deployed members leave warehouse")
local beforeBox, beforeGame = files[Store.PATH], files[source.path]
T.check(not service:deployPreset(source, 1), "repeated deployment refuses missing members")
T.eq(files[Store.PATH], beforeBox, "refusal leaves warehouse unchanged")
T.eq(files[source.path], beforeGame, "refusal leaves game unchanged")
CacheFs.readAt = originalRead; Catalog.reset()
T.finish("Box collection")
+133
View File
@@ -0,0 +1,133 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Serializer = require("src.core.SaveSerializer")
local CacheFs = require("src.import.CacheFs")
local Catalog = require("src.box.Catalog")
local GameVersion = require("src.core.GameVersion")
local SaveData = require("src.core.SaveData")
local bodies, made, renders, volume, suspended = {}, {}, 0, 7, false
local oldRead, oldOptions = CacheFs.readAt, SaveData.loadOptions
CacheFs.readAt = function(path) return bodies[path] end
SaveData.loadOptions = function() return { boxCryVol = volume, pikaVol = 3 } end
local function put(version, path, value)
bodies[version .. "/" .. path] = type(value) == "table" and Serializer.encode(value) or value
end
local function source(sound)
local s = { sound = sound, plays = 0, stops = 0 }
function s:setVolume(value) self.volume = value end
function s:play() self.plays = self.plays + 1; self.playing = true end
function s:stop() self.stops = self.stops + 1; self.playing = false end
function s:release() self.released = true end
made[#made + 1] = s
return s
end
love.audio = { newSource = function(sound) renders = renders + 1; return source(sound) end }
love.sound = { newSoundData = function(count, rate, bits, channels)
local data = { count = count, rate = rate, bits = bits, channels = channels, peak = 0 }
function data:setSample(_, channel, value) self.peak = math.max(self.peak, math.abs(value or channel)) end
function data:release() self.released = true end
return data
end }
local chip = require("src.core.ChipAudio")
local oldChip, oldSuspended = chip.newCry, chip.isSuspended
chip.isSuspended = function() return suspended end
chip.newCry = function(data, species)
T.eq(data.audio.programBytes, "gold-ROM-programs", "chip cry carries its own version's programs")
T.eq(species, "PIKACHU", "old-generation cry uses canonical species key")
return source({ chip = true })
end
put("gold", "data/generated/pokemon.lua", { PIKACHU = { name = "PIKACHU", dex = 25 } })
put("gold", "data/generated/audio.lua", { generation = 2, programFile = "assets/generated/audio/programs.bin",
cries = { PIKACHU = { header = {}, pitch = 0, length = 0 } } })
put("gold", "assets/generated/audio/programs.bin", "gold-ROM-programs")
for _, version in ipairs({ "ruby", "emerald" }) do
put(version, "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU", [1] = "BULBASAUR" })
put(version, "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25, [1] = 1 }, toSpecies = { [25] = 25, [1] = 1 } })
put(version, "data/generated/gba/audio/index.lua", { cryIds = { [25] = 0, [1] = 1 },
cries = { [0] = { sampleId = 9 }, [1] = { sampleId = 9 } },
samples = { [9] = { offset = 0, size = 32, freq = 13379 * 1024 } } })
put(version, "data/generated/gba/audio/samples.bin", string.rep(string.char(35, 90, 200, 220), 8))
end
local Cry = require("src.box.Cry")
local function entry(version, species)
return { version = version, generation = GameVersion.generation(version), mon = { species = species }, display = { national = 25 } }
end
Catalog.reset()
local a, version = Cry.play(entry("gold", "PIKACHU"))
T.check(a ~= nil, "Gen 2 cry starts")
T.eq(version, "gold", "source-game cry wins over newer imported audio")
T.eq(a.plays, 1, "selection plays once")
local count = #made
local repeated = Cry.play(entry("gold", "PIKACHU"))
T.eq(repeated, a, "repeat selection reuses synthesized source")
T.eq(#made, count, "cached cry does not allocate a source again")
T.eq(a.plays, 2, "explicit repeat restarts the cry")
local b = Cry.play(entry("emerald", 25))
T.eq(a.playing, false, "new popup stops the preceding cry")
T.check(b ~= nil and b.playing, "Gen 3 cry plays from imported PCM")
T.eq(b.sound.channels, 2, "Gen 3 cry uses centered stereo")
T.check(b.sound.peak > 0, "actual cry renderer produces non-silent samples")
T.eq(b.volume, 1, "default Showcase Cry volume is applied")
volume = 2
T.eq(Cry.play(entry("emerald", 25)), b, "volume change keeps reusable source")
T.eq(b.volume, 2 / 7, "Showcase Cry setting updates next playback")
volume = 0
T.eq(Cry.play(entry("ruby", 25)), nil, "Showcase Cry mute prevents cry playback")
T.eq(b.playing, false, "muted selection also stops prior audio")
volume = 7
local egg = entry("emerald", 25); egg.mon.isEgg = true
local played = b.plays
T.eq(Cry.play(egg), nil, "egg does not reveal its species by crying")
T.eq(b.plays, played, "egg selection never starts cached species cry")
suspended = true
T.eq(Cry.play(entry("emerald", 25)), nil, "suspended device stays silent")
suspended = false
T.eq(GameVersion.get(), "red", "playback leaves active game unchanged")
T.eq(CacheFs.prefix, "", "playback leaves importer cache prefix unchanged")
local before = Serializer.encode(egg)
Cry.play(egg)
T.eq(Serializer.encode(egg), before, "cry lookup leaves stored Pokémon unchanged")
bodies["gold/data/generated/audio.lua"] = nil
Catalog.reset()
local fallback, fallbackVersion = Cry.play(entry("gold", "PIKACHU"))
T.check(fallback ~= nil, "missing source audio uses an imported fallback")
T.eq(fallbackVersion, "emerald", "fallback maps national species into the newest available game")
local unknown = entry("emerald", 999); unknown.display.national = nil
T.eq(Cry.play(unknown), nil, "unknown species is safely silent")
local oldAudio = love.audio
love.audio = nil
T.eq(Cry.play(entry("emerald", 25)), nil, "headless/audio-unavailable selection is safe")
love.audio = oldAudio
Cry.play(entry("emerald", 25))
Cry.stop()
T.eq(fallback.playing, false, "leaving Box stops playback")
Catalog.reset()
T.check(fallback.released, "cache refresh releases old audio sources")
local active = Cry.play(entry("emerald", 25))
Cry.setSuspended(true)
T.check(active.released, "device suspension releases cached sources")
package.loaded["src.core.ChipAudio"] = nil
T.eq(Cry.play(entry("emerald", 25)), nil, "Gen 3 suspension works without a loaded chip engine")
Cry.reset()
T.eq(Cry.play(entry("emerald", 25)), nil, "cache refresh cannot lift the device suspension gate")
Cry.setSuspended(false)
local resumed = Cry.play(entry("emerald", 25))
T.check(resumed and resumed.playing, "device reset allows new playback")
T.check(resumed ~= active, "device reset creates a fresh source")
package.loaded["src.core.ChipAudio"] = chip
local synth = require("src.core.ChipSynth")
local banks = synth._loadBanksForTest({ audio = { programFile = "wrong-active-game.bin",
bankOrder = { 2 }, programBytes = string.rep("G", 0x4000) } })
T.eq(banks[2], string.rep("G", 0x4000), "chip synth uses supplied ROM bytes, independent of mount")
local oldProfile = require("src.core.game3.profile").of("ruby").audio
T.eq(oldProfile.cryDefaultVolume, 125, "Ruby normal cry uses its own profile parameters")
T.check(renders >= 1, "real Gen 3 renderer was exercised")
chip.newCry, chip.isSuspended = oldChip, oldSuspended
CacheFs.readAt, SaveData.loadOptions = oldRead, oldOptions
Cry.reset()
T.finish()
+101
View File
@@ -0,0 +1,101 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local T = require("tests.harness")
local Details = require("src.import.BoxDetails")
local Kit = require("src.ui.kit.Kit")
Kit.fonts = require("src.ui.kit.Theme").fonts(1)
local Store = require("src.box.Store")
local entry = { generation = 3, version = "emerald", mon = {
ivs = { hp = 31, atk = 15, def = 0, spa = 14, spd = 18, spe = 20 }, evs = { spe = 252 },
contest = { cool = 0, beauty = 80, sheen = 15 }, ribbons = { cool = 2, champion = 1 },
}, display = { stats = { hp = 110, attack = 70, defense = 45, spAtk = 66, spDef = 73, speed = 156 },
experience = 125000, nature = "Jolly", ability = "STATIC", trainer = "Alice", trainerId = 42, secretId = 3,
metGame = 3, metLevel = 5, location = "Route 1", ball = "POKé BALL", gender = "Female", friendship = 110,
markings = 5, pokerus = 0x23, fateful = false, item = "", moveDetails = {
{ name = "THUNDERSHOCK", pp = 15, maxPP = 48, ppUps = 3 },
{ name = "THUNDER WAVE", pp = 0, maxPP = 20, ppUps = 0 },
} } }
local original = Store.copy(entry)
local model = Details.model(entry)
local function field(rows, label)
for _, row in ipairs(rows) do if row.label == label then return row end end
end
T.eq(field(model.overview, "Experience").value, "125,000", "experience is readable without changing its value")
T.eq(field(model.overview, "Markings").value, "Circle · Triangle", "marks have names rather than a mystery bitmask")
T.eq(field(model.overview, "Pokérus").value, "Strain 2\n3 days left", "virus data is separated into readable lines")
T.eq(field(model.overview, "Held item").value, "None", "no item is explicit")
T.eq(field(model.origin, "Trainer ID").value, "00042", "trainer ID keeps its five-digit form")
T.eq(field(model.origin, "Secret ID").value, "00003", "secret ID keeps leading zeroes")
T.eq(field(model.origin, "Met game").value, "Emerald", "met game uses the save codec's name")
T.eq(field(model.origin, "Fateful encounter").value, "No", "false remains a recorded value")
T.eq(#model.stats, 6, "Gen 3 has six separately formatted stat rows")
T.eq(field(model.stats, "Speed").value, 156, "current speed remains exact")
T.eq(field(model.stats, "Speed").gene, 20, "Speed IV is separate from current speed")
T.eq(field(model.stats, "Speed").effort, 252, "Speed effort remains exact")
T.eq(field(model.stats, "Defense").gene, 0, "zero IV is not replaced by missing")
T.eq(field(model.stats, "HP").effort, 0, "recorded sparse EV tables keep zero effort explicit")
T.eq(field(model.contest, "Cool").value, "0", "zero Contest values stay visible")
T.eq(field(model.ribbons, "Cool").value, "×2", "ribbon counts retain their rank count")
T.eq(field(model.ribbons, "Champion").value, "Earned", "one ribbon has an earned badge")
T.eq(model.moves[1].pp, 15, "move cards retain remaining PP")
T.eq(model.moves[1].maxPP, 48, "move cards retain boosted maximum PP")
T.eq(model.moves[1].ppUps, 3, "PP Ups remain separate")
T.eq(model.moves[2].pp, 0, "exhausted moves remain zero")
T.same(entry, original, "formatting leaves all native fields untouched")
local older = { generation = 2, version = "gold", mon = {
dvs = { attack = 15, defense = 10, speed = 10, special = 9 },
statExp = { hp = 100, attack = 400, defense = 500, speed = 600, special = 700 },
}, display = { item = "", stats = { hp = 100, attack = 60, defense = 80, speed = 90,
specialAttack = 75, specialDefense = 85 }, moveDetails = {} } }
local oldCopy = Store.copy(older)
local second = Details.model(older)
T.eq(#second.stats, 6, "Gen 2 retains six current stats")
T.eq(field(second.stats, "HP").gene, 9, "HP DV is derived from the four stored parity bits")
T.check(second.derivedHP, "derived HP DV is identified")
T.eq(field(second.stats, "Sp. Attack").gene, 9, "Gen 2 Special Attack uses its shared DV")
T.eq(field(second.stats, "Sp. Defense").gene, 9, "Gen 2 Special Defense uses its shared DV")
T.eq(field(second.stats, "Sp. Attack").effort, 700, "Gen 2 shared Special effort remains visible")
T.eq(field(second.stats, "Sp. Defense").effort, 700, "both special stats show the same stored effort")
T.eq(field(second.overview, "Nature"), nil, "older summaries gain no fictional nature")
T.eq(field(second.origin, "Secret ID"), nil, "older summaries gain no fictional secret ID")
T.same(older, oldCopy, "deriving the displayed DV leaves the original untouched")
older.generation, older.version = 1, "red"
older.display.stats = { hp = 100, attack = 60, defense = 80, speed = 90, special = 75 }
local first = Details.model(older)
T.eq(#first.stats, 5, "Gen 1 has its single Special stat")
T.eq(field(first.stats, "Special").effort, 700, "Gen 1 keeps its Special effort")
T.eq(field(first.overview, "Gender"), nil, "Gen 1 has no gender field")
local incomplete = Store.copy(entry)
incomplete.mon.ivs, incomplete.mon.evs = nil, nil
incomplete.display.trainer, incomplete.display.fateful, incomplete.display.pokerus = nil, nil, nil
local missing = Details.model(incomplete)
T.eq(field(missing.origin, "Original Trainer").value, "Not recorded", "unknown trainer stays explicit")
T.eq(field(missing.origin, "Fateful encounter").value, "Not recorded", "unknown encounter flag is not false")
T.eq(field(missing.stats, "HP").gene, nil, "missing genes are not manufactured as zero")
T.eq(field(missing.stats, "HP").effort, nil, "missing training is not manufactured as zero")
incomplete.display.moveDetails = {}; incomplete.mon.contest = nil
T.eq(#Details.model(incomplete).moves, 0, "empty move lists remain empty")
local printed, originalPrint = {}, love.graphics.print
love.graphics.print = function(value, ...) printed[#printed + 1] = tostring(value); return originalPrint(value, ...) end
for _, width in ipairs({ 330, 360, 680, 930 }) do
for _, section in ipairs(Details.SECTIONS) do
Kit.scale = 1; Kit.beginFrame(0, 0, false, 0); printed = {}
local ok, height = pcall(Details.draw, {}, entry, section, 10, 10, width, { s = 1 })
T.check(ok and height > 0, section .. " reflows at " .. width .. (not ok and ": " .. tostring(height) or ""))
if section == "Stats" then
local seen = table.concat(printed, "\n")
T.check(seen:find("Attack", 1, true) and seen:find("IV", 1, true) and seen:find("EV", 1, true), "stat headings and columns render")
T.check(not seen:find("attack 70", 1, true), "stats never fall back to a serialized paragraph")
elseif section == "Moves" then
T.check(table.concat(printed, "\n"):find("15 / 48", 1, true), "move PP is displayed separately")
end
Kit.endFrame()
end
end
love.graphics.print = originalPrint
T.finish()
+108
View File
@@ -0,0 +1,108 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Events = require("src.box.Events")
local Store = require("src.box.Store")
local Catalog = require("src.box.Catalog")
local CacheFs = require("src.import.CacheFs")
local Serializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local oldRead, tables = CacheFs.readAt, {}
for _, version in ipairs({ "ruby", "sapphire", "emerald", "firered", "leafgreen" }) do
local C = require("src.core.game3.constants").of(version)
local items = {}
for _, row in ipairs(Events.ROWS) do
local id = C:id("items",row.item)
if id then items[id] = { name=row.name,pocket="KEY_ITEMS" } end
end
for id=500,529 do items[id]={name="Key "..id,pocket="KEY_ITEMS"} end
local prefix=GameVersion.cachePrefix(version)
tables[prefix.."data/generated/gba/pokemon/names.lua"] = Serializer.encode({[25]="PIKACHU"})
tables[prefix.."data/generated/gba/items/pack.lua"] = Serializer.encode({items=items})
end
CacheFs.readAt=function(path) return tables[path] end;Catalog.reset()
T.check(not Events.allowed("oldSeaMap","emerald",2),"English Emerald cannot receive Old Sea Map")
T.check(Events.allowed("oldSeaMap","emerald",1)~=nil,"Japanese Emerald has historical Old Sea Map distribution")
T.check(not Events.allowed("aurora","emerald",1),"Japanese Emerald has no historical AuroraTicket distribution")
T.check(Events.allowed("aurora","firered",1)~=nil,"Japanese FireRed did receive AuroraTicket")
T.check(not Events.allowed("mystic","emerald",3),"French MysticTicket is not offered without historical distribution")
T.check(not Events.allowed("eon","firered",2),"FRLG does not receive Eon Ticket")
T.eq(#Events.list("gold",2),0,"Gen2 has no Gen3 ticket events")
local Items=require("src.core.game3.items_data")
Items.applyProfile("firered")
local beforeModel,beforePack,beforeCap=Items._bagModel,Items._pack,Store.copy(Items.CAPACITY)
local oldSpace=package.loaded["src.core.game3.scripting.space"]
local liveStore={flags={[500]=true},vars={[17000]=42}}
package.loaded["src.core.game3.scripting.space"]={store=liveStore}
local liveBody=Serializer.encode(liveStore)
for _, version in ipairs({"ruby","sapphire","emerald","firered","leafgreen"}) do
for _, row in ipairs(Events.list(version,2)) do
local original={version=version,flags={},vars={},modData={},bag=require("src.core.game3.bag").new(),storage={boxes={},items={}}}
local save,why=Events.receive(original,row.id,2)
T.check(save~=nil,version.." receives "..row.id..": "..tostring(why))
if save then
T.same(original.flags,{},"event planning leaves original flags untouched")
if row.id~="eon" then
T.eq(Events.status(save,row),"Ticket pending","Wonder Card is staged before item delivery")
local collected,err=Events.collect(save,row.id,2)
T.check(collected~=nil,version.." actual delivery collects "..row.id..": "..tostring(err))
if collected then
T.eq(Events.status(collected,row),"Collected","native receipt marks event collected")
T.check(not Events.receive(collected,row.id,2),"event cannot be received twice")
T.check(not Events.collect(collected,row.id,2),"event cannot be collected twice")
local C=require("src.core.game3.constants").of(version)
local flag=C:require("flags",row.id=="aurora" and "FLAG_ENABLE_SHIP_BIRTH_ISLAND" or "FLAG_ENABLE_SHIP_NAVEL_ROCK")
T.check(collected.flags[flag] or collected.flags[tostring(flag)],"delivery enables native island travel")
local codec=require("src.save_convert.Gen3Save").forVersion(version)
local image=assert(codec.encode({playerName="Owner",trainerId=42,secretId=97}))
local port=codec.toPortSave(assert(codec.decode(image)),version)
port.map=codec.mapFor(0,0)
port.flags,port.vars,port.bag=collected.flags,collected.vars,collected.bag
local exported=assert(codec.exportPort(port,{version=version,template=image,
toNational=function(n)return n end,itemId=tonumber,mapLayoutId=function()return 1 end}))
local reimported=codec.toPortSave(assert(codec.decode(exported)),version)
T.eq(Events.status(reimported,row),"Collected",version.." native save retains ticket receipt")
T.check(reimported.flags[flag] or reimported.flags[tostring(flag)],version.." native save retains travel flag")
end
else
T.eq(Events.status(save,row),"Collected","record mixing delivers Eon Ticket through native gift receiver")
T.check(not Events.receive(save,row.id,2),"record mixing Eon Ticket cannot duplicate")
local codec=require("src.save_convert.Gen3Save").forVersion(version)
local image=assert(codec.encode({playerName="Owner",trainerId=42,secretId=97}))
local port=codec.toPortSave(assert(codec.decode(image)),version)
port.map,port.flags,port.vars,port.bag=codec.mapFor(0,0),save.flags,save.vars,save.bag
local exported=assert(codec.exportPort(port,{version=version,template=image,
toNational=function(n)return n end,itemId=tonumber,mapLayoutId=function()return 1 end}))
local reimported=codec.toPortSave(assert(codec.decode(exported)),version)
T.eq(Events.status(reimported,row),"Collected",version.." native save retains record mixing Eon receipt")
local C=require("src.core.game3.constants").of(version)
local flag=C:require("flags",version=="emerald" and "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND" or "FLAG_SYS_HAS_EON_TICKET")
T.check(reimported.flags[flag] or reimported.flags[tostring(flag)],version.." native save retains Southern Island travel flag")
end
end
end
end
T.eq(Items._bagModel,beforeModel,"offline event operations restore active item profile")
T.eq(Items._pack,beforePack,"offline events restore live item pack pointer")
T.same(Items.CAPACITY,beforeCap,"offline events restore live bag capacities")
T.eq(Serializer.encode(liveStore),liveBody,"offline event delivery cannot change active game flags or variables")
package.loaded["src.core.game3.scripting.space"]=oldSpace
T.check(not Events.receive({version="emerald"},"oldSeaMap",2),"activation also rejects historical-language mismatch")
do
local original={version="firered",flags={},vars={},modData={},bag=require("src.core.game3.bag").new()}
local save=assert(Events.receive(original,"aurora",2))
for i=1,30 do save.bag.pockets.KEY_ITEMS[i]={id=499+i,qty=1} end
local body=Serializer.encode(save)
T.check(not Events.collect(save,"aurora",2),"full Key Items pocket refuses ticket delivery")
T.eq(Serializer.encode(save),body,"full bag does not consume receipt or mutate card")
end
for _,version in ipairs({"emerald","firered","leafgreen"}) do
local C=require("src.core.game3.constants").of(version)
local flag=C:require("flags",version=="emerald" and "FLAG_CAUGHT_HO_OH" or "FLAG_FOUGHT_HO_OH")
local save={version=version,flags={[flag]=true},vars={},modData={},bag=require("src.core.game3.bag").new()}
local row=assert(Events.allowed("mystic",version,2))
T.eq(Events.status(save,row),"Event already completed",version.." completed encounter is visible before receiving card")
T.check(not Events.receive(save,"mystic",2),"completed encounter cannot create undeliverable pending card")
end
CacheFs.readAt=oldRead;Catalog.reset()
T.finish("Box events")
+91
View File
@@ -0,0 +1,91 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local RomImporter = require("src.import.RomImporter")
local BoxPanel = require("src.import.BoxPanel")
local Files = {}
love.filesystem.getInfo = function(path)
return Files[path] and { type = "file", size = #Files[path] } or nil
end
love.filesystem.read = function(path) return Files[path] end
love.filesystem.write = function(path, data) Files[path] = data; return true end
love.filesystem.remove = function(path) Files[path] = nil; return true end
love.filesystem.getDirectoryItems = function() return {} end
local imports, gameImports, bytes, problem = 0, 0
BoxPanel.importGCI = function(_, data, why)
bytes, problem = data, why
if data then imports = imports + 1; return true end
return false
end
local function importer()
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab, imp._findFetch = "box", nil
imp._importSave = function() gameImports = gameImports + 1 end
return imp
end
local native = string.rep("N", 0x76040)
local raw = native:sub(65)
Files["picked_save.sav"] = native
local imp = importer()
imp.android, imp.pickerPendingKind = true, "box"
imp:focus(true)
T.eq(imports, 1, "legacy mobile pick reaches Box instead of game save import")
T.eq(gameImports, 0, "Box pick does not overwrite a game slot")
T.eq(bytes, native, "bridge extension does not alter GCI bytes")
T.eq(Files["picked_save.sav"], nil, "successful staged copy is consumed")
T.eq(imp.pickerPendingKind, nil, "completed pick clears Box routing")
Files["picked_save.sav"] = "bad"
imp.pickerPendingKind = "box"
imp:focus(true)
T.eq(imports, 1, "wrong file size never reaches Box parser")
T.check(problem:find("0x76040", 1, true), "bad-size notice describes accepted formats")
T.eq(gameImports, 0, "bad Box pick never falls through to normal saves")
Files["picked_save.sav"] = nil
Files["pick_error.flag"] = "cancelled: picked_save.sav"
imp.pickerPendingKind, imp.pickPending = "box", true
imp:focus(true)
T.check(problem:find("file manager", 1, true), "legacy cancel is shown on Box")
T.eq(imp.pickPending, nil, "legacy cancel stops polling")
local pickerKind
love.system.pickFile = function(kind) pickerKind = kind; return true end
love.system.getPickedFile = function() return "native.gci" end
imp = importer()
imp.nativePicker, imp.android = true, false
Files["native.gci"] = native
imp:chooseBoxImport()
T.eq(pickerKind, "sav", "existing bridge save picker accepts generic data files")
T.eq(imp.pickerPendingKind, "box", "modern picker records Box destination")
imp:update(0)
T.eq(imports, 2, "modern picker imports a GCI")
T.eq(Files["native.gci"], native, "ordinary selected input is preserved")
love.system.getPickedFile = function() return nil end
love.system.getPickError = function() return "Picker cancelled" end
imp.pickerPendingKind = "box"
imp:update(0)
T.eq(problem, "Picker cancelled", "modern cancel reports to Box")
local closed, readCount = false, 0
local drop = {
getFilename = function() return "native.sav" end,
getSize = function() return #raw end,
open = function() return true end,
read = function(_, size) readCount = readCount + 1; T.eq(size, #raw, "raw read is bounded"); return raw end,
close = function() closed = true end,
}
imp:filedropped(drop)
T.eq(imports, 3, "raw native save dropped on Box reaches Box")
T.check(closed, "dropped handle closes after read")
imp.tab = "emerald"
imp:filedropped(drop)
T.eq(gameImports, 1, "game tab retains its ordinary save routing")
T.eq(readCount, 1, "Box reader does not read a game-directed drop")
drop.getFilename = function() return "too-large.gci" end
drop.getSize = function() return 1024 * 1024 * 1024 end
imp:filedropped(drop)
T.eq(readCount, 1, "oversized GCI is refused before allocation")
T.eq(imports, 3, "oversized GCI cannot mutate storage")
T.finish()
+101
View File
@@ -0,0 +1,101 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local GCI = require("src.box.GCI")
local Store = require("src.box.Store")
local CacheFs = require("src.import.CacheFs")
local Serializer = require("src.core.SaveSerializer")
local oldRead = CacheFs.readAt
local tables = {
["data/generated/gba/pokemon/names.lua"] = Serializer.encode({[25]="PIKACHU"}),
["data/generated/gba/pokemon/move_names.lua"] = Serializer.encode({[84]="THUNDERSHOCK"}),
["data/generated/gba/pokemon/national.lua"] = Serializer.encode({toNational={[25]=25},toSpecies={[25]=25}}),
["data/generated/gba/items/pack.lua"] = Serializer.encode({items={}}),
}
CacheFs.readAt = function(path) return path:match("^ruby/") and tables[path:sub(6)] end
require("src.box.Catalog").reset()
local codec = require("src.save_convert.Gen3Save").forVersion("ruby")
local function word(n) return string.char(math.floor(n/16777216)%256,math.floor(n/65536)%256,math.floor(n/256)%256,n%256) end
local function put(s,off,value) return s:sub(1,off)..value..s:sub(off+#value+1) end
local logical = string.rep("\0",35*0x2000)
local originalMons = {}
for _, slot in ipairs({1,6,7,12,13,60,1500}) do
local raw = codec.encodeBoxMon(codec.fromPortMon({ species = 25, personality = slot*123,
nickname = "PIKA"..slot, otName = "Owner", otId = 42, otSecretId = 3,
exp = 1000, moves = { 84 }, pp = { 15 }, ivs = { hp = 7, atk = 8 }, evs = {},
friendship = 70, pokeball = 4, metLevel = 5, metGame = 2, language = 2 },{},false))
originalMons[slot] = raw
logical = put(logical,8+(slot-1)*84,raw..word(0x01020304):reverse())
end
for b = 1,25 do
logical = put(logical,0x1EC38+(b-1)*9,codec.encodeString("BOX "..b,9))
logical = put(logical,0x1ED19+b-1,string.char((b-1)%16))
end
local chunks = { string.rep("B",0x2000) }
for bank = 0,1 do
for i = 0,22 do
local id = 22-i
local block = string.rep("\0",4)..word(id)..word(bank+1)..logical:sub(id*GCI.BODY+1,(id+1)*GCI.BODY).."KEEP"
block = put(block,0,word(GCI.checksum(block)));chunks[#chunks+1]=block
end
end
for id = 0,11 do
local body = string.rep(string.char(id+1),GCI.BODY)
local block = string.rep("\0",4)..word(id)..word(0)..body.."TAIL"
block = put(block,0,word(GCI.checksum(block)));chunks[#chunks+1]=block
end
local header = put("GPXE01"..string.rep("\0",58),56,string.char(0,59))
local bytes = header..table.concat(chunks)
T.eq(#bytes,0x76040,"native GCI size includes 64-byte header and 59 physical blocks")
local decoded = assert(GCI.decode(bytes))
T.eq(decoded.active.bank,1,"newest complete bank is selected")
T.eq(decoded.logical:sub(9,88),originalMons[1],"logical mon bytes survive shuffled physical blocks")
local state = assert(GCI.import(Store.new(),bytes))
T.eq(Store.count(state),7,"import retains all occupied slots")
for _, slot in ipairs({1,6,7,12,13,60,1500}) do
local b,index = math.floor((slot-1)/60)+1,(slot-1)%60+1
T.eq(state.boxes[b].mons[index].mon.personality,slot*123,"native 12-column slot "..slot.." retains position")
T.eq(state.boxes[b].mons[index].depositorId,0x01020304,"depositor ID uses little endian")
end
T.eq(state.boxes[25].name,"BOX 25","native Box 25 name is decoded")
T.eq(state.boxes[25].gciWallpaper,8,"native wallpaper ID is preserved")
local exported = assert(GCI.export(state)); local roundTrip = assert(GCI.decode(exported))
T.eq(roundTrip.active.count,3,"export advances save count and writes other bank")
T.eq(roundTrip.header,header,"GCI header stays byte-exact")
T.eq(exported:sub(65,64+0x2000),bytes:sub(65,64+0x2000),"native banner stays byte-exact")
T.eq(exported:sub(64+47*0x2000+1),bytes:sub(64+47*0x2000+1),"photo and showcase blocks stay byte-exact")
for _, slot in ipairs({1,6,7,12,13,60,1500}) do
local at = 8+(slot-1)*84
T.eq(roundTrip.logical:sub(at+1,at+80),originalMons[slot],"unchanged PK3 "..slot.." stays byte-exact")
end
state = assert(Store.mark(state,{{box=1,slot=7}},8))
local edited = assert(GCI.decode(assert(GCI.export(state))))
T.eq(assert(codec.decodeBoxMon(edited.logical:sub(8+6*84+1,8+6*84+80))).markings,8,"edited markings export as native PK3")
local brokenNewest = put(bytes,64+(1+23)*0x2000+100,"X")
local recovered = assert(GCI.decode(brokenNewest))
T.eq(recovered.active.bank,0,"corrupt newest bank recovers complete older bank")
T.eq(recovered.recovered,true,"recovery is reported")
local brokenBoth = put(brokenNewest,64+0x2000+100,"X")
T.check(not GCI.decode(brokenBoth),"two corrupt banks are rejected")
T.check(not GCI.decode(bytes:sub(1,-2)),"truncated native file is rejected")
T.check(not GCI.decode("GXXE"..bytes:sub(5)),"another game's GCI is rejected")
T.check(not GCI.decode(put(bytes,56,string.char(0,58))),"incorrect header block count is rejected")
T.check(not GCI.import(Store.new(),"GPXJ"..bytes:sub(5)),"unsupported Japanese names are refused before mutation")
local wrapped=bytes
for bank=0,1 do
for i=0,22 do
local at=64+(1+bank*23+i)*GCI.BLOCK
local block=put(wrapped:sub(at+1,at+GCI.BLOCK),8,word(bank==0 and 4294967295 or 0))
block=put(block,0,word(GCI.checksum(block)))
wrapped=put(wrapped,at,block)
end
end
T.eq(assert(GCI.decode(wrapped)).active.bank,1,"save count rollover selects zero after FFFFFFFF")
T.check(not GCI.import(state,bytes),"native import cannot overwrite occupied warehouse")
state.boxes[1].name="Name too long"
T.check(not GCI.export(state),"overlong native name fails without silent truncation")
state.boxes[1].name="BOX 1"
state.boxes[1].mons[1].generation,state.boxes[1].mons[1].version=2,"gold"
T.check(not GCI.export(state),"earlier-generation records cannot silently become PK3")
CacheFs.readAt=oldRead;require("src.box.Catalog").reset()
T.finish("Box GCI")
+148
View File
@@ -0,0 +1,148 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Store = require("src.box.Store")
local Gifts = require("src.box.Gifts")
local Service = require("src.box.Service")
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 root = "data/generated/gba/pokemon/"
local names, national, meta, moves, battle = {}, { toSpecies = {}, toNational = {} }, {}, {}, {}
for i, award in ipairs(Gifts.AWARDS) do
local species = ({ 358, 288, 315, 172 })[i]
names[species], national.toSpecies[award.national], national.toNational[species] = award.name, species, award.national
meta[species] = { eggCycles = 20, growthRate = 0, genderRatio = 127 }
for _, move in ipairs(award.moves) do moves[move], battle[move] = "MOVE " .. move, { pp = 20 } end
end
for _, version in ipairs({ "ruby", "sapphire", "firered", "leafgreen", "emerald" }) do
for path, value in pairs({ ["names.lua"] = names, ["national.lua"] = national, ["meta.lua"] = meta,
["move_names.lua"] = moves, ["battle_moves.lua"] = { moves = battle } }) do
tables[GameVersion.cachePrefix(version)..root..path] = Serializer.encode(value)
end
end
CacheFs.readAt = function(path) return tables[path] end; Catalog.reset()
local source = { version = "firered", path = "saves/firered/slot1.lua", slotId = "slot1" }
local function filesystem(state, flags)
local save = { version = "firered", generation = 3, trainerId = 42, secretId = 97, name = "Owner",
meta = { playthroughId = "first-game" }, party = {}, modData = { cartImport = { boxFlags = flags or 0 } } }
local files = { [Store.PATH] = Serializer.encode(state), [source.path] = Serializer.encode(save) }
local mode, writes = {}, 0
local fs = { read = function(path) return files[path] end,
getInfo = function(path) return files[path] and { type = "file" } end,
createDirectory = function() return true end,
remove = function(path) files[path] = nil; return true end,
write = function(path, body)
writes = writes + 1
if writes == mode.fault then files[path] = body:sub(1, math.floor(#body / 2)); return nil, "interruption" end
files[path] = body; return true
end }
return fs, files, mode, save
end
local function stateWith(count, credited)
local state = Store.new()
for i = 1, count do
local mon = { species = 172, level = 5, personality = i, otId = 123, otSecretId = 45, moves = { 84 } }
local b, slot = math.floor((i - 1) / 60) + 1, (i - 1) % 60 + 1
state.boxes[b].mons[slot] = { id = i, generation = 3, version = "firered", mon = mon,
display = Catalog.describe("firered", mon), depositorId = i <= (credited or count) and 42 + 97 * 65536 or 99 }
end
state.nextId = count + 1
return state
end
local initialRng = require("src.core.game3.rng").getState()
for index, award in ipairs(Gifts.AWARDS) do
local mon = assert(Gifts.create("firered", index, 0))
T.eq(mon.personality, 0x0000E97E, award.name .. " uses high then low PID draws")
T.same(mon.ivs, { hp = 17, atk = 19, def = 20, spe = 16, spa = 13, spd = 12 }, award.name .. " uses next two IV draws")
T.same(mon.moves, award.moves, award.name .. " has complete native gift moves")
T.eq(mon.isEgg, true, award.name .. " starts as an egg")
T.eq(mon.language, 1, award.name .. " has original egg language")
T.eq(mon.metLocation, 255, award.name .. " has original egg met location")
end
T.same(require("src.core.game3.rng").getState(), initialRng, "offline gifts do not disturb active game's RNG")
for _, vector in ipairs({ {99,1,false}, {100,1,true}, {499,3,false}, {500,3,true}, {1498,5,false}, {1499,5,true}, {1500,7,false} }) do
local fs, files = filesystem(stateWith(vector[1]), vector[2])
local service = assert(Service.open(fs))
T.eq(assert(service:giftProgress(source)).eligible, vector[3], "threshold " .. vector[1] .. " flags " .. vector[2])
local before = files[source.path]
local ok = service:claimEgg(source, 0)
T.eq(not not ok, vector[3], "claim behavior matches threshold " .. vector[1])
if ok then
T.eq(Store.count(service.state), vector[1] + 1, "award creates exactly one warehouse record")
T.eq(Gifts.flags(assert(Serializer.decode(files[source.path]))), vector[2] + 2, "award advances native receipt ordinal")
else T.eq(files[source.path], before, "ineligible claim preserves native save") end
end
do
local fs, files = filesystem(Store.new(), 0x80)
local service = assert(Service.open(fs))
T.check(service:claimEgg(source, 0), "first connection can claim Swablu without depositing")
T.eq(Gifts.flags(assert(Serializer.decode(files[source.path]))), 0x81, "first egg sets usedBoxRS and preserves unrelated native bits")
T.eq(Store.count(service.state), 1, "first connection stores exactly one egg")
T.check(not service:claimEgg(source, 0), "reopening cannot repeat first egg")
local nextSave = assert(Serializer.decode(files[source.path]))
nextSave.meta.playthroughId = "new-game"
T.check(Gifts.identity(source, nextSave) ~= Gifts.identity(source, { meta = {playthroughId="first-game"}, trainerId=42,secretId=97 }),
"reused slot new playthrough has independent progress identity")
end
do
local fs = filesystem(stateWith(101, 99), 1)
T.eq(assert(assert(Service.open(fs)):giftProgress(source)).current, 99, "mixed-source entries do not credit another depositor")
local state = stateWith(100)
local _, _, _, save = filesystem(state, 1)
Gifts.recordProgress(state, source, save)
state.boxes[1].mons[1] = nil
T.eq(assert(Gifts.progress(state, source, save)).peak, 100, "withdrawing cannot erase reached progress")
state.boxes[1].mons[1] = Store.copy(state.boxes[1].mons[2]); state.boxes[1].mons[1].id = 101;state.nextId=102
T.eq(assert(Gifts.progress(state, source, save)).peak, 100, "redeposit does not increment a lifetime deposit counter")
end
do
local fs, files = filesystem(stateWith(1500), 0)
local service = assert(Service.open(fs)); local before = files[source.path]
T.check(not service:claimEgg(source, 0), "full warehouse refuses even the first gift")
T.eq(files[source.path], before, "full warehouse consumes no native receipt")
end
for fault = 1, 6 do
local fs, files, mode = filesystem(Store.new(), 0)
local service = assert(Service.open(fs)); mode.fault = fault
service:claimEgg(source, 0); mode.fault = nil
service = assert(Service.open(fs))
local count, flags = Store.count(service.state), Gifts.flags(assert(Serializer.decode(files[source.path])))
T.check(count == 0 and flags == 0 or count == 1 and flags == 1, "interrupted award " .. fault .. " restores consistent egg and receipt")
end
local Pokemon = package.loaded["src.core.game3.pokemon"]
package.loaded["src.core.game3.pokemon"] = { name = function(species) return names[species] end,
_names=names,movePp=function(move) return battle[move] and battle[move].pp or 0 end,
currentMapSec = function() return 88 end, applyStats = function() end,
playerSecretId = function(session) return session and tonumber(session.secretId) or 0 end }
local Breeding = require("src.core.game3.breeding")
for _, version in ipairs({"ruby","sapphire","emerald","firered","leafgreen"}) do
local codec = require("src.save_convert.Gen3Save").forVersion(version)
for index, award in ipairs(Gifts.AWARDS) do
local mon = assert(Gifts.create(version, index, 0))
Breeding.hatchMon({ version = version, name = "Owner", trainerId = 42, secretId = 97, gender = 0 }, mon)
local decoded = assert(codec.decodeBoxMon(codec.encodeBoxMon(codec.fromPortMon(mon, {}, false))))
T.eq(decoded.isEgg, false, award.name .. " hatches through engine and native egg bit clears")
T.eq(mon.level, 5, award.name .. " hatches at level 5")
T.eq(decoded.personality, 0xE97E, award.name .. " hatch preserves PID")
T.eq(decoded.otId, 42, award.name .. " hatch binds recipient trainer")
T.eq(decoded.moves[#award.moves], award.moves[#award.moves], award.name .. " hatch preserves special move")
end
for _, flags in ipairs({248,249,251,253,255}) do
local image=assert(codec.encode({playerName="Owner",trainerId=42,secretId=97,boxFlags=248}))
local port=codec.toPortSave(assert(codec.decode(image)),version)
port.map=codec.mapFor(0,0)
Gifts.setFlags(port,flags)
local exported=assert(codec.exportPort(port,{version=version,template=image,toNational=function(n)return n end,
itemId=tonumber,mapLayoutId=function()return 1 end}))
T.eq(assert(codec.decode(exported)).boxFlags,flags,version.." native .sav preserves reward receipt and unrelated upper bits")
local imported=codec.toPortSave(assert(codec.decode(exported)),version)
T.eq(Gifts.flags(imported),flags,version.." native receipt returns to Box on import")
end
end
package.loaded["src.core.game3.pokemon"] = Pokemon
CacheFs.readAt = oldRead; Catalog.reset()
T.finish("Box gifts")
+148
View File
@@ -0,0 +1,148 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Kit = require("src.ui.kit.Kit")
local LauncherView = require("src.import.LauncherView")
local RomImporter = require("src.import.RomImporter")
local Store = require("src.box.Store")
local BoxPanel = require("src.import.BoxPanel")
local Transition = require("src.ui.kit.Transition")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local found = false
for _, tab in ipairs(LauncherView.HEADER_TABS) do if tab.id == "box" then found = true end end
T.check(found, "Box is reachable from the launcher header")
local function launcher(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
local imp = RomImporter.new(function() end, { launcher = true })
local storage = Store.new()
imp.tab = "box"
imp._boxState = { service = { state = storage }, sources = {}, sourceIndex = 1,
box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {},
selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, sourcePage = 1 }
return imp
end
for _, size in ipairs({ { 360, 780 }, { 1024, 768 }, { 1360, 860 } }) do
local imp = launcher(size[1], size[2])
local seen, printed = {}, love.graphics.print
love.graphics.print = function(text, ...) seen[#seen + 1] = tostring(text); return printed(text, ...) end
local ok, why = pcall(LauncherView.draw, imp)
love.graphics.print = printed
T.check(ok, "Box draws at " .. size[1] .. "×" .. size[2] .. ": " .. tostring(why))
T.check(table.concat(seen, "\n"):find("1500", 1, true) ~= nil, "capacity is visible")
T.check((imp._tabScrollMax.box or 0) > 0, "Box content remains reachable through launcher scrolling")
end
local imp = launcher(1024, 768)
Transition.clear()
Kit.focus = "box-search"
imp:textinput("Pikachu")
LauncherView.draw(imp)
T.eq(imp._boxState.query, "Pikachu", "launcher text input reaches Box search")
imp:keypressed("backspace")
LauncherView.draw(imp)
T.eq(imp._boxState.query, "Pikach", "Box search handles backspace")
imp:_switchTab("mods")
T.eq(Kit.focus, nil, "leaving Box releases text focus")
T.eq(BoxPanel.textinput(imp, "x"), false, "Box does not consume another tab's input")
do
local Catalog = require("src.box.Catalog")
local Cry = require("src.box.Cry")
local Animation = require("src.box.Animation")
local previousCry, cried = Cry.play, {}
local previousAnimation, animated = Animation.new, {}
Animation.new = function(entry) if entry then animated[#animated + 1] = entry.id end end
Cry.play = function(entry) cried[#cried + 1] = entry and entry.id end
local previousArt, previousButton = Catalog.art, LauncherView.btn
Catalog.art = function() return nil end
local demo = launcher(1360, 860)
Transition.clear()
LauncherView.draw(demo)
local previousTime, now = love.timer.getTime, love.timer.getTime() + 1
love.timer.getTime = function() return now end
local s = demo._boxState
local function entry(id)
return { id = id, version = "emerald", generation = 3,
mon = { species = 25, level = id + 10 },
display = { name = "Pikachu " .. id, species = "Pikachu", level = id + 10,
types = "ELECTRIC", moves = "THUNDERSHOCK", item = "", national = 25 } }
end
for i = 1, 3 do s.service.state.boxes[1].mons[i] = entry(i) end
s._filteredBox = nil
s.sources = { { version = "emerald", label = "Emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" } }
s.pcRows = { { where = "box", box = 1, index = 4, key = "box:1:4", entry = entry(4) } }
local controls = {}
LauncherView.btn = function(owner, x, y, w, h, id, label, opts)
controls[id] = { x = x, y = y, w = w, h = h, enabled = opts.enabled ~= false }
return previousButton(owner, x, y, w, h, id, label, opts)
end
local function draw(click)
now = now + 0.3
Kit.scale = 1
Kit.blockClicks = false
Kit.beginFrame(click and click.x + click.w / 2 or 0, click and click.y + click.h / 2 or 0, click ~= nil, 0)
controls = {}
BoxPanel.draw(demo, 10, 10, 1340, 700, { s = 1, btnH = 38 })
Kit.endFrame()
local actions = demo._uiActions
demo._uiActions = {}
if actions then demo:runActions(actions) end
end
local function click(id) local rect = assert(controls[id], id); draw(rect); draw() end
draw()
T.eq(controls["box-withdraw"].enabled, false, "withdraw is disabled with no selection")
T.eq(controls["box-box-1:4"].enabled, false, "empty warehouse slot is disabled")
click("box-box-1:1")
click("box-box-1:2")
T.same(cried, { 1, 2 }, "each native grid popup plays its Pokemon cry")
T.same(animated, { 1, 2 }, "each grid popup starts its Pokemon animation")
draw(); draw()
T.eq(#cried, 2, "steady redraws do not repeat the cry")
BoxPanel.update(demo, 1 / 60)
T.eq(#animated, 2, "steady updates and redraws do not restart the animation")
T.check(s.selectedBox["1:1"] and s.selectedBox["1:2"], "multi-select retains both clicked slots")
T.eq(s.inspect.id, 2, "detail card follows the last clicked Pokemon")
T.eq(controls["box-withdraw"].enabled, true, "withdraw becomes enabled after selection")
click("box-summary")
T.eq(cried[#cried], 2, "opening full summary plays its Pokemon cry")
T.eq(animated[#animated], 2, "opening full summary restarts the Pokemon animation")
click("box-summary-next")
T.eq(cried[#cried], 3, "next summary plays the new Pokemon cry")
T.eq(animated[#animated], 3, "next summary starts its Pokemon animation")
T.check(s.selectedBox["1:1"] and s.selectedBox["1:2"] and not s.selectedBox["1:3"], "summary navigation does not change multiselect")
click("box-summary-prev")
T.eq(cried[#cried], 2, "previous summary plays its Pokemon cry")
T.eq(animated[#animated], 2, "previous summary starts its Pokemon animation")
click("box-summary-back")
click("box-multi")
click("box-box-1:3")
T.eq(s.selectedBox["1:1"], nil, "single selection clears previous multi-select refs")
T.check(s.selectedBox["1:3"] ~= nil, "single selection keeps the current Pokemon")
click("box-view-pc")
T.eq(controls["box-pc-box:1:1"].enabled, false, "empty PC slots cannot be selected")
T.eq(controls["box-pc-box:1:4"].enabled, true, "PC preserves native occupied slot positions")
click("box-pc-box:1:4")
T.eq(cried[#cried], 4, "game-save PC popup also plays its Pokemon cry")
T.eq(animated[#animated], 4, "game-save PC popup also starts its Pokemon animation")
click("box-pc-box:1:4")
click("box-select-visible")
T.check(s.selectedPC["box:1:4"] ~= nil, "select visible selects the occupied PC slot")
T.eq(s.selectedBox["1:1"], nil, "PC bulk selection does not also select warehouse Pokemon")
click("box-view-box")
s.railFirst = 22
draw()
click("box-thumb-box-25")
T.eq(s.box, 25, "box preview rail reaches the last warehouse box")
click("box-active-box-next")
T.eq(s.box, 1, "active box navigation wraps back from box 25")
LauncherView.btn, Catalog.art = previousButton, previousArt
love.timer.getTime = previousTime
Cry.play = previousCry
Animation.new = previousAnimation
end
T.finish()
+148
View File
@@ -0,0 +1,148 @@
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 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
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",[201]="UNOWN",[277]="TREECKO"})
put(v,root.."national.lua",{toNational={[25]=25,[201]=201,[277]=252},toSpecies={[25]=25,[201]=201,[252]=277}})
put(v,root.."meta.lua",{[25]={genderRatio=127,growthRate=0},[201]={genderRatio=255,growthRate=0},[277]={genderRatio=31,growthRate=0}})
put(v,root.."stats.lua",{[25]={hp=35,atk=55,def=40,spe=90,spa=50,spd=50},[201]={hp=48,atk=72,def=48,spe=48,spa=72,spd=48},[277]={hp=40,atk=45,def=35,spe=70,spa=65,spd=55}})
put(v,root.."move_names.lua",{[84]="THUNDERSHOCK",[57]="SURF",[354]="PSYCHO BOOST"})
put(v,root.."battle_moves.lua",{moves={[84]={pp=30},[57]={pp=15},[354]={pp=5}}})
put(v,root.."abilities.lua",{[25]={9},[201]={26},[277]={65}})
put(v,"data/generated/gba/items/pack.lua",{items={[68]={name="RARE CANDY"},[1]={name="MASTER BALL"}}})
else
local pokemon={PIKACHU={id="PIKACHU",name="PIKACHU",dex=25,growthRate="MEDIUM_FAST",genderRatio=127,catchRate=190,
baseStats={hp=35,attack=55,defense=30,speed=90,special=50,specialAttack=50,specialDefense=40}}}
if gen==2 then pokemon.UNOWN={id="UNOWN",name="UNOWN",dex=201,growthRate="MEDIUM_FAST",genderRatio=255,
baseStats={hp=48,attack=72,defense=48,speed=48,specialAttack=72,specialDefense=48}} end
put(v,"data/generated/pokemon.lua",pokemon)
put(v,"data/generated/moves.lua",{THUNDERSHOCK={name="THUNDERSHOCK",pp=30},SURF={name="SURF",pp=15}})
put(v,"data/generated/items.lua",{RARE_CANDY={name="RARE CANDY"},MASTER_BALL={name="MASTER BALL"}})
end
end
CacheFs.readAt=function(path) return tables[path] end;Catalog.reset()
local function entry(v)
local gen=GameVersion.generation(v)
local mon={species=gen==3 and 25 or "PIKACHU",nickname="PIKA",ot="ALICE",otName="ALICE",otId=42,level=50,
exp=125500,experience=125500,happiness=150,friendship=150,
dvs={attack=15,defense=10,speed=10,special=10},statExp={hp=65535,attack=65535,defense=65535,speed=65535,special=65535},
moves=gen==3 and {84} or {{id="THUNDERSHOCK",pp=15,ppUps=3,maxPp=48}}}
if gen==3 then
mon.dvs,mon.statExp=nil,nil
mon.personality,mon.otSecretId,mon.pp,mon.ppBonusesPacked=12345,3,{15},3
mon.ivs={hp=31,atk=31,def=20,spe=20,spa=20,spd=20}
mon.evs={hp=84,atk=84,def=84,spe=84,spa=84,spd=84}
end
return {id=1,version=v,generation=gen,mon=mon,display=Catalog.describe(v,mon),tags="original"}
end
for _,from in ipairs(GameVersion.ORDER) do
for _,to in ipairs(GameVersion.ORDER) do
if GameVersion.generation(from)~=GameVersion.generation(to) then
local original=entry(from)
local before=Serializer.encode(original)
local out,why=Migration.convert(original,to)
T.check(out~=nil,from.." → "..to..": "..tostring(why))
if out then
local d=Catalog.describe(to,out)
T.eq(d.level,50,"migration retains level")
T.eq(d.experience,125500,"migration retains progress within same growth curve")
T.eq(d.moveDetails[1].pp,15,"migration retains spent PP")
T.eq(d.moveDetails[1].ppUps,3,"migration retains PP Ups")
T.eq(out.otId,42,"migration retains visible trainer ID")
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")
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")
T.eq(round.ppBonusesPacked,3,"converted PP Ups survive native encoding")
end
end
T.eq(Serializer.encode(original),before,"preview never alters original")
end
end
end
local e=entry("emerald")
e.mon.status="PAR"
T.eq(assert(Migration.convert(e,"gold")).status,"paralyze","Gen3 status converts to native Gen2 status")
local statusEntry=entry("gold");statusEntry.mon.status="paralyze"
T.eq(assert(Migration.convert(statusEntry,"emerald")).status,"PAR","Gen2 status converts to native Gen3 status")
e.mon.heldItem=68
T.check(not Migration.convert(e,"red"),"Gen1 refuses every held item")
T.eq(assert(Migration.convert(e,"gold")).item,"RARE_CANDY","held item maps by exact name")
e.mon.heldItem=999
T.check(not Migration.convert(e,"gold"),"unknown item refuses migration")
e.mon.heldItem=nil;e.mon.moves={354}
T.check(not Migration.convert(e,"gold"),"newer move refuses migration")
e.mon.moves={84};e.mon.species=277
T.check(not Migration.convert(e,"gold"),"newer species refuses migration")
e=entry("gold");e.mon.isEgg=true
T.check(not Migration.convert(e,"red"),"Gen1 refuses eggs")
T.check(Migration.convert(e,"emerald")~=nil,"Gen2 eggs may migrate to Gen3")
e=entry("red");e.mon.ot="TOO LONG";e.mon.otName=e.mon.ot
T.check(not Migration.convert(e,"emerald"),"overlong trainer names refuse silent truncation")
e.mon.otName="🙂";e.mon.ot=e.mon.otName
T.check(not Migration.convert(e,"emerald"),"unrepresentable names refuse replacement glyphs")
e=entry("gold");e.mon.species="UNOWN"
local unown=assert(Migration.convert(e,"emerald"))
T.eq(require("src.core.game3.pokemon").unownLetter(unown.personality)+1,require("src.core.gen2.Unown").letterFromDVs(e.mon.dvs),"forward migration retains Unown letter")
local u={id=1,version="emerald",generation=3,mon=unown,display=Catalog.describe("emerald",unown)}
T.eq(require("src.core.gen2.Unown").letterFromDVs(assert(Migration.convert(u,"gold")).dvs),require("src.core.gen2.Unown").letterFromDVs(e.mon.dvs),"reverse migration retains Unown letter")
u.mon.personality=3+256*2+65536
T.check(not Migration.convert(u,"gold"),"Unown ! is not silently replaced")
do
local state=Store.new();state.nextId=2;state.boxes[1].mons[1]=entry("red")
local refs={{box=1,slot=1}}
local original=Serializer.encode(state.boxes[1].mons[1])
local preview=assert(Migration.preview(state,refs,"emerald"))
local converted=assert(Migration.apply(state,refs,"emerald",preview))
T.eq(Store.count(converted),1,"migration creates no extra warehouse Pokémon")
T.eq(converted.boxes[1].mons[1].id,1,"migration preserves preset/stage entry ID")
T.eq(Serializer.encode(converted.boxes[1].mons[1].archives[1]),original,"archive retains full original exactly")
T.check(Store.validate(assert(Serializer.decode(Serializer.encode(converted))))~=nil,"archives persist through serialization")
local restored=assert(Migration.restore(converted,refs[1],1))
local copy=Store.copy(restored.boxes[1].mons[1]);copy.archives=nil
T.eq(Serializer.encode(copy),original,"restore reproduces native original and local metadata")
T.eq(restored.boxes[1].mons[1].archives[1].generation,3,"restore archives replaced converted version")
T.check(not Migration.apply(converted,refs,"emerald",preview),"stale preview cannot apply twice")
preview.rows[1].converted.otId=999
T.check(not Migration.apply(state,refs,"emerald",preview),"tampered preview is rejected")
T.check(not Migration.restore(state,refs[1],1),"missing archive fails safely")
converted.boxes[1].mons[1].archives[1].id=9
T.check(not Store.validate(converted),"corrupt archive identity is rejected on load")
end
for fault=1,4 do
local state=Store.new();state.nextId=2;state.boxes[1].mons[1]=entry("red")
local files={[Store.PATH]=Serializer.encode(state)}
local writes,fail=0,true
local fs={read=function(p)return files[p]end,getInfo=function(p)return files[p] and {type="file"}end,
createDirectory=function()return true end,remove=function(p)files[p]=nil;return true end,
write=function(p,b)
writes=writes+1
if fail and writes==fault then files[p]=b:sub(1,math.floor(#b/2));return nil,"interruption" end
files[p]=b;return true
end}
local Service=require("src.box.Service")
local service=assert(Service.open(fs))
local refs={{box=1,slot=1}};local preview=assert(Migration.preview(service.state,refs,"emerald"))
service:migrate(refs,"emerald",preview);fail=false
local recovered=assert(Service.open(fs)).state
local mon=recovered.boxes[1].mons[1]
T.eq(Store.count(recovered),1,"interrupted migration retains one live record")
T.check(mon.generation==1 and not mon.archives or mon.generation==3 and mon.archives and mon.archives[1].generation==1,
"interrupted migration keeps conversion and original archive consistent")
end
CacheFs.readAt=oldRead;Catalog.reset()
T.finish("Box migration")
+303
View File
@@ -0,0 +1,303 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Store = require("src.box.Store")
local Organizer = require("src.box.Organizer")
local Service = require("src.box.Service")
local Records = require("src.box.Records")
local Transaction = require("src.box.Transaction")
local Serializer = require("src.core.SaveSerializer")
local Catalog = require("src.box.Catalog")
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local originalRead = CacheFs.readAt
local data = {}
local defs = { BULBASAUR = { dex = 1, name = "BULBASAUR", types = { "GRASS" } },
CHARMANDER = { dex = 4, name = "CHARMANDER", types = { "FIRE" } },
PIKACHU = { dex = 25, name = "PIKACHU", types = { "ELECTRIC" } },
MEWTWO = { dex = 150, name = "MEWTWO", types = { "PSYCHIC" } },
MEW = { dex = 151, name = "MEW", types = { "PSYCHIC" } } }
for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version)
if GameVersion.generation(version) == 3 then
local names, national = {}, { toNational = {}, toSpecies = {} }
for _, def in pairs(defs) do names[def.dex], national.toNational[def.dex], national.toSpecies[def.dex] = def.name, def.dex, def.dex end
data[prefix .. "data/generated/gba/pokemon/names.lua"] = Serializer.encode(names)
data[prefix .. "data/generated/gba/pokemon/national.lua"] = Serializer.encode(national)
else data[prefix .. "data/generated/pokemon.lua"] = Serializer.encode(defs) end
end
CacheFs.readAt = function(path) return data[path] end
Catalog.reset()
local function config(mode)
return { mode = mode or "sort", sort = "species", descending = false, fallback = "keep",
rules = { { firstBox = 1, lastBox = 1, match = "all", conditions = { { field = "shiny", value = true } } } } }
end
local function entry(id, species, level, shiny, egg)
local def = defs[species]
return { id = id, version = "emerald", generation = 3,
mon = { species = def.dex, personality = id * 1001, moves = { 1, 2 }, pp = { 12, 4 },
opaque = { raw = "\000\255", duplicate = id } },
display = { name = species, species = species, national = def.dex, level = level,
types = table.concat(def.types, " / "), shiny = shiny or false, egg = egg or false } }
end
local function state()
local s = Store.new()
s.nextId = 7
s.boxes[1].mons[8] = entry(1, "MEWTWO", 80)
s.boxes[3].mons[6] = entry(2, "MEW", 20, true)
s.boxes[1].mons[11] = entry(3, "PIKACHU", 5, false, true)
s.boxes[2].mons[9] = entry(4, "MEW", 50)
s.boxes[1].mons[4] = entry(5, "BULBASAUR", 15)
s.boxes[2].mons[1] = entry(6, "CHARMANDER", 17)
s.presets = { { name = "Team", ids = { 1, 5 } } }
s.stages = { [1] = { name = "Demo", background = "Forest", pattern = "Plain", music = "Silent",
pieces = { { entryId = 1, x = .5, y = .5, scale = 1, rotation = 0, flip = false } } } }
return s
end
local function inventory(s)
local out = {}
for _, row in ipairs(Store.search(s, "", "slot")) do out[row.entry.id] = row.entry end
return out
end
local original, cfg = state(), config()
local sorted, report = assert(Organizer.applyWarehouse(original, cfg))
T.eq(report.moved, 6, "all moved slots are reported")
T.eq(sorted.boxes[1].mons[1].id, 5, "lowest dex sorts first")
T.eq(sorted.boxes[1].mons[6].id, 4, "same species uses level to break equal nickname ties")
T.same(inventory(sorted), inventory(original), "whole native records and duplicate species survive")
T.same(sorted.presets, original.presets, "team references stay intact")
T.same(sorted.stages, original.stages, "showcase references stay intact")
T.eq(Organizer.applyWarehouse(sorted, cfg).revision, sorted.revision + 1, "pure planner creates an independent candidate")
local _, repeated = Organizer.applyWarehouse(sorted, cfg)
T.eq(repeated.moved, 0, "sort is idempotent")
cfg.box = 2
local one = assert(Organizer.applyWarehouse(original, cfg))
T.same(one.boxes[1], original.boxes[1], "single-box sorting leaves other boxes alone")
T.eq(one.boxes[2].mons[1].id, 6, "single-box sort keeps its own contents")
cfg.box, cfg.sort, cfg.descending = nil, "level", true
local descending = assert(Organizer.applyWarehouse(original, cfg))
T.eq(descending.boxes[1].mons[1].id, 1, "descending level is honored")
do
local missing, order = state(), config()
missing.boxes[1].mons[4].display.national = 0
order.descending = true
local result = assert(Organizer.applyWarehouse(missing, order))
T.eq(result.boxes[1].mons[6].id, 5, "unknown dex stays last even in descending order")
missing.boxes[1].mons[4].display.types = ""
order.sort, order.descending = "type", false
result = assert(Organizer.applyWarehouse(missing, order))
T.eq(result.boxes[1].mons[6].id, 5, "missing type is unknown instead of an empty-string first entry")
end
cfg = config("rules")
cfg.rules[2] = { firstBox = 2, lastBox = 2, match = "all", conditions = { { field = "species", value = "mew" } } }
cfg.fallback = "sort"
local routed, routing = assert(Organizer.applyWarehouse(original, cfg))
T.eq(routed.boxes[1].mons[1].id, 2, "earlier shiny rule wins over species")
T.eq(routed.boxes[2].mons[1].id, 4, "species match is exact and case insensitive")
T.eq(Store.find(routed, 1).box, 3, "Mewtwo does not match Mew")
T.eq(routing.ruleCounts[1], 1, "priority counts do not double count")
T.same(inventory(routed), inventory(original), "rules preserve every complete entry")
local _, routedAgain = Organizer.applyWarehouse(routed, cfg)
T.eq(routedAgain.moved, 0, "rules are idempotent")
cfg = config("rules")
cfg.rules[1] = { firstBox = 5, lastBox = 5, match = "all",
conditions = { { field = "egg", value = true }, { field = "type", value = "electric" } } }
local eggs = assert(Organizer.applyWarehouse(original, cfg))
T.eq(Store.find(eggs, 3).box, 5, "all conditions combine")
T.same(Store.find(eggs, 1), Store.find(original, 1), "unmatched warehouse entries keep exact slots")
cfg.rules[1].match = "any"
cfg.rules[1].conditions = { { field = "species", value = "MEW" }, { field = "type", value = "fire" } }
local any = assert(Organizer.applyWarehouse(original, cfg))
T.eq(Store.find(any, 6).box, 5, "any condition matches fire")
T.eq(Store.find(any, 4).box, 5, "any condition matches species")
local crowded = Store.new()
crowded.nextId = 62
for i = 1, 60 do crowded.boxes[1].mons[i] = entry(i, "BULBASAUR", i) end
crowded.boxes[2].mons[1] = entry(61, "BULBASAUR", 5)
cfg.rules[1].conditions = { { field = "species", value = "BULBASAUR" } }
cfg.rules[1].firstBox, cfg.rules[1].lastBox = 1, 1
local before = Serializer.encode(crowded)
local failed, reason = Organizer.applyWarehouse(crowded, cfg)
T.eq(failed, nil, "full rule destination blocks")
T.check(reason:find("more room", 1, true), "overflow error tells how to fix it")
T.eq(Serializer.encode(crowded), before, "overflow changes nothing")
cfg.rules[1].lastBox = 2
local spill = assert(Organizer.applyWarehouse(crowded, cfg))
T.eq(Store.count(spill), 61, "box ranges spill without dropping entries")
T.eq(spill.boxes[2].mons[1].display.level, 60, "each box fills before next")
cfg.rules[1].conditions[1] = { field = "bogus", value = "x" }
T.eq(Organizer.validate(cfg), nil, "unknown conditions are blocked")
T.eq(Organizer.validate({ mode = "sort", sort = "bogus", descending = false }), nil, "unknown sort is blocked")
do
local s, rules = state(), config("rules")
s.boxes[1].mons[8].display.moves = "TACKLE, THUNDERBOLT"
s.boxes[1].mons[8].display.item = "LEFTOVERS"
rules.rules[1] = { firstBox = 5, lastBox = 5, match = "all", conditions = {
{ field = "move", value = "thunderbolt" }, { field = "item", value = "leftovers" } } }
T.eq(Store.find(assert(Organizer.applyWarehouse(s, rules)), 1).box, 5, "move and held item combine")
rules.rules[1].conditions[1].value = "THUNDER"
T.eq(Store.find(assert(Organizer.applyWarehouse(s, rules)), 1).box, 1, "move names match exactly")
rules.rules[1].conditions = { { field = "shiny", value = false } }
T.eq(Store.find(assert(Organizer.applyWarehouse(s, rules)), 2).box, 3, "No boolean value excludes shiny")
end
local function filesystem()
local files, mode, writes = {}, {}, 0
return {
read = function(path) return files[path] end,
getInfo = function(path) return files[path] and { type = "file" } or nil end,
createDirectory = function() return true end,
remove = function(path) files[path] = nil; return true end,
write = function(path, body)
writes = writes + 1
if mode.at == writes then
if mode.partial then files[path] = body:sub(1, math.floor(#body / 2)) end
return nil, "write fault"
end
files[path] = body; return true
end,
}, files, mode, function() return writes end
end
do
local full = Store.new()
full.nextId = Store.BOXES * Store.SLOTS + 1
for b = 1, Store.BOXES do
for slot = 1, Store.SLOTS do
local id = (b - 1) * Store.SLOTS + slot
full.boxes[b].mons[slot] = entry(id, "BULBASAUR", (full.nextId - id) % 100)
end
end
local rules = config("rules")
rules.sort = "level"
rules.rules[1] = { firstBox = 1, lastBox = Store.BOXES, match = "all",
conditions = { { field = "species", value = "BULBASAUR" } } }
local arranged, fullReport = assert(Organizer.applyWarehouse(full, rules))
T.eq(fullReport.total, 1500, "full warehouse preview includes every slot")
T.eq(Store.count(arranged), 1500, "full warehouse applies without dropping entries")
T.same(inventory(arranged), inventory(full), "full warehouse preserves every complete entry")
T.check(Store.validate(arranged), "full warehouse remains valid")
local _, settled = Organizer.applyWarehouse(arranged, rules)
T.eq(settled.moved, 0, "full warehouse rules settle")
end
local Trade = require("src.online.Trade")
local originalPending = Trade.pendingSentAt
local pending = false
Trade.pendingSentAt = function() return pending and { {} } or {} end
local function fixture(version)
local generation = GameVersion.generation(version)
local mons = {}
for i, species in ipairs({ "MEWTWO", "BULBASAUR", "PIKACHU", "MEW", "BULBASAUR" }) do
mons[i] = { species = generation == 3 and defs[species].dex or species, nickname = "Mon " .. i,
level = 12 + i, hp = 31, moves = { generation == 3 and 1 or "TACKLE" }, pp = { 17 },
personality = i * 1001, otId = 42, otSecretId = 97, otName = "Demo",
dvs = { attack = 7, defense = 3, speed = 9, special = 4 }, opaque = { raw = "\000\255", id = i } }
end
local save = { version = version, generation = generation, party = { Store.copy(mons[1]) },
money = 123, rawImport = "original-template", modData = { cartImage = "opaque-cart-image" },
boxNames = { "First", "Second" }, currentBox = 2, unknown = { keep = true } }
if generation == 3 then
save.storage = { currentBox = 2, opaque = "keep", boxes = {
{ name = "First", wallpaper = 3, mons = { [2] = mons[1], [6] = mons[2], [9] = mons[3] } },
{ name = "Second", wallpaper = 4, mons = { [5] = mons[4], [8] = mons[5] } } } }
else save.boxes = { { mons[1], mons[2], mons[3] }, { mons[4], mons[5] } } end
return save
end
local function nativeInventory(save, generation)
local out = {}
for _, row in ipairs(Records.candidates(save, generation)) do
if row.where == "box" then out[#out + 1] = Serializer.encode(row.mon) end
end
table.sort(out); return out
end
for _, version in ipairs(GameVersion.ORDER) do
local fs, files, _, writes = filesystem()
local source = { version = version, slotId = "slot1", path = "saves/" .. version .. "/slot1.lua" }
local save, generation = fixture(version), GameVersion.generation(version)
local body = Serializer.encode(save)
files[source.path] = body
local service = assert(Service.open(fs))
local preview = assert(service:previewOrganize("pc", source, config()))
T.eq(writes(), 0, version .. " preview writes nothing")
T.check(service:applyOrganize(preview), version .. " applies to linked game")
local after = Serializer.decode(files[source.path])
T.same(nativeInventory(after, generation), nativeInventory(save, generation), version .. " preserves every native record")
T.same(after.party, save.party, version .. " leaves party alone")
T.same(after.unknown, save.unknown, version .. " keeps unrelated fields")
T.same(after.modData, save.modData, version .. " keeps cartridge image")
T.eq(after.rawImport, save.rawImport, version .. " keeps raw template")
T.eq(after.currentBox, save.currentBox, version .. " keeps selected box")
T.eq(files[source.path .. ".box-bak"], body, version .. " stores exact pre-sort backup")
T.eq(Records.list(after, generation, 1)[1].opaque.id, 2, version .. " writes actual PC arrangement")
if generation == 3 then
T.eq(after.storage.boxes[1].wallpaper, 3, version .. " keeps wallpaper")
T.eq(after.storage.boxes[1].name, "First", version .. " keeps box name")
else T.same(after.boxNames, save.boxNames, version .. " keeps box names") end
T.check(next(service.state.syncMembers) ~= nil, version .. " links organized game to Box sync")
T.eq(Store.count(service.state), 0, version .. " does not copy game Pokémon into warehouse")
local done = assert(service:previewOrganize("pc", source, config()))
T.eq(done.report.moved, 0, version .. " native sort is idempotent")
local rules = config("rules")
local destination = generation == 1 and 12 or 14
rules.rules[1] = { firstBox = destination, lastBox = destination, match = "all",
conditions = { { field = "species", value = "PIKACHU" } } }
local arranged, ruleReport = assert(Organizer.applyGame(save, version, rules))
T.eq(Records.list(arranged, generation, destination)[1].opaque.id, 3, version .. " routes game Pokémon to last box")
T.same(nativeInventory(arranged, generation), nativeInventory(save, generation), version .. " game rules preserve complete records")
T.same(arranged.party, save.party, version .. " game rules leave party alone")
T.check(ruleReport.ruleCounts[1] == 1, version .. " game rules report exact match")
rules.rules[1].lastBox = destination + 1
T.eq(Organizer.applyGame(save, version, rules), nil, version .. " unavailable destination blocks")
rules.rules[1].lastBox = destination
rules.rules[1].conditions = { { field = "tag", value = "private-label" } }
T.eq(Organizer.applyGame(save, version, rules), nil, version .. " rejects warehouse-only tags in Game PC")
end
do
local fs, files, _, writes = filesystem()
local service = assert(Service.open(fs))
T.check(service:saveOrganizer("My rules", "box", config("rules")), "rules save")
local reopened = assert(Service.open(fs))
T.same(reopened.state.organizerProfiles[1].config, config("rules"), "saved rules reload exactly")
local SyncBox = require("src.sync.SyncBox")
local snapshot = assert(SyncBox.new(fs):snapshot({}, {}))
local cloud = assert(SyncBox.validate(snapshot.payload))
T.same(cloud.state.organizerProfiles, reopened.state.organizerProfiles, "saved rules survive cloud snapshot validation")
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
files[source.path] = Serializer.encode(fixture("emerald"))
local preview = assert(service:previewOrganize("pc", source, config()))
files[source.path] = Serializer.encode(fixture("red"))
local n = writes()
T.eq(service:applyOrganize(preview), nil, "changed game refuses stale preview")
T.eq(writes(), n, "stale game writes nothing")
files[source.path] = preview.gameBefore
preview = assert(service:previewOrganize("pc", source, config()))
pending = true
T.eq(service:applyOrganize(preview), nil, "new pending trade blocks apply")
pending = false
files[Store.PATH] = Serializer.encode(state())
n = writes()
T.eq(service:applyOrganize(preview), nil, "changed warehouse refuses stale preview")
T.eq(writes(), n, "stale warehouse writes nothing")
files[Store.PATH] = service.body
T.check(service:deleteOrganizer(1), "preset deletes")
T.eq(#service.state.organizerProfiles, 0, "preset removed")
end
for _, partial in ipairs({ false, true }) do
for at = 1, 6 do
local fs, files, fault = filesystem()
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
local save = fixture("emerald")
files[source.path] = Serializer.encode(save)
local service = assert(Service.open(fs))
local preview = assert(service:previewOrganize("pc", source, config()))
fault.at, fault.partial = at, partial
service:applyOrganize(preview)
fault.at = nil
local recovered = assert(Service.open(fs))
T.same(nativeInventory(Serializer.decode(files[source.path]), 3), nativeInventory(save, 3), "interrupted sort preserves records")
T.eq(files[Transaction.PATH], nil, "interrupted sort journal clears")
T.check(Store.validate(recovered.state), "interrupted sort warehouse validates")
end
end
Trade.pendingSentAt, CacheFs.readAt = originalPending, originalRead
Catalog.reset()
T.finish("box_organizer")
+74
View File
@@ -0,0 +1,74 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Kit = require("src.ui.kit.Kit")
local Prompt = require("src.import.BoxPrompt")
local Tools = require("src.import.BoxTools")
local View = require("src.import.LauncherView")
local Importer = require("src.import.RomImporter")
local Transition = require("src.ui.kit.Transition")
local Store = require("src.box.Store")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local imp = Importer.new(function() end, { launcher = true })
imp.tab = "box"
local saved
local s = { service = { state = Store.new(), preset = function(_, name, refs)
saved = { name = name, refs = refs }; return true
end }, selectedBox = { ["1:1"] = { box = 1, slot = 1 } }, toolsOpen = true, toolsPage = "Teams" }
local controls = {}
Kit.layout(1360, 860)
Tools.draw(imp, s, 0, 0, 1000, { s = 1, btnH = 38 }, {
button = function(_, _, _, _, _, id, _, action) controls[id] = action end,
selected = function() return { { box = 1, slot = 1 } } end,
perform = function(_, fn) return fn() end,
})
controls["tools-save-team"]()
T.check(imp._boxPrompt ~= nil, "desktop team button opens a visible text modal")
T.eq(Kit.VirtualKeyboard.active, false, "desktop does not depend on handheld keyboard")
T.eq(View.modalKey(imp), "_boxPrompt", "prompt shields underlying launcher controls")
Transition.clear()
imp:textinput("Starter team")
imp:keypressed("return")
T.eq(saved.name, "Starter team", "physical keyboard saves the team name through its UI action")
T.same(saved.refs, { { box = 1, slot = 1 } }, "named team keeps the selected record references")
T.eq(imp._boxPrompt, nil, "confirmed prompt closes")
imp:keypressed("return")
T.eq(saved.name, "Starter team", "closed prompt cannot repeat its callback")
local result, calls = nil, 0
local function callback(value) result = value; calls = calls + 1 end
Prompt.open(imp, "Local tags", "", 3, callback)
imp:textinput("éABCD")
T.eq(imp._boxPrompt.text, "éAB", "prompt cap counts UTF-8 characters")
imp:keypressed("backspace")
T.eq(imp._boxPrompt.text, "éA", "backspace removes one complete character")
imp:keypressed("backspace"); imp:keypressed("backspace")
T.eq(imp._boxPrompt.text, "", "backspace removes accented character without leaving invalid bytes")
imp:textinput("tag")
imp:keypressed("escape")
T.eq(calls, 0, "Escape cancels without applying tags")
T.eq(imp._boxPrompt, nil, "Escape removes the modal")
Prompt.open(imp, "Stage name", "Garden", 64, callback)
imp._boxState = { service = { state = Store.new() }, sources = {}, sourceIndex = 1,
box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {}, selectedBox = {}, selectedPC = {} }
for _, size in ipairs({ { 390, 844 }, { 1360, 860 } }) do
love.graphics.getDimensions = function() return size[1], size[2] end
love.graphics.getPixelDimensions = love.graphics.getDimensions
T.check(pcall(View.draw, imp), "naming modal renders at " .. size[1] .. " pixel width")
end
Transition.clear()
imp:gamepadpressed(nil, "b")
T.eq(imp._boxPrompt, nil, "controller B cancels the prompt")
T.eq(calls, 0, "controller cancellation leaves the stage unchanged")
Prompt.open(imp, "Box name", "Names", 32, callback)
Prompt.close(imp, true)
T.eq(result, "Names", "modal Save uses the same confirmation callback")
T.eq(calls, 1, "Save invokes the callback once")
Prompt.open(imp, "Box name", "Discard", 32, callback)
imp:_switchTab("mods")
T.eq(imp._boxPrompt, nil, "leaving Box cancels a pending name edit")
T.eq(calls, 1, "leaving Box cannot commit an edit")
T.finish()
+161
View File
@@ -0,0 +1,161 @@
package.path="./?.lua;./?/init.lua;"..package.path
love=love or require("tests.love_stub")
local T=require("tests.harness")
local Store=require("src.box.Store")
local Showcase=require("src.box.Showcase")
local Serializer=require("src.core.SaveSerializer")
local Service=require("src.box.Service")
local state=Store.new();state.nextId=3
for i=1,2 do state.boxes[1].mons[i]={id=i,version="red",generation=1,
mon={species="PIKACHU",level=50,otId=42,ot="OWNER",nickname="PIKA",hp=10,
dvs={attack=i,defense=2,speed=3,special=4}},display={name="PIKA",species="PIKACHU",national=25}} end
local stage=assert(Showcase.add(Showcase.new(),{{box=1,slot=1},{box=1,slot=2}},state))
stage.name,stage.pattern,stage.music="My stage","Stars","Gentle"
stage.pieces[1].rotation,stage.pieces[1].scale,stage.pieces[1].flip=math.pi/2,2,true
stage.pieces[#stage.pieces+1]={kind="Arch",x=.5,y=.8,scale=1,rotation=0,flip=false}
local saved=assert(Showcase.save(state,5,stage))
T.eq(state.stages,nil,"stage editing leaves saved collection unchanged")
T.eq(saved.stages[5].name,"My stage","fifth stage slot saves its name")
local round=assert(Store.validate(assert(Serializer.decode(Serializer.encode(saved)))))
T.check(math.abs(round.stages[5].pieces[1].rotation-math.pi/2)<1e-12,"placement rotations survive persistence")
T.eq(round.stages[5].pieces[1].flip,true,"placement mirroring survives persistence")
T.eq(round.stages[5].music,"Gentle","selected music persists")
T.eq(#Showcase.resolve(round,stage),3,"furniture and Pokémon retain stage order")
local moved=assert(Store.moveGroup(round,{{box=1,slot=1}},25,60))
T.eq(Showcase.resolve(moved,stage)[1].entry.id,1,"stage references survive warehouse rearrangement")
T.check(not Showcase.save(state,6,stage),"stage slot outside 1–5 refuses save")
local bad=Store.copy(stage);bad.pieces[1].scale=0
T.check(not Showcase.save(state,1,bad),"invalid placement refuses save")
bad=Store.copy(stage);bad.pieces[1].x=0/0
T.check(not Showcase.save(state,1,bad),"NaN coordinates refuse save")
T.check(not Showcase.export(state,1,{}),"unsaved stage cannot be exported")
local files={[Store.PATH]=Serializer.encode(saved),["saves/red/slot1.lua"]=Serializer.encode({version="red",generation=1,
party={Store.copy(state.boxes[1].mons[2].mon)},boxes={}})}
local fs={read=function(p)return files[p]end,getInfo=function(p)return files[p] and {type="file"}end,
createDirectory=function()return true end,write=function(p,b)files[p]=b;return true end,remove=function(p)files[p]=nil;return true end}
local source={version="red",slotId="slot1",path="saves/red/slot1.lua"}
local Catalog=require("src.box.Catalog")
local oldCompatible=Catalog.compatible;Catalog.compatible=function()return true end
local service=assert(Service.open(fs))
T.check(service:withdraw(source,{{box=1,slot=1}},1),"withdrawal moves a staged record into a game")
T.eq(Showcase.resolve(service.state,stage)[1].missing,true,"withdrawn entry becomes a missing placement")
T.check(service:deposit(source,{{where="box",box=1,index=1}},2),"unique native identity can return to Box")
T.eq(Showcase.resolve(service.state,stage)[1].entry.id,1,"return restores the original stage reference")
T.eq(Store.count(service.state),2,"return creates no extra live record")
Catalog.compatible=oldCompatible
do
local themed=assert(Store.theme(saved,1,"Showcase","box/showcase/125.png","Gentle"))
local persisted=assert(Store.validate(assert(Serializer.decode(Serializer.encode(themed)))))
T.eq(Showcase.boxMusic(persisted,persisted.boxes[1]),"Gentle","applied theme keeps its music after reload")
persisted.stages[5].music="Night"
T.eq(Showcase.boxMusic(persisted,persisted.boxes[1]),"Gentle","later stage edits do not change the applied music")
local legacy=assert(Store.theme(saved,1,"Showcase","box/showcase/125.png"))
T.eq(Showcase.boxMusic(legacy,legacy.boxes[1]),"Gentle","existing stage exports resolve their music")
local cleared=assert(Store.theme(themed,1,"Forest"))
T.eq(cleared.boxes[1].showcaseMusic,nil,"changing theme clears associated music")
T.eq(Showcase.boxMusic(cleared,cleared.boxes[1]),"Silent","ordinary themes stop music")
T.check(not Store.theme(saved,1,"Showcase","box/showcase/125.png","invalid"),"unknown theme tracks are rejected")
local music=require("src.box.ShowcaseMusic")
local oldRequest,oldAudio=music.request,love.audio
local created,played,stopped,released=0,0,0,0
local pending=2
music.request=function()
if pending>0 then pending=pending-1;return nil,"pending" end
return {}
end
love.audio={newSource=function()
created=created+1
return {setLooping=function(_,v) T.eq(v,true,"theme music loops") end,
setVolume=function() end,play=function() played=played+1 end,stop=function() stopped=stopped+1 end,
release=function() released=released+1 end}
end}
love.sound=love.sound or {};local oldNew=love.sound.newSoundData
love.sound.newSoundData=love.sound.newSoundData or function() end
local panel=require("src.import.BoxPanel")
local imp={_boxState={service={state=themed},box=1}}
panel.update(imp,.016);panel.update(imp,.016)
T.eq(created,0,"Box theme renders across frames before creating a source")
T.eq(Showcase.currentMusic(),"Gentle","pending render keeps the requested track")
panel.update(imp,.016);panel.update(imp,.016)
T.eq(created,1,"Box theme creates one source across repeated frames")
T.eq(played,1,"applied Box music starts automatically")
imp._boxState.toolsPage="Showcase";panel.update(imp,.016)
T.eq(stopped,1,"leaving board stops theme music")
T.eq(released,1,"old source is released")
Showcase.playMusic({music="Night"});panel.update(imp,.016)
T.eq(Showcase.currentMusic(),"Night","editor playback survives its update")
imp._boxState.toolsPage=nil;panel.update(imp,.016)
T.eq(Showcase.currentMusic(),"Gentle","returning to Boxes resumes its theme")
imp._boxState.box=2;panel.update(imp,.016)
T.eq(Showcase.currentMusic(),"Silent","switching to plain Box stops track")
Showcase.stopMusic();music.request,love.audio,love.sound.newSoundData=oldRequest,oldAudio,oldNew
end
do
local music=require("src.box.ShowcaseMusic")
local oldNew=love.sound and love.sound.newSoundData
love.sound=love.sound or {}
local samples=0
love.sound.newSoundData=function(n) return {n=n,setSample=function() samples=samples+1 end} end
local calls,sound,why=0
repeat calls=calls+1;sound,why=music.request("Dream",0) until sound or why~="pending" or calls>100000
T.check(sound~=nil and calls>1,"song rendering yields across calls")
T.eq(music.request("Dream",0),sound,"rendered song is cached")
T.eq(samples,sound.n*2,"every sample is written once")
love.sound.newSoundData=oldNew
end
do
local files={["box/showcase/11.png"]="a",["box/showcase/12.png"]="b",["box/showcase/21.png"]="c",["box/showcase/note.txt"]="d"}
local fs={read=function(p)return files[p]end,getInfo=function(p)return files[p] and {type="file"}end,remove=function(p)files[p]=nil;return true end,
getDirectoryItems=function()local out={} for p in pairs(files) do out[#out+1]=p:match("[^/]+$") end return out end}
local kept=Store.new();kept.boxes[2].theme,kept.boxes[2].wallpaper="Showcase","box/showcase/12.png"
T.eq(Showcase.prune(kept,fs,"box/showcase/21.png"),1,"stale exported wallpaper is removed")
T.eq(files["box/showcase/11.png"],nil,"unreferenced export is gone")
T.check(files["box/showcase/12.png"] and files["box/showcase/21.png"] and files["box/showcase/note.txt"],"referenced, new and foreign files survive")
files["box/showcase/31.png"]="e";files[require("src.box.Transaction").PATH]="pending"
T.eq(Showcase.prune(kept,fs),0,"pending transfer blocks wallpaper cleanup")
end
do
local g=love.graphics
local oldRect=g.rectangle
local count=0
g.rectangle=function() count=count+1 end
local empty=Showcase.new();empty.pattern="Checks"
Showcase.draw(state,empty,0,0,0,100);Showcase.draw(state,empty,0,0,100,-1)
T.eq(count,0,"zero-sized stage draws nothing")
g.rectangle=oldRect
end
do
local g=love.graphics
local oldColor,oldScissor,oldSetScissor=g.setColor,g.getScissor,g.setScissor
local colors,clips={},{}
g.setColor=function(...) colors[#colors+1]={...} end
g.getScissor=function() return 10,20,100,50 end
g.setScissor=function(...) clips[#clips+1]={...} end
for _,background in ipairs(Showcase.BACKGROUNDS) do
colors,clips={},{}
local empty=Showcase.new();empty.background=background
Showcase.draw(state,empty,0,0,568,216)
T.check(math.max(colors[1][1],colors[1][2],colors[1][3])>.1,
background.." uses the launcher's 255 RGB color contract")
T.eq(colors[2][1],1,"pattern white remains white through Theme.col")
T.same(clips[1],{10,20,100,50},"stage respects the existing launcher clip")
T.same(clips[#clips],{10,20,100,50},"stage restores the launcher clip")
end
g.setColor,g.getScissor,g.setScissor=oldColor,oldScissor,oldSetScissor
end
do
local g=love.graphics
local oldLine=g.line
local calls,patterns={},{}
g.line=function(...) calls[#calls+1]={...} end
for _,pattern in ipairs({"Crosses","Bricks","Grid"}) do
calls={};local empty=Showcase.new();empty.pattern=pattern
Showcase.draw(state,empty,0,0,400,200)
patterns[pattern]=Serializer.encode(calls)
end
T.check(patterns.Crosses~=patterns.Bricks,"cross motifs differ from staggered bricks")
T.check(patterns.Crosses~=patterns.Grid,"cross motifs differ from continuous grid")
T.check(patterns.Bricks~=patterns.Grid,"brick seams alternate instead of square grid")
g.line=oldLine
end
T.finish("Box showcase")
+263
View File
@@ -0,0 +1,263 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Serializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Store = require("src.box.Store")
local Service = require("src.box.Service")
local Transaction = require("src.box.Transaction")
local Records = require("src.box.Records")
local Catalog = require("src.box.Catalog")
local tables = {}
local function put(version, path, value)
tables[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value)
end
for _, version in ipairs(GameVersion.ORDER) do
if GameVersion.generation(version) == 3 then
put(version, "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU" })
put(version, "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25 }, toSpecies = { [25] = 25 } })
put(version, "data/generated/gba/pokemon/move_names.lua", { [84] = "THUNDERSHOCK" })
put(version, "data/generated/gba/items/pack.lua", { items = { [139] = { name = "ORAN BERRY" } } })
else
put(version, "data/generated/pokemon.lua", { PIKACHU = { dex = 25, name = "PIKACHU", types = { "ELECTRIC" } } })
put(version, "data/generated/moves.lua", { THUNDERSHOCK = { name = "THUNDERSHOCK" } })
put(version, "data/generated/items.lua", {})
end
end
local readAt = CacheFs.readAt
CacheFs.readAt = function(path) return tables[path] end
Catalog.reset()
local function filesystem()
local files, writes, mode = {}, 0, {}
local fs = {
read = function(path) return files[path] end,
getInfo = function(path) return files[path] and { type = "file" } or nil end,
createDirectory = function() return true end,
remove = function(path)
if mode.remove == path then return nil, "remove fault" end
files[path] = nil; return true
end,
write = function(path, body)
writes = writes + 1
if writes == mode.at then
if mode.kind == "partial" then files[path] = body:sub(1, math.floor(#body / 2)) end
if mode.kind == "crash" then files[path] = body; error("simulated interruption") end
return nil, "write fault"
end
files[path] = body
return true
end,
}
return fs, files, mode
end
local function fixture(version)
local generation = GameVersion.generation(version)
local mon = { species = generation == 3 and 25 or "PIKACHU", nickname = "Sparky", level = 12,
hp = 31, maxHp = 31, otId = 42, otName = "Player", personality = 987654,
otSecretId = 92, moves = { generation == 3 and { move = 84, pp = 17 } or "THUNDERSHOCK" },
dvs = { attack = 7, defense = 3, speed = 9, special = 4 },
opaque = { raw = "\000\255\127", unknown = { [7] = "keep" } } }
if generation == 3 then mon.heldItem = 139 end
local save = { version = version, generation = generation, party = { Store.copy(mon) },
money = 19374, unknown = { keep = true }, rawImport = "original-template" }
if generation == 3 then
save.storage = { currentBox = 1, boxes = { { name = "First", wallpaper = 3, mons = { [4] = mon } } } }
else save.boxes = { { mon } }; save.currentBox = 1 end
return save, mon, { where = "box", box = 1, index = generation == 3 and 4 or 1 }
end
for _, version in ipairs(GameVersion.ORDER) do
local fs, files = filesystem()
local save, mon, ref = fixture(version)
local source = { version = version, slotId = "slot1", path = "saves/" .. version .. "/slot1.lua" }
local original = Serializer.encode(save)
files[source.path] = original
local service = assert(Service.open(fs))
T.eq(Store.count(service.state), 0, version .. " begins with empty Box")
T.check(service:deposit(source, { ref }, 1, original), version .. " deposits")
T.eq(Store.count(service.state), 1, version .. " stored exactly once")
T.same(Store.at(service.state, 1, 1).mon, mon, version .. " preserves the entire native record")
T.same(Serializer.decode(files[source.path]).unknown, save.unknown, version .. " retains unrelated save fields")
T.check(service:withdraw(source, { { box = 1, slot = 1 } }, 1), version .. " withdraws")
T.eq(Store.count(service.state), 0, version .. " leaves no duplicate in Box")
local after = Serializer.decode(files[source.path])
local returned = Records.list(after, GameVersion.generation(version), 1)
returned = returned[1]
T.same(returned, mon, version .. " returned record is unchanged")
T.eq(after.money, save.money, version .. " keeps money")
T.eq(after.rawImport, save.rawImport, version .. " keeps cartridge template")
T.eq(files[Transaction.PATH], nil, version .. " journal is cleared")
end
for _, kind in ipairs({ "fail", "partial", "crash" }) do
for at = 1, 6 do
local fs, files, mode = filesystem()
local save, mon, ref = fixture("ruby")
local source = { version = "ruby", slotId = "slot1", path = "saves/ruby/slot1.lua" }
files[source.path] = Serializer.encode(save)
local original = files[source.path]
local service = assert(Service.open(fs))
mode.at, mode.kind = at, kind
pcall(service.deposit, service, source, { ref }, 1, original)
mode.at = nil
local reopened = Service.open(fs)
if kind == "crash" and at == 1 then
T.check(reopened ~= nil, "journal interruption recovers")
else T.check(reopened ~= nil, kind .. " at " .. at .. " can reopen") end
if reopened then
local count = Store.count(reopened.state)
local native = Serializer.decode(files[source.path])
local inPC = Records.at(native, 3, ref) ~= nil
T.check((count == 1 and not inPC) or (count == 0 and inPC), kind .. " at " .. at .. " retains exactly one copy")
T.eq(files[Transaction.PATH], nil, kind .. " at " .. at .. " completes recovery")
if count == 1 then
T.eq(files[source.path .. ".bak"], files[source.path], kind .. " at " .. at .. " keeps game recovery consistent")
T.eq(files[Store.PATH .. ".bak"], files[Store.PATH], kind .. " at " .. at .. " keeps Box recovery consistent")
end
end
end
end
do
local fs, files = filesystem()
local save, _, ref = fixture("gold")
local source = { version = "gold", slotId = "slot1", path = "saves/gold/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
files[source.path] = Serializer.encode({ version = "gold", generation = 2, party = {}, boxes = {} })
T.check(not service:deposit(source, { ref }, 1, body), "stale selection is refused")
T.eq(Store.count(service.state), 0, "stale selection cannot put a different mon in Box")
files[source.path] = body
T.check(not service:deposit(source, { { where = "party", index = 1 } }, 1, body), "last healthy party mon stays")
T.eq(files[source.path], body, "party refusal is nonmutating")
T.check(service:deposit(source, { ref }, 1, body), "valid box deposit still succeeds")
local red = { version = "red", slotId = "slot1", path = "saves/red/slot1.lua" }
local redSave = fixture("red"); files[red.path] = Serializer.encode(redSave)
local original = files[red.path]
T.check(not service:withdraw(red, { { box = 1, slot = 1 } }, 1), "generation conversion is refused")
T.eq(files[red.path], original, "generation refusal preserves destination")
T.eq(Store.count(service.state), 1, "generation refusal preserves stored record")
T.check(service:move(1, 1, 25, 60), "last storage slot is reachable")
T.check(Store.at(service.state, 25, 60) ~= nil, "move retains entry")
T.check(service:rename(25, "Rare Pokémon"), "Box can be renamed")
T.eq(#Store.search(service.state, "THUNDERSHOCK"), 1, "move names are searchable")
T.eq(#Store.search(service.state, "Rare Pokémon"), 1, "Box names are searchable")
T.eq(#Store.search(service.state, "type:electric level<50 move:thundershock -egg"), 1,
"type, level, move and egg filters combine")
T.eq(#Store.search(service.state, "level>=50"), 0, "numeric level filter excludes lower levels")
T.eq(#Store.search(service.state, "game:gold species:pikachu"), 1, "game and species filters work")
T.eq(#Store.search(service.state, "shiny"), 0, "shiny filter respects native metadata")
end
do
local save = fixture("gold")
save.party[2] = { species = "PIKACHU", hp = 40, item = "FLOWER_MAIL" }
save.mail = { party = { [2] = { message = "hello" } }, box = { { message = "keep" } } }
local after = assert(Records.remove(save, 2, { { where = "party", index = 1 } }))
T.eq(after.mail.party[1].message, "hello", "remaining party mail shifts with its Pokémon")
T.same(after.mail.box, save.mail.box, "mailbox stays intact")
T.eq(save.mail.party[2].message, "hello", "source save is not modified during planning")
end
do
local fs, files = filesystem()
files[Store.PATH] = "return { broken = true }"
T.check(not Service.open(fs), "corrupt Box storage is refused")
T.eq(files[Store.PATH], "return { broken = true }", "corrupt storage is not overwritten with an empty Box")
end
do
local fs, files = filesystem()
local save, _, ref = fixture("emerald")
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
local Trade = require("src.online.Trade")
local previous = Trade.pendingSentAt
Trade.pendingSentAt = function() return { { species = 25 } } end
T.check(not service:deposit(source, { ref }, 1, body), "pending outgoing trade blocks deposit")
T.eq(files[source.path], body, "pending trade refusal preserves the game save")
T.eq(Store.count(service.state), 0, "pending trade refusal preserves Box")
Trade.pendingSentAt = previous
T.check(service:deposit(source, { ref }, 1, body), "deposit succeeds after pending trade finishes")
local full = Serializer.decode(files[source.path])
for i = 1, 30 do full.storage.boxes[1].mons[i] = Store.copy(save.party[1]) end
local fullBody = Serializer.encode(full); files[source.path] = fullBody
local boxBody = files[Store.PATH]
T.check(not service:withdraw(source, { { box = 1, slot = 1 } }, 1), "full destination PC box blocks withdrawal")
T.eq(files[source.path], fullBody, "full PC refusal preserves every destination record")
T.eq(files[Store.PATH], boxBody, "full PC refusal preserves the stored record")
end
do
local fs, files, mode = filesystem()
local save, _, ref = fixture("ruby")
local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
mode.at, mode.kind = 2, "fail"
T.check(not service:deposit(source, { ref }, 1, body), "target write fault leaves a recoverable journal")
T.check(files[Transaction.PATH] ~= nil, "journal remains durable before target recovery")
mode.at = nil
local changed = Store.copy(save); changed.money = 1
local changedBody = Serializer.encode(changed); files[source.path] = changedBody
local boxBody = files[Store.PATH]
local recovered = Service.open(fs)
T.check(recovered and recovered.recoveryNotice, "externally changed save undoes the pending transfer")
T.eq(files[source.path], changedBody, "undone transfer leaves the changed game intact")
T.eq(files[Store.PATH], boxBody, "undone transfer restores the Box before image")
T.eq(Store.count(recovered.state), 0, "undone transfer does not duplicate the Pokémon")
T.eq(files[Transaction.PATH], nil, "undone transfer no longer blocks boot")
end
do
local fs, files = filesystem()
local save, _, ref = fixture("ruby")
local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
T.check(service:deposit(source, { ref }, 1, body), "cart ID with dots and hyphens deposits")
local boxBody, gameBody = files[Store.PATH], files[source.path]
local nextState = Serializer.decode(boxBody); nextState.boxes[1].name = "next"; nextState.revision = nextState.revision + 1
local nextSave = Serializer.decode(gameBody); nextSave.money = 5
local journal = Serializer.encode({ format = 1, jobs = {
{ path = Store.PATH, before = boxBody, after = Serializer.encode(nextState) },
{ path = source.path, before = gameBody, after = Serializer.encode(nextSave) } } })
files[Store.PATH] = Serializer.encode(nextState)
files[Store.PATH .. ".bak"] = files[Store.PATH]
files[source.path] = Serializer.encode({ version = "ruby", generation = 3, party = {}, money = 1 })
files[Transaction.PATH] = journal
assert(Service.open(fs))
T.eq(files[Store.PATH], boxBody, "conflict rolls an already written Box back to its before image")
T.eq(files[Store.PATH .. ".bak"], boxBody, "conflict rolls the Box recovery copy back too")
files[Transaction.PATH] = journal:sub(1, math.floor(#journal / 2))
local reopened = Service.open(fs)
T.check(reopened and reopened.recoveryNotice, "truncated journal is set aside instead of blocking boot")
T.eq(files[Transaction.PATH], nil, "truncated journal is cleared")
T.eq(files[Store.PATH], boxBody, "truncated journal leaves targets untouched")
end
do
local fs, files = filesystem()
local save, _, ref = fixture("emerald")
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
local body = Serializer.encode(save); files[source.path] = body
local service = assert(Service.open(fs))
T.check(service:deposit(source, { ref }, 1, body), "deposit before storage loss")
local boxBody = files[Store.PATH]
files[Store.PATH] = nil
local reopened = assert(Service.open(fs))
T.eq(Store.count(reopened.state), 1, "missing storage recovers from its backup")
T.eq(files[Store.PATH], boxBody, "backup is promoted back to storage")
files[Store.PATH], files[Store.PATH .. ".bak"] = nil, "return {"
T.check(not Service.open(fs), "missing storage with an unreadable backup is refused")
T.eq(files[Store.PATH .. ".bak"], "return {", "unreadable backup is not overwritten")
end
CacheFs.readAt = readAt
Catalog.reset()
T.finish()
@@ -0,0 +1,59 @@
package.path="./?.lua;./?/init.lua;"..package.path
love=love or require("tests.love_stub")
local T=require("tests.harness")
local Store=require("src.box.Store")
local Search=require("src.box.Search")
local Showcase=require("src.box.Showcase")
local Editor=require("src.import.ShowcaseEditor")
local Organizer=require("src.box.Organizer")
local Kit=require("src.ui.kit.Kit")
local s={stageDraft=Showcase.new(),stagePiece=1,toolsPage="Showcase",stageRect={x=10,y=20,w=400,h=200}}
s.stageDraft.pieces={{kind="Arch",x=.5,y=.5,scale=1,rotation=0},
{kind="Tree",x=.5,y=.5,scale=1,rotation=0}}
T.eq(Showcase.hit(s.stageDraft,10,20,400,200,210,120),2,"frontmost visible layer is picked")
T.eq(Showcase.reorder(s.stageDraft,2,1),1,"send behind updates array layer")
T.eq(Showcase.hit(s.stageDraft,10,20,400,200,210,120),2,"new frontmost layer is picked")
T.eq(s.stageDraft.pieces[2].kind,"Arch","arch is now in front")
local imp={tab="box",_boxState=s}
Kit.blockClicks=false
T.check(Editor.pointerPressed(imp,"finger",210,120),"touch starts a direct scene drag")
T.check(Editor.pointerMoved(imp,"finger",250,140),"touch move stays in scene")
T.eq(s.stageDraft.pieces[2].x,.6,"scene drag uses normalized X")
T.eq(s.stageDraft.pieces[2].y,.6,"scene drag uses normalized Y")
T.check(Editor.pointerReleased(imp,"finger"),"touch release ends drag")
T.eq(s.stageGesture,nil,"gesture cleared")
T.eq(#s.stageUndo,1,"one undo snapshot for entire drag")
Editor.begin(s,"resize",210,120,s.stageRect)
Editor.move(s,350,80,s.stageRect)
T.check(s.stageDraft.pieces[2].scale>1,"hold resize uses pointer movement")
Editor.move(s,9999,-9999,s.stageRect)
T.eq(s.stageDraft.pieces[2].scale,4,"resize respects maximum")
Editor.finish(s)
Editor.begin(s,"rotate",290,140,s.stageRect)
Editor.move(s,250,180,s.stageRect)
T.check(math.abs(s.stageDraft.pieces[2].rotation)>0,"hold rotation uses angle around piece")
Editor.finish(s)
s.stageDraft.pieces[2].flipY=true
T.check(Showcase.validate(s.stageDraft,1),"vertical flip survives stage validation")
local state=Store.new()
local saved=assert(Showcase.save(state,1,s.stageDraft))
T.eq(saved.stages[1].pieces[2].flipY,true,"vertical flip persists")
T.eq(saved.stages[1].pieces[2].kind,"Arch","layer order persists")
for _,query in ipairs({"bulba","BULBA","BuLbA","Pokémon","POKÉMON","pokemon"}) do
local entry={version="red",display={name="Bulbasaur Pokémon",species="BULBASAUR"},mon={}}
T.check(Search.compile(query)(entry,"BOX 1"),"lookup folds case/accents: "..query)
end
local entry={id=1,version="red",generation=1,mon={},display={name="Bulbasaur",species="BULBASAUR",types="Grass",national=1,level=10}}
local layout={count=14,capacity=30,names={},rows={{box=1,slot=1,entry=entry}}}
for b=1,14 do layout.names[b]="BOX "..b end
local config={mode="rules",sort="species",descending=false,fallback="sort",
rules={{firstBox=1,lastBox=14,match="all",conditions={{field="species",value="buLBAsaur"}}}}}
local analysis=assert(Organizer.analyze(layout,config))
T.eq(analysis.ruleCounts[1],1,"live count shares actual case-insensitive rule matcher")
T.eq(analysis.freeBoxes,0,"all reserved boxes are reported")
T.eq(analysis.remaining,0,"remaining count excludes first matching rule")
config.rules[1].conditions[1].value="PIKACHU"
analysis=assert(Organizer.analyze(layout,config))
T.eq(analysis.remaining,1,"unmatched group is visible before preview")
T.check(Organizer.plan(layout,config)==nil,"full reserved range prevents fallback placement")
T.finish("box_studio_interaction")
+168
View File
@@ -0,0 +1,168 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Kit = require("src.ui.kit.Kit")
local UI = require("src.import.BoxUI")
local View = require("src.import.LauncherView")
local Importer = require("src.import.RomImporter")
local Store = require("src.box.Store")
local Transition = require("src.ui.kit.Transition")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local clock = 100
love.timer.getTime = function() return clock end
local controls, texts, original = {}, {}, View.btn
View.btn = function(imp, x, y, w, h, id, label, opts)
controls[id] = { x = x, y = y, w = w, h = h, label = label, opts = opts }
return original(imp, x, y, w, h, id, label, opts)
end
local printText = love.graphics.print
love.graphics.print = function(value, ...)
texts[#texts + 1] = tostring(value); return printText(value, ...)
end
local function launcher(width, height)
love.graphics.getDimensions = function() return width, height end
love.graphics.getPixelDimensions = function() return width, height end
local imp = Importer.new(function() end, { launcher = true })
local service = { state = Store.new() }
service.read = function(_, source)
service.lastRead = source.path
return { version = source.version, party = {}, boxes = {} }, nil, source.path
end
imp.tab = "box"
imp._boxState = { service = service, sources = {
{ version = "red", label = "Red · First", path = "first", slotId = "slot1" },
{ version = "emerald", label = "Emerald · Second", path = "second", slotId = "slot2" } },
sourceIndex = 1, box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {},
selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, view = "box", multi = true }
Transition.reset(); Kit.blur()
return imp
end
local function draw(imp, click)
clock = clock + .3; controls, texts = {}, {}
imp._clickPt = click; View.draw(imp)
local queue = imp._uiActions; imp._uiActions = {}
imp:runActions(queue or {})
end
local function click(imp, id)
local r = assert(controls[id], id)
draw(imp, { x = r.x + r.w / 2, y = r.y + r.h / 2 }); draw(imp)
end
local function settle(imp) draw(imp); draw(imp) end
local function keyboardChoice(imp, key)
imp:keypressed(key); settle(imp)
end
for _, size in ipairs({ { 360, 780 }, { 390, 844 }, { 1024, 768 }, { 1360, 860 }, { 640, 360 } }) do
local imp = launcher(size[1], size[2]); settle(imp)
T.check(controls["box-choose-save"].opts.icon and controls["box-tools-menu"].opts.icon,
"game and tools pickers have icons at " .. size[1])
T.check(not table.concat(texts):find("Game save", 1, true), "ambiguous Game save label is gone")
click(imp, "box-choose-save")
T.eq(View.modalKey(imp), "_boxPopup", "game picker owns the modal input layer")
T.eq(imp._boxPopup.index, 1, "picker opens at the current playthrough")
click(imp, "box-popup-option-2")
T.eq(imp._boxState.sourceIndex, 2, "choosing a game changes the linked playthrough")
T.eq(imp._boxState.service.lastRead, "second", "picker reloads the chosen game's PC")
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")
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.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]
and bounds.y + bounds.h <= size[2], "popup close remains on screen")
keyboardChoice(imp, "escape")
end
local imp = launcher(1024, 768); settle(imp)
click(imp, "box-tools-menu")
Kit.focus = "box-chooser-filter"
imp:textinput("Dashboard"); settle(imp)
T.eq(imp._boxPopup.query, "Dashboard", "launcher typing reaches the picker search")
T.eq(#imp._boxPopup.rows, 1, "picker search narrows actual options")
keyboardChoice(imp, "return")
T.eq(imp._boxState.toolsPage, "Dashboard", "Enter applies the filtered choice")
imp._boxState.toolsPage = nil; settle(imp)
click(imp, "box-options")
for _, option in ipairs(imp._boxPopup.options) do
if option.label == "Box theme" then option.action() end
end
settle(imp)
T.eq(imp._boxPopup.title, "Box theme", "Options opens the Box theme picker")
keyboardChoice(imp, "end")
local old = imp._boxState.service.state.boxes[1].theme
keyboardChoice(imp, "return")
T.check(imp._boxPopup ~= nil, "disabled Showcase wallpaper cannot be chosen")
T.eq(imp._boxState.service.state.boxes[1].theme, old, "disabled option leaves the box unchanged")
keyboardChoice(imp, "escape")
imp._boxState.toolsPage, imp._boxState.view = nil, "box"; settle(imp)
click(imp, "box-options")
T.eq(imp._boxPopup.index, 1, "action menu starts at its first enabled action")
for id, r in pairs(controls) do
if id:match("^box%-popup%-option") then T.check(not r.opts.active, "action rows have no false selected checks") end
end
local behind = controls["box-choose-save"]
draw(imp, { x = behind.x + 10, y = behind.y + behind.h / 2 }); settle(imp)
T.eq(imp._boxPopup, nil, "outside tap dismisses without opening the game picker beneath it")
T.eq(imp._boxState.view, "box", "a popup shields the board from taps")
if imp._boxPopup then keyboardChoice(imp, "escape") end
click(imp, "box-options")
click(imp, "box-popup-option-1")
T.check(imp._boxPrompt ~= nil and not imp._boxPopup, "rename opens the existing name prompt")
keyboardChoice(imp, "escape")
local changes = 0
UI.open(imp, "Pick", { { id = 1, label = "One" } }, nil, function() changes = changes + 1 end)
settle(imp)
local staleAction = controls["box-popup-option-1"].opts.action
staleAction(); staleAction()
T.eq(changes, 1, "a retired popup action cannot apply twice")
settle(imp)
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); imp._padCursorActive = false; imp.isNX = false
for _ = 1, 10 do imp:gamepadpressed(nil, "dpdown"); settle(imp) end
T.eq(imp._boxPopup.index, 11, "controller navigation crosses the visible row boundary")
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", {}, nil); settle(imp)
imp:gamepadpressed(nil, "b"); settle(imp)
T.eq(imp._boxPopup, nil, "controller B closes help")
UI.open(imp, "Box", {}, nil); settle(imp)
draw(imp, { x = 1, y = 1 }); settle(imp)
T.eq(imp._boxPopup, nil, "outside tap closes without activating the launcher")
UI.open(imp, "Box", {}, nil); Kit.focus = "box-chooser-filter"
imp:_switchTab("mods")
T.eq(imp._boxPopup, nil, "leaving Box dismisses its popup")
T.eq(Kit.focus, nil, "leaving Box releases picker text focus")
Transition.reset(); Transition.armed = true; Transition.reduceMotion = false
local state = { view = "box" }
UI.change(imp, state, "view", "pc", 1)
Transition.update(clock + .06)
local positions, blocked = {}, {}
UI.pages(imp, state, 10, 20, 300, 400, {}, function(_, s, x)
positions[s.view] = x; blocked[#blocked + 1] = Kit.blockClicks; return 400
end)
T.check(positions.box < 10 and positions.pc > 10, "both pages slide in the requested direction")
T.check(blocked[1] and blocked[2], "neither moving page accepts input")
Transition.update(clock + 1)
local draws = 0
UI.pages(imp, state, 10, 20, 300, 400, {}, function() draws = draws + 1; return 400 end)
T.eq(draws, 1, "finished slide draws only the current page")
T.eq(state.motionFrom, nil, "finished slide releases the old view")
Transition.reduceMotion = true
UI.change(imp, state, "view", "box", -1)
T.check(not Transition.active("box"), "reduced motion changes Box instantly")
Transition.reduceMotion = false; Transition.reset()
View.btn, love.graphics.print = original, printText
T.finish()
+22 -1
View File
@@ -24,7 +24,20 @@ end
local function run(o)
GameVersion.set(o.version or "emerald")
local user, foe = mon(o.species or 1, o.own and 1 or 2, o.fateful), mon(1, 3)
local session = { version = o.version or "emerald", name = "PLAYER", trainerId = 1, party = { user } }
user.otSecretId, user.level = o.monSid or 0, o.level or 30
user.otName = o.monName or user.otName
local session = { version = o.version or "emerald", name = "PLAYER", trainerId = 1,
secretId = o.playerSid or 0, party = { user } }
if o.legacy then user.otSecretId, session.secretId = nil, nil end
if o.sync then
user.personality = 12345
local Sync = require("src.core.TrainerIdSync")
local owners = {}
Sync.addOwner(owners, { id = 1, sid = 7, name = "MAY" }, 3)
T.check(Sync.rewriteSave(session, 3, { id = 1, sid = 42, name = "PLAYER" }, owners),
o.label .. " syncs original OT identity")
end
Space.store = Flags.newStore()
Battle.start({ headless = false, autoFight = false, session = session, playerParty = session.party,
foe = { species = 1, level = 30, mon = foe }, wild = o.wild == true, frontier = o.frontier,
rng = function(lo, hi) if lo == 1 and hi == 100 then return 1 end return hi end })
@@ -62,4 +75,12 @@ run({ own = true, obey = true, label = "Emerald own OT" })
run({ link = true, obey = true, label = "Emerald link" })
run({ badge = 2, obey = true, label = "Emerald badge two level 30" })
run({ badge = 8, obey = true, label = "Emerald badge eight" })
run({ own = true, level = 100, playerSid = 42, monSid = 42, obey = true,
label = "Level 100 full own identity with no badges" })
run({ own = true, level = 100, playerSid = 42, monSid = 7, obey = false,
label = "Level 100 same TID and name but foreign SID" })
run({ own = true, level = 100, playerSid = 42, monSid = 7, monName = "MAY", sync = true, obey = true,
label = "Level 100 synced name TID SID with no badges" })
run({ own = true, level = 100, legacy = true, wild = true, obey = true,
label = "Level 100 own mon on a save with no secret id in a wild battle" })
T.finish("game3_frontier_obedience_2638")
@@ -0,0 +1,37 @@
package.path="./?.lua;./?/init.lua;"..package.path
love=love or require("tests.love_stub")
local T=require("tests.harness")
local Importer=require("src.import.RomImporter")
local Panel=require("src.import.BoxPanel")
local Transition=require("src.ui.kit.Transition")
local clock=10
local oldTime,oldRefresh=love.timer.getTime,Panel.refresh
love.timer.getTime=function() return clock end
Panel.refresh=function(self) clock=clock+.7;self._boxState={};return self._boxState end
local imp=setmetatable({_disarmTextInput=function() end,_setModScope=function() end,
_queueReimport=function() end,_ensureSkins=function() clock=clock+.3 end}, {__index=Importer})
Transition.armed,Transition.reduceMotion=true,false
local tabs={"red","box","mods","online","skins"}
for _,from in ipairs(tabs) do
for _,to in ipairs(tabs) do
if from~=to then
imp.tab=from
imp:_switchTab(to)
Transition.update()
local tr=Transition.get("tabs")
T.check(tr~=nil,from.." → "..to.." starts after loading completes")
T.eq(tr.from,from,"outgoing page retained")
T.eq(tr.to,to,"incoming page retained")
T.eq(tr.p,0,"first frame has whole slide remaining")
clock=clock+.09;Transition.update()
T.check(Transition.progress("tabs")>0 and Transition.progress("tabs")<1,"midpoint still slides")
clock=clock+.1;Transition.update()
T.eq(Transition.get("tabs"),nil,"slide finishes")
end
end
end
Transition.reduceMotion=true;imp.tab="red";imp:_switchTab("box")
T.eq(Transition.get("tabs"),nil,"reduced motion still disables Box slide")
love.timer.getTime,Panel.refresh=oldTime,oldRefresh
Transition.armed,Transition.reduceMotion=false,false;Transition.clear("tabs")
T.finish("launcher_box_transition")
@@ -0,0 +1,78 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Kit = require("src.ui.kit.Kit")
local View = require("src.import.LauncherView")
local RomImporter = require("src.import.RomImporter")
local Transition = require("src.ui.kit.Transition")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
for _, tab in ipairs(View.HEADER_TABS) do
T.check(tab.id ~= "find", "Search has no separate launcher header button")
end
local originalButton, originalTime = Kit.button, love.timer.getTime
local controls, now = {}, originalTime() + 1
love.timer.getTime = function() return now end
Kit.button = function(x, y, w, h, label, opts)
if opts and opts.id then
controls[opts.id] = { x = x, y = y, w = w, h = h, active = not not opts.active }
end
return originalButton(x, y, w, h, label, opts)
end
for _, size in ipairs({ { 390, 844 }, { 1360, 860 } }) do
love.graphics.getDimensions = function() return size[1], size[2] end
love.graphics.getPixelDimensions = love.graphics.getDimensions
local imp = RomImporter.new(function() end, { launcher = true })
imp.mods, imp.modStraysChecked = {}, true
imp.findLoaded, imp.findSources = true, { { feed = "https://example.invalid/mods.json" } }
imp.findIndex = { mods = {}, carts = {} }
imp._ensureMods = function() end
imp._ensureFind = function() end
imp:_switchTab("mods")
local function draw(click)
now = now + 0.5
Transition.clear()
controls = {}
imp._clickPt = click and { x = click.x + click.w / 2, y = click.y + click.h / 2 } or nil
View.draw(imp)
local actions = imp._uiActions
imp._uiActions = {}
if actions then imp:runActions(actions) end
end
local function click(id)
local rect = assert(controls[id], id)
draw(rect)
draw()
end
draw()
T.check(controls["mods-inner-installed"].active, "Installed starts selected")
T.check(not controls["mods-inner-search"].active, "Search starts inactive")
T.check(controls["tab-find"] == nil, "Find header control is absent")
T.check(controls["mods-inner-search"].x + controls["mods-inner-search"].w <= size[1],
"inner tabs fit the window")
click("mods-inner-search")
T.eq(imp.tab, "find", "Search opens the existing discovery page")
T.check(controls["tab-mods"].active, "Mods remains the selected header button in Search")
T.check(controls["mods-inner-search"].active, "Search inner tab is selected")
T.eq(imp._busy, nil, "opening Search does not raise a blocking loader")
imp.findQuery, imp.findGame, imp.findCategory = "sprite", "emerald", "graphics"
imp._findSearchFocus = true
click("mods-inner-installed")
T.eq(imp.tab, "mods", "Installed returns to local mods")
T.eq(imp._findSearchFocus, false, "leaving Search releases its text focus")
click("mods-inner-search")
T.eq(imp.findQuery, "sprite", "Search query survives inner tab switches")
T.eq(imp.findGame, "emerald", "game filter survives inner tab switches")
T.eq(imp.findCategory, "graphics", "category filter survives inner tab switches")
imp:_cycleTab(1)
T.eq(imp.tab, "online", "controller navigation treats both inner pages as Mods")
draw()
click("tab-mods")
T.eq(imp.tab, "find", "Mods header remembers its last selected inner page")
end
Kit.button, love.timer.getTime = originalButton, originalTime
T.finish("launcher mods inner tabs")
+10 -5
View File
@@ -56,15 +56,16 @@ check(view:find('skins = "paintbrush"', 1, true) ~= nil,
"the skins tab uses the shared Lucide paintbrush icon")
check(read("assets/launcher/lucide/icons.png"):sub(1, 4) == "\137PNG",
"the shared icon atlas is packaged with the launcher")
-- the tab has to be next to Find, which is what the request was
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
check(order ~= nil, "HEADER_TABS found")
if order then
local findAt = order:find('id = "find"', 1, true)
local modsAt = order:find('id = "mods"', 1, true)
local skinsAt = order:find('id = "skins"', 1, true)
local onlineAt = order:find('id = "online"', 1, true)
check(findAt and skinsAt and skinsAt > findAt,
"the skins tab sits immediately after Find")
check(modsAt and skinsAt and skinsAt > modsAt,
"the skins tab follows Mods")
check(order:find('id = "find"', 1, true) == nil,
"Find lives inside Mods instead of the launcher header")
check(onlineAt ~= nil, "the online tab is in the header")
local onlineRow = order:match('{ id = "online".-}')
check(onlineRow and onlineRow:find("beta = true", 1, true) ~= nil,
@@ -101,12 +102,16 @@ local GameVersion = require("src.core.GameVersion")
local cycled, probe = {}, nil
probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter })
probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end
for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end
for _ = 1, #GameVersion.ORDER + #require("src.import.LauncherView").HEADER_TABS do
RomImporter._cycleTab(probe, 1)
end
local reached = " " .. table.concat(cycled, " ") .. " "
check(reached:find(" skins ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
check(reached:find(" bug ", 1, true) == nil,
"shoulder-button tab cycling no longer stops on the bug panel")
check(reached:find(" importers ", 1, true) == nil,
"shoulder-button tab cycling leaves Extras in settings")
for _, id in ipairs(GameVersion.ORDER) do
if id ~= GameVersion.ORDER[1] then
check(reached:find(" " .. id .. " ", 1, true) ~= nil,
+521
View File
@@ -0,0 +1,521 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Serializer = require("src.core.SaveSerializer")
local Store = require("src.box.Store")
local Service = require("src.box.Service")
local Transaction = require("src.box.Transaction")
local Box = require("src.sync.SyncBox")
local Engine = require("src.sync.SyncEngine")
local State = require("src.sync.SyncState")
local Json = require("src.link.Json")
local Base64 = require("src.core.Base64")
require("src.import.CacheFs").readAt = function(path)
if path:match("data/generated/pokemon.lua$") then
return Serializer.encode({ PIKACHU = { dex = 25, name = "PIKACHU" } })
elseif path:match("data/generated/moves.lua$") then
return Serializer.encode({ THUNDERSHOCK = { name = "THUNDERSHOCK" } })
end
end
require("src.box.Catalog").reset()
local function filesystem()
local files, mode = {}, { writes = 0 }
local fs = {
read = function(p) return files[p] end,
getInfo = function(p) return files[p] and { type = "file" } end,
createDirectory = function() return true end,
getDirectoryItems = function(dir)
local out = {}; for p in pairs(files) do
if p:sub(1, #dir + 1) == dir .. "/" then out[#out + 1] = p:sub(#dir + 2) end
end; return out
end,
remove = function(p) files[p] = nil; return true end,
write = function(p, body)
mode.writes = mode.writes + 1
if mode.writes == mode.at then
if mode.kind == "partial" then files[p] = body:sub(1, math.floor(#body / 2)) end
if mode.kind == "crash" then files[p] = body; error("interrupted") end
return nil, "disk fault"
end
files[p] = body; return true
end,
}
return fs, files, mode
end
local function linked()
local s = State.defaults(); s.account, s.deviceToken, s.enabled = "test", "token", true; return s
end
local mon = { species = "PIKACHU", nickname = "Sparky", level = 12,
hp = 31, maxHp = 31, otId = 42, otName = "Player", moves = { "THUNDERSHOCK" } }
local function game(id, boxed)
return { version = "red", generation = 1, player = { name = "RED" },
party = {}, boxes = { boxed and { Store.copy(mon) } or {} }, currentBox = 1,
meta = { playthroughId = id, savedAt = 1700000000 } }
end
local function writeGame(files, id, slot, boxed)
files["saves/red/" .. slot .. ".lua"] = Serializer.encode(game(id, boxed))
end
local function savesFor(files)
local adapter = { ordinaryWrites = 0 }
function adapter.list()
local out = {}
for path, body in pairs(files) do
local scope, slot = path:match("^saves/([%w_%-]+)/(slot%d+)%.lua$")
if scope then
local save = Serializer.decode(body)
if save and save.meta and save.meta.playthroughId then
out[#out + 1] = { version = save.version, slot = slot,
cart = scope:match("^cart_(.+)$"), playthroughId = save.meta.playthroughId,
blob = body, meta = Store.copy(save.meta) }
end
end
end
table.sort(out, function(a, b) return a.playthroughId < b.playthroughId end)
return out
end
function adapter.write() adapter.ordinaryWrites = adapter.ordinaryWrites + 1; return "slot99" end
function adapter.remove() error("linked deletion must use compound transaction") end
return adapter
end
local function warehouse(files)
local s = Store.new(); s.savedAt, s.nextId = 1700000000, 2
s.boxes[1].mons[1] = { id = 1, version = "red", generation = 1, slotId = "slot1",
mon = Store.copy(mon), display = { name = "PIKACHU" }, tags = "favorite", markings = 3 }
s.syncMembers = { ["red/source"] = { version = "red", playthroughId = "source",
slotId = "slot1", path = "saves/red/slot1.lua" } }
files[Store.PATH] = Serializer.encode(s); writeGame(files, "source", "slot1", false)
return s
end
local function cloud()
local c = { rev = 0, saves = {}, requests = {} }
function c:transport()
local transport = {}
function transport:begin(req)
c.requests[#c.requests + 1] = req
local method, path = req.method, req.url:match("http://sync%.test([^?]*)")
local data, code = {}, 200
if method == "GET" and path == "/sync/state" then
data = { capabilities = { box = 1 }, saves = {}, deleted = {} }
if c.legacy then data.capabilities = nil end
for key, row in pairs(c.saves) do
data[row.deleted and "deleted" or "saves"][key] = { rev = row.rev, meta = row.meta }
end
if c.box then data.box = { rev = c.rev, meta = c.box.meta, members = c.box.members } end
elseif method == "GET" and path == "/sync/box" then
data = Store.copy(c.box or { format = 1, blob = Serializer.encode(Store.new()), assets = {}, meta = { count = 0 }, members = {} })
data.rev, data.saves = c.rev, Store.copy(c.saves)
for version, id in req.url:gmatch("([%w_]+)/([%w%-]+)") do
local key = version .. "/" .. id
if version == "red" and not data.saves[key] then data.saves[key] = { version = version, deleted = true, rev = 0 } end
end
if c.onGet then c.onGet(); c.onGet = nil end
elseif method == "PUT" and path == "/sync/box" then
local body = assert(Json.decode(req.body))
if body.baseRev ~= c.rev then code = 409 end
for key, row in pairs(body.saves) do if row.baseRev ~= (c.saves[key] and c.saves[key].rev or 0) then code = 409 end end
if code == 200 then
c.rev = c.rev + 1; c.box = body; c.box.members = {}; data = { rev = c.rev, revs = {} }
for key, row in pairs(body.saves) do
row.rev = row.baseRev + 1; c.saves[key] = Store.copy(row)
data.revs[key] = row.rev; c.box.members[#c.box.members + 1] = key
end
else data = { error = "conflict", rev = c.rev } end
elseif method == "PUT" and path == "/sync/save" then
local body = assert(Json.decode(req.body)); data.rev = 1
c.saves[body.version .. "/" .. body.meta.playthroughId] = { rev = 1, meta = body.meta, blob = body.blob }
elseif method == "GET" and path == "/sync/save" then
data = Store.copy(c.saves["red/source"])
if c.onGetSave then c.onGetSave(); c.onGetSave = nil end
else code, data = 404, { error = "missing route" } end
return { status = "ok", code = code, body = Json.encode(data) }
end
function transport:poll(handle) return handle end
function transport:release() end
return transport
end
return c
end
local function engine(fs, files, c, state)
local saves = savesFor(files)
local e = Engine.new({ fs = fs, box = Box.new(fs), saves = saves,
state = state or linked(), persist = false, baseUrl = "http://sync.test", transport = c:transport() })
return e, saves
end
local function pump(e)
for _ = 1, 40 do e:update(0.01) end
T.check(not e:busy(), "sync settles")
end
local function run(e) assert(e:syncNow()); pump(e) end
local function count(files)
local s = assert(Serializer.decode(files[Store.PATH])); local n = Store.count(s)
for _, e in ipairs(savesFor(files).list()) do
local save = assert(Serializer.decode(e.blob))
for _, b in ipairs(save.boxes or {}) do n = n + #b end
end
return n
end
do
local fs, files = filesystem(); writeGame(files, "source", "slot1", true)
files[Store.PATH] = Serializer.encode(Store.new())
local c = cloud(); c.saves["red/source"] = { version = "red", rev = 1, blob = files["saves/red/slot1.lua"], meta = game("source").meta }
local state = linked(); State.setRev(state, "red/source", 1, 1700000000)
local e, adapter = engine(fs, files, c, state)
e._boxKeys = {}
c.onGetSave = function() warehouse(files) end
e:_queueDownload("red/source", "red", "source", "replace", 1)
pump(e)
T.eq(adapter.ordinaryWrites, 0, "deposit during standalone fetch blocks stale game replacement")
T.eq(count(files), 1, "concurrent deposit keeps one Pokémon")
T.eq(c.rev, 1, "standalone fetch restarts as compound Box sync")
T.eq(#Serializer.decode(c.saves["red/source"].blob).boxes[1], 0, "cloud receives transferred game state with warehouse")
end
do
local fs, files = filesystem(); writeGame(files, "source", "slot1", true)
local c = cloud(); local e = engine(fs, files, c)
local entry = savesFor(files).list()[1]; e._boxKeys = {}
e:_queueUpload(entry, "red/source", false)
warehouse(files)
pump(e)
for _, req in ipairs(c.requests) do
T.check(not req.url:find("/sync/save", 1, true), "queued independent write yields to new Box membership")
end
T.eq(c.rev, 1, "new Box member uploads as complete collection")
end
do
local fs, files = filesystem(); warehouse(files)
local c = cloud(); local e = engine(fs, files, c)
run(e)
T.eq(e.phase, "idle", "first upload succeeds")
T.eq(c.rev, 1, "warehouse revision advances")
T.eq(c.saves["red/source"].rev, 1, "source save advances in same request")
T.eq(#c.requests, 2, "state plus one compound upload")
T.eq(c.requests[2].url, "http://sync.test/sync/box", "no standalone linked save upload")
run(e); T.eq(c.rev, 1, "unchanged collection does not upload again")
local fs2, files2 = filesystem()
writeGame(files2, "unrelated", "slot1", true)
files2["options.lua"] = Serializer.encode({ saveSlots = { red = { list = { "slot1" }, active = "slot1" } }, custom = "local setting" })
local e2, adapter = engine(fs2, files2, c); run(e2)
T.eq(e2.phase, "idle", "new device receives collection")
T.eq(adapter.ordinaryWrites, 0, "linked save bypasses ordinary download")
local state2 = Serializer.decode(files2[Store.PATH])
T.eq(state2.syncMembers["red/source"].slotId, "slot2", "destination slot can differ")
T.eq(state2.boxes[1].mons[1].slotId, "slot2", "Pokémon origin remapped")
T.eq(Serializer.decode(files2["options.lua"]).custom, "local setting", "device settings preserved")
T.eq(count(files2), 2, "local unrelated Pokémon and one synced Pokémon retained")
T.eq(c.saves["red/unrelated"].rev, 1, "independent saves still sync individually")
local before = c.rev; run(e2); T.eq(c.rev, before, "remapped slots do not cause ping-pong")
end
do
local fs, files = filesystem(); local s = warehouse(files)
local entry = s.boxes[1].mons[1]
entry.archives = { { id = 1, version = "red", generation = 1, slotId = "slot1", mon = { species = "PIKACHU", opaque = "\0\255" }, display = {} } }
s.presets = { { name = "Team", ids = { 1 } } }
s.stages = { require("src.box.Showcase").new() }; s.progress = { trainer = 7 }; s.gciTemplate = "native\0banner"
s.boxes[1].theme, s.boxes[1].wallpaper = "Showcase", "box/showcase/1.png"
s.boxes[1].showcaseMusic = "Night"
local png = "\137PNG\r\n\26\n" .. string.rep("\0", 8) .. "\0\0\4\112\0\0\1\176" .. "fixture"
files[s.boxes[1].wallpaper] = png; files[Store.PATH] = Serializer.encode(s)
local payload = assert(Box.new(fs):snapshot(savesFor(files).list(), linked())).payload
T.check(Box.validate(payload), "full snapshot validates")
local fs2, files2 = filesystem(); local box2 = Box.new(fs2)
T.check(box2:apply(payload, {}, linked()), "full snapshot applies")
local copy = Serializer.decode(files2[Store.PATH])
T.same(copy.presets, s.presets, "teams survive")
T.same(copy.stages, s.stages, "stages survive")
T.eq(copy.boxes[1].showcaseMusic,"Night","applied theme music survives device sync")
T.same(copy.progress, s.progress, "gift progress survives")
T.same(copy.boxes[1].mons[1].archives, entry.archives, "archived native records survive")
T.eq(copy.gciTemplate, s.gciTemplate, "native template survives")
T.eq(files2["box/showcase/1.png"], png, "wallpaper pixels survive")
local before = Store.copy(files2)
for _, mutate in ipairs({
function(p) p.saves["red/source"].meta.playthroughId = "other" end,
function(p) p.saves["red/source"].blob = "return { player={},version='gold' }" end,
function(p) p.assets["box/showcase/1.png"] = nil end,
function(p) p.assets["../../escape"] = Base64.encode(png) end,
function(p) p.blob = "return os.execute('bad')" end,
function(p) p.saves["red/source"].blob = "return {}" end,
}) do
local bad = Store.copy(payload); mutate(bad)
T.check(not box2:apply(bad, savesFor(files2).list(), linked()), "malformed snapshot rejected")
T.same(files2, before, "invalid snapshot changes no file")
end
local cart = Store.copy(payload)
local save = Serializer.decode(cart.saves["red/source"].blob); save.meta.cartId = "missing-cart"
cart.saves["red/source"].blob = Serializer.encode(save)
local cartState = Serializer.decode(cart.blob)
cartState.syncMembers["red/source"].cartId, cartState.syncMembers["red/source"].path = "missing-cart", "saves/cart_missing-cart/slot1.lua"
cart.blob = Serializer.encode(cartState)
local cleanFs, cleanFiles = filesystem()
local result, why = Box.new(cleanFs):apply(cart, {}, linked())
T.eq(result, false, "missing cart defers entire collection")
T.check(why:find("Install", 1, true), "cart instruction given")
T.eq(next(cleanFiles), nil, "no partial warehouse or save installed")
end
do
local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c)
files["saves/red/slot1.lua"] = nil
T.eq(e:noteSaveDeleted("red/source"), false, "never uploaded deletion stays local")
run(e)
T.eq(e.phase, "idle", "unsynced deleted source does not block first Box sync")
T.eq(c.saves["red/source"].deleted, true, "first snapshot records departed source tomb")
end
do
local fsA, a = filesystem(); warehouse(a); local c = cloud(); local A = engine(fsA, a, c); run(A)
local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B)
assert(assert(Service.open(fsA)):rename(1, "remote edit")); run(A)
assert(assert(Service.open(fsB)):rename(1, "local edit")); run(B)
local before = b[Store.PATH]
c.rev = c.rev + 1
local remote = Serializer.decode(c.box.blob); remote.boxes[1].name = "remote changed again"; c.box.blob = Serializer.encode(remote)
c.saves["red/source"].rev = c.saves["red/source"].rev + 1
assert(B:resolveConflict(Box.KEY, "remote")); pump(B)
T.eq(#B.conflicts, 1, "remote changing after prompt requires new choice")
T.eq(b[Store.PATH], before, "stale prompt does not overwrite local state")
assert(assert(Service.open(fsB)):rename(1, "local changed again"))
T.eq(B:resolveConflict(Box.KEY, "local"), false, "local changing after prompt requires review")
T.eq(#B.conflicts, 1, "new local content keeps conflict open")
T.check(B.conflicts[1].reviewMessage ~= nil, "prompt explains updated local state")
assert(B:resolveConflict(Box.KEY, "local")); pump(B)
T.eq(Serializer.decode(c.box.blob).boxes[1].name, "local changed again", "local choice uploads complete state")
T.eq(assert(B.box:readBackup(B.box:latestBackup())).blob, Serializer.encode(remote), "remote losing state preserved")
end
for _, version in ipairs(require("src.core.GameVersion").ORDER) do
local generation = require("src.core.GameVersion").generation(version)
local fs, files = filesystem(); local s = Store.new()
local id, key, path = "edition-test", version .. "/edition-test", "saves/" .. version .. "/slot7.lua"
local save = { version = version, generation = generation, player = { name = "TEST" }, meta = { playthroughId = id }, opaque = "\0\255" }
if generation == 3 then save.player, save.engine, save.name, save.party, save.bag = nil, "game3", "TEST", {}, {} end
s.syncMembers = { [key] = { version = version, playthroughId = id, slotId = "slot7", path = path } }
files[Store.PATH], files[path] = Serializer.encode(s), Serializer.encode(save)
local payload = assert(Box.new(fs):snapshot(savesFor(files).list(), linked())).payload
T.check(Box.validate(payload), version .. " native shape validates")
local fs2, files2 = filesystem(); local box = Box.new(fs2)
T.check(box:apply(payload, {}, linked()), version .. " applies")
local target = "saves/" .. version .. "/slot1.lua"
T.eq(Serializer.decode(files2[target]).opaque, save.opaque, version .. " native bytes preserved")
T.eq(Serializer.decode(files2[Store.PATH]).syncMembers[key].path, target, version .. " routing persisted")
files2[target .. ".bak"], files2[target .. ".tmp"] = files2[target], files2[target]
payload.saves[key] = { version = version, deleted = true }
T.check(box:apply(payload, savesFor(files2).list(), linked()), version .. " linked deletion applies")
T.eq(files2[target], nil, version .. " primary removed")
T.eq(files2[target .. ".bak"], nil, version .. " backup cannot resurrect deletion")
T.eq(files2[target .. ".tmp"], nil, version .. " staged copy cannot resurrect deletion")
end
for _, kind in ipairs({ "fail", "partial", "crash" }) do
for at = 1, 18 do
local fs, files, mode = filesystem(); warehouse(files)
local source = "saves/red/slot1.lua"
files[source .. ".tmp"] = files[source]
files["options.lua"] = Serializer.encode({ custom = "before", saveSlots = { red = { list = { "slot1" }, active = "slot1" } } })
local oldBox = files[Store.PATH]; local newBox = Serializer.decode(oldBox); newBox.boxes[1].name = "wallpaper synced"
newBox.boxes[1].theme, newBox.boxes[1].wallpaper = "Showcase", "box/showcase/1.png"
local png = "\137PNG\r\n\26\n" .. string.rep("\0", 8) .. "\0\0\4\112\0\0\1\176" .. "bytes"
local save = Serializer.decode(files[source]); save.boxes[1] = { Store.copy(mon) }; newBox.boxes[1].mons = {}
mode.at, mode.kind = at, kind
pcall(Transaction.commitSync, fs, {
{ path = source, before = files[source], after = Serializer.encode(save) },
{ kind = "asset", path = "box/showcase/1.png", after = png },
{ path = "options.lua", before = files["options.lua"], after = Serializer.encode({ custom = "after" }) },
{ path = Store.PATH, before = oldBox, after = Serializer.encode(newBox) },
})
mode.at = nil
T.check(Transaction.recover(fs), "batch " .. kind .. " " .. at .. " recovers")
T.eq(count(files), 1, "batch keeps one Pokémon")
local changed = Serializer.decode(files[Store.PATH]).boxes[1].name == "wallpaper synced"
if changed then
T.eq(files["box/showcase/1.png"], png, "committed PNG intact")
T.eq(Serializer.decode(files["options.lua"]).custom, "after", "options and Box commit together")
T.eq(files[source .. ".tmp"], nil, "old staged save cleared")
else T.eq(files["box/showcase/1.png"], nil, "failed initial journal leaves no wallpaper") end
end
end
do
local fsA, a = filesystem(); warehouse(a)
local c = cloud(); local A = engine(fsA, a, c); run(A)
local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B)
local source = { version = "red", slotId = "slot1", path = "saves/red/slot1.lua" }
assert(assert(Service.open(fsA)):withdraw(source, { { box = 1, slot = 1 } }, 1))
assert(assert(Service.open(fsB)):rename(1, "B chose this"))
run(A); run(B)
T.eq(#B.conflicts, 1, "two changed collections conflict")
T.eq(B.conflicts[1].box, true, "one conflict covers collection and saves")
T.eq(count(a), 1, "withdraw leaves exactly one Pokémon")
T.eq(count(b), 1, "conflict leaves local Pokémon intact")
T.eq(B:resolveConflict(Box.KEY, "both"), false, "cannot duplicate collection with keep-both")
T.eq(#B.conflicts, 1, "invalid choice retains conflict")
T.check(B:resolveConflict(Box.KEY, "remote"), "whole remote choice accepted"); pump(B)
T.eq(count(b), 1, "remote choice preserves exactly one Pokémon")
T.eq(Store.count(Serializer.decode(b[Store.PATH])), 0, "remote withdrawal applied")
local current = Serializer.decode(b[Store.PATH])
T.eq(current.departures[1].path, source.path, "departed destination remapped")
local backup = B.box:latestBackup(); T.check(backup ~= nil, "losing copy has reachable backup")
T.eq(assert(B.box:readBackup(backup)).meta.count, 1, "backup holds losing warehouse")
T.check(B:restoreBoxBackup(backup), "whole snapshot can be restored")
T.eq(count(b), 1, "restore keeps exactly one Pokémon")
T.eq(Store.count(Serializer.decode(b[Store.PATH])), 1, "restore includes warehouse")
run(B); T.eq(Serializer.decode(c.box.blob).boxes[1].mons[1].mon.species, "PIKACHU", "restored collection uploads")
local extra = Serializer.decode(b[Store.PATH]); extra.syncMembers["red/new"] = { version = "red", playthroughId = "new", path = "saves/red/slot2.lua", slotId = "slot2" }
b[Store.PATH] = Serializer.encode(extra); writeGame(b, "new", "slot2", false)
local original = b[Store.PATH]
T.check(not B:restoreBoxBackup(backup), "backup missing newly linked save cannot restore")
T.eq(b[Store.PATH], original, "incomplete restore leaves collection unchanged")
local ghost = { format = 1, blob = Serializer.encode(Store.new()), assets = {},
saves = { ["red/source"] = { version = "red", deleted = true, rev = 0 }, ["red/new"] = { version = "red", deleted = true, rev = 0 } } }
local ghostPath = assert(B.box:archive(ghost))
T.check(not B:restoreBoxBackup(ghostPath), "never-seen cloud source is not a complete deletion backup")
T.eq(b[Store.PATH], original, "placeholder backup cannot remove new linked saves")
end
do
local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c)
e:protectPlaythrough("red", "source"); run(e)
T.eq(c.rev, 0, "active member defers entire upload")
T.check(e.status:find("launcher", 1, true), "deferred status explains next step")
e:protectPlaythrough(nil); T.eq(e.uploadAt, e.clock, "return schedules immediate retry")
pump(e); T.eq(c.rev, 1, "return uploads collection")
local s = Serializer.decode(files[Store.PATH]); s.boxes[1].name = "same second"; files[Store.PATH] = Serializer.encode(s)
run(e); T.eq(c.rev, 2, "same timestamp content changes still upload")
local state = Serializer.decode(files[Store.PATH]); state.boxes[1].mons = {}; state.departures = nil
files[Store.PATH] = Serializer.encode(state); run(e)
T.check(c.saves["red/source"] ~= nil, "empty collection retains save membership")
files["saves/red/slot1.lua.bak"], files["saves/red/slot1.lua.tmp"] = "old", "old"
files["saves/red/slot1.lua"] = nil; assert(e:noteSaveDeleted("red/source")); run(e)
T.eq(c.saves["red/source"].deleted, true, "linked deletion uploads in compound snapshot")
local fs2, files2 = filesystem(); local e2 = engine(fs2, files2, c); run(e2)
T.eq(#savesFor(files2).list(), 0, "deleted member is not resurrected")
end
do
local fs, files = filesystem(); warehouse(files); local c = cloud(); c.legacy = true
local e = engine(fs, files, c); run(e)
T.eq(e.phase, "error", "old server blocks Box upload")
T.eq(#c.requests, 1, "old server receives no half-transfer")
end
do
local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c); run(e)
local fs2, files2 = filesystem(); local e2 = engine(fs2, files2, c)
c.onGet = function() warehouse(files2); local s = Serializer.decode(files2[Store.PATH]); s.boxes[1].name = "edited during fetch"; files2[Store.PATH] = Serializer.encode(s) end
run(e2); T.eq(e2.phase, "error", "edit during download blocks apply")
T.eq(Serializer.decode(files2[Store.PATH]).boxes[1].name, "edited during fetch", "new local edits preserved")
end
for _, kind in ipairs({ "fail", "partial", "crash" }) do
for at = 1, 13 do
local fs, files, mode = filesystem(); warehouse(files)
local box = Box.new(fs); local s = Serializer.decode(files[Store.PATH]); s.boxes[1].mons = {}
local payload = assert(box:snapshot(savesFor(files).list(), linked())).payload
payload.blob = Serializer.encode(s); local save = Serializer.decode(payload.saves["red/source"].blob)
save.boxes[1] = { Store.copy(mon) }; payload.saves["red/source"].blob = Serializer.encode(save)
mode.at, mode.kind = at, kind
pcall(box.apply, box, payload, savesFor(files).list(), linked())
mode.at = nil
T.check(box:recover(), kind .. " " .. at .. " recovers interrupted group")
T.eq(count(files), 1, kind .. " " .. at .. " retains exactly one Pokémon")
T.eq(files[Transaction.PATH], nil, "recovery journal cleared")
end
end
do
local fs, files, mode = filesystem(); warehouse(files)
local before = files[Store.PATH]; local nextState = Serializer.decode(before); nextState.boxes[1].name = "cloud"
mode.at = 2
Transaction.commitSync(fs, { { path = Store.PATH, before = before, after = Serializer.encode(nextState) } })
mode.at = nil; local other = Serializer.decode(before); other.boxes[1].name = "external edit"; files[Store.PATH] = Serializer.encode(other)
local ok, applied, notice = Transaction.recover(fs)
T.check(ok and not applied and notice, "external edit sets the pending transfer aside")
T.eq(Serializer.decode(files[Store.PATH]).boxes[1].name, "external edit", "external edit not overwritten")
T.eq(files[Transaction.PATH], nil, "conflicting journal no longer blocks boot")
local kept = notice and notice:match("(box/conflicts/[^ ]+%.lua)")
T.check(kept and files[kept] ~= nil, "conflicting journal is kept for inspection")
end
local function link(files, id, slot)
writeGame(files, id, slot, false)
local s = Serializer.decode(files[Store.PATH])
s.syncMembers["red/" .. id] = { version = "red", playthroughId = id, path = "saves/red/" .. slot .. ".lua", slotId = slot }
files[Store.PATH] = Serializer.encode(s)
end
do
local fsA, a = filesystem(); warehouse(a); local c = cloud(); local A = engine(fsA, a, c); run(A)
local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B)
link(b, "added", "slot5"); run(B)
T.eq(B.phase, "idle", "device B uploads a newly linked save")
local rev = c.rev
run(A)
T.eq(#A.conflicts, 0, "save linked on another device is not a local Box change")
T.eq(A.phase, "idle", "receiving the new member settles")
local got = false
for _, entry in ipairs(savesFor(a).list()) do if entry.playthroughId == "added" then got = true end end
T.check(got, "newly linked save downloads with the collection")
T.eq(c.rev, rev, "receiving the collection uploads nothing")
run(A); T.eq(c.rev, rev, "no ping-pong after receiving a new member")
end
do
local fsA, a = filesystem(); warehouse(a); local c = cloud(); local A = engine(fsA, a, c); run(A)
local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B)
link(b, "added", "slot5"); assert(assert(Service.open(fsB)):rename(1, "B edit")); run(B)
assert(assert(Service.open(fsA)):rename(1, "A edit")); run(A)
T.eq(#A.conflicts, 1, "real edits on both devices still conflict")
T.check(A:resolveConflict(Box.KEY, "local"), "local choice accepted with a remote-only member"); pump(A)
T.eq(A.phase, "idle", "local choice uploads despite a member it never had")
T.eq(Serializer.decode(c.box.blob).boxes[1].name, "A edit", "local collection wins")
T.check(c.saves["red/added"] and not c.saves["red/added"].deleted, "remote-only member is carried, not dropped")
run(A)
local got = false
for _, entry in ipairs(savesFor(a).list()) do if entry.playthroughId == "added" then got = true end end
T.check(got, "carried member downloads on the next sync")
T.eq(Serializer.decode(a[Store.PATH]).boxes[1].name, "A edit", "local collection kept after receiving the member")
end
do
local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c)
c.saves["red/source"] = { version = "red", rev = 3, blob = files["saves/red/slot1.lua"], meta = game("source").meta }
run(e)
T.eq(e.phase, "conflict", "server without a Box but a changed linked save asks once")
T.check(e:resolveConflict(Box.KEY, "local"), "local choice accepted"); pump(e)
T.eq(e.phase, "idle", "first Box upload initializes the server")
T.eq(c.rev, 1, "server now has the Box")
end
do
local fs, files = filesystem(); warehouse(files)
local payload = assert(Box.new(fs):snapshot(savesFor(files).list(), linked())).payload
local fs2, files2 = filesystem(); files2["options.lua"] = "return {"
local box = Box.new(fs2)
T.check(not box:apply(payload, {}, linked()), "unreadable options refuse the Box apply")
T.eq(files2["options.lua"], "return {", "unreadable options are not replaced")
T.eq(files2[Store.PATH], nil, "no Box written when options are unreadable")
end
do
local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c); run(e)
files["saves/red/slot1.lua"] = nil
e.box.markDeleted = function() return nil, "disk fault" end
local ok = e:noteSaveDeleted("red/source")
T.eq(ok, nil, "failed Box mark is reported")
T.check(e.state.pendingDeletes["red/source"] ~= nil, "deletion is still recorded")
T.eq(State.rev(e.state, "red/source"), nil, "deleted save is forgotten")
e.box.markDeleted = nil
run(e)
T.eq(c.saves["red/source"].deleted, true, "deletion reaches the cloud without resurrecting")
end
T.finish("sync_box")
+22 -84
View File
@@ -30,96 +30,17 @@ local function fresh()
return files
end
do
local ids = { [100] = true, [200] = true, [300] = true }
local save = {
version = "red",
player = { id = 200, name = "RED" },
party = {
{ species = "PIKACHU", otId = 200 },
{ species = "ABRA", otId = 100, traded = true },
{ species = "MEW", otId = 4242, traded = true },
{ species = "ODDISH" },
},
boxes = { { { species = "ZUBAT", otId = 300 } } },
daycare = { mon = { species = "EKANS", otId = 200 } },
}
T.check(TrainerIdSync.rewriteSave(save, 1, 100, ids), "gen1 save reports a change")
T.eq(save.player.id, 100, "gen1 player id takes the new id")
T.eq(save.party[1].otId, 100, "own catch follows the id")
T.eq(save.party[2].otId, 100, "mon traded from another save follows the id")
T.eq(save.party[2].traded, nil, "and is no longer flagged traded")
T.eq(save.party[3].otId, 4242, "a real trade keeps its OT")
T.eq(save.party[3].traded, true, "and stays traded")
T.eq(save.party[4].otId, nil, "an unstamped mon is left for load-time stamping")
T.eq(save.boxes[1][1].otId, 100, "boxed mons follow the id")
T.eq(save.daycare.mon.otId, 100, "daycare mon follows the id")
T.check(not TrainerIdSync.rewriteSave(save, 1, 100, ids), "a second pass is a no-op")
end
do
local raw = ("00"):rep(12) .. "00C8" .. ("00"):rep(30)
local save = { player = { id = 200 }, party = { { cartRaw = raw } } }
TrainerIdSync.rewriteSave(save, 1, 100, { [100] = true, [200] = true })
T.eq(save.party[1].cartRaw:sub(25, 28), "0064", "raw gen1 carrier gets its OT bytes rewritten")
T.eq(#save.party[1].cartRaw, #raw, "without changing its length")
end
do
local save = {
version = "gold", player = { id = 200 },
party = { { otId = 200, traded = true } },
dayCare = { man = { mon = { otId = 200 } }, egg = { otId = 200 } },
hallOfFame = { teams = { { mons = { { otId = 200 } } } } },
mail = { party = { { authorId = 200 } }, box = { { authorId = 9 } } },
}
TrainerIdSync.rewriteSave(save, 2, 100, { [100] = true, [200] = true })
T.eq(save.player.id, 100, "gen2 player id")
T.eq(save.dayCare.man.mon.otId, 100, "gen2 daycare parent")
T.eq(save.dayCare.egg.otId, 100, "gen2 daycare egg")
T.eq(save.hallOfFame.teams[1].mons[1].otId, 100, "gen2 hall of fame")
T.eq(save.mail.party[1].authorId, 100, "gen2 own mail")
T.eq(save.mail.box[1].authorId, 9, "gen2 foreign mail untouched")
end
do
local bit = require("bit")
local function shinyKey(tid, sid) return bit.bxor(tid, sid) end
local save = {
engine = "game3", version = "emerald", trainerId = 200, secretId = 0x1234,
party = { { otId = 200, otSecretId = 0x1234 }, { otId = 300, otSecretId = 0x0F0F } },
storage = { boxes = { [3] = { mons = { [7] = { otId = 200, otSecretId = 0x1234 } } } } },
modData = { emerald_daycare = { daycare = { { otId = 200, otSecretId = 0x1234 } } } },
hallOfFameTeams = { { { trainerId = 200, otSecretId = 0x1234 } } },
secretBases = { { trainerId = { 200, 0, 0x34, 0x12 } } },
}
TrainerIdSync.rewriteSave(save, 3, 100, { [100] = true, [200] = true, [300] = true })
T.eq(save.trainerId, 100, "gen3 trainer id")
T.eq(shinyKey(save.trainerId, save.secretId), shinyKey(200, 0x1234),
"gen3 secret id is reseated so own shinies stay shiny")
T.eq(save.party[1].otId, 100, "gen3 party mon")
T.eq(save.party[1].otSecretId, save.secretId, "own mon keeps matching the save's secret id")
T.eq(shinyKey(save.party[2].otId, save.party[2].otSecretId), shinyKey(300, 0x0F0F),
"mon from another save keeps its shininess")
T.eq(save.storage.boxes[3].mons[7].otId, 100, "gen3 sparse box mon")
T.eq(save.modData.emerald_daycare.daycare[1].otId, 100, "gen3 daycare mon")
T.eq(save.hallOfFameTeams[1][1].trainerId, 100, "gen3 hall of fame")
local b = save.secretBases[1].trainerId
T.eq(b[1] + b[2] * 256, 100, "own secret base TID")
T.eq(b[3] + b[4] * 256, save.secretId, "own secret base SID")
end
do
local files = fresh()
local red = SaveData.createSlot("red")
SaveData.writeSlot("red", red, { version = "red", player = { id = 111, name = "RED" },
party = { { species = "PIKACHU", otId = 111 } } })
party = { { species = "PIKACHU", otId = 111, ot = "RED" } } })
local gold = SaveData.createSlot("gold")
SaveData.writeSlot("gold", gold, { version = "gold", generation = 2, player = { id = 222, name = "GOLD" },
party = { { species = "CYNDAQUIL", otId = 222 }, { species = "ABRA", otId = 111, traded = true } } })
party = { { species = "CYNDAQUIL", otId = 222, ot = "GOLD" }, { species = "ABRA", otId = 111, ot = "RED", traded = true } } })
local em = SaveData.createSlot("emerald")
SaveData.writeSlot("emerald", em, { version = "emerald", engine = "game3", generation = 3,
trainerId = 333, secretId = 5, name = "MAY", party = { { otId = 333, otSecretId = 5 } } })
trainerId = 333, secretId = 5, name = "MAY", party = { { species = 25, otId = 333, otSecretId = 5, otName = "MAY", personality = 12345 } } })
local job = TrainerIdSync.newJob({ scope = "gold", slot = gold })
local steps, last = 0, -1
@@ -162,10 +83,10 @@ do
local red = SaveData.createSlot("red")
SaveData.writeSlot("red", red, { version = "red", meta = { savedAt = 500 },
player = { id = 111, name = "RED" }, party = { { species = "PIKACHU", otId = 111 } } })
player = { id = 111, name = "RED" }, party = { { species = "PIKACHU", otId = 111, ot = "RED" } } })
local gold = SaveData.createSlot("gold")
SaveData.writeSlot("gold", gold, { version = "gold", generation = 2, meta = { savedAt = 500 },
player = { id = 222, name = "GOLD" }, party = { { species = "CYNDAQUIL", otId = 222 } } })
player = { id = 222, name = "GOLD" }, party = { { species = "CYNDAQUIL", otId = 222, ot = "GOLD" } } })
local server, puts = {}, {}
local transport = { sent = {}, handles = {} }
@@ -228,6 +149,23 @@ do
T.eq(eng.phase, "idle", "and the engine settles")
end
do
local nick = ("50"):rep(11)
local ot = "91848350" .. ("00"):rep(7)
local struct = ("A5"):rep(12) .. "006F" .. ("00"):rep(19)
local save = { version = "red", player = { id = 111, name = "RED" }, party = {},
daycare = { cartRaw = "01" .. nick .. ot .. struct } }
local owners = {}
TrainerIdSync.addOwner(owners, { id = 111, name = "RED" }, 1)
local changed = TrainerIdSync.rewriteSave(save, 1, { id = 222, name = "BLUE", sid = 0 }, owners)
T.check(changed, "raw cart day-care block counts as a change")
local raw = save.daycare.cartRaw
T.eq(#raw, 112, "raw day-care block keeps its size")
T.eq(raw:sub(71, 74), "00DE", "raw day-care OT id rewritten")
T.eq(raw:sub(25, 34), "818B9484" .. "50", "raw day-care OT name rewritten")
T.eq(raw:sub(1, 24), "01" .. nick, "raw day-care nickname untouched")
end
love.filesystem = realFS
T.finish("trainer_id_sync")
+265
View File
@@ -0,0 +1,265 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local bit = require("bit")
local Sync = require("src.core.TrainerIdSync")
local Identity = require("src.core.TrainerIdentity")
local SaveData = require("src.core.SaveData")
local Store = require("src.box.Store")
local Records = require("src.box.Records")
local Gifts = require("src.box.Gifts")
local Transaction = require("src.box.Transaction")
local Pokemon = require("src.core.game3.pokemon")
local Engine = require("src.core.game3.battle.engine")
local target = { id = 100, sid = 42, name = "RED" }
local other = { id = 200, sid = 0x1234, name = "MAY" }
local owners = {}
Sync.addOwner(owners, target, 3)
Sync.addOwner(owners, other, 3)
local function mon(id, sid, name, pid, species)
return { species = species or 25, otId = id, otSecretId = sid, otName = name,
ot = name, personality = pid or 12345, nickname = "PIKA", moves = { 84 },
level = 100, hp = 99, maxHp = 99, ivs = { hp = 31 }, opaque = "keep" }
end
local function nativeShiny(m)
return bit.bxor(m.otId, m.otSecretId, m.personality % 65536, math.floor(m.personality / 65536)) < 8
end
local function shinyMon(profile, low, species)
return mon(profile.id, profile.sid, profile.name,
bit.bxor(profile.id, profile.sid, low) * 65536 + low, species)
end
for gen = 1, 3 do
local own = mon(other.id, other.sid, other.name)
local foreignName = mon(other.id, other.sid, "BLUE")
local foreignSid = mon(other.id, other.sid + 1, other.name)
local save = { version = gen == 1 and "red" or gen == 2 and "gold" or "emerald",
player = { id = other.id, name = other.name }, name = other.name,
trainerId = other.id, secretId = other.sid, party = { own, foreignName, foreignSid },
boxes = { { Store.copy(own) } }, daycare = { mon = Store.copy(own) },
storage = { boxes = { [3] = { mons = { [7] = Store.copy(own) } } } },
savedPlayerParty = { Store.copy(own) }, frontier = { towerPlayer = { party = { Store.copy(own) } } },
dayCare = { man = { mon = Store.copy(own) }, lady = { mon = Store.copy(own) }, egg = Store.copy(own) },
modData = { emerald_daycare = { daycare = { Store.copy(own), Store.copy(own) },
route5Daycare = { mon = Store.copy(own) } } },
mail = gen == 2 and { party = { { author = "MAY", authorId = 200 }, { author = "BLUE", authorId = 200 } } }
or gen == 3 and { { playerName = "MAY", trainerId = other.id + other.sid * 65536 } } or nil,
secretBases = { { trainerName = "MAY", trainerId = { 200, 0, 0x34, 0x12 } } },
hallOfFameTeams = { { { trainerId = 200, otSecretId = other.sid, personality = 12345 } } } }
T.check(Sync.rewriteSave(save, gen, target, owners), "Gen " .. gen .. " sync rewrites")
local profile = Identity.profile(save, gen)
T.eq(profile.id, target.id, "public ID follows selected save")
T.eq(profile.name, target.name, "player name follows selected save")
if gen == 3 then
T.eq(profile.sid, target.sid, "secret ID follows selected save")
T.eq(foreignSid.otSecretId, other.sid + 1, "colliding public ID with foreign SID untouched")
T.check(not Pokemon.isTradedMon(own, save), "synced mon passes native ownership check")
T.check(not Engine.isTradedMon({ session = save }, own), "battle ownership uses actual session")
T.check(Engine.isTradedMon({ session = save }, foreignSid), "battle detects different full OT ID")
T.eq(save.mail[1].trainerId, target.id + target.sid * 65536, "mail full identity syncs")
T.eq(save.secretBases[1].trainerName, "RED", "own secret base name syncs")
T.same(save.secretBases[1].trainerId, { 100, 0, 42, 0 }, "own secret base full ID syncs")
T.eq(save.hallOfFameTeams[1][1].trainerId, 200, "nameless historical OT is not guessed")
elseif gen == 2 then
T.eq(save.mail.party[1].authorId, 100, "own mail ID")
T.eq(save.mail.party[1].author, "RED", "own mail name")
T.eq(save.mail.party[2].authorId, 200, "foreign mail collision protected")
end
T.eq(own.otName, "RED", "OT name synced")
T.eq(own.ot, "RED", "OT alias synced")
T.eq(own.otId, 100, "OT ID synced")
T.eq(own.opaque, "keep", "opaque mon data retained")
T.eq(foreignName.otName, "BLUE", "foreign name untouched")
T.eq(foreignName.otId, 200, "ID collision does not relabel foreign mon")
for _, m in ipairs(Sync.monsOf(save, gen)) do
if m ~= foreignName and m ~= foreignSid then T.eq(m.otId, 100, "every live collection synced") end
end
local afterOwners = {}
Sync.addOwner(afterOwners, target, 3)
local body = SaveData.encode(save)
T.check(not Sync.rewriteSave(save, gen, target, afterOwners), "second sync has no changes")
T.eq(SaveData.encode(save), body, "second sync retains exact serialized record")
end
for _, species in ipairs({ 25, 290, 308 }) do
for low = 0, 65535, 257 do
local m = shinyMon(other, low, species)
local pid = assert(Identity.personality(m, target))
T.eq(pid % 25, m.personality % 25, "nature retained")
T.eq(pid % 256, low % 256, "gender byte retained")
T.eq(pid % 2, m.personality % 2, "ability parity retained")
if species == 290 then
T.eq(math.floor(pid / 65536) % 10 < 5, math.floor(m.personality / 65536) % 10 < 5,
"Wurmple evolution retained")
end
m.personality, m.otId, m.otSecretId = pid, target.id, target.sid
T.check(nativeShiny(m), "shiny remains shiny under canonical ID")
local ordinary = mon(other.id, other.sid, other.name, low + 10000 * 65536, species)
local nextTarget = { id = ordinary.personality % 65536, sid = math.floor(ordinary.personality / 65536) }
local adjusted = assert(Identity.personality(ordinary, nextTarget))
T.eq(adjusted % 25, ordinary.personality % 25, "accidental shiny correction keeps nature")
T.eq(adjusted % 65536, ordinary.personality % 65536, "accidental shiny correction keeps lower word")
ordinary.personality, ordinary.otId, ordinary.otSecretId = adjusted, nextTarget.id, nextTarget.sid
T.check(not nativeShiny(ordinary), "non-shiny remains non-shiny")
end
end
for low = 0, 255 do
local m = shinyMon(other, low, 201)
local pid, why = Identity.personality(m, target)
T.check(pid ~= nil or why ~= nil, "Unown either preserves its traits or refuses conversion")
if pid then
T.eq(Pokemon.unownLetter(pid), Pokemon.unownLetter(m.personality), "Unown letter retained")
T.eq(pid % 25, m.personality % 25, "Unown nature retained")
T.eq(pid % 2, m.personality % 2, "Unown parity retained")
m.personality, m.otId, m.otSecretId = pid, target.id, target.sid
T.check(nativeShiny(m), "Unown shininess retained")
end
end
do
local raw = ("00"):rep(12) .. "00C8" .. ("00"):rep(30)
local carrier = { cartRaw = raw, cartOt = "8C809850" .. ("AB"):rep(7) }
local save = { player = { id = 200, name = "MAY" }, party = { carrier } }
T.check(Sync.rewriteSave(save, 1, target, owners), "Gen 1 raw carrier sync")
T.eq(carrier.cartRaw:sub(25, 28), "0064", "Gen 1 carrier numeric ID patched")
T.eq(Identity.monName(carrier), "RED", "Gen 1 carrier OT name patched")
T.eq(carrier.cartOt:sub(9), ("AB"):rep(7), "raw name tail preserved")
T.eq(#carrier.cartRaw, #raw, "raw record length preserved")
local raw2 = ("00"):rep(6) .. "00C8" .. ("00"):rep(24)
local c2 = { species = 99, otId = 200, ot = "MAY", cartRaw = raw2 }
T.check(Sync.rewriteSave({ player = { id = 200, name = "MAY" }, party = { c2 } }, 2, target, owners),
"Gen 2 carrier sync")
T.eq(c2.cartRaw:sub(13, 16), "0064", "Gen 2 raw ID patched")
T.check(c2.cartRawFp ~= nil, "Gen 2 carrier fingerprint updated")
end
local realFs = love.filesystem
local function fresh()
local files, fault = {}, {}
love.filesystem = {
read = function(path) return files[path] end,
getInfo = function(path) return files[path] and { type = "file" } end,
createDirectory = function() return true end,
remove = function(path) files[path] = nil; return true end,
write = function(path, body)
if path == fault.path then
if fault.partial then files[path] = body:sub(1, math.floor(#body / 2)) end
return nil, "disk fault"
end
files[path] = body; return true
end,
}
SaveData.resetSlotState()
local a = SaveData.createSlot("emerald")
SaveData.writeSlot("emerald", a, { version = "emerald", engine = "game3", name = other.name,
trainerId = other.id, secretId = other.sid, meta = { playthroughId = "em-one" },
party = { shinyMon(other, 123, 25) } })
local b = SaveData.createSlot("firered")
SaveData.writeSlot("firered", b, { version = "firered", engine = "game3", name = target.name,
trainerId = target.id, secretId = target.sid, meta = { playthroughId = "fr-one" },
party = { mon(target.id, target.sid, target.name) } })
return files, fault, a, b
end
local function finish(job)
for _ = 1, 1000 do if job:step() then return end end
error("ID Sync did not finish")
end
do
local files, _, a, b = fresh()
local state = Store.new()
state.nextId = 3
local stored = { id = 1, generation = 3, version = "emerald", slotId = a,
mon = shinyMon(other, 123, 308), display = {}, depositorId = other.id + other.sid * 65536,
tags = "keep", archives = { { id = 1, generation = 2, version = "gold", display = {},
mon = { species = "PIKACHU", otId = other.id, ot = other.name } } } }
state.boxes[1].mons[1] = stored
state.departures = { [2] = { entry = { id = 2, generation = 3, version = "emerald",
mon = shinyMon(other, 123), display = {} }, path = "saves/emerald/" .. a .. ".lua" } }
state.departures[2].identity = Records.identity(3, state.departures[2].entry.mon)
state.stages = {}
state.presets = { { name = "Team", ids = { 1, 2 } } }
local source = { version = "emerald", path = "saves/emerald/" .. a .. ".lua" }
local oldSave = SaveData.decode(files[source.path])
state.progress = { [Gifts.identity(source, oldSave)] = 500 }
files[Store.PATH] = SaveData.encode(state)
local job = Sync.newJob({ scope = "firered", slot = b })
finish(job)
T.eq(job.error, nil, "warehouse job succeeds")
T.check(job.boxUpdated, "warehouse is included in coordinated commit")
local saved = SaveData.decode(files[source.path])
local box = SaveData.decode(files[Store.PATH])
T.eq(box.boxes[1].mons[1].mon.otName, "RED", "stored OT name follows")
T.check(nativeShiny(box.boxes[1].mons[1].mon), "stored shiny preserved")
T.eq(box.boxes[1].mons[1].archives[1].mon.ot, "RED", "restorable archive identity follows")
T.eq(box.boxes[1].mons[1].depositorId, target.id + target.sid * 65536, "depositor ID follows")
T.eq(box.boxes[1].mons[1].tags, "keep", "labels preserved")
T.same(box.presets, state.presets, "team references preserved")
T.eq(box.progress[Gifts.identity(source, saved)], 500, "gift peak survives identity change")
T.eq(box.departures[2].identity, Records.identity(3, box.departures[2].entry.mon), "return identity refreshed")
T.eq(box.departures[2].identity, Records.identity(3, saved.party[1]), "return tracking matches updated game mon")
T.check(box.syncMembers["emerald/em-one"] ~= nil, "changed save linked to warehouse sync")
T.eq(files[Transaction.PATH], nil, "completed journal removed")
local before = SaveData.encode(box)
local again = Sync.newJob({ scope = "firered", slot = b })
finish(again)
T.eq(again.error, nil, "repeat job succeeds")
T.eq(again.written, 0, "repeat job writes no saves")
T.eq(files[Store.PATH], before, "repeat job leaves warehouse unchanged")
end
for _, mode in ipairs({ "changed", "unreadable", "pending", "partial" }) do
local files, fault, a, b = fresh()
local path = "saves/emerald/" .. a .. ".lua"
local original, source = files[path], files["saves/firered/" .. b .. ".lua"]
local Trade = require("src.online.Trade")
local pendingSentAt = Trade.pendingSentAt
if mode == "pending" then Trade.pendingSentAt = function() return { {} } end end
if mode == "unreadable" then files[path], files[path .. ".bak"] = "invalid save", "invalid backup" end
local job = Sync.newJob({ scope = "firered", slot = b })
if mode == "changed" then
while job.phase == "read" do job:step() end
files[path] = original .. "\n-- changed externally"
elseif mode == "partial" then fault.path, fault.partial = path, true end
finish(job)
T.check(job.error ~= nil, mode .. " reports a failure")
T.eq(job.written, 0, mode .. " does not claim success")
T.eq(files["saves/firered/" .. b .. ".lua"], source, mode .. " source is untouched")
if mode == "partial" then
fault.path = nil
T.check(Transaction.recover(love.filesystem), "partial identity commit recovers")
T.eq(SaveData.decode(files[path]).party[1].otName, "RED", "recovery completes exact intended identity")
T.eq(files[path .. ".bak"], files[path], "recovery backup follows committed side")
else T.eq(files[Transaction.PATH], nil, mode .. " starts no transaction") end
Trade.pendingSentAt = pendingSentAt
end
love.filesystem = realFs
do
local Gen3Save = require("src.save_convert.Gen3Save")
local codec = Gen3Save.forVersion("emerald")
local JP = codec.L.LANGUAGE_JAPANESE
local egg = { personality = 12345, otId = 1, otSecretId = 2, otName = "BRYAN", language = JP,
isEgg = true, isEggFlag = true, species = 1, nicknameBytes = codec.L.EGG_NICKNAME,
ivs = {}, evs = {}, contest = {}, moves = { 1, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
local decoded = codec.decodeBoxMon(codec.encodeBoxMon(egg))
T.eq(decoded.otName, "BRYAN", "egg OT name decodes in the player's charset")
T.eq(decoded.nickname, "タマゴ", "egg nickname keeps its Japanese bytes")
local healed = { personality = 1, language = JP, isEgg = true, otName = "BRYAN", ot = "BRYAN",
cartExtra = { otNameRaw = { codec.encodeString("BRYAN", 8, 0xFF):byte(1, -1) } } }
T.check(Gen3Save.repairJapaneseNames({ healed }), "fullwidth egg OT name is healed")
T.eq(healed.otName, "BRYAN", "healed egg OT name")
T.eq(healed.ot, "BRYAN", "healed egg OT alias")
local flagged = { personality = 1, language = JP, cartExtra = { flagsRaw = 6 }, otName = "ASH1" }
T.check(Gen3Save.repairJapaneseNames({ flagged }), "egg flag byte alone marks an egg for healing")
T.eq(flagged.otName, "ASH1", "fullwidth digits heal too")
local japanese = { personality = 1, language = JP, otName = "さとし", nickname = "ピカチュウ", species = 25,
ivs = {}, evs = {}, contest = {}, moves = { 1, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
local bytes = codec.encodeBoxMon(japanese)
local back = codec.decodeBoxMon(bytes)
T.eq(back.otName, "さとし", "Japanese OT name decodes")
T.eq(codec.encodeBoxMon(back), bytes, "Japanese names round-trip their bytes")
end
T.finish("trainer_identity_sync")
@@ -77,7 +77,7 @@ do
flags = {}, vars = {},
}
local loaded = Schema.fromSaveTable(old)
eq(loaded.secretId, nil, "no secretId in, no secretId out")
eq(loaded.secretId, 4242, "no secretId in, the owned mon's secret id is recovered at load")
eq(loaded.safari, nil, "no safari block")
eq(loaded.mail, nil, "no mail pool")
eq(#loaded.party, 1, "the party is not quarantined")
+4 -4
View File
@@ -206,11 +206,11 @@ do
dex = { seen = {}, owned = {} }, flags = {}, vars = {},
}
local fixed = Schema.fromSaveTable(secretSave)
eq(fixed.party[1].otSecretId, rolled.secretId,
"a stale per-boot secret id on an own mon is repaired")
eq(fixed.party[1].otSecretId, ((rolled.secretId or 0) + 1) % 0x10000,
"a same-name same-TID mon with a different secret id keeps it on load")
local ownLine = memoLine(fixed.party[1], fixed)
check(ownLine:find("a trade", 1, true) == nil,
"so it stops reading as a trade (" .. ownLine:gsub("\n", " ") .. ")")
check(ownLine:find("Apparently", 1, true) ~= nil,
"so it still reads as another trainer's mon (" .. ownLine:gsub("\n", " ") .. ")")
eq(fixed.party[2].otSecretId, 4242, "a foreign mon keeps its own secret half")
end
+41 -1
View File
@@ -193,7 +193,7 @@ eq(order[1], "start", "a caller that faded for itself gets no lead-in")
print("[test] 4. OT identity, the outsider rule and the met stamps")
-- pokefirered/src/pokemon.c:5974 IsOtherTrainer
check(not Pokemon.isOtherTrainer(4242, "RED", session), "the player's own mon is not an outsider")
check(not Pokemon.isOtherTrainer(4242 + (session.secretId or 0) * 65536, "RED", session), "the player's own mon is not an outsider")
check(Pokemon.isOtherTrainer(1985, "RED", session), "a different trainer id is an outsider")
check(Pokemon.isOtherTrainer(4242, "BLUE", session), "the same id with another name is an outsider")
-- pokefirered/src/pokemon.c:5985
@@ -355,4 +355,44 @@ eq(Trade.refusalText(Trade.CAN_TRADE_MON), nil, "a tradeable mon has no refusal
check(Trade.badEggText():find("can't be taken", 1, true) ~= nil,
"the bad-egg abort is pret's cable club text: " .. Trade.badEggText())
print("[test] 8. The player's secret id settles before ownership is compared")
do
local Catching = require("src.core.game3.battle.catching")
local Schema = require("src.core.game3.save_schema_firered")
local legacy = { version = "firered", name = "RED", trainerId = 4242, party = {} }
check(Party.giveMon(legacy, 4, 5, ""), "a gift mon on a save with no secret id")
local starter = legacy.party[1]
check(starter.otSecretId ~= nil, "the gift mon is stamped with a secret id")
eq(Catching.playerSecretId(legacy), starter.otSecretId, "a wild battle reuses the gift mon's secret id")
check(not Pokemon.isTradedMon(starter, legacy), "the gift mon stays the player's own after a wild battle")
local bare = { name = "RED", trainerId = 4242, party = { { species = 4, otId = 4242, otName = "RED" } } }
eq(Catching.playerSecretId(bare), 0, "an own mon with no secret id pins the player's to zero, not a random roll")
check(not Pokemon.isTradedMon(bare.party[1], bare), "so that mon is never counted as traded")
local function save(secretId, party)
return { schemaVersion = 1, engine = "game3", version = "firered", name = "RED", trainerId = 4242,
secretId = secretId, party = party, dex = { seen = {}, owned = {} },
map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down", flags = {}, vars = {} }
end
local loaded = Schema.fromSaveTable(save(42, {
{ species = 4, speciesId = 4, level = 5, hp = 20, maxHp = 20, otId = 4242, otName = "RED" },
{ species = 7, speciesId = 7, level = 5, hp = 20, maxHp = 20, otId = 4242, otName = "RED", otSecretId = 7 },
}))
eq(loaded.party[1].otSecretId, 42, "a missing own secret id is filled from the player's")
eq(loaded.party[2].otSecretId, 7, "a foreign same-name same-TID mon keeps its secret id on load")
check(Pokemon.isTradedMon(loaded.party[2], loaded), "and is still an outsider")
local again = Schema.fromSaveTable(Schema.toSaveTable(loaded))
eq(again.party[2].otSecretId, 7, "a second save and load still leaves the foreign secret id alone")
check(Pokemon.isTradedMon(again.party[2], again), "and it is still an outsider after the reload")
check(not Pokemon.isTradedMon(again.party[1], again), "the player's own mon stays own")
local old = Schema.fromSaveTable(save(nil, {
{ species = 4, speciesId = 4, level = 5, hp = 20, maxHp = 20, otId = 4242, otName = "RED" },
}))
eq(old.secretId, 0, "a save with no secret ids anywhere settles the player's to zero at load")
eq(old.party[1].otSecretId, 0, "and stamps it on the own mon")
eq(Catching.playerSecretId(old), 0, "a later wild battle keeps that secret id")
check(not Pokemon.isTradedMon(old.party[1], old), "so the starter is never traded")
end
finish()
+6
View File
@@ -0,0 +1,6 @@
function love.conf(t)
t.identity = "g1r-box-sync-native-" .. tostring(os.getenv("BOX_TEST_RUN"))
t.version = "11.5"
t.window.width, t.window.height = 1024, 768
t.window.title = "Box sync verification"
end
+144
View File
@@ -0,0 +1,144 @@
io.stdout:setvbuf("no")
function love.errorhandler(why)
print("BOX_NATIVE_FAIL bootstrap: " .. tostring(why))
return function() return 1 end
end
local repo = assert(os.getenv("BOX_TEST_REPO"))
local url = assert(os.getenv("BOX_TEST_URL"))
assert(url:match("^http://127%.0%.0%.1:%d+$"), "loopback server required")
assert(love.filesystem.getInfo("src/net/fetch_worker.lua"), "native test must bundle the project sources")
package.path = repo .. "/?.lua;" .. repo .. "/?/init.lua;" .. package.path
local realFs = love.filesystem
local SaveData = require("src.core.SaveData")
local Serializer = require("src.core.SaveSerializer")
local Store = require("src.box.Store")
local Engine = require("src.sync.SyncEngine")
local SyncState = require("src.sync.SyncState")
local SyncBox = require("src.sync.SyncBox")
local Fetch = require("src.net.Fetch")
local checks, stage, shots = 0, "starting", {}
local function check(value, label)
checks = checks + 1; assert(value, label)
end
local function deviceFs(name)
local fs = setmetatable({}, { __index = realFs })
for _, method in ipairs({ "read", "write", "getInfo", "remove", "createDirectory", "getDirectoryItems" }) do
fs[method] = function(path, ...) return realFs[method](name .. "/" .. path, ...) end
end
fs.getSaveDirectory = function() return realFs.getSaveDirectory() .. "/" .. name end
fs.getSource, fs.getWorkingDirectory, fs.getSourceBaseDirectory = realFs.getSource, realFs.getWorkingDirectory, realFs.getSourceBaseDirectory
fs.getRealDirectory, fs.load = realFs.getRealDirectory, realFs.load
return fs
end
local function context(fs)
love.filesystem = fs; SaveData.resetSlotState()
end
local function write(fs, path, value)
local parent = path:match("^(.*)/[^/]+$"); if parent then assert(fs.createDirectory(parent)) end
assert(fs.write(path, Serializer.encode(value)))
end
local function newEngine(fs)
context(fs)
local state = SyncState.defaults(); state.enabled = true
return Engine.new({ fs = fs, state = state, baseUrl = url })
end
local function pump(e, fs)
local deadline = love.timer.getTime() + 15
repeat
context(fs); e:update(1 / 60); coroutine.yield()
assert(love.timer.getTime() < deadline, "sync timeout")
until not e:busy()
check(e.phase ~= "error", tostring(e.error))
end
local function read(fs, p)
local body = fs.read(p)
return assert(Serializer.decode(body))
end
local function run()
local fsA, fsB = deviceFs("a"), deviceFs("b")
local state = Store.new(); state.nextId = 2
state.boxes[1].mons[1] = { id = 1, version = "red", generation = 1, slotId = "slot1",
mon = { species = "PIKACHU", level = 12, opaque = "\0\255" }, display = {} }
state.syncMembers = { ["red/loopback"] = { version = "red", playthroughId = "loopback", slotId = "slot1", path = "saves/red/slot1.lua" } }
state.presets = { { name = "Native team", ids = { 1 } } }
state.stages = { require("src.box.Showcase").new() }
state.gciTemplate = string.rep("GCI template fixture", 300000)
local pixels = love.image.newImageData(1136, 432)
pixels:mapPixel(function() return 0.15, 0.35, 0.5, 1 end)
state.boxes[1].theme, state.boxes[1].wallpaper = "Showcase", "box/showcase/1.png"
assert(fsA.createDirectory("box/showcase")); local png = pixels:encode("png"):getString()
assert(fsA.write(state.boxes[1].wallpaper, png)); pixels:release()
write(fsA, Store.PATH, state)
write(fsA, "saves/red/slot1.lua", { version = "red", generation = 1, player = { name = "RED" }, party = {}, boxes = { {} },
pokedex = { seen = {}, owned = {} }, inventory = {}, meta = { playthroughId = "loopback", savedAt = 1700000000 } })
local A = newEngine(fsA); stage = "uploading native HTTP snapshot"
assert(A:createAccount("native test A")); pump(A, fsA)
check(A.state.boxRev == 1, "first collection revision")
check(#A.box:snapshot(A.saves.list(), A.state).payload.blob > 4 * 1024 * 1024, "reply exceeds ordinary save limit")
write(fsB, "saves/red/slot1.lua", { version = "red", player = { name = "LOCAL" }, meta = { playthroughId = "independent", savedAt = 1700000000 } })
write(fsB, "options.lua", { saveSlots = { red = { list = { "slot1" }, active = "slot1" } }, custom = "device B" })
local B = newEngine(fsB); stage = "receiving on second device"
assert(B:linkDevice(A.state.code1, A.state.code2, "native test B")); pump(B, fsB)
local remote = read(fsB, Store.PATH)
check(remote.syncMembers["red/loopback"].slotId == "slot2", "production adapter remaps slots")
check(remote.boxes[1].mons[1].slotId == "slot2", "origin remapped")
check(remote.gciTemplate == state.gciTemplate, "large template unchanged")
check(remote.presets[1].name == "Native team", "team synced")
check(remote.stages[1].name == state.stages[1].name, "stage synced")
check(fsB.read("box/showcase/1.png") == png, "real PNG synced")
check(read(fsB, "options.lua").custom == "device B", "device options retained")
check(read(fsB, "saves/red/slot2.lua").meta.playthroughId == "loopback", "production save routing")
local receivedRev = B.state.boxRev; assert(B:syncNow()); pump(B, fsB)
check(B.state.boxRev == receivedRev, "no redundant upload after remapping")
context(fsA); local s = read(fsA, Store.PATH); s.boxes[1].name = "A offline edit"; write(fsA, Store.PATH, s)
assert(A:syncNow()); pump(A, fsA)
context(fsB); s = read(fsB, Store.PATH); s.boxes[1].name = "B offline edit"; write(fsB, Store.PATH, s)
assert(B:syncNow()); pump(B, fsB)
check(B.conflicts[1] and B.conflicts[1].box, "production HTTP conflict")
check(not B:resolveConflict(SyncBox.KEY, "both"), "whole collection choice required")
stage = "capturing Box conflict"
local imp = require("src.import.RomImporter").new(function() end, { launcher = true })
imp._sync, imp._syncTransportOk = B, true; imp:_openSync()
local previousDraw = love.draw
local pending
love.draw = function()
imp:draw()
if pending then
local p = pending; pending = nil
love.graphics.captureScreenshot(function(image)
local encoded = image:encode("png"):getString()
assert(realFs.write(p, encoded)); shots[#shots + 1] = p
end)
end
end
for _, view in ipairs({ { 1024, 768, "conflict-desktop.png" }, { 390, 844, "conflict-portrait.png" } }) do
love.window.setMode(view[1], view[2], { resizable = true })
for _ = 1, 3 do imp:update(1 / 60); coroutine.yield() end
pending = view[3]; while pending do coroutine.yield() end
for _ = 1, 3 do coroutine.yield() end
end
love.draw = previousDraw
check(#shots == 2, "desktop and portrait conflict rendered")
assert(B:resolveConflict(SyncBox.KEY, "remote")); pump(B, fsB)
check(read(fsB, Store.PATH).boxes[1].name == "A offline edit", "remote collection selected")
check(B.box:latestBackup() ~= nil, "recoverable losing snapshot")
check(SyncState.load(fsB).boxRev == B.state.boxRev, "sync baseline persisted")
stage = "complete"
love.filesystem = realFs
Fetch.shutdown()
print(("BOX_NATIVE_PASS %d checks; screenshots: %s"):format(checks, realFs.getSaveDirectory()))
love.event.quit(0)
end
local co
function love.load() co = coroutine.create(run) end
function love.update()
if coroutine.status(co) == "dead" then return end
local ok, why = coroutine.resume(co)
if not ok then
print("BOX_NATIVE_FAIL " .. stage .. ": " .. tostring(why)); love.filesystem = realFs
Fetch.shutdown(); love.event.quit(1)
end
end
function love.draw()
love.graphics.clear(0.04, 0.06, 0.1); love.graphics.print(stage, 30, 30)
end
+6
View File
@@ -0,0 +1,6 @@
function love.conf(t)
t.identity = "g1r-box-ui-native-" .. assert(os.getenv("BOX_UI_RUN"))
t.window.title = "Box UI verification"
t.window.width, t.window.height = 1360, 860
t.window.resizable = true
end
+476
View File
@@ -0,0 +1,476 @@
local root = assert(os.getenv("BOX_UI_REPO"))
io.stdout:setvbuf("no")
package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path
local imp
local co, pending, controls, checks = nil, nil, {}, 0
local awards={}
local function check(value, label) checks = checks + 1; assert(value, label) end
function love.load()
local CacheFs = require("src.import.CacheFs")
local CacheBlob = require("src.import.CacheBlob")
CacheFs.readAt = function(path)
local file
for cache in assert(os.getenv("BOX_UI_CACHES")):gmatch("[^:]+") do
file=io.open(cache.."/"..path,"rb")
if file then break end
end
if not file then return nil end
local bytes = file:read("*a"); file:close()
return CacheBlob.decode(path, bytes)
end
local SaveData = require("src.core.SaveData")
local Serializer = require("src.core.SaveSerializer")
local Store = require("src.box.Store")
local Service = require("src.box.Service")
local Catalog = require("src.box.Catalog")
local state = Store.new()
local save = { version = "emerald", generation = 3, engine = "game3", name = "Demo",
trainerId=42,secretId=97,flags={},vars={},modData={},bag={pockets={}},
party = { { species = 25, level = 20, hp = 52, otId = 42, otSecretId = 97, personality = 345 } },
storage = { currentBox = 1, boxes = { { name = "Demo PC", wallpaper = 1, mons = {} } } } }
local species = {1,4,7,25,133,152,155,158,175,246,151,251,52,54,56,58,60,63,66,69,72,74,77,79,81,83,84,86,88,90,92,95,96,98,100,102,104,108,109,111,113,114,115,116,118,120,122,123,127,128,129,131,132,137,147,149,154,157,160,165}
for i, id in ipairs(species) do
local mon = { species = id, level = 12 + i, hp = 40, personality = i * 1001,
otId = 42, otSecretId = 97, otName = "Demo", moves = { 33,45 }, pp={30,35}, friendship=70, language=2,
ivs={hp=15,atk=15,def=15,spe=15,spa=15,spd=15},evs={},heldItem=0,
exp=require("src.core.game3.summary_data").expForLevel(Catalog.get("emerald").meta[id].growthRate,12+i), metGame=3,metLevel=5,metLocation=7,pokeball=4 }
if i == 2 then
mon.markings, mon.pokerus, mon.modernFatefulEncounter = 5, 0x23, false
mon.contest = { cool=0, beauty=80, cute=70, smart=45, tough=54, sheen=15 }
mon.ribbons = { cool=2, champion=1, artist=1 }
end
if i <= 24 then save.storage.boxes[1].mons[i] = Store.copy(mon) end
state.boxes[1].mons[i] = { id = i, version = "emerald", generation = 3,
mon = mon, display = Catalog.describe("emerald", mon) }
end
state.boxes[1].name, state.boxes[2].name, state.boxes[3].name = "Kanto collection", "Starters", "Rare finds"
for i = 1, 12 do
local entry = Store.copy(state.boxes[1].mons[i]); entry.id = 60 + i
state.boxes[2].mons[i] = entry
end
for i = 1, 6 do
local entry = Store.copy(state.boxes[1].mons[i + 8]); entry.id = 72 + i
state.boxes[3].mons[i * 3] = entry
end
state.nextId = 79
local Showcase=require("src.box.Showcase")
local stage=Showcase.new();stage.name="Starter garden";stage.pattern="Dots"
stage=assert(Showcase.add(stage,{{box=1,slot=1},{box=1,slot=2},{box=1,slot=3}},state))
for i,piece in ipairs(stage.pieces) do piece.x=.28+(i-1)*.22;piece.y=.5;piece.scale=1.4 end
stage.pieces[#stage.pieces+1]={kind="Platform",x=.5,y=.75,scale=2,rotation=0,flip=false}
state=assert(Showcase.save(state,1,stage))
local fs = SaveData.persistenceFs()
fs.createDirectory("box"); fs.createDirectory("saves/emerald")
fs.write(Store.PATH, Serializer.encode(state))
fs.write("saves/emerald/slot1.lua", Serializer.encode(save))
local second = Store.copy(save); second.name = "Other trainer"; second.trainerId = 99
if os.getenv("BOX_UI_ID_SYNC_ONLY") then
second.name, second.secretId = "May", 123
for _, mon in ipairs(require("src.core.TrainerIdSync").monsOf(second, 3)) do
mon.otId, mon.otSecretId, mon.otName = 99, 123, "May"
mon.personality = require("bit").bxor(99, 123, 12345) * 65536 + 12345
end
local opts = SaveData.loadOptions()
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots.emerald = { active = "slot1", list = { "slot1", "slot2" } }
SaveData.saveOptions(opts)
end
fs.write("saves/emerald/slot2.lua", Serializer.encode(second))
imp = require("src.import.RomImporter").new(function() end, { launcher = true })
imp.tab = "box"
imp.mods, imp.modStraysChecked = {}, true
imp.findErrors, imp._findFetch, imp.findLoading, imp.findNotice = {}, nil, false, nil
imp._refreshFindSources = function() end
imp._refreshFind = function() end
imp.findLoaded, imp.findSources = true, { { feed = "https://example.invalid/mods.json" } }
imp.findIndex = { mods = {}, carts = {} }
imp._ensureMods = function() end
imp._ensureFind = function() end
local pcRows = {}
for i, mon in ipairs(save.storage.boxes[1].mons) do
pcRows[i] = { where = "box", box = 1, index = i, key = "box:1:" .. i,
source = "Demo PC", mon = mon, entry = { version = "emerald", generation = 3,
mon = mon, display = Catalog.describe("emerald", mon) } }
end
imp._boxState = { service = assert(Service.open()), sources = {
{ version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua", label = "Emerald · Demo" },
{ version = "emerald", slotId = "slot2", path = "saves/emerald/slot2.lua", label = "Emerald · Other trainer" } },
sourceIndex = 1, box = 1, pcBox = 1, query = "", sort = "slot", pcRows = pcRows,
pcBody = Serializer.encode(save), selectedBox = {}, selectedPC = {}, pageBox = 1,
pagePC = 1, sourcePage = 1, pcSave = save, multi = true, view = "box" }
for _, slot in ipairs({ 1, 2 }) do imp._boxState.selectedBox["1:" .. slot] = { box = 1, slot = slot } end
imp._boxState.inspect = imp._boxState.service.state.boxes[1].mons[2]
local View = require("src.import.LauncherView")
local original = View.btn
View.btn = function(owner, x, y, w, h, id, label, opts)
controls[id] = { x=x, y=y, w=w, h=h, label=label, enabled=opts.enabled ~= false, icon=opts.icon }
return original(owner, x, y, w, h, id, label, opts)
end
local Icons=require("src.ui.kit.Icons")
local drawIcon=Icons.draw
Icons.draw=function(icon,x,y,...)
if icon=="award" then awards[#awards+1]={x=x,y=y} end
return drawIcon(icon,x,y,...)
end
co = coroutine.create(run)
end
local UI = require("src.import.BoxUI")
local Transition = require("src.ui.kit.Transition")
local function wait(seconds)
local untilAt = love.timer.getTime() + (seconds or .3)
repeat coroutine.yield() until love.timer.getTime() >= untilAt
end
local function click(id)
wait()
local o=imp._boxState and imp._boxState.organizer
local r = assert(controls[id], "missing control " .. id.." (rule "..tostring(o and o.ruleIndex)..
", mode "..tostring(o and o.config.mode)..", page "..tostring(imp._boxState and imp._boxState.toolsPage)..")")
if not imp._boxPopup and not imp._modConfirm and not imp._idSyncResult then
local view = imp._tabRegionRect
if r.y < view.y or r.y+r.h > view.y+view.h then
imp._tabScroll.box = math.max(0, math.min(imp._tabScrollMax.box, imp._tabScroll.box + r.y - view.y - 20))
wait(); r = assert(controls[id])
end
end
check(r.w > 0 and r.y >= 0 and r.y+r.h <= love.graphics.getHeight(), id .. " reachable")
imp:mousepressed(r.x+r.w/2, r.y+r.h/2, 1)
if imp.mousereleased then imp:mousereleased(r.x+r.w/2, r.y+r.h/2, 1) end
wait()
end
local function shot(name)
wait(); pending = name
repeat coroutine.yield() until pending == nil
wait(.05)
end
local function close()
imp:keypressed("escape"); wait()
check(not imp._boxPopup, "escape closed the popup")
end
local function page(name)
imp._tabScroll.box = 0
if name=="Filters" then
imp._boxState.toolsPage=nil;wait();click("box-filters")
check(imp._boxState.toolsPage=="Filters","filter button opens filters")
return
end
click("box-tools-menu")
local option
for i,row in ipairs(imp._boxPopup.options) do if row.id == name then option=i end end
assert(option, name)
imp._boxPopup.index, imp._boxPopup.reveal = option, true
imp:keypressed("return"); wait()
check(imp._boxState.toolsPage == (name ~= "Pokémon" and name or nil), "tool picker chose " .. name)
end
local function chooseValue(id, value)
click(id)
local index
for i, option in ipairs(imp._boxPopup.options) do if option.id == value then index = i; break end end
assert(index, "missing choice " .. tostring(value))
imp._boxPopup.index, imp._boxPopup.reveal = index, true
imp:keypressed("return"); wait()
end
local function boxInventory(state)
local out = {}
for _, box in ipairs(state.boxes) do
for _, entry in pairs(box.mons) do out[entry.id] = require("src.core.SaveSerializer").encode(entry.mon) end
end
return require("src.core.SaveSerializer").encode(out)
end
local function pcInventory(save)
local out = {}
for _, row in ipairs(require("src.box.Records").candidates(save, 3)) do
if row.where == "box" then out[#out + 1] = require("src.core.SaveSerializer").encode(row.mon) end
end
table.sort(out)
return require("src.core.SaveSerializer").encode(out)
end
function run()
wait()
if os.getenv("BOX_UI_STUDIO_ONLY") then
page("Showcase")
shot("ui-40-showcase-editor-desktop.png")
click("box-stage-add-pokemon")
check(#imp._boxPopup.options>50,"Pokémon picker makes stored collection available")
shot("ui-41-showcase-add-pokemon-desktop.png")
imp._boxPopup.index=2;imp:keypressed("return");wait()
local s=imp._boxState
check(#s.stageDraft.pieces==5,"picker adds a Pokémon directly to the stage")
click("box-stage-add-scenery")
local tree
for i,row in ipairs(imp._boxPopup.options) do if row.label=="Tree" then tree=i end end
imp._boxPopup.index=tree;imp:keypressed("return");wait()
check(s.stageDraft.pieces[s.stagePiece].kind=="Tree","new scenery is a real tree")
local r=s.stageRect
local p=s.stageDraft.pieces[s.stagePiece]
local cx,cy=r.x+p.x*r.w,r.y+p.y*r.h
imp:mousepressed(cx,cy,1);imp:mousemoved(cx+30,cy-20);imp:mousereleased(cx+30,cy-20,1);wait()
check(p.x>.5 and p.y<.5,"native scene dragging changes placement")
local handles=s.stageHandles
local resize=handles[1]
imp:mousepressed(resize.x+resize.w/2,resize.y+resize.h/2,1)
imp:mousemoved(resize.x+resize.w/2+35,resize.y+resize.h/2-10)
imp:mousereleased(0,0,1);wait()
check(p.scale>1.5,"hold resize changes scenery scale")
local flip=s.stageHandles[3]
imp:mousepressed(flip.x+flip.w/2,flip.y+flip.h/2,1);wait()
check(p.flipY,"vertical flip toggles from pop-out")
flip=s.stageHandles[3]
imp:mousepressed(flip.x+flip.w/2,flip.y+flip.h/2,1);wait()
check(not p.flipY,"vertical flip can be restored from pop-out")
click("box-tools-stage-back")
check(s.stagePiece==#s.stageDraft.pieces-1,"layer arrow sends selected object backward")
shot("ui-42-showcase-selected-tools-desktop.png")
chooseValue("box-tools-stageSection","Scene")
chooseValue("box-tools-stage-music","Night")
local Showcase=require("src.box.Showcase")
local fingerprints={}
for i,pattern in ipairs({"Crosses","Bricks","Grid"}) do
chooseValue("box-tools-stage-pattern",pattern)
imp._tabScroll.box=0
shot("ui-"..(46+i).."-showcase-"..pattern:lower().."-desktop.png")
local canvas=love.graphics.newCanvas(400,200)
love.graphics.push("all");love.graphics.setCanvas(canvas);love.graphics.origin();love.graphics.setScissor()
local empty=Showcase.new();empty.pattern=pattern;Showcase.draw(s.service.state,empty,0,0,400,200)
love.graphics.pop()
local data=canvas:newImageData();fingerprints[i]=love.data.hash("sha256",data:getString())
data:release();canvas:release()
end
check(fingerprints[1]~=fingerprints[2] and fingerprints[2]~=fingerprints[3] and fingerprints[1]~=fingerprints[3],
"Crosses, Bricks and Grid render different native pixels")
click("box-stage-options")
imp._boxPopup.index=1;imp:keypressed("return");wait()
check(s.service.state.boxes[1].theme=="Showcase","saved scene is applied as Box theme")
check(s.service.fs.getInfo(s.service.state.boxes[1].wallpaper),"scene wallpaper PNG exists")
check(s.service.state.boxes[1].showcaseMusic=="Night","applying scene saves its chosen music")
page("Pokémon")
local track,owner,music=Showcase.currentMusic()
check(track=="Night" and owner=="box" and music:isPlaying(),"applied scene music plays on native Box board")
shot("ui-50-applied-stage-desktop.png")
page("Themes");click("box-theme-picker")
local saved
for i,row in ipairs(imp._boxPopup.options) do if row.label:find("Stage 1",1,true) then saved=i end end
check(saved~=nil,"saved stages are selectable from Themes")
shot("ui-43-saved-stage-theme-desktop.png");close()
page("Showcase");love.window.setMode(390,844,{resizable=true});imp._tabScroll.box=0;wait()
shot("ui-44-showcase-editor-portrait.png")
page("Pokémon")
s.query="bULba";wait();shot("ui-45-search-context-portrait.png")
check(#require("src.box.Store").search(s.service.state,s.query,"slot")>0,"mixed-case Pokémon lookup matches")
page("List");shot("ui-46-list-sprites-portrait.png")
love.window.setMode(1360,860,{resizable=true});s.query="";page("Pokémon")
local Store=require("src.box.Store")
local edited=Store.copy(s.service.state);edited.revision=edited.revision+1
local shiny=edited.boxes[1].mons[1]
shiny.mon.personality=require("bit").bxor(42,97,12345)*65536+12345
shiny.display=require("src.box.Catalog").describe("emerald",shiny.mon)
check(shiny.display.shiny,"move fixture is actually shiny")
assert(s.service:commit(edited));s.selectedBox={["1:1"]={box=1,slot=1}};s.inspect=nil;wait()
local function marked(r)
for _,a in ipairs(awards) do if a.x>=r.x and a.x<=r.x+r.w and a.y>=r.y and a.y<=r.y+r.h then return true end end
return false
end
check(marked(controls["box-box-1:1"]),"occupied shiny slot has its marker")
click("box-options");imp._boxPopup.index=1;imp:keypressed("return");wait()
chooseValue("box-active-box-picker",2);click("box-box-2:20")
chooseValue("box-active-box-picker",1)
check(s.service.state.boxes[1].mons[1]==nil,"moving Pokémon empties its old slot")
check(not marked(controls["box-box-1:1"]),"empty former slot has no leftover shiny marker")
shot("ui-51-moved-shiny-empty-slot-desktop.png")
imp:_switchTab("mods");wait()
imp:_switchTab("box")
local tr=assert(Transition.get("tabs"),"entering Box keeps its slide")
check(tr.from=="mods" and tr.to=="box" and love.timer.getTime()-tr.t0<.05,"native Box slide starts after refresh")
wait(.03)
tr=assert(Transition.get("tabs"),"default Box transition survives initial native rendering")
check(tr.p>0 and tr.p<1,"default native Box slide renders an intermediate frame")
tr.t0=love.timer.getTime()
tr.dur=1.5
shot("ui-52-box-tab-slide-desktop.png")
tr.dur=.18;wait()
print("BOX_UI_NATIVE_PASS "..checks.." checks; "..love.filesystem.getSaveDirectory())
love.event.quit(0);return
end
if os.getenv("BOX_UI_ID_SYNC_ONLY") then
imp.activeSlot.emerald = "slot1"
imp:askIdSync("emerald"); wait()
check(imp._modConfirm.lines[1]:find("Demo", 1, true), "confirmation names actual trainer")
check(imp._modConfirm.lines[2]:find("secret ID", 1, true), "confirmation covers full trainer identity")
shot("ui-36-id-sync-confirm-desktop.png")
love.window.setMode(390,844,{resizable=true}); wait()
shot("ui-37-id-sync-confirm-portrait.png")
local accept = imp._modConfirm.onYes
imp._modConfirm = nil
accept()
while imp._idSync do wait(.05) end
check(imp._idSyncResult and imp._idSyncResult.title == "ID Sync", "native launcher job succeeds")
local SaveData = require("src.core.SaveData")
local other = SaveData.decode(SaveData.readSlotSource("emerald", "slot2"))
check(other.name == "Demo" and other.trainerId == 42 and other.secretId == 97,
"native disk save matches selected trainer")
check(not require("src.core.game3.pokemon").isTradedMon(other.party[1], other),
"native mon obeys after reload")
check(require("src.core.game3.pokemon").isShiny(other.party[1]), "native shiny remains shiny after reload")
check(not require("src.core.TrainerIdSync").newJob({scope="emerald",slot="slot1"}).error,
"completed journal leaves storage usable")
shot("ui-38-id-sync-result-portrait.png")
love.window.setMode(1360,860,{resizable=true}); wait()
shot("ui-39-id-sync-result-desktop.png")
print("BOX_UI_NATIVE_PASS "..checks.." checks; "..love.filesystem.getSaveDirectory())
love.event.quit(0)
return
end
if not os.getenv("BOX_UI_ORGANIZER_ONLY") then
if not os.getenv("BOX_UI_DETAILS_ONLY") then
check(controls["box-choose-save"].y == controls["box-tools-menu"].y, "desktop toolbar shares one row")
check(controls["box-withdraw"].icon ~= nil, "transfer has an icon")
check(controls["box-withdraw"].label:find("Send 2 to game",1,true), "transfer direction is named")
shot("ui-01-board-desktop.png")
click("box-choose-save"); check(#imp._boxPopup.options==2,"game picker shows playthroughs")
shot("ui-02-game-picker-desktop.png"); close()
click("box-help-Game"); check(imp._boxPopup.message==UI.HELP.Game,"game help explains the PC")
shot("ui-03-game-help-desktop.png"); close()
click("box-tools-menu"); check(#imp._boxPopup.options==11,"all tools remain reachable; filters have their own button")
shot("ui-04-tool-picker-desktop.png"); close()
page("Filters"); shot("ui-05-filters-desktop.png")
click("box-tools-filter-type"); shot("ui-06-filter-picker-desktop.png"); close()
page("Gifts"); shot("ui-07-gifts-desktop.png")
page("Dashboard"); shot("ui-08-dashboard-desktop.png")
page("Showcase"); shot("ui-09-showcase-desktop.png")
click("box-tools-stageSection"); imp._boxPopup.index=2; imp:keypressed("return"); wait()
check(imp._boxState.stageSection=="Placement","placement section chosen")
shot("ui-10-placement-desktop.png")
page("Pokémon")
click("box-options"); shot("ui-11-options-desktop.png"); close()
love.window.setMode(390,844,{resizable=true}); imp._tabScroll.box=0; wait()
shot("ui-12-board-portrait.png")
click("box-tools-menu"); shot("ui-13-tool-picker-portrait.png"); close()
page("Filters"); shot("ui-14-filters-portrait.png")
click("box-help-Game"); shot("ui-15-help-portrait.png"); close()
page("Showcase"); shot("ui-16-showcase-portrait.png")
page("Pokémon")
click("box-view-pc"); check(imp._boxState.view=="pc","game PC view chosen")
imp._tabScroll.box=0; shot("ui-17-game-pc-portrait.png")
click("box-view-box")
click("box-transfer-target"); shot("ui-18-destination-portrait.png")
imp:keypressed("end"); wait(); imp:keypressed("return"); wait()
check(imp._boxState.pcBox==14,"destination picker reaches the last PC box")
shot("ui-19-transfer-toolbar-portrait.png")
imp._tabScroll.box=0
Transition.armed=true; UI.change(imp,imp._boxState,"view","pc",1)
local tr=assert(Transition.get("box")); tr.t0=love.timer.getTime()-.03
pending="ui-20-slide-portrait.png"; coroutine.yield()
wait()
check(not Transition.active("box"),"slide settles")
end
love.window.setMode(1360,860,{resizable=true}); imp._tabScroll.box=0; wait()
click("box-summary"); check(imp._boxState.summary,"summary opens")
shot("ui-21-summary-desktop.png")
click("box-summary-section"); imp._boxPopup.index=2; imp:keypressed("return"); wait()
check(imp._boxState.summarySection=="Moves","moves section chosen")
shot("ui-22-moves-desktop.png")
click("box-summary-section"); imp._boxPopup.index=3; imp:keypressed("return"); wait()
check(imp._boxState.summarySection=="Stats","stats section chosen")
shot("ui-23-stats-desktop.png")
click("box-summary-section"); imp._boxPopup.index=4; imp:keypressed("return"); wait()
shot("ui-24-origin-desktop.png")
local r=assert(controls["box-summary-back"])
local viewport=imp._tabRegionRect
imp._tabScroll.box=math.max(0,math.min(imp._tabScrollMax.box,imp._tabScroll.box+r.y-viewport.y-20)); wait()
shot("ui-25-ribbons-desktop.png")
love.window.setMode(390,844,{resizable=true}); imp._tabScroll.box=0; wait()
click("box-summary-section"); imp._boxPopup.index=1; imp:keypressed("return"); wait()
shot("ui-26-overview-portrait.png")
click("box-summary-section"); imp._boxPopup.index=2; imp:keypressed("return"); wait()
shot("ui-27-moves-portrait.png")
click("box-summary-section"); imp._boxPopup.index=3; imp:keypressed("return"); wait()
shot("ui-28-stats-portrait.png")
r=assert(controls["box-summary-back"]); viewport=imp._tabRegionRect
imp._tabScroll.box=math.max(0,math.min(imp._tabScrollMax.box,imp._tabScroll.box+r.y-viewport.y-20)); wait()
shot("ui-29-contest-portrait.png")
end
if not os.getenv("BOX_UI_DETAILS_ONLY") then
if imp._boxState.summary then click("box-summary-back") end
love.window.setMode(390,844,{resizable=true}); imp._tabScroll.box = 0; wait()
page("Arrange")
chooseValue("box-tools-arrangeSection", "Auto Box")
chooseValue("box-organize-first-box", 2)
chooseValue("box-organize-field", "species")
chooseValue("box-organize-value", "PIKACHU")
check(imp._boxState.organizer.config.rules[1].firstBox == 2, "Auto Box destination is selected")
check(imp._boxState.organizer.config.rules[1].conditions[1].value == "PIKACHU", "Auto Box species condition is selected")
imp._tabScroll.box = 0; shot("ui-30-auto-box-portrait.png")
local warehouseBefore = boxInventory(imp._boxState.service.state)
click("box-organize-preview")
check(boxInventory(imp._boxState.service.state) == warehouseBefore, "rule preview leaves warehouse unchanged")
check(imp._boxState.organizer.preview.report.moved > 0, "rule preview reports actual moves")
check(imp._boxState.organizer.ruleIndex==1,"sticky preview does not click rule underneath")
shot("ui-31-auto-box-preview-portrait.png")
close()
love.window.setMode(1360,860,{resizable=true}); imp._tabScroll.box = 0; wait()
shot("ui-32-auto-box-desktop.png")
check(imp._boxState.organizer.preview ~= nil, "closing movement review keeps the plan")
click("box-organize-apply")
check(boxInventory(imp._boxState.service.state) == warehouseBefore, "Auto Box preserves every native record and entry ID")
local at = require("src.box.Store").find(imp._boxState.service.state, 4)
check(at.box == 2, "Auto Box applies species rule")
click("box-organize-presets")
imp._boxPopup.index = #imp._boxPopup.options; imp:keypressed("return"); wait()
check(imp._boxPrompt ~= nil, "preset uses the existing name prompt")
imp:textinput("Pikachu box"); imp:keypressed("return"); wait()
check(imp._boxState.service.state.organizerProfiles[1].name == "Pikachu box", "preset saved from UI")
chooseValue("box-organize-field", "egg")
click("box-organize-presets")
imp._boxPopup.index = 1; imp:keypressed("return"); wait()
check(imp._boxState.organizer.config.rules[1].conditions[1].value == "PIKACHU", "saved preset reloads its conditions")
click("box-organize-rule-options")
imp._boxPopup.index = 1; imp:keypressed("return"); wait()
check(#imp._boxState.organizer.config.rules[1].conditions == 2, "rule editor adds a second condition")
click("box-organize-rule-options")
imp._boxPopup.index = 4; imp:keypressed("return"); wait()
check(imp._boxState.organizer.config.rules[1].match == "any", "rule editor switches to any condition")
click("box-organize-rule-options")
imp._boxPopup.index = 2; imp:keypressed("return"); wait()
check(#imp._boxState.organizer.config.rules[1].conditions == 1, "rule editor removes selected condition")
chooseValue("box-tools-arrangeSection", "Sort")
click("box-organize-target-pc")
imp._tabScroll.box = 0; shot("ui-33-game-sort-desktop.png")
local pcBefore = pcInventory(imp._boxState.pcSave)
local partyBefore = require("src.core.SaveSerializer").encode(imp._boxState.pcSave.party)
click("box-organize-preview")
check(imp._boxState.organizer.preview.report.moved > 0, "Game PC preview reports actual moves")
check(pcInventory(imp._boxState.pcSave) == pcBefore, "Game PC preview leaves save unchanged")
shot("ui-34-game-sort-preview-desktop.png")
close()
click("box-organize-apply")
check(pcInventory(imp._boxState.pcSave) == pcBefore, "Game PC apply preserves all native records")
check(require("src.core.SaveSerializer").encode(imp._boxState.pcSave.party) == partyBefore, "Game PC sort leaves party untouched")
local source = imp._boxState.sources[imp._boxState.sourceIndex]
check(imp._boxState.service.fs.getInfo(source.path .. ".box-bak"), "Game PC sort keeps a native save backup")
page("Pokémon"); click("box-view-pc")
imp._boxState.pcBox = 1; imp._tabScroll.box = 0
shot("ui-35-sorted-game-pc-desktop.png")
end
require("src.box.Cry").stop(); require("src.box.Showcase").stopMusic()
print("BOX_UI_NATIVE_PASS "..checks.." checks; "..love.filesystem.getSaveDirectory())
love.event.quit(0)
end
function love.update(dt)
if imp then imp:update(dt) end
if co and coroutine.status(co)~="dead" then
local ok, why=coroutine.resume(co)
if not ok then print("BOX_UI_NATIVE_FAIL "..tostring(why)); love.event.quit(1) end
end
end
function love.draw()
if imp then controls={};awards={};imp:draw() end
if pending then
local name=pending; pending=nil
love.graphics.captureScreenshot(function(data)
assert(love.filesystem.write(name,data:encode("png"):getString()))
end)
end
end
function love.errorhandler(why)
print("BOX_UI_NATIVE_FAIL "..tostring(why)); return function() return 1 end
end
+1 -1
View File
@@ -64,7 +64,7 @@ do
local b1, b2, b3, b4 = head:byte(17, 20)
local width = ((b1 or 0) * 16777216) + ((b2 or 0) * 65536)
+ ((b3 or 0) * 256) + (b4 or 0)
T.eq(width, #Icons.NAMES * 96, "and the atlas has one 96px cell per name")
T.eq(width, #Icons.ATLASES[1].names * 96, "and the atlas has one 96px cell per name")
end
T.eq(OnlinePanel.sanitizePin("12a3 4567"), "1234", "a PIN keeps four digits")
+39
View File
@@ -1,3 +1,4 @@
tests/emerald_tent_save_reload_2638_test.lua
tests/engine/advanced_palette_map.lua
tests/engine/amnesia_rest_share_anim_bug1881.lua
tests/engine/android_asymmetric_display.lua
@@ -20,6 +21,7 @@ tests/engine/ball_toss_sound_sync_bug2611.lua
tests/engine/battle_ai_item_messages_2589.lua
tests/engine/battle_ai_item_sfx_bug2541.lua
tests/engine/battle_already_out_bug1953.lua
tests/engine/battle_arrow_cell_bug2674.lua
tests/engine/battle_ball_dodge_romtext.lua
tests/engine/battle_checkpoint_boundary.lua
tests/engine/battle_checkpoint_capture.lua
@@ -47,6 +49,7 @@ tests/engine/boulder_dust_obp1_bug2650.lua
tests/engine/box_menu_message_pages_test.lua
tests/engine/box_release_confirmation_romtext.lua
tests/engine/buena_prize_box_bug1935.lua
tests/engine/cache_contract_emerald_test.lua
tests/engine/cache_fs_blue_mount_test.lua
tests/engine/cache_fs_gold_nx_load_test.lua
tests/engine/cache_fs_headless_test.lua
@@ -107,6 +110,12 @@ tests/engine/fly_map_arrows_bug1892.lua
tests/engine/formulas.lua
tests/engine/frame_cap_display.lua
tests/engine/frame_cap_legacy_loop_2106.lua
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_speed_hotkey_test.lua
tests/engine/game3_tent_save_reload_2638.lua
tests/engine/game_display_chord_test.lua
tests/engine/game_speed_categories_test.lua
tests/engine/game2_faithful_res.lua
@@ -204,7 +213,9 @@ tests/engine/game3_virtual_objects_drawhook_test.lua
tests/engine/game3_virtual_objects_map_load_test.lua
tests/engine/game3_virtual_objects_test.lua
tests/engine/gate_gen2_mod_facade.lua
tests/engine/gate_gen3_mod_api.lua
tests/engine/gate_gen3_mod_facade.lua
tests/engine/gen1_field_fx_palette_h13.lua
tests/engine/gen2_always_hit_bug1272.lua
tests/engine/gen2_anim_bake_scissor_bug1895.lua
tests/engine/gen2_anim_param_feed_sweep.lua
@@ -282,6 +293,7 @@ tests/engine/gen2_night_shade_top_row_bug1921.lua
tests/engine/gen2_no_checkhit_chain_audit.lua
tests/engine/gen2_offmap_object_bug1923.lua
tests/engine/gen2_options_menu_translation_test.lua
tests/engine/gen2_orientation_bug2586_test.lua
tests/engine/gen2_pack_menu_item_list_palette_test.lua
tests/engine/gen2_pack_sfx_bug1893.lua
tests/engine/gen2_pack_switch_cursor_bug2042.lua
@@ -422,6 +434,7 @@ tests/engine/launcher_osk_touch_bug1861.lua
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_skins_options_reads_bug2038.lua
tests/engine/launcher_skins_tab.lua
tests/engine/launcher_skins_ux.lua
@@ -452,6 +465,7 @@ tests/engine/menu_click_bug570.lua
tests/engine/menu_hold_scroll_bug1898.lua
tests/engine/menu_keep_open_2281.lua
tests/engine/menu_row_offset_bug564.lua
tests/engine/menu_speed_bug2578_test.lua
tests/engine/mew_dock_private_artifact_gate.lua
tests/engine/minimize_blob_slot_bug1940.lua
tests/engine/mod_developer_mode_parity_test.lua
@@ -591,6 +605,7 @@ tests/engine/saffron_cries_bug2248_2249.lua
tests/engine/save_confirm_layout_bug1522.lua
tests/engine/save_convert_extra_flags.lua
tests/engine/save_convert_toggle_objects.lua
tests/engine/save_editor_buena_points_2640.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
@@ -721,22 +736,27 @@ tests/game3_boat_subpriority_test.lua
tests/game3_braille_dig_test.lua
tests/game3_braille_regi_field_moves_test.lua
tests/game3_cache.lua
tests/game3_carried_actor_ghost_test.lua
tests/game3_cart_cache.lua
tests/game3_compat_connections_view_test.lua
tests/game3_controls_menu_test.lua
tests/game3_corner_screen_test.lua
tests/game3_cry_modes_test.lua
tests/game3_cut_grass_mow_test.lua
tests/game3_cycling_road_bike_test.lua
tests/game3_display_fit_test.lua
tests/game3_doors_viewport_test.lua
tests/game3_elevation_oam_priority_test.lua
tests/game3_event_flags_test.lua
tests/game3_field_garbage_test.lua
tests/game3_flags_extract_preserve_test.lua
tests/game3_fly_cycling_state_test.lua
tests/game3_fullscreen_display_test.lua
tests/game3_gift_rse_test.lua
tests/game3_grass_oam_order_test.lua
tests/game3_growth_coins_test.lua
tests/game3_held_items_test.lua
tests/game3_import_cache_version_test.lua
tests/game3_import_hardening_test.lua
tests/game3_item_pc_give_test.lua
tests/game3_latin_glyphs_test.lua
@@ -746,6 +766,7 @@ tests/game3_link_relay_transport_test.lua
tests/game3_link_rse_groups_test.lua
tests/game3_link_union_chat_test.lua
tests/game3_menu_sfx_test.lua
tests/game3_message_speed_test.lua
tests/game3_mods_menu_test.lua
tests/game3_mon_pic_center_test.lua
tests/game3_multichoice_window_test.lua
@@ -784,8 +805,26 @@ tests/game3_u12_healthbox_hp_text_test.lua
tests/game3_ui_braille_test.lua
tests/game3_weather_test.lua
tests/gen2_fly_map_test.lua
tests/gen2_menus_test.lua
tests/modkit/cases/bag_capacity.lua
tests/parity_F.lua
tests/parity_ball_shake_anim.lua
tests/parity_battle_intro_chrome.lua
tests/parity_e4_after.lua
tests/parity_flavor.lua
tests/parity_flavor_talk_branches.lua
tests/parity_lance.lua
tests/parity_party_row_columns.lua
tests/parity_rival_end_text_bug2660.lua
tests/parity_static.lua
tests/parity_text_markers.lua
tests/parity_text_scroll_bounds.lua
tests/parity_text_wrap.lua
tests/parity_viridian_gambler_bug2632.lua
tests/save_compat/gen1_cache_selection_test.lua
tests/save_compat/gen2_coverage_test.lua
tests/save_compat/gen2_event_clear_test.lua
tests/save_compat/gen3_cache_selection_test.lua
tests/save_compat/regions_test.lua
tests/save_editor_split_stack_bug2671_test.lua
tests/save_editor_task6_tests.lua
@@ -0,0 +1,80 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Sync = require("src.core.TrainerIdSync")
local Identity = require("src.core.TrainerIdentity")
local Compat = require("src.save_convert.Compat")
local K = require("tests.save_compat._codec")
local G1 = require("tests.fixtures.save.gen1_build")
local G2 = require("tests.fixtures.save.gen2_build")
local Gen3Save = require("src.save_convert.Gen3Save")
local bit = require("bit")
local target = { id = 100, sid = 42, name = "RED" }
K.gen2Data.pokemon = { CYNDAQUIL = { index = 155, dex = 155, name = "CYNDAQUIL" } }
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
local gen = (version == "red" or version == "blue" or version == "yellow") and 1 or 2
local builder = gen == 1 and G1 or G2
local spec = { version = version, player = "MAY", playerId = 200,
party = { builder.mon({ otId = 200, ot = "MAY" }),
builder.mon({ species = gen == 1 and 0x1F or 252, otId = 200, ot = "MAY" }),
builder.mon({ otId = 200, ot = "BLUE" }) } }
local bytes = builder.build(spec)
local save = assert(K.import(gen, version, bytes))
local raw = save.party[2].cartRaw
T.check(raw ~= nil, version .. " exercises an opaque carrier")
local own = assert(Identity.profile(save, gen))
local owners = {}; Sync.addOwner(owners, own, gen)
T.check(Sync.rewriteSave(save, gen, target, owners), version .. " sync prepares")
local out, why = K.export(gen, version, save, bytes)
T.check(out ~= nil, version .. " exports: " .. tostring(why))
if out then
T.eq(#Compat.check(out, version).errors, 0, version .. " native checksum/reader rules pass")
local back = assert(K.import(gen, version, out))
T.eq(back.player.id, 100, version .. " native player ID")
T.eq(back.player.name, "RED", version .. " native player name")
T.eq(back.party[1].otId, 100, version .. " native own OT ID")
T.eq(Identity.monName(back.party[1]), "RED", version .. " native own OT name")
T.eq(Identity.rawId(back.party[2], gen), 100, version .. " raw carrier native OT ID")
T.eq(Identity.monName(back.party[2]), "RED", version .. " raw carrier native OT name")
T.eq(back.party[3].otId, 200, version .. " foreign ID collision retained")
T.eq(Identity.monName(back.party[3]), "BLUE", version .. " foreign OT retained")
local at = gen == 1 and 25 or 13
local afterRaw = back.party[2].cartRaw
T.eq(afterRaw:sub(1, at - 1) .. afterRaw:sub(at + 4),
raw:sub(1, at - 1) .. raw:sub(at + 4), version .. " opaque carrier bytes retained apart from OT ID")
end
end
for _, version in ipairs({ "firered", "leafgreen", "ruby", "sapphire", "emerald" }) do
local Codec = Gen3Save.forVersion(version)
local old = { id = 200, sid = 0x1234, name = "MAY" }
local low = 0x1298
local pid = bit.bxor(old.id, old.sid, low) * 65536 + low
local raw = Codec.encodeBoxMon({ species = 25, personality = pid, otId = old.id,
otSecretId = old.sid, otName = old.name, nickname = "PIKA", language = 2,
moves = { 84, 45 }, pp = { 11, 23 }, ivs = { hp = 31, atk = 23 },
exp = 1000, friendship = 120, markings = 5, abilityNum = pid % 2,
unknown = 0x1234, metLocation = 88 })
local cart = assert(Codec.decodeBoxMon(raw))
local native = Codec.toPortMon(cart, false)
local save = { name = old.name, trainerId = old.id, secretId = old.sid, party = { native } }
local owners = {}; Sync.addOwner(owners, old, 3)
T.check(Sync.rewriteSave(save, 3, target, owners), version .. " identity/PID changes prepare")
cart.personality, cart.otId, cart.otSecretId, cart.otIdRaw, cart.otName =
native.personality, native.otId, native.otSecretId, nil, native.otName
local updated = assert(Codec.decodeBoxMon(Codec.encodeBoxMon(cart)))
T.eq(updated.otId, 100, version .. " encrypted record TID")
T.eq(updated.otSecretId, 42, version .. " encrypted record SID")
T.eq(updated.otName, "RED", version .. " native OT name")
T.eq(updated.isBadEgg, false, version .. " encrypted checksum remains valid")
T.eq(updated.personality % 25, pid % 25, version .. " native nature retained")
T.eq(updated.personality % 256, pid % 256, version .. " native gender retained")
T.eq(updated.abilityNum, cart.abilityNum, version .. " native ability retained")
T.eq(updated.unknown, 0x1234, version .. " opaque header retained")
T.eq(updated.pp[1], 11, version .. " PP retained")
T.eq(updated.metLocation, 88, version .. " met location retained")
T.check(bit.bxor(updated.otId, updated.otSecretId, updated.personality % 65536,
math.floor(updated.personality / 65536)) < 8, version .. " native shiny remains shiny")
end
T.finish("trainer_identity_sync_native_codecs")