From 72a17b27dde40ba6d554c3a5ae5cb108b43c0d09 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 24 Sep 2026 08:39:43 -0400 Subject: [PATCH] CLOSES #2444 --- src/core/ChipAudio.lua | 9 +- src/core/ChipSynth.lua | 21 +- src/core/Game.lua | 10 +- src/core/Game2.lua | 10 +- src/core/Game3.lua | 132 +- src/core/GamepadMap.lua | 4 +- src/core/Input.lua | 135 +- src/core/SaveData.lua | 3 + src/core/WorkerFs.lua | 34 + src/core/chip_worker.lua | 2 + src/core/game3/audio.lua | 9 +- src/core/game3/battle/init.lua | 33 + src/core/game3/battle_bridge.lua | 7 + src/core/game3/collision.lua | 29 +- src/core/game3/field.lua | 2 + src/core/game3/field_move_show_mon.lua | 5 + src/core/game3/forced_movement.lua | 1 + src/core/game3/m4a_worker.lua | 16 +- src/core/game3/objects.lua | 19 +- src/core/game3/save_mon.lua | 78 + src/core/game3/scripting/natives_events.lua | 3 + src/core/game3/summary_data.lua | 18 +- src/core/game3/trainer_sight.lua | 36 +- src/debug/SwitchDiagnostics.lua | 88 +- src/import/CacheContract.lua | 6 + src/import/RomImporter.lua | 3 +- src/import/SaveFileIO.lua | 40 + src/import/gba/editions/leafgreen_1_0.lua | 4 + src/import/gba/items_extract.lua | 6 - src/import/gba/summary_chrome_extract.lua | 156 +- src/import/gba/versions.lua | 6 +- src/save_convert/Gen3Layout.lua | 338 ++++ src/save_convert/Gen3Save.lua | 1667 +++++++++++++++++ src/save_convert/SaveConvert.lua | 103 +- src/ui/BindingsMenu.lua | 1 + src/ui/game3/map_preview_screen.lua | 8 + src/ui/game3/shop_menu.lua | 12 + src/ui/game3/summary_chrome.lua | 79 +- src/ui/game3/summary_menu.lua | 803 ++++++-- .../drivers/game3_catch_ball_sheets_2445.lua | 31 +- .../game3_collision_elevation_2441.lua | 50 +- tests/drivers/game3_doubles.lua | 69 +- tests/drivers/game3_export_cart_save.lua | 102 + tests/drivers/game3_forced_elevation_s4.lua | 237 +++ tests/drivers/game3_import_cart_save.lua | 108 ++ tests/drivers/game3_shop_soft_reset.lua | 122 ++ tests/drivers/game3_sign_walk_into.lua | 25 +- tests/drivers/game3_soft_reset_overlays.lua | 230 +++ tests/drivers/game3_speed_hotkey_h1.lua | 72 + tests/drivers/game3_stitchfield_onframe.lua | 8 +- tests/drivers/game3_summary_pages_s3.lua | 290 +++ tests/drivers/red_speed_hotkey_h1.lua | 32 + tests/drivers/trainer_fanfare_bug764_test.lua | 57 +- tests/drivers/util.lua | 7 +- tests/drivers/worker_fs_chip_music.lua | 39 + tests/engine/game3_soft_reset_list_test.lua | 99 + tests/engine/game3_speed_hotkey_test.lua | 161 ++ tests/engine/game3_summary_layout_s3_test.lua | 395 ++++ tests/engine/game3_trainer_sight_test.lua | 12 +- tests/engine/gen3_save_codec_tests.lua | 827 ++++++++ tests/engine/input_pad_watchdog_test.lua | 201 ++ tests/engine/launcher_studio.lua | 6 +- tests/engine/save_lua_import_tests.lua | 90 +- tests/engine/worker_fs_nx_test.lua | 239 +++ tests/fixture_data/gen3_saves.lua | 152 ++ tests/game3_cart_cache.lua | 48 + tests/game3_cart_export_test.lua | 168 ++ tests/game3_cart_import_load_test.lua | 108 ++ tests/game3_collision_behaviors_test.lua | 118 ++ tests/game3_ladder_warp_test.lua | 4 +- tests/game3_sign_walkaway_wait_test.lua | 84 + tests/game3_summary_chrome_bake_s3_test.lua | 169 ++ tests/game3_warp_behaviors_test.lua | 8 +- 73 files changed, 7852 insertions(+), 452 deletions(-) create mode 100644 src/core/WorkerFs.lua create mode 100644 src/save_convert/Gen3Layout.lua create mode 100644 src/save_convert/Gen3Save.lua create mode 100644 tests/drivers/game3_export_cart_save.lua create mode 100644 tests/drivers/game3_forced_elevation_s4.lua create mode 100644 tests/drivers/game3_import_cart_save.lua create mode 100644 tests/drivers/game3_shop_soft_reset.lua create mode 100644 tests/drivers/game3_soft_reset_overlays.lua create mode 100644 tests/drivers/game3_speed_hotkey_h1.lua create mode 100644 tests/drivers/game3_summary_pages_s3.lua create mode 100644 tests/drivers/red_speed_hotkey_h1.lua create mode 100644 tests/drivers/worker_fs_chip_music.lua create mode 100644 tests/engine/game3_soft_reset_list_test.lua create mode 100644 tests/engine/game3_speed_hotkey_test.lua create mode 100644 tests/engine/game3_summary_layout_s3_test.lua create mode 100644 tests/engine/gen3_save_codec_tests.lua create mode 100644 tests/engine/input_pad_watchdog_test.lua create mode 100644 tests/engine/worker_fs_nx_test.lua create mode 100644 tests/fixture_data/gen3_saves.lua create mode 100644 tests/game3_cart_cache.lua create mode 100644 tests/game3_cart_export_test.lua create mode 100644 tests/game3_cart_import_load_test.lua create mode 100644 tests/game3_sign_walkaway_wait_test.lua create mode 100644 tests/game3_summary_chrome_bake_s3_test.lua diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index f9a705c5..5ff23152 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -111,16 +111,9 @@ end -- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker local function slimAudio(data) local audio = data.audio or {} - -- NX-only: resolve the versioned cache prefix on the main thread and hand - -- it to the worker, which runs in a fresh Lua state without GameVersion. - local programPrefix - if require("src.core.Platform").isNX() then - local prefix = require("src.core.GameVersion").cachePrefix() - if prefix ~= "" then programPrefix = prefix end - end return { programFile = audio.programFile, - programPrefix = programPrefix, + programPrefix = require("src.core.WorkerFs").prefix(), bankOrder = audio.bankOrder, waveBanks = audio.waveBanks, noiseHeaders = audio.noiseHeaders, diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 41b1310f..fad01d35 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -4,7 +4,7 @@ -- worker thread (src/core/chip_worker.lua), which is where map/battle music is -- synthesized so a song change never stutters the render thread. -- --- Deliberately depends ONLY on `bit`, love.sound and love.filesystem: no +-- Deliberately depends ONLY on `bit`, src.core.WorkerFs, love.sound and love.filesystem: no -- love.audio (Sources are a playback concern the caller owns) and no -- src.render.Assets (hot-reload registration stays in ChipAudio). Both of -- those are unavailable or main-thread-only inside a love.thread worker, so @@ -178,28 +178,19 @@ local cachedBanks local function loadBanks(data) local audio = data.audio - if cachedProgramFile == audio.programFile and cachedBanks then + local WorkerFs = require("src.core.WorkerFs") + local key = (WorkerFs.normalize(audio.programPrefix) or "") .. tostring(audio.programFile) + if cachedProgramFile == key and cachedBanks then return cachedBanks end - local raw, readError - -- The chip worker runs in a separate Lua state without the NX overlay; - -- ChipAudio hands it the versioned cache prefix explicitly. On the main - -- thread the NX overlay (or desktop mountVersion) makes the plain read - -- resolve, so no platform branching belongs here. - local prefix = audio.programPrefix - if prefix and prefix ~= "" then - raw, readError = love.filesystem.read(prefix .. audio.programFile) - end - if not raw then - raw, readError = love.filesystem.read(audio.programFile) - end + local raw, readError = WorkerFs.read(audio.programPrefix, audio.programFile) if not raw then error("could not read sound programs: " .. tostring(readError)) end local banks = {} for index, bank in ipairs(audio.bankOrder) do local first = (index - 1) * 0x4000 + 1 banks[bank] = raw:sub(first, first + 0x3FFF) end - cachedProgramFile, cachedBanks = audio.programFile, banks + cachedProgramFile, cachedBanks = key, banks return banks end diff --git a/src/core/Game.lua b/src/core/Game.lua index b75c4939..b4409285 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -844,6 +844,7 @@ function Game:keypressed(key) end return end + local hk = Input.hotkeyKey(key) if key == "f1" then if not self:quickSaveAllowed() then return end self:writeSave() @@ -864,12 +865,12 @@ function Game:keypressed(key) elseif key == "=" then self:zoomStep(1) return - elseif key == "1" then + elseif hk == "1" then -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); -- shoulders/triggers on gamepad do the same (see gamepadpressed) self:_cycleSpeed(1) return - elseif key == "2" then + elseif hk == "2" then -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so -- hold it while a warp/transition or an on-screen scripted cutscene is @@ -887,7 +888,7 @@ function Game:keypressed(key) self:writeOptions() end return - elseif key == "3" then + elseif hk == "3" then -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only local Tilt = require("src.render.Tilt") if Tilt.gateOK(self.stack:top(), self.overworld) then @@ -895,7 +896,7 @@ function Game:keypressed(key) self:writeOptions() end return - elseif key == "4" then + elseif hk == "4" then -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) local Zoom = require("src.render.Zoom") if Zoom.gateOK(self.stack:top(), self.overworld) then @@ -1002,6 +1003,7 @@ end -- Game:keypressed; see its comment and RFC 0020 for the precedent this -- restores. function Game:gamepadpressed(joystick, button) + Input:padEventSeen(button) local function vanilla() padPressedBody(self, joystick, button) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 629f375a..bd24dd25 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2046,6 +2046,7 @@ function Game2:hotkey(key) if self.save then self.save.options = options end self:persistOptions() end + local hk = Input.hotkeyKey(key) if key == "f1" then if not self:quickSaveAllowed() then return true end self:writeSave() @@ -2055,19 +2056,19 @@ function Game2:hotkey(key) local loaded = Save.load() if loaded then self:continueGame(loaded) end return true - elseif key == "1" then + elseif hk == "1" then local GameSpeed = require("src.core.GameSpeed") options.speed = GameSpeed.cycle(options.speed, 1) persist() return true - elseif key == "2" then + elseif hk == "2" then local GbcPalette = require("src.render.GbcPalette") GbcPalette.setMode(options.color or "gbc") options.color = GbcPalette.cycle(1) options.palette = "" persist() return true - elseif key == "3" then + elseif hk == "3" then local Tilt = require("src.render.Tilt") options.tilt = Tilt.cycle() persist() @@ -2082,7 +2083,7 @@ function Game2:hotkey(key) elseif key == "=" or key == "kp+" then self:zoomStep(1) return true - elseif key == "4" then + elseif hk == "4" then self.world:zoomCycle() self:storeZoom() return true @@ -2451,6 +2452,7 @@ end -- own comment (src/core/Game.lua) for why, and for the precedent this -- restores. function Game2:gamepadpressed(joystick, button) + Input:padEventSeen(button) local function vanilla() padPressedBody(self, joystick, button) end diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 4c1d8bd1..3176dd0b 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -573,14 +573,20 @@ function Game3:speedCategory() if okB and Battle and Battle.isActive and Battle.isActive() then return "battle" end - local okS, Stack = pcall(require, "src.ui.game3.stack") - if okS and Stack and Stack.busy and Stack.busy() then return "menu" end - if Help.isOpen and Help.isOpen() then return "menu" end - if self.phase ~= "field" then return "menu" end - return "overworld" + if self.phase == "field" then return "overworld" end + return "menu" +end + +function Game3:isFixedSpeed() + if self.phase == "arena" then return true end + local Link = package.loaded["src.core.game3.link"] + if type(Link) == "table" and Link.link ~= nil then return true end + local MG = package.loaded["src.core.game3.minigames.common"] + return type(MG) == "table" and MG.isActive ~= nil and MG.isActive() == true end function Game3:logicSpeed() + if self:isFixedSpeed() then return 1 end local override = tonumber(self.speedOverride) if override then return math.max(1, override) end local b = self.phase == "boot" and self.boot @@ -722,6 +728,7 @@ function Game3:draw() end function Game3:_hotkey(key) + local hk = Input.hotkeyKey(key) if key == "f1" then if self.phase == "field" and self:saveOffered() then self:saveGame() end return true @@ -736,10 +743,10 @@ function Game3:_hotkey(key) self:_handleBootAction({ action = "continue" }) end return true - elseif key == "1" then + elseif hk == "1" then self:_cycleSpeed(1) return true - elseif key == "3" then + elseif hk == "3" then if self:zoomGateOK() then local Tilt = require("src.render.Tilt") Tilt.cycle() @@ -749,7 +756,7 @@ function Game3:_hotkey(key) end end return true - elseif key == "4" then + elseif hk == "4" then if self:zoomGateOK() then local Zoom = require("src.render.Zoom") local Renderer = require("src.render.Renderer") @@ -795,8 +802,8 @@ function Game3:_padPressedBody(joystick, button) local ok, down = pcall(function() return joystick:isGamepadDown("back") end) selectHeld = ok and down == true end - local isTrigger = button == "lefttrigger" or button == "righttrigger" - if not selectHeld and isTrigger and Input2.padAction then + local isShoulder = button == "leftshoulder" or button == "rightshoulder" + if not selectHeld and not isShoulder and Input2.padAction then local action = Input2:padAction(button) if action == "speedUp" then self:_cycleSpeed(1) @@ -821,6 +828,7 @@ function Game3:_padReleasedBody(joystick, button) end function Game3:gamepadpressed(joystick, button) + if self.input and self.input.padEventSeen then self.input:padEventSeen(button) end local function vanilla() self:_padPressedBody(joystick, button) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end return ModRuntime.call("input.gamepad", vanilla, self, @@ -1017,26 +1025,98 @@ function Game3:onResume() Audio.onFocusGained() end +local function closeFlag(flag) + return function(m) + m[flag] = false + for k, v in pairs(m) do + if type(k) == "string" and type(v) == "function" and k:match("^_on%u") then m[k] = nil end + end + end +end + +local function dropField(field) + return function(m) m[field] = nil end +end + -- pokefirered/src/main.c:480 -local FIELD_SCREENS = { - "src.ui.game3.choice", - "src.ui.game3.message", - "src.ui.game3.money_box", - "src.ui.game3.coins_box", - "src.ui.game3.elevator_window", - "src.ui.game3.berry_powder_box", - "src.ui.game3.minigame_records", - "src.ui.game3.hall_of_fame", - "src.ui.game3.hall_of_fame_pc", - "src.ui.game3.credits", +local SOFT_RESET = { + { "src.core.game3.battle.init", "reset" }, + { "src.core.game3.battle_bridge", "reset" }, + { "src.core.game3.battle_transition", "abort" }, + { "src.core.game3.task", "clear" }, + { "src.core.game3.step_events", "flush" }, + { "src.core.game3.field_move_show_mon", "reset" }, + { "src.core.game3.pokecenter_heal", dropField("_fx") }, + { "src.core.game3.ss_anne_cutscene", "reset" }, + { "src.core.game3.itemfinder", "reset" }, + { "src.core.game3.special_field_anim", "stopEscalator" }, + { "src.core.game3.pc_anim", "reset" }, + { "src.core.game3.camera_object", "reset" }, + { "src.core.game3.trade_scene", "cancel" }, + { "src.core.game3.scripting.natives_listmenu", "close" }, + { "src.ui.game3.hud", "clearWaitButton" }, + { "src.ui.game3.mon_pic", "hide" }, + { "src.ui.game3.seagallop", "stop" }, + { "src.ui.game3.map_preview_screen", "reset" }, + { "src.ui.game3.cave_transition", "clear" }, + { "src.ui.game3.map_name_popup", "dismiss" }, + { "src.ui.game3.help_window", "reset" }, + { "src.ui.game3.whiteout_rush", dropField("_state") }, + { "src.ui.game3.trade_scene", "close" }, + { "src.ui.game3.shop_menu", "reset" }, + { "src.ui.game3.choice", "reset" }, + { "src.ui.game3.message", "reset" }, + { "src.ui.game3.money_box", "hide" }, + { "src.ui.game3.coins_box", "hide" }, + { "src.ui.game3.elevator_window", "hide" }, + { "src.ui.game3.berry_powder_box", "hide" }, + { "src.ui.game3.minigame_records", "reset" }, + { "src.ui.game3.hall_of_fame", "reset" }, + { "src.ui.game3.hall_of_fame_pc", "reset" }, + { "src.ui.game3.credits", "reset" }, + { "src.ui.game3.start_menu", closeFlag("open") }, + { "src.ui.game3.start_menu", "resetCursor" }, -- pokefirered/src/start_menu.c:64 + { "src.ui.game3.bag_menu", closeFlag("open") }, + { "src.ui.game3.berry_pouch", closeFlag("open") }, + { "src.ui.game3.tm_case", closeFlag("open") }, + { "src.ui.game3.party_menu", closeFlag("open") }, + { "src.ui.game3.summary_menu", closeFlag("open") }, + { "src.ui.game3.pokedex", closeFlag("open") }, + { "src.ui.game3.option_menu", closeFlag("open") }, + { "src.ui.game3.save_menu", closeFlag("open") }, + { "src.ui.game3.trainer_card", closeFlag("open") }, + { "src.ui.game3.pc_menu", closeFlag("open") }, + { "src.ui.game3.item_pc", closeFlag("open") }, + { "src.ui.game3.box_storage_ui", closeFlag("open") }, + { "src.ui.game3.region_map", closeFlag("open") }, + { "src.ui.game3.daycare_menu", closeFlag("open") }, + { "src.ui.game3.fame_checker", closeFlag("open") }, + { "src.ui.game3.move_relearner", closeFlag("open") }, + { "src.ui.game3.egg_hatch", closeFlag("open") }, + { "src.ui.game3.evolution_scene", closeFlag("open") }, + { "src.ui.game3.diploma", closeFlag("open") }, + { "src.ui.game3.trainer_tower_records", closeFlag("open") }, + { "src.ui.game3.teachy_tv", closeFlag("open") }, + { "src.ui.game3.slot_machine", closeFlag("open") }, + { "src.ui.game3.mod_manager", closeFlag("open") }, + { "src.ui.game3.prize_corner", "reset" }, + { "src.ui.game3.stat_growth", closeFlag("_open") }, + { "src.ui.game3.release_seq", function(m) m.active = false; m.onComplete = nil end }, + { "src.ui.game3.naming", closeFlag("openFlag") }, + { "src.ui.game3.easy_chat", closeFlag("openFlag") }, + { "src.ui.game3.fade", "clear" }, } local function clearFieldScreens() - for _, name in ipairs(FIELD_SCREENS) do - local ok, mod = pcall(require, name) - if ok and type(mod) == "table" then - local fn = mod.reset or mod.hide - if fn then pcall(fn) end + for _, e in ipairs(SOFT_RESET) do + local mod = package.loaded[e[1]] + if type(mod) == "table" then + local fn = e[2] + if type(fn) == "string" then + if mod[fn] then pcall(mod[fn]) end + else + pcall(fn, mod) + end end end local LeagueLighting = package.loaded["src.core.game3.league_lighting"] diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index 79d1a12f..20557cdf 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -18,8 +18,8 @@ GamepadMap.TRIGGER_ON = 0.4 GamepadMap.TRIGGER_OFF = 0.2 GamepadMap.DEFAULT_PAD_ACTIONS = { - rightshoulder = "speedUp", righttrigger = "speedUp", - leftshoulder = "speedDown", lefttrigger = "speedDown", + rightshoulder = "speedUp", triggerright = "speedUp", + leftshoulder = "speedDown", triggerleft = "speedDown", -- FRLG: shoulders also drive l/r via Input dual-emit (see Input:gamepadpressed). } diff --git a/src/core/Input.lua b/src/core/Input.lua index 9ad33d95..13b36d25 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -59,6 +59,12 @@ local STICK_OFF = 0.3 Input.PAD_ACTIONS = { speedUp = true, speedDown = true } +local POLLABLE_PAD_BUTTONS = { + a = true, b = true, x = true, y = true, back = true, guide = true, start = true, + leftstick = true, rightstick = true, leftshoulder = true, rightshoulder = true, + dpup = true, dpdown = true, dpleft = true, dpright = true, +} + local HAT_DIRECTIONS = { u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" }, lu = { "left", "up" }, ru = { "right", "up" }, @@ -99,11 +105,13 @@ function Input:applyBindings(overlay) if action == actionId then acts[button] = nil end end if type(binding) == "table" and binding.pad then - acts[binding.pad] = actionId + acts[GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad] = actionId end elseif type(binding) == "table" then if binding.key then keys[binding.key] = actionId end - if binding.pad then pads[binding.pad] = actionId end + if binding.pad then + pads[GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad] = actionId + end elseif type(binding) == "string" then keys[binding] = actionId end @@ -124,11 +132,37 @@ function Input:applyBindings(overlay) local n = tonumber(padName:match("^joy(%d+)$")) if n then joyActs[n] = action end end + local poll = {} + local canPoll = GamepadMap.gamepadBindings() ~= GamepadMap.NX_GAMEPAD_BINDINGS + for button, action in pairs(pads) do + if canPoll and POLLABLE_PAD_BUTTONS[button] then + poll[#poll + 1] = { button = button, btn = action, source = "pad:" .. button, + chord = GamepadMap.displayChordDigit(button) ~= nil } + end + end self.keyBindings = keys self.padBindings = pads self.joyBindings = joys self.padActions = acts self.joyActions = joyActs + self.padPoll = poll +end + +function Input.hotkeyKey(key) + if type(key) ~= "string" then return key end + if key:match("^%d$") then return key end + local bound = Input.keyBindings + if bound and bound[key] then return key end + local digit = key:match("^kp(%d)$") + if digit then return digit end + local kb = love and love.keyboard + if kb and kb.getScancodeFromKey then + local ok, scancode = pcall(kb.getScancodeFromKey, key) + if ok and type(scancode) == "string" and scancode:match("^%d$") then + return scancode + end + end + return key end function Input:padAction(button) @@ -157,6 +191,22 @@ function Input:reset() self.captureEvents = nil self.aliases = nil self.aliasHeld = nil + local suppress = {} + local poll = self.padPoll + if poll then + for i = 1, #poll do suppress[poll[i].button] = true end + end + self.padSuppress = suppress +end + +function Input:padEventSeen(button) + if type(button) ~= "string" then return end + local suppress = self.padSuppress + if not suppress then + suppress = {} + self.padSuppress = suppress + end + suppress[button] = true end -- pokefirered/src/main.c:325 @@ -256,7 +306,85 @@ end -- button stuck on after the queue drains. -- Synthetic injects (tests/drivers writing pressQueue directly, with no -- source entry) still set state so scripted holds keep working. +local function modOwnsPad() + local Runtime = package.loaded["src.mods.Runtime"] + return type(Runtime) == "table" and Runtime.wantsHook ~= nil + and Runtime.wantsHook("input.gamepad") == true +end + +local function notePadRepair(fix, button) + local Diag = package.loaded["src.debug.SwitchDiagnostics"] + if type(Diag) == "table" and Diag.onEvent then + Diag.onEvent("padwatch", { fix = fix, button = button }) + end +end + +local function anyPadDown(pads, button) + for k = 1, #pads do + local j = pads[k] + local ok, down = pcall(j.isGamepadDown, j, button) + if ok and down then return true end + end + return false +end + +-- pokefirered/src/main.c:296 +function Input:pollPads() + local js = love and love.joystick + if not (js and js.getJoystickCount and js.getJoysticks) then return end + local okC, count = pcall(js.getJoystickCount) + if not okC or type(count) ~= "number" or count == 0 then + self._pollPadCount = 0 + return + end + local pads = self._pollPads + if not pads or self._pollPadCount ~= count then + pads = {} + local ok, list = pcall(js.getJoysticks) + if ok and type(list) == "table" then + for _, j in ipairs(list) do + if j.isGamepadDown and GamepadMap.ignoreRawForJoystick(j) + and not GamepadMap.isAccelerometer(j) then + pads[#pads + 1] = j + end + end + end + self._pollPads = pads + self._pollPadCount = count + end + local list = self.padPoll + if #pads == 0 or not list then return end + local mute = self.captureArmed or modOwnsPad() + local suppress = self.padSuppress + if not suppress then + suppress = {} + self.padSuppress = suppress + end + local backDown = nil + for i = 1, #list do + local e = list[i] + local down = anyPadDown(pads, e.button) + local sources = self.sources[e.btn] + local held = sources ~= nil and sources[e.source] == true + if not down then suppress[e.button] = nil end + if held and not down then + release(self, e.btn, e.source) + notePadRepair("release", e.button) + elseif down and not held and not mute and not suppress[e.button] + and not self.padActions[e.button] then + if e.chord and backDown == nil then + backDown = self.state.select == true or anyPadDown(pads, "back") + end + if not (e.chord and backDown) then + press(self, e.btn, e.source) + notePadRepair("press", e.button) + end + end + end +end + function Input:step() + self:pollPads() self.pressed = {} for _, btn in ipairs(self.pressQueue) do self.pressed[btn] = true @@ -317,6 +445,7 @@ function Input:sourceRelease(btn, source) end function Input:gamepadpressed(joystick, button) + if self.padSuppress then self.padSuppress[button] = nil end noteCapture(self, "pad", "pressed", button) local btn = self.padBindings[button] if btn then @@ -473,6 +602,8 @@ end -- Game:step needs the clean slate (re-arming A there would read it as a -- title-menu choice). function Input:reconcile() + local okD, Diag = pcall(require, "src.debug.SwitchDiagnostics") + if okD and Diag.onPadReconcile then pcall(Diag.onPadReconcile) end local kb = love and love.keyboard if kb and kb.isDown then for key, btn in pairs(self.keyBindings) do diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 662c6e60..ee7522b4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2268,6 +2268,9 @@ function SaveData.save(data, mods) end remove(fs, TMP_FILENAME) stampActiveCartHash(fs) + local slot = data.version and GameVersion.VERSIONS[data.version] + and GameVersion.generation(data.version) == 3 and SaveData.activeSlot(data.version) + if slot then pcall(function() require("src.import.SaveFileIO").dropStaleCart(data.version, slot, data) end) end Logger.info("saved game") return true end diff --git a/src/core/WorkerFs.lua b/src/core/WorkerFs.lua new file mode 100644 index 00000000..64881fbe --- /dev/null +++ b/src/core/WorkerFs.lua @@ -0,0 +1,34 @@ +local WorkerFs = {} + +function WorkerFs.normalize(prefix) + if type(prefix) == "string" and prefix ~= "" then return prefix end + return nil +end + +function WorkerFs.prefix() + if not require("src.core.Platform").isNX() then return nil end + return WorkerFs.normalize(require("src.core.GameVersion").cachePrefix()) +end + +function WorkerFs.read(prefix, rel, fs) + fs = fs or love.filesystem + prefix = WorkerFs.normalize(prefix) + if prefix then + local bytes = fs.read(prefix .. rel) + if bytes then return bytes end + end + return fs.read(rel) +end + +function WorkerFs.cache(prefix, fs) + local cache = { prefix = WorkerFs.normalize(prefix) } + function cache:setPrefix(p) + self.prefix = WorkerFs.normalize(p) + end + function cache:read(rel) + return WorkerFs.read(self.prefix, rel, fs) + end + return cache +end + +return WorkerFs diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 4e839444..8e902e1e 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -31,6 +31,8 @@ end -- Load the synth explicitly via love.filesystem (a fresh thread Lua state does -- not necessarily carry the package searcher that resolves "src.core..."): +package.loaded["src.core.WorkerFs"] = package.loaded["src.core.WorkerFs"] + or assert(love.filesystem.load("src/core/WorkerFs.lua"))() local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))() local cmdCh = love.thread.getChannel("chipaudio_cmd") diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 5296ac76..c4b39041 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -87,18 +87,11 @@ local function filesystem_cache() } end -local function worker_prefix() - if not require("src.core.Platform").isNX() then return nil end - local prefix = require("src.core.GameVersion").cachePrefix() - if type(prefix) == "string" and prefix ~= "" then return prefix end - return nil -end - local function push_install() Audio._cmdCh:push({ cmd = "install", root = Audio._root, - prefix = worker_prefix(), + prefix = require("src.core.WorkerFs").prefix(), sampleRate = Mix.SAMPLE_RATE, }) end diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index d23fde2a..fcfc6274 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -3748,6 +3748,39 @@ function Battle.abort(result) if Battle._active then finish(result or "run") end end +-- pokefirered/src/main.c:480 +function Battle.reset() + stop_low_hp_song() + require("src.core.game3.battle.link_guard").disarm() + Battle._active = false + Battle._st = nil + Battle._adapter = nil + Battle._phase = nil + Battle._onDone = nil + Battle._onExpDone = nil + Battle._pendingEnd = nil + Battle._pendingChoice = nil + Battle._actions = nil + Battle._actionI = 1 + Battle._metaAct = nil + Battle._auto = false + Battle._headless = false + Battle._fade = true + Battle._residualEvents = nil + Battle._residualIndex = 1 + Battle._residualStepState = nil + D.reset() + AnimSeq.reset() + CatchSeq.reset() + ExpSeq.reset() + EvoSeq.reset() + IntroSeq.reset() + LearnMove.reset() + SwitchSeq.reset() + Anim.reset({ headless = true }) + if Ui.clearLinger then Ui.clearLinger() end +end + function Battle.linkEnd(result, text, reason) if not Battle._active then return false end local st = Battle._st diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 0342e286..5b072f1b 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -558,4 +558,11 @@ function BattleBridge.finishPending(result) end end +-- pokefirered/src/main.c:480 +function BattleBridge.reset() + BattleBridge._finish = nil + BattleBridge._remap = nil + BattleBridge._battleParty = nil +end + return BattleBridge diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index 56960223..07dbef53 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -513,7 +513,6 @@ end local ARRIVAL_FACING = { [MB_CAVE_DOOR] = "down", [MB_WARP_DOOR] = "down", - [MB_LADDER] = "down", [MB_SOUTH_ARROW_WARP] = "up", [MB_NORTH_ARROW_WARP] = "down", [MB_WEST_ARROW_WARP] = "right", @@ -527,6 +526,8 @@ local ARRIVAL_FACING = { function Collision.arrivalFacing(destBeh, storedDir) local f = ARRIVAL_FACING[destBeh] if f then return f end + -- pokefirered/src/overworld.c:933 + if destBeh == MB_LADDER then return storedDir or "down" end return "down" end @@ -773,10 +774,10 @@ function Collision.isWater(cx, cy) return Collision.isWaterOn(Collision._mapDef, cx, cy, Collision.cell(cx, cy)) end -local function entityBlocks(game, tx, ty) +local function entityBlocks(game, tx, ty, elevation) local okO, Objects = pcall(require, "src.core.game3.objects") if okO and Objects and Objects.hasMap and Objects.hasMap() then - if Objects.blocks(tx, ty) then return true end + if Objects.blocks(tx, ty, nil, elevation) then return true end return false end local world = hostWorld(game) @@ -826,17 +827,22 @@ function Collision.canEnter(game, tx, ty, opts) -- pokefirered/src/event_object_movement.c:4839 local mismatch = Collision.elevationMismatchOn(Collision._mapDef, opts.elevation, tx, ty) if mismatch and not surfing then return false, "elevation" end - if entityBlocks(game, tx, ty) then return false, "entity" end local isW = Collision.isWater(tx, ty) + local entElevation = opts.elevation + if mismatch then + if isW then return false, "elevation" end + if not Collision.isWalkable(tx, ty) then return false, "tile" end + -- pokefirered/src/field_player_avatar.c:597 + if Collision.elevationAt(tx, ty) ~= 3 then return false, "elevation" end + entElevation = 3 + end + if entityBlocks(game, tx, ty, entElevation) then return false, "entity" end if surfing then if isW then - if mismatch then return false, "elevation" end return true, nil else -- Dismount onto land: verify land tile is walkable if not Collision.isWalkable(tx, ty) then return false, "tile" end - -- pokefirered/src/field_player_avatar.c:597 - if mismatch and Collision.elevationAt(tx, ty) ~= 3 then return false, "elevation" end return true, nil end else @@ -881,8 +887,13 @@ function Collision.ledgeLanding(game, fromX, fromY, dir) local facings = P.ledgeFacings(coll) if not (facings and facings[dir]) then return nil end local tx, ty = fromX + d[1] * 2, fromY + d[2] * 2 - local ok = Collision.canEnter(game, tx, ty, {}) - if not ok then return nil end + -- pokefirered/src/field_player_avatar.c:613 ShouldJumpLedge + if Collision._grid and Collision._grid[1] ~= nil then + if not Collision.inBounds(tx, ty) then return nil end + else + local map = hostWorld(game) and hostWorld(game).map + if map and map.inBounds and not map:inBounds(tx, ty) then return nil end + end return tx, ty end diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 89d3eb64..28987fe7 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -1498,6 +1498,7 @@ function Field.forcedMovementPending() -- pokefirered/src/field_player_avatar.c:295 return Collision.canEnter(Field._game, x, y + 1, { fromX = x, fromY = y, dir = "down", surfing = true, + elevation = Player.currentElevation, }) == true end @@ -1593,6 +1594,7 @@ function Field.updateWaterfall(game) -- pokefirered/src/field_player_avatar.c:295 if not Collision.canEnter(game or Field._game, tx, ty, { fromX = Player.cellX, fromY = Player.cellY, dir = "down", surfing = true, + elevation = Player.currentElevation, }) then return end if Player.moving then -- pokefirered/src/field_player_avatar.c:147 diff --git a/src/core/game3/field_move_show_mon.lua b/src/core/game3/field_move_show_mon.lua index c8766c20..6305cd74 100644 --- a/src/core/game3/field_move_show_mon.lua +++ b/src/core/game3/field_move_show_mon.lua @@ -48,6 +48,11 @@ function ShowMon.isActive() return ShowMon._fx ~= nil end +-- pokefirered/src/main.c:480 +function ShowMon.reset() + ShowMon._fx = nil +end + -- pokefirered/src/fldeff_rocksmash.c:44 function ShowMon.start(mon, opts, onDone) opts = opts or {} diff --git a/src/core/game3/forced_movement.lua b/src/core/game3/forced_movement.lua index 0908c7b8..20071a8b 100644 --- a/src/core/game3/forced_movement.lua +++ b/src/core/game3/forced_movement.lua @@ -104,6 +104,7 @@ local function doForcedMovement(game, dir, frames, opts) local tx, ty = P.cellX + d[1], P.cellY + d[2] local ok = Collision.canEnter(game, tx, ty, { fromX = P.cellX, fromY = P.cellY, dir = dir, surfing = P.surfing, + elevation = P.currentElevation, }) if not ok then -- pokefirered/src/field_player_avatar.c:299 diff --git a/src/core/game3/m4a_worker.lua b/src/core/game3/m4a_worker.lua index 3db17ce9..a7585d0e 100644 --- a/src/core/game3/m4a_worker.lua +++ b/src/core/game3/m4a_worker.lua @@ -40,17 +40,11 @@ package.loaded["src.core.game3.m4a_seq"] = Seq local Player = load_mod("src/core/game3/m4a_player.lua", "src.core.game3.m4a_player") package.loaded["src.core.game3.m4a_player"] = Player +local WorkerFs = load_mod("src/core/WorkerFs.lua", "src.core.WorkerFs") +package.loaded["src.core.WorkerFs"] = WorkerFs + local pack = nil -local cachePrefix = nil -local cache = { - read = function(_, rel) - if cachePrefix then - local bytes = love.filesystem.read(cachePrefix .. rel) - if bytes then return bytes end - end - return love.filesystem.read(rel) - end, -} +local cache = WorkerFs.cache(nil) local fanfareCh = love.thread.getChannel("game3_m4a_fanfare") local statusCh = love.thread.getChannel("game3_m4a_status") @@ -82,7 +76,7 @@ local function apply_cmd(msg) BUFFER = Player.BUFFER_SAMPLES or 8192 TARGET_QUEUED = Player.CHANNEL_TARGET or 12 end - cachePrefix = type(msg.prefix) == "string" and msg.prefix ~= "" and msg.prefix or nil + cache:setPrefix(msg.prefix) local err pack, err = Player.loadPack(cache, msg.root) if not pack then diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index 658e1cfa..c542ce09 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -587,13 +587,20 @@ function Objects.at(tx, ty) return nil end +-- pokefirered/src/event_object_movement.c:8432 AreElevationsCompatible +function Objects.elevationsCompatible(a, b) + a, b = tonumber(a) or 0, tonumber(b) or 0 + return a == 0 or b == 0 or a == b +end + --- True if any non-passable EO occupies (tx,ty) or is stepping onto it. -function Objects.blocks(tx, ty, exceptLocalId) +function Objects.blocks(tx, ty, exceptLocalId, elevation) exceptLocalId = tonumber(exceptLocalId) for _, lid in ipairs(Objects._order) do if lid ~= exceptLocalId then local eo = Objects._byId[lid] - if eo and eo.visible and not eo.hidden and not eo.passable then + if eo and eo.visible and not eo.hidden and not eo.passable + and Objects.elevationsCompatible(elevation, eo.currentElevation) then if eo.cellX == tx and eo.cellY == ty then return true end if eo.moving and eo.targetX == tx and eo.targetY == ty then return true @@ -611,9 +618,10 @@ function Objects.blocks(tx, ty, exceptLocalId) end -- pokefirered/src/event_object_movement.c:4899 -function Objects.playerBlocks(tx, ty) +function Objects.playerBlocks(tx, ty, elevation) local P = Player() if not P then return false end + if not Objects.elevationsCompatible(elevation, P.currentElevation) then return false end if P.cellX == tx and P.cellY == ty then return true end if P.moving and P.targetX == tx and P.targetY == ty then return true end return false @@ -1065,8 +1073,9 @@ local function stepCollision(eo, game, ctx, dir) { fromX = eo.cellX, fromY = eo.cellY, dir = dir, surfing = onWater, elevation = eo.currentElevation }) if ok and Coll.isWater(tx, ty) ~= onWater then ok = false end - if Objects.playerBlocks(tx, ty) then ok = false end - if Objects.blocks(tx, ty, eo.localId) then ok = false end + -- pokefirered/src/event_object_movement.c:4841 DoesObjectCollideWithObjectAt + if Objects.playerBlocks(tx, ty, eo.currentElevation) then ok = false end + if Objects.blocks(tx, ty, eo.localId, eo.currentElevation) then ok = false end end if not ok then return "blocked" end return nil diff --git a/src/core/game3/save_mon.lua b/src/core/game3/save_mon.lua index f7fc3a8b..36d8082f 100644 --- a/src/core/game3/save_mon.lua +++ b/src/core/game3/save_mon.lua @@ -2,6 +2,37 @@ local Pokemon = require("src.core.game3.pokemon") local M = {} +-- src/pokemon.c:2196 +local function levelFromExp(growthRate, exp) + local SummaryData = require("src.core.game3.summary_data") + local level = 1 + while level <= 100 and SummaryData.expForLevel(growthRate, level) <= exp do level = level + 1 end + return math.max(1, level - 1) +end + +local function finishCartMon(mon, species) + local growthRate = Pokemon.growthRate(species) + mon.growthRate = growthRate + if mon.level == nil then mon.level = levelFromExp(growthRate, tonumber(mon.exp) or 0) end + if not mon.isEgg then + mon.name = Pokemon.name(species) + if mon.nickname == mon.name then mon.nickname = "" end + end + mon.gender = Pokemon.gender(species, mon.personality) + local pair = Pokemon.abilities(species) + local ability = pair[(tonumber(mon.abilityNum) or 0) + 1] + if not ability or ability == 0 then ability = pair[1] end + mon.ability, mon.abilityId = ability, ability + mon.maxPp = {} + local bonuses = tonumber(mon.ppBonusesPacked) or 0 + for slot, move in ipairs(mon.moves or {}) do + local base = Pokemon.movePp(move) + -- src/pokemon.c:3898 + mon.maxPp[slot] = base + math.floor(base * 20 * (math.floor(bonuses / 4 ^ (slot - 1)) % 4) / 100) + end + mon.cartImport = nil +end + function M.normalize(mon) if type(mon) ~= "table" then return mon end local species = Pokemon.speciesOf(mon) @@ -11,6 +42,7 @@ function M.normalize(mon) if not species or not Pokemon.isInternalSpecies(species) then return mon end mon.species, mon.speciesId = species, species mon.speciesNumbering = Pokemon.NUMBERING_INTERNAL + if mon.cartImport then finishCartMon(mon, species) end local hp = tonumber(mon.hp) Pokemon.applyStats(mon) if hp then mon.hp = math.max(0, math.min(hp, mon.maxHp)) end @@ -40,7 +72,53 @@ function M.normalize(mon) return mon end +-- src/heal_location.c:30 +local function healIdFor(w) + local Field = require("src.core.game3.field") + if not Field.flyDestinationsMounted() then return nil end + for _, dest in pairs(Field._flyBaked or {}) do + if dest.healLocation and dest.map == w.map then return dest.healLocation end + end + return false +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 + save.dex = save.dex or {} + local dex = save.dex + dex.seen, dex.owned, dex.caught = dex.seen or {}, dex.owned or {}, dex.caught or {} + for _, nat in ipairs(ci.dexSeen or {}) do + local sp = Pokemon.speciesFromNational(nat) + if sp then dex.seen[sp] = true end + end + for _, nat in ipairs(ci.dexOwned or {}) do + local sp = Pokemon.speciesFromNational(nat) + if sp then dex.owned[sp], dex.caught[sp] = true, true end + end + ci.dexSeen, ci.dexOwned = nil, nil + local dc = save.modData.firered_daycare + if type(dc) == "table" then + for _, mon in pairs(type(dc.daycare) == "table" and dc.daycare or {}) do + if type(mon) == "table" and mon.cartImport then M.normalize(mon) end + end + if type(dc.route5Daycare) == "table" and type(dc.route5Daycare.mon) == "table" then + M.normalize(dc.route5Daycare.mon) + end + end + if type(ci.lastHealLocation) == "table" then + -- src/heal_location.c:79 + local id = healIdFor(ci.lastHealLocation) + if id then require("src.core.game3.heal_locations").applyToSession(save, id) end + if id ~= nil then ci.lastHealLocation = nil end + else + ci.lastHealLocation = nil + end + if ci.lastHealLocation == nil then save.modData.cartImport = nil end +end + function M.each(save, fn) + M.finishCartImport(save) for _, mon in pairs(save.party or {}) do fn(mon) end for _, box in pairs(save.storage and save.storage.boxes or {}) do for _, mon in pairs(type(box) == "table" and box.mons or {}) do fn(mon) end diff --git a/src/core/game3/scripting/natives_events.lua b/src/core/game3/scripting/natives_events.lua index 8f648476..6e62e9a1 100644 --- a/src/core/game3/scripting/natives_events.lua +++ b/src/core/game3/scripting/natives_events.lua @@ -555,6 +555,9 @@ function Events.pollWalkaway(vm, input) if okF and Field and Field.unlock then Field.unlock() end end clearWalkaway() + -- pokefirered/src/script.c:353 + local HudW = package.loaded["src.ui.game3.hud"] + if HudW and HudW.clearWaitButton then HudW.clearWaitButton() end vm:halt(true) if started then -- pokefirered/src/field_control_avatar.c:329 diff --git a/src/core/game3/summary_data.lua b/src/core/game3/summary_data.lua index bf95061c..6bb5e3cf 100644 --- a/src/core/game3/summary_data.lua +++ b/src/core/game3/summary_data.lua @@ -302,16 +302,26 @@ local function egg_origin_index(mon, heldByOt) return idx end +function SummaryData.eggCycles(mon) + return tonumber(mon and (mon.eggCycles or mon.friendship)) or 40 +end + -- src/pokemon_summary_screen.c:2483 local function egg_hatch_index(mon) if mon.isBadEgg then return 0 end - local cycles = tonumber(mon.eggCycles or mon.friendship) or 40 + local cycles = SummaryData.eggCycles(mon) if cycles <= 5 then return 3 end if cycles <= 10 then return 2 end if cycles <= 40 then return 1 end return 0 end +-- src/pokemon_summary_screen.c:2495 +function SummaryData.eggHatchText(mon) + if not mon then return "" end + return RomText.at("sEggHatchTimeTexts", egg_hatch_index(mon)) +end + --- Trainer Memo formatting (pokefirered/src/pokemon_summary_screen.c PokeSum_PrintTrainerMemo) function SummaryData.formatTrainerMemo(mon, playerState, opts) if not mon then return { RomText.plain("gText_PokeSum_NoData") } end @@ -320,10 +330,8 @@ function SummaryData.formatTrainerMemo(mon, playerState, opts) local heldByOt = held_by_ot(mon, (opts and opts.owner) or playerState) if mon.isEgg then - return { - RomText.at("sEggOriginTexts", egg_origin_index(mon, heldByOt)), - RomText.at("sEggHatchTimeTexts", egg_hatch_index(mon)), - } + -- src/pokemon_summary_screen.c:2839 + return { RomText.at("sEggOriginTexts", egg_origin_index(mon, heldByOt)) } end local _, natureName = SummaryData.nature(mon) diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index 0c27e5ed..c41d694c 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -188,23 +188,18 @@ function TrainerSight.checkLineOfSight(eo, P, game) return false, 0 end - local function normElevation(e) - local n = tonumber(e) or 0 - if n == 0 or n == 3 then - return 3 - end - return n - end - - -- Elevation tier check: trainer and player must share the exact same elevation tier - local eoElev = normElevation(eo.elevation or (eo.def and eo.def.elevation)) - local pElev = normElevation(P.elevation) - if eoElev ~= pElev then - return false, 0 - end - local Coll = Collision() local Objs = Objects() + local eoElev = tonumber(eo.currentElevation) or 0 + local mapDef = eo.mapDef or Coll._mapDef + + -- pokefirered/src/trainer_see.c:225 + if Objs.elevationsCompatible and not Objs.elevationsCompatible(eoElev, P.currentElevation) then + return false, 0 + end + if Coll.elevationMismatchOn and Coll.elevationMismatchOn(mapDef, eoElev, px, py) then + return false, 0 + end -- Raycast intermediate tiles strictly between trainer and player for step = 1, dist - 1 do @@ -214,7 +209,11 @@ function TrainerSight.checkLineOfSight(eo, P, game) local fromY = ey + dy * (step - 1) -- 1. Collision check: must be passable along raycast direction - if Coll.canEnter and not Coll.canEnter(game, cx, cy, { fromX = fromX, fromY = fromY, dir = facing }) then + if Coll.canEnter and not Coll.canEnter(game, cx, cy, { fromX = fromX, fromY = fromY, dir = facing, elevation = eoElev }) then + return false, 0 + end + -- pokefirered/src/trainer_see.c:214 + if Coll.elevationMismatchOn and Coll.elevationMismatchOn(mapDef, eoElev, cx, cy) then return false, 0 end @@ -224,10 +223,11 @@ function TrainerSight.checkLineOfSight(eo, P, game) end -- 3. Intermediary NPCs block vision - if Objs.blocks and Objs.blocks(cx, cy, eo.localId) then + if Objs.blocks and Objs.blocks(cx, cy, eo.localId, eoElev) then return false, 0 end - if Objs.at and Objs.at(cx, cy) then + local other = Objs.at and Objs.at(cx, cy) + if other and (not Objs.elevationsCompatible or Objs.elevationsCompatible(eoElev, other.currentElevation)) then return false, 0 end end diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index 07e2b849..55de37d9 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -9,7 +9,9 @@ local ERROR_LOG = "lua-error.log" local ERROR_LOG_ROTATED = "lua-error.log.1" local ERROR_LOG_MAX = 32 * 1024 local FLUSH_INTERVAL = 1.0 -local RING_SIZE = 64 +local RING_SIZE = 256 +local FOCUS_LOG = "pad-reconcile.log" +local FOCUS_LOG_MAX = 256 * 1024 local enabled = nil local buffer = {} @@ -114,11 +116,86 @@ function SwitchDiagnostics.identityOverlay() return identityLine end +local function now() + return love and love.timer and love.timer.getTime and love.timer.getTime() or 0 +end + function SwitchDiagnostics.onEvent(kind, payload) if not SwitchDiagnostics.isEnabled() then return end bufCount = bufCount + 1 local slot = ((bufCount - 1) % RING_SIZE) + 1 - buffer[slot] = ("%s %s"):format(tostring(kind), encodePayload(payload)) + buffer[slot] = ("t=%.3f %s %s"):format(now(), tostring(kind), encodePayload(payload)) +end + +local function ringLines(lines) + local start = math.max(1, bufCount - RING_SIZE + 1) + for i = start, bufCount do + local slot = ((i - 1) % RING_SIZE) + 1 + if buffer[slot] then lines[#lines + 1] = buffer[slot] end + end + return lines +end + +local SNAPSHOT_BUTTONS = { + "a", "b", "x", "y", "back", "start", "leftshoulder", "rightshoulder", + "dpup", "dpdown", "dpleft", "dpright", +} +local SNAPSHOT_AXES = { "leftx", "lefty", "triggerleft", "triggerright" } + +local function padSnapshot(j) + local parts = {} + local function call(name, ...) + local fn = j[name] + if type(fn) ~= "function" then return nil end + local ok, v = pcall(fn, j, ...) + if ok then return v end + return nil + end + parts[#parts + 1] = "name=" .. tostring(redactString(tostring(call("getName")))) + parts[#parts + 1] = "guid=" .. tostring(call("getGUID")) + parts[#parts + 1] = "connected=" .. tostring(call("isConnected")) + parts[#parts + 1] = "isGamepad=" .. tostring(call("isGamepad")) + if call("isGamepad") then + local down = {} + for _, button in ipairs(SNAPSHOT_BUTTONS) do + if call("isGamepadDown", button) then down[#down + 1] = button end + end + parts[#parts + 1] = "down=" .. table.concat(down, ",") + for _, axis in ipairs(SNAPSHOT_AXES) do + local v = call("getGamepadAxis", axis) + if type(v) == "number" then parts[#parts + 1] = ("%s=%.2f"):format(axis, v) end + end + end + return table.concat(parts, " ") +end + +function SwitchDiagnostics.onPadReconcile() + if not SwitchDiagnostics.isEnabled() then return end + local filesystem = fs() + if not filesystem then return end + local lines = { + ("=== reconcile t=%.3f at=%s focus=%s"):format(now(), os.date("!%Y-%m-%dT%H:%M:%SZ"), + tostring(love and love.window and love.window.hasFocus and love.window.hasFocus())), + SwitchDiagnostics.identityOverlay(), + } + local js = love and love.joystick + local ok, list = false, nil + if js and js.getJoysticks then ok, list = pcall(js.getJoysticks) end + if ok and type(list) == "table" then + for i, j in ipairs(list) do + lines[#lines + 1] = ("pad%d %s"):format(i, padSnapshot(j)) + end + end + lines[#lines + 1] = "--- last events" + ringLines(lines) + local existing = filesystem.read(FOCUS_LOG) or "" + if #existing > FOCUS_LOG_MAX then + filesystem.write(FOCUS_LOG .. ".1", existing) + existing = "" + end + filesystem.write(FOCUS_LOG, existing .. table.concat(lines, "\n") .. "\n") + SwitchDiagnostics.onEvent("reconcile", nil) + SwitchDiagnostics.maybeFlush(true) end function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra) @@ -162,12 +239,7 @@ function SwitchDiagnostics.maybeFlush(force, now) local filesystem = fs() if not filesystem then return end - local lines = { SwitchDiagnostics.identityOverlay(), "---" } - local start = math.max(1, bufCount - RING_SIZE + 1) - for i = start, bufCount do - local slot = ((i - 1) % RING_SIZE) + 1 - if buffer[slot] then lines[#lines + 1] = buffer[slot] end - end + local lines = ringLines({ SwitchDiagnostics.identityOverlay(), "---" }) filesystem.write(LOG_FILE, table.concat(lines, "\n") .. "\n") end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 038f2063..dbedaacd 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -275,6 +275,12 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/gba/native/layouts/alt_319.mid", "data/generated/gba/pokemon/summary/manifest.lua", "data/generated/gba/pokemon/summary/menu_info.rgba", + -- src/pokemon_summary_screen.c:1862 + "data/generated/gba/pokemon/summary/bg3_info.rgba", + "data/generated/gba/pokemon/summary/bg3_moves.rgba", + "data/generated/gba/pokemon/summary/layer_moves_info.rgba", + "data/generated/gba/pokemon/summary/progress_moves_info_select.rgba", + "data/generated/gba/pokemon/summary/markings.rgba", "data/generated/gba/pokemon/storage/manifest.lua", "data/generated/gba/pokedex/manifest.lua", "data/generated/gba/chrome/manifest.lua", diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index b29ea765..d4a520f8 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2921,7 +2921,8 @@ function RomImporter:_importSave(version, source, force) self:_refreshSlots(version) self.activeSlot[version] = res self.slotScroll[version] = math.huge -- pin the new row on screen (clamped in draw) - self.saveNotice[version] = { ok = true, text = "Imported save into " .. tostring(res) .. "." } + self.saveNotice[version] = { ok = true, text = "Imported save into " .. tostring(res) .. "." + .. (info and info.note and (" " .. info.note) or "") } return end if res == nil and info and info.needsConfirm then diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 2bdc8b3f..1719cc9b 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -62,6 +62,25 @@ local function readCart(version, slotId) return nil end +local function removeCart(version, slotId) + local fs = cartFs() + local rel = cartPath(version, slotId) + if not (fs and fs.remove and rel) then return false end + if fs.getInfo and not fs.getInfo(rel) then return false end + return pcall(fs.remove, rel) == true +end + +function SaveFileIO.dropStaleCart(version, slotId, save) + if not (GameVersion.VERSIONS[version] and GameVersion.generation(version) == 3) or type(save) ~= "table" then + return false + end + local bytes = readCart(version, slotId) + if not bytes then return false end + local Gen3Save = require("src.save_convert.Gen3Save") + if Gen3Save.templateBelongs(Gen3Save.ownerOf(bytes), save) then return false end + return removeCart(version, slotId) +end + local SAVE_SIZE = SaveConvert.SAVE_SIZE -- Resolve raw save bytes from whatever the launcher hands us: @@ -155,6 +174,23 @@ local function importLuaSlot(bytes, version) return true, slotId end +local function importGen3Cart(bytes, version) + local save, convertErr, note = SaveConvert.importSav(bytes, version, version) + if not save then return false, convertErr end + save.version = version + save.meta = SaveData.buildMeta(nil, save.meta) + local slotId = SaveData.createSlot(version) + if not slotId then return false, "this game has no save slots to import into" end + local ok, writeErr = SaveData.writeSlot(version, slotId, save) + if not ok then + SaveData.deleteSlot(version, slotId) + return false, "could not write the imported save: " .. tostring(writeErr) + end + SaveData.setActiveSlot(version, slotId) + writeCart(version, slotId, bytes) + return true, slotId, note and { note = note } or nil +end + -- importToSlot(source, version, force) -> ok, slotIdOrErr | (false, nil, info) -- source: an absolute path, a LOVE DroppedFile, or raw bytes. On success -- registers a new slot for the version, writes the imported save into it, makes @@ -179,6 +215,9 @@ function SaveFileIO.importToSlot(source, version, force) if isLuaSlotExport(bytes) then return importLuaSlot(bytes, version) end local supported, unsupportedWhy = SaveConvert.importSupported(version) if not supported then return false, unsupportedWhy end + if GameVersion.VERSIONS[version] and GameVersion.generation(version) == 3 then + return importGen3Cart(bytes, version) + end if #bytes ~= SAVE_SIZE then local check, checkWhy = SaveConvert.mainChecksumValid(bytes, version) if check == nil then @@ -239,6 +278,7 @@ function SaveFileIO.exportActiveSlot(version) local minted, id = pcall(SaveData.slotPlaythroughId, version, activeSlot, save) if minted and type(id) == "string" then save.meta.playthroughId = id end end + SaveFileIO.dropStaleCart(version, slotId, save) local bytes, exportErr = SaveConvert.exportSav(save, version, readCart(version, slotId)) if not bytes then return false, exportErr end diff --git a/src/import/gba/editions/leafgreen_1_0.lua b/src/import/gba/editions/leafgreen_1_0.lua index a47a1686..bb1e08ec 100644 --- a/src/import/gba/editions/leafgreen_1_0.lua +++ b/src/import/gba/editions/leafgreen_1_0.lua @@ -1696,6 +1696,7 @@ return { [0x3E2280] = 0x3E20BC, -- naming_screen.o:sTransferredToPCMessages + 0 [0x3ECED4] = 0x3ECD10, -- easy_chat.o:sEasyChatGroups + 0 [0x3EDF98] = 0x3EDDD4, -- easy_chat.o:sEasyChatGroupNamePointers + 0 + [0x3EE028] = 0x3EDE64, -- mon_markings.o:sMonMarkings_Gfx + 0 [0x3EE828] = 0x3EE664, -- mon_markings.o:sJPText_Confirm + 0 [0x3EEBF8] = 0x3EEA34, -- heal_location.o:sHealLocations + 0 [0x3EEC98] = 0x3EEAD4, -- heal_location.o:sWhiteoutRespawnHealCenterMapIdxs + 0 @@ -4297,6 +4298,8 @@ return { [0x4633D8] = 0x462DF8, -- menu_indicators.o:sRedArrowGfx + 0 [0x46343C] = 0x462E5C, -- pokedex_area_markers.o:sMarkerTiles + 0 [0x463580] = 0x462FA0, -- pokedex_area_markers.o:sAreaMarkers + 0 + [0x4636E0] = 0x463100, -- pokemon_summary_screen.o:sMonMarkingSpritePalette + 0 + [0x463700] = 0x463120, -- pokemon_summary_screen.o:sTextMovesPalette + 0 [0x463720] = 0x463140, -- pokemon_summary_screen.o:sMoveSelectionCursorPals + 0 [0x463740] = 0x463160, -- pokemon_summary_screen.o:sMoveSelectionCursorTiles_Left + 0 [0x46386C] = 0x46328C, -- pokemon_summary_screen.o:sMoveSelectionCursorTiles_Right + 0 @@ -4311,6 +4314,7 @@ return { [0x463E60] = 0x463880, -- :gNatureNamePointers + 0 [0x463EC4] = 0x4638E4, -- pokemon_summary_screen.o:sEggHatchTimeTexts + 0 [0x463ED4] = 0x4638F4, -- pokemon_summary_screen.o:sEggOriginTexts + 0 + [0x463FBE] = 0x4639DE, -- pokemon_summary_screen.o:sMonPicBounceYDelta_Under60 + 0 [0x464148] = 0x463B68, -- wild_pokemon_area.o:sDexAreas_Kanto + 0 [0x464224] = 0x463C44, -- wild_pokemon_area.o:sDexAreas_Sevii1 + 0 [0x464234] = 0x463C54, -- wild_pokemon_area.o:sDexAreas_Sevii2 + 0 diff --git a/src/import/gba/items_extract.lua b/src/import/gba/items_extract.lua index e8dfcec3..e67a2c4c 100644 --- a/src/import/gba/items_extract.lua +++ b/src/import/gba/items_extract.lua @@ -258,12 +258,6 @@ function ItemsExtract.run(rom, cache, opts) pcall(love.filesystem.write, outRel, outputText) end - local f = io.open(outRel, "wb") - if f then - f:write(outputText) - f:close() - end - return { ok = true, count = itemCount, diff --git a/src/import/gba/summary_chrome_extract.lua b/src/import/gba/summary_chrome_extract.lua index 7a8f16c9..2efbfbf1 100644 --- a/src/import/gba/summary_chrome_extract.lua +++ b/src/import/gba/summary_chrome_extract.lua @@ -98,9 +98,15 @@ end --- Bake a BG tilemap to RGBA. -- On GBA, palette index 0 is transparent (shows lower BGs / backdrop). -- When transparent0 is true, index 0 is written with alpha 0. -local function bake_bg_rgba(gfx, palBytes, map, W, H, transparent0) +local function bake_bg_rgba(gfx, palBytes, map, W, H, transparent0, slots) local tileCount = math.floor(byte_len(gfx) / 32) local banks = load_pal_banks(palBytes, math.max(1, math.floor(byte_len(palBytes) / 32))) + if slots then + local src = banks + banks = {} + for k, v in pairs(src) do banks[k] = v end + for slot, bank in pairs(slots) do banks[slot] = src[bank] or src[slot] end + end local mapW = 32 local indices, pals = {}, {} for i = 1, W * H do indices[i] = 0; pals[i] = 0 end @@ -170,10 +176,7 @@ local function composite_rgba(bottom, top) return table.concat(out) end ---- Pret uses BG3 base tilemaps under the page overlays: ---- INFO/SKILLS/EGG → sBgTilemap_MovesInfoPage (ROM 0x463B88, LZ 1280 B) ---- MOVES/MOVES_INFO → sBgTilemap_MovesPage (ROM 0x463C80, LZ 2048 B) ---- First try ROM decompress (works on Android), then fall back to external .bin files. +-- pokefirered/src/pokemon_summary_screen.c:1862 local function load_base_tilemap(kind, get) -- ROM path (always works on Android, iOS, etc.) if get then @@ -211,6 +214,17 @@ local function bake_page_composited(gfx, palBytes, pageMap, baseMap, W, H) return bake_bg_rgba(gfx, palBytes, pageMap, W, H, false) end +local PROGRESS_X, PROGRESS_Y, PROGRESS_W, PROGRESS_H = 104, 0, 48, 16 + +local function crop_rgba(rgba, W, x, y, w, h) + local rows = {} + for row = 0, h - 1 do + local start = ((y + row) * W + x) * 4 + 1 + rows[#rows + 1] = rgba:sub(start, start + w * 4 - 1) + end + return table.concat(rows) +end + --- Bake tile strip (e.g. 12 tiles in a horizontal line: 96x8 px). local function bake_strip_rgba(gfx, palBytes, numTiles, palBank) local W = numTiles * 8 @@ -357,6 +371,14 @@ local function apply_page_progress_tiles(baseMapBytes, pageKind) set_tilemap_rect(map, 66 + BASE, 17, 1, 1, 1) set_tilemap_rect(map, 48 + BASE, 18, 0, 1, 1) set_tilemap_rect(map, 64 + BASE, 18, 1, 1, 1) + elseif pageKind == "moves_info_select" then + -- pokefirered/src/pokemon_summary_screen.c:3334 + set_tilemap_rect(map, 1 + BASE, 13, 0, 4, 1) + set_tilemap_rect(map, 19 + BASE, 13, 1, 4, 1) + set_tilemap_rect(map, 50 + BASE, 17, 0, 1, 1) + set_tilemap_rect(map, 66 + BASE, 17, 1, 1, 1) + set_tilemap_rect(map, 48 + BASE, 18, 0, 1, 1) + set_tilemap_rect(map, 64 + BASE, 18, 1, 1, 1) elseif pageKind == "egg" then set_tilemap_rect(map, 17 + BASE, 13, 0, 1, 1) set_tilemap_rect(map, 33 + BASE, 13, 1, 1, 1) @@ -402,17 +424,103 @@ function SummaryChromeExtract.run(rom, cache, opts) local baseMoves = load_base_tilemap("moves", get) if bgGfx and bgPal and mapInfo then - local baseInfoP1 = apply_page_progress_tiles(baseInfo, "info") - local baseSkillsP2 = apply_page_progress_tiles(baseInfo, "skills") - local baseMovesP3 = apply_page_progress_tiles(baseMoves, "moves") - local baseMovesInfoP4 = apply_page_progress_tiles(baseMoves, "moves_info") - local baseEggPEgg = apply_page_progress_tiles(baseInfo, "egg") + cache:write(root .. "/page_info.rgba", + bake_page_composited(bgGfx, bgPal, mapInfo, apply_page_progress_tiles(baseInfo, "info"), W, H)) - cache:write(root .. "/page_info.rgba", bake_page_composited(bgGfx, bgPal, mapInfo, baseInfoP1, W, H)) - cache:write(root .. "/page_skills.rgba", bake_page_composited(bgGfx, bgPal, mapSkills, baseSkillsP2, W, H)) - cache:write(root .. "/page_moves.rgba", bake_page_composited(bgGfx, bgPal, mapMoves, baseMovesP3, W, H)) - cache:write(root .. "/page_moves_info.rgba", bake_page_composited(bgGfx, bgPal, mapMovesInfo, baseMovesInfoP4, W, H)) - cache:write(root .. "/page_egg.rgba", bake_page_composited(bgGfx, bgPal, mapEgg, baseEggPEgg, W, H)) + -- pokefirered/src/pokemon_summary_screen.c:2009 + local variants = { { suffix = "", slots = nil }, { suffix = "_shiny", slots = { [0] = 6, [1] = 5 } } } + local bases = { info = baseInfo, moves = baseMoves } + local progress = { + info = baseInfo, skills = baseInfo, moves = baseInfo, egg = baseInfo, + moves_info = baseMoves, moves_info_select = baseMoves, + } + local layers = { info = mapInfo, skills = mapSkills, moves = mapMoves, moves_info = mapMovesInfo, egg = mapEgg } + for _, v in ipairs(variants) do + local shiny = v.slots ~= nil + for kind, map in pairs(bases) do + cache:write(root .. "/bg3_" .. kind .. v.suffix .. ".rgba", bake_bg_rgba(bgGfx, bgPal, map, W, H, false, v.slots)) + end + for kind, map in pairs(progress) do + if not (shiny and kind == "egg") then + local full = bake_bg_rgba(bgGfx, bgPal, apply_page_progress_tiles(map, kind), W, H, false, v.slots) + cache:write(root .. "/progress_" .. kind .. v.suffix .. ".rgba", + crop_rgba(full, W, PROGRESS_X, PROGRESS_Y, PROGRESS_W, PROGRESS_H)) + end + end + for kind, map in pairs(layers) do + if map and not (shiny and kind == "egg") then + cache:write(root .. "/layer_" .. kind .. v.suffix .. ".rgba", bake_bg_rgba(bgGfx, bgPal, map, W, H, true, v.slots)) + end + end + end + end + + local markGfx = rom and Versions.MON_MARKINGS_GFX and read_bytes(Versions.MON_MARKINGS_GFX, 2048) + local markPal = rom and Versions.SUMMARY_MARKING_PAL and read_bytes(Versions.SUMMARY_MARKING_PAL, 32) + if markGfx and markPal then + cache:write(root .. "/markings.rgba", (bake_sheet_rgba(markGfx, markPal, 4, 16, 0))) + end + + local function rgb_of(bank, idx) + local r, g, b = bgr555_to_rgb8(bank[idx] or 0) + return string.format("{ %d, %d, %d }", r, g, b) + end + local moveTextColors = "{}" + if rom and Versions.SUMMARY_TEXT_MOVES_PAL then + local bank = load_pal_banks(read_bytes(Versions.SUMMARY_TEXT_MOVES_PAL, 32), 1)[0] + -- pokefirered/src/pokemon_summary_screen.c:645 + local pairsIdx = { { 7, 8 }, { 1, 2 }, { 3, 4 }, { 5, 6 } } + local rows = {} + for i, p in ipairs(pairsIdx) do + rows[i] = string.format("[%d] = { fg = %s, shadow = %s }", i - 1, rgb_of(bank, p[1]), rgb_of(bank, p[2])) + end + moveTextColors = "{ " .. table.concat(rows, ", ") .. " }" + end + + local monPicBounce = "{}" + if rom and Versions.SUMMARY_MON_PIC_BOUNCE then + local lens = { 3, 5, 7, 7 } + local off = Versions.SUMMARY_MON_PIC_BOUNCE + local rows = {} + for i, n in ipairs(lens) do + local vals = {} + for j = 1, n do + local b = get(off + j - 1) + vals[j] = tostring(b >= 128 and b - 256 or b) + end + rows[i] = "{ " .. table.concat(vals, ", ") .. " }" + off = off + n + end + monPicBounce = "{ " .. table.concat(rows, ", ") .. " }" + end + + local eggPicShake = "{}" + if rom and Versions.SUMMARY_MON_PIC_BOUNCE then + -- pokefirered/src/pokemon_summary_screen.c:944 + local off = Versions.SUMMARY_MON_PIC_BOUNCE + 3 + 5 + 7 + 7 + local rows = {} + for i, n in ipairs({ 11, 11, 15 }) do + local vals = {} + for j = 1, n do + local b = get(off + j - 1) + vals[j] = tostring(b >= 128 and b - 256 or b) + end + rows[i] = "{ " .. table.concat(vals, ", ") .. " }" + off = off + n + end + eggPicShake = "{ " .. table.concat(rows, ", ") .. " }" + end + + local noFlip = "{}" + if rom and Versions.SPECIES_INFO and Versions.SPECIES_INFO_SIZE and Versions.NUM_SPECIES then + local ids = {} + for sp = 1, Versions.NUM_SPECIES - 1 do + -- pokefirered/include/pokemon.h:235 + if get(Versions.SPECIES_INFO + sp * Versions.SPECIES_INFO_SIZE + 0x19) >= 128 then + ids[#ids + 1] = "[" .. sp .. "] = true" + end + end + noFlip = "{ " .. table.concat(ids, ", ") .. " }" end -- HP Bar & EXP Bar @@ -539,13 +647,25 @@ return { -- WIN_SKILLS_5 ability pane at (8,128) abilityName = { x = 74, y = 129 }, abilityDesc = { x = 10, y = 143, w = 232, h = 16 }, - status = { x = 16, y = 38 }, - statusMovesInfo = { x = 16, y = 45 }, + -- src/pokemon_summary_screen.c:4387 + status = { x = 0, y = 34 }, + statusMovesInfo = { x = 0, y = 41 }, -- src/pokemon_summary_screen.c:4800, :4830, :4716 shinyStar = { x = 102, y = 36 }, shinyStarMovesInfo = { x = 4, y = 20 }, pokerus = { x = 110, y = 88 }, + -- src/pokemon_summary_screen.c:4888 + markings = { x = 4, y = 87 }, + -- src/pokemon_summary_screen.c:4152 + monIcon = { x = 8, y = 16 }, + -- src/pokemon_summary_screen.c:3377 + movesInfoType1 = { x = 48, y = 35 }, + movesInfoType2 = { x = 84, y = 35 }, }, + moveTextColors = %s, + monPicBounce = %s, + eggPicShake = %s, + noFlip = %s, -- WIN_MOVES_5 types at (120,16); WIN_MOVES_3 names/PP at (160,16) -- GetMoveNamePrinterYpos(i)=i*28+5, GetMovePpPrinterYpos(i)=i*28+16 moveSlots = { @@ -561,7 +681,7 @@ return { desc = { x = 7, y = 98, w = 112, h = 48 }, }, } -]], W, H) +]], W, H, moveTextColors, monPicBounce, eggPicShake, noFlip) cache:write(root .. "/manifest.lua", manifest) return { diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 22d929e8..770c7843 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -43,7 +43,7 @@ Versions.ROM_SIZE = 16777216 -- v115: LeafGreen profiles, edition-specific title assets and Deoxys stats. -- v121: chrome/fonts/japanese_{normal,small}_* and japanese_widths.lua, the -- cart's Japanese fonts, for text a Japanese translation mod prints. -Versions.CACHE_VERSION = 123 +Versions.CACHE_VERSION = 125 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 3 Versions.ANIM_VERSION = 1 @@ -1572,6 +1572,10 @@ Versions.SUMMARY_POKERUS_GFX = 0x463B20 -- sPokerus_Gfx (64 bytes) Versions.SUMMARY_POKERUS_PAL = 0x463B00 -- sPokerus_Pal (32 bytes) Versions.SUMMARY_HP_BAR_YELLOW_PAL = 0x463AAC -- sHpBar_Yellow_Pal (32 bytes) Versions.SUMMARY_HP_BAR_RED_PAL = 0x463ACC -- sHpBar_Red_Pal (32 bytes) +Versions.SUMMARY_MARKING_PAL = 0x4636E0 +Versions.SUMMARY_TEXT_MOVES_PAL = 0x463700 +Versions.SUMMARY_MON_PIC_BOUNCE = 0x463FBE +Versions.MON_MARKINGS_GFX = 0x3EE028 Versions.MENU_INFO_GFX = 0xE95DDC -- gMenuInfoElements_Gfx (4bpp 128x128, 8192 bytes) Versions.MENU_INFO_PAL = 0xE95D9C -- gMenuInfoElements1_Pal + gMenuInfoElements2_Pal (64 bytes) diff --git a/src/save_convert/Gen3Layout.lua b/src/save_convert/Gen3Layout.lua new file mode 100644 index 00000000..224468a2 --- /dev/null +++ b/src/save_convert/Gen3Layout.lua @@ -0,0 +1,338 @@ +local L = {} + +-- include/save.h:8 +L.SECTOR_DATA_SIZE = 0xF80 +-- include/save.h:10 +L.SECTOR_SIZE = 0x1000 +-- include/save.h:63 +L.FOOTER = { id = 0xFF4, checksum = 0xFF6, signature = 0xFF8, counter = 0xFFC } +-- include/save.h:15 +L.SIGNATURE = 0x08012025 +-- include/save.h:24 +L.SECTORS_PER_SLOT = 14 +L.NUM_SLOTS = 2 +L.NUM_SECTORS = 32 +-- include/save.h:26 +L.SECTOR_ID_HOF_1 = 28 +L.SECTOR_ID_HOF_2 = 29 +L.FLASH_SIZE = 0x20000 +L.HALF_FLASH_SIZE = 0x10000 +L.MAX_TRAILER = 0x3F + +-- src/save.c:54 +L.CHUNK_SIZES = { + [0] = 0xF24, + 0xF80, 0xF80, 0xF80, 0xEE8, + 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0x7D0, +} + +-- src/save.c:56 +L.BLOCKS = { + { key = "sb2", first = 0, last = 0, size = 0xF24 }, + { key = "sb1", first = 1, last = 4, size = 0x3D68 }, + { key = "storage", first = 5, last = 13, size = 0x83D0 }, +} + +-- include/global.h:329 +L.SB2 = { + { "name", 0x000, "strFF", 8 }, + { "gender", 0x008, "u8" }, + { "specialSaveWarpFlags", 0x009, "u8" }, + { "trainerId", 0x00A, "u16" }, + { "secretId", 0x00C, "u16" }, + { "playHours", 0x00E, "u16" }, + { "playMinutes", 0x010, "u8" }, + { "playSeconds", 0x011, "u8" }, + { "playVBlanks", 0x012, "u8" }, + { "buttonMode", 0x013, "u8" }, + { "optionsWord", 0x014, "u16" }, + -- include/global.h:195 + { "dexOrder", 0x018, "u8" }, + { "dexMode", 0x019, "u8" }, + { "dexUnused", 0x01A, "u8" }, + { "dexNationalMagic", 0x01B, "u8" }, + { "unownPersonality", 0x01C, "u32" }, + { "spindaPersonality", 0x020, "u32" }, + { "dexOwned", 0x028, "bits", 52 }, + { "dexSeen", 0x05C, "bits", 52 }, + -- include/global.h:348 + { "gcnLinkFlags", 0x0A8, "u32" }, + { "frlgMarker", 0x0AC, "u32" }, + -- include/global.h:354 + { "berryPowder", 0xAF8, "u32", nil, "key32" }, + -- include/global.h:358 + { "encryptionKey", 0xF20, "u32" }, +} + +-- include/global.h:338 +L.OPTIONS_BITS = { + { "textSpeed", 0, 3 }, + { "frameType", 3, 5 }, + { "sound", 8, 1 }, + { "battleStyle", 9, 1 }, + { "battleScene", 10, 1 }, + { "regionMapZoom", 11, 1 }, +} + +-- include/global.h:392 +L.WARP = { group = { 0, "s8" }, num = { 1, "s8" }, warpId = { 2, "s8" }, x = { 4, "s16" }, y = { 6, "s16" } } + +-- include/global.h:761 +L.SB1 = { + { "posX", 0x0000, "s16" }, + { "posY", 0x0002, "s16" }, + { "location", 0x0004, "warp" }, + { "continueGameWarp", 0x000C, "warp" }, + { "dynamicWarp", 0x0014, "warp" }, + { "lastHealLocation", 0x001C, "warp" }, + { "escapeWarp", 0x0024, "warp" }, + { "savedMusic", 0x002C, "u16" }, + { "weather", 0x002E, "u8" }, + { "flashLevel", 0x0030, "u8" }, + { "mapLayoutId", 0x0032, "u16" }, + { "partyCount", 0x0034, "u8" }, + { "money", 0x0290, "u32", nil, "key32" }, + { "coins", 0x0294, "u16", nil, "key16" }, + { "registeredItem", 0x0296, "u16" }, + { "dexSeen1", 0x05F8, "bits", 52 }, + { "flags", 0x0EE0, "bits", 0x120 }, + { "dexSeen2", 0x3A18, "bits", 52 }, + { "rivalName", 0x3A4C, "str", 8 }, +} + +-- include/global.h:773 +L.PARTY_OFFSET = 0x0038 +L.PARTY_SIZE = 6 +L.PARTY_MON_SIZE = 100 +-- include/pokemon.h:133 +L.PARTY_MAIL = 85 + +-- include/global.h:777 +L.PC_ITEMS = { off = 0x0298, count = 30 } +-- include/global.h:778 +L.POCKETS = { + { key = "ITEMS", off = 0x0310, count = 42 }, + { key = "KEY_ITEMS", off = 0x03B8, count = 30 }, + { key = "POKE_BALLS", off = 0x0430, count = 13 }, + { key = "TM_CASE", off = 0x0464, count = 58 }, + { key = "BERRY_POUCH", off = 0x054C, count = 43 }, +} +-- include/global.h:400 +L.ITEM_SLOT_SIZE = 4 + +-- include/global.h:788 +L.OBJECT_EVENTS = { off = 0x06A0, count = 16, size = 0x24 } +-- include/global.fieldmap.h:212 +L.OBJECT_EVENT = { flags = 0x00, isPlayerByte = 0x02, graphicsId = 0x05, currentX = 0x10, currentY = 0x12, facing = 0x18 } +-- include/fieldmap.h:21 +L.MAP_OFFSET = 7 + +-- include/global.h:791 +L.VARS = { off = 0x1000, count = 256, first = 0x4000 } +-- include/global.h:792 +L.GAME_STATS = { off = 0x1200, count = 64 } +-- include/global.h:794 +L.EASY_CHAT_PROFILE = { off = 0x2CA0, count = 6 } + +-- include/global.h:549 +L.DAYCARE = { off = 0x2F80, monSize = 0x8C, stepsOff = 0x88, offspringPersonality = 0x118, stepCounter = 0x11A } +-- include/global.h:818 +L.ROUTE5_DAYCARE = 0x3C98 + +-- include/global.h:417 +L.ROAMER = { + { "ivs", 0x00, "u32" }, + { "personality", 0x04, "u32" }, + { "species", 0x08, "u16" }, + { "hp", 0x0A, "u16" }, + { "level", 0x0C, "u8" }, + { "status", 0x0D, "u8" }, + { "active", 0x13, "u8" }, +} +L.ROAMER_OFFSET = 0x30D0 + +-- include/pokemon_storage_system.h:44 +L.STORAGE = { + currentBox = 0x0000, + boxes = 0x0004, + boxNames = 0x8344, + boxNameLength = 9, + wallpapers = 0x83C2, + totalBoxes = 14, + inBox = 30, +} + +-- include/pokemon.h:105 +L.BOX_MON_SIZE = 80 +L.BOX_MON = { + personality = 0x00, + otId = 0x04, + nickname = 0x08, nicknameLength = 10, + language = 0x12, + flags = 0x13, + otName = 0x14, otNameLength = 7, + markings = 0x1B, + checksum = 0x1C, + unknown = 0x1E, + secure = 0x20, secureWords = 12, +} +-- include/pokemon.h:111 +L.BOX_MON_FLAGS = { isBadEgg = 0, hasSpecies = 1, isEgg = 2 } + +L.SUBSTRUCT_SIZE = 12 +-- include/pokemon.h:8 +L.SUBSTRUCT0 = { + { "species", 0, "u16" }, + { "heldItem", 2, "u16" }, + { "exp", 4, "u32" }, + { "ppBonuses", 8, "u8" }, + { "friendship", 9, "u8" }, + { "growthFiller", 10, "u16" }, +} +-- include/pokemon.h:18 +L.SUBSTRUCT1 = { moves = 0, pp = 8 } +-- include/pokemon.h:24 +L.EV_KEYS = { "hp", "atk", "def", "spe", "spa", "spd" } +L.CONTEST_KEYS = { "cool", "beauty", "cute", "smart", "tough", "sheen" } +-- include/pokemon.h:40 +L.SUBSTRUCT3 = { pokerus = 0, metLocation = 1, origins = 2, ivWord = 4, ribbons = 8 } +-- include/pokemon.h:45 +L.ORIGINS_BITS = { { "metLevel", 0, 7 }, { "metGame", 7, 4 }, { "pokeball", 11, 4 }, { "otGender", 15, 1 } } +-- include/pokemon.h:50 +L.IV_KEYS = { "hp", "atk", "def", "spe", "spa", "spd" } +L.IV_EGG_BIT = 30 +L.IV_ABILITY_BIT = 31 +-- include/pokemon.h:64 +L.RIBBON_CHAMPION_BIT = 15 +-- include/pokemon.h:84 +L.RIBBON_FATEFUL_BIT = 31 + +-- include/pokemon.h:128 +L.PARTY_EXTRA = { + { "status", 80, "u32" }, + { "level", 84, "u8" }, + { "mail", 85, "u8" }, + { "hp", 86, "u16" }, + { "maxHp", 88, "u16" }, + { "attack", 90, "u16" }, + { "defense", 92, "u16" }, + { "speed", 94, "u16" }, + { "spAtk", 96, "u16" }, + { "spDef", 98, "u16" }, +} + +-- src/pokemon.c:2863 +L.SUBSTRUCT_ORDER = { + [0] = { 0, 1, 2, 3 }, { 0, 1, 3, 2 }, { 0, 2, 1, 3 }, { 0, 3, 1, 2 }, { 0, 2, 3, 1 }, { 0, 3, 2, 1 }, + { 1, 0, 2, 3 }, { 1, 0, 3, 2 }, { 2, 0, 1, 3 }, { 3, 0, 1, 2 }, { 2, 0, 3, 1 }, { 3, 0, 2, 1 }, + { 1, 2, 0, 3 }, { 1, 3, 0, 2 }, { 2, 1, 0, 3 }, { 3, 1, 0, 2 }, { 2, 3, 0, 1 }, { 3, 2, 0, 1 }, + { 1, 2, 3, 0 }, { 1, 3, 2, 0 }, { 2, 1, 3, 0 }, { 3, 1, 2, 0 }, { 2, 3, 1, 0 }, { 3, 2, 1, 0 }, +} + +-- include/constants/battle.h:91 +L.STATUS = { sleepMask = 0x7, PSN = 0x8, BRN = 0x10, FRZ = 0x20, PAR = 0x40, TOX = 0x80 } +L.STATUS_ORDER = { "PSN", "BRN", "FRZ", "PAR", "TOX" } + +-- src/hall_of_fame.c:32 +L.HOF = { teams = 50, monsPerTeam = 6, monSize = 20, tid = 0, personality = 4, speciesLevel = 8, nick = 10, nickLength = 10 } + +-- src/event_data.c:107 +L.NATIONAL_DEX = { magic = 0xB9, var = 0x404E, varValue = 0x6258, flag = 0x840 } +-- include/constants/flags.h:1378 +L.FLAG_SYS_GAME_CLEAR = 0x82C +-- include/constants/flags.h:44 +L.TEMP_FLAGS_END = 0x1F +-- include/constants/game_stat.h:5 +L.GAME_STAT_FIRST_HOF_PLAY_TIME = 1 +-- include/save_location.h:5 +L.CONTINUE_GAME_WARP = 0x01 +-- include/constants/global.h:110 +L.FACING = { [1] = "down", [2] = "up", [3] = "left", [4] = "right" } + +-- src/pokemon_storage_system_menu.c:415 +L.DEFAULT_BOX_NAME = "BOX%d" +-- src/pokemon_storage_system_menu.c:420 +L.DEFAULT_WALLPAPER_MOD = 4 + +L.EMPTY_WARP = { group = -1, num = -1, warpId = -1, x = -1, y = -1 } + +-- include/constants/global.h:63 +L.PLAYER_NAME_LENGTH = 7 +L.POKEMON_NAME_LENGTH = 10 +-- include/pokemon_storage_system.h:11 +L.BOX_NAME_LENGTH = 8 +-- include/constants/global.h:20 +L.LANGUAGE_JAPANESE = 1 +L.LANGUAGE_ENGLISH = 2 +-- src/daycare.c:135 +L.EGG_NICKNAME = "\x60\x6F\x8B" +-- include/global.h:790 +L.FLAGS_COUNT = 0x900 +-- include/constants/easy_chat.h:1091 +L.EC_WORD_UNDEFINED = 0xFFFF +-- src/easy_chat.c:444 +L.PORT_PROFILE_WORDS = 4 +-- include/save.h:26 +L.HOF_SECTORS = 2 +-- include/constants/global.h:11 +L.VERSION_FIRE_RED = 4 +L.VERSION_LEAF_GREEN = 5 +-- include/constants/heal_locations.h:11 +L.HEAL_LOCATION_PALLET_TOWN = 1 + +-- include/global.h:524 +L.MAIL = { off = 0x2CD0, count = 16, size = 36, words = 0x00, wordCount = 9, playerName = 0x12, + playerNameLength = 8, trainerId = 0x1A, species = 0x1E, itemId = 0x20 } +-- include/constants/items.h:451 +L.MAIL_NONE = 0xFF +-- src/mail_data.c:28 +L.MAIL_CLEAR_SPECIES = 1 +-- src/mail_data.c:167 +L.MAIL_ITEM_FIRST, L.MAIL_ITEM_LAST = 121, 132 +-- src/mail_data.c:41 +L.MAIL_PARTY_SLOTS = 6 +L.MAIL_NAME_PAD = 6 +-- include/global.h:533 +L.DAYCARE_MAIL = { off = 80, otName = 36, otNameLength = 8, monName = 44, monNameLength = 11 } + +-- include/global.h:795 +L.EASY_CHAT_BATTLE = { start = 0x2CAC, won = 0x2CB8, lost = 0x2CC4, count = 6 } +local function ecWord(group, index) return group * 512 + index end +-- src/easy_chat.c:73 +L.DEFAULT_BATTLE_START_WORDS = { + -- include/constants/easy_chat.h:507 + ecWord(0x8, 0xF), + -- include/constants/easy_chat.h:290 + ecWord(0x5, 0x2), + -- include/constants/easy_chat.h:467 + ecWord(0x7, 0x25), + -- include/constants/easy_chat.h:368 + ecWord(0x6, 0x3), + -- include/constants/easy_chat.h:247 + ecWord(0x4, 0x3), + -- include/constants/easy_chat.h:365 + ecWord(0x6, 0x0), +} + +-- include/global.h:814 +L.FAME_CHECKER = { off = 0x3A54, count = 16, pickBits = 2, flavorShift = 2, flavorBits = 12, unkShift = 14 } +-- include/constants/fame_checker.h:4 +L.FAMECHECKER_OAK = 0 +-- include/constants/fame_checker.h:24 +L.FCPICKSTATE_COLORED = 2 + +-- include/global.h:816 +L.REGISTERED_TEXTS = { off = 0x3AD4, count = 10, size = 21 } + +-- include/global.h:693 +L.TRAINER_TOWER = { off = 0x3D38, count = 4, size = 12, bestTime = 4 } +-- include/constants/trainer_tower.h:61 +L.TRAINER_TOWER_MAX_TIME = 215999 + +-- include/global.h:240 +L.LINK_BATTLE_RECORDS = { off = 0xA98, count = 5, size = 16 } + +-- src/event_data.c:71 +L.RSE_NATIONAL_VAR, L.RSE_NATIONAL_VALUE, L.RSE_NATIONAL_FLAG = 0x403C, 0x0302, 0x838 + +return L diff --git a/src/save_convert/Gen3Save.lua b/src/save_convert/Gen3Save.lua new file mode 100644 index 00000000..900e3f81 --- /dev/null +++ b/src/save_convert/Gen3Save.lua @@ -0,0 +1,1667 @@ +local bit = require("bit") +local L = require("src.save_convert.Gen3Layout") + +local band, bor, bxor, lshift, rshift = bit.band, bit.bor, bit.bxor, bit.lshift, bit.rshift + +local Gen3Save = {} + +Gen3Save.MSG = { + size = "A FireRed/LeafGreen save must be 128 KB (or 64 KB); this one is %d bytes.", + notFrlg = "That is a Ruby/Sapphire/Emerald save, not FireRed/LeafGreen.", + japanese = "Japanese FireRed/LeafGreen saves can't be imported.", + empty = "That save file is empty (no game was ever saved).", + corrupt = "Both copies of the save in that file are damaged.", + olderSlot = "The newest copy of that save was damaged, so the previous save was imported.", +} + +local U32 = 4294967296 + +local function u8(s, o) return s:byte(o + 1) end +local function u16(s, o) local a, b = s:byte(o + 1, o + 2); return a + b * 256 end +local function u32(s, o) local a, b, c, d = s:byte(o + 1, o + 4); return a + b * 256 + c * 65536 + d * 16777216 end +local function s8(s, o) local v = u8(s, o); return v >= 128 and v - 256 or v end +local function s16(s, o) local v = u16(s, o); return v >= 32768 and v - 65536 or v end +local function tou32(x) return x % U32 end +local function bits(v, shift, width) return band(rshift(v, shift), lshift(1, width) - 1) end + +Gen3Save.u8, Gen3Save.u16, Gen3Save.u32 = u8, u16, u32 + +local CHARMAP +local REVERSE +local EXTRA +local EXTRA_REVERSE +-- charmap.txt:837 +local EXTRA_PREFIX = 0xF9 + +local function charmap() + if not CHARMAP then + local TextIR = require("src.core.game3.scripting.text_ir") + CHARMAP, EXTRA = TextIR.CHARMAP, TextIR.EXTRA_SYMBOL or {} + end + return CHARMAP +end + +local function reverse() + if REVERSE then return REVERSE end + REVERSE, EXTRA_REVERSE = {}, {} + for code, ch in pairs(charmap()) do + if REVERSE[ch] == nil or code < REVERSE[ch] then REVERSE[ch] = code end + end + for code, ch in pairs(EXTRA) do + if EXTRA_REVERSE[ch] == nil or code < EXTRA_REVERSE[ch] then EXTRA_REVERSE[ch] = code end + end + return REVERSE +end + +function Gen3Save.decodeString(s, off, len) + local map, out = charmap(), {} + local i = 0 + while i < len do + local c = u8(s, off + i) + if c == 0xFF then break end + local sym = c == EXTRA_PREFIX and i + 1 < len and EXTRA[u8(s, off + i + 1)] + if sym then + out[#out + 1] = sym + i = i + 2 + else + out[#out + 1] = map[c] or string.format("{%02X}", c) + i = i + 1 + end + end + return table.concat(out) +end + +function Gen3Save.encodeString(str, len, pad) + local r, out, i = reverse(), {}, 1 + str = str or "" + local function put(tok) + local code = r[tok] + if code then + out[#out + 1] = code + elseif EXTRA_REVERSE[tok] and #out + 2 <= len then + out[#out + 1] = EXTRA_PREFIX + out[#out + 1] = EXTRA_REVERSE[tok] + else + out[#out + 1] = r["?"] + end + end + while i <= #str and #out < len do + local hex = str:match("^{(%x%x)}", i) + local tag = not hex and str:match("^{[%u%d_]+}", i) + if hex then + out[#out + 1] = tonumber(hex, 16) + i = i + 4 + elseif tag then + put(tag) + i = i + #tag + else + local c = str:byte(i) + local n = c < 0x80 and 1 or c < 0xE0 and 2 or c < 0xF0 and 3 or 4 + put(str:sub(i, i + n - 1)) + i = i + n + end + end + if #out < len then out[#out + 1] = 0xFF end + while #out < len do out[#out + 1] = pad or 0 end + local t = {} + for k = 1, len do t[k] = string.char(out[k]) end + return table.concat(t) +end + +-- src/save.c:614 +function Gen3Save.checksum(s, off, size) + local sum = 0 + for o = off, off + size - 4, 4 do sum = (sum + u32(s, o)) % U32 end + return (math.floor(sum / 65536) + sum) % 65536 +end + +local function readValue(s, off, kind, n) + if kind == "u8" then return u8(s, off) end + if kind == "u16" then return u16(s, off) end + if kind == "u32" then return u32(s, off) end + if kind == "s8" then return s8(s, off) end + if kind == "s16" then return s16(s, off) end + if kind == "str" or kind == "strFF" then return Gen3Save.decodeString(s, off, n) end + if kind == "warp" then + local w = {} + for k, spec in pairs(L.WARP) do w[k] = readValue(s, off + spec[1], spec[2]) end + return w + end + if kind == "bits" then + local list = {} + for i = 0, n * 8 - 1 do + if band(u8(s, off + math.floor(i / 8)), lshift(1, i % 8)) ~= 0 then list[#list + 1] = i end + end + return list + end + error("unknown field kind " .. tostring(kind)) +end + +local function applyKey(v, xor, key) + if xor == "key32" then return tou32(bxor(v, key)) end + if xor == "key16" then return bxor(v, key % 65536) % 65536 end + return v +end + +function Gen3Save.readFields(s, fields, out, key) + out = out or {} + for _, f in ipairs(fields) do + local v = readValue(s, f[2], f[3], f[4]) + if f[5] then v = applyKey(v, f[5], key) end + out[f[1]] = v + end + return out +end + +function Gen3Save.normalizeSize(bytes) + local n = #bytes + if n >= L.FLASH_SIZE and n <= L.FLASH_SIZE + L.MAX_TRAILER then return bytes:sub(1, L.FLASH_SIZE), 2 end + if n >= L.HALF_FLASH_SIZE and n <= L.HALF_FLASH_SIZE + L.MAX_TRAILER then return bytes:sub(1, L.HALF_FLASH_SIZE), 1 end + return nil +end + +-- src/save.c:466 +local function scanSlot(s, slot) + local found, counter, sig = {}, nil, false + for i = 0, L.SECTORS_PER_SLOT - 1 do + local base = (slot * L.SECTORS_PER_SLOT + i) * L.SECTOR_SIZE + if u32(s, base + L.FOOTER.signature) == L.SIGNATURE then + sig = true + local id = u16(s, base + L.FOOTER.id) + local size = L.CHUNK_SIZES[id] + if size and u16(s, base + L.FOOTER.checksum) == Gen3Save.checksum(s, base, size) then + found[id] = base + counter = u32(s, base + L.FOOTER.counter) + end + end + end + if not sig then return { status = "empty" } end + for id = 0, L.SECTORS_PER_SLOT - 1 do + if not found[id] then return { status = "error" } end + end + return { status = "ok", counter = counter, base = found, slot = slot } +end + +-- src/save.c:536 +function Gen3Save.pickSlot(s, slots) + local a = scanSlot(s, 0) + local b = slots >= 2 and scanSlot(s, 1) or { status = "empty" } + local counter, older + if a.status == "ok" and b.status == "ok" then + local c1, c2 = a.counter, b.counter + if (c1 == U32 - 1 and c2 == 0) or (c1 == 0 and c2 == U32 - 1) then + counter = ((c1 + 1) % U32 < (c2 + 1) % U32) and c2 or c1 + else + counter = (c1 < c2) and c2 or c1 + end + older = false + elseif a.status == "ok" then + counter, older = a.counter, b.status == "error" + elseif b.status == "ok" then + counter, older = b.counter, a.status == "error" + end + if counter then + if slots < 2 then return a, older end + -- src/save.c:444 + local chosen = (counter % L.NUM_SLOTS == 0) and a or b + if chosen.status ~= "ok" then return nil, "corrupt" end + return chosen, older + end + if a.status == "empty" and b.status == "empty" then return nil, "empty" end + return nil, "corrupt" +end + +function Gen3Save.readBlocks(bytes) + if type(bytes) ~= "string" then return nil, "size" end + local s, slots = Gen3Save.normalizeSize(bytes) + if not s then return nil, "size" end + local slot, extra = Gen3Save.pickSlot(s, slots) + if not slot then return nil, extra end + local out = { counter = slot.counter, slot = slot.slot, olderSlot = extra == true, image = s } + for _, blk in ipairs(L.BLOCKS) do + local parts = {} + for id = blk.first, blk.last do + parts[#parts + 1] = s:sub(slot.base[id] + 1, slot.base[id] + L.CHUNK_SIZES[id]) + end + out[blk.key] = table.concat(parts):sub(1, blk.size) + end + return out +end + +-- src/pokemon.c:2807 +local function decryptSecure(raw, personality, otId) + local key = bxor(personality, otId) + local words = {} + for i = 0, L.BOX_MON.secureWords - 1 do + local w = tou32(bxor(u32(raw, L.BOX_MON.secure + i * 4), key)) + words[#words + 1] = string.char(w % 256, rshift(w, 8) % 256, rshift(w, 16) % 256, rshift(w, 24) % 256) + end + return table.concat(words) +end + +-- src/pokemon.c:2069 +local function secureChecksum(sec) + local sum = 0 + for i = 0, L.BOX_MON.secureWords * 2 - 1 do sum = (sum + u16(sec, i * 2)) % 65536 end + return sum +end + +local function isBlank(raw) + for i = 1, #raw do if raw:byte(i) ~= 0 then return false end end + return true +end + +function Gen3Save.decodeBoxMon(raw) + if isBlank(raw) then return nil end + local B = L.BOX_MON + local personality, otId = u32(raw, B.personality), u32(raw, B.otId) + local flags = u8(raw, B.flags) + 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 + local mon = { + personality = personality, + otIdRaw = otId, + otId = otId % 65536, + otSecretId = math.floor(otId / 65536), + nickname = Gen3Save.decodeString(raw, B.nickname, B.nicknameLength), + language = u8(raw, B.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), + } + if mon.isEggFlag then mon.nicknameBytes = raw:sub(B.nickname + 1, B.nickname + B.nicknameLength) end + mon.checksumOk = secureChecksum(sec) == mon.checksum + for _, f in ipairs(L.SUBSTRUCT0) do mon[f[1]] = readValue(sec, g + f[2], f[3]) end + mon.moves, mon.pp = {}, {} + for i = 0, 3 do + mon.moves[i + 1] = u16(sec, a + L.SUBSTRUCT1.moves + i * 2) + mon.pp[i + 1] = u8(sec, a + L.SUBSTRUCT1.pp + i) + end + mon.evs, mon.contest = {}, {} + for i, k in ipairs(L.EV_KEYS) do mon.evs[k] = u8(sec, e + i - 1) end + for i, k in ipairs(L.CONTEST_KEYS) do mon.contest[k] = u8(sec, e + 6 + i - 1) end + local S3 = L.SUBSTRUCT3 + mon.pokerus = u8(sec, m + S3.pokerus) + mon.metLocation = u8(sec, m + S3.metLocation) + local origins = u16(sec, m + S3.origins) + for _, f in ipairs(L.ORIGINS_BITS) do mon[f[1]] = bits(origins, f[2], f[3]) end + local ivw = u32(sec, m + S3.ivWord) + 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 + mon.abilityNum = bits(ivw, L.IV_ABILITY_BIT, 1) + local rib = u32(sec, m + S3.ribbons) + mon.ribbons = rib + mon.championRibbon = bits(rib, L.RIBBON_CHAMPION_BIT, 1) == 1 + mon.modernFatefulEncounter = bits(rib, L.RIBBON_FATEFUL_BIT, 1) == 1 + -- src/pokemon.c:2994 + if not mon.checksumOk then + mon.isBadEgg, mon.isEggFlag, mon.isEgg = true, true, true + end + return mon +end + +function Gen3Save.decodePartyMon(raw) + local mon = Gen3Save.decodeBoxMon(raw:sub(1, L.BOX_MON_SIZE)) + if not mon then return nil end + for _, f in ipairs(L.PARTY_EXTRA) do mon[f[1]] = readValue(raw, f[2], f[3]) end + return mon +end + +local Buf = {} +Buf.__index = Buf + +function Gen3Save.newBuf(n, template) + local b = setmetatable({ n = n, b = {} }, Buf) + if template then + for i = 1, math.min(n, #template) do b.b[i - 1] = template:byte(i) end + end + return b +end +local newBuf = Gen3Save.newBuf + +function Buf:w8(o, v) self.b[o] = v % 256 end +function Buf:w16(o, v) v = v % 65536; self.b[o] = v % 256; self.b[o + 1] = math.floor(v / 256) end +function Buf:w32(o, v) + v = v % U32 + for i = 0, 3 do self.b[o + i] = math.floor(v / 256 ^ i) % 256 end +end +function Buf:bytes(o, s) for i = 1, #s do self.b[o + i - 1] = s:byte(i) end end +function Buf:fill(o, n, v) for i = 0, n - 1 do self.b[o + i] = v end end +function Buf:str() + local t = {} + for i = 0, self.n - 1 do t[#t + 1] = string.char(self.b[i] or 0) end + return table.concat(t) +end + +local function writeValue(buf, off, kind, n, v) + if kind == "u8" or kind == "s8" then return buf:w8(off, v or 0) end + if kind == "u16" or kind == "s16" then return buf:w16(off, v or 0) end + if kind == "u32" then return buf:w32(off, v or 0) end + if kind == "str" then return buf:bytes(off, Gen3Save.encodeString(v, n)) end + if kind == "strFF" then return buf:bytes(off, Gen3Save.encodeString(v, n, 0xFF)) end + if kind == "warp" then + local w = v or L.EMPTY_WARP + for k, spec in pairs(L.WARP) do writeValue(buf, off + spec[1], spec[2], nil, w[k] or -1) end + return + end + if kind == "bits" then + buf:fill(off, n, 0) + for _, i in ipairs(v or {}) do + local o = off + math.floor(i / 8) + buf.b[o] = bor(buf.b[o] or 0, lshift(1, i % 8)) + end + return + end + error("unknown field kind " .. tostring(kind)) +end + +function Gen3Save.writeFields(buf, fields, t, key) + for _, f in ipairs(fields) do + local v = t[f[1]] + if f[5] then v = applyKey(v or 0, f[5], key) end + writeValue(buf, f[2], f[3], f[4], v) + end +end + +-- src/pokemon.c:2797 +function Gen3Save.encodeBoxMon(mon) + local B = L.BOX_MON + local sec = newBuf(L.BOX_MON.secureWords * 4) + local pos = L.SUBSTRUCT_ORDER[mon.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 + for _, f in ipairs(L.SUBSTRUCT0) do writeValue(sec, g + f[2], f[3], nil, mon[f[1]] or 0) end + for i = 1, 4 do + sec:w16(a + L.SUBSTRUCT1.moves + (i - 1) * 2, (mon.moves or {})[i] or 0) + sec:w8(a + L.SUBSTRUCT1.pp + i - 1, (mon.pp or {})[i] or 0) + end + local ev, c = mon.evs or {}, mon.contest or {} + for i, k in ipairs(L.EV_KEYS) do sec:w8(e + i - 1, ev[k] or 0) end + for i, k in ipairs(L.CONTEST_KEYS) do sec:w8(e + 6 + i - 1, c[k] or 0) end + local S3 = L.SUBSTRUCT3 + sec:w8(m + S3.pokerus, mon.pokerus or 0) + sec:w8(m + S3.metLocation, mon.metLocation or 0) + local origins = 0 + for _, f in ipairs(L.ORIGINS_BITS) do origins = origins + ((mon[f[1]] or 0) % 2 ^ f[3]) * 2 ^ f[2] end + sec:w16(m + S3.origins, origins) + local iv, ivw = mon.ivs or {}, 0 + for i, k in ipairs(L.IV_KEYS) do ivw = ivw + ((iv[k] or 0) % 32) * 2 ^ ((i - 1) * 5) end + if mon.isEgg then ivw = ivw + 2 ^ L.IV_EGG_BIT end + if mon.abilityNum == 1 then ivw = ivw + 2 ^ L.IV_ABILITY_BIT end + sec:w32(m + S3.ivWord, ivw) + sec:w32(m + S3.ribbons, mon.ribbons or 0) + local plain = sec:str() + local out = newBuf(L.BOX_MON_SIZE) + local otIdRaw = mon.otIdRaw or ((mon.otId or 0) + (mon.otSecretId or 0) * 65536) + out:w32(B.personality, mon.personality) + out:w32(B.otId, otIdRaw) + if mon.nicknameBytes then + out:fill(B.nickname, B.nicknameLength, 0xFF) + out:bytes(B.nickname, mon.nicknameBytes:sub(1, B.nicknameLength)) + else + out:bytes(B.nickname, Gen3Save.encodeString(mon.nickname, B.nicknameLength)) + end + out:w8(B.language, mon.language or 2) + local F = L.BOX_MON_FLAGS + local flags = band(mon.flagsRaw or 0, 0xF8) + + (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) + out:bytes(B.otName, Gen3Save.encodeString(mon.otName, B.otNameLength)) + out:w8(B.markings, mon.markings or 0) + out:w16(B.checksum, secureChecksum(plain)) + out:w16(B.unknown, mon.unknown or 0) + local key = bxor(mon.personality, otIdRaw) + for i = 0, B.secureWords - 1 do out:w32(B.secure + i * 4, tou32(bxor(u32(plain, i * 4), key))) end + return out:str() +end + +function Gen3Save.encodePartyMon(mon) + local b = newBuf(L.PARTY_MON_SIZE) + b:bytes(0, Gen3Save.encodeBoxMon(mon)) + for _, f in ipairs(L.PARTY_EXTRA) do writeValue(b, f[2], f[3], nil, mon[f[1]] or (f[1] == "mail" and 0xFF or 0)) end + return b:str() +end + +local function readItems(s, off, count, key) + local out = {} + for i = 0, count - 1 do + local id = u16(s, off + i * L.ITEM_SLOT_SIZE) + local q = u16(s, off + i * L.ITEM_SLOT_SIZE + 2) + if key then q = bxor(q, key % 65536) % 65536 end + if id ~= 0 then out[#out + 1] = { id = id, qty = q } end + end + return out +end + +local function writeItems(buf, off, count, list, key) + for i = 1, count do + local it = (list or {})[i] + local o = off + (i - 1) * L.ITEM_SLOT_SIZE + local qty = it and it.qty or 0 + buf:w16(o, it and it.id or 0) + buf:w16(o + 2, key and bxor(qty, key % 65536) % 65536 or qty) + end +end + +local function isMailItem(id) + return id >= L.MAIL_ITEM_FIRST and id <= L.MAIL_ITEM_LAST +end + +local function decodeMail(s, off) + local M = L.MAIL + local rec = { words = {} } + for i = 1, M.wordCount do rec.words[i] = u16(s, off + M.words + (i - 1) * 2) end + rec.playerName = Gen3Save.decodeString(s, off + M.playerName, M.playerNameLength):gsub(" +$", "") + rec.trainerIdRaw = u32(s, off + M.trainerId) + rec.trainerId = rec.trainerIdRaw % 65536 + rec.species = u16(s, off + M.species) + rec.itemId = u16(s, off + M.itemId) + return rec +end + +-- src/mail_data.c:18 +local function writeMail(buf, off, rec) + local M = L.MAIL + rec = type(rec) == "table" and rec or {} + local item = tonumber(rec.itemId) or 0 + local words = type(rec.words) == "table" and rec.words or {} + for i = 1, M.wordCount do buf:w16(off + M.words + (i - 1) * 2, tonumber(words[i]) or L.EC_WORD_UNDEFINED) end + buf:fill(off + M.playerName, M.playerNameLength, 0xFF) + if item ~= 0 then + local name = rec.playerName or "" + local n = 0 + for _ in tostring(name):gmatch("[^\128-\191]") do n = n + 1 end + -- src/mail_data.c:58 + if n < L.MAIL_NAME_PAD then name = name .. string.rep(" ", L.MAIL_NAME_PAD - n) end + buf:bytes(off + M.playerName, Gen3Save.encodeString(name, M.playerNameLength - 1, 0xFF)) + end + buf:w32(off + M.trainerId, item ~= 0 and (tonumber(rec.trainerIdRaw) or tonumber(rec.trainerId) or 0) or 0) + buf:w16(off + M.species, item ~= 0 and (tonumber(rec.species) or L.MAIL_CLEAR_SPECIES) or L.MAIL_CLEAR_SPECIES) + buf:w16(off + M.itemId, item) +end + +local function decodeDaycareMail(s, off) + local D = L.DAYCARE_MAIL + local message = decodeMail(s, off + D.off) + if message.itemId == 0 then return nil end + return { + message = message, + otName = Gen3Save.decodeString(s, off + D.off + D.otName, D.otNameLength), + monName = Gen3Save.decodeString(s, off + D.off + D.monName, D.monNameLength), + } +end + +-- src/daycare.c:425 +local function writeDaycareMail(buf, off, mail) + local D = L.DAYCARE_MAIL + local base = off + D.off + if type(mail) == "table" and type(mail.message) == "table" and (tonumber(mail.message.itemId) or 0) ~= 0 then + buf:fill(base + D.otName, D.otNameLength, 0) + buf:fill(base + D.monName, D.monNameLength, 0) + writeMail(buf, base, mail.message) + -- src/daycare.c:431 + buf:bytes(base + D.otName, Gen3Save.encodeString(mail.otName, D.otNameLength)) + buf:bytes(base + D.monName, Gen3Save.encodeString(mail.monName, D.monNameLength)) + else + buf:w16(base + L.MAIL.itemId, 0) + end +end + +local function decodeDaycareMon(sb1, off) + local D = L.DAYCARE + local mon = Gen3Save.decodeBoxMon(sb1:sub(off + 1, off + L.BOX_MON_SIZE)) + if mon and not mon.hasSpecies then mon = nil end + return mon, u32(sb1, off + D.stepsOff), mon and decodeDaycareMail(sb1, off) or nil +end + +-- src/hall_of_fame.c:32 +local function decodeHof(image) + local parts = {} + for _, id in ipairs({ L.SECTOR_ID_HOF_1, L.SECTOR_ID_HOF_2 }) do + local base = id * L.SECTOR_SIZE + if #image < base + L.SECTOR_SIZE then return {} end + if u32(image, base + L.FOOTER.signature) ~= L.SIGNATURE then return {} end + -- src/save.c:592 + if u16(image, base + L.FOOTER.id) ~= Gen3Save.checksum(image, base, L.SECTOR_DATA_SIZE) then return {} end + parts[#parts + 1] = image:sub(base + 1, base + L.SECTOR_DATA_SIZE) + end + local data, H, teams = table.concat(parts), L.HOF, {} + for t = 0, H.teams - 1 do + local team = {} + for i = 0, H.monsPerTeam - 1 do + local o = (t * H.monsPerTeam + i) * H.monSize + local sl = u16(data, o + H.speciesLevel) + local species = sl % 512 + if species == 0 then break end + team[#team + 1] = { + tid = u32(data, o + H.tid), + personality = u32(data, o + H.personality), + species = species, + level = math.floor(sl / 512), + nickname = Gen3Save.decodeString(data, o + H.nick, H.nickLength), + } + end + if #team == 0 then break end + teams[#teams + 1] = team + end + return teams +end + +-- src/hall_of_fame.c:32 +function Gen3Save.encodeHof(teams) + local H = L.HOF + local b = newBuf(L.HOF_SECTORS * L.SECTOR_DATA_SIZE) + b:fill(0, b.n, 0) + for t, team in ipairs(teams or {}) do + if t > H.teams then break end + for i, m in ipairs(team) do + if i > H.monsPerTeam then break end + local o = ((t - 1) * H.monsPerTeam + i - 1) * H.monSize + b:w32(o + H.tid, m.tid or 0) + b:w32(o + H.personality, m.personality or 0) + b:w16(o + H.speciesLevel, (m.species or 0) % 512 + ((m.level or 0) % 128) * 512) + b:bytes(o + H.nick, Gen3Save.encodeString(m.nickname, H.nickLength)) + end + end + return b:str() +end + +function Gen3Save.decode(bytes) + local blocks, why = Gen3Save.readBlocks(bytes) + if not blocks then return nil, why end + local sb2, sb1, st = blocks.sb2, blocks.sb1, blocks.storage + -- src/new_game.c:121 + if u32(sb2, 0x0AC) ~= 1 then return nil, "notFrlg" end + if u16(sb2, 0x006) == 0 then return nil, "japanese" end + local key = u32(sb2, 0xF20) + local out = { counter = blocks.counter, slot = blocks.slot, olderSlot = blocks.olderSlot } + Gen3Save.readFields(sb2, L.SB2, out, key) + out.options = {} + 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.party = {} + for i = 0, math.min(out.partyCount, L.PARTY_SIZE) - 1 do + local o = L.PARTY_OFFSET + i * L.PARTY_MON_SIZE + out.party[#out.party + 1] = Gen3Save.decodePartyMon(sb1:sub(o + 1, o + L.PARTY_MON_SIZE)) + end + out.pcItems = readItems(sb1, L.PC_ITEMS.off, L.PC_ITEMS.count, nil) + out.pockets = {} + -- src/load_save.c:286 + for _, p in ipairs(L.POCKETS) do out.pockets[p.key] = readItems(sb1, p.off, p.count, key) end + local OE, E = L.OBJECT_EVENTS, L.OBJECT_EVENT + for i = 0, OE.count - 1 do + local o = OE.off + i * OE.size + if band(u8(sb1, o + E.flags), 1) == 1 and band(u8(sb1, o + E.isPlayerByte), 1) == 1 then + out.player = { + facing = band(u8(sb1, o + E.facing), 15), + graphicsId = u8(sb1, o + E.graphicsId), + x = s16(sb1, o + E.currentX) - L.MAP_OFFSET, + y = s16(sb1, o + E.currentY) - L.MAP_OFFSET, + } + break + end + end + out.vars = {} + for i = 0, L.VARS.count - 1 do + local v = u16(sb1, L.VARS.off + i * 2) + if v ~= 0 then out.vars[L.VARS.first + i] = v end + end + out.gameStats = {} + for i = 0, L.GAME_STATS.count - 1 do + -- src/overworld.c:384 + local v = tou32(bxor(u32(sb1, L.GAME_STATS.off + i * 4), key)) + if v ~= 0 then out.gameStats[i] = v end + end + out.easyChatProfile = {} + for i = 0, L.EASY_CHAT_PROFILE.count - 1 do + out.easyChatProfile[i + 1] = u16(sb1, L.EASY_CHAT_PROFILE.off + i * 2) + end + local EB = L.EASY_CHAT_BATTLE + out.easyChatBattle = {} + for _, k in ipairs({ "start", "won", "lost" }) do + out.easyChatBattle[k] = {} + for i = 0, EB.count - 1 do out.easyChatBattle[k][i + 1] = u16(sb1, EB[k] + i * 2) end + end + local M = L.MAIL + out.mail = {} + for i = 0, M.count - 1 do out.mail[i + 1] = decodeMail(sb1, M.off + i * M.size) end + local FC = L.FAME_CHECKER + out.fameChecker = {} + for i = 0, FC.count - 1 do + local w = u16(sb1, FC.off + i * 2) + out.fameChecker[i + 1] = { pickState = bits(w, 0, FC.pickBits), flavorTextFlags = bits(w, FC.flavorShift, FC.flavorBits), + unk = bits(w, FC.unkShift, 2) } + end + local RT = L.REGISTERED_TEXTS + out.registeredTexts = {} + for i = 0, RT.count - 1 do out.registeredTexts[i + 1] = Gen3Save.decodeString(sb1, RT.off + i * RT.size, RT.size) end + local TT = L.TRAINER_TOWER + out.trainerTowerBest = {} + for i = 0, TT.count - 1 do + -- src/trainer_tower.c:1082 + out.trainerTowerBest[i + 1] = tou32(bxor(u32(sb1, TT.off + i * TT.size + TT.bestTime), key)) + end + local D = L.DAYCARE + out.daycare = { mons = {}, steps = {}, mail = {} } + for i = 0, 1 do + local mon, steps, mail = decodeDaycareMon(sb1, D.off + i * D.monSize) + out.daycare.mons[i + 1] = mon + out.daycare.steps[i + 1] = steps + out.daycare.mail[i + 1] = mail + end + out.daycare.offspringPersonality = u16(sb1, D.off + D.offspringPersonality) + out.daycare.stepCounter = u8(sb1, D.off + D.stepCounter) + local r5mon, r5steps, r5mail = decodeDaycareMon(sb1, L.ROUTE5_DAYCARE) + out.route5Daycare = { mon = r5mon, steps = r5steps, mail = r5mail } + local roamer = {} + for _, f in ipairs(L.ROAMER) do roamer[f[1]] = readValue(sb1, L.ROAMER_OFFSET + f[2], f[3]) end + out.roamer = roamer + local S = L.STORAGE + out.storage = { currentBox = u8(st, S.currentBox), boxes = {} } + for bx = 0, S.totalBoxes - 1 do + local box = { + name = Gen3Save.decodeString(st, S.boxNames + bx * S.boxNameLength, S.boxNameLength), + wallpaper = u8(st, S.wallpapers + bx), + mons = {}, + } + for sl = 0, S.inBox - 1 do + local o = S.boxes + (bx * S.inBox + sl) * L.BOX_MON_SIZE + local mon = Gen3Save.decodeBoxMon(st:sub(o + 1, o + L.BOX_MON_SIZE)) + if mon and mon.hasSpecies then box.mons[sl + 1] = mon end + end + out.storage.boxes[bx + 1] = box + end + out.hallOfFame = decodeHof(blocks.image) + return out, blocks +end + +function Gen3Save.encodeBlocks(t, template) + template = template or {} + local key = t.encryptionKey or 0 + local sb2 = newBuf(L.BLOCKS[1].size, template.sb2) + local o = t.options or {} + local word = 0 + for _, f in ipairs(L.OPTIONS_BITS) do word = word + ((o[f[1]] or 0) % 2 ^ f[3]) * 2 ^ f[2] end + local fields = {} + for k, v in pairs(t) do fields[k] = v end + fields.optionsWord = word + fields.buttonMode = o.buttonMode or t.buttonMode or 0 + fields.frlgMarker = 1 + fields.dexUnused = t.dexUnused or 0xDA + Gen3Save.writeFields(sb2, L.SB2, fields, key) + if t.clearLinkRecords then + local LR = L.LINK_BATTLE_RECORDS + -- src/battle_records.c:274 + for i = 0, LR.count - 1 do + sb2:fill(LR.off + i * LR.size, LR.size, 0) + sb2:w8(LR.off + i * LR.size, 0xFF) + end + end + + local sb1 = newBuf(L.BLOCKS[2].size, template.sb1) + fields.partyCount = #(t.party or {}) + Gen3Save.writeFields(sb1, L.SB1, fields, key) + 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 + sb1:w8(L.PARTY_OFFSET + i * L.PARTY_MON_SIZE + L.PARTY_MAIL, 0xFF) + end + for i, mon in ipairs(t.party or {}) do + sb1:bytes(L.PARTY_OFFSET + (i - 1) * L.PARTY_MON_SIZE, Gen3Save.encodePartyMon(mon)) + end + writeItems(sb1, L.PC_ITEMS.off, L.PC_ITEMS.count, t.pcItems, nil) + for _, p in ipairs(L.POCKETS) do writeItems(sb1, p.off, p.count, (t.pockets or {})[p.key], key) end + for i = 0, L.VARS.count - 1 do sb1:w16(L.VARS.off + i * 2, (t.vars or {})[L.VARS.first + i] or 0) end + for i = 0, L.GAME_STATS.count - 1 do + sb1:w32(L.GAME_STATS.off + i * 4, bxor((t.gameStats or {})[i] or 0, key)) + end + for i = 0, L.EASY_CHAT_PROFILE.count - 1 do + sb1:w16(L.EASY_CHAT_PROFILE.off + i * 2, (t.easyChatProfile or {})[i + 1] or 0) + end + local EB = L.EASY_CHAT_BATTLE + if t.easyChatBattle then + for _, k in ipairs({ "start", "won", "lost" }) do + local list = t.easyChatBattle[k] or {} + for i = 0, EB.count - 1 do sb1:w16(EB[k] + i * 2, list[i + 1] or L.EC_WORD_UNDEFINED) end + end + end + local M = L.MAIL + if t.mail then + for i = 0, M.count - 1 do writeMail(sb1, M.off + i * M.size, t.mail[i + 1]) end + end + local FC = L.FAME_CHECKER + if t.fameChecker then + for i = 0, FC.count - 1 do + local r = t.fameChecker[i + 1] or {} + sb1:w16(FC.off + i * 2, ((r.pickState or 0) % 2 ^ FC.pickBits) + ((r.flavorTextFlags or 0) % 2 ^ FC.flavorBits) * 2 ^ FC.flavorShift + + ((r.unk or 0) % 4) * 2 ^ FC.unkShift) + end + end + local RT = L.REGISTERED_TEXTS + if t.registeredTexts then + for i = 0, RT.count - 1 do + sb1:fill(RT.off + i * RT.size, RT.size, 0) + sb1:bytes(RT.off + i * RT.size, Gen3Save.encodeString(t.registeredTexts[i + 1] or "", RT.size - 1)) + end + end + local TT = L.TRAINER_TOWER + if t.trainerTowerBest then + for i = 0, TT.count - 1 do + -- src/trainer_tower.c:1082 + sb1:w32(TT.off + i * TT.size + TT.bestTime, bxor(t.trainerTowerBest[i + 1] or L.TRAINER_TOWER_MAX_TIME, key)) + end + end + local D = L.DAYCARE + local dc = t.daycare or { mons = {}, steps = {} } + for i = 0, 1 do + local off = D.off + i * D.monSize + local mon = dc.mons and dc.mons[i + 1] + if mon then sb1:bytes(off, Gen3Save.encodeBoxMon(mon)) else sb1:fill(off, L.BOX_MON_SIZE, 0) end + writeDaycareMail(sb1, off, mon and (dc.mail or {})[i + 1] or nil) + sb1:w32(off + D.stepsOff, (dc.steps or {})[i + 1] or 0) + end + sb1:w16(D.off + D.offspringPersonality, dc.offspringPersonality or 0) + sb1:w8(D.off + D.stepCounter, dc.stepCounter or 0) + local r5 = t.route5Daycare or {} + if r5.mon then + sb1:bytes(L.ROUTE5_DAYCARE, Gen3Save.encodeBoxMon(r5.mon)) + else + sb1:fill(L.ROUTE5_DAYCARE, L.BOX_MON_SIZE, 0) + end + writeDaycareMail(sb1, L.ROUTE5_DAYCARE, r5.mon and r5.mail or nil) + sb1:w32(L.ROUTE5_DAYCARE + D.stepsOff, r5.steps or 0) + if t.roamer then + for _, f in ipairs(L.ROAMER) do writeValue(sb1, L.ROAMER_OFFSET + f[2], f[3], nil, t.roamer[f[1]] or 0) end + end + + local S = L.STORAGE + local st = newBuf(L.BLOCKS[3].size, template.storage) + local stg = t.storage or { currentBox = 0, boxes = {} } + st:w8(S.currentBox, stg.currentBox or 0) + st:fill(S.boxes, S.totalBoxes * S.inBox * L.BOX_MON_SIZE, 0) + for bx = 1, S.totalBoxes do + local box = (stg.boxes or {})[bx] or {} + st:bytes(S.boxNames + (bx - 1) * S.boxNameLength, + Gen3Save.encodeString(box.name or L.DEFAULT_BOX_NAME:format(bx), S.boxNameLength)) + st:w8(S.wallpapers + bx - 1, box.wallpaper or ((bx - 1) % L.DEFAULT_WALLPAPER_MOD)) + for sl, mon in pairs(box.mons or {}) do + st:bytes(S.boxes + ((bx - 1) * S.inBox + sl - 1) * L.BOX_MON_SIZE, Gen3Save.encodeBoxMon(mon)) + end + end + return { sb2 = sb2:str(), sb1 = sb1:str(), storage = st:str() } +end + +local function footer(id, checksum, counter) + local f = newBuf(12) + f:w16(0, id) + f:w16(2, checksum) + f:w32(4, L.SIGNATURE) + f:w32(8, counter) + return f:str() +end + +-- src/save.c:174 +function Gen3Save.buildFlash(blocks, opts) + opts = opts or {} + local chunks = {} + for _, blk in ipairs(L.BLOCKS) do + local off = 0 + for id = blk.first, blk.last do + chunks[id] = blocks[blk.key]:sub(off + 1, off + L.CHUNK_SIZES[id]) + off = off + L.CHUNK_SIZES[id] + end + end + local counter = opts.counter or 1 + local slot = counter % L.NUM_SLOTS + local rot = opts.rotation or (counter % L.SECTORS_PER_SLOT) + local flash = {} + local base = opts.image + for i = 0, L.NUM_SECTORS - 1 do + flash[i] = base and base:sub(i * L.SECTOR_SIZE + 1, (i + 1) * L.SECTOR_SIZE) or string.rep("\255", L.SECTOR_SIZE) + if #flash[i] < L.SECTOR_SIZE then flash[i] = flash[i] .. string.rep("\255", L.SECTOR_SIZE - #flash[i]) end + end + for id = 0, L.SECTORS_PER_SLOT - 1 do + local data = chunks[id] + local padded = data .. string.rep("\0", L.SECTOR_DATA_SIZE - #data) + local ck = Gen3Save.checksum(padded, 0, L.CHUNK_SIZES[id]) + local phys = slot * L.SECTORS_PER_SLOT + (rot + id) % L.SECTORS_PER_SLOT + flash[phys] = padded .. string.rep("\0", L.FOOTER.id - L.SECTOR_DATA_SIZE) .. footer(id, ck, counter) + end + if opts.hof then + for i = 0, L.HOF_SECTORS - 1 do + local data = opts.hof:sub(i * L.SECTOR_DATA_SIZE + 1, (i + 1) * L.SECTOR_DATA_SIZE) + -- src/save.c:210 + flash[L.SECTOR_ID_HOF_1 + i] = data .. string.rep("\0", L.FOOTER.id - L.SECTOR_DATA_SIZE) + .. footer(Gen3Save.checksum(data, 0, L.SECTOR_DATA_SIZE), 0, 0) + end + end + local out = {} + for i = 0, L.NUM_SECTORS - 1 do out[#out + 1] = flash[i] end + return table.concat(out) +end + +function Gen3Save.encode(t, opts) + opts = opts or {} + return Gen3Save.buildFlash(Gen3Save.encodeBlocks(t, opts.template), opts) +end + +function Gen3Save.message(why, n) + if why == "size" then return Gen3Save.MSG.size:format(n or 0) end + return Gen3Save.MSG[why] or Gen3Save.MSG.corrupt +end + +local function mapFor(group, num) + return require("src.import.gba.map_catalog").mapIdFor(group, num) +end + +local function portWarp(w) + if type(w) ~= "table" or w.group < 0 or w.num < 0 then return nil end + local map = mapFor(w.group, w.num) + if not map then return nil end + return { map = map, warpId = w.warpId, x = w.x, y = w.y } +end + +local function portStatus(raw) + raw = raw or 0 + local sleep = band(raw, L.STATUS.sleepMask) + if sleep ~= 0 then return "SLP", sleep end + for _, k in ipairs(L.STATUS_ORDER) do + if band(raw, L.STATUS[k]) ~= 0 then return k, 0 end + end + return nil, 0 +end + +function Gen3Save.toPortMon(c, party) + local moves, pp = {}, {} + for i = 1, 4 do + if (c.moves[i] or 0) ~= 0 then + moves[#moves + 1] = c.moves[i] + pp[#pp + 1] = c.pp[i] + end + end + local mon = { + species = c.species, + speciesId = c.species, + speciesNumbering = "internal", + personality = c.personality, + nature = c.personality % 25, + otId = c.otId, + otSecretId = c.otSecretId, + ot = c.otName, + otName = c.otName, + otGender = c.otGender, + nickname = c.nickname, + language = c.language, + isEgg = c.isEgg or nil, + isBadEgg = c.isBadEgg or nil, + markings = c.markings, + item = c.heldItem ~= 0 and c.heldItem or nil, + heldItem = c.heldItem, + exp = c.exp, + friendship = c.friendship, + happiness = c.friendship, + ppBonusesPacked = c.ppBonuses, + moves = moves, + pp = pp, + evs = c.evs, + ivs = c.ivs, + pokerus = c.pokerus, + metLocation = c.metLocation, + metLevel = c.metLevel, + metGame = c.metGame, + pokeball = c.pokeball, + abilityNum = c.abilityNum, + cartExtra = { + contest = c.contest, + ribbons = c.ribbons, + championRibbon = c.championRibbon or nil, + modernFatefulEncounter = c.modernFatefulEncounter or nil, + flagsRaw = c.flagsRaw, + unknown = c.unknown, + growthFiller = c.growthFiller, + }, + cartImport = true, + } + if c.isEgg then + mon.nickname, mon.name = "EGG", "EGG" + mon.eggCycles = c.friendship + end + if c.nicknameBytes then + mon.cartExtra.nicknameBytes = { c.nicknameBytes:byte(1, -1) } + end + if party then + mon.level = c.level + mon.hp = c.hp + mon.status, mon.sleep = portStatus(c.status) + mon.maxHp, mon.attack, mon.defense = c.maxHp, c.attack, c.defense + mon.speed, mon.spAtk, mon.spDef = c.speed, c.spAtk, c.spDef + if c.mail < L.MAIL.count then mon.mail = c.mail end + end + return mon +end + +local function portBoxName(name, b) + if name == L.DEFAULT_BOX_NAME:format(b) then return string.format("BOX %d", b) end + return name +end + +local function portWallpaper(w, b) + if w == (b - 1) % L.DEFAULT_WALLPAPER_MOD then return ((b - 1) % 16) + 1 end + return w + 1 +end + +local function has(list, v) + for _, x in ipairs(list) do if x == v then return true end end + return false +end + +local function nationalList(bitsList) + local out = {} + for _, i in ipairs(bitsList) do out[#out + 1] = i + 1 end + return out +end + +function Gen3Save.toPortSave(c, version) + local flags = {} + for _, id in ipairs(c.flags) do + if id > L.TEMP_FLAGS_END then + flags[id] = true + flags[tostring(id)] = true + end + end + local vars = {} + for id, v in pairs(c.vars) do vars[id] = v end + local pockets = {} + for _, p in ipairs(L.POCKETS) do pockets[p.key] = c.pockets[p.key] end + local boxes = {} + for b, box in ipairs(c.storage.boxes) do + local mons = {} + for sl, mon in pairs(box.mons) do mons[sl] = Gen3Save.toPortMon(mon, false) end + boxes[b] = { name = portBoxName(box.name, b), wallpaper = portWallpaper(box.wallpaper, b), mons = mons } + end + local party = {} + for i, mon in ipairs(c.party) do party[i] = Gen3Save.toPortMon(mon, true) end + local seenCount = {} + for _, list in ipairs({ c.dexSeen, c.dexSeen1, c.dexSeen2 }) do + for _, i in ipairs(list) do seenCount[i] = (seenCount[i] or 0) + 1 end + end + local seenList, ownedList = {}, {} + -- src/pokedex_screen.c:2243 + for i, n in pairs(seenCount) do if n == 3 then seenList[#seenList + 1] = i end end + table.sort(seenList) + -- src/pokedex_screen.c:2252 + for _, i in ipairs(c.dexOwned) do if seenCount[i] == 3 then ownedList[#ownedList + 1] = i end end + local N = L.NATIONAL_DEX + local national = c.dexNationalMagic == N.magic and c.vars[N.var] == N.varValue and has(c.flags, N.flag) + local mail + for i, rec in ipairs(c.mail or {}) do + if rec.itemId ~= 0 then + mail = mail or {} + mail[i] = { words = rec.words, playerName = rec.playerName, trainerId = rec.trainerId, species = rec.species, + itemId = rec.itemId, design = isMailItem(rec.itemId) and rec.itemId - L.MAIL_ITEM_FIRST or nil } + end + end + if mail then + for i = 1, L.MAIL.count do + mail[i] = mail[i] or { words = {}, playerName = "", trainerId = 0, species = L.MAIL_CLEAR_SPECIES, itemId = 0 } + end + end + local function portDaycareMail(m) + if not m then return nil end + local r = m.message + return { otName = m.otName, monName = m.monName, message = { words = r.words, playerName = r.playerName, + trainerId = r.trainerId, species = r.species, itemId = r.itemId, + design = isMailItem(r.itemId) and r.itemId - L.MAIL_ITEM_FIRST or nil } } + end + for _, mon in ipairs(party) do + if mon.mail and not (mail and mail[mon.mail + 1] and mail[mon.mail + 1].itemId ~= 0) then mon.mail = nil end + end + local fame = {} + for i, r in ipairs(c.fameChecker or {}) do fame[i] = { pickState = r.pickState, flavorTextFlags = r.flavorTextFlags } end + local tower + if c.trainerTowerBest then + tower = { challengeId = 0, records = {} } + for i, t in ipairs(c.trainerTowerBest) do + tower.records[i] = { bestTime = math.min(t, L.TRAINER_TOWER_MAX_TIME) } + end + end + local map = mapFor(c.location.group, c.location.num) + local facing = c.player and L.FACING[c.player.facing] or "down" + local hofTime = c.gameStats[L.GAME_STAT_FIRST_HOF_PLAY_TIME] + local hofTeams = {} + for _, team in ipairs(c.hallOfFame or {}) do + local rec = {} + for _, m in ipairs(team) do + rec[#rec + 1] = { + species = m.species, level = m.level, nickname = m.nickname, + trainerId = m.tid % 65536, otSecretId = math.floor(m.tid / 65536), personality = m.personality, + } + end + hofTeams[#hofTeams + 1] = rec + end + local daycare = { steps = { c.daycare.steps[1], c.daycare.steps[2] }, + offspringPersonality = c.daycare.offspringPersonality, stepCounter = c.daycare.stepCounter } + for i = 1, 2 do + if c.daycare.mons[i] then daycare[i] = Gen3Save.toPortMon(c.daycare.mons[i], false) end + local dm = portDaycareMail((c.daycare.mail or {})[i]) + if dm then + daycare.mail = daycare.mail or {} + daycare.mail[i] = dm + end + end + local route5 = { steps = c.route5Daycare.steps, mail = portDaycareMail(c.route5Daycare.mail) } + if c.route5Daycare.mon then route5.mon = Gen3Save.toPortMon(c.route5Daycare.mon, false) end + local profile = {} + for i = 1, 4 do profile[i] = c.easyChatProfile[i] end + local save = { + schemaVersion = 1, + engine = "game3", + version = version, + generation = 3, + name = c.name, + rivalName = c.rivalName, + gender = c.gender, + money = c.money, + coins = c.coins, + berryPowder = c.berryPowder, + party = party, + bag = { pockets = pockets }, + dex = { seen = {}, owned = {}, caught = {}, national = national, nationalUnlocked = national or nil, + unownPersonality = c.unownPersonality, spindaPersonality = c.spindaPersonality }, + map = map, + x = c.posX, + y = c.posY, + facing = facing, + flags = flags, + vars = vars, + playTime = { hours = c.playHours, minutes = c.playMinutes, seconds = c.playSeconds, vblanks = c.playVBlanks }, + easyChatProfile = profile, + options = { + textSpeed = c.options.textSpeed, frameType = c.options.frameType, sound = c.options.sound, + battleStyle = c.options.battleStyle, battleScene = c.options.battleScene, buttonMode = c.options.buttonMode, + }, + storage = { currentBox = math.min(c.storage.currentBox, L.STORAGE.totalBoxes - 1) + 1, boxes = boxes, items = c.pcItems }, + mail = mail, + registeredTexts = c.registeredTexts, + registeredItem = c.registeredItem ~= 0 and c.registeredItem or nil, + dynamicWarp = portWarp(c.dynamicWarp), + escapeWarp = portWarp(c.escapeWarp), + continueGameWarp = portWarp(c.continueGameWarp), + specialSaveWarpFlags = c.specialSaveWarpFlags, + gcnLinkFlags = c.gcnLinkFlags, + flashLevel = c.flashLevel, + trainerId = c.trainerId, + secretId = c.secretId, + gameStats = c.gameStats, + game_cleared = has(c.flags, L.FLAG_SYS_GAME_CLEAR), + hallOfFameTeams = hofTeams, + hasHallOfFameRecords = #hofTeams > 0, + modData = { + firered_daycare = { daycare = daycare, route5Daycare = route5 }, + fameChecker = #fame > 0 and fame or nil, + trainerTower = tower, + cartImport = { + dexSeen = nationalList(seenList), + dexOwned = nationalList(ownedList), + lastHealLocation = portWarp(c.lastHealLocation), + }, + }, + meta = { mods = {} }, + } + if hofTime and hofTime ~= 0 then + save.hofDebutHours = math.floor(hofTime / 65536) + save.hofDebutMinutes = math.floor(hofTime / 256) % 256 + save.hofDebutSeconds = hofTime % 256 + save.hofDebutTime = string.format("%d:%02d:%02d", save.hofDebutHours, save.hofDebutMinutes, save.hofDebutSeconds) + end + return save +end + +function Gen3Save.importPort(bytes, version) + if type(bytes) ~= "string" then return nil, Gen3Save.message("size", 0) end + local cart, why = Gen3Save.decode(bytes) + if not cart then return nil, Gen3Save.message(why, #bytes) end + if not mapFor(cart.location.group, cart.location.num) then + return nil, Gen3Save.MSG.corrupt + end + return Gen3Save.toPortSave(cart, version), nil, cart.olderSlot and Gen3Save.MSG.olderSlot or nil +end + +local function num(v, d) + return tonumber(v) or d +end + +local function clamp(v, lo, hi) + return math.max(lo, math.min(hi, v)) +end + +local function trunc(s, n) + s = type(s) == "string" and s or "" + local out, i, count = {}, 1, 0 + while i <= #s and count < n do + local hex = s:match("^{%x%x}", i) + local len + if hex then + len = 4 + else + local c = s:byte(i) + len = c < 0x80 and 1 or c < 0xE0 and 2 or c < 0xF0 and 3 or 4 + end + out[#out + 1] = s:sub(i, i + len - 1) + i, count = i + len, count + 1 + end + return table.concat(out) +end + +function Gen3Save.cartWarp(w) + if type(w) ~= "table" or type(w.map) ~= "string" then return nil end + local key = require("src.import.gba.map_catalog").slotKeyFor(w.map) + local g, n = (key or ""):match("^(%d+)_(%d+)$") + if not g then return nil end + return { group = tonumber(g), num = tonumber(n), warpId = num(w.warpId, -1), x = num(w.x, -1), y = num(w.y, -1) } +end + +local function cartStatus(mon) + local s = mon.status + if type(s) == "number" then return s % U32 end + if s == "SLP" then return clamp(num(mon.sleep, 1), 1, L.STATUS.sleepMask) end + for _, k in ipairs(L.STATUS_ORDER) do + if s == k then return L.STATUS[k] end + end + return 0 +end + +local function statTable(v, keys, width) + local out = {} + if type(v) == "number" then + for i, k in ipairs(keys) do out[k] = bits(v, (i - 1) * 5, 5) end + return out + end + v = type(v) == "table" and v or {} + for _, k in ipairs(keys) do out[k] = clamp(num(v[k], 0), 0, width) end + return out +end + +local FLAG_NAMES +local function flagTables() + if FLAG_NAMES == nil then + local ok, T = pcall(require, "src.core.game3.scripting.flags_table") + FLAG_NAMES = ok and T or false + end + return FLAG_NAMES or {} +end + +local function resolveId(k, names, prefix) + if type(k) == "number" then return k end + if type(k) ~= "string" then return nil end + local n = tonumber(k) + if n then return n end + names = names or {} + return names[k] or names[prefix .. k] +end + +function Gen3Save.fromPortMon(mon, ctx, party) + ctx = ctx or {} + local x = type(mon.cartExtra) == "table" and mon.cartExtra or {} + local species = num(mon.species or mon.speciesId, 0) + if mon.speciesNumbering == "national" then + species = (ctx.speciesFromNational and ctx.speciesFromNational(species)) or 0 + end + local moves, pp, portPp = {}, {}, type(mon.pp) == "table" and mon.pp or {} + for i = 1, 4 do + local m = type(mon.moves) == "table" and mon.moves[i] or nil + if type(m) == "table" then + moves[i] = num(m.moveId or m.id or m.move, 0) + pp[i] = num(m.pp or portPp[i], 0) + else + moves[i] = num(m, 0) + pp[i] = num(portPp[i], 0) + end + if moves[i] == 0 then pp[i] = 0 end + end + local tid = num(ctx.trainerId, 0) + local otId = num(mon.otId, tid) % 65536 + local sid = num(mon.otSecretId, nil) + if sid == nil then sid = otId == tid and num(ctx.secretId, 0) or 0 end + local isEgg = mon.isEgg == true + -- src/daycare.c:1098 + local language = num(mon.language, isEgg and L.LANGUAGE_JAPANESE or L.LANGUAGE_ENGLISH) + local nicknameBytes + if type(x.nicknameBytes) == "table" then + nicknameBytes = string.char(unpack(x.nicknameBytes)) + elseif isEgg and language == L.LANGUAGE_JAPANESE then + nicknameBytes = L.EGG_NICKNAME + end + local nickname = mon.nickname + if type(nickname) ~= "string" or nickname == "" then + nickname = mon.name or (ctx.speciesName and ctx.speciesName(species)) or "" + end + local personality = num(mon.personality, 0) % U32 + local item = mon.item or mon.heldItem + local c = { + personality = personality, + otIdRaw = otId + (sid % 65536) * 65536, + nickname = trunc(nickname, L.POKEMON_NAME_LENGTH), + nicknameBytes = nicknameBytes, + language = language, + flagsRaw = num(x.flagsRaw, 0), + isBadEgg = mon.isBadEgg == true, + isEgg = isEgg, + isEggFlag = isEgg, + otName = trunc(mon.otName or mon.ot or ctx.playerName, L.PLAYER_NAME_LENGTH), + markings = num(mon.markings, 0), + unknown = num(x.unknown, 0), + species = species, + heldItem = (ctx.itemId and item ~= nil and ctx.itemId(item)) or num(item, 0), + exp = num(mon.exp, 0), + ppBonuses = num(mon.ppBonusesPacked, 0), + friendship = isEgg and num(mon.eggCycles, num(mon.friendship, 0)) or num(mon.friendship or mon.happiness, 0), + growthFiller = num(x.growthFiller, 0), + moves = moves, + pp = pp, + evs = statTable(mon.evs, L.EV_KEYS, 255), + contest = statTable(x.contest, L.CONTEST_KEYS, 255), + pokerus = num(mon.pokerus, 0), + metLocation = num(mon.metLocation, 0), + metLevel = num(mon.metLevel, isEgg and 0 or num(mon.level, 0)), + metGame = num(mon.metGame, ctx.metGame or 0), + pokeball = num(mon.pokeball, 4), + otGender = num(mon.otGender, ctx.gender or 0), + ivs = statTable(mon.ivs, L.IV_KEYS, 31), + -- src/pokemon.c:1857 + abilityNum = num(mon.abilityNum, personality % 2), + ribbons = num(x.ribbons, 0), + } + if x.ribbons == nil then + if x.championRibbon then c.ribbons = c.ribbons + 2 ^ L.RIBBON_CHAMPION_BIT end + if x.modernFatefulEncounter then c.ribbons = c.ribbons + 2 ^ L.RIBBON_FATEFUL_BIT end + end + if party then + local maxHp = num(mon.maxHp or (type(mon.stats) == "table" and mon.stats.hp), 0) + c.status = cartStatus(mon) + c.level = clamp(num(mon.level, 1), 1, 100) + c.mail = ctx.mailIndex and ctx.mailIndex(mon, c) or L.MAIL_NONE + c.maxHp = maxHp + c.hp = clamp(num(mon.hp, maxHp), 0, maxHp) + c.attack, c.defense = num(mon.attack, 0), num(mon.defense, 0) + c.speed, c.spAtk, c.spDef = num(mon.speed, 0), num(mon.spAtk, 0), num(mon.spDef, 0) + end + return c +end + +local function freshCart() + return { + options = {}, + party = {}, + pcItems = {}, + pockets = {}, + flags = {}, + vars = {}, + gameStats = {}, + easyChatProfile = {}, + daycare = { mons = {}, steps = { 0, 0 }, offspringPersonality = 0, stepCounter = 0 }, + route5Daycare = { steps = 0 }, + storage = { currentBox = 0, boxes = {} }, + hallOfFame = {}, + } +end + +local function itemList(list, count, ctx) + local out = {} + for _, it in ipairs(type(list) == "table" and list or {}) do + if #out >= count then break end + local id = type(it) == "table" and it.id + local qty = type(it) == "table" and num(it.qty, 0) or 0 + id = id ~= nil and ((ctx.itemId and ctx.itemId(id)) or num(id, 0)) or 0 + if id ~= 0 and qty > 0 then out[#out + 1] = { id = id, qty = clamp(qty, 0, 65535) } end + end + return out +end + +local function cartBoxName(name, b) + if name == nil or name == string.format("BOX %d", b) then return L.DEFAULT_BOX_NAME:format(b) end + return trunc(name, L.BOX_NAME_LENGTH) +end + +local function cartWallpaper(w, b) + w = num(w, nil) + if w == nil or w == ((b - 1) % 16) + 1 then return (b - 1) % L.DEFAULT_WALLPAPER_MOD end + return clamp(w - 1, 0, 15) +end + +local function portOptions(save, version) + local o = type(save.options) == "table" and save.options or {} + local ok, Profile = pcall(require, "src.core.game3.profile") + local block = ok and version and Profile.of(version).optionsBlock + if block and type(o[block]) == "table" then return o[block] end + return o +end + +local function bitList(set) + local out = {} + for i in pairs(set) do out[#out + 1] = i end + table.sort(out) + return out +end + +Gen3Save.MSG.noMap = "That save is on a map the cartridge does not have." +Gen3Save.MSG.noData = "Import this game's ROM again before exporting a cartridge save." + +function Gen3Save.ownerOf(bytes) + local blocks = Gen3Save.readBlocks(bytes) + if not blocks then return nil end + return Gen3Save.readFields(blocks.sb2, { L.SB2[1], L.SB2[4], L.SB2[5] }, {}) +end + +function Gen3Save.templateBelongs(c, save) + if type(c) ~= "table" or type(save) ~= "table" then return false end + return c.trainerId == num(save.trainerId, -1) % 65536 + and c.secretId == num(save.secretId, -1) % 65536 + and c.name == trunc(save.name, L.PLAYER_NAME_LENGTH) +end + +local function mailSpecies(mon) + local ok, Mail = pcall(require, "src.core.game3.mail") + local species = num(mon.species or mon.speciesId, 0) + if ok and Mail.speciesToMailSpecies then return Mail.speciesToMailSpecies(species, mon.personality) end + return species +end + +function Gen3Save.fromPortSave(save, opts) + opts = opts or {} + local c, blocks + if type(opts.template) == "string" then c, blocks = Gen3Save.decode(opts.template) end + if c and not Gen3Save.templateBelongs(c, save) then c, blocks = nil, nil end + if not c then c, blocks = freshCart(), nil end + if not opts.toNational then return nil, Gen3Save.MSG.noData end + local tid, sid = num(save.trainerId, 0) % 65536, num(save.secretId, 0) % 65536 + local function cartMail(r) + if type(r) ~= "table" or num(r.itemId, 0) == 0 then return nil end + local rt = num(r.trainerId, 0) % 65536 + return { words = type(r.words) == "table" and r.words or {}, playerName = r.playerName, species = num(r.species, 0), + itemId = num(r.itemId, 0), trainerId = rt, trainerIdRaw = rt == tid and rt + sid * 65536 or rt } + end + local pool = {} + for i = 1, L.MAIL.count do pool[i] = cartMail(type(save.mail) == "table" and save.mail[i] or nil) end + local function mailIndex(mon, cm) + if not isMailItem(cm.heldItem) then return L.MAIL_NONE end + local idx = tonumber(mon.mail) + if idx and idx % 1 == 0 and idx >= 0 and idx < L.MAIL.count and pool[idx + 1] then return idx end + -- src/mail_data.c:41 + for id = 0, L.MAIL_PARTY_SLOTS - 1 do + if not pool[id + 1] then + pool[id + 1] = { words = {}, playerName = save.name, trainerId = tid, trainerIdRaw = tid + sid * 65536, + species = mailSpecies(mon), itemId = cm.heldItem } + return id + end + end + return L.MAIL_NONE + end + local ctx = { + mailIndex = mailIndex, + trainerId = num(save.trainerId, 0) % 65536, + secretId = num(save.secretId, 0) % 65536, + gender = num(save.gender, 0) == 1 and 1 or 0, + playerName = save.name, + metGame = opts.metGame, + itemId = opts.itemId, + speciesName = opts.speciesName, + speciesFromNational = opts.speciesFromNational, + } + + c.name = trunc(save.name, L.PLAYER_NAME_LENGTH) + c.rivalName = trunc(save.rivalName, L.PLAYER_NAME_LENGTH) + c.gender = ctx.gender + c.trainerId, c.secretId = ctx.trainerId, ctx.secretId + local pt = type(save.playTime) == "table" and save.playTime or (type(save.playtime) == "table" and save.playtime) or {} + c.playHours = clamp(num(pt.hours, 0), 0, 999) + c.playMinutes = clamp(num(pt.minutes, 0), 0, 59) + c.playSeconds = clamp(num(pt.seconds, 0), 0, 59) + c.playVBlanks = clamp(num(pt.vblanks, 0), 0, 59) + local po = portOptions(save, opts.version) + c.options = c.options or {} + for _, f in ipairs(L.OPTIONS_BITS) do + if f[1] ~= "regionMapZoom" then c.options[f[1]] = num(po[f[1]], c.options[f[1]] or 0) end + end + c.options.buttonMode = num(po.buttonMode, c.options.buttonMode or 0) + c.buttonMode = c.options.buttonMode + c.money = clamp(num(save.money, 0), 0, 999999) + c.coins = clamp(num(save.coins, 0), 0, 9999) + c.berryPowder = num(save.berryPowder, 0) + c.registeredItem = save.registeredItem ~= nil and ((opts.itemId and opts.itemId(save.registeredItem)) or num(save.registeredItem, 0)) or 0 + c.gcnLinkFlags = num(save.gcnLinkFlags, 0) + c.flashLevel = num(save.flashLevel, 0) + + local x, y = num(save.x, 0), num(save.y, 0) + local here = Gen3Save.cartWarp({ map = save.map, warpId = -1, x = x, y = y }) + if not here then return nil, Gen3Save.MSG.noMap end + local layout = opts.mapLayoutId and opts.mapLayoutId(here.group, here.num) + if layout then + c.location, c.posX, c.posY, c.mapLayoutId = here, x, y, layout + elseif not blocks then + return nil, Gen3Save.MSG.noData + end + local warpFlags = num(save.specialSaveWarpFlags, 0) + local cont = band(warpFlags, L.CONTINUE_GAME_WARP) ~= 0 and Gen3Save.cartWarp(save.continueGameWarp) or nil + -- src/overworld.c:1706 + c.continueGameWarp = cont or here + c.specialSaveWarpFlags = bor(warpFlags, L.CONTINUE_GAME_WARP) % 256 + c.dynamicWarp = Gen3Save.cartWarp(save.dynamicWarp) or L.EMPTY_WARP + c.escapeWarp = Gen3Save.cartWarp(save.escapeWarp) or L.EMPTY_WARP + local ci = type(save.modData) == "table" and type(save.modData.cartImport) == "table" and save.modData.cartImport or {} + local heal = Gen3Save.cartWarp(ci.lastHealLocation) or (opts.healWarp and opts.healWarp(save.healMap)) + if heal then + c.lastHealLocation = heal + elseif not blocks then + return nil, Gen3Save.MSG.noData + end + + c.party = {} + for i, m in ipairs(type(save.party) == "table" and save.party or {}) do + if i > L.PARTY_SIZE then break end + c.party[i] = Gen3Save.fromPortMon(m, ctx, true) + end + + local st = type(save.storage) == "table" and save.storage or {} + local S = L.STORAGE + c.storage = { currentBox = clamp(num(st.currentBox, 1) - 1, 0, S.totalBoxes - 1), boxes = {} } + for b = 1, S.totalBoxes do + local pb = type(st.boxes) == "table" and type(st.boxes[b]) == "table" and st.boxes[b] or {} + local box = { name = cartBoxName(pb.name, b), wallpaper = cartWallpaper(pb.wallpaper, b), mons = {} } + for key, m in pairs(type(pb.mons) == "table" and pb.mons or {}) do + local sl = tonumber(key) + if sl and sl >= 1 and sl <= S.inBox and type(m) == "table" then box.mons[sl] = Gen3Save.fromPortMon(m, ctx, false) end + end + c.storage.boxes[b] = box + end + c.pcItems = itemList(st.items, L.PC_ITEMS.count, ctx) + local pockets = type(save.bag) == "table" and type(save.bag.pockets) == "table" and save.bag.pockets or {} + c.pockets = {} + for _, p in ipairs(L.POCKETS) do c.pockets[p.key] = itemList(pockets[p.key], p.count, ctx) end + + local T = flagTables() + local flags = {} + for _, id in ipairs(c.flags or {}) do + if id <= L.TEMP_FLAGS_END then flags[id] = true end + end + for k, v in pairs(type(save.flags) == "table" and save.flags or {}) do + local id = v == true and resolveId(k, T.FLAGS, "FLAG_") + if id and id > L.TEMP_FLAGS_END and id < L.FLAGS_COUNT then flags[id] = true end + end + local vars = {} + for k, v in pairs(type(save.vars) == "table" and save.vars or {}) do + local id = resolveId(k, T.VARS, "VAR_") + local value = num(v, 0) % 65536 + if id and id >= L.VARS.first and id < L.VARS.first + L.VARS.count and value ~= 0 then vars[id] = value end + end + local dex = type(save.dex) == "table" and save.dex or {} + local N = L.NATIONAL_DEX + if not blocks and vars[L.RSE_NATIONAL_VAR] == nil then + -- src/event_data.c:71 + vars[L.RSE_NATIONAL_VAR] = L.RSE_NATIONAL_VALUE + flags[L.RSE_NATIONAL_FLAG] = true + end + -- src/event_data.c:99 + if dex.national == true or dex.nationalUnlocked == true or dex.isNationalUnlocked == true + or save.national_dex_unlocked == true or flags[N.flag] or vars[N.var] == N.varValue then + c.dexNationalMagic = N.magic + vars[N.var] = N.varValue + flags[N.flag] = true + else + c.dexNationalMagic = 0 + end + c.flags = bitList(flags) + c.vars = vars + + local seen, owned = {}, {} + local function mark(set, species) + local nat = opts.toNational(num(species, 0)) + if nat and nat >= 1 and nat <= 52 * 8 then set[nat - 1] = true end + end + for sp, v in pairs(type(dex.seen) == "table" and dex.seen or {}) do if v then mark(seen, sp) end end + for _, key in ipairs({ "owned", "caught" }) do + for sp, v in pairs(type(dex[key]) == "table" and dex[key] or {}) do if v then mark(owned, sp) end end + end + for _, nat in ipairs(type(ci.dexSeen) == "table" and ci.dexSeen or {}) do seen[nat - 1] = true end + for _, nat in ipairs(type(ci.dexOwned) == "table" and ci.dexOwned or {}) do owned[nat - 1] = true end + -- src/pokedex_screen.c:2252 + for i in pairs(owned) do seen[i] = true end + local seenList = bitList(seen) + c.dexSeen, c.dexSeen1, c.dexSeen2 = seenList, seenList, seenList + c.dexOwned = bitList(owned) + c.unownPersonality = num(dex.unownPersonality, c.unownPersonality or 0) + c.spindaPersonality = num(dex.spindaPersonality, c.spindaPersonality or 0) + + c.gameStats = {} + for k, v in pairs(type(save.gameStats) == "table" and save.gameStats or {}) do + local id = tonumber(k) + if id and id >= 0 and id < L.GAME_STATS.count and num(v, 0) ~= 0 then c.gameStats[id] = num(v, 0) % U32 end + end + c.easyChatProfile = c.easyChatProfile or {} + for i = 1, L.PORT_PROFILE_WORDS do + local w = type(save.easyChatProfile) == "table" and save.easyChatProfile[i] + c.easyChatProfile[i] = num(w, c.easyChatProfile[i] or L.EC_WORD_UNDEFINED) + end + + local md = type(save.modData) == "table" and save.modData or {} + local dcRoot = type(md.firered_daycare) == "table" and md.firered_daycare or {} + local dc = type(dcRoot.daycare) == "table" and dcRoot.daycare or {} + local dsteps = type(dc.steps) == "table" and dc.steps or {} + c.daycare = { mons = {}, steps = {}, mail = {}, offspringPersonality = num(dc.offspringPersonality, 0) % 65536, + stepCounter = num(dc.stepCounter, 0) % 256 } + local function daycareMail(m) + local message = type(m) == "table" and cartMail(m.message) + if not message then return nil end + return { message = message, otName = trunc(m.otName, L.PLAYER_NAME_LENGTH), monName = trunc(m.monName, L.POKEMON_NAME_LENGTH) } + end + for i = 1, 2 do + c.daycare.mons[i] = type(dc[i]) == "table" and Gen3Save.fromPortMon(dc[i], ctx, false) or nil + c.daycare.steps[i] = num(dsteps[i], 0) + c.daycare.mail[i] = daycareMail(type(dc.mail) == "table" and dc.mail[i] or nil) + end + local r5 = type(dcRoot.route5Daycare) == "table" and dcRoot.route5Daycare or {} + c.route5Daycare = { mon = type(r5.mon) == "table" and Gen3Save.fromPortMon(r5.mon, ctx, false) or nil, + steps = num(r5.steps, 0), mail = daycareMail(r5.mail) } + + c.mail = {} + for i = 1, L.MAIL.count do c.mail[i] = pool[i] or false end + local fameSrc = type(md.fameChecker) == "table" and md.fameChecker + or (type(save.fameChecker) == "table" and save.fameChecker) or nil + local oldFame = c.fameChecker or {} + if fameSrc then + c.fameChecker = {} + for i = 1, L.FAME_CHECKER.count do + local rec = type(fameSrc[i]) == "table" and fameSrc[i] or {} + c.fameChecker[i] = { pickState = num(rec.pickState, 0), flavorTextFlags = num(rec.flavorTextFlags, 0), + unk = (oldFame[i] or {}).unk } + end + elseif not blocks then + -- src/fame_checker.c:1140 + c.fameChecker = {} + for i = 1, L.FAME_CHECKER.count do c.fameChecker[i] = { pickState = 0, flavorTextFlags = 0 } end + c.fameChecker[L.FAMECHECKER_OAK + 1].pickState = L.FCPICKSTATE_COLORED + else + c.fameChecker = nil + end + if type(save.registeredTexts) == "table" then + c.registeredTexts = {} + for i = 1, L.REGISTERED_TEXTS.count do c.registeredTexts[i] = tostring(save.registeredTexts[i] or "") end + elseif not blocks then + -- src/union_room_chat.c:1430 + c.registeredTexts = opts.registeredTextDefaults and opts.registeredTextDefaults() or nil + else + c.registeredTexts = nil + end + local tower = type(md.trainerTower) == "table" and type(md.trainerTower.records) == "table" and md.trainerTower.records + if tower then + c.trainerTowerBest = {} + for i = 1, L.TRAINER_TOWER.count do + local rec = type(tower[i]) == "table" and tower[i] or {} + c.trainerTowerBest[i] = clamp(num(rec.bestTime, L.TRAINER_TOWER_MAX_TIME), 0, L.TRAINER_TOWER_MAX_TIME) + end + elseif not blocks then + -- src/new_game.c:151 + c.trainerTowerBest = {} + else + c.trainerTowerBest = nil + end + if not blocks then + -- src/easy_chat.c:440 + c.easyChatBattle = { start = L.DEFAULT_BATTLE_START_WORDS, won = {}, lost = {} } + -- src/new_game.c:130 + c.clearLinkRecords = true + else + c.easyChatBattle, c.clearLinkRecords = nil, nil + end + + local r = save.roamer + if type(r) == "table" then + local ivs, ivw = statTable(r.ivs, L.IV_KEYS, 31), 0 + for i, k in ipairs(L.IV_KEYS) do ivw = ivw + ivs[k] * 2 ^ ((i - 1) * 5) end + c.roamer = { + ivs = ivw, + personality = num(r.pid or r.personality, 0) % U32, + species = num(r.species, 0), + hp = num(r.hp, 0), + level = num(r.level, 0), + status = num(r.statusNum, type(r.status) == "number" and r.status or 0) % 256, + active = r.active and 1 or 0, + } + end + + local teams = {} + for _, team in ipairs(type(save.hallOfFameTeams) == "table" and save.hallOfFameTeams or {}) do + local rec = {} + for _, m in ipairs(type(team) == "table" and team or {}) do + rec[#rec + 1] = { + tid = num(m.trainerId, 0) % 65536 + (num(m.otSecretId, 0) % 65536) * 65536, + personality = num(m.personality, 0) % U32, + species = num(m.species, 0), + level = num(m.level, 0), + nickname = trunc(m.nickname, L.HOF.nickLength), + } + end + if #rec > 0 then teams[#teams + 1] = rec end + end + c.hallOfFame = teams + return c, blocks +end + +function Gen3Save.exportPort(save, opts) + local c, blocks = Gen3Save.fromPortSave(save, opts) + if not c then return nil, blocks end + local hof = #c.hallOfFame > 0 and Gen3Save.encodeHof(c.hallOfFame) or nil + local counter = blocks and (blocks.counter + 1) % U32 or 1 + return Gen3Save.buildFlash(Gen3Save.encodeBlocks(c, blocks), + { counter = counter, image = blocks and blocks.image, hof = hof }) +end + +return Gen3Save diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 7b2eaeaf..26493ed4 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -22,6 +22,7 @@ local GenSave = require("src.save_convert.GenSave") local Gen2Save = require("src.save_convert.Gen2Save") +local Gen3Save = require("src.save_convert.Gen3Save") local SaveConvert = {} @@ -33,6 +34,7 @@ local function isGen3(gameVersion) return GameVersion.VERSIONS[gameVersion] ~= nil and GameVersion.generation(gameVersion) == 3 end +SaveConvert.isGen3 = isGen3 SaveConvert.GEN3_FLASH_MISMATCH = "That is a FireRed/LeafGreen (Game Boy Advance) save, " .. "not a save for this game." @@ -310,14 +312,10 @@ function SaveConvert.isGen2Cart(gameVersion) return Gen2Save.layoutFor(gameVersion) ~= nil end -SaveConvert.GEN3_IMPORT_REFUSAL = "FireRed/LeafGreen cartridge saves can't be imported yet. " - .. "Only a .lua save exported from this launcher can be imported." - function SaveConvert.importSupported(gameVersion) -- Gen 2 imports through Gen2Save now. Kept as a predicate rather than -- deleted: SaveFileIO asks it before it measures the bytes, and export -- still answers no. - if isGen3(gameVersion) then return false, SaveConvert.GEN3_IMPORT_REFUSAL end return true end @@ -325,7 +323,7 @@ end -- A Gen 3 save must never fall through to the Gen 1 encoder. function SaveConvert.exportSupported(gameVersion) if gameVersion == nil or gameVersion == "red" or gameVersion == "blue" - or gameVersion == "yellow" or Gen2Save.layoutFor(gameVersion) then + or gameVersion == "yellow" or Gen2Save.layoutFor(gameVersion) or isGen3(gameVersion) then return true end return false, "Cartridge save export is not implemented for this game yet." @@ -345,6 +343,7 @@ function SaveConvert.importSav(bytes, version, gameVersion) end local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion) if not supported then return nil, unsupportedWhy end + if isGen3(gameVersion) then return Gen3Save.importPort(bytes, gameVersion) end -- Gen 2 is a different SRAM entirely: different bank map, different party -- struct, its own check values. Gen2Save owns it, and it needs no crosswalk -- tables because it decodes ids the engine already speaks. @@ -376,6 +375,95 @@ function SaveConvert.importSav(bytes, version, gameVersion) return mergeDefaults(decoded, version) end +local GBA_ROOT = "data/generated/gba/" + +local function gen3CacheBytes(gameVersion, rel) + local path = GBA_ROOT .. rel + if gameVersion and love and love.filesystem then + local okc, CacheFs = pcall(require, "src.import.CacheFs") + local info = require("src.core.GameVersion").VERSIONS[gameVersion] + if okc and type(CacheFs) == "table" and info then + local saved = CacheFs.prefix + CacheFs.prefix = info.cachePrefix + local okr, bytes = pcall(CacheFs.read, path) + CacheFs.prefix = saved + if okr and type(bytes) == "string" then return bytes end + end + end + local okd, Dataset = pcall(require, "src.core.game3.dataset") + local cache = okd and Dataset.cache and Dataset.cache() + local bytes = cache and cache:read(path) + return type(bytes) == "string" and bytes or nil +end + +local function gen3CacheTable(gameVersion, rel) + local bytes = gen3CacheBytes(gameVersion, rel) + local chunk = bytes and loadstring(bytes, "@" .. rel) + if not chunk then return nil end + local ok, t = pcall(setfenv(chunk, {})) + return ok and type(t) == "table" and t or nil +end + +local function gen3ExportOpts(gameVersion, cartImage) + local L3 = require("src.save_convert.Gen3Layout") + local national = gen3CacheTable(gameVersion, "pokemon/national.lua") + local names = gen3CacheTable(gameVersion, "pokemon/names.lua") + local fly = gen3CacheTable(gameVersion, "region_map/fly_destinations.lua") + local heal = gen3CacheTable(gameVersion, "region_map/heal_locations.lua") + local okI, ItemsData = pcall(require, "src.core.game3.items_data") + local opts = { + template = cartImage, + version = gameVersion, + metGame = gameVersion == "leafgreen" and L3.VERSION_LEAF_GREEN or L3.VERSION_FIRE_RED, + itemId = function(id) + return tonumber(id) or (okI and ItemsData.toNumericId(id)) or nil + end, + mapLayoutId = function(group, num) + local raw = gen3CacheBytes(gameVersion, ("map_tree/maps/%d_%d/header.json"):format(group, num)) + return raw and tonumber(raw:match('"layoutId"%s*:%s*(%d+)')) or nil + end, + } + if national and type(national.toNational) == "table" then + opts.toNational = function(species) return national.toNational[species] end + opts.speciesFromNational = function(nat) return national.toSpecies and national.toSpecies[nat] end + end + if names then + opts.speciesName = function(species) return names[species] end + end + opts.registeredTextDefaults = function() + local okC, Chat = pcall(require, "src.core.game3.link.chat") + local text = okC and gen3CacheTable(gameVersion, "scripts/text.lua") + if not text then return nil end + local out = {} + for i, key in ipairs(Chat.REGISTERED_DEFAULTS) do + if type(text[key]) ~= "table" then return nil end + local parts = {} + for _, seg in ipairs(text[key]) do + if seg.t == "text" then parts[#parts + 1] = seg.s elseif seg.t == "tag" then parts[#parts + 1] = seg.tag end + end + out[i] = table.concat(parts) + end + return out + end + local whiteout = heal and (heal.whiteout or heal.heal_locations) + local dests = fly and fly.fly_destinations + if type(whiteout) == "table" and type(dests) == "table" then + opts.healWarp = function(respawnMap) + local id = L3.HEAL_LOCATION_PALLET_TOWN + for i, loc in pairs(whiteout) do + if type(loc) == "table" and loc.map == respawnMap then id = i end + end + for _, dest in pairs(dests) do + if type(dest) == "table" and dest.healLocation == id then + return Gen3Save.cartWarp({ map = dest.map, warpId = -1, x = dest.x, y = dest.y }) + end + end + return nil + end + end + return opts +end + -- exportSav(saveTable, gameVersion, cartImage) -> bytes, err -- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware: -- if the table still carries the stashed import template (saveTable.rawImport) @@ -388,6 +476,11 @@ function SaveConvert.exportSav(saveTable, gameVersion, cartImage) end local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion) if not supported then return nil, unsupportedWhy end + if isGen3(gameVersion) then + local ok, bytes, err = pcall(Gen3Save.exportPort, saveTable, gen3ExportOpts(gameVersion, cartImage)) + if not ok then return nil, "encode failed: " .. tostring(bytes) end + return bytes, err + end -- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks. if Gen2Save.layoutFor(gameVersion) then return Gen2Save.encode(saveTable, gameVersion, cartImage, diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index b58b1062..a76c84d6 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -57,6 +57,7 @@ local PAD_SHORT = { dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT", leftshoulder = "LB", rightshoulder = "RB", triggerleft = "L2", triggerright = "R2", + lefttrigger = "L2", righttrigger = "R2", leftstick = "LS", rightstick = "RS", guide = "GUIDE", } local function shortName(name, shorts) diff --git a/src/ui/game3/map_preview_screen.lua b/src/ui/game3/map_preview_screen.lua index 9dcaabad..2624bea5 100644 --- a/src/ui/game3/map_preview_screen.lua +++ b/src/ui/game3/map_preview_screen.lua @@ -308,6 +308,14 @@ function MapPreviewScreen.dismiss() if wasActive and onDone then onDone() end end +-- pokefirered/src/main.c:480 +function MapPreviewScreen.reset() + MapPreviewScreen._onDone = nil + MapPreviewScreen.dismiss() + MapPreviewScreen._cave = nil + CaveTransition.clear() +end + function MapPreviewScreen.isForestActive() return MapPreviewScreen._active == true end diff --git a/src/ui/game3/shop_menu.lua b/src/ui/game3/shop_menu.lua index 21de1473..0dd0c491 100644 --- a/src/ui/game3/shop_menu.lua +++ b/src/ui/game3/shop_menu.lua @@ -94,6 +94,7 @@ function ShopMenu.show(opts) ShopMenu.yesNoCursor = 1 ShopMenu._pending = nil ShopMenu._shopSe = nil + ShopMenu._fading = false ShopMenu._items = opts.items or {} ShopMenu._rowsGen = (ShopMenu._rowsGen or 0) + 1 ShopMenu._session = opts.session @@ -118,6 +119,17 @@ function ShopMenu.close() if cb then cb() end end +-- pokefirered/src/main.c:480 +function ShopMenu.reset() + ShopMenu.open = false + ShopMenu._fading = false + ShopMenu.mode = "root" + ShopMenu._onClose = nil + ShopMenu._pending = nil + ShopMenu._shopSe = nil + ShopMenu._session = nil +end + function ShopMenu.isOpen() return ShopMenu.open end diff --git a/src/ui/game3/summary_chrome.lua b/src/ui/game3/summary_chrome.lua index 44884068..b90642ec 100644 --- a/src/ui/game3/summary_chrome.lua +++ b/src/ui/game3/summary_chrome.lua @@ -137,23 +137,43 @@ function SummaryChrome.manifest() return SummaryChrome._manifest end -function SummaryChrome.pageImage(pageIndex) - if SummaryChrome._pages[pageIndex] then return SummaryChrome._pages[pageIndex] end - local filenames = { - [0] = "page_info.rgba", - [1] = "page_skills.rgba", - [2] = "page_moves.rgba", - [3] = "page_moves_info.rgba", - [4] = "page_egg.rgba", - } - local fn = filenames[pageIndex] or "page_info.rgba" - local raw = read_bytes(summary_root() .. "/" .. fn) - if raw then - local img = rgba_to_image(raw, 240, 160) - SummaryChrome._pages[pageIndex] = img - return img - end - return nil +local function cached_image(name, w, h) + local key = name + if SummaryChrome._pages[key] ~= nil then return SummaryChrome._pages[key] or nil end + local raw = read_bytes(summary_root() .. "/" .. name .. ".rgba") + local img = raw and rgba_to_image(raw, w, h) + SummaryChrome._pages[key] = img or false + return img +end + +local function variant(name, shiny) + return shiny and (name .. "_shiny") or name +end + +-- pokefirered/src/pokemon_summary_screen.c:1862 +function SummaryChrome.drawBg3(kind, shiny) + local img = cached_image(variant("bg3_" .. kind, shiny), 240, 160) + if not (img and love and love.graphics) then return false end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 0, 0) + return true +end + +-- pokefirered/src/pokemon_summary_screen.c:3276 +function SummaryChrome.drawProgress(kind, shiny) + local img = cached_image(variant("progress_" .. kind, shiny and kind ~= "egg"), 48, 16) + if not (img and love and love.graphics) then return false end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, 104, 0) + return true +end + +function SummaryChrome.drawLayer(kind, xOffset, shiny) + local img = cached_image(variant("layer_" .. kind, shiny and kind ~= "egg"), 240, 160) + if not (img and love and love.graphics) then return false end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, xOffset or 0, 0) + return true end function SummaryChrome.hpBarImage(color) @@ -239,17 +259,6 @@ local function get_quad(key, x, y, w, h, sw, sh) return SummaryChrome._quads[key] end ---- Draw the background texture for a page with horizontal offset for slide animations. -function SummaryChrome.drawPageBg(pageIndex, xOffset) - xOffset = xOffset or 0 - local img = SummaryChrome.pageImage(pageIndex) - if img and love and love.graphics then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, xOffset, 0) - return true - end - return false -end --- Pret UpdateHpBarObjs: 9 sprites at (x + i*8, y). --- anim 9=HP label, 10=left cap, 0..8=fill (0 empty..8 full), 11=right cap. @@ -439,7 +448,8 @@ function SummaryChrome.drawShinyStar(x, y) if not (love and love.graphics) then return end local img = SummaryChrome.shinyStarImage() if not img then return end - local q = get_quad("shiny_star", 0, 0, 8, 8, 8, 16) + -- pokefirered/src/pokemon_summary_screen.c:610 + local q = get_quad("shiny_star", 0, 8, 8, 8, 8, 16) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, q, x, y) end @@ -453,4 +463,15 @@ function SummaryChrome.drawPokerus(x, y) love.graphics.draw(img, x, y) end +-- pokefirered/src/mon_markings.c:558 +function SummaryChrome.drawMarkings(markings, x, y) + markings = tonumber(markings) or 0 + if markings <= 0 or markings > 15 then return end + local img = cached_image("markings", 32, 128) + if not (img and love and love.graphics) then return end + local q = get_quad("markings_" .. markings, 0, markings * 8, 32, 8, 32, 128) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, q, x, y) +end + return SummaryChrome diff --git a/src/ui/game3/summary_menu.lua b/src/ui/game3/summary_menu.lua index cbf2b263..612883cf 100644 --- a/src/ui/game3/summary_menu.lua +++ b/src/ui/game3/summary_menu.lua @@ -37,11 +37,15 @@ SummaryMenu._onClose = nil SummaryMenu._playerState = nil SummaryMenu._slide = { active = false, + kind = "page", direction = 0, - timer = 0, - duration = 0.12, + frame = 0, prevPage = 0, + queued = nil, } +SummaryMenu._tick = 0 +SummaryMenu._bounce = { dy = 0, dx = 0, delay = 0, anim = 0, count = 2, vigor = 0, egg = false } +SummaryMenu._blink = { frame = 0, hidden = false } local PAGE_INFO = 0 local PAGE_SKILLS = 1 @@ -49,10 +53,31 @@ local PAGE_MOVES = 2 local PAGE_MOVES_INFO = 3 local PAGE_EGG = 4 +SummaryMenu.PAGE_INFO = PAGE_INFO +SummaryMenu.PAGE_SKILLS = PAGE_SKILLS +SummaryMenu.PAGE_MOVES = PAGE_MOVES +SummaryMenu.PAGE_MOVES_INFO = PAGE_MOVES_INFO +SummaryMenu.PAGE_EGG = PAGE_EGG + +-- pokefirered/src/pokemon_summary_screen.c:3058 +local SLIDE_STEP = 60 +local SLIDE_FRAMES = 240 / SLIDE_STEP + +-- pokefirered/src/pokemon_summary_screen.c:1233 +local FLIP_TIMING = { + page = { delay = 6, header = 6, text = 14, done = 15 }, + -- pokefirered/src/pokemon_summary_screen.c:1332 + detail = { delay = 8, hide = 4, bg = 7, header = 7, list5 = 9, bottom = 13, show = 15, name = 16, done = 17 }, + -- pokefirered/src/pokemon_summary_screen.c:1443 + back = { delay = 7, list5 = 5, bottom = 5, header = 7, hide = 8, name = 13, bg = 14, show = 14, done = 15 }, +} +SummaryMenu.FLIP_TIMING = FLIP_TIMING + +-- pokefirered/src/pokemon_summary_screen.c:3956 +local EGG_SHAKE_DELAY = { [0] = 120, [1] = 90, [2] = 60 } + local STAT_COLORS = { NORMAL = FrlgFont.COLOR.NORMAL, - UP = { fg = { 210 / 255, 60 / 255, 60 / 255, 1 }, shadow = { 240 / 255, 200 / 255, 200 / 255, 1 } }, - DOWN = { fg = { 60 / 255, 90 / 255, 210 / 255, 1 }, shadow = { 200 / 255, 210 / 255, 240 / 255, 1 } }, } local function current_mon() @@ -76,6 +101,14 @@ local function play_mon_cry() end end +-- pokefirered/src/pokemon_summary_screen.c:2297 +local function power_accuracy(mdef) + local none = RomText.plain("gText_ThreeHyphens") + local power = (mdef and mdef.power and mdef.power > 1) and tostring(mdef.power) or none + local acc = (mdef and mdef.accuracy and mdef.accuracy > 0) and tostring(mdef.accuracy) or none + return power, acc +end + local function moves_for_mon(mon) if not mon then return {} end local out = {} @@ -102,8 +135,7 @@ local function moves_for_mon(mon) name = (mdef and mdef.name) or name or Strings("MOVE %s", tostring(moveId)) end local mType = (mdef and (mdef.type or mdef.kind)) or "NORMAL" - local power = (mdef and mdef.power and mdef.power > 0) and tostring(mdef.power) or "---" - local acc = (mdef and mdef.accuracy and mdef.accuracy > 0) and tostring(mdef.accuracy) or "---" + local power, acc = power_accuracy(mdef) out[i] = { id = moveId, name = name, @@ -125,8 +157,7 @@ local function moves_for_mon(mon) end local maxPp = (mdef and mdef.pp) or 5 local mType = (mdef and (mdef.type or mdef.kind)) or "NORMAL" - local power = (mdef and mdef.power and mdef.power > 0) and tostring(mdef.power) or "---" - local acc = (mdef and mdef.accuracy and mdef.accuracy > 0) and tostring(mdef.accuracy) or "---" + local power, acc = power_accuracy(mdef) out[5] = { id = newId, name = name, @@ -165,6 +196,9 @@ function SummaryMenu.openMenu(party, startIndex, opts) SummaryMenu._moveCursor = 1 SummaryMenu._swapSlot = nil SummaryMenu._slide.active = false + -- pokefirered/src/pokemon_summary_screen.c:1046 + SummaryMenu._slide.queued = nil + SummaryMenu._tick = 0 local mon = current_mon() if mon and Pokemon.isEgg(mon) then @@ -176,6 +210,7 @@ function SummaryMenu.openMenu(party, startIndex, opts) end SummaryChrome.install(opts.cache) + SummaryMenu.resetPicBounce() Stack.push("summary", SummaryMenu, { hideBelow = true, fullscreen = true }) -- pokefirered/src/pokemon_summary_screen.c:1111 play_mon_cry() @@ -214,28 +249,117 @@ local function change_mon(delta) elseif SummaryMenu._page == PAGE_EGG then SummaryMenu._page = PAGE_INFO end + SummaryMenu.resetPicBounce() -- pokefirered/src/pokemon_summary_screen.c:5153 play_mon_cry() end -local function start_page_slide(newPage, dir) +local function start_slide(kind, newPage, dir) if SummaryMenu._page == newPage then return end SummaryMenu._slide.active = true + SummaryMenu._slide.kind = kind SummaryMenu._slide.direction = dir - SummaryMenu._slide.timer = 0 + SummaryMenu._slide.frame = 0 SummaryMenu._slide.prevPage = SummaryMenu._page SummaryMenu._page = newPage - SummaryMenu._moveCursor = 1 SummaryMenu._swapSlot = nil end +local function ailment_blocks_bounce(ailment) + return ailment ~= 0 and ailment ~= 6 +end + +-- pokefirered/src/pokemon_summary_screen.c:4048 +function SummaryMenu.resetPicBounce() + local b = SummaryMenu._bounce + b.dy, b.dx, b.delay, b.anim, b.count, b.vigor, b.egg = 0, 0, 0, 0, 2, 0, false + local mon = current_mon() + if not mon then return end + if Pokemon.isEgg(mon) then + local cycles = SummaryData.eggCycles(mon) + b.egg = true + -- pokefirered/src/pokemon_summary_screen.c:4054 + if cycles <= 5 then b.vigor = 2 + elseif cycles <= 10 then b.vigor = 1 end + b.count = 0 + return + end + if ailment_blocks_bounce(SummaryData.statusAilment(mon)) then return end + local curHp = tonumber(mon.hp or mon.currentHp) or 0 + local maxHp = tonumber(mon.maxHp or mon.maxhp) or 0 + if curHp == maxHp then b.vigor = 4 + elseif maxHp * 0.8 <= curHp then b.vigor = 3 + elseif maxHp * 0.6 <= curHp then b.vigor = 2 + else b.vigor = 1 end + b.count = 0 +end + +-- pokefirered/src/pokemon_summary_screen.c:3916 +local function step_pic_bounce() + local b = SummaryMenu._bounce + if b.count >= 2 then return end + local m = SummaryChrome.manifest() + local deltas = m and m.monPicBounce and m.monPicBounce[b.vigor] + if not deltas or #deltas == 0 then return end + local ready = b.delay >= 2 + b.delay = b.delay + 1 + if not ready then return end + b.anim = b.anim + 1 + b.dy = b.dy + (deltas[b.anim] or 0) + if b.anim >= #deltas then + b.anim = 0 + b.count = b.count + 1 + end + b.delay = 0 +end + +-- pokefirered/src/pokemon_summary_screen.c:3956 +local function step_egg_shake() + local b = SummaryMenu._bounce + if b.count >= 2 then return end + local deltas = SummaryChrome.manifest().eggPicShake[b.vigor + 1] + local ready = b.delay >= EGG_SHAKE_DELAY[b.vigor] + b.delay = b.delay + 1 + if not ready then return end + b.anim = b.anim + 1 + b.dx = b.dx + (deltas[b.anim] or 0) + if b.anim >= #deltas then + b.anim = 0 + b.delay = 0 + b.count = b.count + 1 + end +end + +-- pokefirered/src/pokemon_summary_screen.c:4247 +local function step_swap_blink() + local bl = SummaryMenu._blink + if not SummaryMenu._swapSlot then + bl.frame, bl.hidden = 0, false + return + end + bl.frame = bl.frame + 1 + if bl.frame > 60 then + bl.hidden = not bl.hidden + bl.frame = 0 + end +end + +local function step_frame() + local s = SummaryMenu._slide + if s.active then + s.frame = s.frame + 1 + if s.frame >= FLIP_TIMING[s.kind].done then s.active = false end + end + if SummaryMenu._bounce.egg then step_egg_shake() else step_pic_bounce() end + step_swap_blink() +end + function SummaryMenu.update(dt) dt = tonumber(dt) or (1 / 60) - if SummaryMenu._slide.active then - SummaryMenu._slide.timer = SummaryMenu._slide.timer + dt - if SummaryMenu._slide.timer >= SummaryMenu._slide.duration then - SummaryMenu._slide.active = false - end + SummaryMenu._tick = (SummaryMenu._tick or 0) + dt * 60 + while SummaryMenu._tick >= 0.999 do + SummaryMenu._tick = SummaryMenu._tick - 1 + step_frame() end end @@ -255,14 +379,74 @@ local function select_move_step(moves, cur, dir) return 5 end +-- pokefirered/src/pokemon_summary_screen.c:3506 +function SummaryMenu.detailCursorStep(moves, pos, dir, swapping) + local function has(i) return i <= 3 and moves[i + 1] ~= nil end + if dir < 0 then + if pos > 0 then + for i = pos, 1, -1 do + if has(i - 1) then return i - 1 end + end + return pos + end + if swapping then + for i = 4, 1, -1 do + if has(i - 1) then return i - 1 end + end + end + return 4 + end + if pos < 4 then + local last = 4 + if swapping then + if pos == 3 then return 0 end + last = 3 + end + local i = pos + while i < last do + if has(i + 1) then return i + 1 end + i = i + 1 + end + return swapping and 0 or i + end + return 0 +end + +local function lr_mode() + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + if type(session) ~= "table" then return false end + return require("src.core.game3.options").lrMode(session) +end + +-- pokefirered/src/pokemon_summary_screen.c:1064 +local function page_flip_input(input, dir) + local s = SummaryMenu._slide + if s.queued == dir then + s.queued = nil + return true + end + -- pokefirered/src/pokemon_summary_screen.c:1056 + if s.active and s.direction ~= dir then return false end + if dir > 0 then + return input:wasPressed("right") or (lr_mode() and input:wasPressed("r")) and true or false + end + return input:wasPressed("left") or (lr_mode() and input:wasPressed("l")) and true or false +end + function SummaryMenu.handleInput(input) if not input then return end - if SummaryMenu._slide.active then - if SummaryMenu._slide.timer >= SummaryMenu._slide.duration then - SummaryMenu._slide.active = false - else - return + local slide = SummaryMenu._slide + if slide.active then + -- pokefirered/src/pokemon_summary_screen.c:1132 + if slide.kind == "page" then + if page_flip_input(input, 1) then + slide.queued = 1 + elseif page_flip_input(input, -1) then + slide.queued = -1 + end end + return end local mon = current_mon() @@ -335,67 +519,70 @@ function SummaryMenu.handleInput(input) -- Move detail & swap mode if SummaryMenu._page == PAGE_MOVES_INFO then local moves = moves_for_mon(mon) - local nMoves = #moves - if nMoves < 1 then nMoves = 1 end + local swapping = SummaryMenu._swapSlot ~= nil + local pos = SummaryMenu._moveCursor - 1 if input:wasPressed("up") then - SummaryMenu._moveCursor = ((SummaryMenu._moveCursor - 2) % nMoves) + 1 + SummaryMenu._moveCursor = SummaryMenu.detailCursorStep(moves, pos, -1, swapping) + 1 + pcall(function() require("src.core.game3.audio").playSe(5) end) elseif input:wasPressed("down") then - SummaryMenu._moveCursor = (SummaryMenu._moveCursor % nMoves) + 1 + SummaryMenu._moveCursor = SummaryMenu.detailCursorStep(moves, pos, 1, swapping) + 1 + pcall(function() require("src.core.game3.audio").playSe(5) end) elseif input:wasPressed("a") then - if SummaryMenu._swapSlot == nil then + pcall(function() require("src.core.game3.audio").playSe(5) end) + if pos == 4 then + -- pokefirered/src/pokemon_summary_screen.c:3589 + SummaryMenu._moveCursor = 1 + start_slide("back", PAGE_MOVES, -1) + elseif not swapping then -- pokefirered/src/pokemon_summary_screen.c:3604 local Battle = package.loaded["src.core.game3.battle"] local inBattle = Battle and Battle.isActive and Battle.isActive() if not (SummaryMenu._enemyParty or inBattle or SummaryMenu._mode == "trade") then - -- Begin move swap SummaryMenu._swapSlot = SummaryMenu._moveCursor + SummaryMenu._blink.frame, SummaryMenu._blink.hidden = 0, false end else - -- Complete atomic move swap local slotA = SummaryMenu._swapSlot local slotB = SummaryMenu._moveCursor SummaryMenu._swapSlot = nil - Pokemon.swapMoves(mon, slotA, slotB) + if slotA ~= slotB then Pokemon.swapMoves(mon, slotA, slotB) end end elseif input:wasPressed("b") then - if SummaryMenu._swapSlot ~= nil then - -- Cancel swapping + if swapping then SummaryMenu._swapSlot = nil else - -- Return to regular moves page - SummaryMenu._page = PAGE_MOVES + -- pokefirered/src/pokemon_summary_screen.c:3640 + if pos == 4 then SummaryMenu._moveCursor = 1 end + start_slide("back", PAGE_MOVES, -1) end end return end - -- Standard 3-page summary - if input:wasPressed("left") then - if SummaryMenu._page == PAGE_INFO then - start_page_slide(PAGE_MOVES, -1) - elseif SummaryMenu._page == PAGE_SKILLS then - start_page_slide(PAGE_INFO, -1) - elseif SummaryMenu._page == PAGE_MOVES then - start_page_slide(PAGE_SKILLS, -1) + -- pokefirered/src/pokemon_summary_screen.c:1126 + if page_flip_input(input, 1) then + if SummaryMenu._page < PAGE_MOVES then + pcall(function() require("src.core.game3.audio").playSe(5) end) + start_slide("page", SummaryMenu._page + 1, 1) end - elseif input:wasPressed("right") then - if SummaryMenu._page == PAGE_INFO then - start_page_slide(PAGE_SKILLS, 1) - elseif SummaryMenu._page == PAGE_SKILLS then - start_page_slide(PAGE_MOVES, 1) - elseif SummaryMenu._page == PAGE_MOVES then - start_page_slide(PAGE_INFO, 1) + elseif page_flip_input(input, -1) then + if SummaryMenu._page > PAGE_INFO then + pcall(function() require("src.core.game3.audio").playSe(5) end) + start_slide("page", SummaryMenu._page - 1, -1) end elseif input:wasPressed("up") then change_mon(-1) elseif input:wasPressed("down") then change_mon(1) elseif input:wasPressed("a") then - if SummaryMenu._page == PAGE_MOVES then - SummaryMenu._page = PAGE_MOVES_INFO - SummaryMenu._moveCursor = 1 - SummaryMenu._swapSlot = nil + -- pokefirered/src/pokemon_summary_screen.c:1178 + if SummaryMenu._page == PAGE_INFO then + pcall(function() require("src.core.game3.audio").playSe(5) end) + SummaryMenu.close() + elseif SummaryMenu._page == PAGE_MOVES then + pcall(function() require("src.core.game3.audio").playSe(5) end) + start_slide("detail", PAGE_MOVES_INFO, 1) end elseif input:wasPressed("b") or input:wasPressed("start") then SummaryMenu.close() @@ -403,7 +590,7 @@ function SummaryMenu.handleInput(input) end local function draw_text(str, x, y, maxW, colorKey) - local colors = STAT_COLORS[colorKey] or FrlgFont.COLOR.NORMAL + local colors = (type(colorKey) == "table" and colorKey) or STAT_COLORS[colorKey] or FrlgFont.COLOR.NORMAL FrlgFont.draw(tostring(str or ""), x, y, { maxWidth = maxW, colors = colors, @@ -479,95 +666,116 @@ local function draw_ball_icon(mon) love.graphics.draw(img, quad, 106 - 8, 88 - 8) end -local function draw_header(mon) - local c = coords() - local species = Pokemon.speciesOf(mon) - local isMovesPage = (SummaryMenu._page == PAGE_MOVES or SummaryMenu._page == PAGE_MOVES_INFO) +local function mon_no_flip(mon) + local m = SummaryChrome.manifest() + local nf = m and m.noFlip + return (nf and nf[tonumber(Pokemon.speciesOf(mon)) or -1]) and true or false +end +local function draw_flipped(img, quad, x, y, w, flip) + if flip then + if quad then love.graphics.draw(img, quad, x + w, y, 0, -1, 1) + else love.graphics.draw(img, x + w, y, 0, -1, 1) end + elseif quad then + love.graphics.draw(img, quad, x, y) + else + love.graphics.draw(img, x, y) + end +end + +-- pokefirered/src/pokemon_summary_screen.c:1615 +function SummaryMenu.leftPaneSprites(page, mode) + local function at(key, fx, fy) + local x, y = cxy(key, fx, fy) + return { x = x, y = y } + end + if page == PAGE_MOVES_INFO then + return { + icon = at("monIcon", 8, 16), + -- pokefirered/src/pokemon_summary_screen.c:1976 + status = (mode == "select_move") and at("statusMovesInfo", 0, 41) or nil, + shiny = at("shinyStarMovesInfo", 4, 20), + pokerus = at("pokerus", 110, 88), + } + end + return { + pic = at("monPic", 60, 65), + ball = at("ball", 106, 88), + markings = at("markings", 4, 87), + status = at("status", 0, 34), + shiny = at("shinyStar", 102, 36), + pokerus = at("pokerus", 110, 88), + } +end + +local function draw_left_sprites(mon, page) + if not (love and love.graphics) then return end + local s = SummaryMenu.leftPaneSprites(page, SummaryMenu._mode) + local flip = not mon_no_flip(mon) + + if s.pic then + local front = Pokemon.monFrontPic(mon) + if front and front.image then + local iw, ih = front.w or 64, front.h or 64 + love.graphics.setColor(1, 1, 1, 1) + -- pokefirered/src/pokemon_summary_screen.c:4037 + draw_flipped(front.image, nil, s.pic.x - iw / 2, s.pic.y - ih / 2 + SummaryMenu._bounce.dy, iw, flip) + end + end + if s.icon then + local icon = Pokemon.monIcon(mon) + if icon and icon.image then + love.graphics.setColor(1, 1, 1, 1) + -- pokefirered/src/pokemon_summary_screen.c:4164 + draw_flipped(icon.image, icon.quads and icon.quads[0], s.icon.x, s.icon.y, icon.w or 32, flip) + end + end + if s.ball then + draw_ball_icon(mon) + end + if s.markings then + -- pokefirered/src/pokemon_summary_screen.c:4900 + SummaryChrome.drawMarkings(mon.markings, s.markings.x, s.markings.y) + end + local ailment = SummaryData.statusAilment(mon) + if s.status and ailment > 0 then + SummaryChrome.drawStatusIcon(s.status.x, s.status.y, ailment) + end + if s.shiny and SummaryData.isShiny(mon) then + SummaryChrome.drawShinyStar(s.shiny.x, s.shiny.y) + end + -- pokefirered/src/pokemon_summary_screen.c:4736 + if s.pokerus and SummaryMenu.showsPokerusIcon(mon) then + SummaryChrome.drawPokerus(s.pokerus.x, s.pokerus.y) + end +end + +local function draw_header(mon, showLevel, dx) + dx = dx or 0 + if dx >= 240 then return end -- Nickname + level + gender live in the left LVL_NICK strip (not the right pane). local nick = Pokemon.displayName(mon) local nx, ny = cxy("name", 40, 18) - draw_text(nick, nx, ny, 64, "NORMAL") + draw_text(nick, nx + dx, ny, 64, "NORMAL") - -- In pret pokefirered (pokemon_summary_screen.c:2430), Level is NOT printed on PAGE_MOVES_INFO - if SummaryMenu._page ~= PAGE_MOVES_INFO then + -- pokefirered/src/pokemon_summary_screen.c:2430 + if showLevel then local lv = tonumber(mon.level) or 1 local lx, ly = cxy("level", 4, 18) -- src/pokemon_summary_screen.c:2137 - draw_text(RomText.plain("gText_Lv") .. lv, lx, ly, 36, "NORMAL") + draw_text(RomText.plain("gText_Lv") .. lv, lx + dx, ly, 36, "NORMAL") end local gender = SummaryData.gender(mon) local gx, gy = cxy("gender", 105, 18) + gx = gx + dx if gender == "M" then FrlgFont.draw("♂", gx, gy, { colors = FrlgFont.COLOR.MALE, small = false }) elseif gender == "F" then FrlgFont.draw("♀", gx, gy, { colors = FrlgFont.COLOR.FEMALE, small = false }) end - - if SummaryData.isShiny(mon) then - local sx, sy = 8, isMovesPage and 24 or 40 - SummaryChrome.drawShinyStar(sx, sy) - end - - local ailment = SummaryData.statusAilment(mon) - if ailment > 0 then - local ax, ay = 16, isMovesPage and 44 or 38 - SummaryChrome.drawStatusIcon(ax, ay, ailment) - end - - if not isMovesPage then - draw_ball_icon(mon) - end - - -- pokefirered/src/pokemon_summary_screen.c:4716 - if SummaryMenu.showsPokerusIcon(mon) then - SummaryChrome.drawPokerus(110, 88) - end - - -- In pret pokefirered (pokemon_summary_screen.c:1635, 1681, 1979-1984, 4139-4175): - -- On PAGE_MOVES (Known Moves) and PAGE_MOVES_INFO (Move Details), the large 64x64 front pic is HIDDEN. - -- Instead, the 32x32 party mon icon is displayed below the level/name plate at (24, 34). - if isMovesPage then - local icon = Pokemon.monIcon(mon) - if icon and icon.image and love and love.graphics then - local iw = icon.w or 32 - local ih = icon.h or 32 - love.graphics.setColor(1, 1, 1, 1) - local q = icon.quads and icon.quads[0] - if q then - love.graphics.draw(icon.image, q, 24 - iw / 2, 34 - ih / 2) - else - love.graphics.draw(icon.image, 24 - iw / 2, 34 - ih / 2) - end - end - else - local pic = c.monPic or { x = 60, y = 65 } - local cx, cy = pic.x or 60, pic.y or 65 - local front = Pokemon.monFrontPic(mon) - if front and front.image and love and love.graphics then - local iw = front.w or 64 - local ih = front.h or 64 - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(front.image, cx - iw / 2, cy - ih / 2) - else - local icon = Pokemon.monIcon(mon) - if icon and icon.image and love and love.graphics then - local iw = icon.w or 32 - local ih = icon.h or 32 - love.graphics.setColor(1, 1, 1, 1) - local q = icon.quads and icon.quads[0] - if q then - love.graphics.draw(icon.image, q, cx - iw / 2, cy - ih / 2) - else - love.graphics.draw(icon.image, cx - iw / 2, cy - ih / 2) - end - end - end - end end - local function draw_page_info(mon) local species = Pokemon.speciesOf(mon) local t1 = mon.type1 or (Pokemon.types and Pokemon.types(species) and Pokemon.types(species)[1]) or "NORMAL" @@ -609,31 +817,44 @@ local function draw_page_info(mon) end end +-- pokefirered/src/pokemon_summary_screen.c:2148 +function SummaryMenu.rightAlign(str, width) + return width - #tostring(str) * 6 +end + +local function draw_skill_bars(mon, dx) + local curHp = tonumber(mon.hp or mon.currentHp) or 0 + local maxHp = tonumber(mon.maxHp or mon.maxhp) or 1 + local bar = coords().hpBar or { x = 168, y = 32 } + SummaryChrome.drawHpBar((bar.x or 168) + dx, bar.y or 32, curHp, maxHp) + local prog = SummaryData.expProgress(mon) + local expBar = coords().expBar or { x = 152, y = 128 } + SummaryChrome.drawExpBar((expBar.x or 152) + dx, expBar.y or 128, prog.progressPercent) +end + local function draw_page_skills(mon) local curHp = tonumber(mon.hp or mon.currentHp) or 0 local maxHp = tonumber(mon.maxHp or mon.maxhp) or 1 local hx, hy = cxy("hpText", 174, 20) - draw_text(string.format("%d/%d", curHp, maxHp), hx, hy, 56, "NORMAL") + local hpStr = string.format("%d/%d", curHp, maxHp) + -- pokefirered/src/pokemon_summary_screen.c:2170 + draw_text(hpStr, hx + SummaryMenu.rightAlign(hpStr, 63), hy, 63, "NORMAL") - local bar = coords().hpBar or { x = 168, y = 32 } - SummaryChrome.drawHpBar(bar.x or 168, bar.y or 32, curHp, maxHp) + draw_skill_bars(mon, 0) - local natureId = SummaryData.nature(mon) local stats = { - { key = "atk", val = mon.attack or mon.atk or 0, coord = "atk", fy = 38 }, - { key = "def", val = mon.defense or mon.def or 0, coord = "def", fy = 51 }, - { key = "spAtk", val = mon.spAtk or mon.spatk or 0, coord = "spAtk", fy = 64 }, - { key = "spDef", val = mon.spDef or mon.spdef or 0, coord = "spDef", fy = 77 }, - { key = "spd", val = mon.speed or mon.spe or 0, coord = "spd", fy = 90 }, + { val = mon.attack or mon.atk or 0, coord = "atk", fy = 38 }, + { val = mon.defense or mon.def or 0, coord = "def", fy = 51 }, + { val = mon.spAtk or mon.spatk or 0, coord = "spAtk", fy = 64 }, + { val = mon.spDef or mon.spdef or 0, coord = "spDef", fy = 77 }, + { val = mon.speed or mon.spe or 0, coord = "spd", fy = 90 }, } for _, st in ipairs(stats) do - local mod = SummaryData.natureStatModifier(natureId, st.key) - local color = "NORMAL" - if mod > 1.0 then color = "UP" - elseif mod < 1.0 then color = "DOWN" end local x, y = cxy(st.coord, 210, st.fy) - draw_text(string.format("%d", tonumber(st.val) or 0), x, y, 32, color) + local str = string.format("%d", tonumber(st.val) or 0) + -- pokefirered/src/pokemon_summary_screen.c:2502 + draw_text(str, x + SummaryMenu.rightAlign(str, 27), y, 27, "NORMAL") end local lx, ly = cxy("expPointsLabel", 74, 103) @@ -643,12 +864,12 @@ local function draw_page_skills(mon) local prog = SummaryData.expProgress(mon) local ex, ey = cxy("expTotal", 175, 103) - draw_text(string.format("%d", prog.totalExp), ex, ey, 56, "NORMAL") + local expStr = string.format("%d", prog.totalExp) + -- pokefirered/src/pokemon_summary_screen.c:2507 + draw_text(expStr, ex + SummaryMenu.rightAlign(expStr, 63), ey, 63, "NORMAL") local nx, ny = cxy("expNext", 175, 116) - draw_text(string.format("%d", prog.expNeeded), nx, ny, 56, "NORMAL") - - local expBar = coords().expBar or { x = 152, y = 128 } - SummaryChrome.drawExpBar(expBar.x or 152, expBar.y or 128, prog.progressPercent) + local nextStr = string.format("%d", prog.expNeeded) + draw_text(nextStr, nx + SummaryMenu.rightAlign(nextStr, 63), ny, 63, "NORMAL") -- Party stores ability as numeric id (e.g. 65 = OVERGROW); resolve to name. local abilityId = tonumber(mon.abilityId) or tonumber(mon.ability) @@ -683,39 +904,108 @@ end -- FRENZY PLANT), and a translated one can use the rest. local MOVE_NAME_RIGHT = (20 + 10) * 8 -local function draw_page_moves(mon, isDetail) +-- pokefirered/src/pokemon_summary_screen.c:2532 +function SummaryMenu.ppColorIndex(cur, max, hasMove) + cur, max = tonumber(cur) or 0, tonumber(max) or 0 + if not hasMove or cur == max then return 0 end + if cur == 0 then return 3 end + if max == 3 then + if cur == 2 then return 2 elseif cur == 1 then return 1 end + return 0 + elseif max == 2 then + return (cur == 1) and 1 or 0 + end + if cur <= math.floor(max / 4) then return 2 end + if cur <= math.floor(max / 2) then return 1 end + return 0 +end + +local moveColorCache = setmetatable({}, { __mode = "k" }) + +-- pokefirered/src/pokemon_summary_screen.c:645 +local function move_text_colors(idx) + local m = SummaryChrome.manifest() + local src = m and m.moveTextColors + local c = src and src[idx] + if not c then return "NORMAL" end + local cache = moveColorCache[src] + if not cache then cache = {} moveColorCache[src] = cache end + if not cache[idx] then + local function rgb(t) return { (t[1] or 0) / 255, (t[2] or 0) / 255, (t[3] or 0) / 255, 1 } end + cache[idx] = { fg = rgb(c.fg), shadow = rgb(c.shadow), bg = FrlgFont.STDPAL[0] } + end + return cache[idx] +end + +local function draw_move_row(slot, m) + local colorIdx = SummaryMenu.ppColorIndex(m and m.pp, m and m.maxPp, m ~= nil) + local colors = move_text_colors(colorIdx) + if m then + SummaryChrome.drawTypeBadge(m.type, slot.typeX, slot.typeY) + end + -- pokefirered/src/pokemon_summary_screen.c:2543 + draw_text(m and m.name or RomText.plain("gText_PokeSum_OneHyphen"), slot.nameX, slot.nameY, + MOVE_NAME_RIGHT - slot.nameX, move_text_colors(0)) + draw_text(RomText.plain("gText_PokeSum_PP"), slot.ppX, slot.ppY, 16, colors) + if not m then + -- pokefirered/src/pokemon_summary_screen.c:2263 + draw_text(RomText.plain("gText_PokeSum_TwoHyphens"), slot.ppX + 9, slot.ppY, 24, colors) + return + end + local cur, max = tostring(m.pp), tostring(m.maxPp) + -- pokefirered/src/pokemon_summary_screen.c:2571 + draw_text(cur, slot.ppX + 10 + SummaryMenu.rightAlign(cur, 12), slot.ppY, 24, colors) + draw_text(RomText.plain("gText_Slash"), slot.ppX + 22, slot.ppY, 8, colors) + draw_text(max, slot.ppX + 28 + SummaryMenu.rightAlign(max, 12), slot.ppY, 24, colors) +end + +local function draw_page_moves(mon, isDetail, parts) local moves = moves_for_mon(mon) local slots = move_slots() - local maxSlot = (SummaryMenu._mode == "select_move") and 5 or 4 + parts = parts or (isDetail and { rows5 = true, cursor = true, types = true, bottom = true }) or {} - for i = 1, maxSlot do + for i = 1, (parts.rows5 and 5 or 4) do local slot = slots[i] or { nameX = 163, nameY = 21 + (i - 1) * 28, typeX = 123, typeY = 21 + (i - 1) * 28, ppX = 196, ppY = 32 + (i - 1) * 28, } - local m = moves[i] - if m then - SummaryChrome.drawTypeBadge(m.type, slot.typeX, slot.typeY) - draw_text(m.name, slot.nameX, slot.nameY, MOVE_NAME_RIGHT - slot.nameX, "NORMAL") - draw_text(string.format("%d/%d", m.pp, m.maxPp), slot.ppX, slot.ppY, 40, "NORMAL") - elseif i == 5 and SummaryMenu._forgetMove then - -- pokefirered/src/pokemon_summary_screen.c:2526 - draw_text(RomText.plain("gFameCheckerText_Cancel"), slot.nameX, slot.nameY, MOVE_NAME_RIGHT - slot.nameX, "NORMAL") + if i <= 4 then + draw_move_row(slot, moves[i]) + elseif SummaryMenu._mode == "select_move" and not SummaryMenu._forgetMove then + draw_move_row(slot, moves[5]) else - draw_text("-", slot.typeX + 8, slot.typeY + 2, 16, "NORMAL") - draw_text("----------", slot.nameX, slot.nameY, 64, "NORMAL") + -- pokefirered/src/pokemon_summary_screen.c:2526 + draw_text(RomText.plain("gFameCheckerText_Cancel"), slot.nameX, slot.nameY, MOVE_NAME_RIGHT - slot.nameX, move_text_colors(0)) end end - if isDetail then - local curY = 18 + (SummaryMenu._moveCursor - 1) * 28 - SummaryChrome.drawMoveSelectionCursor(120, curY, false) + if parts.cursor then + if not (SummaryMenu._swapSlot and SummaryMenu._blink.hidden) then + local curY = 18 + (SummaryMenu._moveCursor - 1) * 28 + SummaryChrome.drawMoveSelectionCursor(120, curY, false) + end if SummaryMenu._swapSlot then local swapY = 18 + (SummaryMenu._swapSlot - 1) * 28 SummaryChrome.drawMoveSelectionCursor(120, swapY, true) end + end + if parts.types then + local species = Pokemon.speciesOf(mon) + local types = Pokemon.types and Pokemon.types(species) or {} + local t1 = mon.type1 or types[1] or "NORMAL" + local t2 = mon.type2 or types[2] + -- pokefirered/src/pokemon_summary_screen.c:3375 + local ax, ay = cxy("movesInfoType1", 48, 35) + SummaryChrome.drawTypeBadge(t1, ax, ay) + if t2 and t2 ~= t1 and t2 ~= "" then + local bx, by = cxy("movesInfoType2", 84, 35) + SummaryChrome.drawTypeBadge(t2, bx, by) + end + end + + if parts.bottom then local selMove = moves[SummaryMenu._moveCursor] if SummaryMenu._hmNotice then local descBox = moves_info_coords().desc or { x = 7, y = 98, w = 112 } @@ -726,8 +1016,9 @@ local function draw_page_moves(mon, isDetail) local power = mi.power or { x = 57, y = 57 } local accuracy = mi.accuracy or { x = 57, y = 71 } local descBox = mi.desc or { x = 7, y = 98, w = 112 } - draw_text(selMove.power, power.x, power.y, 32, "NORMAL") - draw_text(selMove.accuracy, accuracy.x, accuracy.y, 32, "NORMAL") + -- pokefirered/src/pokemon_summary_screen.c:2297 + draw_text(selMove.power, power.x + SummaryMenu.rightAlign(selMove.power, 18), power.y, 32, "NORMAL") + draw_text(selMove.accuracy, accuracy.x + SummaryMenu.rightAlign(selMove.accuracy, 18), accuracy.y, 32, "NORMAL") local desc = SummaryData.moveDescription(selMove.id, selMove.name) draw_text(desc, descBox.x, descBox.y, descBox.w or 112, "NORMAL") end @@ -737,8 +1028,11 @@ end local function draw_page_egg(mon) -- pokefirered/src/pokemon_summary_screen.c:4016 MON_DATA_SPECIES_OR_EGG local species = Pokemon.speciesOrEgg(mon) - local nx, ny = cxy("name", 40, 18) - draw_text(RomText.plain("gText_EggNickname"), nx, ny, 64, "NORMAL") + -- pokefirered/src/pokemon_summary_screen.c:2467 + local sx, sy = cxy("species", 167, 35) + draw_text(RomText.plain("gText_EggNickname"), sx, sy, 64, "NORMAL") + -- pokefirered/src/pokemon_summary_screen.c:2495 + draw_text(SummaryData.eggHatchText(mon), 120 + 7, 16 + 45, 113, "NORMAL") local pic = coords().monPic or { x = 60, y = 65 } local cx, cy = pic.x or 60, pic.y or 65 @@ -747,17 +1041,20 @@ local function draw_page_egg(mon) local iw = front.w or 64 local ih = front.h or 64 love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(front.image, cx - iw / 2, cy - ih / 2) + local nf = SummaryChrome.manifest() and SummaryChrome.manifest().noFlip + -- pokefirered/src/pokemon_summary_screen.c:4037 + draw_flipped(front.image, nil, cx - iw / 2 + SummaryMenu._bounce.dx, cy - ih / 2, iw, + not (nf and nf[tonumber(species) or -1])) end draw_ball_icon(mon) local memo = coords().memo or { x = 8, y = 115, w = 224 } local memoLines = SummaryData.formatTrainerMemo(mon, SummaryMenu._playerState, { enemyParty = SummaryMenu._enemyParty, owner = SummaryMenu._owner }) - local memoY = memo.y or 80 + local memoY = memo.y or 115 for _, line in ipairs(memoLines) do draw_text(line, memo.x or 8, memoY, memo.w or 224, "NORMAL") - memoY = memoY + 28 + memoY = memoY + 14 end end @@ -804,15 +1101,137 @@ end SummaryMenu.controlsString = get_controls_str -local function draw_top_bar_text(page, isEgg) +local function draw_top_bar_text(page, isEgg, dx) + dx = dx or 0 + if dx >= 240 then return end local title = PAGE_TITLES[page] - FrlgFont.draw(title, 4, 1, { + FrlgFont.draw(title, 4 + dx, 1, { colors = FrlgFont.COLOR.WHITE, small = false, }) local ctrl = get_controls_str(page, isEgg) - PokedexChrome.drawControlInfo(ctrl, 236, 1) + PokedexChrome.drawControlInfo(ctrl, 236 + dx, 1) +end + +local PAGE_LAYERS = { + [PAGE_INFO] = { bg3 = "info", progress = "info", layer = "info" }, + [PAGE_SKILLS] = { bg3 = "info", progress = "skills", layer = "skills" }, + [PAGE_MOVES] = { bg3 = "info", progress = "moves", layer = "moves" }, + [PAGE_MOVES_INFO] = { bg3 = "moves", progress = "moves_info", layer = "moves_info" }, + [PAGE_EGG] = { bg3 = "info", progress = "egg", layer = "egg" }, +} + +-- pokefirered/src/pokemon_summary_screen.c:3091 +function SummaryMenu.slideOffset(frame, incoming) + local hofs = math.min(math.max(frame or 0, 0), SLIDE_FRAMES) * SLIDE_STEP + return incoming and (240 - hofs) or hofs +end + +-- pokefirered/src/pokemon_summary_screen.c:1929 +local function draw_page_bg(page, shiny) + local L = PAGE_LAYERS[page] or PAGE_LAYERS[PAGE_INFO] + local progress = L.progress + if page == PAGE_MOVES_INFO and SummaryMenu._mode == "select_move" then + progress = "moves_info_select" + end + SummaryChrome.drawBg3(L.bg3, shiny) + SummaryChrome.drawProgress(progress, shiny) + if page == PAGE_MOVES_INFO then + SummaryChrome.drawLayer("moves", 0, shiny) + end + SummaryChrome.drawLayer(L.layer, 0, shiny) +end + +local function draw_page_text(mon, page) + if page == PAGE_INFO then + draw_page_info(mon) + elseif page == PAGE_SKILLS then + draw_page_skills(mon) + elseif page == PAGE_MOVES then + draw_page_moves(mon, false) + elseif page == PAGE_MOVES_INFO then + draw_page_moves(mon, true) + end +end + +function SummaryMenu.flipStep() + local s = SummaryMenu._slide + local T = FLIP_TIMING[s.kind] or FLIP_TIMING.page + return math.min(math.max((s.frame or 0) - T.delay, 0), SLIDE_FRAMES) +end + +-- pokefirered/src/pokemon_summary_screen.c:1233 +local function draw_page_flip(mon, shiny) + local s = SummaryMenu._slide + local T = FLIP_TIMING.page + local from, to = s.prevPage, SummaryMenu._page + local toL, fromL = PAGE_LAYERS[to], PAGE_LAYERS[from] + local step = SummaryMenu.flipStep() + local swapped = s.frame >= T.header + SummaryChrome.drawBg3("info", shiny) + SummaryChrome.drawProgress((swapped and toL or fromL).progress, shiny) + local toX = 0 + if s.direction > 0 then + SummaryChrome.drawLayer(toL.layer, 0, shiny) + -- pokefirered/src/pokemon_summary_screen.c:1665 + if to == PAGE_SKILLS then draw_skill_bars(mon, 0) end + SummaryChrome.drawLayer(fromL.layer, SummaryMenu.slideOffset(step, false), shiny) + else + SummaryChrome.drawLayer(fromL.layer, 0, shiny) + toX = SummaryMenu.slideOffset(step, true) + SummaryChrome.drawLayer(toL.layer, toX, shiny) + -- pokefirered/src/pokemon_summary_screen.c:3027 + if to == PAGE_SKILLS then draw_skill_bars(mon, toX) end + end + -- pokefirered/src/pokemon_summary_screen.c:3171 + if swapped then + draw_top_bar_text(to, false, toX) + draw_header(mon, true, toX) + else + draw_top_bar_text(from, false, 0) + draw_header(mon, true, 0) + end + draw_left_sprites(mon, to) + -- pokefirered/src/pokemon_summary_screen.c:1310 + if s.frame >= T.text then draw_page_text(mon, to) end +end + +-- pokefirered/src/pokemon_summary_screen.c:1332 +local function draw_detail_flip(mon, shiny) + local s = SummaryMenu._slide + local T = FLIP_TIMING[s.kind] + local f = s.frame + local incoming = s.kind == "detail" + local detailBg + if incoming then detailBg = f >= T.bg else detailBg = f < T.bg end + SummaryChrome.drawBg3(detailBg and "moves" or "info", shiny) + SummaryChrome.drawProgress(detailBg and "moves_info" or "moves", shiny) + SummaryChrome.drawLayer("moves", 0, shiny) + local layerX = SummaryMenu.slideOffset(SummaryMenu.flipStep(), incoming) + if layerX < 240 then SummaryChrome.drawLayer("moves_info", layerX, shiny) end + + if f < T.header then + draw_top_bar_text(s.prevPage, false, 0) + draw_header(mon, s.prevPage ~= PAGE_MOVES_INFO, 0) + else + draw_top_bar_text(SummaryMenu._page, false, incoming and layerX or 0) + -- pokefirered/src/pokemon_summary_screen.c:1412 + if f >= T.name then draw_header(mon, SummaryMenu._page ~= PAGE_MOVES_INFO, 0) end + end + + local before, after = f < T.hide, f >= T.show + if before then + draw_left_sprites(mon, s.prevPage) + elseif after then + draw_left_sprites(mon, SummaryMenu._page) + end + + if incoming then + draw_page_moves(mon, true, { rows5 = f >= T.list5, cursor = after, types = f >= T.name, bottom = f >= T.bottom }) + else + draw_page_moves(mon, true, { rows5 = f < T.list5, cursor = before, types = before, bottom = f < T.bottom }) + end end function SummaryMenu.draw() @@ -820,36 +1239,30 @@ function SummaryMenu.draw() local mon = current_mon() if not mon then return end - -- 1. BACKGROUND LAYER (Transforms during page slide transitions) - if SummaryMenu._slide.active then - local p = SummaryMenu._slide.timer / SummaryMenu._slide.duration - p = math.max(0.0, math.min(1.0, p)) - local dir = SummaryMenu._slide.direction - local curOffset = math.floor(240 * (1.0 - p) * dir) - local prevOffset = math.floor(-240 * p * dir) + local page = SummaryMenu._page + local isEgg = Pokemon.isEgg(mon) + -- pokefirered/src/pokemon_summary_screen.c:2009 + local shiny = (not isEgg) and SummaryData.isShiny(mon) + local s = SummaryMenu._slide - SummaryChrome.drawPageBg(SummaryMenu._slide.prevPage, prevOffset) - SummaryChrome.drawPageBg(SummaryMenu._page, curOffset) - else - SummaryChrome.drawPageBg(SummaryMenu._page, 0) - end - - -- 2. OVERLAY LAYER (Foreground elements anchored to screen coordinates) - draw_top_bar_text(SummaryMenu._page, Pokemon.isEgg(mon)) - if SummaryMenu._page == PAGE_EGG then - draw_page_egg(mon) - else - draw_header(mon) - if SummaryMenu._page == PAGE_INFO then - draw_page_info(mon) - elseif SummaryMenu._page == PAGE_SKILLS then - draw_page_skills(mon) - elseif SummaryMenu._page == PAGE_MOVES then - draw_page_moves(mon, false) - elseif SummaryMenu._page == PAGE_MOVES_INFO then - draw_page_moves(mon, true) + if s.active and page ~= PAGE_EGG then + if s.kind == "page" then + draw_page_flip(mon, shiny) + else + draw_detail_flip(mon, shiny) end + return end + + draw_page_bg(page, shiny) + draw_top_bar_text(page, isEgg) + if page == PAGE_EGG then + draw_page_egg(mon) + return + end + draw_header(mon, page ~= PAGE_MOVES_INFO) + draw_left_sprites(mon, page) + draw_page_text(mon, page) end return SummaryMenu diff --git a/tests/drivers/game3_catch_ball_sheets_2445.lua b/tests/drivers/game3_catch_ball_sheets_2445.lua index 35557883..af0ff800 100644 --- a/tests/drivers/game3_catch_ball_sheets_2445.lua +++ b/tests/drivers/game3_catch_ball_sheets_2445.lua @@ -106,6 +106,14 @@ return function(game) return false end + local Healthbox = require("src.core.game3.battle.healthbox") + local BattleChrome = require("src.ui.game3.battle_chrome") + local function enemyBoxRect() + local hb = Anim.stage().healthbox and Anim.stage().healthbox.enemy + local w, h = BattleChrome._enemyBox:getDimensions() + return Healthbox.ENEMY_CENTER.x - 32 + ((hb and hb.ox) or 0), Healthbox.ENEMY_CENTER.y - 16, w, h + end + local function throw(item) local prev = CatchSeq._ball Ui._pendingCommand = { kind = "bag", user = "player", itemId = item } @@ -136,8 +144,27 @@ return function(game) local b = throw(item) if not b then pass("2445 throw " .. name, false) break end draws = {} - U.wait(14) - U.still(game, DIR .. "/2445_" .. name .. "_midthrow.png") + local clear = false + local startX + for _ = 1, 120 do + U.wait(1) + local s = Anim.stage().ball + if s.visible and s.frame == 0 then + local bx = (s.x or 0) + (s.ox or 0) - 8 + local by = (s.y or 0) + (s.oy or 0) - 8 + startX = startX or bx + local hbx, hby, hbw, hbh = enemyBoxRect() + local apart = bx + 16 <= hbx or bx >= hbx + hbw or by + 16 <= hby or by >= hby + hbh + if apart and bx >= 0 and by >= 0 and bx - startX >= 24 then + U.still(game, DIR .. "/2445_" .. name .. "_midthrow.png") + print(string.format("[driver] %s midthrow ball (%d,%d) healthbox (%d,%d %dx%d)", + name, bx, by, hbx, hby, hbw, hbh)) + clear = true + break + end + end + end + pass("2445 throw " .. name .. " clear of healthbox", clear) local midCols = drawnCols() local opened = false for _ = 1, 200 do diff --git a/tests/drivers/game3_collision_elevation_2441.lua b/tests/drivers/game3_collision_elevation_2441.lua index 790778d4..d1059273 100644 --- a/tests/drivers/game3_collision_elevation_2441.lua +++ b/tests/drivers/game3_collision_elevation_2441.lua @@ -207,10 +207,16 @@ return function(game) end end end - local function push(dir, lid, bx, by) + local function push(dir, lid, bx, by, beforeShot) Flags.setFlag(Space.store, Space.vm and Space.vm.ctx, FieldMoves.SYS_FLAGS.USE_STRENGTH, true) Objects.setObjectXY(lid, bx, by) U.wait(4) + if beforeShot then + local eo = Objects.find(lid) + result(eo and eo.cellX == bx and eo.cellY == by, "seafoam_boulder_before_push", + eo and string.format("boulder (%d,%d) %s", eo.cellX, eo.cellY, pos())) + U.still(game, beforeShot) + end for _ = 1, 10 do U.hold(game, dir, 2) if Player.boulderPush then break end @@ -229,17 +235,51 @@ return function(game) if result(sb ~= nil, "seafoam_boulder_found") then local lid = sb.localId goTo(SF, 5, 8, "down") - local b = push("down", lid, 5, 9) - result(b and b.cellX == 5 and b.cellY == 9 and at(5, 8), "seafoam_boulder_not_pushed_onto_elev4", + local b = push("down", lid, 5, 9, DIR .. "/2441_seafoam_boulder_before_push.png") + result(b and b.cellX == 5 and b.cellY == 9 and at(5, 8) and Player.facing == "down", + "seafoam_boulder_not_pushed_onto_elev4", b and string.format("boulder (%d,%d) cur=%s", b.cellX, b.cellY, tostring(b.currentElevation))) - U.still(game, DIR .. "/2441_seafoam_boulder_stays_on_floor.png") + U.still(game, DIR .. "/2441_seafoam_boulder_after_refused_push.png") goTo(SF, 4, 9, "right") b = push("right", lid, 5, 9) result(b and b.cellX == 6 and b.cellY == 9, "seafoam_boulder_pushes_along_floor", b and string.format("boulder (%d,%d)", b.cellX, b.cellY)) end - print("[driver] 7. save and continue standing on a Cerulean Cave platform") + print("[driver] 7. Pokemon Center escalator cells") + local Warp = require("src.core.game3.warp") + local PC1 = "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F" + local PC2 = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" + goTo(PC1, 3, 6, "left") + step("left") + result(Map.current == PC1 and at(2, 6) and Player.currentElevation == 4, "pc1f_elev0_to_elev4_approach", + pos() .. " cur=" .. tostring(Player.currentElevation)) + local onStairs = false + for _ = 1, 20 do + U.hold(game, "left", 1) + for _ = 1, 60 do + if Map.current == PC1 and at(1, 6) and not Player.moving then break end + if Map.current ~= PC1 then break end + U.wait(1) + end + if Map.current == PC1 and at(1, 6) and not Player.moving then + onStairs = true + U.still(game, DIR .. "/2441_pc1f_on_stairs.png") + break + end + if Map.current ~= PC1 then break end + end + result(onStairs and Warp.isEscalatorActive(), "pc1f_on_stairs_before_warp", pos()) + for _ = 1, 600 do + if Map.current == PC2 and not Warp.isBusy() then break end + U.wait(1) + end + settle(300) + U.wait(30) + result(Map.current == PC2, "pc_stairs_up_reach_2f", tostring(Map.current) .. " " .. pos()) + U.still(game, DIR .. "/2441_pc2f_arrival.png") + + print("[driver] 8. save and continue standing on a Cerulean Cave platform") local CC = "FR_CERULEAN_CAVE_B1F" goTo(CC, 16, 9, "up") Bridge.persistSessionOnly(Runtime._mod, game) diff --git a/tests/drivers/game3_doubles.lua b/tests/drivers/game3_doubles.lua index 8acc8578..9c15f1a4 100644 --- a/tests/drivers/game3_doubles.lua +++ b/tests/drivers/game3_doubles.lua @@ -144,7 +144,26 @@ return function(game) result(log_has(Ui, "sent\nout ") and log_has(Ui, " and "), "intro used the two-mon send-out line") result(log_has(Ui, "Go! ") and log_has(Ui, " and\n"), "intro used Go! X and Y!") - local sawFaint, sawReplacement = false, false + local Commands = require("src.core.game3.battle.commands") + local Moves = require("src.core.game3.battle.moves") + local StatGrowth = require("src.ui.game3.stat_growth") + local function pick_move(state, id) + local b = state and state.battlers and state.battlers[id] + local mon = b and b.mon + if not mon then return nil end + local best, bestPow + for slot = 1, 4 do + local mv = mon.moves and mon.moves[slot] + if mv and mv ~= 0 and mv ~= "" and Commands.moveUsable(state, slot, id) then + local def = Moves.get(mv) + local pow = def and tonumber(def.power) or 0 + if not best or pow > bestPow then best, bestPow = slot, pow end + end + end + return best + end + + local sawFaint, sawReplacement, sawSelErr = false, false, false local guard = 0 while Battle.isActive() and guard < 20000 do guard = guard + 1 @@ -189,10 +208,45 @@ return function(game) U.tap(game, "a") U.wait(8) elseif phase == "command" and Ui._mode == "moves" then + local who = Ui.activeBattler and Ui.activeBattler() or 0 + local slot + if not shots.target then + for s = 1, 4 do + if not slot and Commands.moveUsable(st, s, who) and Ui.targetSelection(st, who, s) then slot = s end + end + end + slot = slot or pick_move(st, who) + if slot then + for _ = 1, 6 do + local cur = (Ui._moveIndex or 1) - 1 + local want = slot - 1 + if cur == want then break end + local key = (cur % 2 ~= want % 2) and "right" or "down" + U.tap(game, key) + U.wait(4) + if (Ui._moveIndex or 1) - 1 == cur then + U.tap(game, key == "right" and "down" or "right") + U.wait(4) + end + end + end + U.tap(game, "a") + U.wait(8) + elseif phase == "command" and Ui._mode == "selmsg" then + sawSelErr = true U.tap(game, "a") U.wait(8) elseif phase == "command" and Ui._mode == "target" then - shot_once("target", "03_target_select") + if not shots.target then + shots.target = true + local cur = Ui.targetCursor() + for _ = 1, 40 do + if Ui.targetHidden(cur) then break end + U.wait(1) + end + result(Ui._mode == "target" and Ui.targetHidden(cur), "target cue blink is on for battler " .. tostring(cur)) + result(U.still(game, DIR .. "/03_target_select.png"), "screenshot 03_target_select") + end U.tap(game, "a") U.wait(8) elseif phase == "animating" or phase == "residuals" then @@ -201,12 +255,22 @@ return function(game) shot_once("mid", "04_mid_turn") end if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end + elseif StatGrowth.isOpen() then + shot_once("statgrowth", "05b_level_up_stats") + U.tap(game, "a") + U.wait(4) else if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end if Ui.choiceActive and Ui.choiceActive() then U.tap(game, "b") U.wait(4) end end end + if Battle.isActive() then + local lg = Ui.log and Ui.log() or {} + print(string.format("[driver] stuck guard=%d phase=%s mode=%s active=%s last=%q", guard, + tostring(Battle._phase), tostring(Ui._mode), tostring(Ui.activeBattler and Ui.activeBattler()), + tostring(lg[#lg]))) + end local res = Battle.getResult() or (st and st.result) result(not Battle.isActive(), "battle ended within " .. TURN_CAP .. " turns (turn=" .. tostring(st and st.turn) .. ")") result(res == "win", "player won the double battle (result=" .. tostring(res) .. ")") @@ -216,6 +280,7 @@ return function(game) else print("NOTE no player replacement was needed this run") end + if sawSelErr then print("NOTE a move selection was refused this run") end for _ = 1, 600 do if not (Space.vm and Space.vm:isRunning()) and not (Message.isOpen and Message.isOpen()) then break end U.tap(game, "a") diff --git a/tests/drivers/game3_export_cart_save.lua b/tests/drivers/game3_export_cart_save.lua new file mode 100644 index 00000000..048c2fde --- /dev/null +++ b/tests/drivers/game3_export_cart_save.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_export_cart_save" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + print((failures == 0 and "PASS" or "FAIL") .. " export_cart_save failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "PORT" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + local GameVersion = require("src.core.GameVersion") + local SaveFileIO = require("src.import.SaveFileIO") + local SaveConvert = require("src.save_convert.SaveConvert") + local Gen3Save = require("src.save_convert.Gen3Save") + local L = require("src.save_convert.Gen3Layout") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 + Flags.setFlag(Space.store, nil, 0x828, true) + local ok = Party.giveMon(session, 4, 5) + result(ok and #session.party == 1, "a Charmander Lv5 joins the port party") + local Mail = require("src.core.game3.mail") + local mailId = Mail.giveMailToMon(session, session.party[1], 121) + Mail.slot(session, mailId).words = { 2601, 4128, 526, 2611, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF } + result(Mail.monHasMail(session.party[1]), "Charmander holds ORANGE MAIL with a letter") + session.dex.nationalUnlocked = true + + Map.load(nil, game, "FR_PALLET_TOWN", { x = 12, y = 16, facing = "left" }) + session.x, session.y, session.facing = 12, 16, "left" + Player.cellX, Player.cellY = 12, 16 + Player.px, Player.py = 12 * 16, 16 * 16 + Player.targetX, Player.targetY = 12, 16 + Player.facing = "left" + U.wait(60) + U.hold(game, "left", 34) + U.wait(40) + session = Runtime.getSession() + print(string.format("walked to %s %s,%s", tostring(session.map), tostring(session.x), tostring(session.y))) + result(session.map == "FR_PALLET_TOWN" and session.x < 12, "walked west through Pallet Town") + U.still(game, DIR .. "/2444_port_slot_pallet.png") + + local saved = game:saveGame() + result(saved ~= false, "the port slot saves") + local version = GameVersion.get() + local exOk, path = SaveFileIO.exportActiveSlot(version) + if not result(exOk == true, "SaveFileIO exports the port slot (" .. tostring(path) .. ")") then return finish() end + local f = io.open(path, "rb") + local bytes = f and f:read("*a") + if f then f:close() end + if not result(type(bytes) == "string" and #bytes == L.FLASH_SIZE, "the export is a 128K flash image") then + return finish() + end + local out = io.open(DIR .. "/2444_port_export_" .. version .. ".sav", "wb") + if out then out:write(bytes); out:close() end + + local c, why = Gen3Save.decode(bytes) + if not result(c ~= nil, "the export decodes as a valid FireRed/LeafGreen save" .. (c and "" or (" (" .. tostring(why) .. ")"))) then + return finish() + end + result(c.frlgMarker == 1 and c.counter == 1, "FRLG marker and first save counter") + result(c.specialSaveWarpFlags == L.CONTINUE_GAME_WARP, "continue-game warp flag set") + local w = c.continueGameWarp + result(w.group == 3 and w.num == 0 and w.x == session.x and w.y == session.y, + ("continue warp is Pallet %d,%d (%d:%d %d,%d)"):format(session.x, session.y, w.group, w.num, w.x, w.y)) + result((c.mapLayoutId or 0) > 0, "map layout id " .. tostring(c.mapLayoutId)) + result(c.name == "PORT" and c.money == session.money, "player name and money") + local p = c.party[1] or {} + result(#c.party == 1 and p.species == 4 and p.level == 5 and p.checksumOk, + ("party 1 is Charmander Lv5 (%s Lv%s)"):format(tostring(p.species), tostring(p.level))) + result(p.hp == session.party[1].hp and p.maxHp == session.party[1].maxHp, "party HP matches the port") + local fl = {} + for _, id in ipairs(c.flags) do fl[id] = true end + result(fl[0x828] == true, "FLAG_SYS_POKEMON_GET carried") + result(c.dexOwned[1] == 3, "Charmander owned in the dex (national 4)") + result(p.mail == mailId and c.mail[mailId + 1].itemId == 121 and c.mail[mailId + 1].words[1] == 2601, + ("the letter exports at mail index %s"):format(tostring(p.mail))) + result(c.dexNationalMagic == L.NATIONAL_DEX.magic and c.vars[L.NATIONAL_DEX.var] == L.NATIONAL_DEX.varValue, + "the National Dex unlock exports the cart's magic and var") + local back = SaveConvert.importSav(bytes, version, version) + result(back and back.party[1].species == 4 and back.map == "FR_PALLET_TOWN" and back.x == session.x, + "the export imports back to the same slot state") + finish() +end diff --git a/tests/drivers/game3_forced_elevation_s4.lua b/tests/drivers/game3_forced_elevation_s4.lua new file mode 100644 index 00000000..672a9472 --- /dev/null +++ b/tests/drivers/game3_forced_elevation_s4.lua @@ -0,0 +1,237 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_forced_elevation_s4" + +local failures = 0 +local function result(ok, label, detail) + print((ok and "PASS " or "FAIL ") .. label .. (detail and (" " .. detail) or "")) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS forced_elevation_s4") + love.event.quit(0) + else + print("FAIL forced_elevation_s4 failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local Objects = require("src.core.game3.objects") + local Space = require("src.core.game3.scripting.space") + local Message = require("src.ui.game3.message") + local ForcedMovement = require("src.core.game3.forced_movement") + local TrainerSight = require("src.core.game3.trainer_sight") + + local session = Runtime.getSession() + if not result(session ~= nil, "new_game_reached_field") then return finish() end + session.repelSteps = 5000 + + local function busy() + return (Space.vm and Space.vm:isRunning()) or (Message.isOpen and Message.isOpen()) + end + + local function settle(limit) + for _ = 1, (limit or 240) do + if not busy() then return true end + if Message.isOpen and Message.isOpen() then U.tap(game, "a") end + U.wait(4) + end + return false + end + + local function place(x, y, facing, surfing) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.moving = false + Player.progress = 0 + Player.facing = facing or "down" + Player.surfing = surfing and true or false + Player.dismounting = false + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing or "down" + end + end + + local function goTo(mapId, x, y, facing, surfing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing or "down" }) + place(x, y, facing, surfing) + U.wait(60) + settle(600) + place(x, y, facing, surfing) + U.wait(10) + end + + local function step(dir) + local fromX, fromY = Player.cellX, Player.cellY + for _ = 1, 3 do + U.hold(game, dir, 4) + for _ = 1, 60 do + U.wait(1) + if not Player.moving then break end + end + if Player.cellX ~= fromX or Player.cellY ~= fromY then break end + end + settle(200) + end + + local function at(x, y) return Player.cellX == x and Player.cellY == y end + local function pos() + return string.format("at (%d,%d) cur=%s surf=%s", Player.cellX, Player.cellY, + tostring(Player.currentElevation), tostring(Player.surfing)) + end + + local function firstObject() + for _, lid in ipairs(Objects._order) do + local eo = Objects._byId[lid] + if eo and eo.visible and not eo.hidden and not eo.passable then return eo end + end + end + + print("[driver] 1. constructed trainer on Safari Zone East sand, player on the rock stairs") + local E = "FR_SAFARI_ZONE_EAST" + goTo(E, 40, 18, "down") + local Field = require("src.core.game3.field") + for _ = 1, 600 do + if not Field.locked and not busy() then break end + U.wait(1) + end + result(not Field.locked, "field_unlocked_before_walking") + goTo(E, 40, 18, "down") + local stand = firstObject() + if stand then + Objects.setObjectXY(stand.localId, 40, 21) + stand.frozen = true + stand.facing = "up" + local def = {} + for k, v in pairs(stand.def or {}) do def[k] = v end + def.graphics, def.graphicsId, def.graphicsVar = 39, 39, nil + stand.def = def + stand.graphicsId = 39 + stand.sprite = require("src.core.game3.scripting.gfx_ids").spriteFor(39) or stand.sprite + require("src.core.game3.field_view")._nativeDirty = true + end + result(stand ~= nil and stand.cellX == 40 and stand.cellY == 21 and stand.currentElevation == 3, + "safari_watcher_drawn_on_elev3_sand", stand and ("elev=" .. tostring(stand.currentElevation)) or "none") + local watcher = { localId = stand and stand.localId or 250, cellX = 40, cellY = 21, facing = "up", sight = 3, + currentElevation = 3, visible = true, hidden = false, moving = false } + result(Player.currentElevation == 4 + and TrainerSight.checkLineOfSight(watcher, Player, game) == false, + "safari_elev3_trainer_misses_elev4_plateau", pos()) + step("down") + local seen, dist = TrainerSight.checkLineOfSight(watcher, Player, game) + result(at(40, 19) and Player.currentElevation == 0 and seen == true and dist == 2, + "safari_elev3_trainer_sees_player_on_elev0_stair", pos() .. " dist=" .. tostring(dist)) + local Popup = require("src.ui.game3.map_name_popup") + for _ = 1, 900 do + if not Popup.isActive() then break end + U.wait(1) + end + U.wait(2) + result(not Popup.isActive() and at(40, 19), "safari_map_name_popup_closed_before_shot", pos()) + U.still(game, DIR .. "/s4_safari_player_on_stair_in_sight_line.png") + + print("[driver] 2. an object on the Safari Zone East plateau still blocks the plateau walker") + local obj = firstObject() + if result(obj ~= nil, "safari_object_found") then + goTo(E, 38, 18, "right") + obj = Objects.find(obj.localId) + Objects.setObjectXY(obj.localId, 39, 18) + obj.frozen = true + U.wait(4) + step("right") + result(at(38, 18) and obj.currentElevation == 4 and Player.currentElevation == 4, + "plateau_object_same_elev_blocks", pos() .. " obj=" .. tostring(obj.currentElevation)) + U.still(game, DIR .. "/s4_safari_bump_into_elev4_object.png") + end + + print("[driver] 3. a ledge jump lands on an occupied cell") + local LM, lx, ly, npc + for _, m in ipairs({ "FR_ROUTE_1", "FR_ROUTE_3", "FR_ROUTE_4", "FR_ROUTE_22" }) do + goTo(m, 0, 0, "down") + lx, ly = nil, nil + for y = 0, Collision._heightCells - 3 do + for x = 0, Collision._widthCells - 1 do + if not lx and Collision.isWalkable(x, y) and Collision.elevationAt(x, y) == 3 + and Collision.ledgeLanding(game, x, y, "down") and not Objects.blocks(x, y) then + lx, ly = x, y + end + end + end + npc = firstObject() + if lx and npc then LM = m break end + end + if result(LM ~= nil, "ledge_and_object_found", + tostring(LM) .. " " .. tostring(lx) .. "," .. tostring(ly)) then + goTo(LM, lx, ly, "down") + npc = Objects.find(npc.localId) + Objects.setObjectXY(npc.localId, lx, ly + 2) + npc.frozen = true + U.wait(2) + local sawJump = false + U.hold(game, "down", 4) + local shotMid = false + for i = 1, 80 do + if Player.jumping then + sawJump = true + if not shotMid and i >= 8 then + shotMid = U.still(game, DIR .. "/s4_ledge_midjump_toward_object_below_ledge.png") + end + end + U.wait(1) + if not Player.moving then break end + end + result(sawJump and shotMid and at(lx, ly + 2) and npc.cellX == lx and npc.cellY == ly + 2, + "ledge_jump_lands_on_object_cell", pos()) + local ok = Collision.canEnter(game, lx, ly + 1, { fromX = lx, fromY = ly, dir = "down", + elevation = Player.currentElevation }) + result(ok == false, "ledge_cell_itself_still_impassable") + end + + print("[driver] 4. Seafoam B3F southward current against the elev 4 ledge") + local B3F = "FR_SEAFOAM_ISLANDS_B3F" + goTo(B3F, 16, 6, "down", true) + result(Collision.behavior(16, 6) == 0x53, "b3f_current_cell_is_southward", + "beh=" .. tostring(Collision.behavior(16, 6))) + result(Collision.elevationAt(16, 7) == 4, "b3f_cell_below_is_elev4", + "elev=" .. tostring(Collision.elevationAt(16, 7))) + for _ = 1, 90 do U.wait(1) end + result(at(16, 6) and Player.surfing and not Player.dismounting, + "b3f_current_holds_surfer_at_ledge", pos()) + result(ForcedMovement.forced == false, "b3f_forced_flag_cleared") + U.still(game, DIR .. "/s4_seafoam_b3f_current_held_below_elev4_ledge.png") + + print("[driver] 5. Route 12 fisher does not see a surfer below him") + local R12 = "FR_ROUTE_12" + goTo(R12, 17, 33, "up", true) + local fisher + for _, lid in ipairs(Objects._order) do + local eo = Objects._byId[lid] + if eo and eo.cellX == 17 and eo.cellY == 32 then fisher = eo end + end + if result(fisher ~= nil and (tonumber(fisher.sight) or 0) >= 1, "r12_fisher_found", + fisher and ("sight=" .. tostring(fisher.sight) .. " elev=" .. tostring(fisher.currentElevation))) then + fisher.facing = "down" + local seen = TrainerSight.checkLineOfSight(fisher, Player, game) + result(seen == false and Player.currentElevation == 1, "r12_fisher_misses_surfer", pos()) + for _ = 1, 120 do U.wait(1) end + result(not busy() and at(17, 33) and Player.surfing, "r12_no_battle_after_wait", pos()) + U.still(game, DIR .. "/s4_route12_surfer_below_fisher_unseen.png") + end + + finish() +end diff --git a/tests/drivers/game3_import_cart_save.lua b/tests/drivers/game3_import_cart_save.lua new file mode 100644 index 00000000..80fefd9b --- /dev/null +++ b/tests/drivers/game3_import_cart_save.lua @@ -0,0 +1,108 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_import_cart_save" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + print((failures == 0 and "PASS" or "FAIL") .. " import_cart_save failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end + +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + + local GameVersion = require("src.core.GameVersion") + local SaveData = require("src.core.SaveData") + local SaveFileIO = require("src.import.SaveFileIO") + local version = GameVersion.get() + local cart = unrle(require("tests.fixture_data.gen3_saves").images.fr_rich_game) + love.filesystem.write("picked_cart_2444.sav", cart) + local ok, slotId, info = SaveFileIO.importToSlot("picked_cart_2444.sav", version) + love.filesystem.remove("picked_cart_2444.sav") + if not result(ok == true, "the FireRed cart imports into " .. version .. " (" .. tostring(slotId) .. ")") then + return finish() + end + result(info == nil, "no confirm prompt and no fallback note") + result(SaveData.activeSlot(version) == slotId, "the imported slot is active") + + game:_handleBootAction({ action = "continue" }) + local Runtime = require("src.core.game3.runtime") + local s + for _ = 1, 900 do + s = Runtime.getSession() + if s and game.phase ~= "quest_log" and game.phase ~= "boot" then break end + if game.phase == "quest_log" then U.tap(game, "b") end + U.wait(4) + end + U.wait(90) + s = Runtime.getSession() + if not result(s ~= nil, "CONTINUE reached the field") then return finish() end + print(string.format("CONTINUE map=%s x=%s y=%s money=%s heal=%s %s,%s", tostring(s.map), tostring(s.x), + tostring(s.y), tostring(s.money), tostring(s.healMap), tostring(s.healX), tostring(s.healY))) + result(s.map == "FR_PLAYERS_HOUSE_2F" and s.x == 6 and s.y == 6, "continue lands in the bedroom at 6,6 like the cart") + result(s.money == 123456, "money 123456") + result(s.coins == 777, "coins 777") + result(s.healMap == "FR_PLAYERS_HOUSE_1F", "whiteout spot is Mom's house") + local want = { { 6, 36, "BLAZE" }, { 277, 10, "" }, { 172, 5, "EGG" }, { 201, 25, "" } } + for i, w in ipairs(want) do + local m = s.party[i] or {} + result(m.species == w[1] and m.level == w[2] and m.nickname == w[3], + ("party %d is species %d Lv%d (%s Lv%s %q)"):format(i, w[1], w[2], tostring(m.species), tostring(m.level), tostring(m.nickname))) + end + result(s.party[1].hp == 50 and s.party[1].status == "PSN", "Charizard keeps 50 HP and poison") + local box = s.storage.boxes[2].mons[20] + result(box and box.species == 150 and box.level == 70, "Mewtwo Lv70 in box 2 slot 20") + result(s.dex.owned[277] == true, "Treecko caught in the dex") + U.still(game, DIR .. "/2444_continue_bedroom.png") + + local StartMenu = require("src.ui.game3.start_menu") + local PartyMenu = require("src.ui.game3.party_menu") + U.tap(game, "start") + U.wait(30) + if not result(StartMenu.isOpen(), "start menu opened") then return finish() end + local idx + for i, e in ipairs(StartMenu.ENTRIES or {}) do + if e.id == "pokemon" then idx = i break end + end + if not result(idx ~= nil, "the start menu offers POKéMON") then return finish() end + for _ = 1, 20 do + if StartMenu.cursor == idx then break end + U.tap(game, "down") + U.wait(6) + end + U.tap(game, "a") + for _ = 1, 120 do + if PartyMenu.open then break end + U.wait(2) + end + U.wait(60) + if result(PartyMenu.open, "party screen opened") then + U.still(game, DIR .. "/2444_party_screen.png") + end + for _ = 1, 60 do + if not PartyMenu.open and not StartMenu.isOpen() then break end + U.tap(game, "b") + U.wait(6) + end + finish() +end diff --git a/tests/drivers/game3_shop_soft_reset.lua b/tests/drivers/game3_shop_soft_reset.lua new file mode 100644 index 00000000..4ccaf8f0 --- /dev/null +++ b/tests/drivers/game3_shop_soft_reset.lua @@ -0,0 +1,122 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_shop_soft_reset" +local MODE = os.getenv("S1_MODE") or "new_game" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + if failures == 0 then + print("PASS shop_soft_reset " .. MODE) + love.event.quit(0) + else + print("FAIL shop_soft_reset " .. MODE .. " failures=" .. failures) + love.event.quit(1) + end +end + +local function run(game) + local function waitBoot() + for _ = 1, 900 do + if game.phase == "boot" and game.boot then return true end + U.wait(1) + end + return false + end + local function enterField() + if MODE == "continue" then + game:_handleBootAction({ action = "continue" }) + for _ = 1, 3000 do + if game.phase == "field" then break end + if game.phase == "quest_log" and game.questPlayback then game.questPlayback.done = true end + U.wait(1) + end + else + game:_handleBootAction({ action = "new_game", name = "RED" }) + end + U.wait(240) + end + + waitBoot() + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + if MODE == "continue" then pcall(function() game:saveGame() end) end + + local Map = require("src.core.game3.map") + local MapCatalog = require("src.import.gba.map_catalog") + local Player = require("src.core.game3.player") + local ShopMenu = require("src.ui.game3.shop_menu") + local Fade = require("src.ui.game3.fade") + local Hud = require("src.ui.game3.hud") + local MART = MapCatalog.resolve("VermilionCity_Mart") + + local function placeAtClerk() + Map.load(nil, game, MART, { x = 2, y = 4, facing = "up" }) + if game.session then + game.session.x, game.session.y, game.session.facing = 2, 4, "up" + end + Player.cellX, Player.cellY = 2, 4 + Player.px, Player.py = 2 * 16, 4 * 16 + Player.targetX, Player.targetY = 2, 4 + Player.facing = "up" + U.wait(90) + end + local function toShopRoot() + placeAtClerk() + for _ = 1, 360 do + if ShopMenu.isOpen() and ShopMenu.mode == "root" and not ShopMenu._fading then return true end + U.tap(game, "a") + U.wait(6) + end + return false + end + + for _, case in ipairs({ "buy_fade", "root" }) do + if not result(toShopRoot(), case .. ": clerk opened the shop root") then return end + if case == "buy_fade" then + ShopMenu.cursor = 1 + U.tap(game, "a") + U.wait(4) + result(ShopMenu._fading == true and Fade.isActive(), case .. ": BUY started the shop fade") + U.still(game, DIR .. "/shop_reset_" .. MODE .. "_buy_midfade.png") + end + game.softResetRequested = true + U.wait(2) + result(waitBoot(), case .. ": soft reset reached the title") + enterField() + result(not ShopMenu.isOpen() and not ShopMenu._fading and not Hud.isMenuOpen(), + case .. ": shop closed in the new " .. MODE .. " session (open=" .. tostring(ShopMenu.open) + .. " _fading=" .. tostring(ShopMenu._fading) .. ")") + local sx, sy = Player.cellX, Player.cellY + local moved = false + for _, d in ipairs({ "left", "down", "right", "up" }) do + U.hold(game, d, 24) + U.wait(24) + if Player.cellX ~= sx or Player.cellY ~= sy then moved = true break end + end + result(moved, case .. ": player can walk in the new session menuOpen=" .. tostring(Hud.isMenuOpen())) + U.shot(game, DIR .. "/shop_reset_" .. MODE .. "_" .. case .. "_after.png") + local opened = toShopRoot() + result(opened, case .. ": second visit reached the shop root") + for _ = 1, 10 do + U.tap(game, "b") + U.wait(10) + if not ShopMenu.isOpen() then break end + end + result(not ShopMenu.isOpen(), case .. ": B closes the shop on the second visit") + for _ = 1, 30 do + if not Hud.busy() then break end + U.tap(game, "b") + U.wait(10) + end + end +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then result(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/game3_sign_walk_into.lua b/tests/drivers/game3_sign_walk_into.lua index 26166bdf..e74e7c48 100644 --- a/tests/drivers/game3_sign_walk_into.lua +++ b/tests/drivers/game3_sign_walk_into.lua @@ -72,25 +72,42 @@ return function(game) result(Player.cellX == SIGN_X and Player.cellY == SIGN_Y + 1, "the player stayed below the sign") local ctx = Space.vm and Space.vm.ctx result(ctx and ctx.msgBoxIsCancelable == true, "SetWalkingIntoSignVars made the box cancelable") + local Hud = require("src.ui.game3.hud") if opened then - for _ = 1, 120 do - if not Message.isTyping() then break end + local armed = false + for _ = 1, 900 do + if Hud._waitButton ~= nil and not Message.isTyping() then armed = true break end U.wait(1) end - result(U.shot(game, DIR .. "/spec_sign_walked_into.png"), "screenshot spec_sign_walked_into") + result(armed, "the sign box is fully shown and waiting for A/B") + result(U.shot(game, DIR .. "/sign_walked_into_waiting.png"), "screenshot sign_walked_into_waiting") end - U.wait(12) U.hold(game, "down", 20) U.wait(4) result(not Message.isOpen(), "pressing DOWN closed the sign") result(not Space.vm:isRunning(), "EventScript_CancelMessageBox ended the sign script") + result(Hud._waitButton == nil, "the cancel dropped the waitbuttonpress wait") + result(not Hud.busy(), "the field is not held busy after the cancel") local walked = false for _ = 1, 30 do if Player.cellY > SIGN_Y + 1 then walked = true break end U.wait(1) end result(walked or Player.facing == "down", "and the player turned or walked away (y=" .. tostring(Player.cellY) .. ")") + for _ = 1, 60 do + if not Player.moving then break end + U.wait(1) + end + local x0, y0 = Player.cellX, Player.cellY + U.hold(game, "right", 24) + for _ = 1, 60 do + if not Player.moving then break end + U.wait(1) + end + result(Player.cellX ~= x0 or Player.cellY ~= y0, + string.format("the next RIGHT moves the player without an A press (%d,%d -> %d,%d)", x0, y0, Player.cellX, Player.cellY)) + result(U.shot(game, DIR .. "/sign_walked_away_free.png"), "screenshot sign_walked_away_free") print("[driver] 2. an A-button read is not cancelable by walking") placeBelowSign("up") diff --git a/tests/drivers/game3_soft_reset_overlays.lua b/tests/drivers/game3_soft_reset_overlays.lua new file mode 100644 index 00000000..ec22021f --- /dev/null +++ b/tests/drivers/game3_soft_reset_overlays.lua @@ -0,0 +1,230 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_soft_reset_overlays" +local MODE = os.getenv("S2_MODE") or "new_game" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS soft_reset_overlays " .. MODE) + love.event.quit(0) + else + print("FAIL soft_reset_overlays " .. MODE .. " failures=" .. failures) + love.event.quit(1) + end +end + +local function waitBoot(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then return true end + U.wait(1) + end + return false +end + +local function enterField(game) + if MODE == "continue" then + game:_handleBootAction({ action = "continue" }) + for _ = 1, 3000 do + if game.phase == "field" then break end + if game.phase == "quest_log" and game.questPlayback then game.questPlayback.done = true end + U.wait(1) + end + else + game:_handleBootAction({ action = "new_game", name = "RED" }) + end + U.wait(240) +end + +local function run(game) + waitBoot(game) + if MODE == "continue" then + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + pcall(function() game:saveGame() end) + game.softResetRequested = true + U.wait(2) + waitBoot(game) + end + enterField(game) + local Runtime = require("src.core.game3.runtime") + local Player = require("src.core.game3.player") + local Hud = require("src.ui.game3.hud") + local Field = require("src.core.game3.field") + local Task = require("src.core.game3.task") + local Fade = require("src.ui.game3.fade") + local CaveTransition = require("src.ui.game3.cave_transition") + local MapPreviewScreen = require("src.ui.game3.map_preview_screen") + local MapSectionsExtract = require("src.import.gba.map_sections_extract") + local Sea = require("src.ui.game3.seagallop") + local StartMenu = require("src.ui.game3.start_menu") + local MonPic = require("src.ui.game3.mon_pic") + local BattleTransition = require("src.core.game3.battle_transition") + local Battle = require("src.core.game3.battle") + local BattleBridge = require("src.core.game3.battle_bridge") + local ShowMon = require("src.core.game3.field_move_show_mon") + local PokecenterHeal = require("src.core.game3.pokecenter_heal") + local StepEvents = require("src.core.game3.step_events") + if not result(Runtime.getSession() ~= nil, "reached the field") then return end + local function giveParty() + local session = Runtime.getSession() + session.party = {} + require("src.core.game3.party").giveMon(session, 39, 20) + end + local function sec(id) return MapSectionsExtract.ID_TO_SECTION[id] end + + local function tryMove() + local x0, y0 = Player.cellX, Player.cellY + for _, d in ipairs({ "down", "left", "right", "up" }) do + U.hold(game, d, 24) + U.wait(24) + if Player.cellX ~= x0 or Player.cellY ~= y0 then return true, d end + end + return false + end + + local scenarios = { + { name = "control", setup = function() end, active = function() return false end }, + { name = "cave_transition", setup = function(fired) + CaveTransition.start("enter", function() fired.cb = true end) + U.wait(8) + end, active = function() return CaveTransition.isActive() end }, + { name = "cave_preview", setup = function(fired) + MapPreviewScreen.runCave(sec("MAPSEC_MT_MOON"), function() fired.cb = true end) + U.wait(30) + end, active = function() return MapPreviewScreen.isActive() end }, + { name = "forest_preview", setup = function() + MapPreviewScreen.show(sec("MAPSEC_VIRIDIAN_FOREST")) + U.wait(20) + end, active = function() return MapPreviewScreen.isActive() end }, + { name = "seagallop", setup = function(fired) + Sea.start(0, 1, function() fired.cb = true end, function() fired.cb = true end) + U.wait(30) + end, active = function() return Sea.isActive() end }, + { name = "start_menu", setup = function() + U.tap(game, "start") + U.wait(12) + U.tap(game, "down") + U.wait(6) + U.tap(game, "down") + U.wait(6) + print("[s2] start_menu cursor before reset=" .. tostring(StartMenu.cursor)) + end, active = function() return StartMenu.isOpen() end, + after = function() + result(StartMenu.cursor == 1, "start_menu: cursor back on the top entry after " .. MODE + .. " (cursor=" .. tostring(StartMenu.cursor) .. ")") + end }, + { name = "step_event_message", setup = function() + local session = Runtime.getSession() + session.repelSteps = 1 + session.vars[0x4020] = 1 + StepEvents.onRepelStep(session, game) + U.wait(20) + end, active = function() return StepEvents.busy() end }, + { name = "step_event_tick", setup = function(fired) + StepEvents.queueEvent({ type = "probe", run = function() end, + tick = function() fired.cb = true end }) + U.wait(5) + end, active = function() return StepEvents.busy() end }, + { name = "mon_pic", setup = function() + MonPic.show(25) + U.wait(5) + end, active = function() return MonPic.isActive() end }, + { name = "battle_transition", setup = function(fired) + giveParty() + BattleBridge.start(Runtime._mod, game, + { species = 129, level = 5, moves = { 150 }, pp = { 40 }, item = 0 }, + { wild = true, done = function() fired.cb = true end }) + U.wait(20) + end, active = function() return BattleTransition.isActive() or Battle.isActive() end }, + { name = "battle_active", setup = function(fired) + giveParty() + BattleBridge.start(Runtime._mod, game, + { species = 129, level = 5, moves = { 150 }, pp = { 40 }, item = 0 }, + { wild = true, fade = false, done = function() fired.cb = true end }) + for _ = 1, 600 do + if Battle.isActive() then break end + U.wait(1) + end + U.wait(60) + end, active = function() return Battle.isActive() or BattleTransition.isActive() end }, + { name = "task", setup = function(fired) + Task.spawn(function(t) if t.frames > 400 then fired.cb = true return true end end) + U.wait(5) + end, active = function() return Task.count() > 0 end }, + { name = "bag_menu", setup = function() + U.tap(game, "start") + U.wait(12) + U.tap(game, "a") + U.wait(60) + end, active = function() + local B = package.loaded["src.ui.game3.bag_menu"] + return (B and B.isOpen()) or StartMenu.isOpen() + end }, + { name = "pokecenter_heal", setup = function() + giveParty() + PokecenterHeal.start() + U.wait(10) + end, active = function() return PokecenterHeal.isActive() end }, + { name = "show_mon", setup = function(fired) + ShowMon.start(25, {}, function() fired.cb = true end) + U.wait(10) + end, active = function() return ShowMon.isActive() end }, + { name = "hud_wait_button", setup = function(fired) + Hud.armWaitButton(function() fired.cb = true end) + U.wait(5) + end, active = function() return Hud._waitButton ~= nil end }, + { name = "fade", setup = function(fired) + Fade.begin(Fade.MODE.TO_BLACK, 1, function() fired.cb = true end) + U.wait(5) + end, active = function() return Fade.isActive() end }, + } + + for _, sc in ipairs(scenarios) do + local fired = {} + sc.setup(fired) + local was = sc.active() + print("[s2] " .. sc.name .. " active before reset=" .. tostring(was)) + if sc.name ~= "control" then result(was, sc.name .. ": overlay up before the reset") end + fired.cb = nil + game.softResetRequested = true + U.wait(2) + result(waitBoot(game), sc.name .. ": soft reset returned to the title") + enterField(game) + local still = sc.active() + result(not still, sc.name .. ": overlay gone after " .. MODE .. " (active=" .. tostring(still) .. ")") + result(not fired.cb, sc.name .. ": stale callback did not fire into the new session") + if sc.after then sc.after() end + local moved, dir = tryMove() + result(moved, sc.name .. ": player can move (" .. tostring(dir) .. ") busy=" .. tostring(Hud.busy()) + .. " menu=" .. tostring(Hud.isMenuOpen()) .. " locked=" .. tostring(Field.locked) + .. " tasks=" .. tostring(Task.count()) .. " battle=" .. tostring(Battle.isActive())) + U.shot(game, DIR .. "/soft_reset_" .. sc.name .. "_" .. MODE .. "_after.png") + if StartMenu.isOpen() then StartMenu.close(true) end + require("src.ui.game3.stack").clear() + if Battle.isActive() then pcall(Battle.abort, "run") end + if BattleTransition.isActive() then BattleTransition.abort() end + MonPic.hide() + local BM = package.loaded["src.ui.game3.bag_menu"] + if BM and BM.isOpen() then pcall(BM.close) end + if BM then BM.open = false end + StartMenu.open = false + PokecenterHeal._fx = nil + ShowMon._fx = nil + Hud.clearWaitButton() + Task.clear() + StepEvents.flush() + U.wait(30) + end +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then result(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/game3_speed_hotkey_h1.lua b/tests/drivers/game3_speed_hotkey_h1.lua new file mode 100644 index 00000000..0cb9d189 --- /dev/null +++ b/tests/drivers/game3_speed_hotkey_h1.lua @@ -0,0 +1,72 @@ +local U = require("tests.drivers.util") + +local failed = false +local function expect(cond, label) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then failed = true end +end + +local function steps(game, frames) + local n = 0 + local orig = game.fixedUpdate + game.fixedUpdate = function(self, dt) n = n + 1; return orig(self, dt) end + for _ = 1, frames do game:update(1 / 60) end + game.fixedUpdate = orig + return n +end + +local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + expect(game.phase == "field", "h1_field_reached") + + game.speedOverride = nil + game.options.speedOverworld = 1 + game.options.speedMenu = 1 + love.keypressed("escape", "escape", false) + U.wait(2) + love.keyreleased("escape", "escape") + U.wait(20) + expect(require("src.ui.game3.stack").busy(), "h1_start_menu_open") + key("1") + expect(game.options.speedOverworld == 2 and game.options.speedMenu == 1, + "h1_start_menu_1_bumps_overworld") + game.speedOverride = 1 + U.still(game, ".bazinga/BSA/09-24-26-01-followup/shots/game3_speed_hotkey_h1/h1_start_menu_speed2.png") + game.speedOverride = nil + love.keypressed("escape", "escape", false) + U.wait(2) + love.keyreleased("escape", "escape") + U.wait(30) + expect(game:logicSpeed() == 2 and steps(game, 10) == 20, "h1_start_menu_closed_walk_2x") + + key("kp1") + expect(game.options.speedOverworld == 3, "h1_numpad_1_bumps") + + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + expect(game.options.speedOverworld == 4, "h1_rt_bumps") + game:gamepadaxis(nil, "triggerleft", 1) + game:gamepadaxis(nil, "triggerleft", 0) + expect(game.options.speedOverworld == 3, "h1_lt_lowers") + game:gamepadpressed(nil, "rightshoulder") + game:gamepadreleased(nil, "rightshoulder") + expect(game.options.speedOverworld == 3, "h1_r_shoulder_stays_r") + + package.loaded["src.core.game3.link"].link = {} + expect(game:logicSpeed() == 1, "h1_link_locked_1x") + package.loaded["src.core.game3.link"].link = nil + expect(game:logicSpeed() == 3, "h1_link_unlocked") + + game.speedOverride = 1 + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/game3_stitchfield_onframe.lua b/tests/drivers/game3_stitchfield_onframe.lua index 93b1899a..417e08ba 100644 --- a/tests/drivers/game3_stitchfield_onframe.lua +++ b/tests/drivers/game3_stitchfield_onframe.lua @@ -79,13 +79,11 @@ return function(game) print("[driver] --- Seafoam Islands: the hole on B2F, the current on B3F ---") -- pokefirered/data/maps/Route20/scripts.inc:10 ResetSeafoamBouldersForB3F goTo(ROUTE_20, 30, 9, "left") - goTo(B2F, 24, 10, "up") + goTo(B2F, 24, 9, "up") print("[driver] on " .. tostring(Space.mapId) .. " at (" .. tostring(Player.cellX) - .. "," .. tostring(Player.cellY) .. ")") - U.shot(game, DIR .. "/stitchfield_onframe_01_b2f_above_hole.png") + .. "," .. tostring(Player.cellY) .. ") elevation=" .. tostring(Player.currentElevation)) + U.shot(game, DIR .. "/stitchfield_onframe_01_b2f_below_hole.png") - U.hold(game, "up", 24) - waitFor(function() return Player.cellY == 9 and not Player.moving end, 120) U.hold(game, "up", 24) local fell = waitFor(function() return Warp.isBusy and Warp.isBusy() end, 180) result(fell, "walking onto the B2F hole started the fall warp") diff --git a/tests/drivers/game3_summary_pages_s3.lua b/tests/drivers/game3_summary_pages_s3.lua new file mode 100644 index 00000000..bf26634b --- /dev/null +++ b/tests/drivers/game3_summary_pages_s3.lua @@ -0,0 +1,290 @@ +-- pokefirered/src/pokemon_summary_screen.c:1615 +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/BSA/09-24-26-01-followup/shots/game3_summary_pages_s3" + +local failures = 0 +local function pass(label, ok, detail) + if ok then + print("PASS " .. label) + else + failures = failures + 1 + print("FAIL " .. label .. (detail and (" " .. tostring(detail)) or "")) + end +end + +local calls = {} +local function spy(mod, name) + local real = mod[name] + mod[name] = function(...) + calls[#calls + 1] = { name = name, args = { ... } } + return real(...) + end +end +local function called(name, pred) + for _, c in ipairs(calls) do + if c.name == name and (not pred or pred(c.args)) then return true end + end + return false +end + +local function run(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Party = require("src.core.game3.party") + local Pokemon = require("src.core.game3.pokemon") + local SummaryMenu = require("src.ui.game3.summary_menu") + local SummaryChrome = require("src.ui.game3.summary_chrome") + + local session = Runtime.getSession() + if not session then print("FAIL no session") love.event.quit(1) return end + session.party = {} + Party.giveMon(session, 25, 20) + Party.giveMon(session, 1, 20) + Party.giveMon(session, 4, 5) + local mon = session.party[1] + mon.isShiny = true + mon.status = "PSN" + mon.pokerus = 0x10 + mon.markings = 5 + mon.moves[4] = 0 + mon.pp[4] = 0 + mon.maxPp[4] = 0 + mon.pp[1] = 4 + mon.pp[2] = 14 + mon.pp[3] = 0 + mon.hp = math.max(1, math.floor((tonumber(mon.maxHp) or 40) / 2)) + local plain = session.party[2] + plain.hp = plain.maxHp + plain.status = nil + session.party[3].isEgg = true + session.party[3].friendship = 5 + + Map.load(nil, game, "FR_PALLET_TOWN", { x = 10, y = 6, facing = "down" }) + Player.cellX, Player.cellY = 10, 6 + U.wait(60) + + for _, n in ipairs({ "drawBg3", "drawProgress", "drawLayer", "drawMarkings", "drawShinyStar", + "drawStatusIcon", "drawTypeBadge", "drawMoveSelectionCursor" }) do + spy(SummaryChrome, n) + end + spy(Pokemon, "monFrontPic") + spy(Pokemon, "monIcon") + + local function frame_calls() + calls = {} + for _ = 1, 4000 do + U.wait(1) + if called("drawBg3") then + calls = {} + break + end + end + for _ = 1, 4000 do + U.wait(1) + if called("drawBg3") then return end + end + end + local function shot(name) return U.still(game, DIR .. "/" .. name .. ".png") end + local function settle() + for _ = 1, 120 do + if not SummaryMenu._slide.active then break end + U.wait(1) + end + U.wait(4) + end + local function until_step(n) + for _ = 1, 60 do + if SummaryMenu._slide.active and SummaryMenu.flipStep() == n then return true end + U.wait(1) + end + return false + end + + SummaryMenu.openMenu(session.party, 1, {}) + settle() + frame_calls() + pass("s3 info shiny bg", called("drawBg3", function(a) return a[1] == "info" and a[2] == true end)) + pass("s3 info shiny star frame", called("drawShinyStar", function(a) return a[1] == 102 and a[2] == 36 end)) + pass("s3 info status TL", called("drawStatusIcon", function(a) return a[1] == 0 and a[2] == 34 end)) + pass("s3 info markings", called("drawMarkings", function(a) return a[1] == 5 and a[2] == 4 and a[3] == 87 end)) + shot("s3_info_shiny_psn_markings") + + U.tap(game, "left") U.wait(4) + pass("s3 left on INFO does not wrap", SummaryMenu._page == SummaryMenu.PAGE_INFO and not SummaryMenu._slide.active) + + U.tap(game, "right") + pass("s3 right from INFO starts a slide", SummaryMenu._slide.active and SummaryMenu._page == SummaryMenu.PAGE_SKILLS) + local waited = 0 + while SummaryMenu._slide.active and SummaryMenu.flipStep() == 0 and waited < 30 do + U.wait(1) waited = waited + 1 + end + pass("s3r flip waits the cart's task frames before sliding", waited >= 5, waited) + until_step(2) + shot("s3_slide_info_out_to_skills") + settle() + shot("s3_skills_right_aligned") + + U.tap(game, "left") U.wait(3) + U.tap(game, "right") U.wait(1) + pass("s3r opposite press during a flip is dropped", SummaryMenu._slide.active and SummaryMenu._slide.queued == nil) + settle() + pass("s3r dropped press did not flip", SummaryMenu._page == SummaryMenu.PAGE_INFO) + U.tap(game, "right") U.wait(3) + U.tap(game, "right") U.wait(1) + pass("s3r same-direction press during a flip is queued", SummaryMenu._slide.queued == 1) + settle() settle() + pass("s3r right-right from INFO lands on KNOWN MOVES", SummaryMenu._page == SummaryMenu.PAGE_MOVES, SummaryMenu._page) + + U.tap(game, "left") + until_step(2) + pass("s3r left flip header on incoming layer", SummaryMenu._page == SummaryMenu.PAGE_SKILLS + and SummaryMenu._slide.frame >= SummaryMenu.FLIP_TIMING.page.header) + shot("s3r_flip_left_header_rides_incoming_layer") + settle() + + local Options = require("src.core.game3.options") + local opts = Options.ensure(session) + local savedMode = opts.buttonMode + opts.buttonMode = 2 + U.tap(game, "r") U.wait(2) + pass("s3r R does not flip in L=A mode", SummaryMenu._page == SummaryMenu.PAGE_SKILLS and not SummaryMenu._slide.active) + opts.buttonMode = 1 + U.tap(game, "r") + pass("s3r R flips in LR mode", SummaryMenu._page == SummaryMenu.PAGE_MOVES) + settle() + opts.buttonMode = savedMode + frame_calls() + pass("s3 known moves page", SummaryMenu._page == SummaryMenu.PAGE_MOVES) + pass("s3 known moves picture frame bg3", called("drawBg3", function(a) return a[1] == "info" end) + and not called("drawBg3", function(a) return a[1] == "moves" end)) + pass("s3 known moves draws front pic", called("monFrontPic")) + pass("s3 known moves hides icon", not called("monIcon")) + pass("s3 known moves status TL", called("drawStatusIcon", function(a) return a[1] == 0 and a[2] == 34 end)) + pass("s3 known moves shiny star", called("drawShinyStar", function(a) return a[1] == 102 and a[2] == 36 end)) + shot("s3_known_moves_lowpp_empty_slot") + + U.tap(game, "right") U.wait(4) + pass("s3 right on MOVES does not wrap", SummaryMenu._page == SummaryMenu.PAGE_MOVES and not SummaryMenu._slide.active) + + U.tap(game, "a") + pass("s3 A on MOVES slides detail in", SummaryMenu._slide.active and SummaryMenu._slide.kind == "detail") + until_step(2) + pass("s3r detail slide name strip blank", SummaryMenu._slide.frame < SummaryMenu.FLIP_TIMING.detail.name) + shot("s3r_detail_slide_blank_name_strip") + settle() + frame_calls() + pass("s3 detail page", SummaryMenu._page == SummaryMenu.PAGE_MOVES_INFO) + pass("s3 detail keeps move list panel", called("drawLayer", function(a) return a[1] == "moves" end) + and called("drawLayer", function(a) return a[1] == "moves_info" end)) + pass("s3 detail icon not pic", called("monIcon") and not called("monFrontPic")) + pass("s3 detail hides status on A path", not called("drawStatusIcon")) + pass("s3 detail shiny star TL", called("drawShinyStar", function(a) return a[1] == 4 and a[2] == 20 end)) + pass("s3 detail mon type badge", called("drawTypeBadge", function(a) return a[2] == 48 and a[3] == 35 end)) + shot("s3_move_detail") + + U.tap(game, "down") U.wait(2) + U.tap(game, "a") U.wait(2) + U.tap(game, "down") U.wait(2) + pass("s3 swap started", SummaryMenu._swapSlot == 2 and SummaryMenu._moveCursor == 3) + shot("s3_swap_cursor_shown") + local flipped = false + for _ = 1, 70 do + U.wait(1) + if SummaryMenu._blink.hidden then flipped = true break end + end + pass("s3 swap cursor blinks", flipped) + if flipped then shot("s3_swap_cursor_blinked_off") end + U.tap(game, "b") U.wait(2) + pass("s3 B cancels swap", SummaryMenu._swapSlot == nil and SummaryMenu._page == SummaryMenu.PAGE_MOVES_INFO) + + U.tap(game, "b") settle() + U.tap(game, "left") settle() + U.tap(game, "right") settle() + U.tap(game, "a") settle() + pass("s3r move cursor kept across back-out and page flips", SummaryMenu._page == SummaryMenu.PAGE_MOVES_INFO + and SummaryMenu._moveCursor == 3, SummaryMenu._moveCursor) + + U.tap(game, "down") U.wait(2) + pass("s3 detail cursor skips the empty slot onto CANCEL", SummaryMenu._moveCursor == 5, SummaryMenu._moveCursor) + shot("s3_detail_cancel_row") + + U.tap(game, "b") + until_step(2) + shot("s3_detail_slide_out") + settle() + pass("s3 B returns to known moves", SummaryMenu._page == SummaryMenu.PAGE_MOVES and not SummaryMenu._slide.active) + pass("s3r B on CANCEL resets the move cursor", SummaryMenu._moveCursor == 1, SummaryMenu._moveCursor) + + U.tap(game, "left") + until_step(3) + shot("s3_slide_skills_in_from_moves") + settle() + U.tap(game, "left") settle() + pass("s3 back on INFO", SummaryMenu._page == SummaryMenu.PAGE_INFO) + U.tap(game, "a") U.wait(4) + pass("s3 A on INFO closes", not SummaryMenu.isOpen()) + for _ = 1, 10 do if not SummaryMenu.isOpen() then break end U.tap(game, "b") U.wait(4) end + + SummaryMenu.openMenu(session.party, 1, { mode = "select_move", moveToLearn = 85, onSelectMove = function() end }) + settle() + frame_calls() + pass("s3 learn progress tiles", called("drawProgress", function(a) return a[1] == "moves_info_select" end)) + pass("s3 learn status TL", called("drawStatusIcon", function(a) return a[1] == 0 and a[2] == 41 end)) + shot("s3_learn_move_select") + for _ = 1, 10 do if not SummaryMenu.isOpen() then break end U.tap(game, "b") U.wait(4) end + + SummaryMenu.openMenu(session.party, 1, { mode = "select_move", forgetMove = true, onSelectMove = function() end }) + settle() + shot("s3_forget_move_select") + for _ = 1, 10 do if not SummaryMenu.isOpen() then break end U.tap(game, "b") U.wait(4) end + + SummaryMenu.openMenu(session.party, 2, {}) + local peak = 0 + for _ = 1, 12 do + U.wait(1) + if SummaryMenu._bounce.dy < peak then peak = SummaryMenu._bounce.dy end + if peak <= -8 then break end + end + pass("s3 full hp pic bounces", peak <= -8, peak) + shot("s3_plain_pic_bounce_peak") + U.wait(60) + pass("s3 bounce settles", SummaryMenu._bounce.dy == 0 and SummaryMenu._bounce.count >= 2) + frame_calls() + pass("s3 plain bg not shiny", called("drawBg3", function(a) return a[2] == false end)) + shot("s3_plain_info_flipped_pic") + local eps = SummaryChrome.manifest().eggPicShake + pass("s3r egg shake tables from the cache", eps and #eps == 3 and #eps[3] == 15 and eps[3][7] == -2) + U.tap(game, "down") settle() + pass("s3 egg page", SummaryMenu._page == SummaryMenu.PAGE_EGG) + pass("s3r egg shake vigor from friendship 5", SummaryMenu._bounce.egg and SummaryMenu._bounce.vigor == 2) + shot("s3r_egg_state_box_text") + local still, moved = true, false + for i = 1, 90 do + U.wait(1) + if i < 50 and SummaryMenu._bounce.dx ~= 0 then still = false end + if math.abs(SummaryMenu._bounce.dx) >= 2 then moved = true break end + end + pass("s3r egg holds still through the 60-frame delay", still) + pass("s3r egg shakes", moved, SummaryMenu._bounce.dx) + if moved then shot("s3r_egg_shake_midjiggle") end + for _ = 1, 10 do if not SummaryMenu.isOpen() then break end U.tap(game, "b") U.wait(4) end + + SummaryMenu.openMenu(session.party, 1, {}) + settle() + local statused = SummaryMenu._bounce.count >= 2 and SummaryMenu._bounce.dy == 0 + pass("s3 statused pic does not bounce", statused) + for _ = 1, 10 do if not SummaryMenu.isOpen() then break end U.tap(game, "b") U.wait(4) end + + print(failures == 0 and "PASS game3_summary_pages_s3" or ("FAIL game3_summary_pages_s3 " .. failures)) + love.event.quit(failures == 0 and 0 or 1) +end + +return run diff --git a/tests/drivers/red_speed_hotkey_h1.lua b/tests/drivers/red_speed_hotkey_h1.lua new file mode 100644 index 00000000..522fbf85 --- /dev/null +++ b/tests/drivers/red_speed_hotkey_h1.lua @@ -0,0 +1,32 @@ +local U = require("tests.drivers.util") + +local failed = false +local function expect(cond, label) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then failed = true end +end + +local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) +end + +return function(game) + U.newGame(game) + local o = game.save.options + game.speedOverride = nil + o.speedOverworld = 1 + key("1") + expect(o.speedOverworld == 2, "red_1_bumps_overworld") + key("kp1") + expect(o.speedOverworld == 3, "red_numpad_1_bumps") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + expect(o.speedOverworld == 4, "red_rt_bumps") + game:gamepadaxis(nil, "triggerleft", 1) + game:gamepadaxis(nil, "triggerleft", 0) + expect(o.speedOverworld == 3, "red_lt_lowers") + expect(game:logicSpeed() == 3, "red_logic_follows_overworld") + game.speedOverride = 1 + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/trainer_fanfare_bug764_test.lua b/tests/drivers/trainer_fanfare_bug764_test.lua index 6b74ac69..eda8bfc1 100644 --- a/tests/drivers/trainer_fanfare_bug764_test.lua +++ b/tests/drivers/trainer_fanfare_bug764_test.lua @@ -7,16 +7,15 @@ return function(game) local U = dofile("tests/drivers/util.lua") - -- pokered data/maps/objects/ViridianForest.asm: YOUNGSTER2 (the first - -- Bug Catcher) stands at (30, 33) facing LEFT, so his sight line runs - -- west; the cell below him, (30, 34), is outside it and lets us talk - -- our way into the battle instead of being spotted. + -- data/maps/objects/ViridianForest.asm:32 local MAP = "VIRIDIAN_FOREST" local TRAINER = "VIRIDIANFOREST_YOUNGSTER2" - local STAND = { x = 30, y = 34, facing = "up" } + local STAND = { x = 30, y = 32, facing = "down" } + local failed = false local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) + if not ok then failed = true end return ok end @@ -30,12 +29,15 @@ return function(game) return realPlay(data, song, ...) end - U.newGame(game) + check("new game reached the overworld", U.newGame(game)) check("music volume is audible (save.options.musicVol)", (game.save.options.musicVol or 0) > 0) + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "PIKACHU", 30, function(_, b) return b end) } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) - U.wait(30) + U.wait(90) local ow = game.overworld local npc @@ -43,25 +45,23 @@ return function(game) if n.def and n.def.name == TRAINER then npc = n end end check("Bug Catcher object loaded on " .. MAP, npc ~= nil) + check(string.format("teleport kept the stand cell (%d,%d)", STAND.x, STAND.y), + ow.player.cellX == STAND.x and ow.player.cellY == STAND.y) if npc then check("standing on his blind side, facing him", ow:npcAtCell(ow.player:facingCell()) == npc) check("he did not spot us on the way in", not ow.engaging) end - -- talk; the sting must start only once the before-battle text closes - -- (TalkToTrainer prints first, then engages) + -- home/trainers.asm:109 played = {} U.tap(game, "a") U.wait(30) check("no sting while the dialogue is up", #played == 0) - local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" - U.shot(game, SHOT_DIR .. "/bug764_dialogue.png") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + U.shot(game, SHOT_DIR .. "/764_bugcatcher_talk_dialogue.png") - -- close the text; a Bug Catcher is neither female-list nor evil-list, - -- so PlayTrainerMusic lands on the male sting. A presses both finish - -- the typewriter and turn pages, so keep tapping until the sting lands - -- or the drain gives up. + -- home/trainers.asm:123 local sting for _ = 1, 8 do U.tap(game, "a") @@ -71,19 +71,24 @@ return function(game) end if sting then break end end - check("closing the text started an encounter sting", sting ~= nil) + check("the last page started an encounter sting", sting ~= nil) check("it is the male trainer sting", sting == "Music_MeetMaleTrainer") - U.shot(game, SHOT_DIR .. "/bug764_transition.png") + U.still(game, SHOT_DIR .. "/764_bugcatcher_sting_last_page.png") U.log("songs started since the A press:", table.concat(played, ", ")) - U.log("The Bug Catcher's line has just closed and the battle is opening.") - U.log("You should have heard the male trainer sting begin the moment the") - U.log("text box shut, carrying over the battle transition. Before #764") - U.log("the forest theme played straight through into the fight. To hear") - U.log("the sight path for comparison, lose or run, step west across his") - U.log("eyeline, and the same sting should fire once at the \"!\" bubble.") - - while true do - coroutine.yield() + -- home/text_script.asm:93 + U.tap(game, "a") + local left = false + for _ = 1, 300 do + U.wait(1) + if game.stack:top() ~= ow then left = true break end end + check("A on the last page opens the battle", left) + + U.log("The male trainer sting should begin as the Bug Catcher's last page") + U.log("(\"Let's battle 'em!\") lands, before A is pressed, and carry into") + U.log("the battle transition. Before #764 the forest theme played straight") + U.log("through into the fight.") + + love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 7012621c..7419da04 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -89,12 +89,15 @@ function U.newGame(game) -- Oak speech: mash through text + naming (presets pick first = RED). -- The closing shrink-away beat (~103 frames) is not skippable, like -- the DelayFrames chain it ports, so leave headroom. - for _ = 1, 400 do + local reached = false + for _ = 1, 2000 do U.tap(game, "a") U.wait(2) - if game.overworld and game.stack:top() == game.overworld then break end + if game.overworld and game.stack:top() == game.overworld then reached = true break end end + if not reached then print("FAIL newGame never reached the overworld") end U.wait(10) + return reached end -- jump straight into the overworld at a position, bypassing the intro diff --git a/tests/drivers/worker_fs_chip_music.lua b/tests/drivers/worker_fs_chip_music.lua new file mode 100644 index 00000000..225566d1 --- /dev/null +++ b/tests/drivers/worker_fs_chip_music.lua @@ -0,0 +1,39 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/worker_fs_chip_music" + +return function(game) + local ChipAudio = require("src.core.ChipAudio") + local Music = require("src.core.Music") + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + result(U.newGame(game), "new game reached the overworld") + U.teleport(game, "ROUTE_1", 5, 5, "down") + local want = game.data.audio and game.data.audio.mapSongs and game.data.audio.mapSongs.ROUTE_1 + result(want ~= nil, "route 1 map song in the cache (" .. tostring(want) .. ")") + for _ = 1, 200 do + if Music.current() == want then break end + U.wait(1) + end + local deadline = love.timer.getTime() + 8 + local playing = false + while love.timer.getTime() < deadline do + U.wait(1) + local src = ChipAudio.currentSource() + if src and src:isPlaying() then playing = true break end + end + + local stats = ChipAudio.stats() + print("chip stats: song=" .. tostring(Music.current()) .. " " .. stats.line) + result(Music.current() ~= nil and Music.current() == want, "route 1 plays " .. tostring(want)) + result(stats.worker == "jit" or stats.worker == "interp", "chip worker running (" .. stats.worker .. ")") + result((stats.buffers or 0) > 0, "chip worker PCM reached the source (" .. tostring(stats.buffers) .. " buffers)") + result(playing, "chip music source playing") + if fails == 0 then + U.shot(game, DIR .. "/chip_music_route1_playing.png") + end + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/engine/game3_soft_reset_list_test.lua b/tests/engine/game3_soft_reset_list_test.lua new file mode 100644 index 00000000..06bcbaae --- /dev/null +++ b/tests/engine/game3_soft_reset_list_test.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +local function read(path) + local f = assert(io.open(path, "rb")) + local s = f:read("*a") + f:close() + return s +end + +local game3 = read("src/core/Game3.lua") +local block = game3:match("\nlocal SOFT_RESET = (%b{})") +check(block ~= nil, "Game3.lua declares the SOFT_RESET list") +block = block or "" + +local listed = {} +for name, rest in block:gmatch('{%s*"([%w%._]+)"%s*,%s*([^\n]*)') do + listed[name] = rest +end + +local ALLOW = { + ["src.core.game3.runtime"] = "Runtime.stop in returnToTitle", + ["src.core.game3"] = "forwards Runtime.isActive", + ["src.core.game3.scripting.space"] = "Space.activate on every Runtime.start", + ["src.core.game3.weather"] = "map-owned, Map.load reapplies it", + ["src.core.game3.minigames.common"] = "arena phase skips soft reset", + ["src.ui.game3.minigames.common_lobby"] = "link session owns teardown", + ["src.ui.game3.minigames.berry_crush.pouch"] = "link session owns teardown", + ["src.core.game3.link.battle"] = "link session owns teardown", + ["src.core.game3.link.chat"] = "link session owns teardown", + ["src.core.game3.link.trade"] = "link session owns teardown", + ["src.core.game3.link.union_room"] = "link session owns teardown", + ["src.ui.game3.link_menu"] = "link session owns teardown", + ["src.ui.game3.link_trade_menu"] = "link session owns teardown", + ["src.ui.game3.union_chat"] = "link session owns teardown", + ["src.ui.game3.union_room"] = "link session owns teardown", + ["src.ui.game3.pin_entry"] = "link session owns teardown", + ["src.ui.game3.braille"] = "reads Message, which is listed", + ["src.ui.game3.help_system"] = "Help.reset in returnToTitle", + ["src.core.game3.battle.anim"] = "Battle.reset calls Anim.reset", + ["src.core.game3.battle.anim_seq"] = "Battle.reset calls AnimSeq.reset", + ["src.core.game3.battle.catch_seq"] = "Battle.reset calls CatchSeq.reset", + ["src.core.game3.battle.evo_seq"] = "Battle.reset calls EvoSeq.reset", + ["src.core.game3.battle.exp_seq"] = "Battle.reset calls ExpSeq.reset", + ["src.core.game3.battle.intro_seq"] = "Battle.reset calls IntroSeq.reset", + ["src.core.game3.battle.learn_move"] = "Battle.reset calls LearnMove.reset", + ["src.core.game3.battle.switch_seq"] = "Battle.reset calls SwitchSeq.reset", + ["src.core.game3.battle.ui"] = "only read while a battle runs, Battle.start calls Ui.reset", + ["src.core.game3.warp"] = "Runtime.stop -> Field.stop -> Warp.clear", + ["src.core.game3.doors"] = "Runtime.stop -> Field.stop -> Doors.release", + ["src.ui.game3.stack"] = "Stack.clear in returnToTitle", +} + +local FLAG_FNS = { "isOpen", "isActive", "busy", "isBusy" } + +local files = {} +local p = assert(io.popen("find src/core/game3 src/ui/game3 -name '*.lua' | sort")) +for line in p:lines() do files[#files + 1] = line end +p:close() +check(#files > 50, "found the game3 module tree") + +local seen = {} +for _, path in ipairs(files) do + local src = read(path) + local flagged = false + for _, fn in ipairs(FLAG_FNS) do + if src:match("\nfunction [%w_]+%." .. fn .. "%(%)") then flagged = true end + end + if flagged then + local name = path:gsub("%.lua$", ""):gsub("/", ".") + local alt = name:gsub("%.init$", "") + seen[name], seen[alt] = true, true + local ok = listed[name] or listed[alt] or ALLOW[name] or ALLOW[alt] + check(ok ~= nil, name .. " has an open/active/busy flag and is in SOFT_RESET or the allowlist") + end + local name = path:gsub("%.lua$", ""):gsub("/", ".") + local rest = listed[name] + if rest then + local method = rest:match('^"([%w_]+)"') + if method then + check(src:match("\nfunction [%w_]+%." .. method .. "%(") ~= nil, + name .. "." .. method .. " exists") + end + end +end + +for name in pairs(ALLOW) do + check(seen[name] == true, "allowlisted " .. name .. " still exposes an open/active/busy flag") +end +for name in pairs(listed) do + local path = name:gsub("%.", "/") + local f = io.open(path .. ".lua", "rb") or io.open(path .. "/init.lua", "rb") + check(f ~= nil, "SOFT_RESET entry " .. name .. " is a real module") + if f then f:close() end +end + +T.finish() diff --git a/tests/engine/game3_speed_hotkey_test.lua b/tests/engine/game3_speed_hotkey_test.lua new file mode 100644 index 00000000..da01a1eb --- /dev/null +++ b/tests/engine/game3_speed_hotkey_test.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Input = require("src.core.Input") +local GamepadMap = require("src.core.GamepadMap") +local Game = require("src.core.Game") +local Game3 = require("src.core.Game3") + +local battleActive = false +package.loaded["src.core.game3.battle"] = { isActive = function() return battleActive end } +local stackBusy = false +package.loaded["src.ui.game3.stack"] = { busy = function() return stackBusy end, clear = function() end } + +local function newGame3(phase) + local g = setmetatable({ + phase = phase or "field", + options = { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }, + input = Input, + writeOptions = function() end, + }, { __index = Game3 }) + return g +end + +Input:init() +Input:applyBindings(nil) + +do + local g = newGame3("field") + stackBusy = true + eq(g:speedCategory(), "overworld", "menu over the field inherits overworld") + g:keypressed("1") + eq(g.options.speedOverworld, 2, "1 with a field menu open bumps OVERWORLD SPEED") + eq(g.options.speedMenu, 1, "1 with a field menu open leaves MENU SPEED alone") + stackBusy = false + eq(g:logicSpeed(), 2, "walk speed sticks after the menu closes") + battleActive = true + eq(g:speedCategory(), "battle", "battle stays battle") + battleActive = false + eq(newGame3("boot"):speedCategory(), "menu", "boot phase stays menu") + eq(newGame3("quest_log"):speedCategory(), "menu", "quest log stays menu") +end + +do + local g = newGame3("field") + g:keypressed("kp1") + eq(g.options.speedOverworld, 2, "numpad 1 cycles speed") + local realKb = love.keyboard + love.keyboard = setmetatable({ + getScancodeFromKey = function(key) return key == "&" and "1" or key end, + }, { __index = realKb }) + g:keypressed("&") + eq(g.options.speedOverworld, 3, "AZERTY top-row 1 cycles speed by scancode") + love.keyboard = realKb + eq(Input.hotkeyKey("kp3"), "3", "numpad 3 normalizes") + eq(Input.hotkeyKey("kp4"), "4", "numpad 4 normalizes") + eq(Input.hotkeyKey("z"), "z", "letters pass through") +end + +do + Input:applyBindings({ + left = { key = "kp4" }, down = { key = "kp2" }, up = { key = "kp8" }, + right = { key = "kp6" }, a = { key = "&" }, b = { key = "kp1" }, + }) + local realKb = love.keyboard + love.keyboard = setmetatable({ + getScancodeFromKey = function(key) return key == "&" and "1" or key end, + }, { __index = realKb }) + eq(Input.hotkeyKey("kp4"), "kp4", "numpad 4 bound to LEFT is not a hotkey") + eq(Input.hotkeyKey("kp2"), "kp2", "numpad 2 bound to DOWN is not a hotkey") + eq(Input.hotkeyKey("&"), "&", "AZERTY & bound to A is not a hotkey") + eq(Input.hotkeyKey("kp3"), "3", "unbound numpad 3 is still a hotkey") + Input:reset() + local g = newGame3("field") + g:keypressed("kp4") + Input:step() + check(Input:isDown("left"), "numpad 4 bound to LEFT walks left in FRLG") + g:keyreleased("kp4") + g:keypressed("kp1") + eq(g.options.speedOverworld, 1, "numpad 1 bound to B leaves speed alone") + Input:step() + check(Input:isDown("b"), "numpad 1 bound to B presses B") + g:keyreleased("kp1") + g:keypressed("&") + eq(g.options.speedOverworld, 1, "AZERTY & bound to A leaves speed alone") + Input:step() + check(Input:isDown("a"), "AZERTY & bound to A presses A") + g:keyreleased("&") + love.keyboard = realKb + Input:applyBindings(nil) + Input:reset() +end + +do + local trigger = Input:triggerAxis("triggerright", 1) + eq(Input:padAction(trigger), "speedUp", "RT axis name resolves to speedUp") + Input:triggerAxis("triggerright", 0) + local left = Input:triggerAxis("triggerleft", 1) + eq(Input:padAction(left), "speedDown", "LT axis name resolves to speedDown") + Input:triggerAxis("triggerleft", 0) + eq(GamepadMap.DEFAULT_PAD_ACTIONS.righttrigger, nil, "no legacy trigger key in defaults") +end + +do + Input:reset() + local g = newGame3("field") + g:gamepadaxis(nil, "triggerright", 1) + eq(g.options.speedOverworld, 2, "gen3 RT bumps speed") + g:gamepadaxis(nil, "triggerright", 0) + g:gamepadaxis(nil, "triggerleft", 1) + eq(g.options.speedOverworld, 1, "gen3 LT lowers speed") + g:gamepadaxis(nil, "triggerleft", 0) + g:gamepadpressed(nil, "rightshoulder") + eq(g.options.speedOverworld, 1, "gen3 R shoulder never changes speed") + Input:step() + check(Input:isDown("r"), "gen3 R shoulder still presses R") + g:gamepadreleased(nil, "rightshoulder") +end + +do + Input:applyBindings({ speedUp = { pad = "y" } }) + Input:reset() + local g = newGame3("field") + g:gamepadpressed(nil, "y") + eq(g.options.speedOverworld, 2, "gen3 honors a SPEED + rebind to Y") + g:gamepadreleased(nil, "y") + Input:applyBindings({ speedUp = { pad = "righttrigger" }, a = { pad = "lefttrigger" } }) + eq(Input:padAction("triggerright"), "speedUp", "legacy righttrigger speed rebind still lands") + eq(Input.padBindings.triggerleft, "a", "legacy lefttrigger button rebind still lands") + Input:applyBindings(nil) +end + +do + local g1 = setmetatable({ save = { options = { speedOverworld = 1 } }, stack = { states = {}, top = function() return nil end }, + writeOptions = function() end }, { __index = Game }) + Input:reset() + g1:gamepadaxis(nil, "triggerright", 1) + eq(g1.save.options.speedMenu, 2, "Gen 1 RT reaches _cycleSpeed") + g1:gamepadaxis(nil, "triggerright", 0) +end + +do + local g = newGame3("field") + g.speedOverride = 8 + g.options.speedOverworld = 4 + eq(g:logicSpeed(), 8, "override applies off-link") + package.loaded["src.core.game3.link"] = { link = {} } + eq(g:logicSpeed(), 1, "gen3 link play is locked to 1X") + package.loaded["src.core.game3.link"] = { link = nil } + package.loaded["src.core.game3.minigames.common"] = { isActive = function() return true end } + eq(g:logicSpeed(), 1, "gen3 minigames are locked to 1X") + package.loaded["src.core.game3.minigames.common"] = { isActive = function() return false end } + g.phase = "arena" + eq(g:logicSpeed(), 1, "gen3 arena is locked to 1X") + package.loaded["src.core.game3.link"] = nil + package.loaded["src.core.game3.minigames.common"] = nil +end + +T.finish() diff --git a/tests/engine/game3_summary_layout_s3_test.lua b/tests/engine/game3_summary_layout_s3_test.lua new file mode 100644 index 00000000..9ba1bab8 --- /dev/null +++ b/tests/engine/game3_summary_layout_s3_test.lua @@ -0,0 +1,395 @@ +#!/usr/bin/env luajit +-- pokefirered/src/pokemon_summary_screen.c:1615 + +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.fixture_data.game3_map_sections").install() + +local T = require("tests.harness") +local check, eq = T.check, T.eq +require("tests.game3_cache").stubSpeciesNames() + +require("src.core.GameVersion").set("firered") + +package.loaded["src.core.game3.audio"] = { + playSe = function() end, playCry = function() end, + playSong = function() end, stopAll = function() end, +} +package.loaded["src.core.game3.rom_text"] = { + plain = function(key) return key end, box = function(key) return key end, + ascii = function(key) return key end, has = function() return true end, + key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, + at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, + count = function() return 0 end, list = function() return {} end, + lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, +} + +local gfx = setmetatable({}, { __index = function() return function() end end }) +gfx.newQuad = function() return {} end +gfx.newImage = function() return nil end +gfx.getWidth = function() return 240 end +gfx.getHeight = function() return 160 end +_G.love = { graphics = gfx, timer = { getTime = function() return 0 end } } + +local FrlgFont = require("src.ui.game3.frlg_font") +local Pokemon = require("src.core.game3.pokemon") +local SummaryMenu = require("src.ui.game3.summary_menu") +local SummaryChrome = require("src.ui.game3.summary_chrome") + +local calls +local function reset_calls() calls = {} end +reset_calls() +for _, name in ipairs({ "drawBg3", "drawProgress", "drawLayer", "drawPageBg", "drawShinyStar", "drawStatusIcon", + "drawTypeBadge", "drawHpBar", "drawExpBar", "drawPokerus", "drawMoveSelectionCursor", + "drawMarkings" }) do + SummaryChrome[name] = function(...) + calls[#calls + 1] = { name = name, args = { ... } } + end +end +local MANIFEST = nil +SummaryChrome.manifest = function() return MANIFEST end +local SESSION = nil +package.loaded["src.core.game3.runtime"] = { getSession = function() return SESSION end } +local SummaryData = require("src.core.game3.summary_data") +SummaryData.moveDescription = function() return "" end +SummaryData.abilityDescription = function() return "" end +local realMemo = SummaryData.formatTrainerMemo +SummaryData.formatTrainerMemo = function() return {} end +SummaryData.expProgress = function() return { totalExp = 0, expNeeded = 0, progressPercent = 0 } end +Pokemon.abilityName = function() return "STATIC" end +Pokemon.abilityId = function() return 9 end +Pokemon.monFrontPic = function() calls[#calls + 1] = { name = "pic", args = {} } return nil end +Pokemon.monIcon = function() calls[#calls + 1] = { name = "icon", args = {} } return nil end +local texts = {} +local NORMAL = FrlgFont.COLOR.NORMAL +FrlgFont.draw = function(text, x, y, opts) + texts[#texts + 1] = { text = tostring(text), x = x, y = y, colors = opts and opts.colors } +end + +local function find(name, pred) + for _, c in ipairs(calls) do + if c.name == name and (not pred or pred(c.args)) then return c end + end + return nil +end +local function text_at(str) + for _, t in ipairs(texts) do if t.text == str then return t end end + return nil +end + +local function mon() + return { + species = 25, level = 20, hp = 20, maxHp = 47, status = "PSN", isShiny = true, markings = 5, + attack = 29, defense = 7, spAtk = 125, spDef = 22, speed = 45, + moves = { "THUNDER WAVE", "GROWL" }, pp = { 4, 40 }, maxPp = { 20, 40 }, + otName = "RED", otId = 1, personality = 1, + } +end + +local pressed +local input = { wasPressed = function(_, b) return pressed == b end } +local function press(b) + pressed = b + SummaryMenu.handleInput(input) + pressed = nil +end +local function frames(n) for _ = 1, n do SummaryMenu.update(1 / 60) end end +local function settle() + for _ = 1, 60 do + if not SummaryMenu._slide.active then break end + frames(1) + end + SummaryMenu.handleInput(input) +end +local function draw() + reset_calls() + texts = {} + SummaryMenu.draw() +end + +print("[test] 1. pret's sprite table: the picture pane stays on KNOWN MOVES") +check(type(SummaryMenu.leftPaneSprites) == "function", "SummaryMenu.leftPaneSprites exists") +if type(SummaryMenu.leftPaneSprites) == "function" then + local moves = SummaryMenu.leftPaneSprites(SummaryMenu.PAGE_MOVES, nil) + check(moves.pic and moves.ball and moves.markings and not moves.icon, "KNOWN MOVES keeps pic, ball, markings; no icon") + eq(moves.status and moves.status.x, 0, "KNOWN MOVES status x is the INFO top-left") + eq(moves.status and moves.status.y, 34, "KNOWN MOVES status y is the INFO top-left") + eq(moves.shiny and moves.shiny.x, 102, "KNOWN MOVES star x") + local detail = SummaryMenu.leftPaneSprites(SummaryMenu.PAGE_MOVES_INFO, nil) + check(detail.icon and not detail.pic and not detail.ball and not detail.markings, "move detail swaps the pic for the icon") + eq(detail.icon and detail.icon.y, 16, "move detail icon top-left y") + check(detail.status == nil, "move detail from A hides the status icon") + local learn = SummaryMenu.leftPaneSprites(SummaryMenu.PAGE_MOVES_INFO, "select_move") + eq(learn.status and learn.status.y, 41, "learn/forget screen shows status at y 41") + eq(detail.shiny and detail.shiny.x, 4, "move detail star x") +end + +print("[test] 2. PP colours") +check(type(SummaryMenu.ppColorIndex) == "function", "SummaryMenu.ppColorIndex exists") +if type(SummaryMenu.ppColorIndex) == "function" then + eq(SummaryMenu.ppColorIndex(20, 20, true), 0, "full PP is normal") + eq(SummaryMenu.ppColorIndex(10, 20, true), 1, "half PP is yellow") + eq(SummaryMenu.ppColorIndex(5, 20, true), 2, "quarter PP is orange") + eq(SummaryMenu.ppColorIndex(0, 20, true), 3, "no PP is red") + eq(SummaryMenu.ppColorIndex(2, 3, true), 2, "2/3 is orange") + eq(SummaryMenu.ppColorIndex(1, 2, true), 1, "1/2 is yellow") + eq(SummaryMenu.ppColorIndex(0, 0, false), 0, "empty slot is normal") +end + +print("[test] 3. move detail cursor") +check(type(SummaryMenu.detailCursorStep) == "function", "SummaryMenu.detailCursorStep exists") +if type(SummaryMenu.detailCursorStep) == "function" then + local two = { {}, {} } + eq(SummaryMenu.detailCursorStep(two, 1, 1, false), 4, "down past the last move lands on CANCEL") + eq(SummaryMenu.detailCursorStep(two, 4, 1, false), 0, "down from CANCEL wraps to the first move") + eq(SummaryMenu.detailCursorStep(two, 0, -1, false), 4, "up from the first move lands on CANCEL") + eq(SummaryMenu.detailCursorStep(two, 0, -1, true), 1, "swapping never lands on CANCEL") + eq(SummaryMenu.detailCursorStep(two, 1, 1, true), 0, "swapping wraps down to the first move") +end + +print("[test] 4. page flips, A on INFO, slides") +local party = { mon() } +local closed = false +SummaryMenu.openMenu(party, 1, { onClose = function() closed = true end }) +eq(SummaryMenu._page, SummaryMenu.PAGE_INFO, "opens on INFO") +press("left") +eq(SummaryMenu._page, SummaryMenu.PAGE_INFO, "left on INFO does not wrap to MOVES") +press("right") +eq(SummaryMenu._page, SummaryMenu.PAGE_SKILLS, "right flips to SKILLS") +check(SummaryMenu._slide.active, "the flip slides") +frames(14) +check(SummaryMenu._slide.active, "the flip task still runs 14 frames after the press") +frames(1) +check(not SummaryMenu._slide.active, "the flip task ends on frame 15 (hop + Task_PokeSum_FlipPages)") +press("right") +settle() +eq(SummaryMenu._page, SummaryMenu.PAGE_MOVES, "right flips to KNOWN MOVES") +press("right") +eq(SummaryMenu._page, SummaryMenu.PAGE_MOVES, "right on KNOWN MOVES does not wrap to INFO") + +draw() +check(find("pic") ~= nil and find("icon") == nil, "KNOWN MOVES draws the front pic, not the icon") +check(find("drawBg3", function(a) return a[1] == "info" end) ~= nil, "KNOWN MOVES keeps the picture-frame bg3") +check(find("drawStatusIcon", function(a) return a[1] == 0 and a[2] == 34 end) ~= nil, "KNOWN MOVES status at 0,34") +check(find("drawShinyStar", function(a) return a[1] == 102 and a[2] == 36 end) ~= nil, "KNOWN MOVES shiny star at 102,36") +check(find("drawMarkings", function(a) return a[1] == 5 end) ~= nil, "KNOWN MOVES draws the markings") +local pp = text_at("gText_PokeSum_PP") +check(pp ~= nil and pp.x == 196, "the PP glyph prints at x 196") +local cur = text_at("4") +eq(cur and cur.x, 212, "a one-digit current PP right-aligns to end at 217") +local hy = text_at("gText_PokeSum_OneHyphen") +eq(hy and hy.x, 163, "an empty slot prints one hyphen at the name column") +local two = text_at("gText_PokeSum_TwoHyphens") +eq(two and two.x, 205, "an empty slot's PP prints two hyphens at x 205") + +press("a") +eq(SummaryMenu._page, SummaryMenu.PAGE_MOVES_INFO, "A opens move detail") +check(SummaryMenu._slide.active and SummaryMenu._slide.kind == "detail", "move detail slides in") +settle() +draw() +check(find("icon") ~= nil and find("pic") == nil, "move detail draws the icon") +check(find("drawStatusIcon") == nil, "move detail hides the status icon") +check(find("drawLayer", function(a) return a[1] == "moves" end) ~= nil, "move detail keeps the move list panel") +check(find("drawTypeBadge", function(a) return a[2] == 48 and a[3] == 35 end) ~= nil, "move detail shows the mon's type") +press("b") +settle() +eq(SummaryMenu._page, SummaryMenu.PAGE_MOVES, "B returns to KNOWN MOVES") + +press("left") settle() +press("left") settle() +eq(SummaryMenu._page, SummaryMenu.PAGE_INFO, "back on INFO") +draw() +check(find("drawShinyStar", function(a) return a[1] == 102 end) ~= nil, "INFO star at 102,36") +press("a") +check(closed and not SummaryMenu.isOpen(), "A on INFO closes the summary") + +print("[test] 5. SKILLS numbers right-align, one colour") +SummaryMenu.openMenu({ mon() }, 1, { page = SummaryMenu.PAGE_SKILLS }) +draw() +local hp = text_at("20/47") +eq(hp and hp.x, 174 + 63 - 5 * 6, "HP right-aligns in 63 px") +local spa = text_at("125") +eq(spa and spa.x, 210 + 27 - 3 * 6, "a stat right-aligns in 27 px") +local atk = text_at("29") +check(atk and atk.colors == NORMAL and spa and spa.colors == NORMAL, "every stat prints in the one skills colour") +local def = text_at("7") +eq(def and def.x, 210 + 27 - 6, "a one-digit stat right-aligns too") +SummaryMenu.close() + +local function text_xy(x, y) + for _, t in ipairs(texts) do if t.x == x and t.y == y then return t end end + return nil +end +local function layer_at(name, x) + return find("drawLayer", function(a) return a[1] == name and a[2] == x end) ~= nil +end + +print("[test] 6. flips wait for the cart's task frames before sliding") +SummaryMenu.openMenu({ mon() }, 1, {}) +press("right") +frames(6) draw() +check(layer_at("info", 0), "six frames after the press INFO has not moved") +frames(1) draw() +check(layer_at("info", 60), "frame 7 slides INFO 60 px") +frames(3) draw() +check(layer_at("info", 240), "frame 10 has INFO fully off") +check(text_at("20/47") == nil, "the SKILLS right pane waits for ShowBg(0)") +frames(4) draw() +check(text_at("20/47") ~= nil, "frame 14 shows the SKILLS right pane") +settle() +press("right") settle() +press("a") +frames(8) draw() +check(find("drawLayer", function(a) return a[1] == "moves_info" end) == nil, "eight frames after A the detail layer has not entered") +frames(1) draw() +check(layer_at("moves_info", 180), "frame 9 slides the detail layer in 60 px") +settle() +press("b") +frames(7) draw() +check(layer_at("moves_info", 0), "seven frames after B the detail layer has not left") +frames(1) draw() +check(layer_at("moves_info", 60), "frame 8 slides the detail layer out 60 px") +settle() +SummaryMenu.close() + +print("[test] 7. header and name strip ride the page layers") +SummaryMenu.openMenu({ mon() }, 1, { page = SummaryMenu.PAGE_MOVES }) +press("left") +frames(5) draw() +check(text_xy(4, 1) and text_xy(4, 1).text == "gText_PokeSum_PageName_KnownMoves", "before case 4 the old title stays") +frames(3) draw() +local title = text_xy(124, 1) +check(title and title.text == "gText_PokeSum_PageName_PokemonSkills", "a left flip carries the new title on the incoming layer") +check(text_xy(160, 18) ~= nil, "the name strip rides the incoming layer too") +check(text_xy(40, 18) == nil, "nothing is left at the static name position") +settle() +press("right") +frames(8) draw() +check(text_xy(4, 1) and text_xy(4, 1).text == "gText_PokeSum_PageName_KnownMoves", "a right flip prints the new title on the layer underneath") +check(layer_at("skills", 120), "while SKILLS slides off") +settle() +press("a") +frames(10) draw() +check(text_xy(40, 18) == nil and text_xy(4, 18) == nil, "the name strip is blank while move detail slides in") +title = text_xy(124, 1) +check(title and title.text == "gText_PokeSum_PageName_KnownMoves", "the detail title rides the sliding layer") +frames(6) draw() +check(text_xy(40, 18) ~= nil, "FromInfo case 11 reprints the name strip") +settle() +press("b") +frames(9) draw() +check(text_xy(40, 18) == nil, "the name strip is blank while move detail slides out") +check(text_xy(4, 1) ~= nil, "the KNOWN MOVES title stays put on the back-out") +frames(4) draw() +check(text_xy(40, 18) ~= nil and text_xy(4, 18) ~= nil, "back-out case 9 reprints the name strip with the level") +settle() +SummaryMenu.close() + +print("[test] 8. a same-direction press during a flip is queued") +SummaryMenu.openMenu({ mon() }, 1, {}) +press("right") frames(3) +press("right") +eq(SummaryMenu._slide.queued, 1, "lastPageFlipDirection holds the second right") +settle() +eq(SummaryMenu._page, SummaryMenu.PAGE_MOVES, "right-right from INFO lands on KNOWN MOVES") +settle() +press("left") frames(3) +press("right") +eq(SummaryMenu._slide.queued, nil, "an opposite press during a flip is dropped") +settle() +eq(SummaryMenu._page, SummaryMenu.PAGE_SKILLS, "the dropped press does not flip back") +SummaryMenu.close() + +print("[test] 9. L/R flip pages only in the LR button mode") +SESSION = { options = { buttonMode = 1 } } +SummaryMenu.openMenu({ mon() }, 1, {}) +press("r") +eq(SummaryMenu._page, SummaryMenu.PAGE_SKILLS, "R flips right in LR mode") +settle() +press("l") +eq(SummaryMenu._page, SummaryMenu.PAGE_INFO, "L flips left in LR mode") +settle() +SummaryMenu.close() +for _, mode in ipairs({ 0, 2 }) do + SESSION = { options = { buttonMode = mode } } + SummaryMenu.openMenu({ mon() }, 1, {}) + press("r") + eq(SummaryMenu._page, SummaryMenu.PAGE_INFO, "R does not flip in button mode " .. mode) + check(not SummaryMenu._slide.active, "no flip starts in button mode " .. mode) + SummaryMenu.close() +end +SESSION = nil + +print("[test] 10. the move cursor survives detail and page flips") +SummaryMenu.openMenu({ mon() }, 1, { page = SummaryMenu.PAGE_MOVES }) +press("a") settle() +press("down") +eq(SummaryMenu._moveCursor, 2, "cursor on the second move") +press("b") settle() +press("a") settle() +eq(SummaryMenu._moveCursor, 2, "A back into detail keeps the cursor (only :989 and :5055 reset it)") +press("b") settle() +press("left") settle() +press("right") settle() +press("a") settle() +eq(SummaryMenu._moveCursor, 2, "page flips keep the cursor") +press("down") +eq(SummaryMenu._moveCursor, 5, "down past the last move lands on CANCEL") +press("b") settle() +press("a") settle() +eq(SummaryMenu._moveCursor, 1, "B on CANCEL resets the cursor to the first move") +SummaryMenu.close() + +print("[test] 11. egg STATE box and memo") +local egg = { species = 1, isEgg = true, friendship = 5, otName = "RED", otId = 1, metLocation = 1 } +local memo = realMemo(egg, nil) +eq(#memo, 1, "the egg memo holds only the origin text") +check(memo[1] and not tostring(memo[1]):find("sEggHatchTimeTexts", 1, true), "the hatch text is not in the memo") +SummaryMenu.openMenu({ egg }, 1, {}) +draw() +local st = text_at("sEggHatchTimeTexts[3]") +eq(st and st.x, 127, "the hatch text prints at the STATE box x (right pane + 7)") +eq(st and st.y, 61, "the hatch text prints at the STATE box y (right pane + 45)") +local nm = text_at("gText_EggNickname") +check(nm and nm.x == 167 and nm.y == 35, "the egg's name prints in the right pane, not the name strip") +SummaryMenu.close() + +print("[test] 12. egg pic shake") +MANIFEST = { eggPicShake = { + { 1, 1, 0, -1, -1, 0, -1, -1, 0, 1, 1 }, + { 2, 1, 0, -1, -2, 0, -2, -1, 0, 1, 2 }, + { 2, 1, 1, 0, -1, -1, -2, 0, -2, -1, -1, 0, 1, 1, 2 }, +} } +local eggImg = {} +local drawn +gfx.draw = function(img, a, b) + if img == eggImg then drawn = a end +end +local realFront = Pokemon.frontPic +Pokemon.frontPic = function() return { image = eggImg, w = 64, h = 64 } end +for _, c in ipairs({ { 5, 60, 2, 15 }, { 10, 90, 2, 11 }, { 40, 120, 1, 11 }, { 41, 120, 1, 11 } }) do + local cycles, wait, first, len = c[1], c[2], c[3], c[4] + SummaryMenu.openMenu({ { species = 1, isEgg = true, friendship = cycles } }, 1, {}) + frames(wait) + eq(SummaryMenu._bounce.dx, 0, cycles .. " cycles: still for " .. wait .. " frames") + frames(1) + eq(SummaryMenu._bounce.dx, first, cycles .. " cycles: first jiggle step") + frames(len - 1) + eq(SummaryMenu._bounce.dx, 0, cycles .. " cycles: jiggle ends centred") + eq(SummaryMenu._bounce.count, 1, cycles .. " cycles: one jiggle done") + frames(wait + 1) + eq(SummaryMenu._bounce.dx, first, cycles .. " cycles: second jiggle after the same delay") + frames(len - 1 + 400) + eq(SummaryMenu._bounce.count, 2, cycles .. " cycles: two jiggles only") + eq(SummaryMenu._bounce.dx, 0, cycles .. " cycles: rests centred") + SummaryMenu.close() +end +SummaryMenu.openMenu({ { species = 1, isEgg = true, friendship = 5 } }, 1, {}) +frames(61) +drawn = nil +draw() +eq(drawn, 60 - 32 + 2 + 64, "the egg pic draws h-flipped at its shaken x") +SummaryMenu.close() +Pokemon.frontPic = realFront +MANIFEST = nil + +T.finish("game3_summary_layout_s3_test") diff --git a/tests/engine/game3_trainer_sight_test.lua b/tests/engine/game3_trainer_sight_test.lua index 7ae2e7e0..48caefec 100644 --- a/tests/engine/game3_trainer_sight_test.lua +++ b/tests/engine/game3_trainer_sight_test.lua @@ -127,16 +127,16 @@ eo.facing = "down" Player.reset(10, 13, "up") -- Different elevation (eo on bridge elevation 4 vs player on ground 3) -eo.elevation = 4 -Player.elevation = 3 +eo.currentElevation = 4 +Player.currentElevation = 3 spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame) check(spotted == false, "Blocked by elevation mismatch (eo=4 bridge, player=3 ground)") -eo.elevation = 3 -Player.elevation = 3 +eo.currentElevation = 3 +Player.currentElevation = 3 spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame) check(spotted == true, "Spotted when elevations match (eo=3, player=3)") -eo.elevation = 0 -Player.elevation = 0 +eo.currentElevation = 0 +Player.currentElevation = 0 spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame) check(spotted == true, "Spotted when default ground elevations match (eo=0, player=0)") diff --git a/tests/engine/gen3_save_codec_tests.lua b/tests/engine/gen3_save_codec_tests.lua new file mode 100644 index 00000000..bcc5e300 --- /dev/null +++ b/tests/engine/gen3_save_codec_tests.lua @@ -0,0 +1,827 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Gen3Save = require("src.save_convert.Gen3Save") +local L = require("src.save_convert.Gen3Layout") +local SaveConvert = require("src.save_convert.SaveConvert") +local F = require("tests.fixture_data.gen3_saves") + +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +local IMAGES = {} +for name, r in pairs(F.images) do IMAGES[name] = unrle(r) end + +local function same(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b 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 show(v) + if type(v) ~= "table" then return tostring(v) end + local parts = {} + for k, x in pairs(v) do parts[#parts + 1] = tostring(k) .. "=" .. show(x) end + table.sort(parts) + return "{" .. table.concat(parts, ",") .. "}" +end + +local function same_eq(a, b, label) + check(same(a, b), label .. " (" .. show(a) .. " vs " .. show(b) .. ")") +end + +local function items(list) + local out = {} + for _, it in ipairs(list) do out[#out + 1] = { it.id, it.qty } end + return out +end + +local function plusOne(list) + local out = {} + for _, i in ipairs(list) do out[#out + 1] = i + 1 end + return out +end + +local function checkMon(label, m, o, party) + eq(m.species, o.speciesInternal, label .. " species") + eq(m.personality, o.pid, label .. " personality") + eq(m.otId, o.tid, label .. " TID") + eq(m.otSecretId, o.sid, label .. " SID") + if o.nickname then eq(m.nickname, o.nickname, label .. " nickname") end + if o.ot then eq(m.otName, o.ot, label .. " OT name") end + eq(m.otGender, o.otGender, label .. " OT gender") + eq(m.language, o.lang, label .. " language") + eq(m.isEgg, o.isEgg, label .. " egg") + eq(m.isBadEgg, o.badEgg, label .. " bad egg") + eq(m.markings, o.markingsRaw, label .. " markings") + eq(m.heldItem, o.heldItem, label .. " held item") + eq(m.exp, o.exp, label .. " exp") + eq(m.friendship, o.friendship, label .. " friendship") + eq(m.ppBonuses, o.ppUps, label .. " PP ups") + same_eq(m.moves, o.moves, label .. " moves") + same_eq(m.pp, o.pp, label .. " PP") + same_eq({ m.evs.hp, m.evs.atk, m.evs.def, m.evs.spe, m.evs.spa, m.evs.spd }, o.evs, label .. " EVs") + local c = m.contest + same_eq({ c.cool, c.beauty, c.cute, c.smart, c.tough, c.sheen }, o.contest, label .. " contest") + eq(m.pokerus, o.pokerus, label .. " pokerus") + eq(m.metLocation, o.metLocation, label .. " met location") + eq(m.metLevel, o.metLevel, label .. " met level") + eq(m.metGame, o.version, label .. " met game") + eq(m.pokeball, o.ball, label .. " ball") + local iv = m.ivs + same_eq({ iv.hp, iv.atk, iv.def, iv.spe, iv.spa, iv.spd }, o.ivs, label .. " IVs") + eq(m.abilityNum == 1, o.abilityBit, label .. " ability bit") + eq(m.ribbons, o.ribbons, label .. " ribbons") + eq(m.modernFatefulEncounter, o.fateful, label .. " fateful") + eq(m.checksumOk, o.checksumValid, label .. " checksum") + if party then + same_eq({ m.status, m.level, m.hp, m.maxHp, m.attack, m.defense, m.speed, m.spAtk, m.spDef }, + { o.status, o.statLevel, o.hp, o.maxHp, o.stats[1], o.stats[2], o.stats[3], o.stats[4], o.stats[5] }, + label .. " party stats") + end +end + +local function checkOracle(name, c, o) + eq(c.name, o.ot, name .. " player name") + eq(c.gender, o.gender, name .. " gender") + eq(c.trainerId, o.tid, name .. " TID") + eq(c.secretId, o.sid, name .. " SID") + same_eq({ c.playHours, c.playMinutes, c.playSeconds }, o.playTime, name .. " play time") + eq(c.money, o.money, name .. " money") + eq(c.coins, o.coins, name .. " coins") + eq(c.encryptionKey, o.securityKey, name .. " encryption key") + eq(c.rivalName, o.rival, name .. " rival") + same_eq({ c.posX, c.posY }, o.pos, name .. " position") + local w = c.location + same_eq({ w.group, w.num, w.warpId, w.x, w.y }, o.location, name .. " location") + w = c.lastHealLocation + same_eq({ w.group, w.num, w.warpId, w.x, w.y }, o.heal, name .. " last heal location") + same_eq(items(c.pockets.ITEMS), o.pouches.Items, name .. " items pocket") + same_eq(items(c.pockets.KEY_ITEMS), o.pouches.KeyItems, name .. " key items") + same_eq(items(c.pockets.POKE_BALLS), o.pouches.Balls, name .. " balls") + same_eq(items(c.pockets.TM_CASE), o.pouches.TMHMs, name .. " TM case") + same_eq(items(c.pockets.BERRY_POUCH), o.pouches.Berries, name .. " berry pouch") + same_eq(items(c.pcItems), o.pouches.PCItems, name .. " PC items") + same_eq(plusOne(c.dexSeen), o.seen, name .. " dex seen") + same_eq(plusOne(c.dexOwned), o.caught, name .. " dex caught") + same_eq(c.flags, o.flags, name .. " flags") + local vars = {} + for id, v in pairs(c.vars) do vars[tostring(id)] = v end + local ovars = {} + for id, v in pairs(o.vars) do ovars[tostring(id)] = v end + same_eq(vars, ovars, name .. " vars") + local stats = {} + for id, v in pairs(c.gameStats) do stats[tostring(id)] = v end + local ostats = {} + for id, v in pairs(o.gameStats) do ostats[tostring(id)] = v end + same_eq(stats, ostats, name .. " game stats") + eq(c.storage.currentBox, o.currentBox, name .. " current box") + local names, walls = {}, {} + for b, box in ipairs(c.storage.boxes) do names[b], walls[b] = box.name, box.wallpaper end + same_eq(names, o.boxNames, name .. " box names") + same_eq(walls, o.walls, name .. " wallpapers") + eq(#c.party, #o.party, name .. " party size") + for i, om in ipairs(o.party) do checkMon(name .. " party " .. i, c.party[i], om, true) end + local n = 0 + for bx, box in ipairs(c.storage.boxes) do + for sl, mon in pairs(box.mons) do + n = n + 1 + local found + for _, ob in ipairs(o.boxes) do + if ob.box == bx - 1 and ob.slot == sl - 1 then found = ob end + end + check(found ~= nil, name .. " box " .. bx .. " slot " .. sl .. " is in the oracle") + if found then checkMon(name .. " box " .. bx .. "/" .. sl, mon, found.mon, false) end + end + end + eq(n, #o.boxes, name .. " box mon count") +end + +for _, name in ipairs({ "fr_rich_game", "lg_rich_game", "fr_bedroom" }) do + local c, why = Gen3Save.decode(IMAGES[name]) + check(c ~= nil, name .. " decodes (" .. tostring(why) .. ")") + if c then + checkOracle(name, c, F.oracle[name]) + eq(c.olderSlot, false, name .. " reads the newest slot") + end +end + +local rich = IMAGES.fr_rich_game + +do + local c = assert(Gen3Save.decode(rich .. string.rep("\0", 16))) + checkOracle("128K+16 trailer", c, F.oracle.fr_rich_game) + local c64 = assert(Gen3Save.decode(rich:sub(1, 0x10000))) + checkOracle("64K flash", c64, F.oracle.fr_rich_game) + local c63 = Gen3Save.decode(rich .. string.rep("\0", 0x40)) + eq(c63, nil, "a 128K image with 64 extra bytes is refused") +end + +local function poke(bytes, off, v) + return bytes:sub(1, off) .. string.char(v) .. bytes:sub(off + 2) +end + +do + local corrupt = poke(rich, 24677, (rich:byte(24678) + 1) % 256) + local c = assert(Gen3Save.decode(corrupt)) + eq(c.olderSlot, true, "a damaged newest slot falls back to the older one") + checkOracle("older slot", c, F.oracle.fr_rich_pkhex) + local save, err, note = SaveConvert.importSav(corrupt, "firered", "firered") + check(save ~= nil, "the older slot imports (" .. tostring(err) .. ")") + eq(note, Gen3Save.MSG.olderSlot, "the import says the previous save was used") + local both = poke(corrupt, 14 * 0x1000 + 100, (corrupt:byte(14 * 0x1000 + 101) + 1) % 256) + local _, bad = SaveConvert.importSav(both, "firered", "firered") + eq(bad, Gen3Save.MSG.corrupt, "two damaged slots are refused") + local _, empty = SaveConvert.importSav(string.rep("\255", 0x20000), "firered", "firered") + eq(empty, Gen3Save.MSG.empty, "an erased flash is refused as empty") + local _, small = SaveConvert.importSav(string.rep("\0", 32768), "firered", "firered") + eq(small, Gen3Save.MSG.size:format(32768), "a 32 KB file gets the size sentence") +end + +local function rebuild(bytes, patch) + local blocks = assert(Gen3Save.readBlocks(bytes)) + patch(blocks) + return Gen3Save.buildFlash(blocks, { counter = blocks.counter + 1 }) +end + +local function setByte(block, off, v) return block:sub(1, off) .. string.char(v) .. block:sub(off + 2) end + +do + local rs = rebuild(rich, function(b) b.sb2 = setByte(b.sb2, 0xAC, 0) end) + local _, err = SaveConvert.importSav(rs, "firered", "firered") + eq(err, Gen3Save.MSG.notFrlg, "a Ruby/Sapphire marker is refused") + local jp = rebuild(rich, function(b) b.sb2 = setByte(setByte(b.sb2, 6, 0), 7, 0) end) + local _, jerr = SaveConvert.importSav(jp, "firered", "firered") + eq(jerr, Gen3Save.MSG.japanese, "a Japanese save is refused") + local ok = assert(Gen3Save.decode(rebuild(rich, function() end))) + checkOracle("rebuilt flash", ok, F.oracle.fr_rich_game) +end + +math.randomseed(2444) +local function rnd(n) return math.random(0, n - 1) end +for order = 0, 23 do + for trial = 1, 3 do + local personality = (rnd(65536) * 65536 + rnd(65536)) - ((rnd(65536) * 65536 + rnd(65536)) % 24) + order + personality = personality % 4294967296 + if personality % 24 ~= order then personality = personality - personality % 24 + order end + local mon = { + personality = personality, + otIdRaw = rnd(65536) * 65536 + rnd(65536), + nickname = "MON" .. order, language = 2, otName = "OT", markings = rnd(16), + species = 1 + rnd(411), heldItem = rnd(376), exp = rnd(1000000), ppBonuses = rnd(256), friendship = rnd(256), + growthFiller = 0, + moves = { 1 + rnd(354), rnd(355), rnd(355), rnd(355) }, pp = { rnd(64), rnd(64), rnd(64), rnd(64) }, + evs = { hp = rnd(256), atk = rnd(256), def = rnd(256), spe = rnd(256), spa = rnd(256), spd = rnd(256) }, + contest = { cool = rnd(256), beauty = rnd(256), cute = rnd(256), smart = rnd(256), tough = rnd(256), sheen = rnd(256) }, + pokerus = rnd(256), metLocation = rnd(256), metLevel = rnd(101), metGame = rnd(16), pokeball = rnd(16), otGender = rnd(2), + ivs = { hp = rnd(32), atk = rnd(32), def = rnd(32), spe = rnd(32), spa = rnd(32), spd = rnd(32) }, + isEgg = trial == 2, abilityNum = rnd(2), ribbons = rnd(65536) * 65536 + rnd(65536), + isBadEgg = trial == 3, unknown = 0, + } + local raw = Gen3Save.encodeBoxMon(mon) + eq(#raw, L.BOX_MON_SIZE, "order " .. order .. " encodes 80 bytes") + local back = Gen3Save.decodeBoxMon(raw) + eq(back.checksumOk, true, "order " .. order .. " trial " .. trial .. " checksum") + for _, k in ipairs({ "personality", "otIdRaw", "nickname", "otName", "species", "heldItem", "exp", "ppBonuses", + "friendship", "pokerus", "metLocation", "metLevel", "metGame", "pokeball", "otGender", "isEgg", + "abilityNum", "ribbons", "markings", "isBadEgg" }) do + eq(back[k], mon[k], "order " .. order .. " trial " .. trial .. " " .. k) + end + same_eq(back.moves, mon.moves, "order " .. order .. " moves") + same_eq(back.ivs, mon.ivs, "order " .. order .. " IVs") + same_eq(back.evs, mon.evs, "order " .. order .. " EVs") + same_eq(back.contest, mon.contest, "order " .. order .. " contest") + eq(back.isEggFlag, mon.isEgg, "order " .. order .. " egg flag byte") + local tampered = poke(raw, 0x20 + rnd(48), (raw:byte(0x21) + 1) % 256) + local bad = Gen3Save.decodeBoxMon(tampered) + if not bad.checksumOk then + eq(bad.isBadEgg, true, "order " .. order .. " a bad checksum reads as a bad egg") + end + end +end + +local function strip(c) + c.counter, c.slot, c.olderSlot = nil, nil, nil + return c +end + +for _, name in ipairs({ "fr_rich_game", "lg_rich_game", "fr_bedroom" }) do + local c, blocks = Gen3Save.decode(IMAGES[name]) + local again = assert(Gen3Save.decode(Gen3Save.encode(c, { template = blocks, counter = c.counter + 1 }))) + same_eq(strip(again), strip(Gen3Save.decode(IMAGES[name])), name .. " encode(decode) with its template decodes identically") + local c2 = Gen3Save.decode(IMAGES[name]) + local bare = assert(Gen3Save.decode(Gen3Save.encode(c2, { counter = 1 }))) + bare.player, c2.player = nil, nil + same_eq(strip(bare), strip(c2), name .. " encode(decode) without a template decodes identically") +end + +do + local save, err, note = SaveConvert.importSav(rich, "firered", "firered") + check(save ~= nil, "fr_rich_game converts to a port save (" .. tostring(err) .. ")") + eq(note, nil, "a clean import has no fallback note") + eq(save.engine, "game3", "port save engine") + eq(save.version, "firered", "port save version") + eq(save.name, "AAA", "port player name") + eq(save.rivalName, "GREEN", "port rival name") + eq(save.trainerId, 56718, "port TID") + eq(save.secretId, 43871, "port SID") + eq(save.money, 123456, "port money") + eq(save.coins, 777, "port coins") + eq(save.map, "FR_PLAYERS_HOUSE_2F", "port map") + eq(save.x, 6, "port x") + eq(save.y, 6, "port y") + same_eq(save.playTime, { hours = 12, minutes = 35, seconds = 24, vblanks = 12 }, "port play time") + eq(save.modData.cartImport.lastHealLocation.map, "FR_PALLET_TOWN", "the heal spot waits for the first load") + local p = save.party + eq(#p, 4, "port party size") + eq(p[1].species, 6, "party 1 species") + eq(p[1].nickname, "BLAZE", "party 1 nickname") + eq(p[1].level, 36, "party 1 level") + eq(p[1].hp, 50, "party 1 hp") + eq(p[1].status, "PSN", "party 1 poisoned") + eq(p[1].ppBonusesPacked, 3, "party 1 PP ups") + eq(p[1].item, 200, "party 1 held item") + eq(p[1].cartExtra.championRibbon, true, "party 1 champion ribbon kept") + eq(p[1].cartImport, true, "party mons are finished on first load") + eq(p[2].species, 277, "Treecko keeps its internal species") + eq(p[2].otName, "PKHEX", "Treecko keeps its foreign OT") + eq(p[3].isEgg, true, "the Pichu egg is an egg") + eq(p[3].nickname, "EGG", "the egg is named EGG") + eq(p[3].eggCycles, 10, "the egg keeps its cycles") + eq(p[4].species, 201, "Unown") + eq(p[4].cartExtra.modernFatefulEncounter, true, "Unown fateful bit kept") + eq(save.storage.currentBox, 2, "current box is 1-based") + eq(save.storage.boxes[2].mons[20].species, 150, "box 2 slot 20 straddles storage chunks") + eq(save.storage.boxes[2].mons[20].level, nil, "box levels wait for the first load") + eq(save.storage.boxes[14].mons[30].nickname, "SPARKY", "box 14 slot 30") + eq(save.storage.boxes[8].mons[15].species, 410, "Deoxys in box 8") + eq(save.storage.boxes[3].name, "CUSTOM", "a renamed box keeps its name") + eq(save.storage.boxes[3].wallpaper, 6, "a changed wallpaper is 1-based") + eq(save.storage.boxes[1].name, "BOX 1", "a default box name takes the port default") + eq(save.storage.boxes[5].wallpaper, 5, "a default wallpaper takes the port default") + same_eq(save.storage.items, { { id = 13, qty = 1 }, { id = 20, qty = 3 } }, "PC items") + same_eq(save.bag.pockets.ITEMS, { { id = 13, qty = 10 }, { id = 68, qty = 5 }, { id = 23, qty = 99 } }, "bag items") + same_eq(save.bag.pockets.KEY_ITEMS, { { id = 360, qty = 1 }, { id = 364, qty = 1 } }, "key items") + for _, f in ipairs({ 0x820, 0x821, 0x828, 0x829 }) do + eq(save.flags[f], true, ("flag 0x%X set"):format(f)) + eq(save.flags[tostring(f)], true, ("flag 0x%X set by string key"):format(f)) + end + eq(save.flags[0x822], nil, "flag 0x822 clear") + eq(save.vars[0x4050], 7, "var 0x4050") + local nat = {} + for _, n in ipairs(save.modData.cartImport.dexOwned) do nat[n] = true end + eq(nat[252], true, "Treecko is caught under national 252") + eq(nat[386], true, "Deoxys is caught under national 386") + eq(save.dex.national, false, "national dex stays off") + eq(save.game_cleared, false, "game not cleared") + eq(save.options.textSpeed, 1, "text speed") +end + +do + local lg, err = SaveConvert.importSav(IMAGES.lg_rich_game, "firered", "firered") + check(lg ~= nil, "a LeafGreen cart imports into FireRed (" .. tostring(err) .. ")") + local fr, err2 = SaveConvert.importSav(rich, "leafgreen", "leafgreen") + check(fr ~= nil, "a FireRed cart imports into LeafGreen (" .. tostring(err2) .. ")") + eq(fr and fr.version, "leafgreen", "the import is stamped with the target game") +end + +local STUB_NATIONAL = { [277] = 252, [410] = 386 } +local STUB_LAYOUT = { ["4:1"] = 2, ["3:0"] = 1, ["3:1"] = 79 } +local function stubOpts(extra) + local o = { + version = "firered", + metGame = L.VERSION_FIRE_RED, + toNational = function(sp) return STUB_NATIONAL[sp] or (sp >= 1 and sp <= 251 and sp or nil) end, + speciesFromNational = function(n) for sp, nat in pairs(STUB_NATIONAL) do if nat == n then return sp end end return n end, + speciesName = function(sp) return ({ [4] = "CHARMANDER", [16] = "PIDGEY", [172] = "PICHU" })[sp] end, + mapLayoutId = function(g, n) return STUB_LAYOUT[g .. ":" .. n] end, + healWarp = function() return { group = 3, num = 0, warpId = -1, x = 6, y = 8 } end, + } + for k, v in pairs(extra or {}) do o[k] = v end + return o +end + +local SaveSerializer = require("src.core.SaveSerializer") +local function reload(t) return SaveSerializer.decode(SaveSerializer.encode(t)) end + +local function byteRuns(a, b) + local runs, start = {}, nil + for i = 1, #a + 1 do + local d = i <= #a and a:byte(i) ~= b:byte(i) + if d and not start then start = i - 1 end + if not d and start then runs[#runs + 1] = { start, i - 2 }; start = nil end + end + return runs +end + +local function onlyIn(runs, allowed) + for _, r in ipairs(runs) do + local ok = false + for _, w in ipairs(allowed) do + if r[1] >= w[1] and r[2] <= w[2] then ok = true end + end + if not ok then return false, string.format("0x%X-0x%X", r[1], r[2]) end + end + return true +end + +do + eq(SaveConvert.exportSupported("firered"), true, "FireRed cart export is supported") + eq(SaveConvert.exportSupported("leafgreen"), true, "LeafGreen cart export is supported") + + local save = reload(assert(SaveConvert.importSav(rich, "firered", "firered"))) + local out = assert(Gen3Save.exportPort(save, stubOpts({ template = rich }))) + eq(#out, L.FLASH_SIZE, "a template export is a 128K flash image") + local a, b = assert(Gen3Save.readBlocks(rich)), assert(Gen3Save.readBlocks(out)) + eq(b.counter, a.counter + 1, "the export is the next save after the cart's") + local ok, where = onlyIn(byteRuns(a.sb2, b.sb2), { { 0x09, 0x09 } }) + check(ok, "SaveBlock2 only changes the continue flag (" .. tostring(where) .. ")") + ok, where = onlyIn(byteRuns(a.sb1, b.sb1), { { 0x0C, 0x13 } }) + check(ok, "SaveBlock1 only changes the continue warp (" .. tostring(where) .. ")") + eq(#byteRuns(a.storage, b.storage), 0, "PC storage is byte-identical") + for s = 28, 31 do + eq(out:sub(s * 0x1000 + 1, (s + 1) * 0x1000), rich:sub(s * 0x1000 + 1, (s + 1) * 0x1000), + "sector " .. s .. " (Hall of Fame / Trainer Tower) is kept from the cart") + end + local c = assert(Gen3Save.decode(out)) + eq(c.specialSaveWarpFlags, L.CONTINUE_GAME_WARP, "the export continues through the continue-game warp") + same_eq(c.continueGameWarp, { group = 4, num = 1, warpId = -1, x = 6, y = 6 }, "continue warp is the bedroom") + eq(c.encryptionKey, a.sb2:byte(0xF21) + a.sb2:byte(0xF22) * 256 + a.sb2:byte(0xF23) * 65536 + a.sb2:byte(0xF24) * 16777216, + "the cart's encryption key is kept for the unmodeled key-XORed fields") + + local again = reload(assert(SaveConvert.importSav(out, "firered", "firered"))) + local cw = again.continueGameWarp + again.continueGameWarp, again.specialSaveWarpFlags = nil, nil + local base = reload(save) + base.continueGameWarp, base.specialSaveWarpFlags = nil, nil + same_eq(again, base, "import(export(cart slot)) equals the slot") + same_eq(cw, { map = "FR_PLAYERS_HOUSE_2F", warpId = -1, x = 6, y = 6 }, "the re-import sees the continue warp") + + local moved = reload(save) + moved.map, moved.x, moved.y = "FR_VIRIDIAN_CITY", 26, 28 + moved.money = 777 + table.remove(moved.party, 4) + moved.storage.boxes[2].mons[20] = nil + moved.flags["FLAG_BADGE01_GET"] = nil + moved.flags[0x820], moved.flags["2080"] = nil, nil + moved.flags["FLAG_SYS_POKEDEX_GET"] = true + moved.vars.VAR_STARTER_MON = 2 + local mb = assert(Gen3Save.exportPort(moved, stubOpts({ template = rich }))) + local m = assert(Gen3Save.decode(mb)) + same_eq(m.location, { group = 3, num = 1, warpId = -1, x = 26, y = 28 }, "a moved slot saves its new map") + eq(m.mapLayoutId, 79, "the new map's layout id rides with it") + eq(m.money, 777, "money change") + eq(#m.party, 3, "a released party mon is gone") + eq(m.storage.boxes[2].mons[20], nil, "a released box mon is gone") + local fl = {} + for _, id in ipairs(m.flags) do fl[id] = true end + eq(fl[0x820], nil, "a cleared flag is cleared") + eq(fl[0x829], true, "a flag set by name is set") + eq(m.vars[0x4031], 2, "a var set by name is set") + local mblk = assert(Gen3Save.readBlocks(mb)) + -- include/global.h:793 + eq(mblk.sb1:sub(0x1301, 0x2CA0), a.sb1:sub(0x1301, 0x2CA0), "the quest log is kept from the cart") + -- include/global.h:352 + eq(mblk.sb2:sub(0x899, 0xAF0), a.sb2:sub(0x899, 0xAF0), "map view and link battle records are kept") + -- include/global.h:807 + eq(mblk.sb1:sub(0x3121, 0x3A18), a.sb1:sub(0x3121, 0x3A18), "mystery gift data is kept") +end + +local function portMon(o) + local m = { + species = 4, speciesNumbering = "internal", name = "CHARMANDER", nickname = "", level = 5, exp = 135, + moves = { 10, 45 }, pp = { 35, 40 }, personality = 0x11223344, + ivs = { hp = 1, atk = 2, def = 3, spe = 4, spa = 5, spd = 6 }, + evs = { hp = 7, atk = 8, def = 9, spe = 10, spa = 11, spd = 12 }, + friendship = 70, metLocation = 88, metLevel = 5, metGame = 4, pokeball = 4, + otName = "PORT", otId = 12345, otSecretId = 54321, otGender = 1, + hp = 20, maxHp = 20, attack = 11, defense = 10, speed = 12, spAtk = 11, spDef = 10, + } + for k, v in pairs(o or {}) do m[k] = v end + return m +end + +do + local save = { + engine = "game3", version = "firered", generation = 3, + name = "PORT", rivalName = "RIVAL", gender = 1, trainerId = 12345, secretId = 54321, + money = 4321, coins = 12, map = "FR_PALLET_TOWN", x = 12, y = 16, facing = "down", + healMap = "FR_PLAYERS_HOUSE_1F", healX = 8, healY = 5, + party = { + portMon({ hp = 7, status = "PAR" }), + portMon({ species = 16, name = "PIDGEY", nickname = "BIRDY", personality = 0x55667788, item = 13, heldItem = 13 }), + portMon({ species = 172, name = "EGG", nickname = "EGG", isEgg = true, eggCycles = 10, personality = 0x0BADF00D }), + }, + storage = { + currentBox = 2, + boxes = { + [1] = { name = "BOX 1", wallpaper = 1, mons = { [5] = portMon({ species = 277, name = nil, nickname = "", personality = 0x99 }) } }, + [3] = { name = "MINE", wallpaper = 7, mons = {} }, + }, + items = { { id = 13, qty = 1 } }, + }, + bag = { pockets = { ITEMS = { { id = 13, qty = 3 } }, KEY_ITEMS = { { id = 360, qty = 1 } }, + POKE_BALLS = { { id = 4, qty = 5 } }, TM_CASE = {}, BERRY_POUCH = {} } }, + flags = { ["2088"] = true, [0x820] = true, FLAG_SYS_POKEDEX_GET = true, ["5"] = true }, + vars = { [0x4050] = 3, VAR_STARTER_MON = 1 }, + dex = { seen = { [4] = true, [16] = true, [277] = true }, owned = { [4] = true }, caught = { [4] = true }, national = false }, + playTime = { hours = 1, minutes = 2, seconds = 3, vblanks = 4 }, + options = { firered = { textSpeed = 2, battleScene = 1, battleStyle = 1, sound = 1, buttonMode = 1, frameType = 3 } }, + gameStats = { [2] = 5 }, + easyChatProfile = { 1, 2, 3, 4 }, + specialSaveWarpFlags = 0, + hallOfFameTeams = { { { species = 6, level = 50, nickname = "CHAR", trainerId = 12345, otSecretId = 54321, personality = 99 } } }, + roamer = { active = true, species = 243, level = 50, hp = 100, statusNum = 0, pid = 7, + ivs = { hp = 31, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } }, + modData = { firered_daycare = { daycare = { [1] = portMon({ personality = 0x1234 }), steps = { 300, 0 } }, + route5Daycare = { steps = 0 } } }, + } + local bytes, err = Gen3Save.exportPort(reload(save), stubOpts()) + check(bytes ~= nil, "a port-born slot exports without a template (" .. tostring(err) .. ")") + eq(#bytes, L.FLASH_SIZE, "a fresh export is a 128K flash image") + local c = assert(Gen3Save.decode(bytes)) + eq(c.counter, 1, "a fresh export is a first save") + eq(c.encryptionKey, 0, "a fresh export starts with key 0") + eq(c.specialSaveWarpFlags, L.CONTINUE_GAME_WARP, "continue-game warp flag set") + same_eq(c.continueGameWarp, { group = 3, num = 0, warpId = -1, x = 12, y = 16 }, "continue warp is Pallet 12,16") + same_eq(c.location, { group = 3, num = 0, warpId = -1, x = 12, y = 16 }, "location") + eq(c.mapLayoutId, 1, "map layout id comes from the map header") + eq(c.posX == 12 and c.posY == 16, true, "position") + same_eq(c.lastHealLocation, { group = 3, num = 0, warpId = -1, x = 6, y = 8 }, "heal location") + eq(c.name, "PORT", "player name") + eq(c.rivalName, "RIVAL", "rival name") + eq(c.gender, 1, "gender") + eq(c.trainerId == 12345 and c.secretId == 54321, true, "TID/SID") + eq(c.money, 4321, "money") + eq(c.coins, 12, "coins") + eq(c.playHours == 1 and c.playMinutes == 2 and c.playSeconds == 3, true, "play time") + eq(c.options.textSpeed == 2 and c.options.battleScene == 1 and c.options.battleStyle == 1 + and c.options.sound == 1 and c.options.frameType == 3 and c.buttonMode == 1, true, "options from the game's options block") + eq(#c.party, 3, "party size") + local p1 = c.party[1] + eq(p1.checksumOk, true, "party 1 checksum") + eq(p1.species, 4, "party 1 species") + eq(p1.nickname, "CHARMANDER", "an unnamed mon carries its species name like the cart") + eq(p1.level, 5, "party 1 level") + eq(p1.hp, 7, "party 1 hp") + eq(p1.maxHp, 20, "party 1 max hp") + eq(p1.status, L.STATUS.PAR, "party 1 paralysis") + eq(p1.mail, 0xFF, "no mail") + same_eq(p1.moves, { 10, 45, 0, 0 }, "party 1 moves") + same_eq(p1.pp, { 35, 40, 0, 0 }, "party 1 pp") + same_eq(p1.ivs, { hp = 1, atk = 2, def = 3, spe = 4, spa = 5, spd = 6 }, "party 1 IVs") + eq(p1.otId == 12345 and p1.otSecretId == 54321 and p1.otName == "PORT", true, "party 1 OT") + eq(p1.abilityNum, 0x11223344 % 2, "ability bit from personality") + eq(p1.language, L.LANGUAGE_ENGLISH, "English") + eq(c.party[2].nickname, "BIRDY", "nickname") + eq(c.party[2].heldItem, 13, "held item") + local egg = c.party[3] + eq(egg.isEgg, true, "egg bit") + eq(egg.language, L.LANGUAGE_JAPANESE, "a port egg is Japanese like CreateEgg") + eq(egg.nicknameBytes:sub(1, 3), L.EGG_NICKNAME, "a port egg carries the egg nickname") + eq(egg.friendship, 10, "egg cycles") + eq(c.storage.currentBox, 1, "current box is 0-based") + eq(c.storage.boxes[1].name, "BOX1", "default box name is the cart's") + eq(c.storage.boxes[1].wallpaper, 0, "default wallpaper is the cart's") + eq(c.storage.boxes[3].name, "MINE", "renamed box") + eq(c.storage.boxes[3].wallpaper, 6, "wallpaper is 0-based") + eq(c.storage.boxes[1].mons[5].species, 277, "box mon position") + eq(c.storage.boxes[1].mons[5].exp, 135, "box mon exp") + same_eq(c.pcItems, { { id = 13, qty = 1 } }, "PC items") + same_eq(c.pockets.ITEMS, { { id = 13, qty = 3 } }, "bag items") + same_eq(c.pockets.KEY_ITEMS, { { id = 360, qty = 1 } }, "key items") + same_eq(c.pockets.POKE_BALLS, { { id = 4, qty = 5 } }, "balls") + local fl = {} + for _, id in ipairs(c.flags) do fl[id] = true end + eq(fl[0x828] and fl[0x820] and fl[0x829], true, "flags by number, string and name") + eq(fl[5], nil, "temp flags are not saved") + eq(c.vars[0x4050], 3, "var by number") + eq(c.vars[0x4031], 1, "var by name") + same_eq(c.dexOwned, { 3 }, "owned bit is national order") + same_eq(c.dexSeen, { 3, 15, 251 }, "seen bits use the national number (Treecko 252)") + same_eq(c.dexSeen1, c.dexSeen, "seen1 matches") + same_eq(c.dexSeen2, c.dexSeen, "seen2 matches") + eq(c.dexNationalMagic, 0, "national dex off") + eq(c.gameStats[2], 5, "game stat") + same_eq({ c.easyChatProfile[1], c.easyChatProfile[2], c.easyChatProfile[3], c.easyChatProfile[4] }, { 1, 2, 3, 4 }, "profile words") + eq(#c.hallOfFame, 1, "Hall of Fame team") + eq(c.hallOfFame[1][1].species == 6 and c.hallOfFame[1][1].level == 50 and c.hallOfFame[1][1].nickname == "CHAR", true, "Hall of Fame mon") + eq(c.roamer.species == 243 and c.roamer.active == 1 and c.roamer.ivs == 31 and c.roamer.personality == 7, true, "roamer") + eq(c.daycare.mons[1] and c.daycare.mons[1].species, 4, "daycare mon") + eq(c.daycare.steps[1], 300, "daycare steps") + eq(c.daycare.mons[2], nil, "empty daycare slot") + + local back = assert(SaveConvert.importSav(bytes, "firered", "firered")) + eq(back.name == "PORT" and back.rivalName == "RIVAL" and back.money == 4321 and back.coins == 12, true, "re-import: trainer") + eq(back.map == "FR_PALLET_TOWN" and back.x == 12 and back.y == 16, true, "re-import: map") + for i, m in ipairs(save.party) do + local r = back.party[i] + for _, k in ipairs({ "species", "level", "hp", "personality", "exp", "otId", "otSecretId", "metLevel", "pokeball", "otGender" }) do + eq(r[k], m[k], ("re-import: party %d %s"):format(i, k)) + end + same_eq(r.moves, m.moves, ("re-import: party %d moves"):format(i)) + same_eq(r.ivs, m.ivs, ("re-import: party %d IVs"):format(i)) + same_eq(r.evs, m.evs, ("re-import: party %d EVs"):format(i)) + end + eq(back.party[1].status, "PAR", "re-import: status") + eq(back.party[3].isEgg, true, "re-import: egg") + eq(back.party[3].eggCycles, 10, "re-import: egg cycles") + eq(back.storage.currentBox, 2, "re-import: current box") + eq(back.storage.boxes[1].name, "BOX 1", "re-import: default box name") + eq(back.storage.boxes[1].wallpaper, 1, "re-import: default wallpaper") + eq(back.storage.boxes[3].name == "MINE" and back.storage.boxes[3].wallpaper == 7, true, "re-import: custom box") + eq(back.storage.boxes[1].mons[5].species, 277, "re-import: box mon") + same_eq(back.bag.pockets.ITEMS, save.bag.pockets.ITEMS, "re-import: bag") + same_eq(back.storage.items, save.storage.items, "re-import: PC items") + eq(back.flags[0x828] and back.flags[0x820] and back.flags[0x829], true, "re-import: flags") + eq(back.vars[0x4050] == 3 and back.vars[0x4031] == 1, true, "re-import: vars") + same_eq(back.modData.cartImport.dexOwned, { 4 }, "re-import: owned (national)") + same_eq(back.modData.cartImport.dexSeen, { 4, 16, 252 }, "re-import: seen (national)") + same_eq(back.playTime, save.playTime, "re-import: play time") + eq(back.options.textSpeed == 2 and back.options.buttonMode == 1 and back.options.frameType == 3, true, "re-import: options") + eq(back.gameStats[2], 5, "re-import: game stat") + eq(back.hallOfFameTeams[1][1].species == 6 and back.hallOfFameTeams[1][1].trainerId == 12345 + and back.hallOfFameTeams[1][1].otSecretId == 54321, true, "re-import: Hall of Fame") + eq(back.modData.firered_daycare.daycare[1].species, 4, "re-import: daycare") + + local _, noData = Gen3Save.exportPort(reload(save), stubOpts({ toNational = false })) + eq(noData, Gen3Save.MSG.noData, "no species table refuses with the re-import sentence") + local _, noLayout = Gen3Save.exportPort(reload(save), stubOpts({ mapLayoutId = function() return nil end })) + eq(noLayout, Gen3Save.MSG.noData, "no map header refuses a fresh export") + local lost = reload(save) + lost.map = "FR_NOWHERE" + local _, noMap = Gen3Save.exportPort(lost, stubOpts()) + eq(noMap, Gen3Save.MSG.noMap, "an unknown map is refused") +end + +local function portSave(extra) + local s = { + engine = "game3", version = "firered", generation = 3, + name = "PORT", rivalName = "RIVAL", gender = 0, trainerId = 12345, secretId = 54321, + money = 3000, coins = 0, map = "FR_PALLET_TOWN", x = 12, y = 16, facing = "down", + healMap = "FR_PLAYERS_HOUSE_1F", healX = 8, healY = 5, + party = { portMon() }, + storage = { currentBox = 1, boxes = {}, items = {} }, + bag = { pockets = {} }, flags = {}, vars = {}, + dex = { seen = { [4] = true }, owned = { [4] = true }, caught = { [4] = true }, national = false }, + playTime = { hours = 1, minutes = 0, seconds = 0, vblanks = 0 }, options = {}, specialSaveWarpFlags = 0, + } + for k, v in pairs(extra or {}) do s[k] = v end + return s +end + +local function flagSet(c) + local fl = {} + for _, id in ipairs(c.flags) do fl[id] = true end + return fl +end + +do + local N = L.NATIONAL_DEX + local cases = { + { "dex.nationalUnlocked", { dex = { seen = {}, owned = {}, caught = {}, national = false, nationalUnlocked = true } } }, + { "FLAG_SYS_NATIONAL_DEX alone", { flags = { ["2112"] = true } } }, + { "VAR_NATIONAL_DEX alone", { vars = { [0x404E] = 0x6258 } } }, + } + for _, case in ipairs(cases) do + local bytes = assert(Gen3Save.exportPort(reload(portSave(case[2])), stubOpts())) + local c = assert(Gen3Save.decode(bytes)) + eq(c.dexNationalMagic, N.magic, case[1] .. ": the export carries the National Dex magic") + eq(c.vars[N.var], N.varValue, case[1] .. ": VAR_NATIONAL_DEX") + eq(flagSet(c)[N.flag], true, case[1] .. ": FLAG_SYS_NATIONAL_DEX") + local back = assert(SaveConvert.importSav(bytes, "firered", "firered")) + eq(back.dex.national == true and back.dex.nationalUnlocked == true, true, case[1] .. ": the re-import unlocks the port's National Dex") + end + local off = assert(Gen3Save.decode(assert(Gen3Save.exportPort(reload(portSave()), stubOpts())))) + eq(off.dexNationalMagic, 0, "no unlock, no magic") + eq(off.vars[N.var], nil, "no unlock, no var") + local back = assert(SaveConvert.importSav(Gen3Save.exportPort(reload(portSave()), stubOpts()), "firered", "firered")) + eq(back.dex.nationalUnlocked, nil, "no unlock stays locked on import") +end + +do + local ORANGE, HARBOR = 121, 122 + local words = { 1, 2, 3, 4, 5, 6, 7, 8, 9 } + local save = portSave({ + party = { + portMon({ item = ORANGE, heldItem = ORANGE, mail = 0 }), + portMon({ species = 16, item = HARBOR, heldItem = HARBOR }), + portMon({ species = 16, item = 13, heldItem = 13, mail = 3 }), + portMon({ species = 16, item = HARBOR, heldItem = HARBOR, mail = 20 }), + }, + mail = { { words = words, playerName = "PORT", trainerId = 12345, species = 4, itemId = ORANGE } }, + modData = { firered_daycare = { + daycare = { [1] = portMon({ personality = 0x1234 }), steps = { 5, 0 }, + mail = { [1] = { otName = "PORT", monName = "CHARMANDER", + message = { words = { 9, 8, 7, 6, 5, 4, 3, 2, 1 }, playerName = "PORT", trainerId = 12345, species = 4, itemId = HARBOR } } } }, + route5Daycare = { steps = 0 } } }, + }) + local bytes = assert(Gen3Save.exportPort(reload(save), stubOpts())) + local c = assert(Gen3Save.decode(bytes)) + eq(c.party[1].mail, 0, "a port mon's mail index is exported") + same_eq(c.mail[1].words, words, "the letter's words are in SaveBlock1.mail") + eq(c.mail[1].itemId, ORANGE, "the letter's mail item") + eq(c.mail[1].species, 4, "the letter's species") + eq(c.mail[1].playerName, "PORT", "the letter's author") + eq(c.mail[1].trainerIdRaw, 12345 + 54321 * 65536, "the player's own letter carries TID and SID") + local blk = assert(Gen3Save.readBlocks(bytes)) + -- src/mail_data.c:58 + eq(blk.sb1:sub(L.MAIL.off + L.MAIL.playerName + 1, L.MAIL.off + L.MAIL.playerName + 8), "\xCA\xC9\xCC\xCE\x00\x00\xFF\xFF", + "the author is space padded to 6 like GiveMailToMon") + eq(c.party[2].mail, 1, "mail held without a letter gets a fresh letter slot") + eq(c.mail[2].itemId, HARBOR, "the fresh letter is that mail") + eq(c.party[3].mail, L.MAIL_NONE, "a non-mail item has no mail index") + eq(c.party[4].mail, 2, "an index past the mail table is never exported") + for i = 1, #c.party do check(c.party[i].mail == L.MAIL_NONE or c.party[i].mail < L.MAIL.count, "party " .. i .. " mail index in range") end + eq(c.mail[4].itemId, 0, "unused letters are cleared") + same_eq(c.mail[4].words, { 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF }, "cleared letters use EC_WORD_UNDEFINED") + eq(c.mail[4].species, L.MAIL_CLEAR_SPECIES, "cleared letters use ClearMailStruct's species") + eq(c.daycare.mail[1] and c.daycare.mail[1].message.itemId, HARBOR, "daycare mail is exported") + eq(c.daycare.mail[1] and c.daycare.mail[1].monName, "CHARMANDER", "daycare mail mon name") + + local back = assert(SaveConvert.importSav(bytes, "firered", "firered")) + eq(back.party[1].mail, 0, "import: mon.mail is the cart's index") + same_eq(back.mail[1].words, words, "import: the letter lands in save.mail") + eq(back.mail[1].itemId == ORANGE and back.mail[1].playerName == "PORT" and back.mail[1].trainerId == 12345, true, "import: letter header") + eq(back.mail[1].design, 0, "import: letter design from the mail item") + eq(back.party[3].mail, nil, "import: no mail index without a letter") + eq(#back.mail, 16, "import: the whole mail table") + eq(back.modData.firered_daycare.daycare.mail[1].message.itemId, HARBOR, "import: daycare mail") + eq(back.party[1].cartExtra.mail, nil, "import: the index is not parked in cartExtra") + local Mail = require("src.core.game3.mail") + local restored = Mail.restore(back.mail) + eq(Mail.monHasMail(back.party[1]) and not Mail.isEmpty(restored[back.party[1].mail + 1]), true, "import: the port sees the mon's letter") +end + +do + local bytes = assert(Gen3Save.exportPort(reload(portSave()), stubOpts({ + registeredTextDefaults = function() return { "HELLO", "POKéMON", "TRADE", "BATTLE", "LET'S", "OK!", "SORRY", "YAY{EMOJI_BIGSMILE}", "THANK YOU", "BYE-BYE!" } end, + }))) + local c = assert(Gen3Save.decode(bytes)) + local blk = assert(Gen3Save.readBlocks(bytes)) + -- src/new_game.c:151 + same_eq(c.trainerTowerBest, { L.TRAINER_TOWER_MAX_TIME, L.TRAINER_TOWER_MAX_TIME, L.TRAINER_TOWER_MAX_TIME, L.TRAINER_TOWER_MAX_TIME }, + "a fresh export has no Trainer Tower times") + -- src/easy_chat.c:440 + same_eq(c.easyChatBattle.start, { 4111, 2562, 3621, 3075, 2051, 3072 }, "default battle start words") + same_eq(c.easyChatBattle.won, { 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF }, "battle won words undefined") + same_eq(c.easyChatBattle.lost, { 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF }, "battle lost words undefined") + -- src/fame_checker.c:1140 + eq(c.fameChecker[1].pickState, L.FCPICKSTATE_COLORED, "Fame Checker shows Oak") + local others = 0 + for i = 2, 16 do others = others + c.fameChecker[i].pickState + c.fameChecker[i].flavorTextFlags end + eq(others, 0, "Fame Checker has nobody else") + eq(c.registeredTexts[1], "HELLO", "registered text defaults") + eq(c.registeredTexts[8], "YAY{EMOJI_BIGSMILE}", "registered texts keep the emoji") + eq(blk.sb1:sub(L.REGISTERED_TEXTS.off + 7 * 21 + 1, L.REGISTERED_TEXTS.off + 7 * 21 + 6), "\xD3\xBB\xD3\xF9\xF9\xFF", + "the emoji is the cart's F9 F9 symbol") + -- src/battle_records.c:274 + eq(blk.sb2:byte(L.LINK_BATTLE_RECORDS.off + 1), 0xFF, "link battle record names start empty") + -- src/event_data.c:71 + eq(c.vars[L.RSE_NATIONAL_VAR], L.RSE_NATIONAL_VALUE, "EnableNationalPokedex_RSE var") + eq(flagSet(c)[L.RSE_NATIONAL_FLAG], true, "EnableNationalPokedex_RSE flag") + + local fame = {} + for i = 1, 16 do fame[i] = { pickState = 0, flavorTextFlags = 0 } end + fame[1].pickState, fame[4] = 2, { pickState = 1, flavorTextFlags = 0x15 } + local save = portSave({ + registeredTexts = { "HI", "", "", "", "", "", "", "YAY{EMOJI_BIGSMILE}", "", "" }, + modData = { fameChecker = fame, trainerTower = { challengeId = 0, records = { { bestTime = 3600 }, {}, {}, {} } } }, + }) + local b2 = assert(Gen3Save.exportPort(reload(save), stubOpts())) + local c2 = assert(Gen3Save.decode(b2)) + eq(c2.fameChecker[4].pickState == 1 and c2.fameChecker[4].flavorTextFlags == 0x15, true, "port Fame Checker progress is exported") + eq(c2.registeredTexts[1], "HI", "port registered texts are exported") + eq(c2.trainerTowerBest[1], 3600, "port Trainer Tower best time is exported") + eq(c2.trainerTowerBest[2], L.TRAINER_TOWER_MAX_TIME, "an unplayed Trainer Tower mode has no time") + local back = assert(SaveConvert.importSav(b2, "firered", "firered")) + eq(back.modData.fameChecker[4].pickState == 1 and back.modData.fameChecker[4].flavorTextFlags == 0x15, true, "import: Fame Checker progress") + eq(back.modData.fameChecker[1].pickState, 2, "import: Oak") + eq(back.registeredTexts[8], "YAY{EMOJI_BIGSMILE}", "import: registered texts") + eq(back.modData.trainerTower.records[1].bestTime, 3600, "import: Trainer Tower best time") +end + +do + local stranger = reload(assert(SaveConvert.importSav(rich, "firered", "firered"))) + stranger.trainerId, stranger.secretId, stranger.name = 1111, 2222, "NEWBIE" + stranger.hallOfFameTeams = {} + local out = assert(Gen3Save.exportPort(stranger, stubOpts({ template = rich }))) + local c = assert(Gen3Save.decode(out)) + eq(c.counter, 1, "another player's cart is not used as the template (first save)") + eq(c.encryptionKey, 0, "another player's key is not reused") + eq(c.name, "NEWBIE", "the export is the slot's player") + local other = out:sub(14 * 0x1000 * (1 - c.slot) + 1, 14 * 0x1000 * (2 - c.slot)) + eq(other, string.rep("\255", 14 * 0x1000), "no copy of the other player's save rides in the older slot") + eq(out:sub(28 * 0x1000 + 1, 32 * 0x1000), string.rep("\255", 4 * 0x1000), "no Hall of Fame or Trainer Tower sectors from the other player") + local cart = assert(Gen3Save.decode(rich)) + eq(Gen3Save.templateBelongs(Gen3Save.ownerOf(rich), stranger), false, "ownerOf tells the carts apart") + eq(Gen3Save.templateBelongs(Gen3Save.ownerOf(rich), { trainerId = cart.trainerId, secretId = cart.secretId, name = cart.name }), true, + "ownerOf matches the cart's own player") +end + +do + local c = assert(Gen3Save.decode(rich)) + local nat = c.dexOwned[1] + local patched = rebuild(rich, function(b) + local o = 0x5F8 + math.floor(nat / 8) + b.sb1 = setByte(b.sb1, o, b.sb1:byte(o + 1) - (math.floor(b.sb1:byte(o + 1) / 2 ^ (nat % 8)) % 2) * 2 ^ (nat % 8)) + b.storage = setByte(b.storage, 0, 200) + end) + local save = assert(SaveConvert.importSav(patched, "firered", "firered")) + local seen, owned = {}, {} + for _, n in ipairs(save.modData.cartImport.dexSeen) do seen[n] = true end + for _, n in ipairs(save.modData.cartImport.dexOwned) do owned[n] = true end + -- src/pokedex_screen.c:2243 + eq(seen[nat + 1], nil, "a species missing from one seen copy is not seen") + eq(owned[nat + 1], nil, "and not owned either") + eq(save.storage.currentBox, L.STORAGE.totalBoxes, "an out-of-range current box is clamped") + + local bytes = assert(Gen3Save.exportPort(reload(portSave({ dex = { seen = {}, owned = { [4] = true }, caught = {} } })), stubOpts())) + local e = assert(Gen3Save.decode(bytes)) + same_eq(e.dexSeen, { 3 }, "an owned species is written as seen") + same_eq(e.dexSeen1, { 3 }, "seen1 too") + same_eq(e.dexSeen2, { 3 }, "seen2 too") +end + +do + local function withCounter(img, slot, counter) + local parts = {} + for i = 0, 31 do + local sec = img:sub(i * 0x1000 + 1, (i + 1) * 0x1000) + if math.floor(i / 14) == slot and i < 28 then + local v = counter + sec = sec:sub(1, 0xFFC) .. string.char(v % 256, math.floor(v / 256) % 256, math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256) + end + parts[#parts + 1] = sec + end + return table.concat(parts) + end + local c, blocks = Gen3Save.decode(rich) + c.money = 111 + local a = Gen3Save.buildFlash(Gen3Save.encodeBlocks(c, blocks), { counter = 4 }) + c.money = 222 + local ab = Gen3Save.buildFlash(Gen3Save.encodeBlocks(c, blocks), { counter = 5, image = a }) + local odd = withCounter(ab, 0, 5) + -- src/save.c:444 + eq(assert(Gen3Save.decode(odd)).money, 222, "equal odd counters load slot B like the cart") + local even = withCounter(withCounter(ab, 1, 6), 0, 6) + eq(assert(Gen3Save.decode(even)).money, 111, "equal even counters load slot A like the cart") + eq(assert(Gen3Save.decode(ab)).money, 222, "the newer slot wins") +end + +T.finish("gen3_save_codec") diff --git a/tests/engine/input_pad_watchdog_test.lua b/tests/engine/input_pad_watchdog_test.lua new file mode 100644 index 00000000..ce4fa2f3 --- /dev/null +++ b/tests/engine/input_pad_watchdog_test.lua @@ -0,0 +1,201 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Input = require("src.core.Input") +local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") + +local realJoystick = love.joystick +local physical = {} +local pad = { + isGamepad = function() return true end, + isGamepadDown = function(_, button) return physical[button] == true end, + getGamepadAxis = function() return 0 end, + getName = function() return "Xbox Controller" end, + getGUID = function() return "030000005e040000" end, + isConnected = function() return true end, +} +love.joystick = { + getJoystickCount = function() return 1 end, + getJoysticks = function() return { pad } end, +} + +Input:init() +Input:applyBindings(nil) +Input:reset() +Input:step() + +physical.a = true +Input:step() +check(Input:wasPressed("a"), "lost press event still yields one A edge") +check(Input:isDown("a"), "lost press event still holds A") +Input:step() +check(not Input:wasPressed("a"), "held A does not re-edge") +physical.a = false +Input:step() +check(not Input:isDown("a"), "lost release event still releases A") + +physical.a = true +Input:gamepadpressed(pad, "a") +Input:step() +check(Input:wasPressed("a"), "event + poll agree on the press") +Input:step() +check(not Input:wasPressed("a"), "event + poll never double-edge") +physical.a = false +Input:gamepadreleased(pad, "a") +Input:step() +check(not Input:isDown("a"), "event release stays released") + +physical.a = true +Input:gamepadpressed(pad, "a") +Input:step() +physical.a = false +Input:step() +check(not Input:isDown("a"), "stuck A clears when the pad reports it up") +Input:step() +physical.a = true +Input:step() +check(Input:wasPressed("a"), "next physical A press works without alt-tab") +physical.a = false +Input:step() + +Input:armCapture() +physical.b = true +Input:step() +check(not Input:isDown("b"), "capture-armed press is not injected") +physical.b = false +Input:step() +Input:disarmCapture() + +physical.back = true +Input:gamepadpressed(pad, "back") +Input:step() +physical.a = true +Input:step() +check(not Input:wasPressed("a"), "SELECT+A display chord is not injected as A") +physical.a = false +physical.back = false +Input:gamepadreleased(pad, "back") +Input:step() + +Input:applyBindings({ speedUp = { pad = "start" } }) +physical.start = true +Input:step() +check(not Input:isDown("start"), "a speed-bound button is not injected") +physical.start = false +Input:step() +Input:applyBindings(nil) + +local function countEdges(btn, steps) + local n = 0 + for _ = 1, steps do + Input:step() + if Input:wasPressed(btn) then n = n + 1 end + end + return n +end + +physical.back = true +Input:padEventSeen("back") +Input:gamepadpressed(pad, "back") +Input:step() +physical.a = true +Input:padEventSeen("a") +Input:step() +physical.back = false +Input:gamepadreleased(pad, "back") +eq(countEdges("a", 10), 0, "SELECT+A chord, SELECT released first: no phantom A") +check(not Input:isDown("a"), "SELECT+A chord, SELECT released first: A not held") +physical.a = false +Input:step() +physical.a = true +Input:step() +check(Input:wasPressed("a"), "a fresh A after the chord still gets repaired") +physical.a = false +Input:step() + +for _, b in ipairs({ "a", "b", "start", "back" }) do + physical[b] = true + Input:padEventSeen(b) + Input:gamepadpressed(pad, b) +end +Input:step() +Input:reset() +local resetEdges = 0 +for _ = 1, 10 do + Input:step() + for _, b in ipairs({ "a", "b", "start", "select" }) do + if Input:wasPressed(b) then resetEdges = resetEdges + 1 end + end +end +eq(resetEdges, 0, "soft reset with the chord held: no re-edge on the title") +physical.back = false +eq(countEdges("a", 5) + countEdges("b", 5), 0, "soft reset: A/B do not edge once SELECT lets go") +physical.a, physical.b, physical.start = false, false, false +Input:step() + +physical.a = true +Input:padEventSeen("a") +Input:gamepadpressed(pad, "a") +Input:step() +Input:reset() +eq(countEdges("a", 10), 0, "focus reset with A held: no phantom A") +physical.a = false +Input:step() + +local AutoInput = require("src.core.gen2.AutoInput") +physical.a = true +Input:padEventSeen("a") +Input:gamepadpressed(pad, "a") +Input:step() +local auto = AutoInput.new() +auto:start("CATCH_TUTORIAL", Input) +local autoEdges, autoHeld = 0, 0 +for _ = 1, 30 do + auto:step(Input) + Input:step() + if Input:wasPressed("a") then autoEdges = autoEdges + 1 end + if Input:isDown("a") then autoHeld = autoHeld + 1 end +end +eq(autoEdges, 0, "AutoInput stream: a held pad A never presses A") +eq(autoHeld, 0, "AutoInput stream: a held pad A is never held") +auto:stop(Input) +physical.a = false +Input:gamepadreleased(pad, "a") +Input:step() +Input:step() + +love.filesystem.remove("pad-reconcile.log") +SwitchDiagnostics._resetForTests() +love.filesystem.write("switch-debug.txt", "") +SwitchDiagnostics._resetForTests() +love.filesystem.write("switch-debug.txt", "") +SwitchDiagnostics.onJoystickEvent("gamepadpressed", pad, "a") +physical.a = true +Input:reset() +Input:reconcile() +local log = love.filesystem.read("pad-reconcile.log") or "" +check(log:find("=== reconcile", 1, true) ~= nil, "focus-in reconcile writes a trace block") +check(log:find("down=a", 1, true) ~= nil, "trace records what SDL reports held") +check(log:find("gamepadpressed", 1, true) ~= nil, "trace carries the recent event ring") +check((love.filesystem.read("switch.log") or ""):find("reconcile", 1, true) ~= nil, + "switch.log is flushed on reconcile") +physical.a = false +love.filesystem.remove("switch-debug.txt") +love.filesystem.remove("pad-reconcile.log") +love.filesystem.remove("switch.log") +SwitchDiagnostics._resetForTests() + +love.joystick = { getJoystickCount = function() return 0 end, getJoysticks = function() return {} end } +Input:reset() +Input:gamepadpressed(nil, "a") +Input:step() +check(Input:isDown("a"), "no pads connected leaves event state alone") +Input:gamepadreleased(nil, "a") +Input:step() + +love.joystick = realJoystick +eq(type(Input.pollPads), "function", "pollPads exported") +T.finish() diff --git a/tests/engine/launcher_studio.lua b/tests/engine/launcher_studio.lua index ad3a08de..b5420e13 100644 --- a/tests/engine/launcher_studio.lua +++ b/tests/engine/launcher_studio.lua @@ -172,8 +172,7 @@ for _, version in ipairs({"red","yellow","gold","silver","crystal","firered","le local labels = {} for _, rect in ipairs(draw(subject)) do labels[rect.label or ""] = true end T.check(labels["Original save (.lua)"], version .. " offers original export") - T.eq(labels["Cartridge save (.sav)"] == true, - version ~= "firered" and version ~= "leafgreen", version .. " conversion capability") + T.eq(labels["Cartridge save (.sav)"] == true, true, version .. " conversion capability") subject.exportSave = function(self, v, format, scope, id) self.exported = {v,format,scope,id} end @@ -239,7 +238,8 @@ T.eq(ok,false,"empty slot cannot be exported") for key,fn in pairs(saved)do SaveData[key]=fn end local bytes,err=Convert.exportSav({},"firered") T.eq(bytes,nil,"Gen 3 never falls through to Gen 1 encoder") -T.check(err and err:find("not implemented",1,true),"unsupported converter explains why") +local Gen3Save=require("src.save_convert.Gen3Save") +T.check(err==Gen3Save.MSG.noData or err==Gen3Save.MSG.noMap,"an empty Gen 3 table is refused with a reason") -- Settings keep a fixed footer while touch and keyboard reach long lists. width,height=390,844 diff --git a/tests/engine/save_lua_import_tests.lua b/tests/engine/save_lua_import_tests.lua index a8d8893a..7590eea5 100644 --- a/tests/engine/save_lua_import_tests.lua +++ b/tests/engine/save_lua_import_tests.lua @@ -55,7 +55,7 @@ local function gbaChecksum(data, size) end -- src/save.c:54 -local CHUNK = { 0xF24, 0xF80, 0xF80, 0xF80, 0xCE8, 0xF80, 0xF80, 0xF80, +local CHUNK = { 0xF24, 0xF80, 0xF80, 0xF80, 0xEE8, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0xF80, 0x7D0 } local function sector(id, counter, seed) @@ -94,20 +94,61 @@ local function writeTmp(files, name, bytes) return name end +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +local Gen3Save = require("src.save_convert.Gen3Save") +local FR_CART = unrle(require("tests.fixture_data.gen3_saves").images.fr_rich_game) + for _, version in ipairs({ "firered", "leafgreen" }) do for _, shape in ipairs({ - { "128K flash", gbaFlash(131072, 7) }, - { "128K + RTC trailer", gbaFlash(131072, 7) .. string.rep("\0", 16) }, - { "64K flash", gbaFlash(65536, 7) }, - { "32K", gbaFlash(65536, 7):sub(1, 32768) }, - { "forged Gen 1 checksum", forgeGen1Checksum(gbaFlash(131072, 7)) }, + { "128K flash", FR_CART }, + { "128K + RTC trailer", FR_CART .. string.rep("\0", 16) }, + { "64K flash", FR_CART:sub(1, 65536) }, + }) do + local files = fresh() + local path = writeTmp(files, "picked_save.sav", shape[2]) + local ok, slotId, info = SaveFileIO.importToSlot(path, version) + eq(ok, true, version .. " " .. shape[1] .. " cart save imports (" .. tostring(slotId) .. ")") + check(info == nil, version .. " " .. shape[1] .. " never asks to confirm") + eq(#SaveData.listSlots(version), 1, version .. " " .. shape[1] .. " creates one slot") + eq(SaveData.activeSlot(version), slotId, version .. " " .. shape[1] .. " is made active") + eq(files[("saves/%s/%s.cart"):format(version, tostring(slotId))], shape[2], + version .. " " .. shape[1] .. " keeps the cart image beside the slot") + local back = SaveSerializer.decode(SaveData.readSlotSource(version, slotId) or "") + eq(back and back.version, version, version .. " " .. shape[1] .. " slot is tagged " .. version) + eq(back and back.money, 123456, version .. " " .. shape[1] .. " money") + eq(back and back.party and #back.party, 4, version .. " " .. shape[1] .. " party") + eq(back and back.map, "FR_PLAYERS_HOUSE_2F", version .. " " .. shape[1] .. " map") + local exOk, exRes = SaveFileIO.exportActiveSlot(version) + if exOk then + local out = files[("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, tostring(slotId))] + local c = out and Gen3Save.decode(out) + eq(c and c.money, 123456, version .. " " .. shape[1] .. " cart export decodes as a FireRed/LeafGreen save") + else + eq(exRes, Gen3Save.MSG.noData, version .. " " .. shape[1] .. " cart export without game data says so") + end + end + for _, shape in ipairs({ + { "32K", FR_CART:sub(1, 32768), Gen3Save.MSG.size:format(32768) }, + { "synthetic flash", gbaFlash(131072, 7), Gen3Save.MSG.notFrlg }, + { "forged Gen 1 checksum", forgeGen1Checksum(gbaFlash(131072, 7)), Gen3Save.MSG.notFrlg }, }) do local files = fresh() local path = writeTmp(files, "picked_save.sav", shape[2]) local ok, msg, info = SaveFileIO.importToSlot(path, version, true) - eq(ok, false, version .. " " .. shape[1] .. " cart save is refused") - eq(msg, SaveConvert.GEN3_IMPORT_REFUSAL, - version .. " " .. shape[1] .. " gets the honest refusal") + eq(ok, false, version .. " " .. shape[1] .. " is refused") + eq(msg, shape[3], version .. " " .. shape[1] .. " gets its own sentence") check(info == nil, version .. " " .. shape[1] .. " never asks to import anyway") check(not tostring(msg):find("checksum"), version .. " " .. shape[1] .. " is not described as a checksum failure") @@ -115,6 +156,26 @@ for _, version in ipairs({ "firered", "leafgreen" }) do end end +for _, version in ipairs({ "firered", "leafgreen" }) do + local files = fresh() + GameVersion.set(version) + local ok, slotId = SaveFileIO.importToSlot(writeTmp(files, "picked_save.sav", FR_CART), version) + eq(ok, true, version .. " cart imported for the stale-template check") + local cartFile = ("saves/%s/%s.cart"):format(version, tostring(slotId)) + local same = SaveSerializer.decode(SaveData.readSlotSource(version, slotId)) + check(SaveData.save(same) ~= false, version .. " the imported player saves") + check(files[cartFile] ~= nil, version .. " the imported player's cart stays") + local newbie = { engine = "game3", version = version, generation = 3, name = "NEWBIE", trainerId = 1, secretId = 2, + party = {}, map = "FR_PLAYERS_HOUSE_2F", x = 3, y = 6 } + check(SaveData.save(newbie) ~= false, version .. " NEW GAME saves over the imported slot") + eq(SaveData.activeSlot(version), slotId, version .. " the NEW GAME wrote the imported slot") + eq(files[cartFile], nil, version .. " NEW GAME over the slot drops the old player's cart") + files[cartFile] = FR_CART + eq(SaveFileIO.dropStaleCart(version, slotId, newbie), true, version .. " dropStaleCart removes another player's cart") + files[cartFile] = FR_CART + eq(SaveFileIO.dropStaleCart(version, slotId, same), false, version .. " dropStaleCart keeps the player's own cart") +end + do local forged = forgeGen1Checksum(gbaFlash(131072, 7)) eq(GenSave.mainChecksumValid(forged), true, "the forged flash passes the raw Gen 1 rule") @@ -124,13 +185,14 @@ do "SaveConvert never runs the Gen 1 rule over a GBA flash image") eq(SaveConvert.mainChecksumValid(forged), nil, "SaveConvert never runs the Gen 1 rule over a GBA flash image (no game)") - eq(SaveConvert.importSupported("firered"), false, "FireRed cart import is unsupported") - eq(SaveConvert.importSupported("leafgreen"), false, "LeafGreen cart import is unsupported") + eq(SaveConvert.importSupported("firered"), true, "FireRed cart import is supported") + eq(SaveConvert.importSupported("leafgreen"), true, "LeafGreen cart import is supported") eq(SaveConvert.importSupported("red"), true, "Red cart import stays supported") eq(SaveConvert.importSupported("crystal"), true, "Crystal cart import stays supported") - local sav, err = SaveConvert.importSav(string.rep("\0", 32768), "firered", "firered") - eq(sav, nil, "importSav refuses a FireRed cart") - eq(err, SaveConvert.GEN3_IMPORT_REFUSAL, "importSav gives the same refusal") + local sav, err = SaveConvert.importSav(FR_CART, "firered", "firered") + check(sav ~= nil, "importSav converts a FireRed cart (" .. tostring(err) .. ")") + local lg = SaveConvert.importSav(FR_CART, "leafgreen", "leafgreen") + check(lg ~= nil, "the same cart converts for LeafGreen") end for _, target in ipairs({ "red", "yellow", "gold", "crystal" }) do diff --git a/tests/engine/worker_fs_nx_test.lua b/tests/engine/worker_fs_nx_test.lua new file mode 100644 index 00000000..5e9eceb5 --- /dev/null +++ b/tests/engine/worker_fs_nx_test.lua @@ -0,0 +1,239 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local GameVersion = require("src.core.GameVersion") +local Platform = require("src.core.Platform") +local WorkerFs = require("src.core.WorkerFs") + +local savedSystem = love.system +local savedThread = love.thread +local savedVersion = GameVersion.get() +local savedRead = love.filesystem.read +local savedLoad = love.filesystem.load + +local function setOS(osName) + love.system = { getOS = function() return osName end } + Platform._resetForTests() +end + +local function fakeFs(files) + local fs = { reads = {} } + function fs.read(path) + fs.reads[#fs.reads + 1] = path + local bytes = files[path] + if bytes then return bytes end + return nil, path .. " does not exist" + end + return fs +end + +local function readsInclude(fs, path) + for _, p in ipairs(fs.reads) do + if p == path then return true end + end + return false +end + +local fs = fakeFs({ ["firered/a.bin"] = "P", ["a.bin"] = "B", ["b.bin"] = "B2" }) +eq(WorkerFs.read("firered/", "a.bin", fs), "P", "prefixed copy wins") +eq(WorkerFs.read("firered/", "b.bin", fs), "B2", "bare fallback when the prefixed copy is absent") +fs = fakeFs({ ["firered/a.bin"] = "P", ["a.bin"] = "B" }) +eq(WorkerFs.read(nil, "a.bin", fs), "B", "nil prefix reads only the bare path") +eq(#fs.reads, 1, "nil prefix never probes a prefixed path") +eq(WorkerFs.read("", "a.bin", fs), "B", "empty prefix is the desktop path") +local miss, err = WorkerFs.read("firered/", "c.bin", fakeFs({})) +eq(miss, nil, "double miss is nil") +eq(err, "c.bin does not exist", "double miss returns the bare read's error") + +local cache = WorkerFs.cache(nil, fakeFs({ ["leafgreen/x"] = "L", ["x"] = "X" })) +eq(cache:read("x"), "X", "cache with no prefix reads bare") +cache:setPrefix("leafgreen/") +eq(cache:read("x"), "L", "setPrefix switches the cache to the versioned tree") +cache:setPrefix("") +eq(cache:read("x"), "X", "setPrefix('') returns to bare reads") + +setOS("OS X") +for _, id in ipairs(GameVersion.ORDER) do + GameVersion.set(id) + eq(WorkerFs.prefix(), nil, "desktop hands workers no prefix for " .. id) +end +setOS("NX") +for _, id in ipairs(GameVersion.ORDER) do + GameVersion.set(id) + eq(WorkerFs.prefix(), GameVersion.cachePrefix(id), "NX hands workers " .. id .. "'s prefix") +end + +local function newChannel() + local c = { items = {} } + function c:push(v) self.items[#self.items + 1] = v end + function c:pop() return table.remove(self.items, 1) end + function c:demand() return table.remove(self.items, 1) end + function c:clear() self.items = {} end + function c:getCount() return #self.items end + return c +end + +local channels +local function resetChannels() + channels = {} + love.thread = { + getChannel = function(name) + channels[name] = channels[name] or newChannel() + return channels[name] + end, + } +end + +for _, name in ipairs({ "love.thread", "love.sound", "love.timer", "love.filesystem" }) do + package.preload[name] = package.preload[name] or function() return true end +end +love.timer = love.timer or {} +love.timer.getTime = love.timer.getTime or os.clock +love.timer.sleep = love.timer.sleep or function() end + +local function serve(files) + local reads = {} + love.filesystem.read = function(path) + reads[#reads + 1] = path + if files[path] then return files[path] end + return nil, "Could not open file " .. path .. ". Does not exist." + end + love.filesystem.load = function(path) + if path:sub(1, 4) == "src/" then return loadfile(path) end + return savedLoad(path) + end + return reads +end + +local function contains(list, value) + for _, v in ipairs(list) do + if v == value then return true end + end + return false +end + +local function runChipWorker(audio, files) + resetChannels() + local reads = serve(files) + local saved = package.loaded["src.core.WorkerFs"] + package.loaded["src.core.WorkerFs"] = nil + local cmd = love.thread.getChannel("chipaudio_cmd") + cmd:push({ cmd = "play", gen = 1, header = { engine = 1 }, allowLoops = true, audio = audio }) + cmd:push({ cmd = "quit" }) + local chunk = assert(loadfile("src/core/chip_worker.lua")) + chunk() + package.loaded["src.core.WorkerFs"] = saved + local errors = {} + for _, m in ipairs(love.thread.getChannel("chipaudio_out").items) do + if m.error then errors[#errors + 1] = m.error end + end + return reads, errors +end + +local function programError(errors) + for _, e in ipairs(errors) do + if tostring(e):find("could not read sound programs", 1, true) then return true end + end + return false +end + +local PROG = "assets/generated/audio/programs.bin" +local PROG_BYTES = string.rep("\0", 0x4000 * 2) +local ChipAudio = require("src.core.ChipAudio") +local fullAudio = { audio = { programFile = PROG, bankOrder = { 2, 8 }, waveBanks = {} } } + +for _, id in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do + setOS("NX") + GameVersion.set(id) + local prefix = GameVersion.cachePrefix(id) + local nxOnly = { [prefix .. PROG] = PROG_BYTES } + local slim = ChipAudio._slimAudioForTest(fullAudio) + local reads, errors = runChipWorker(slim, nxOnly) + check(contains(reads, prefix .. PROG), "NX chip worker reads " .. prefix .. PROG) + check(not programError(errors), "NX chip worker finds programs.bin for " .. id) + + local bare = { programFile = PROG, bankOrder = { 2, 8 }, waveBanks = {} } + local _, bareErrors = runChipWorker(bare, nxOnly) + check(programError(bareErrors), "without the prefix the NX chip worker misses programs.bin for " .. id .. " (#2449 class)") +end + +setOS("OS X") +GameVersion.set("blue") +local slimDesktop = ChipAudio._slimAudioForTest(fullAudio) +eq(slimDesktop.programPrefix, nil, "desktop chip payload carries no prefix") +local deskReads, deskErrors = runChipWorker(slimDesktop, { [PROG] = PROG_BYTES }) +eq(deskReads[1], PROG, "desktop chip worker reads the mounted bare path first") +check(not contains(deskReads, "blue/" .. PROG), "desktop chip worker never probes a prefixed path") +check(not programError(deskErrors), "desktop chip worker finds programs.bin") + +local ChipSynth = require("src.core.ChipSynth") +ChipSynth.invalidateBanks() +serve({ ["red/" .. PROG] = string.rep("R", 0x8000), ["blue/" .. PROG] = string.rep("U", 0x8000) }) +local redBanks = ChipSynth._loadBanksForTest({ audio = { programFile = PROG, programPrefix = "red/", bankOrder = { 1, 2 } } }) +local blueBanks = ChipSynth._loadBanksForTest({ audio = { programFile = PROG, programPrefix = "blue/", bankOrder = { 1, 2 } } }) +eq(redBanks[1]:sub(1, 1), "R", "red prefix loads red banks") +eq(blueBanks[1]:sub(1, 1), "U", "bank cache is keyed by prefix, not just programFile") +ChipSynth.invalidateBanks() + +local ROOT = "data/generated/gba/audio" +local INDEX = "return { songs = {} }" +local function runM4aWorker(prefix, files) + resetChannels() + local reads = serve(files) + local saved = package.loaded["src.core.WorkerFs"] + package.loaded["src.core.WorkerFs"] = nil + local cmd = love.thread.getChannel("game3_m4a_cmd") + cmd:push({ cmd = "install", root = ROOT, prefix = prefix }) + cmd:push({ cmd = "quit" }) + local chunk = assert(loadfile("src/core/game3/m4a_worker.lua")) + chunk() + package.loaded["src.core.WorkerFs"] = saved + return reads, love.thread.getChannel("game3_m4a_status") +end + +for _, id in ipairs({ "firered", "leafgreen" }) do + setOS("NX") + GameVersion.set(id) + local prefix = WorkerFs.prefix() + eq(prefix, id .. "/", "NX m4a install prefix for " .. id) + local nxOnly = { [prefix .. ROOT .. "/index.lua"] = INDEX } + local reads, status = runM4aWorker(prefix, nxOnly) + check(contains(reads, prefix .. ROOT .. "/index.lua"), "NX m4a worker reads the " .. id .. " tree") + eq(status:getCount(), 0, "NX m4a worker installs for " .. id) + local _, bareStatus = runM4aWorker(nil, nxOnly) + local failed = bareStatus:pop() + check(type(failed) == "table" and failed.installFailed, "without the prefix the NX m4a worker misses the pack for " .. id .. " (#2449)") +end + +setOS("OS X") +GameVersion.set("firered") +local deskM4aReads, deskStatus = runM4aWorker(WorkerFs.prefix(), { [ROOT .. "/index.lua"] = INDEX }) +eq(deskStatus:getCount(), 0, "desktop m4a worker installs from the mounted path") +check(not contains(deskM4aReads, "firered/" .. ROOT .. "/index.lua"), "desktop m4a worker never probes a prefixed path") + +love.filesystem.read = savedRead +love.filesystem.load = savedLoad +setOS("NX") +GameVersion.set("red") +local CacheFs = require("src.import.CacheFs") +local savedPrefix = CacheFs.prefix +CacheFs.prefix = "leafgreen/" +local REL = "data/generated/gba/items/pack.lua" +check(CacheFs.write(REL, "return {}"), "import worker write succeeds on NX") +eq(love.filesystem.read("leafgreen/" .. REL), "return {}", "import worker writes land under the version prefix on NX") +eq(love.filesystem.read(REL), nil, "import worker never writes the bare path on NX") +check(CacheFs.exists(REL), "import worker exists() sees its own prefixed write") +eq(CacheFs.readActive(REL), "return {}", "readActive in a worker state (GameVersion red) still finds the prefixed file") +love.filesystem.remove("leafgreen/" .. REL) +CacheFs.prefix = savedPrefix + +love.system = savedSystem +love.thread = savedThread +Platform._resetForTests() +GameVersion.set(savedVersion) + +T.finish("worker fs nx") diff --git a/tests/fixture_data/gen3_saves.lua b/tests/fixture_data/gen3_saves.lua new file mode 100644 index 00000000..436ce082 --- /dev/null +++ b/tests/fixture_data/gen3_saves.lua @@ -0,0 +1,152 @@ +return { + images = { + fr_rich_game = "Zff4 0c0000002520010802 Z6f7 555555558edd5fabcdcabbccc5d3ff0000000202bbbbbbff0000000023890000d19c0a02db880afedb880afec2880afe93600bfedbf00afedbd038dc24c4caccdb880afe8e8868fedb880afed4960afebcc9d2a2ff00000000bcc9d2a3ff00000000bdcfcdcec9c7ff0000bcc9d2a5ff00000000bcc9d2a6ff00000000bcc9d2a7ff00000000bcc9d2a8ff00000000bcc9d2a9ff00000000bcc9d2aaff00000000bcc9d2a2a1ff000000bcc9d2a2a2ff000000bcc9d2a2a3ff000000bcc9d2a2a4ff000000bcc9d2a2a5ff0000000001050300010203000102030001 Z824 0d0016b82520010802000000bbbbbbffffffffff00008edd5fab0c0023180c00010000000000da0004030201 Z8 20800401 Ze 20 Z6 01000000000008 Z10 0200000029800401 Ze 2000000800000001000000000008 Z10 02 Z1f 01 Z7eb ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff0308048706200420042b042c04200420042004200420049604ff03ff03ff0308048f06860430043304340428042804150428042804a604ff03ff03ff03080497325a3038303b303c30093009300504060407300930ff03ff03ff03080409300130013001300130863201300d040e040f300130ff03ff03ff03080483328432853243304430350444304630013001300130ff03ff03ff0308048b028c328d02533045308e0645305630013001300130ff03ff03ff03080493429406954253004530963245305630013001300130ff03ff03ff0308040900013001005b305c305c305c305e30013001300130ff03ff03ff03080409300130013001300130013001300130013001300130ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03 Z5c ff Zf ff Zf ff Zf ff Zf ff Z1f 0463a72a Z424 0463a72a Zd2 ea4f2520010802000000060006000401ff00060006 Z11 0300ff00060008 Zf 020004000000785634128edd5fabbcc6bbd4bfff000000000202bbbbbbff0000000524cc0000f08ba3b9b1176bb9f5f36bb9c38b7ab9c28b40b9ee8e72a7fc9f6b45f68b43b9f68b6bb0d7d34f9b09c7ab8bf60b6bb90800000024ff3200720045003d00630057005200efbeadde31d4672bceccbfbfbdc5c9ff00000202cac5c2bfd2ff0000c2a30000de32c0d721260ac7de6acaf5d47eca09de6acaf5de6acaf5df6ae1f5996acaf5fd74def5cb6bcaf5ee68caf5de12caf5000000000aff1f001f000f000e001900130010000df0ad0b8edd5fab60ffbdc2cfff000000000106bbbbbbff00000000967d00002f2df2a0fe2df2a08327f2a08375f7827c6132d2832df2a08939f25c832df2a0832df2a0d72ddfa0832df2a09d05f2a00000000005ff12001200090007000c0009000900040302018edd5fabcfc8c9d1c8ff000000000202bbbbbbff00000000b040000043de5daa83e35daa8aa65daa8a86448875929d988ade5d2a67de5daa8ade5daa85de5daa80ca5d568ade5daa8ade5daa0000000019ff430043002700210030002c0023 Z56 ff Z63 ff Ze 4481a62a0d6000000d000100140003 Z71 0d000e634400016317006763000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463680105636c0105630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046304001063020007630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463210106635301056300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004638b00036300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046300000463000004630000046329800401 Ze 2000000800000001000000000008 Z10 02 Z77 c180010000000b00ff0104330d000d000d000d000d000d002200003f01000000020001 Z9 ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Z17 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z9 18e8b00080000404f870380010f7e2ff5f Z6f cfffffffff7f000000000006c0c71f06 Z7f 0100ffbc2520010802 Z67 03030041 Z62 f401 Z2c 0203 Z26 07 Zb 01 Z153 0663a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a0463a72a Zc74 0200dbb32520010802 Zda3 290a20100e02330a000000000f10020a250e030c0308000c F32 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z79 0300846e2520010802000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z1e1 bfc8c3c1c7bbff0000000201c4f73d08d4f73d0818282828282828 Z15 a70a Z33a F8 Z5b8 29800401 Ze 2000000800000001000000000008 Z10 02000000c1ccbfbfc8ff000002 Z7f c2bfc6c6c9ff Zf cac9c51bc7c9c8ff Zd ceccbbbebfff Zf bcbbcecec6bfff Ze c6bfceb4cdff Zf c9c5abff Z11 cdc9ccccd3ff Zf d3bbd3f9f9ff Zf cec2bbc8c500d3c9cfff Zb bcd3bfaebcd3bfabff Z1a2 bb28a42a Z8 bb28a42a Z8 bb28a42a Z8 bb28a42a Z110 0400672b252001080200000001000000111111118edd5fabcac3bec1bfd3ff0000000202bbbbbbff000000009d91000095d84e469fcc4eba9fcc4eba9f944a9860808e889fcc4ebabecc52ba9fcc4ebabcc34eba8fcc4ebaffcc4eba9fb44eba Z8c0 222222228edd5fabccbbceced3ff000000000202bbbbbbff000000003e8200008dff7d89acff7d898fff7d89aca77eab53b3bdbbacff7d89bfff7d89b7ff7d89ac877d89a6eb7d75acff7d89acff7d89 Z5f0 33333333e7037803c7bfd1ced1c9ff0000000202c1c3c9d0bbc8c8004a22000042304b301aba4d30d4484b30 Z74 0500c7692520010802000000de244bccd4304b30d4304b30d4680d122b7c8b02d4304b308a302230d4304b30de244b30 Zfd0 060013252520010802 Zff7 070000002520010802 Zff7 080000002520010802 Z807 444444448edd5fabbebfc9d2d3cdff0000000202bbbbbbff00000000b9250000cac105cd35d5dbddca991befe99930efca991befde871bef50981bef1c1a1befcae11befc08d1b13ca991befca991bef Z7a0 0900f0bd2520010802 Zff7 0a0000002520010802 Zff7 0b0000002520010802 Z6f7 555555558edd5fabcdcabbccc5d3ff0000000202bbbbbbff0000000023890000d19c0a02db880afedb880afec2880afe93600bfedbf00afedbd038dc24c4caccdb880afe8e8868fedb880afed4960afebcc9d2a2ff00000000bcc9d2a3ff00000000bdcfcdcec9c7ff0000bcc9d2a5ff00000000bcc9d2a6ff00000000bcc9d2a7ff00000000bcc9d2a8ff00000000bcc9d2a9ff00000000bcc9d2aaff00000000bcc9d2a2a1ff000000bcc9d2a2a2ff000000bcc9d2a2a3ff000000bcc9d2a2a4ff000000bcc9d2a2a5ff0000000001050300010203000102030001 Z824 0d0016b82520010801000000bbbbbbffffffffff00008edd5fab0c0022383500010000000000da0004030201 Z8 20800401 Ze 20 Z6 01000000000008 Z10 0200000029800401 Ze 2000000800000001000000000008 Z10 02 Z1f 01 Z7eb ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff0308048706200420042b042c04200420042004200420049604ff03ff03ff0308048f06860430043304340428042804150428042804a604ff03ff03ff03080497325a3038303b303c30093009300504060407300930ff03ff03ff03080409300130013001300130863201300d040e040f300130ff03ff03ff03080483328432853243304430350444304630013001300130ff03ff03ff0308048b028c328d02533045308e0645305630013001300130ff03ff03ff03080493429406954253004530963245305630013001300130ff03ff03ff0308040900013001005b305c305c305c305e30013001300130ff03ff03ff03080409300130013001300130013001300130013001300130ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03 Z5c ff Zf ff Zf ff Zf ff Zf ff Z1f fb2573bc Z424 fb2573bc Zd2 98192520010801000000060006000401ff00060006 Z11 0300ff00060008 Zf 020004000000785634128edd5fabbcc6bbd4bfff000000000202bbbbbbff0000000524cc0000f08ba3b9b1176bb9f5f36bb9c38b7ab9c28b40b9ee8e72a7fc9f6b45f68b43b9f68b6bb0d7d34f9b09c7ab8bf60b6bb90800000024ff3200720045003d00630057005200efbeadde31d4672bceccbfbfbdc5c9ff00000202cac5c2bfd2ff0000c2a30000de32c0d721260ac7de6acaf5d47eca09de6acaf5de6acaf5df6ae1f5996acaf5fd74def5cb6bcaf5ee68caf5de12caf5000000000aff1f001f000f000e001900130010000df0ad0b8edd5fab60ffbdc2cfff000000000106bbbbbbff00000000967d00002f2df2a0fe2df2a08327f2a08375f7827c6132d2832df2a08939f25c832df2a0832df2a0d72ddfa0832df2a09d05f2a00000000005ff12001200090007000c0009000900040302018edd5fabcfc8c9d1c8ff000000000202bbbbbbff00000000b040000043de5daa83e35daa8aa65daa8a86448875929d988ade5d2a67de5daa8ade5daa85de5daa80ca5d568ade5daa8ade5daa0000000019ff430043002700210030002c0023 Z56 ff Z63 ff Ze bbc772bcf22600000d000100140003 Z71 0d00f1254400fe25170098250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb256801fa256c01fa250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250400ef250200f8250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb252101f9255301fa250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb258b00fc250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb2529800401 Ze 2000000800000001000000000008 Z10 02 Z77 c180010000000b00ff0104330d000d000d000d000d000d002200003d01000000020001 Z9 ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Z60c 18e8b00080000404f870380010f7e2ff5f Z6f cfffffffff7f000000000006c0c71f06 Z7f 010073a82520010801 Z67 03030041 Z62 f401 Z2c 0203 Z26 07 Zb 01 Z153 fa2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bc Zc74 020089e42520010801 Zda3 290a20100e02330a000000000f10020a250e030c0308000c F32 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z79 0300846e2520010801000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z1e1 bfc8c3c1c7bbff0000000201c4f73d08d4f73d0818282828282828 Z15 a70a Z33a F8 Z5b8 29800401 Ze 2000000800000001000000000008 Z10 02000000c1ccbfbfc8ff000002 Z7f c2bfc6c6c9ff Zf cac9c51bc7c9c8ff Zd ceccbbbebfff Zf bcbbcecec6bfff Ze c6bfceb4cdff Zf c9c5abff Z11 cdc9ccccd3ff Zf d3bbd3f9f9ff Zf cec2bbc8c500d3c9cfff Zb bcd3bfaebcd3bfabff Z1a2 446e70bc Z8 446e70bc Z8 446e70bc Z8 446e70bc Z110 0400bc88252001080100000001000000111111118edd5fabcac3bec1bfd3ff0000000202bbbbbbff000000009d91000095d84e469fcc4eba9fcc4eba9f944a9860808e889fcc4ebabecc52ba9fcc4ebabcc34eba8fcc4ebaffcc4eba9fb44eba Z8c0 222222228edd5fabccbbceced3ff000000000202bbbbbbff000000003e8200008dff7d89acff7d898fff7d89aca77eab53b3bdbbacff7d89bfff7d89b7ff7d89ac877d89a6eb7d75acff7d89acff7d89 Z5f0 33333333e7037803c7bfd1ced1c9ff0000000202c1c3c9d0bbc8c8004a22000042304b301aba4d30d4484b30 Z74 0500c7692520010801000000de244bccd4304b30d4304b30d4680d122b7c8b02d4304b308a302230d4304b30de244b30 Zfd0 060013252520010801 Zff7 070000002520010801 Zff7 080000002520010801 Z807 444444448edd5fabbebfc9d2d3cdff0000000202bbbbbbff00000000b9250000cac105cd35d5dbddca991befe99930efca991befde871bef50981bef1c1a1befcae11befc08d1b13ca991befca991bef Z7a0 0900f0bd2520010801 Zff7 0a0000002520010801 Zff7 0b0000002520010801 Zff7 0c0000002520010801000000 F4000", + lg_rich_game = "Zff4 0c0000002520010802 Z6f7 55555555bddd9950cdcabbccc5d3ff0000000202bbbbbbff0000000023890000e29cccf9e888cc05e888cc05f188cc05a060cd05e8f0cc05e8d0fe2717c40c37e888cc05bd88ae05e888cc05e796cc05bcc9d2a2ff00000000bcc9d2a3ff00000000bdcfcdcec9c7ff0000bcc9d2a5ff00000000bcc9d2a6ff00000000bcc9d2a7ff00000000bcc9d2a8ff00000000bcc9d2a9ff00000000bcc9d2aaff00000000bcc9d2a2a1ff000000bcc9d2a2a2ff000000bcc9d2a2a3ff000000bcc9d2a2a4ff000000bcc9d2a2a5ff0000000001050300010203000102030001 Z824 0d006f512520010802000000bbbbbbffffffffff0000bddd99500c0023173500010000000000da0004030201 Z8 20800401 Ze 20 Z6 01000000000008 Z10 0200000029800401 Ze 2000000800000001000000000008 Z10 02 Z1f 01 Z7eb ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff0308048706200420042b042c04200420042004200420049604ff03ff03ff0308048f06860430043304340428042804150428042804a604ff03ff03ff03080497325a3038303b303c30093009300504060407300930ff03ff03ff03080409300130013001300130863201300d040e040f300130ff03ff03ff03080483328432853243304430350444304630013001300130ff03ff03ff0308048b028c328d02533045308e0645305630013001300130ff03ff03ff03080493429406954253004530963245305630013001300130ff03ff03ff0308040900013001005b305c305c305c305e30013001300130ff03ff03ff03080409300130013001300130013001300130013001300130ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03 Z5c ff Zf ff Zf ff Zf ff Zf ff Z1f e8c9637e Z424 e8c9637e Zd2 bd692520010802000000060006000401ff00060006 Z11 0300ff00060008 Zf 02000400000078563412bddd9950bcc6bbd4bfff000000000202bbbbbbff0000000524cc0000c38b65428217ad42c6f3ad42f08bbc42f18b8642dd8eb45ccf9fadbec58b8542c58bad4be4d389603ac76d70c50bad420800000024ff3200720045003d00630057005200efbeadde31d4672bceccbfbfbdc5c9ff00000202cac5c2bfd2ff0000c2a30000de32c0d721260ac7de6acaf5d47eca09de6acaf5de6acaf5df6ae1f5996acaf5fd74def5cb6bcaf5ee68caf5de12caf5000000000aff1f001f000f000e001900130010000df0ad0bbddd995060ffbdc2cfff000000000106bbbbbbff00000000967d00001c2d345bcd2d345bb027345bb07531794f61f429b02d345bba3934a7b02d345bb02d345be42d195bb02d345bae05345b0000000005ff12001200090007000c000900090004030201bddd9950cfc8c9d1c8ff000000000202bbbbbbff00000000b040000070de9b51b0e39b51b9a69b51b986827346925b63b9de9bd154de9b51b9de9b51b6de9b51b3ca9badb9de9b51b9de9b510000000019ff430043002700210030002c0023 Z56 ff Z63 ff Ze a82b627ee1ca00000d000100140003 Z71 0d00e2c94400edc917008bc90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c96801e9c96c01e9c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90400fcc90200ebc90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c92101eac95301e9c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c98b00efc90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c90000e8c929800401 Ze 2000000800000001000000000008 Z10 02 Z77 c180010000000b00ff0104330d000d000d000d000d000d002200003f01000000020001 Z9 ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Z17 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z14 02c05ee5 Z9 18e8b00080000404f870380010f7e2ff5f Z6f cfffffffff7f000000000006c0c71f06 Z7f 0100e21f2520010802 Z67 03030041 Z62 f401 Z2c 0203 Z26 07 Zb 01 Z153 eac9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637ee8c9637e Zc74 0200f55b2520010802 Zda3 290a20100e02330a000000000f10020a250e030c0308000c F32 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z79 0300846e2520010802000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z1e1 bfc8c3c1c7bbff000000020100f63d0810f63d0818282828282828 Z15 1d09 Z33a F8 Z5b8 29800401 Ze 2000000800000001000000000008 Z10 02000000ccbfbeff0000000002 Z7f c2bfc6c6c9ff Zf cac9c51bc7c9c8ff Zd ceccbbbebfff Zf bcbbcecec6bfff Ze c6bfceb4cdff Zf c9c5abff Z11 cdc9ccccd3ff Zf d3bbd3f9f9ff Zf cec2bbc8c500d3c9cfff Zb bcd3bfaebcd3bfabff Z1a2 5782607e Z8 5782607e Z8 5782607e Z8 5782607e Z110 0400f70e25200108020000000100000011111111bddd9950cac3bec1bfd3ff0000000202bbbbbbff000000009d910000a6d888bdaccc8841accc8841ac948c6353804873accc88418dcc9441accc88418fc38841bccc8841cccc8841acb48841 Z8c0 22222222bddd9950ccbbceced3ff000000000202bbbbbbff000000003e820000beffbb729fffbb72bcffbb729fa7b85060b37b409fffbb728cffbb7284ffbb729f87bb7295ebbb8e9fffbb729fffbb72 Z5f0 33333333e7037803c7bfd1ced1c9ff0000000202c1c3c9d0bbc8c8004a22000042304b301aba4d30d4484b30 Z74 0500d9182520010802000000de244bccd4304b30d4304b30d4680d122b7c8b02d4304b308a302230d4304b30de244b30 Zfd0 060013252520010802 Zff7 070000002520010802 Zff7 080000002520010802 Z807 44444444bddd9950bebfc9d2d3cdff0000000202bbbbbbff00000000b9250000f9c1c33606d51d26f999dd14da99f614f999dd14ed87dd146398dd142f1add14f9e1dd14f38ddde8f999dd14f999dd14 Z7a0 090035402520010802 Zff7 0a0000002520010802 Zff7 0b0000002520010802 Z6f7 55555555bddd9950cdcabbccc5d3ff0000000202bbbbbbff0000000023890000e29cccf9e888cc05e888cc05f188cc05a060cd05e8f0cc05e8d0fe2717c40c37e888cc05bd88ae05e888cc05e796cc05bcc9d2a2ff00000000bcc9d2a3ff00000000bdcfcdcec9c7ff0000bcc9d2a5ff00000000bcc9d2a6ff00000000bcc9d2a7ff00000000bcc9d2a8ff00000000bcc9d2a9ff00000000bcc9d2aaff00000000bcc9d2a2a1ff000000bcc9d2a2a2ff000000bcc9d2a2a3ff000000bcc9d2a2a4ff000000bcc9d2a2a5ff0000000001050300010203000102030001 Z824 0d006f512520010801000000bbbbbbffffffffff0000bddd99500c0022382200010000000000da0004030201 Z8 20800401 Ze 20 Z6 01000000000008 Z10 0200000029800401 Ze 2000000800000001000000000008 Z10 02 Z1f 01 Z7eb ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff0308048706200420042b042c04200420042004200420049604ff03ff03ff0308048f06860430043304340428042804150428042804a604ff03ff03ff03080497325a3038303b303c30093009300504060407300930ff03ff03ff03080409300130013001300130863201300d040e040f300130ff03ff03ff03080483328432853243304430350444304630013001300130ff03ff03ff0308048b028c328d02533045308e0645305630013001300130ff03ff03ff03080493429406954253004530963245305630013001300130ff03ff03ff0308040900013001005b305c305c305c305e30013001300130ff03ff03ff03080409300130013001300130013001300130013001300130ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03 Z5c ff Zf ff Zf ff Zf ff Zf ff Z1f d85e02f9 Z424 d85e02f9 Zd2 c6a92520010801000000060006000401ff00060006 Z11 0300ff00060008 Zf 02000400000078563412bddd9950bcc6bbd4bfff000000000202bbbbbbff0000000524cc0000c38b65428217ad42c6f3ad42f08bbc42f18b8642dd8eb45ccf9fadbec58b8542c58bad4be4d389603ac76d70c50bad420800000024ff3200720045003d00630057005200efbeadde31d4672bceccbfbfbdc5c9ff00000202cac5c2bfd2ff0000c2a30000de32c0d721260ac7de6acaf5d47eca09de6acaf5de6acaf5df6ae1f5996acaf5fd74def5cb6bcaf5ee68caf5de12caf5000000000aff1f001f000f000e001900130010000df0ad0bbddd995060ffbdc2cfff000000000106bbbbbbff00000000967d00001c2d345bcd2d345bb027345bb07531794f61f429b02d345bba3934a7b02d345bb02d345be42d195bb02d345bae05345b0000000005ff12001200090007000c000900090004030201bddd9950cfc8c9d1c8ff000000000202bbbbbbff00000000b040000070de9b51b0e39b51b9a69b51b986827346925b63b9de9bd154de9b51b9de9b51b6de9b51b3ca9badb9de9b51b9de9b510000000019ff430043002700210030002c0023 Z56 ff Z63 ff Ze 98bc03f9d15d00000d000100140003 Z71 0d00d25e4400dd5e1700bb5e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e6801d95e6c01d95e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0400cc5e0200db5e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e2101da5e5301d95e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e8b00df5e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e0000d85e29800401 Ze 2000000800000001000000000008 Z10 02 Z77 c180010000000b00ff0104330d000d000d000d000d000d002200003d01000000020001 Z9 ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Z60c 18e8b00080000404f870380010f7e2ff5f Z6f cfffffffff7f000000000006c0c71f06 Z7f 0100b39a2520010801 Z67 03030041 Z62 f401 Z2c 0203 Z26 07 Zb 01 Z153 d95e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9d85e02f9 Zc74 0200993f2520010801 Zda3 290a20100e02330a000000000f10020a250e030c0308000c F32 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z79 0300846e2520010801000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z1e1 bfc8c3c1c7bbff000000020100f63d0810f63d0818282828282828 Z15 1d09 Z33a F8 Z5b8 29800401 Ze 2000000800000001000000000008 Z10 02000000ccbfbeff0000000002 Z7f c2bfc6c6c9ff Zf cac9c51bc7c9c8ff Zd ceccbbbebfff Zf bcbbcecec6bfff Ze c6bfceb4cdff Zf c9c5abff Z11 cdc9ccccd3ff Zf d3bbd3f9f9ff Zf cec2bbc8c500d3c9cfff Zb bcd3bfaebcd3bfabff Z1a2 671501f9 Z8 671501f9 Z8 671501f9 Z8 671501f9 Z110 0400b94525200108010000000100000011111111bddd9950cac3bec1bfd3ff0000000202bbbbbbff000000009d910000a6d888bdaccc8841accc8841ac948c6353804873accc88418dcc9441accc88418fc38841bccc8841cccc8841acb48841 Z8c0 22222222bddd9950ccbbceced3ff000000000202bbbbbbff000000003e820000beffbb729fffbb72bcffbb729fa7b85060b37b409fffbb728cffbb7284ffbb729f87bb7295ebbb8e9fffbb729fffbb72 Z5f0 33333333e7037803c7bfd1ced1c9ff0000000202c1c3c9d0bbc8c8004a22000042304b301aba4d30d4484b30 Z74 0500d9182520010801000000de244bccd4304b30d4304b30d4680d122b7c8b02d4304b308a302230d4304b30de244b30 Zfd0 060013252520010801 Zff7 070000002520010801 Zff7 080000002520010801 Z807 44444444bddd9950bebfc9d2d3cdff0000000202bbbbbbff00000000b9250000f9c1c33606d51d26f999dd14da99f614f999dd14ed87dd146398dd142f1add14f9e1dd14f38ddde8f999dd14f999dd14 Z7a0 090035402520010801 Zff7 0a0000002520010801 Zff7 0b0000002520010801 Zff7 0c0000002520010801000000 F4000", + fr_bedroom = "Fe000 Z744 bcc9d2a2ff00000000bcc9d2a3ff00000000bcc9d2a4ff00000000bcc9d2a5ff00000000bcc9d2a6ff00000000bcc9d2a7ff00000000bcc9d2a8ff00000000bcc9d2a9ff00000000bcc9d2aaff00000000bcc9d2a2a1ff000000bcc9d2a2a2ff000000bcc9d2a2a3ff000000bcc9d2a2a4ff000000bcc9d2a2a5ff0000000001020300010203000102030001 Z824 0d0031f12520010801000000bbbbbbffffffffff00008edd5fab0000000f3500010000000000da Z91 01 Z7eb ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff0308048706200420042b042c04200420042004200420049604ff03ff03ff0308048f06860430043304340428042804150428042804a604ff03ff03ff03080497325a3038303b303c30093009300504060407300930ff03ff03ff03080409300130013001300130863201300d040e040f300130ff03ff03ff03080483328432853243304430350444304630013001300130ff03ff03ff0308048b028c328d02533045308e0645305630013001300130ff03ff03ff03080493429406954253004530963245305630013001300130ff03ff03ff0308040900013001005b305c305c305c305e30013001300130ff03ff03ff03080409300130013001300130013001300130013001300130ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03ff03 Z5c ff Zf ff Zf ff Zf ff Zf ff Z1f fb2573bc Z424 fb2573bc Zd2 cecf2520010801000000060006000401ff00060006 Z11 0300ff00060008 Zf 02 Z5a ff Z63 ff Z63 ff Z63 ff Z63 ff Z63 ff Ze 432e73bcfb2500000d0001 Z77 fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb250000fb25 Za8 c180010000000b00ff0104330d000d000d000d000d000d002200003d01000000020001 Z9 ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Zf ffffff Z11 ff Z60c 18e8b00080000404f870380010f7e2ff5f Z6f cfffffffff7f000000000006c0c71f06 Z7f 01001d372520010801 Z6a 41 Z62 f401 Z2c 0203 Z32 01 Z153 fa2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bcfb2573bc Zc74 02007fe12520010801 Zda3 290a20100e02330a000000000f10020a250e030c0308000c F32 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z79 0300846e2520010801000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 00000000010000000000 F1a 0000000001 Z1e1 bfc8c3c1c7bbff0000000201c4f73d08d4f73d0818282828282828 Z15 a70a Z33a F8 Z5ec c1ccbfbfc8ff000002 Z7f c2bfc6c6c9ff Zf cac9c51bc7c9c8ff Zd ceccbbbebfff Zf bcbbcecec6bfff Ze c6bfceb4cdff Zf c9c5abff Z11 cdc9ccccd3ff Zf d3bbd3f9f9ff Zf cec2bbc8c500d3c9cfff Zb bcd3bfaebcd3bfabff Z1a2 446e70bc Z8 446e70bc Z8 446e70bc Z8 446e70bc Z110 04006cf62520010801 Zff7 050000002520010801 Zff7 060000002520010801 Zff7 070000002520010801 Zff7 080000002520010801 Zff7 090000002520010801 Zff7 0a0000002520010801 Zff7 0b0000002520010801 Zff7 0c0000002520010801000000 F4000", + }, + oracle = { + fr_rich_game = { + ot = "AAA", + gender = 0, + tid = 56718, + sid = 43871, + playTime = { 12, 35, 24 }, + money = 123456, + coins = 777, + securityKey = 715612932, + badges = 3, + currentBox = 1, + nationalDex = false, + rival = "GREEN", + pos = { 6, 6 }, + location = { 4, 1, -1, 6, 6 }, + heal = { 3, 0, -1, 6, 8 }, + seen = { 1, 4, 6, 16, 19, 25, 150, 172, 201, 252, 386 }, + caught = { 6, 16, 19, 25, 150, 201, 252, 386 }, + flags = { 43, 44, 51, 53, 54, 55, 60, 61, 63, 79, 90, 98, 107, 108, 109, 110, 111, 116, 117, 118, 123, 124, 125, 140, 144, 145, 146, 148, 149, 150, 151, 153, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 174, 1064, 1065, 1066, 1067, 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1153, 1154, 1166, 1167, 1168, 1169, 1170, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1185, 1186, 2080, 2081, 2088, 2089, 2104, 2110 }, + boxNames = { "BOX1", "BOX2", "CUSTOM", "BOX4", "BOX5", "BOX6", "BOX7", "BOX8", "BOX9", "BOX10", "BOX11", "BOX12", "BOX13", "BOX14" }, + walls = { 0, 1, 5, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1 }, + vars = { [16421] = 500, [16444] = 770, [16464] = 7, [16470] = 1 }, + gameStats = { [0] = 2 }, + pouches = { Items = { { 13, 10 }, { 68, 5 }, { 23, 99 } }, KeyItems = { { 360, 1 }, { 364, 1 } }, Balls = { { 4, 20 }, { 2, 3 } }, TMHMs = { { 289, 2 }, { 339, 1 } }, Berries = { { 139, 7 } }, PCItems = { { 13, 1 }, { 20, 3 } } }, + party = { + { speciesInternal = 6, speciesNational = 6, pid = 305419896, tid = 56718, sid = 43871, nickname = "BLAZE", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 5, heldItem = 200, exp = 40007, friendship = 120, ppUps = 3, moves = { 53, 17, 52, 43 }, pp = { 24, 5, 25, 30 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 40, 0, 0, 0, 0, 9 }, pokerus = 33, metLocation = 88, metLevel = 36, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 32768, fateful = false, checksumValid = true, status = 8, statLevel = 36, hp = 50, maxHp = 114, stats = { 69, 61, 99, 87, 82 } }, + { speciesInternal = 277, speciesNational = 252, pid = 3735928559, tid = 54321, sid = 11111, nickname = "TREECKO", ot = "PKHEX", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 560, friendship = 120, ppUps = 0, moves = { 1, 43, 71, 0 }, pp = { 35, 30, 20, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 10, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true, status = 0, statLevel = 10, hp = 31, maxHp = 31, stats = { 15, 14, 25, 19, 16 } }, + { speciesInternal = 172, speciesNational = 172, pid = 195948557, tid = 56718, sid = 43871, otGender = 0, lang = 1, isEgg = true, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 125, friendship = 10, ppUps = 0, moves = { 84, 45, 0, 0 }, pp = { 30, 40, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 5, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true, status = 0, statLevel = 5, hp = 18, maxHp = 18, stats = { 9, 7, 12, 9, 9 } }, + { speciesInternal = 201, speciesNational = 201, pid = 16909060, tid = 56718, sid = 43871, nickname = "UNOWN", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 15625, friendship = 120, ppUps = 0, moves = { 237, 0, 0, 0 }, pp = { 15, 0, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 25, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 2147483648, fateful = true, checksumValid = true, status = 0, statLevel = 25, hp = 67, maxHp = 67, stats = { 39, 33, 48, 44, 35 } }, + }, + boxes = { + { box = 0, slot = 0, mon = { speciesInternal = 16, speciesNational = 16, pid = 286331153, tid = 56718, sid = 43871, nickname = "PIDGEY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 96, friendship = 120, ppUps = 0, moves = { 33, 28, 0, 0 }, pp = { 35, 15, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 4, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 0, slot = 29, mon = { speciesInternal = 19, speciesNational = 19, pid = 572662306, tid = 56718, sid = 43871, nickname = "RATTY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 27, friendship = 120, ppUps = 0, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 3, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 1, slot = 19, mon = { speciesInternal = 150, speciesNational = 150, pid = 858993459, tid = 999, sid = 888, nickname = "MEWTWO", ot = "GIOVANN", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 428750, friendship = 120, ppUps = 0, moves = { 94, 105, 0, 0 }, pp = { 10, 20, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 70, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 7, slot = 14, mon = { speciesInternal = 410, speciesNational = 386, pid = 1145324612, tid = 56718, sid = 43871, nickname = "DEOXYS", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 33750, friendship = 120, ppUps = 0, moves = { 35, 43, 0, 0 }, pp = { 20, 30, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 30, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 13, slot = 29, mon = { speciesInternal = 25, speciesNational = 25, pid = 1431655765, tid = 56718, sid = 43871, nickname = "SPARKY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 125000, friendship = 120, ppUps = 0, moves = { 85, 98, 0, 0 }, pp = { 15, 30, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 50, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + }, + }, + lg_rich_game = { + ot = "AAA", + gender = 0, + tid = 56765, + sid = 20633, + playTime = { 12, 35, 23 }, + money = 123456, + coins = 777, + securityKey = 2120468968, + badges = 3, + currentBox = 1, + nationalDex = false, + rival = "RED", + pos = { 6, 6 }, + location = { 4, 1, -1, 6, 6 }, + heal = { 3, 0, -1, 6, 8 }, + seen = { 1, 4, 6, 16, 19, 25, 150, 172, 201, 252, 386 }, + caught = { 6, 16, 19, 25, 150, 201, 252, 386 }, + flags = { 43, 44, 51, 53, 54, 55, 60, 61, 63, 79, 90, 98, 107, 108, 109, 110, 111, 116, 117, 118, 123, 124, 125, 140, 144, 145, 146, 148, 149, 150, 151, 153, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 174, 1064, 1065, 1066, 1067, 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1153, 1154, 1166, 1167, 1168, 1169, 1170, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1185, 1186, 2080, 2081, 2088, 2089, 2104, 2110 }, + boxNames = { "BOX1", "BOX2", "CUSTOM", "BOX4", "BOX5", "BOX6", "BOX7", "BOX8", "BOX9", "BOX10", "BOX11", "BOX12", "BOX13", "BOX14" }, + walls = { 0, 1, 5, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1 }, + vars = { [16421] = 500, [16444] = 770, [16464] = 7, [16470] = 1 }, + gameStats = { [0] = 2 }, + pouches = { Items = { { 13, 10 }, { 68, 5 }, { 23, 99 } }, KeyItems = { { 360, 1 }, { 364, 1 } }, Balls = { { 4, 20 }, { 2, 3 } }, TMHMs = { { 289, 2 }, { 339, 1 } }, Berries = { { 139, 7 } }, PCItems = { { 13, 1 }, { 20, 3 } } }, + party = { + { speciesInternal = 6, speciesNational = 6, pid = 305419896, tid = 56765, sid = 20633, nickname = "BLAZE", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 5, heldItem = 200, exp = 40007, friendship = 120, ppUps = 3, moves = { 53, 17, 52, 43 }, pp = { 24, 5, 25, 30 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 40, 0, 0, 0, 0, 9 }, pokerus = 33, metLocation = 88, metLevel = 36, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 32768, fateful = false, checksumValid = true, status = 8, statLevel = 36, hp = 50, maxHp = 114, stats = { 69, 61, 99, 87, 82 } }, + { speciesInternal = 277, speciesNational = 252, pid = 3735928559, tid = 54321, sid = 11111, nickname = "TREECKO", ot = "PKHEX", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 560, friendship = 120, ppUps = 0, moves = { 1, 43, 71, 0 }, pp = { 35, 30, 20, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 10, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true, status = 0, statLevel = 10, hp = 31, maxHp = 31, stats = { 15, 14, 25, 19, 16 } }, + { speciesInternal = 172, speciesNational = 172, pid = 195948557, tid = 56765, sid = 20633, otGender = 0, lang = 1, isEgg = true, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 125, friendship = 10, ppUps = 0, moves = { 84, 45, 0, 0 }, pp = { 30, 40, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 5, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true, status = 0, statLevel = 5, hp = 18, maxHp = 18, stats = { 9, 7, 12, 9, 9 } }, + { speciesInternal = 201, speciesNational = 201, pid = 16909060, tid = 56765, sid = 20633, nickname = "UNOWN", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 15625, friendship = 120, ppUps = 0, moves = { 237, 0, 0, 0 }, pp = { 15, 0, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 25, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 2147483648, fateful = true, checksumValid = true, status = 0, statLevel = 25, hp = 67, maxHp = 67, stats = { 39, 33, 48, 44, 35 } }, + }, + boxes = { + { box = 0, slot = 0, mon = { speciesInternal = 16, speciesNational = 16, pid = 286331153, tid = 56765, sid = 20633, nickname = "PIDGEY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 96, friendship = 120, ppUps = 0, moves = { 33, 28, 0, 0 }, pp = { 35, 15, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 4, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 0, slot = 29, mon = { speciesInternal = 19, speciesNational = 19, pid = 572662306, tid = 56765, sid = 20633, nickname = "RATTY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 27, friendship = 120, ppUps = 0, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 3, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 1, slot = 19, mon = { speciesInternal = 150, speciesNational = 150, pid = 858993459, tid = 999, sid = 888, nickname = "MEWTWO", ot = "GIOVANN", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 428750, friendship = 120, ppUps = 0, moves = { 94, 105, 0, 0 }, pp = { 10, 20, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 70, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 7, slot = 14, mon = { speciesInternal = 410, speciesNational = 386, pid = 1145324612, tid = 56765, sid = 20633, nickname = "DEOXYS", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 33750, friendship = 120, ppUps = 0, moves = { 35, 43, 0, 0 }, pp = { 20, 30, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 30, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 13, slot = 29, mon = { speciesInternal = 25, speciesNational = 25, pid = 1431655765, tid = 56765, sid = 20633, nickname = "SPARKY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 125000, friendship = 120, ppUps = 0, moves = { 85, 98, 0, 0 }, pp = { 15, 30, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 50, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + }, + }, + fr_bedroom = { + ot = "AAA", + gender = 0, + tid = 56718, + sid = 43871, + playTime = { 0, 0, 15 }, + money = 3000, + coins = 0, + securityKey = 3161662971, + badges = 0, + currentBox = 0, + nationalDex = false, + rival = "GREEN", + pos = { 6, 6 }, + location = { 4, 1, -1, 6, 6 }, + heal = { 3, 0, -1, 6, 8 }, + seen = { }, + caught = { }, + flags = { 43, 44, 51, 53, 54, 55, 60, 61, 63, 79, 90, 98, 107, 108, 109, 110, 111, 116, 117, 118, 123, 124, 125, 140, 144, 145, 146, 148, 149, 150, 151, 153, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 174, 1064, 1065, 1066, 1067, 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1153, 1154, 1166, 1167, 1168, 1169, 1170, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1185, 1186, 2104, 2110 }, + boxNames = { "BOX1", "BOX2", "BOX3", "BOX4", "BOX5", "BOX6", "BOX7", "BOX8", "BOX9", "BOX10", "BOX11", "BOX12", "BOX13", "BOX14" }, + walls = { 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1 }, + vars = { [16421] = 500, [16444] = 770, [16470] = 1 }, + gameStats = { [0] = 1 }, + pouches = { Items = { }, KeyItems = { }, Balls = { }, TMHMs = { }, Berries = { }, PCItems = { { 13, 1 } } }, + party = { + }, + boxes = { + }, + }, + fr_rich_pkhex = { + ot = "AAA", + gender = 0, + tid = 56718, + sid = 43871, + playTime = { 12, 34, 56 }, + money = 123456, + coins = 777, + securityKey = 3161662971, + badges = 3, + currentBox = 1, + nationalDex = false, + rival = "GREEN", + pos = { 6, 6 }, + location = { 4, 1, -1, 6, 6 }, + heal = { 3, 0, -1, 6, 8 }, + seen = { 1, 4, 6, 16, 19, 25, 150, 172, 201, 252, 386 }, + caught = { 6, 16, 19, 25, 150, 201, 252, 386 }, + flags = { 43, 44, 51, 53, 54, 55, 60, 61, 63, 79, 90, 98, 107, 108, 109, 110, 111, 116, 117, 118, 123, 124, 125, 140, 144, 145, 146, 148, 149, 150, 151, 153, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 174, 1064, 1065, 1066, 1067, 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1153, 1154, 1166, 1167, 1168, 1169, 1170, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1185, 1186, 2080, 2081, 2088, 2089, 2104, 2110 }, + boxNames = { "BOX1", "BOX2", "CUSTOM", "BOX4", "BOX5", "BOX6", "BOX7", "BOX8", "BOX9", "BOX10", "BOX11", "BOX12", "BOX13", "BOX14" }, + walls = { 0, 1, 5, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1 }, + vars = { [16421] = 500, [16444] = 770, [16464] = 7, [16470] = 1 }, + gameStats = { [0] = 1 }, + pouches = { Items = { { 13, 10 }, { 68, 5 }, { 23, 99 } }, KeyItems = { { 360, 1 }, { 364, 1 } }, Balls = { { 4, 20 }, { 2, 3 } }, TMHMs = { { 289, 2 }, { 339, 1 } }, Berries = { { 139, 7 } }, PCItems = { { 13, 1 }, { 20, 3 } } }, + party = { + { speciesInternal = 6, speciesNational = 6, pid = 305419896, tid = 56718, sid = 43871, nickname = "BLAZE", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 5, heldItem = 200, exp = 40007, friendship = 120, ppUps = 3, moves = { 53, 17, 52, 43 }, pp = { 24, 5, 25, 30 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 40, 0, 0, 0, 0, 9 }, pokerus = 33, metLocation = 88, metLevel = 36, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 32768, fateful = false, checksumValid = true, status = 8, statLevel = 36, hp = 50, maxHp = 114, stats = { 69, 61, 99, 87, 82 } }, + { speciesInternal = 277, speciesNational = 252, pid = 3735928559, tid = 54321, sid = 11111, nickname = "TREECKO", ot = "PKHEX", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 560, friendship = 120, ppUps = 0, moves = { 1, 43, 71, 0 }, pp = { 35, 30, 20, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 10, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true, status = 0, statLevel = 10, hp = 31, maxHp = 31, stats = { 15, 14, 25, 19, 16 } }, + { speciesInternal = 172, speciesNational = 172, pid = 195948557, tid = 56718, sid = 43871, otGender = 0, lang = 1, isEgg = true, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 125, friendship = 10, ppUps = 0, moves = { 84, 45, 0, 0 }, pp = { 30, 40, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 5, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true, status = 0, statLevel = 5, hp = 18, maxHp = 18, stats = { 9, 7, 12, 9, 9 } }, + { speciesInternal = 201, speciesNational = 201, pid = 16909060, tid = 56718, sid = 43871, nickname = "UNOWN", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 15625, friendship = 120, ppUps = 0, moves = { 237, 0, 0, 0 }, pp = { 15, 0, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 25, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 2147483648, fateful = true, checksumValid = true, status = 0, statLevel = 25, hp = 67, maxHp = 67, stats = { 39, 33, 48, 44, 35 } }, + }, + boxes = { + { box = 0, slot = 0, mon = { speciesInternal = 16, speciesNational = 16, pid = 286331153, tid = 56718, sid = 43871, nickname = "PIDGEY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 96, friendship = 120, ppUps = 0, moves = { 33, 28, 0, 0 }, pp = { 35, 15, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 4, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 0, slot = 29, mon = { speciesInternal = 19, speciesNational = 19, pid = 572662306, tid = 56718, sid = 43871, nickname = "RATTY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 27, friendship = 120, ppUps = 0, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 3, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 1, slot = 19, mon = { speciesInternal = 150, speciesNational = 150, pid = 858993459, tid = 999, sid = 888, nickname = "MEWTWO", ot = "GIOVANN", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 428750, friendship = 120, ppUps = 0, moves = { 94, 105, 0, 0 }, pp = { 10, 20, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 70, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 7, slot = 14, mon = { speciesInternal = 410, speciesNational = 386, pid = 1145324612, tid = 56718, sid = 43871, nickname = "DEOXYS", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 33750, friendship = 120, ppUps = 0, moves = { 35, 43, 0, 0 }, pp = { 20, 30, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 30, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + { box = 13, slot = 29, mon = { speciesInternal = 25, speciesNational = 25, pid = 1431655765, tid = 56718, sid = 43871, nickname = "SPARKY", ot = "AAA", otGender = 0, lang = 2, isEgg = false, badEgg = false, markingsRaw = 0, heldItem = 0, exp = 125000, friendship = 120, ppUps = 0, moves = { 85, 98, 0, 0 }, pp = { 15, 30, 0, 0 }, evs = { 10, 20, 0, 252, 0, 0 }, contest = { 0, 0, 0, 0, 0, 0 }, pokerus = 0, metLocation = 88, metLevel = 50, version = 4, ball = 4, ivs = { 31, 7, 19, 0, 12, 25 }, abilityBit = false, ribbons = 0, fateful = false, checksumValid = true } }, + }, + }, + }, +} diff --git a/tests/game3_cart_cache.lua b/tests/game3_cart_cache.lua new file mode 100644 index 00000000..5df25896 --- /dev/null +++ b/tests/game3_cart_cache.lua @@ -0,0 +1,48 @@ +local Cache = require("tests.game3_cache") + +local M = {} + +local function readable(path) + local f = io.open(path, "rb") + if f then f:close() return true end + return false +end + +local function current(root) + local f = io.open(root .. "/meta.json", "rb") + if not f then return false end + local src = f:read("*a") or "" + f:close() + local v = tonumber(src:match('"cache_version"%s*:%s*(%d+)')) + return v == require("src.import.gba.versions").CACHE_VERSION +end + +local function leafgreenRoot() + local home, identity = os.getenv("HOME"), os.getenv("POKEPORT_IDENTITY") + if not (home and identity and identity ~= "") then return nil end + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + local root = base .. "/" .. identity .. "/leafgreen/data/generated/gba" + if readable(root .. "/meta.json") and current(root) then return root end + end + return nil +end + +function M.mountOrSkip(label) + local root = Cache.mount() + if root then + print("[info] FireRed cache at " .. root) + return "firered", root + end + root = leafgreenRoot() + if not root then + print("[skip] " .. tostring(label) .. ": " .. tostring(Cache.reason or "no imported FireRed/LeafGreen cache found")) + os.exit(0) + end + local Dataset = require("src.core.game3.dataset") + Dataset.cacheRootOverride = root + Dataset.mountExtractRoots() + print("[info] LeafGreen cache at " .. root) + return "leafgreen", root +end + +return M diff --git a/tests/game3_cart_export_test.lua b/tests/game3_cart_export_test.lua new file mode 100644 index 00000000..341af559 --- /dev/null +++ b/tests/game3_cart_export_test.lua @@ -0,0 +1,168 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local GAME = require("tests.game3_cart_cache").mountOrSkip("game3_cart_export_test") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + createDirectory = function() return true end, + getSaveDirectory = function() return "/fake/save" end, + } +end + +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local SaveFileIO = require("src.import.SaveFileIO") +local SaveConvert = require("src.save_convert.SaveConvert") +local Gen3Save = require("src.save_convert.Gen3Save") +local L = require("src.save_convert.Gen3Layout") +local Schema = require("src.core.game3.save_schema_firered") +local Party = require("src.core.game3.party") +local Pokemon = require("src.core.game3.pokemon") + +local CART = unrle(require("tests.fixture_data.gen3_saves").images.fr_rich_game) +local realFs = love.filesystem + +local function fresh(version) + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set(version) + return files +end + +local function exported(files, version) + local ok, path = SaveFileIO.exportActiveSlot(version) + check(ok == true, version .. " exportActiveSlot succeeds (" .. tostring(path) .. ")") + return files[("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, tostring(SaveData.activeSlot(version)))] +end + +for _, version in ipairs({ "firered", "leafgreen" }) do + local files = fresh(version) + files["picked.sav"] = CART + local ok, slotId = SaveFileIO.importToSlot("picked.sav", version) + check(ok == true, version .. " imports the cart (" .. tostring(slotId) .. ")") + local bytes = exported(files, version) + check(type(bytes) == "string" and #bytes == L.FLASH_SIZE, version .. " cart slot exports a 128K .sav") + local a, b = assert(Gen3Save.readBlocks(CART)), bytes and Gen3Save.readBlocks(bytes) + check(b and b.counter == a.counter + 1, version .. " export is the cart's next save") + local same = b and b.storage == a.storage and b.sb1:sub(0x15) == a.sb1:sub(0x15) and b.sb1:sub(1, 0xC) == a.sb1:sub(1, 0xC) + check(same, version .. " the cart template's bytes survive outside the continue warp") + local c = bytes and Gen3Save.decode(bytes) + check(c and c.specialSaveWarpFlags == L.CONTINUE_GAME_WARP, version .. " export continues through the continue-game warp") +end + +do + local files = fresh(GAME) + local s = Schema.newGame({ version = GAME, name = "PORT", rivalName = "RIVAL", rngSeed = 2444 }) + Party.giveMon(s, 4, 5) + Party.giveMon(s, 277, 7, "TREE") + s.party[1].status, s.party[1].hp = "PAR", 7 + s.map, s.x, s.y = "FR_PALLET_TOWN", 12, 16 + s.money = 4321 + local Mail = require("src.core.game3.mail") + local mailId = Mail.giveMailToMon(s, s.party[2], 121) + Mail.slot(s, mailId).words[1] = 1234 + s.dex.nationalUnlocked = true + local slot = SaveData.createSlot(GAME) + check(SaveData.writeSlot(GAME, slot, Schema.toSaveTable(s)) == true, "port-born slot written") + SaveData.setActiveSlot(GAME, slot) + local bytes = exported(files, GAME) + local c = bytes and Gen3Save.decode(bytes) + check(c ~= nil, "the port-born export decodes as a valid FireRed flash") + if c then + check(c.counter == 1 and c.frlgMarker == 1, "fresh first save with the FRLG marker") + check(c.location.group == 3 and c.location.num == 0 and c.posX == 12 and c.posY == 16, "Pallet Town 12,16") + check((c.mapLayoutId or 0) > 0, "map layout id from the cached map header (" .. tostring(c.mapLayoutId) .. ")") + check(c.continueGameWarp.group == 3 and c.continueGameWarp.num == 0 and c.specialSaveWarpFlags == 1, "continue warp") + local h = c.lastHealLocation + check(h.group == 3 and h.num == 0 and h.x == 6 and h.y == 8, "Mom's house respawn saves as the Pallet heal location (6,8)") + check(c.money == 4321 and c.name == "PORT" and c.rivalName == "RIVAL", "trainer data") + check(c.trainerId == s.trainerId % 65536 and c.secretId == s.secretId % 65536, "TID/SID") + check(#c.party == 2, "party size") + local p1, m1 = c.party[1], s.party[1] + check(p1.checksumOk and p1.species == 4 and p1.level == 5 and p1.hp == 7 and p1.status == L.STATUS.PAR, + "Charmander Lv5 7 HP paralysed") + check(p1.maxHp == m1.maxHp and p1.attack == m1.attack and p1.speed == m1.speed, "party stats are the port's") + check(p1.nickname == Pokemon.name(4), "the unnamed starter carries its species name") + check(p1.personality == m1.personality and p1.exp == m1.exp, "personality and exp") + check(c.party[2].species == 277 and c.party[2].nickname == "TREE", "Treecko nickname") + local owned = {} + for _, i in ipairs(c.dexOwned) do owned[i + 1] = true end + check(owned[Pokemon.national(277)] and owned[4], "dex bits use the national numbers from the cache") + check(c.storage.boxes[1].name == "BOX1" and c.storage.boxes[1].wallpaper == 0, "default box 1 like the cart") + check(c.party[2].mail == mailId and c.mail[mailId + 1].itemId == 121 and c.mail[mailId + 1].words[1] == 1234, + "the port mon's letter exports at its mail index") + check(c.party[1].mail == L.MAIL_NONE, "a mon without mail exports MAIL_NONE") + check(c.dexNationalMagic == L.NATIONAL_DEX.magic, "the port's National Dex unlock exports the magic") + check(c.fameChecker[1].pickState == L.FCPICKSTATE_COLORED, "Fame Checker starts with Oak like a new cart") + check(c.registeredTexts[1] ~= "" and c.registeredTexts[1] ~= nil, "registered texts come from the ROM text (" .. tostring(c.registeredTexts[1]) .. ")") + check(c.trainerTowerBest[1] == L.TRAINER_TOWER_MAX_TIME, "Trainer Tower has no time yet") + local back = assert(SaveConvert.importSav(bytes, GAME, GAME)) + check(back.party[1].species == 4 and back.party[1].level == 5 and back.money == 4321, "re-import of the export") + check(back.party[2].mail == mailId and back.mail[mailId + 1].words[1] == 1234, "re-import keeps the letter") + end +end + +do + local files = fresh(GAME) + files["picked.sav"] = CART + local ok, slot = SaveFileIO.importToSlot("picked.sav", GAME) + check(ok == true, "cart imported for the NEW GAME case") + local cartFile = ("saves/%s/%s.cart"):format(GAME, slot) + check(files[cartFile] ~= nil, "the import keeps the cart beside the slot") + local s = Schema.newGame({ version = GAME, name = "NEWBIE", rivalName = "RIVAL", rngSeed = 7 }) + s.map, s.x, s.y = "FR_PALLET_TOWN", 12, 16 + check(SaveData.save(Schema.toSaveTable(s)) ~= false, "NEW GAME saves over the imported slot") + check(SaveData.activeSlot(GAME) == slot, "the same slot is active") + check(files[cartFile] == nil, "NEW GAME over the slot drops the old cart") + local bytes = exported(files, GAME) + local c = bytes and Gen3Save.decode(bytes) + check(c and c.name == "NEWBIE" and c.counter == 1 and #c.hallOfFame == 0, "the export is a first save of the new player") + + files = fresh(GAME) + files["picked.sav"] = CART + ok, slot = SaveFileIO.importToSlot("picked.sav", GAME) + local keep = SaveData.load(GAME) + check(SaveData.save(keep) ~= false, "the imported player saves again") + check(files[("saves/%s/%s.cart"):format(GAME, slot)] ~= nil, "the imported player keeps the cart") +end + +love.filesystem = realFs +print(failed == 0 and "game3_cart_export_test: all passed" or ("game3_cart_export_test: " .. failed .. " failed")) +os.exit(failed == 0 and 0 or 1) diff --git a/tests/game3_cart_import_load_test.lua b/tests/game3_cart_import_load_test.lua new file mode 100644 index 00000000..228073e9 --- /dev/null +++ b/tests/game3_cart_import_load_test.lua @@ -0,0 +1,108 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local GAME = require("tests.game3_cart_cache").mountOrSkip("game3_cart_import_load_test") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveSerializer = require("src.core.SaveSerializer") +local Schema = require("src.core.game3.save_schema_firered") +local Pokemon = require("src.core.game3.pokemon") +local F = require("tests.fixture_data.gen3_saves") + +local oracle = F.oracle.fr_rich_game +local save = assert(SaveConvert.importSav(unrle(F.images.fr_rich_game), GAME, GAME)) +save = assert(SaveSerializer.decode(SaveSerializer.encode(save))) +local s = Schema.fromSaveTable(save) + +check(s.map == "FR_PLAYERS_HOUSE_2F" and s.x == 6 and s.y == 6, "continue lands in the bedroom at 6,6") +check(s.healMap == "FR_PLAYERS_HOUSE_1F" and s.healX == 8 and s.healY == 5, + "the Pallet heal spot resolves to Mom's tile (" .. tostring(s.healMap) .. " " .. tostring(s.healX) .. "," .. tostring(s.healY) .. ")") +check(s.modData.cartImport == nil, "the import marker is spent after the first load") +for i, o in ipairs(oracle.party) do + local m = s.party[i] + check(m.level == o.statLevel, ("party %d level %s"):format(i, tostring(m.level))) + check(m.maxHp == o.maxHp, ("party %d max HP %s == %s"):format(i, tostring(m.maxHp), tostring(o.maxHp))) + check(m.attack == o.stats[1] and m.defense == o.stats[2] and m.speed == o.stats[3] + and m.spAtk == o.stats[4] and m.spDef == o.stats[5], ("party %d stats match the cart"):format(i)) + check(m.hp == o.hp, ("party %d HP %s"):format(i, tostring(m.hp))) + check(m.cartImport == nil, ("party %d finished"):format(i)) +end +check(s.party[1].nickname == "BLAZE", "a real nickname stays") +check(s.party[2].nickname == "", "a nickname equal to the species name clears") +check(s.party[2].name == Pokemon.name(277), "Treecko name comes from the species table") +check(s.party[1].gender == Pokemon.gender(6, oracle.party[1].pid), "gender from personality") +check(s.party[1].ability == Pokemon.abilities(6)[1], "ability slot 1") +check(s.party[1].maxPp[1] == Pokemon.movePp(53) + math.floor(Pokemon.movePp(53) * 20 * 3 / 100), + "three PP Ups raise max PP on move 1") +check(s.party[1].maxPp[2] == Pokemon.movePp(17), "no PP Ups on move 2") +check(s.party[3].isEgg == true and s.party[3].eggCycles == 10, "the egg keeps its cycles") +local levels = {} +for _, b in ipairs(oracle.boxes) do + local m = s.storage.boxes[b.box + 1].mons[b.slot + 1] + levels[#levels + 1] = m and m.level +end +check(levels[1] == 4 and levels[2] == 3 and levels[3] == 70 and levels[4] == 30 and levels[5] == 50, + "box levels come from exp (" .. table.concat(levels, ",") .. ")") +check(s.storage.boxes[2].mons[20].hp == s.storage.boxes[2].mons[20].maxHp, "box mons are full HP") +for _, nat in ipairs(oracle.caught) do + local sp = Pokemon.speciesFromNational(nat) + check(s.dex.owned[sp] == true and s.dex.caught[sp] == true, "caught national " .. nat .. " -> species " .. tostring(sp)) +end +for _, nat in ipairs(oracle.seen) do + check(s.dex.seen[Pokemon.speciesFromNational(nat)] == true, "seen national " .. nat) +end +check(s.dex.owned[277] == true and s.dex.owned[252] == nil, "Treecko is internal 277, not 252") +check(s.options.textSpeed == 1, "text speed carried") +check(#s.bag.pockets.ITEMS == 3, "bag items") + +do + local Gen3Save = require("src.save_convert.Gen3Save") + local L = require("src.save_convert.Gen3Layout") + local Mail = require("src.core.game3.mail") + local c, blocks = Gen3Save.decode(unrle(F.images.fr_rich_game)) + c.party[1].heldItem, c.party[1].mail = 121, 0 + c.mail[1] = { words = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, playerName = "AAA", trainerId = c.trainerId, species = 6, itemId = 121 } + local N = L.NATIONAL_DEX + c.dexNationalMagic, c.vars[N.var] = N.magic, N.varValue + c.flags[#c.flags + 1] = N.flag + c.fameChecker[3] = { pickState = 1, flavorTextFlags = 5 } + local img = Gen3Save.buildFlash(Gen3Save.encodeBlocks(c, blocks), { counter = c.counter + 1, image = blocks.image }) + local cart = assert(SaveConvert.importSav(img, GAME, GAME)) + local s2 = Schema.fromSaveTable(assert(SaveSerializer.decode(SaveSerializer.encode(cart)))) + check(Mail.monHasMail(s2.party[1]), "a cart mon holding mail still has its letter after load") + local letter = Mail.get(s2, s2.party[1].mail) + check(letter ~= nil and letter.words[1] == 1 and letter.itemId == 121, "the letter's words survive the load") + check(require("src.core.game3.pokedex_data").isNationalUnlocked(s2, s2.dex) == true, "a National Dex cart loads unlocked") + local fc = require("src.core.game3.fame_checker").records(s2) + check(fc and fc[3].pickState == 1 and fc[3].flavorTextFlags == 5, "Fame Checker progress loads") +end + +if failed > 0 then + print(string.format("\n%d FAILED", failed)) + os.exit(1) +end +print("\nAll cart import load tests passed.") +os.exit(0) diff --git a/tests/game3_collision_behaviors_test.lua b/tests/game3_collision_behaviors_test.lua index f4294f35..f34fe0d3 100644 --- a/tests/game3_collision_behaviors_test.lua +++ b/tests/game3_collision_behaviors_test.lua @@ -206,6 +206,124 @@ do eo.cellX, eo.cellY, eo.moving = 1, 2, false Objects.updateElevation(eo) check(eo.currentElevation == 3, "an object reads its own mapDef elevation") + + print("[test] 2c. forced movement, waterfall, objects, trainer sight and ledges honor elevation") + local FM = require("src.core.game3.forced_movement") + local function forced(x, y, facing, surfing, elev, beh) + Player.reset(x, y, facing) + Player.surfing = surfing + Player.currentElevation = elev + local moved = FM.tryDoMetatileBehaviorForcedMovement(fakeGame, beh) + local tx, ty = Player.targetX, Player.targetY + Player.moving, Player.surfing, Player.dismounting = false, false, false + FM.forced = false + return moved, tx, ty + end + local moved, tx, ty = forced(1, 0, "down", false, 3, MB.SPIN_DOWN) + check(not moved and tx == 1 and ty == 0, "SpinDown from elev 3 into elev 4 is refused") + moved, tx, ty = forced(1, 2, "right", false, 3, MB.SPIN_RIGHT) + check(moved and tx == 2 and ty == 2, "SpinRight along elev 3 still moves") + moved, tx, ty = forced(1, 3, "right", true, 1, MB.EASTWARD_CURRENT) + check(not moved and tx == 1 and ty == 3, "an east current does not push a surfer onto elev 4 land") + moved, tx, ty = forced(4, 3, "left", true, 1, MB.WESTWARD_CURRENT) + check(moved and tx == 3 and ty == 3, "a west current still lands a surfer on elev 3 land") + moved, tx, ty = forced(0, 3, "right", true, 1, MB.EASTWARD_CURRENT) + check(moved and tx == 1 and ty == 3, "an east current still carries a surfer along elev 1 water") + + local Field = require("src.core.game3.field") + local FieldMoves = require("src.core.game3.field_moves") + local realCanEnter, realWf, realWfFlag = Collision.canEnter, FieldMoves.isWaterfallBehavior, Field._waterfall + local seen + Collision.canEnter = function(_, _, _, opts) seen = opts return true end + FieldMoves.isWaterfallBehavior = function() return true end + Field._waterfall = nil + Player.reset(0, 2, "down") + Player.surfing = true + Player.currentElevation = 1 + Field.forcedMovementPending() + Collision.canEnter, FieldMoves.isWaterfallBehavior, Field._waterfall = realCanEnter, realWf, realWfFlag + Player.surfing = false + check(seen and seen.elevation == 1, "the waterfall push passes the surfer's elevation") + + Objects._mapId = "FAKE_ELEVATION" + Objects._byId, Objects._order = {}, {} + local function npc(lid, x, y, elev) + local o = { localId = lid, cellX = x, cellY = y, targetX = x, targetY = y, moving = false, + visible = true, hidden = false, passable = false, currentElevation = elev } + Objects._byId[lid] = o + Objects._order[#Objects._order + 1] = lid + return o + end + local block = npc(1, 3, 2, 4) + check(Objects.blocks(3, 2, nil, 3) == false, "an elev 4 object does not block an elev 3 mover") + check(Objects.blocks(3, 2, nil, 4) == true, "an elev 4 object blocks an elev 4 mover") + check(Objects.blocks(3, 2, nil, 0) == true, "elev 0 movers collide with everything") + check(Objects.blocks(3, 2) == true, "no elevation keeps the old callers unchanged") + block.currentElevation = 0 + check(Objects.blocks(3, 2, nil, 3) == true, "an elev 0 object collides with everything") + Player.reset(2, 2, "right") + Player.currentElevation = 4 + check(Objects.playerBlocks(2, 2, 3) == false, "an elev 3 object walks through an elev 4 player") + check(Objects.playerBlocks(2, 2, 4) == true, "an elev 4 object bumps an elev 4 player") + check(Objects.playerBlocks(2, 2) == true, "playerBlocks with no elevation is unchanged") + block.currentElevation = 4 + check(try(2, 2, 3, 2, 3) == true, "the player walks past an object on another elevation") + local ok2, why2 = try(2, 2, 3, 2, nil) + check(ok2 == false and why2 == "entity", "canEnter without elevation still stops at the object") + Objects._byId, Objects._order = {}, {} + npc(2, 1, 2, 3) + ok2, why2 = try(1, 3, 1, 2, 1, true) + check(ok2 == false and why2 == "entity", "a surfer cannot dismount onto an elev 3 object") + Objects._byId[2].currentElevation = 4 + check(try(1, 3, 1, 2, 1, true) == true, "a surfer dismounts past an elev 4 object like GetObjectEventIdByPosition(x, y, 3)") + + local TrainerSight = require("src.core.game3.trainer_sight") + Objects._byId, Objects._order = {}, {} + local trainer = { localId = 9, cellX = 1, cellY = 0, facing = "down", sight = 4, + currentElevation = 3, visible = true, hidden = false, moving = false } + Player.reset(1, 2, "up") + Player.currentElevation = 3 + local spotted = TrainerSight.checkLineOfSight(trainer, Player, fakeGame) + check(spotted == false, "a trainer does not see across an elev 4 cell") + trainer.cellX, trainer.cellY, trainer.facing = 0, 2, "right" + Player.reset(3, 2, "left") + Player.currentElevation = 3 + local dist + spotted, dist = TrainerSight.checkLineOfSight(trainer, Player, fakeGame) + check(spotted == true and dist == 3, "a trainer sees along its own elevation") + npc(3, 1, 2, 4) + check(TrainerSight.checkLineOfSight(trainer, Player, fakeGame) == true, + "an object on another elevation does not block sight") + Objects._byId[3].currentElevation = 3 + check(TrainerSight.checkLineOfSight(trainer, Player, fakeGame) == false, + "an object on the same elevation blocks sight") + Objects._byId, Objects._order = {}, {} + Player.currentElevation = 4 + check(TrainerSight.checkLineOfSight(trainer, Player, fakeGame) == false, + "an elev 3 trainer does not see an elev 4 player") + trainer.cellX, trainer.cellY, trainer.facing = 4, 0, "down" + Player.reset(4, 1, "up") + Player.currentElevation, Player.elevation = 0, 4 + spotted, dist = TrainerSight.checkLineOfSight(trainer, Player, fakeGame) + check(spotted == true and dist == 1, "a player on an elev 0 stair is seen from elev 3") + trainer.cellX, trainer.cellY, trainer.facing = 1, 2, "down" + Player.reset(1, 3, "up") + Player.surfing, Player.currentElevation = true, 1 + check(TrainerSight.checkLineOfSight(trainer, Player, fakeGame) == false, + "a land trainer does not see a surfer on elev 1 water") + Player.surfing = false + + COLL[1 * W + 2] = 0xA3 + npc(4, 1, 2, 3) + local lx, ly = Collision.ledgeLanding(fakeGame, 1, 0, "down") + check(lx == 1 and ly == 2, "a ledge jump lands on an occupied cell like ShouldJumpLedge") + COLL[2 * W + 3] = 0x07 + COLL[1 * W + 3] = 0xA3 + lx, ly = Collision.ledgeLanding(fakeGame, 2, 0, "down") + check(lx == 2 and ly == 2, "a ledge jump does not test the landing tile") + check(Collision.ledgeLanding(fakeGame, 2, 1, "down") == nil, "a wall is not a ledge") + COLL[1 * W + 2], COLL[1 * W + 3], COLL[2 * W + 3] = 0x00, 0x00, 0x00 + Objects.clear() Collision.clear() end diff --git a/tests/game3_ladder_warp_test.lua b/tests/game3_ladder_warp_test.lua index a78a56db..d0a62e0d 100644 --- a/tests/game3_ladder_warp_test.lua +++ b/tests/game3_ladder_warp_test.lua @@ -74,7 +74,9 @@ check(okUp == false, "(8,1) above ladder is solid wall") -- 1. Arrival facing on ladder local arrivalFace = Collision.destArrivalFacing(game, mapId, 8, 2, "up") -check(arrivalFace == "down", "arrival facing on ladder is down (was " .. tostring(arrivalFace) .. ")") +check(arrivalFace == "up", "arrival facing on ladder keeps the stored direction (was " .. tostring(arrivalFace) .. ")") +local scriptedFace = Collision.destArrivalFacing(game, mapId, 8, 2, "down") +check(scriptedFace == "down", "a scripted warp onto a ladder faces south after ResetInitialPlayerAvatarState (was " .. tostring(scriptedFace) .. ")") -- 2. Standing on ladder at (8, 2) facing down Player.cellX = 8 diff --git a/tests/game3_sign_walkaway_wait_test.lua b/tests/game3_sign_walkaway_wait_test.lua new file mode 100644 index 00000000..6c376d09 --- /dev/null +++ b/tests/game3_sign_walkaway_wait_test.lua @@ -0,0 +1,84 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Schema = require("src.core.game3.save_schema_firered") +local Hud = require("src.ui.game3.hud") +local Events = require("src.core.game3.scripting.natives_events") + +local passed, failed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function signVm(session) + package.loaded["src.core.game3.runtime"] = { getSession = function() return session end } + local ctx = { + session = session, + flags = session.flags, + vars = session.vars, + stringVars = session.stringVars, + specialVars = session.specialVars, + } + local vm = { + ctx = ctx, + halted = false, + adapters = { closeMessage = function() end }, + isRunning = function() return true end, + halt = function(self, aborted) self.halted = aborted == true end, + } + Natives.special(ctx, Std.SPECIAL.SetWalkingIntoSignVars) + ctx.messageOpen = true + ctx.specialVars[0x800C] = 2 + return vm +end + +local function dirInput(key) + return { + wasPressed = function(_, k) return k == key end, + isDown = function(_, k) return k == key end, + } +end + +print("=== walking away from a sign drops the waitbuttonpress wait ===") +do + local vm = signVm(Schema.newGame({ name = "RED" })) + local fired = 0 + Hud.armWaitButton(function() fired = fired + 1 end) + check(Hud.busy() == true, "the armed waitbuttonpress keeps the HUD busy") + for _ = 1, 7 do Events.pollWalkaway(vm, dirInput("down")) end + check(vm.halted == true, "DOWN cancels the sign script") + check(Hud._waitButton == nil, "the HUD wait is cleared with the script context") + check(Hud.busy() == false, "field input is free right after the cancel") + check(fired == 0, "the stale wait callback never runs") +end + +print("=== START cancel drops the wait too ===") +do + local vm = signVm(Schema.newGame({ name = "RED" })) + package.loaded["src.core.game3.runtime"].defer = function() return true end + Hud.armWaitButton(function() end) + for _ = 1, 6 do Events.pollWalkaway(vm, dirInput("up")) end + Events.pollWalkaway(vm, dirInput("start")) + check(vm.halted == true, "START cancels the sign script") + check(Hud._waitButton == nil, "START cancel clears the HUD wait") +end + +print("=== a blocked walkaway leaves the wait armed ===") +do + local vm = signVm(Schema.newGame({ name = "RED" })) + Hud.armWaitButton(function() end) + for _ = 1, 7 do Events.pollWalkaway(vm, dirInput("up")) end + check(vm.halted == false, "pushing the facing direction does not cancel") + check(Hud._waitButton ~= nil, "the wait stays armed for A/B") + Hud.clearWaitButton() +end + +print(string.format("\nTotal: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_summary_chrome_bake_s3_test.lua b/tests/game3_summary_chrome_bake_s3_test.lua new file mode 100644 index 00000000..021de281 --- /dev/null +++ b/tests/game3_summary_chrome_bake_s3_test.lua @@ -0,0 +1,169 @@ +#!/usr/bin/env luajit +-- pokefirered/src/pokemon_summary_screen.c:1862 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local PRET = "../pokefirered" +local GFX = PRET .. "/graphics/summary_screen/" +local ROM_SHA1 = "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc" + +local function slurp(p) + local f = io.open(p, "rb") + if not f then return nil end + local d = f:read("*a") + f:close() + return d +end + +local tiles = slurp(GFX .. "bg.4bpp") +local pal = slurp(GFX .. "bg.gbapal") +local romPresent = slurp(PRET .. "/pokefirered.gba") ~= nil +if not (tiles and pal and romPresent) then + print("[skip] " .. PRET .. " summary graphics or ROM not present") + os.exit(0) +end + +local function u16(s, i) return s:byte(i + 1) + s:byte(i + 2) * 256 end + +-- pokefirered/src/pokemon_summary_screen.c:3276 +local BASE = 345 +local PROGRESS = { + info = { { 17, 13, 0 }, { 33, 13, 1 }, { 16, 14, 0 }, { 32, 14, 1 }, { 18, 15, 0 }, { 34, 15, 1 }, + { 20, 16, 0 }, { 36, 16, 1 }, { 18, 17, 0 }, { 34, 17, 1 }, { 21, 18, 0 }, { 37, 18, 1 } }, + moves = { { 49, 13, 0 }, { 65, 13, 1 }, { 1, 14, 0 }, { 19, 14, 1 }, { 49, 15, 0 }, { 65, 15, 1 }, + { 1, 16, 0 }, { 19, 16, 1 }, { 17, 17, 0 }, { 33, 17, 1 }, { 48, 18, 0 }, { 64, 18, 1 } }, + moves_info_select = { { 1, 13, 0 }, { 1, 14, 0 }, { 1, 15, 0 }, { 1, 16, 0 }, + { 19, 13, 1 }, { 19, 14, 1 }, { 19, 15, 1 }, { 19, 16, 1 }, + { 50, 17, 0 }, { 66, 17, 1 }, { 48, 18, 0 }, { 64, 18, 1 } }, +} + +local function render(binName, progressKind, shiny) + local map = assert(slurp(GFX .. binName), binName) + local entries = {} + for i = 0, 32 * 20 - 1 do entries[i] = u16(map, i * 2) end + for _, p in ipairs(PROGRESS[progressKind] or {}) do + entries[p[3] * 32 + p[2]] = p[1] + BASE + end + local out = {} + for ty = 0, 19 do + for tx = 0, 29 do + local e = entries[ty * 32 + tx] + local tile, hf, vf, pn = e % 1024, math.floor(e / 1024) % 2 == 1, math.floor(e / 2048) % 2 == 1, math.floor(e / 4096) + local bank = pn + if shiny and pn == 0 then bank = 6 elseif shiny and pn == 1 then bank = 5 end + for y = 0, 7 do + for x = 0, 7 do + local sx, sy = hf and (7 - x) or x, vf and (7 - y) or y + local b = tiles:byte(tile * 32 + sy * 4 + math.floor(sx / 2) + 1) or 0 + local idx = (sx % 2 == 0) and (b % 16) or math.floor(b / 16) + if idx ~= 0 then + out[(ty * 8 + y) * 240 + tx * 8 + x] = u16(pal, bank * 32 + idx * 2) % 32768 + end + end + end + end + end + return out +end + +local function to15(rgba, i) + local r, g, b = rgba:byte(i * 4 + 1), rgba:byte(i * 4 + 2), rgba:byte(i * 4 + 3) + local function c5(v) return math.floor(v * 31 / 255 + 0.5) end + return c5(r) + c5(g) * 32 + c5(b) * 1024, rgba:byte(i * 4 + 4) +end + +local function compare(label, bake, want, w, h, ox, oy, opaque) + if not bake then check(false, label .. " was baked") return end + eq(#bake, w * h * 4, label .. " size") + local bad = 0 + for y = 0, h - 1 do + for x = 0, w - 1 do + local c, a = to15(bake, y * w + x) + local p = want[(y + oy) * 240 + x + ox] + if p == nil then + if not opaque and a ~= 0 then bad = bad + 1 end + elseif a == 0 or c ~= p then + bad = bad + 1 + end + end + end + eq(bad, 0, label .. " matches pret's tilemap render pixel for pixel") +end + +local FileIO = require("src.import.gba.file_io") +local imports = FileIO.makeImports(PRET .. "/pokefirered.gba", ROM_SHA1, "firered") +local rom = assert(require("src.import.gba.rom").open(imports, "firered")) +local mem = { files = {} } +function mem:write(p, d) self.files[p] = d end +function mem:exists(p) return self.files[p] ~= nil end +require("src.import.gba.summary_chrome_extract").run(rom, mem, { cacheRoot = "X" }) +local function baked(name) return mem.files["X/pokemon/summary/" .. name] end + +print("[test] 1. KNOWN MOVES keeps the picture frame; move detail swaps it") +for _, shiny in ipairs({ false, true }) do + local sfx = shiny and "_shiny" or "" + compare("bg3_info" .. sfx, baked("bg3_info" .. sfx .. ".rgba"), render("moves_info_page.bin", nil, shiny), 240, 160, 0, 0, true) + compare("bg3_moves" .. sfx, baked("bg3_moves" .. sfx .. ".rgba"), render("moves_page.bin", nil, shiny), 240, 160, 0, 0, true) + for _, layer in ipairs({ "info", "moves", "moves_info" }) do + compare("layer_" .. layer .. sfx, baked("layer_" .. layer .. sfx .. ".rgba"), + render("page_" .. layer .. ".bin", nil, shiny), 240, 160, 0, 0, false) + end + for _, kind in ipairs({ "info", "moves", "moves_info_select" }) do + local base = kind == "moves_info_select" and "moves_page.bin" or "moves_info_page.bin" + compare("progress_" .. kind .. sfx, baked("progress_" .. kind .. sfx .. ".rgba"), + render(base, kind, shiny), 48, 16, 104, 0, true) + end +end +check(baked("page_moves.rgba") == nil, "the old move-detail bake under the KNOWN MOVES name is gone") +check(baked("page_info.rgba") ~= nil, "page_info.rgba still marks the cache ready") + +print("[test] 2. manifest carries the ROM tables the screen reads") +local manifest = baked("manifest.lua") +local m = manifest and load(manifest, "=manifest", "t", {})() +check(m ~= nil, "the manifest loads") +if m then + local n = 0 + for _ in pairs(m.noFlip or {}) do n = n + 1 end + eq(n, 18, "eighteen species are never flipped") + eq(m.noFlip and m.noFlip[25], nil, "Pikachu is flipped") + local lens = {} + for i, d in ipairs(m.monPicBounce or {}) do lens[i] = #d end + eq(table.concat(lens, ","), "3,5,7,7", "four pic bounce tables") + eq(m.monPicBounce and m.monPicBounce[4] and m.monPicBounce[4][1], -5, "the full-HP bounce starts at -5") + local el = {} + for i, d in ipairs(m.eggPicShake or {}) do el[i] = #d end + eq(table.concat(el, ","), "11,11,15", "three egg shake tables") + eq(m.eggPicShake and m.eggPicShake[3] and m.eggPicShake[3][7], -2, "AlmostReadyToHatch step 7 is -2") + local c = m.moveTextColors and m.moveTextColors[3] + check(c and c.fg[1] > 200 and c.fg[2] < 40, "PP colour 3 is red") + eq(m.coords and m.coords.status and m.coords.status.y, 34, "status is stored as a top-left") +end + +print("[test] 3. markings sheet") +local marks = baked("markings.rgba") +eq(marks and #marks, 32 * 128 * 4, "sixteen 32x8 marking combos") +if marks then + local lit0, lit5 = 0, 0 + for i = 0, 32 * 8 - 1 do + if marks:byte(i * 4 + 4) > 0 then lit0 = lit0 + 1 end + if marks:byte((5 * 256 + i) * 4 + 4) > 0 then lit5 = lit5 + 1 end + end + check(lit0 > 0 and lit5 > 0, "combos have pixels") +end + +print("[test] 4. the imported cache") +local Cache = require("tests.game3_cache") +local root = Cache.root("meta.json") +if not root then + print("[skip] " .. tostring(Cache.reason)) +else + for _, name in ipairs({ "bg3_info.rgba", "layer_moves.rgba", "progress_moves_info_select.rgba", "markings.rgba" }) do + local d = slurp(root .. "/pokemon/summary/" .. name) + check(d ~= nil and d == baked(name), "the cache's " .. name .. " is the current bake") + end +end + +T.finish("game3_summary_chrome_bake_s3_test") diff --git a/tests/game3_warp_behaviors_test.lua b/tests/game3_warp_behaviors_test.lua index 33b123b9..bda268f1 100644 --- a/tests/game3_warp_behaviors_test.lua +++ b/tests/game3_warp_behaviors_test.lua @@ -125,8 +125,12 @@ else check(Collision.arrivalFacing(beh, "left") == want, string.format("landing on 0x%02X faces %s", beh, want)) end - check(Collision.arrivalFacing(MB.LADDER, "left") == "down", - "a ladder lands facing south") + check(Collision.arrivalFacing(MB.LADDER, "left") == "left", + "a ladder keeps the direction the player left with") + check(Collision.arrivalFacing(MB.LADDER, "up") == "up", + "a ladder reached walking up keeps facing up") + check(Collision.arrivalFacing(MB.LADDER, nil) == "down", + "a ladder with no stored direction faces south") check(Collision.arrivalFacing(0x00, "left") == "down", "hasDirectionSet is cleared, so a plain cell faces south") check(Collision.arrivalFacing(MB.FALL, "up") == "down",