From 1850b50ee605b686edaf9a3c9baa8f6ab637d986 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 25 Aug 2026 07:55:52 -0400 Subject: [PATCH] CLOSES #941, CLOSES #1081, CLOSES #1349, CLOSES #1602, CLOSES #1618, CLOSES #1624, CLOSES #1630, CLOSES #1633, CLOSES #1661, CLOSES #1669, CLOSES #1676, CLOSES #1731, CLOSES #1739, CLOSES #1740, CLOSES #1742, CLOSES #1744, CLOSES #1753, CLOSES #1757, CLOSES #1758, CLOSES #1760, CLOSES #1761, CLOSES #1762, CLOSES #1763, CLOSES #1765, CLOSES #1770, CLOSES #1777, CLOSES #1781, CLOSES #1783, CLOSES #1784, CLOSES #1785, CLOSES #1787, CLOSES #1788, CLOSES #1789, CLOSES #1790, CLOSES #1792, CLOSES #1797, CLOSES #1803 --- data/scripts/celadon_eevee.lua | 46 ++++---- data/scripts/flavor/celadon_city.lua | 6 +- data/scripts/flavor/celadon_mansion_1f.lua | 18 +-- data/scripts/flavor/lavender_cubone_house.lua | 5 +- data/scripts/flavor/mr_fujis_house.lua | 9 +- data/scripts/flavor/route_16_fly_house.lua | 5 +- data/scripts/story.lua | 1 + data/scripts/story4.lua | 14 ++- main.lua | 40 ++++++- src/battle/BattleState.lua | 105 +++++++++++++----- src/battle/Damage.lua | 38 +++++-- src/battle/MoveEffects.lua | 11 ++ src/battle/TurnOrder.lua | 10 +- src/battle/WideBattle.lua | 15 ++- src/battle/gen2/Battle.lua | 71 ++++++++++-- src/battle/gen2/Effects.lua | 8 ++ src/battle/gen2/Prize.lua | 7 +- src/battle/rulesets/gen1_faithful.lua | 2 + src/core/BattleCheckpoint.lua | 2 +- src/core/FixedStep.lua | 20 +++- src/core/Game.lua | 4 +- src/core/Game2.lua | 4 + src/core/SaveData.lua | 9 +- src/core/TouchControls.lua | 4 - src/core/Version.lua | 2 +- src/core/chip_worker.lua | 11 +- src/core/gen2/MomShopping.lua | 14 +-- src/core/gen2/Save.lua | 10 +- src/import/CacheContract.lua | 2 +- src/import/LauncherSettings.lua | 36 ++---- src/import/RomExtractorGen2.lua | 19 +++- src/inventory/ItemEffects.lua | 5 + src/render/Renderer.lua | 2 +- src/script/Commands.lua | 29 ++++- src/script/gen2/CallAsm.lua | 64 ++++++++++- src/ui/Diploma.lua | 49 +++++--- src/ui/EvolutionState.lua | 19 +++- src/ui/OptionsMenu.lua | 26 ++--- src/ui/PlayerPC.lua | 32 +++--- src/ui/SkinStudio.lua | 4 +- src/ui/TownMap.lua | 13 +-- src/ui/gen2/BattleState.lua | 29 ++++- src/ui/gen2/ItemPcMenu.lua | 38 ++++--- src/ui/gen2/TitleState.lua | 19 +++- src/world/OverworldController.lua | 20 +++- src/world/gen2/World.lua | 8 +- .../drivers/fill_extended_auto_menu_test.lua | 12 +- .../exp_traded_ot_survives_reload_bug1265.lua | 2 +- tests/parity_trade_gift.lua | 18 +-- tests/pet_cries_test.lua | 12 +- 50 files changed, 659 insertions(+), 290 deletions(-) diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index d7982e97..c34e38d2 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" } local function linkCableHelp(game) local text = game.data.text or {} - local items, showMenu, askHeading - function showMenu() - game.stack:push(Menu.new(game, items, - { tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 })) - end - function askHeading() - game.stack:push(TextBox.new(game, - text._LinkCableHelpText2 or "Which heading do\nyou want to read?", - showMenu)) + local items, menu, openMenu + local function closeAll() + game.stack:pop() end items = {} for i, label in ipairs(LINK_HEADINGS) do - items[i] = { label = label, onSelect = function() + items[i] = { label = label, keepOpen = true, onSelect = function() game.stack:push(TextBox.new(game, - text["_LinkCableInfoText" .. i] or label, askHeading)) + text["_LinkCableInfoText" .. i] or label)) end } end - items[#items + 1] = { label = "STOP READING" } + items[#items + 1] = { label = "STOP READING", onSelect = closeAll } + menu = Menu.new(game, items, + { tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2, + onCancel = closeAll }) + function openMenu() game.stack:push(menu) end game.stack:push(TextBox.new(game, text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable", - askHeading)) + function() + game.stack:push(TextBox.new(game, + text._LinkCableHelpText2 or "Which heading do\nyou want to read?", + nil, { stay = { onShown = openMenu } })) + end)) end return { @@ -84,21 +86,15 @@ return { { "jump_if_false", 5 }, -- 2 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves) - { "jump", 13 }, -- 4 - { "give_pokemon", "EEVEE", 25 }, -- 5 - { "jump_if_false", 12 }, -- 6 (party+box full) - -- flag + HideObject before the jingle and GotMonText: the nickname - -- prompt inside give_pokemon and the text row both yield, and a - -- script that dies there would leave the EEVEE taken with the ball - -- still on the table and the gift claimable again (#426). The row - -- count is unchanged, so the numeric jump targets still hold. + { "jump", 11 }, -- 4 + { "give_pokemon", "EEVEE", 25, false, true }, -- 5 + { "jump_if_false", 10 }, -- 6 (party+box full) + -- scripts/CeladonMansionRoofHouse.asm:17-20 (#426) { "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 - { "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle) - { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 - { "jump", 13 }, -- 11 - { "show_text", "_BoxIsFullText" }, -- 12 + { "jump", 11 }, -- 9 + { "show_text", "_BoxIsFullText" }, -- 10 }, }, } diff --git a/data/scripts/flavor/celadon_city.lua b/data/scripts/flavor/celadon_city.lua index 5d0cb328..06add996 100644 --- a/data/scripts/flavor/celadon_city.lua +++ b/data/scripts/flavor/celadon_city.lua @@ -2,12 +2,10 @@ return { CELADON_CITY = { talk = { - -- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far - -- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONCITY_POLIWRATH = { {"face_player"}, + -- pokered/scripts/CeladonCity.asm:90 + {"play_cry", "POLIWRATH", true}, {"show_text", "_CeladonCityPoliwrathText"}, }, }, diff --git a/data/scripts/flavor/celadon_mansion_1f.lua b/data/scripts/flavor/celadon_mansion_1f.lua index 9e8c3f3b..3e77b45c 100644 --- a/data/scripts/flavor/celadon_mansion_1f.lua +++ b/data/scripts/flavor/celadon_mansion_1f.lua @@ -2,30 +2,24 @@ return { CELADON_MANSION_1F = { talk = { - -- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far - -- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONMANSION1F_CLEFAIRY = { {"face_player"}, + -- pokered/scripts/CeladonMansion1F.asm:27 + {"play_cry", "CLEFAIRY", true}, {"show_text", "_CeladonMansion1FClefairyText"}, }, - -- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far - -- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONMANSION1F_MEOWTH = { {"face_player"}, + -- pokered/scripts/CeladonMansion1F.asm:18 + {"play_cry", "MEOWTH", true}, {"show_text", "_CeladonMansion1FMeowthText"}, }, - -- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far - -- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends. - -- The cry playback has no port-side equivalent command, so we just - -- show the flavor line. TEXT_CELADONMANSION1F_NIDORANF = { {"face_player"}, + -- pokered/scripts/CeladonMansion1F.asm:33 + {"play_cry", "NIDORAN_F", true}, {"show_text", "_CeladonMansion1FNidoranFText"}, }, }, diff --git a/data/scripts/flavor/lavender_cubone_house.lua b/data/scripts/flavor/lavender_cubone_house.lua index a5981cbe..54aa3d04 100644 --- a/data/scripts/flavor/lavender_cubone_house.lua +++ b/data/scripts/flavor/lavender_cubone_house.lua @@ -4,11 +4,10 @@ return { LAVENDER_CUBONE_HOUSE = { talk = { - -- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText, - -- then text_asm plays the CUBONE cry. Cry playback has no Commands - -- equivalent in this port, so just show the line. + -- LavenderCuboneHouse.asm:10 TEXT_LAVENDERCUBONEHOUSE_CUBONE = { { "face_player" }, + { "play_cry", "CUBONE", true }, { "show_text", "_LavenderCuboneHouseCuboneText" }, }, diff --git a/data/scripts/flavor/mr_fujis_house.lua b/data/scripts/flavor/mr_fujis_house.lua index 6e1a7ae0..4022add5 100644 --- a/data/scripts/flavor/mr_fujis_house.lua +++ b/data/scripts/flavor/mr_fujis_house.lua @@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = { { "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6 }, - -- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then - -- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just - -- show the flavor text. + -- scripts/MrFujisHouse.asm:56 TEXT_MRFUJISHOUSE_PSYDUCK = { + { "play_cry", "PSYDUCK", true }, { "show_text", "_MrFujisHousePsyduckText" }, }, - -- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then - -- PlayCry(NIDORINO). + -- scripts/MrFujisHouse.asm:63 TEXT_MRFUJISHOUSE_NIDORINO = { + { "play_cry", "NIDORINO", true }, { "show_text", "_MrFujisHouseNidorinoText" }, }, }, diff --git a/data/scripts/flavor/route_16_fly_house.lua b/data/scripts/flavor/route_16_fly_house.lua index 563c1a30..6f348382 100644 --- a/data/scripts/flavor/route_16_fly_house.lua +++ b/data/scripts/flavor/route_16_fly_house.lua @@ -4,10 +4,9 @@ return { ROUTE_16_FLY_HOUSE = { talk = { - -- Route16FlyHouseFearowText: text_asm just prints the one line and - -- plays the FEAROW cry (no cry-playback command exists in this - -- port's Commands vocabulary, so only the text is ported). + -- scripts/Route16FlyHouse.asm:45-51 TEXT_ROUTE16FLYHOUSE_FEAROW = { + { "play_cry", "FEAROW", true }, { "show_text", "_Route16FlyHouseFearowText" }, }, }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 372a1836..ec5435a2 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1047,6 +1047,7 @@ local championsRoomRivalScript = { -- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past -- it, so a returning champion warped straight into the induction. { "jump_if_true", "end" }, -- 3 + { "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57 { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 -- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after -- the intro text, before the battle itself (#706); pushBattle's wipe-time diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 18c6658c..8069bf09 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -359,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = { TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done) local t = text(game) if game.save.flags.EVENT_GOT_HM02 then - push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText - or "HM02 is FLY!\fIt will whisk you\nback to any town!", done) + push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText + or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done) return end push(game, t._Route16FlyHouseBrunetteGirlText - or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function() + or "Oh, you found my\nsecret retreat!", function() if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then - push(game, "You don't have\nroom for HM02!", done) + push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText + or "You don't have any\nroom for this.", done) return end game.save.flags.EVENT_GOT_HM02 = true - push(game, ("%s got\nHM02!"):format(game.save.player.name), done) + -- scripts/Route16FlyHouse.asm:32-35 + push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text + or "{PLAYER} received\nHM02!", { player = game.save.player.name }), + done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item")) end) end, }, diff --git a/main.lua b/main.lua index 8220c9c2..fe9a6357 100644 --- a/main.lua +++ b/main.lua @@ -1170,6 +1170,17 @@ function love.run() -- per-frame sleep-granularity jitter. local nextFrame = love.timer and love.timer.getTime() or 0 local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } return function() -- process events @@ -1188,17 +1199,34 @@ function love.run() return a or 0 end end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end love.handlers[name](a, b, c, d, e, f) end end -- update dt if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt -- call update and draw if love.update then love.update(dt) end - if love.graphics and love.graphics.isActive() then + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + end + + if visible and love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw() end @@ -1209,9 +1237,9 @@ function love.run() if paced then -- Sleep out the remainder of the frame budget, measured from the -- carried deadline, in small chunks so the OS timer stays - -- responsive. vsync is untouched: when it already paces slower - -- than the cap the remainder is <= 0 and this rounds to a no-op. - local budget = 1 / FrameCap.current + -- responsive. The pacer yields to vsync inside a 1ms dead band, so + -- when the panel already paces at or below the cap it is a no-op. + local budget = 1 / cap nextFrame = nextFrame + budget local now = love.timer.getTime() -- A stall (alt-tab, a GC pause, a blocked import) can leave the @@ -1222,8 +1250,8 @@ function love.run() end while true do local remaining = nextFrame - love.timer.getTime() - if remaining <= 0 then break end - love.timer.sleep(remaining < 0.001 and remaining or 0.001) + if remaining <= SLEEP_FLOOR then break end + love.timer.sleep(0.001) end else love.timer.sleep(0.001) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 300cc345..f2a4d0ae 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -97,7 +97,7 @@ end -- space. function BattleState:extendedHUD() local options = self.game and self.game.save and self.game.save.options - local bg = options and options.battleBg + local bg = options and self:bgMode() return self:wideLayout() and options and options.battleHud == "extended" and ((options.battleFit == "fixed" @@ -108,12 +108,12 @@ end function BattleState:extendedWorldHUD() local options = self.game and self.game.save and self.game.save.options return self:extendedHUD() and options - and options.battleFit == "fixed" and options.battleBg == "world" + and options.battleFit == "fixed" and self:bgMode() == "world" end function BattleState:extendedBlackHUD() local options = self.game and self.game.save and self.game.save.options - return self:extendedHUD() and options and options.battleBg == "black" + return self:extendedHUD() and options and self:bgMode() == "black" end -- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids @@ -130,6 +130,10 @@ end -- opaque 160x144 field over the top, so only the surround changes. function BattleState:bgMode() local options = self.game and self.game.save and self.game.save.options + if options and self:wideLayout() and options.battleFit == "fill" + and options.battleHud == "extended" then + return "white" + end local mode = options and options.battleBg if mode == "black" or mode == "world" then return mode end return "white" @@ -212,6 +216,13 @@ local BALL_ANIMS = { SHAKE_ANIM = true, SHOWPIC_ANIM = true, } +-- engine/battle/effects.asm:552 +local ENEMY_STAT_DOWN_MISS = { + ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, + DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true, + ACCURACY_DOWN1_EFFECT = true, +} + local imageCache = {} -- The three tables below are keyed by the Image OBJECT, not by a path, and a -- running battle holds the pics it built at enter() (battler.sprite, @@ -229,6 +240,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS) -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it local imageMeta = setmetatable({}, WEAK_KEYS) +local function mattedPic(path) + return path:sub(1, 17) == "assets/generated/" + or path:sub(1, 17) == "save/mod-derived/" +end + -- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy. -- trueColor art (14 §the 4-shade contract) opts out of the quantize -- entirely, so its palette variant collapses back onto the plain path. @@ -254,29 +270,31 @@ local function getImage(path, pal, trueColor) end) end local w, h = id:getDimensions() - local bottom = h - 1 - while bottom >= 0 do - local opaque = false - for x = 0, w - 1 do - local _, _, _, a = id:getPixel(x, bottom) - if a > 0 then opaque = true break end + if mattedPic(Assets.resolve(path)) then + local bottom = h - 1 + while bottom >= 0 do + local opaque = false + for x = 0, w - 1 do + local _, _, _, a = id:getPixel(x, bottom) + if a > 0 then opaque = true break end + end + if opaque then break end + bottom = bottom - 1 end - if opaque then break end - bottom = bottom - 1 - end - local left = 0 - while left < w do - local opaque = false - for y = 0, h - 1 do - local _, _, _, a = id:getPixel(left, y) - if a > 0 then opaque = true break end + local left = 0 + while left < w do + local opaque = false + for y = 0, h - 1 do + local _, _, _, a = id:getPixel(left, y) + if a > 0 then opaque = true break end + end + if opaque then break end + left = left + 1 end - if opaque then break end - left = left + 1 + pad = h - 1 - bottom + padL = left end img = love.graphics.newImage(id) - pad = h - 1 - bottom - padL = left else img = Assets.image(path) -- headless stub: no pixel access end @@ -2214,6 +2232,9 @@ function BattleState:update(dt) self.waitFrames = nil if destination == "menu" then self.introSlide = nil + -- engine/battle/core.asm:2007 + self.msgHold = nil + self.shown = nil self.phase = "menu" elseif destination == "finish" then self:finish() @@ -3041,18 +3062,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1) local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2) local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette -local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB) +local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4 +local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9 + +-- engine/battle/animations.asm:1090 +local function onSgb() + local m = require("src.render.PaletteFX").mode + return m == "gbc" or m == "gbc_inv" +end -- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle, -- 3 cycles; the first cycle holds each for 2 frames, the rest for 1 -- (FlashScreenLongDelay) -local FLASH_LONG_MAPS = { +local FLASH_LONG_SGB = { { [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 }, } +-- engine/battle/animations.asm:992 +local FLASH_LONG_MONO = { + { [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 }, + { [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 }, + { [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 }, + { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 }, +} + -- the shade map in force this frame (a running flash wins over the -- persistent palette) function BattleState:activeBgp() @@ -3186,7 +3222,7 @@ function BattleState:applyAnimEffect(ev) elseif e == "SE_LIGHT_SCREEN_PALETTE" then fx.bgp = BGP_LIGHT elseif e == "SE_DARKEN_MON_PALETTE" then - fx.bgp = BGP_DARKEN + fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO elseif e == "SE_RESET_SCREEN_PALETTE" then fx.bgp = nil elseif e == "SE_DARK_SCREEN_FLASH" then @@ -3196,8 +3232,9 @@ function BattleState:applyAnimEffect(ev) idx = 1, left = 2 } elseif e == "SE_FLASH_SCREEN_LONG" then local steps = {} + local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO for cycle = 1, 3 do - for _, m in ipairs(FLASH_LONG_MAPS) do + for _, m in ipairs(maps) do steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 } end end @@ -4139,6 +4176,12 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- pure status moves if move.power == 0 and record and record.kind == "primary" and record.run then + if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer + and self.kind ~= "link" and self.rng(0, 255) < 64 then + self:cancelMoveAnim() + self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user))) + return + end -- accuracy-checked status effects run MoveHitTest, which has no -- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- roll) and misses outright against a mid-Fly/Dig target; the @@ -4417,7 +4460,11 @@ function BattleState:awardExp() -- new current HP (house convention: potions drain the bar too, see -- itemUsed) so the bar grows instead. Only the active player battler -- shares its table with the HUD; other party mons (EXP.ALL) have no bar. - if mon == self.player.mon then self:drainNext() end + if mon == self.player.mon then + -- engine/battle/experience.asm:236 + self.player.badgeExtraBoosts = nil + self:drainNext() + end for _, moveId in ipairs(Experience.movesLearnedAt( self.data.pokemon[mon.species], lv)) do self:learnMove(mon, moveId) @@ -5920,7 +5967,9 @@ function BattleState:animSpriteColors(s, px, py) local P -- engine/battle/animations.asm:551 (.notSGB) if PaletteFX.usesSpriteObp() then - P = PaletteFX.ogObj() + -- engine/battle/init_battle_variables.asm:18 + P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE + or PaletteFX.GBC_OBJ if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end else P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4)) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 894bfd44..929fff89 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -41,6 +41,34 @@ local function badgeBoost(battler, stat) return nil end +-- engine/battle/core.asm:6454 +function Damage.applyBadgeBoost(battler, stat, value) + if battler.hazeStatReset then return value end + local row = badgeBoost(battler, stat) + if not row then return value end + local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0 + for _ = 1, 1 + extra do + value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8))) + end + return value +end + +-- engine/battle/effects.asm:498,689 +function Damage.reapplyBadgeBoosts(battler, changedStat) + if not battler or not battler.badges then return end + local extra = battler.badgeExtraBoosts + if not extra then extra = {} battler.badgeExtraBoosts = extra end + for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do + if battler.badges[row.badge] then + if row.stat == changedStat then + extra[row.stat] = 0 + else + extra[row.stat] = (extra[row.stat] or 0) + 1 + end + end + end +end + -- the merged status record for a battler's persistent condition, or nil local function statusRecord(battler) return Status.recordFor(battler.statuses, battler.mon.status) @@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Volcano -> special - local atkBoost = badgeBoost(attacker, atkStat) - if atkBoost then - atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8)) - end - local defBoost = badgeBoost(defender, defStat) - if defBoost then - dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8)) - end + atk = Damage.applyBadgeBoost(attacker, atkStat, atk) + dfn = Damage.applyBadgeBoost(defender, defStat, dfn) -- burn halves physical attack (applied as part of the stat in Gen 1; -- the status record's statPenalty names the stat it cuts). -- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 39d96387..d6abf537 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -11,6 +11,7 @@ -- is the registry view of all three -- the merged Data.move_effects a -- battle dispatches on serves these same objects. +local Damage = require("src.battle.Damage") local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") @@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy) -- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run, -- re-baking the burn/para penalty and ending Haze's temporary lift. who.hazeStatReset = nil + if battle.ruleset and battle.ruleset.badgeBoostReapplyBug + and battle.kind ~= "link" and who == battle.player then + Damage.reapplyBadgeBoosts(who, stat) + end -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- two-stage variants scroll "greatly" onto a third line local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811) @@ -132,6 +137,11 @@ end local function statDownSide(stat) return function(battle, user, target) + -- engine/battle/effects.asm:552 + if not user.isPlayer and battle.kind ~= "link" + and battle.rng(0, 255) < 64 then + return {} + end if target.substituteHP then return {} end if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256) -- StatModifierDownEffect's side-effect branch never runs MoveHitTest, @@ -254,6 +264,7 @@ MoveEffects.primary = { -- Attack-halving and paralysis Speed-quartering on BOTH battlers -- until the next stat recompute (a stage change or switch-in). b.hazeStatReset = true + b.badgeExtraBoosts = nil end -- Gen 1 also removes the enemy's major status; if that cured sleep -- or freeze, the target forfeits its move this turn (haze.asm diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index 499093e8..d681ecf5 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -14,15 +14,7 @@ local function effectiveSpeed(battler) battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback - local badges = battler.badges - if badges then - for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do - if row.stat == "speed" and badges[row.badge] then - spd = math.floor(spd * (row.num or 9) / (row.den or 8)) - break - end - end - end + spd = Damage.applyBadgeBoost(battler, "speed", spd) -- paralysis quarters speed (the status record's statPenalty); -- hazeStatReset suppresses it because Haze (haze.asm ResetStats) -- copied the unmodified speed over the quartered battle stat, lifting diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index ad459574..32508356 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -97,6 +97,19 @@ local function battleIsTopState(battle) return not (stack and stack.top) or stack:top() == battle end +-- engine/menus/party_menu.asm:4 +local function coveredByOpaqueState(battle) + local stack = battle.game and battle.game.stack + local states = stack and stack.states + if not states then return false end + local above = false + for i = 1, #states do + if above and states[i] and states[i].isOpaque then return true end + if states[i] == battle then above = true end + end + return false +end + local function anchorHUD(battle, x, y, w, h, anchor) if not battle:extendedHUD() or not battleIsTopState(battle) then return end local renderer = battle.game and battle.game.renderer @@ -351,7 +364,7 @@ function WideBattle.draw(battle) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) end -- AskName clears the field the same way the classic layout does - if battle.blankForAskName then return end + if battle.blankForAskName or coveredByOpaqueState(battle) then return end local fx = battle.fx local sx = (fx and fx.shakeX) or 0 diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 2b0b0543..b3efe15b 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -645,6 +645,7 @@ function Battle:smartAiState() -- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since -- BattleCommand_LockOn sets the bit on the target it was aimed at. playerLockOn = playerState.lockOn or nil, + playerIdentified = playerState.identified or nil, playerPhysicalMoves = physical, enemyRage = enemyState.rage, enemyRageCount = enemyState.rageCount, @@ -1082,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts) local attackerStages = self.stages[self:sideOf(attacker)] local defenderStages = self.stages[self:sideOf(defender)] local types = self.data.type_chart and self.data.type_chart.types - local matchups = self.data.type_chart and self.data.type_chart.matchups + local matchups = self:matchupsAgainst(defender) local heldEffect, heldParam = self:heldEffect(attacker, "damage") -- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a @@ -1202,6 +1203,8 @@ function Battle:hitOnce(attacker, defender, def, opts) if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then damage = math.max(0, (defender.hp or 0) - 1) end + -- engine/battle_anims/anim_commands.asm:1200 + if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end return self:dealDamage(attacker, defender, damage, { critical = critical, effectiveness = info.effectiveness, -- Counter answers physical damage and Mirror Coat special, so what kind @@ -1690,6 +1693,13 @@ function Battle:useMove(attacker, defender, moveId) text = ("Magnitude %d!"):format(number) }) end + -- data/moves/effects.asm:1607, :1649 + if def.effect == "EFFECT_RETURN" then + powerOverride = Effects.happinessPower(attacker.happiness) + elseif def.effect == "EFFECT_FRUSTRATION" then + powerOverride = Effects.happinessPower(attacker.happiness, true) + end + if not sureHit and not self:accuracyRoll(def, attacker, defender) then -- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and @@ -1744,7 +1754,7 @@ function Battle:useMove(attacker, defender, moveId) -- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG. local defenderTypes = (self:speciesDef(defender) or {}).types or defender.types - local matchups = self.data.type_chart and self.data.type_chart.matchups + local matchups = self:matchupsAgainst(defender) if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then self:markMissed() self:emit({ kind = "message", @@ -2119,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender) text = self:monName(attacker) .. " took aim!" }) end +-- engine/battle/move_effects/foresight.asm +Battle.MOVE_EFFECTS.EFFECT_FORESIGHT = function(self, attacker, defender, + def, _, sureHit) + if not sureHit + and not self:accuracyRoll(def, attacker, defender) then + return fail(self) + end + local target = self:volatile(defender) + if target.vanished or target.identified then return fail(self) end + target.identified = true + self:emit({ kind = "message", + text = self:monName(attacker) .. " identified " + .. self:monName(defender) .. "!" }) +end + -- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next -- move aimed at the mon carrying it, whether or not that move was the one the -- lock-on was meant for, and whether or not the exception at :1683-1688 then @@ -2166,9 +2191,15 @@ function Battle:accuracyRoll(def, attacker, defender, accuracy) end function Battle:vanillaAccuracyRoll(accuracy, attacker, defender) - return Damage.rollHit(self:moveAccuracy(accuracy, defender), - self.stages[self:sideOf(attacker)].accuracy, - self.stages[self:sideOf(defender)].evasion, self.random) + local acc = self.stages[self:sideOf(attacker)].accuracy + local eva = self.stages[self:sideOf(defender)].evasion + -- engine/battle/effect_commands.asm:1786 + if defender and self:volatile(defender).identified + and (eva or 0) >= (acc or 0) then + acc, eva = 0, 0 + end + return Damage.rollHit(self:moveAccuracy(accuracy, defender), acc, eva, + self.random) end -- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE @@ -2354,7 +2385,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def stages = self.stages[self:sideOf(defender)], }, types = self.data.type_chart and self.data.type_chart.types, - matchups = self.data.type_chart and self.data.type_chart.matchups, + matchups = self:matchupsAgainst(defender), random = self.random, }) state.futureSight = Effects.FUTURE_SIGHT_TURNS @@ -3072,6 +3103,30 @@ function Battle:safeguarded(mon) return (self.screens[self:sideOf(mon)].safeguard or 0) > 0 end +-- engine/battle/effect_commands.asm:1305 +function Battle:matchupsAgainst(defender) + local chart = self.data.type_chart + local rows = chart and chart.matchups + if not rows or not defender then return rows end + if not self:volatile(defender).identified then return rows end + local skipped = chart.foresightMatchups + if not skipped or #skipped == 0 then return rows end + if not self.identifiedMatchups then + local drop = {} + for _, row in ipairs(skipped) do + drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] = true + end + local out = {} + for _, row in ipairs(rows) do + if not drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] then + out[#out + 1] = row + end + end + self.identifiedMatchups = out + end + return self.identifiedMatchups +end + -- `source` is the battler that inflicted it, carried only so -- battle.status_inflicted can name it the way Gen 1's does. -- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup, @@ -3084,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status) end local types = (self:speciesDef(defender) or {}).types or defender.types or {} if moveType then - local matchups = self.data.type_chart and self.data.type_chart.matchups + local matchups = self:matchupsAgainst(defender) if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end end if status == "poison" or status == "toxic" then @@ -3917,6 +3972,8 @@ end -- check. Split out because playerAttack needs the same answer. function Battle:lockedInMove(mon) local state = self:volatile(mon) + -- engine/battle/core.asm:543 + if state.chargeMove then return state.chargeMove end if state.rolloutLock then return state.rolloutLock end if state.rampageMove and (state.rampageTurns or 0) > 0 then return state.rampageMove diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index a36f8ea3..2e2b87a4 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -284,6 +284,14 @@ function Effects.magnitudePower(random) return last[2], last[3] end +-- engine/battle/move_effects/return.asm:1-24, frustration.asm:1-25 +function Effects.happinessPower(happiness, frustration) + local h = happiness or 0 + if h < 0 then h = 0 elseif h > 255 then h = 255 end + if frustration then h = 255 - h end + return math.floor(h * 10 / 25) +end + -- ------------------------------------------------------------------- weather -- -- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to diff --git a/src/battle/gen2/Prize.lua b/src/battle/gen2/Prize.lua index 54fef6ae..cdd92e95 100644 --- a/src/battle/gen2/Prize.lua +++ b/src/battle/gen2/Prize.lua @@ -58,9 +58,12 @@ Prize.AMULET_COIN = "AMULET_COIN" -- data/text/battle.asm. Declared here and formatted at the call site so -- Strings.source is what registers them, the same way Decorations declares -- its own five. No line markers: every battle message in this port is one --- flowing string that Chrome.wrap breaks to the box. +-- flowing string that Chrome.wrap breaks to the box, except SentSomeToMomText, +-- which keeps the cart's own `line`/`cont` breaks because it does not fit two +-- rows. local GOT_MONEY = Strings.source("%s got %s%d for winning!") -local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!") +-- data/text/battle.asm:179-185 +local SENT_SOME = Strings.source("%s got %s%d\nfor winning!\vSent some to MOM!") -- The half and all texts really are this short on the cart: they replace the -- money line rather than following it, which is a quirk no Gold player can -- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F. diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 68f171ab..7c0c57ba 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -23,4 +23,6 @@ return { -- (core.asm:426-464): poison/burn/leech seed tick before the slower -- mon acts, not in an end-of-round sweep like Gen 3+. residualAfterMove = true, + -- engine/battle/effects.asm:498,689 + badgeBoostReapplyBug = true, } diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index b933bd7c..8a62eb4e 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -27,7 +27,7 @@ local BATTLER_FIELDS = { "chargeReady", "invulnerable", "mustRecharge", "thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded", "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", - "skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns", + "skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns", "trapMove", "trapDamage", "fainted", "aiLayer2", } diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 792df70a..6a03da6a 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -6,6 +6,9 @@ local FixedStep = {} FixedStep.STEP = 1 / 60 local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall +local SMOOTH_FRAMES = 4 +local SMOOTH_MAX = 1 / 60 * 2.5 +local STEP_EPS = 1 / 60 * 0.02 -- Phase the accumulator is re-seeded with once an absorbed hitch frame has -- been paid for. Half a step is the balanced point: a frame has to come in @@ -23,6 +26,7 @@ function FixedStep:init(callback) self.accum = 0 self.callback = callback self.suppressCatchup = false + self.dtHistory, self.dtSum = nil, 0 end -- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 @@ -39,12 +43,26 @@ function FixedStep:update(dt) -- the burst it would otherwise release doesn't play out as a slide. if self.suppressCatchup then self.suppressCatchup = false + self.dtHistory, self.dtSum = nil, 0 self.accum = self.STEP * RESEED_PHASE self.callback(self.STEP) return end + if dt > 0 and dt <= SMOOTH_MAX then + local hist = self.dtHistory + if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end + hist[#hist + 1] = dt + self.dtSum = self.dtSum + dt + if #hist > SMOOTH_FRAMES then + self.dtSum = self.dtSum - hist[1] + table.remove(hist, 1) + end + dt = self.dtSum / #hist + else + self.dtHistory, self.dtSum = nil, 0 + end self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) - while self.accum >= self.STEP do + while self.accum >= self.STEP - STEP_EPS do self.accum = self.accum - self.STEP self.callback(self.STEP) end diff --git a/src/core/Game.lua b/src/core/Game.lua index 2cc890d7..ce95bea9 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1324,9 +1324,7 @@ function Game:restoreSave(loaded, recovered, opts) self:adoptSave(loaded) -- SaveData.load already attached the standalone options.lua table self:applyOptions(loaded.options) - -- saves from before OT/ID stamping: backfill with the player's (after - -- the scrub, so every mon the stamp loop sees is known) - SaveData.repairTradedOtIds(loaded) + -- saves from before OT/ID stamping: backfill with the player's local stamp = require("src.battle.BattleState").stampOT for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, box in ipairs(loaded.boxes or {}) do diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22d914ff..252c2a37 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -350,6 +350,10 @@ function Game2:showTitle() onContinue = function() self:showMainMenu() end, + -- engine/menus/intro_menu.asm:848-889 + onTimeout = function() + self:showCopyright() + end, }) end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7263d2b0..dde0f760 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1983,6 +1983,11 @@ SaveData.addCoreMigration(3, function(save) end end) +-- #1461 repair, one-shot on pre-format-5 saves +SaveData.addCoreMigration(4, function(save) + SaveData.repairTradedOtIds(save) +end) + -- ------- write -- Game progress only; options are written separately via saveOptions. @@ -2394,8 +2399,8 @@ function SaveData.applyPostGameHome(save, boot) end -- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved --- it, which reads back as home-caught. A caught mon can never carry --- traded=true, so clearing that pair is safe on every load. #1461 +-- it, which reads back as home-caught. Run only as the pre-format-5 +-- migration: a same-id trade partner makes that pair legitimate. #1461 function SaveData.repairTradedOtIds(save) local playerId = save and save.player and save.player.id if playerId == nil then return 0 end diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 50c28048..3df41e2c 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) local abW = dpadW * 0.46 local ssW = dpadW * 0.30 local margin = dpadW * 0.12 - local ok, GameVersion = pcall(require, "src.core.GameVersion") - if ok and GameVersion.generation and GameVersion.generation() == 2 then - margin = math.max(margin, math.min(ww * 0.10, 72)) - end return { dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW }, a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW }, diff --git a/src/core/Version.lua b/src/core/Version.lua index 0424bc82..184d8431 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -18,7 +18,7 @@ local Version = { -- exceeds the shell it provides. modApi = 2, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) - saveFormat = 4, -- save.meta.format + saveFormat = 5, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 21c59f8b..88de0199 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -89,10 +89,12 @@ local function handle(cmd) return false end +local idleWait = false + while true do -- drain every pending command first, so a stop/new-play is seen promptly local quit = false - local cmd = cmdCh:pop() + local cmd = idleWait and cmdCh:demand(0.05) or cmdCh:pop() while cmd do if handle(cmd) then quit = true end cmd = cmdCh:pop() @@ -100,6 +102,7 @@ while true do if quit then break end if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then + idleWait = false local activeGen = gen local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2) if not ok then @@ -113,8 +116,10 @@ while true do finished = true end end + elseif engine and not finished and gen then + idleWait = false + love.timer.sleep(0.005) else - -- nothing to do (idle, or the look-ahead is full): yield the core - love.timer.sleep(0.001) + idleWait = true end end diff --git a/src/core/gen2/MomShopping.lua b/src/core/gen2/MomShopping.lua index a061f9b5..1af7cad0 100644 --- a/src/core/gen2/MomShopping.lua +++ b/src/core/gen2/MomShopping.lua @@ -164,14 +164,12 @@ local function receiveItemToPc(save, id, data) save.pcItems = save.pcItems or {} local pc = save.pcItems local held = pc[id] or 0 - if held == 0 then - local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY - local stacks = 0 - for _ in pairs(pc) do stacks = stacks + 1 end - if stacks >= cap then return false end - elseif held + 1 > MAX_STACK then - return false - end + local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY + local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end + local used = 0 + for _, count in pairs(pc) do used = used + stacksFor(count) end + -- engine/items/items.asm:156 PutItemInPocket + if used + stacksFor(held + 1) - stacksFor(held) > cap then return false end pc[id] = held + 1 return true end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index dd10851c..ad84293a 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -343,6 +343,7 @@ Save.OPTIONS_KEY = "gold" local SHARED_KEYS = { touchControls = true, haptics = true, screenPos = true, + videoMode = true, mods = true, modsByVersion = true, modsGen2 = true, modOptions = true, modProfiles = true, modProfilesSeeded = true, activeProfile = true, @@ -361,12 +362,9 @@ function Save.loadOptions(fs) end if type(loaded) == "table" then for key in pairs(SHARED_KEYS) do - if loaded[key] ~= nil then options[key] = loaded[key] end - end - end - if type(stored) == "table" then - for key in pairs(SHARED_KEYS) do - if options[key] == nil and stored[key] ~= nil then + if loaded[key] ~= nil then + options[key] = loaded[key] + elseif type(stored) == "table" and stored[key] ~= nil then options[key] = stored[key] end end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 1937cb21..e9f9bca9 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -10,7 +10,7 @@ local CacheContract = {} CacheContract.FORMAT = "rom-cache-v10:" CacheContract.VERSION_FORMAT = { - crystal = "rom-cache-v10-crystal2:", + crystal = "rom-cache-v10-crystal3:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 3739dd8a..ecba935c 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -23,6 +23,11 @@ local SaveData = require("src.core.SaveData") local LauncherSettings = {} +local function bgLocked(opts) + return opts.battleLayout == "wide" and opts.battleFit == "fill" + and opts.battleHud == "extended" +end + local function wrapIndex(i, n) i = i % n if i < 0 then i = i + n end @@ -156,8 +161,6 @@ local function coreRows(opts, hooks) opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide" if opts.battleLayout ~= "wide" then opts.battleHud = "standard" - elseif opts.battleFit == "fill" and opts.battleHud == "extended" then - opts.battleBg = "white" end return true end) @@ -167,10 +170,6 @@ local function coreRows(opts, hooks) end, function() opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill" - if opts.battleFit == "fill" and opts.battleLayout == "wide" - and opts.battleHud == "extended" then - opts.battleBg = "white" - end return true end) add(Strings("BATTLE HUD"), @@ -185,28 +184,17 @@ local function coreRows(opts, hooks) return false end opts.battleHud = opts.battleHud == "extended" and "standard" or "extended" - if opts.battleHud == "extended" and opts.battleFit == "fill" then - opts.battleBg = "white" - end return true end) add(Strings("BATTLE BG"), function() - if opts.battleLayout == "wide" and opts.battleFit == "fill" - and opts.battleHud == "extended" then - opts.battleBg = "white" - return Strings("AUTO") - end + if bgLocked(opts) then return Strings("AUTO (FILL HUD)") end if opts.battleBg == "black" then return Strings("BLACK") end if opts.battleBg == "world" then return Strings("WORLD") end return Strings("WHITE") end, function(dir) - if opts.battleLayout == "wide" and opts.battleFit == "fill" - and opts.battleHud == "extended" then - opts.battleBg = "white" - return false - end + if bgLocked(opts) then return false end local order = { "white", "black", "world" } local cur = 1 for i, mode in ipairs(order) do @@ -563,7 +551,7 @@ end -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. local GEN2_KEY = "gold" -local function gen2Rows(opts, hooks) +local function gen2Rows(opts, hooks, shared) local rows = {} local function add(label, value, step) rows[#rows + 1] = { label = label, value = value, step = step } @@ -669,9 +657,9 @@ local function gen2Rows(opts, hooks) local okVm, VideoMode = pcall(require, "src.core.VideoMode") if okVm then add(Strings("VIDEO MODE"), - function() return VideoMode.modeLabel(opts.videoMode) end, + function() return VideoMode.modeLabel(shared.videoMode) end, function(dir) - opts.videoMode = VideoMode.cycle(opts.videoMode, dir) + shared.videoMode = VideoMode.cycle(shared.videoMode, dir) return true end) end @@ -690,7 +678,7 @@ local function gen2Rows(opts, hooks) add(Strings("BATTLE BG"), ladder(opts, "battleBg", { { "white", "WHITE" }, { "black", "BLACK" } }, "white")) - addTouchRows(rows, add, opts, hooks) + addTouchRows(rows, add, shared, hooks) return rows end @@ -718,7 +706,7 @@ function LauncherSettings.open(hooks, version) opts[GEN2_KEY] = block end sections = { - { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) }, + { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks, opts) }, } else sections = { diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 151ffd71..2719dc76 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -151,6 +151,19 @@ local TEXT_BUFFERS = { [0xc602] = "wOTTrademonSpeciesName", [0xc618] = "wOTTrademonSenderName", } +-- ../pokecrystal/ram/wram.asm:1925,2333 +local TEXT_BUFFERS_CRYSTAL = { + [0xd050] = "wMonOrItemNameBuffer", + [0xd073] = "wStringBuffer1", + [0xd086] = "wStringBuffer2", + [0xd099] = "wStringBuffer3", + [0xd0ac] = "wStringBuffer4", + [0xd0bf] = "wStringBuffer5", + [0xc6d1] = "wPlayerTrademonSpeciesName", + [0xc6e7] = "wPlayerTrademonSenderName", + [0xc703] = "wOTTrademonSpeciesName", + [0xc719] = "wOTTrademonSenderName", +} -- The text commands that print nothing and carry no argument -- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL, -- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles. @@ -2295,6 +2308,8 @@ function RomExtractorGen2:extractTitle() trailBobAmplitude = silver and 3 or 2, trailPhaseStep = silver and 7 or 3, trailPhase = silver and 0 or nil, + -- TitleScreenTimer (engine/menus/intro_menu.asm:951-966). + timeoutFrames = silver and (73 * 60 + 36) or (84 * 60 + 16), } self:write("title", data) return data @@ -2805,6 +2820,8 @@ end -- is which alongside the text lets a caller fill them in order without -- changing a marker every screen already reads. function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers) + local textBuffers = (self.edition == "crystal") and TEXT_BUFFERS_CRYSTAL + or TEXT_BUFFERS local out = {} local i = 0 local hops = 0 @@ -2840,7 +2857,7 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers) out[#out + 1] = "{STRBUF}" if buffers then local target = self.rom:word(bank, address + i + 1) - buffers[#buffers + 1] = TEXT_BUFFERS[target] or target + buffers[#buffers + 1] = textBuffers[target] or target end i = i + 2 elseif b == 0x4e or b == 0x4f then diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 2915b4eb..f0ecadcd 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -310,6 +310,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "Nothing happened!") } end b.stages[stat] = cur + 1 + b.hazeStatReset = nil + if battle.ruleset and battle.ruleset.badgeBoostReapplyBug + and battle.kind ~= "link" then + require("src.battle.Damage").reapplyBadgeBoosts(b, stat) + end return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index fa9250df..6b956889 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -817,7 +817,7 @@ function Renderer:frameRects() -- GB pixel stops being a whole number of screen pixels, which is the trade -- the setting exists to offer. Clamped on the horizontal too, so a narrow -- window scales to fit instead of overflowing off both sides. - if self.uiFill then + if self.uiFill and not FaithfulRes.scaleCap() then Up = math.min(ph / uih, pw / uiw) end if uiw * Up > pw or uih * Up > ph then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 3646939a..627e701a 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -529,6 +529,16 @@ function Commands.set_field(ctx, key, value) ctx.save[key] = value end +-- scripts/ChampionsRoom.asm:57 +function Commands.set_option(ctx, key, value) + local o = ctx.save.options + if not o then + o = {} + ctx.save.options = o + end + o[key] = value +end + function Commands.load_player_starter_name(ctx) local flags = ctx.save.flags or {} local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU" @@ -720,7 +730,8 @@ end -- Box deposits also print SentToBoxText (give_pokemon.asm:36-37). -- skipNickname suppresses AskName for callers that name the gift themselves; -- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013) -function Commands.give_pokemon(ctx, species, level, skipNickname) +-- gotText prints GotMonText ahead of AskName (give_pokemon.asm:46). +function Commands.give_pokemon(ctx, species, level, skipNickname, gotText) -- Native mods can transform a gift before the Pokémon object is created. -- This is intentionally an event rather than a special-case starter hook: -- mods can use the same seam for story gifts, fossils, or custom scripts. @@ -754,6 +765,11 @@ function Commands.give_pokemon(ctx, species, level, skipNickname) ctx.lastCheck = true ctx.addedToParty = addedToParty ctx.boxNum = boxNum + -- engine/events/give_pokemon.asm:46 + if gotText and ctx.runner then + Commands.text_sound(ctx, "Get_Item1") + Commands.show_text(ctx, "_GotMonText", { RAM = species }) + end -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks -- and callback-style callers with no script runner to yield on. if not gift.nickname and not skipNickname and ctx.runner then @@ -1303,11 +1319,14 @@ function FadeOverlay:update() end function FadeOverlay:draw() - if self.color == "white" then - love.graphics.setColor(1, 1, 1, self.alpha) - else - love.graphics.setColor(0, 0, 0, self.alpha) + local shade = (self.color == "white") and 1 or 0 + -- home/fade.asm:26 + local r = self.game and self.game.renderer + if r then + r.screenVeil = { shade, self.alpha } + return end + love.graphics.setColor(shade, shade, shade, self.alpha) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index 4feb0155..57472ba2 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -120,6 +120,67 @@ CallAsm.SITES = { ["04:66d1"] = "TryReceiveItem", } +-- pokegold-symbols/pokesilver.sym: bank $03 sits two bytes earlier in Silver. +CallAsm.SITES_SILVER = { + ["03:4749"] = "GetPartyNickname", + ["03:4853"] = "CutDownTreeOrGrass", + ["03:4b47"] = "CheckContinueWaterfall", + ["03:4d13"] = "SetStrengthFlag", + ["03:4d79"] = "TryStrengthOW", + ["03:4e1e"] = "DisappearWhirlpool", + ["03:4f7d"] = "HasRockSmash", + ["03:5094"] = "PutTheRodAway", + ["03:506b"] = "Fishing_CheckFacingUp", + ["03:51c5"] = "AskCutScript_CheckMap", +} + +-- pokecrystal-symbols/pokecrystal.sym: Crystal's own addresses for the same sites. +CallAsm.SITES_CRYSTAL = { + ["25:6f76"] = "GiveItemScript_DummyFunction", + ["04:65cd"] = "StartMenu", + ["04:7327"] = "SelectMenu", + ["05:6f5e"] = "OverworldHatchEgg", + ["25:6706"] = "EnableWildEncounters", + ["24:426f"] = "RingTwice_StartCall", + ["24:42eb"] = "HangUp", + ["04:53e5"] = "InitCallReceiveDelay", + ["24:425c"] = "LoadBillScript", + ["24:426a"] = "LoadElmScript", + ["3f:5017"] = "MomTriesToBuySomething_ASMFunction", + ["04:6599"] = "ItemfinderSound", + ["14:46ef"] = "SweetScentEncounter", + ["02:431e"] = "TrainerWalkToPlayer", + ["14:4753"] = "CheckCanUseSquirtbottle", + ["04:764f"] = "SetMemEvent", + ["14:4658"] = "PlayPoisonSFX", + ["14:467b"] = "CheckWhitedOut", + ["04:64fa"] = "OverworldBGMap", + ["04:650a"] = "BattleBGMap", + ["04:6513"] = "HalveMoney", + ["04:6527"] = "GetWhiteoutSpawn", + ["03:4706"] = "GetPartyNickname", + ["03:4810"] = "CutDownTreeOrGrass", + ["23:47e1"] = "BlindingFlash", + ["00:3016"] = "HideSprites", + ["23:4aed"] = "FlyFromAnim", + ["05:54f1"] = "SkipUpdateMapSprites", + ["23:4b33"] = "FlyToAnim", + ["05:4157"] = "LoadWalkingSpritesGFX", + ["03:4b38"] = "CheckContinueWaterfall", + ["03:4d12"] = "SetStrengthFlag", + ["03:4d78"] = "TryStrengthOW", + ["03:4e1d"] = "DisappearWhirlpool", + ["23:480a"] = "ShakeHeadbuttTree", + ["03:4f7c"] = "HasRockSmash", + ["03:5095"] = "PutTheRodAway", + ["03:506c"] = "Fishing_CheckFacingUp", + ["2e:44b3"] = "LoadFishingGFX", + ["03:51ba"] = "AskCutScript_CheckMap", + ["2e:41ea"] = "TreeMonEncounter", + ["2e:4219"] = "RockMonEncounter", + ["04:62f8"] = "TryReceiveItem", +} + -- The three WRAM addresses `memcall` / `memcallasm` / `memjump` take instead of -- a routine. Not sites: the pointer AT the address is written at run time -- (LoadMemScript for the queued script, the phone engine for the other two), @@ -714,7 +775,8 @@ end -- at import time), and the address pair is the fallback that always works. function CallAsm.nameFor(label, bank, addr) if label and CallAsm.ALL[label] then return label end - return CallAsm.SITES[CallAsm.key(bank, addr)] + local key = CallAsm.key(bank, addr) + return CallAsm.SITES[key] or CallAsm.SITES_SILVER[key] or CallAsm.SITES_CRYSTAL[key] end -- Run a routine by name. Returns the byte the asm leaves in wScriptVar, or diff --git a/src/ui/Diploma.lua b/src/ui/Diploma.lua index ddcd6c73..e957e12c 100644 --- a/src/ui/Diploma.lua +++ b/src/ui/Diploma.lua @@ -8,8 +8,13 @@ local Assets = require("src.render.Assets") local Font = require("src.render.Font") local PaletteFX = require("src.render.PaletteFX") local Sprites = require("src.pokemon.Sprites") +local SpriteRenderer = require("src.render.SpriteRenderer") local Strings = require("src.core.Strings") +-- engine/events/diploma.asm:65 +local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 }, + { 170, 170, 170 }, { 85, 85, 85 } } + local Diploma = {} Diploma.__index = Diploma Diploma.isOpaque = true @@ -49,20 +54,18 @@ local function drawFrameBox(frame, tx, ty, tw, th) local img = frame.img local q = frame.quads love.graphics.setColor(1, 1, 1, 1) - -- corners - love.graphics.draw(img, q[0], tx * 8, ty * 8) - love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8) + -- engine/link/cable_club.asm:937 + love.graphics.draw(img, q[2], tx * 8, ty * 8) + love.graphics.draw(img, q[4], (tx + tw - 1) * 8, ty * 8) love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8) - love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8) - -- horizontal edges + love.graphics.draw(img, q[7], (tx + tw - 1) * 8, (ty + th - 1) * 8) for x = 1, tw - 2 do - love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8) - love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8) + love.graphics.draw(img, q[3], (tx + x) * 8, ty * 8) + love.graphics.draw(img, q[0], (tx + x) * 8, (ty + th - 1) * 8) end - -- vertical edges for y = 1, th - 2 do - love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8) - love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8) + love.graphics.draw(img, q[5], tx * 8, (ty + y) * 8) + love.graphics.draw(img, q[1], (tx + tw - 1) * 8, (ty + y) * 8) end end @@ -91,13 +94,31 @@ function Diploma.render(game) drawFrameBox(frame, 0, 0, 20, 18) -- 2. Draw Player character sprite: farcall DrawPlayerCharacter - -- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80) - local picPath, picTrueColor = Sprites.playerPath( - game.data, "front", { kind = "diploma" }) - local pic = tryImage(picPath) + -- engine/movie/title.asm:321 + local title = (game.data and game.data.field and game.data.field.title) or {} + local titlePlayer = title.player + if type(titlePlayer) == "table" then titlePlayer = titlePlayer.path end + local picPath, picTrueColor = Sprites.playerPic( + titlePlayer or "assets/generated/title/player.png", + { side = "front", kind = "diploma", data = game.data }) + local pic + if picPath then + if picTrueColor then + pic = tryImage(picPath) + else + local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90, + "diploma") + pic = (ok and faded) or tryImage(picPath) + end + end if pic then love.graphics.setColor(1, 1, 1, 1) + local sx, sy, sw, sh = love.graphics.getScissor() + -- engine/events/diploma.asm:44 + love.graphics.setScissor(8, 8, 144, 128) love.graphics.draw(pic, 115, 80) + if sx then love.graphics.setScissor(sx, sy, sw, sh) + else love.graphics.setScissor() end if picTrueColor then PaletteFX.markTrueColor(115, 80, pic:getDimensions()) end diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 5f4fc75f..1ca02256 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -95,11 +95,28 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via) self.t = 0 self.done = false self.canceled = false - Music.play(game.data, Music.special(game.data, "evolution")) + -- engine/movie/evolution.asm:41-46 + Music.stop() + self.crySrc = require("src.core.Sound").playCry(game.data, mon.species) + self.cryT = 0 + self.cryWait = self.crySrc ~= nil + if not self.cryWait then + Music.play(game.data, Music.special(game.data, "evolution")) + end return self end function EvolutionState:update(dt) + if self.cryWait then + self.cryT = self.cryT + 1 + local src = self.crySrc + local playing = src and src.isPlaying and src:isPlaying() + if self.cryT >= 3 and (not playing or self.cryT > 180) then + self.cryWait, self.crySrc = false, nil + Music.play(self.game.data, Music.special(self.game.data, "evolution")) + end + return + end self.t = self.t + 1 if self.done then return end local game = self.game diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 366cfb86..7e2fdc3d 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -157,6 +157,11 @@ end -- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs -- main's chain into secondary's, same as stacking two RetroArch presets. +local function bgLocked(o) + return o.battleLayout == "wide" and o.battleFit == "fill" + and o.battleHud == "extended" +end + -- the vanilla rows as descriptors; each step body is the old per-index -- ladder's, so the save.options mutations are unchanged local function buildRows(game) @@ -197,8 +202,6 @@ local function buildRows(game) o.battleLayout = o.battleLayout == "wide" and "og" or "wide" if o.battleLayout ~= "wide" then o.battleHud = "standard" - elseif o.battleFit == "fill" and o.battleHud == "extended" then - o.battleBg = "white" end return true end }, @@ -215,10 +218,6 @@ local function buildRows(game) step = function(g) local o = g.save.options o.battleFit = o.battleFit == "fill" and "fixed" or "fill" - if o.battleFit == "fill" and o.battleLayout == "wide" - and o.battleHud == "extended" then - o.battleBg = "white" - end return true end }, { id = "battleHud", label = Strings("BATTLE HUD"), @@ -237,9 +236,6 @@ local function buildRows(game) return false end o.battleHud = o.battleHud == "extended" and "standard" or "extended" - if o.battleHud == "extended" and o.battleFit == "fill" then - o.battleBg = "white" - end return true end }, -- What sits behind and around the battle. WHITE is the classic paper @@ -249,11 +245,7 @@ local function buildRows(game) { id = "battleBg", label = Strings("BATTLE BG"), value = function(g) local o = g.save.options - if o.battleLayout == "wide" and o.battleFit == "fill" - and o.battleHud == "extended" then - o.battleBg = "white" - return Strings("AUTO") - end + if bgLocked(o) then return Strings("AUTO (FILL HUD)") end local m = o.battleBg if m == "black" then return Strings("BLACK") end if m == "world" then return Strings("WORLD") end @@ -261,11 +253,7 @@ local function buildRows(game) end, step = function(g, dir) local o = g.save.options - if o.battleLayout == "wide" and o.battleFit == "fill" - and o.battleHud == "extended" then - o.battleBg = "white" - return false - end + if bgLocked(o) then return false end local order = { "white", "black", "world" } local cur = 1 for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index bc951f98..207d4aaf 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -19,17 +19,22 @@ local function itemName(game, id) return def and def.name or id end -local function buildItems(game, store) +local function buildItems(game, store, order) local items = {} - local ids = {} - for id in pairs(store) do table.insert(ids, id) end - table.sort(ids) + local ids = order + if not ids then + ids = {} + for id in pairs(store) do table.insert(ids, id) end + table.sort(ids) + end for _, id in ipairs(ids) do - table.insert(items, { - value = id, - label = itemName(game, id), - right = "x" .. store[id], - }) + if store[id] then + table.insert(items, { + value = id, + label = itemName(game, id), + right = "x" .. store[id], + }) + end end return items end @@ -105,12 +110,9 @@ local function deposit(game) local pc = game.save.pcItems local inv = game.save.inventory local Bag = require("src.inventory.Bag") - -- badges live in save.inventory alongside items but are not depositable - local depositable = {} - for id, count in pairs(inv) do - if not Bag.isBadge(id) then depositable[id] = count end - end - game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { + -- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order + local order = Bag.order(game.save, game.data) + game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), { kind = "pc_item_deposit", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index d7a111d8..4787f13c 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -687,8 +687,10 @@ function Studio.deleteEntry(id) local opts = SaveData.loadOptions() local tc = type(opts.touchControls) == "table" and opts.touchControls or {} if tc.skin == id then - tc.enabled, tc.skin = false, nil + tc.enabled, tc.skin = true, nil + opts.touchControls = tc SaveData.saveOptions(opts) + TouchControls:applyOptions(opts) end Studio.refreshAvailable() setStatus("Deleted " .. id) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 323958ee..38157f41 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -334,10 +334,9 @@ function TownMap:update(dt) end end --- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed --- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301) +-- OG RED and ADVANCED bake an OBJ palette in, so the marker replays over the TOWNMAP zone pass (#301) function TownMap:markPlayerRedraw(x, y) - if not PaletteFX.usesSpriteObp() then return end + if not (PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack()) then return end PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y) end @@ -402,11 +401,7 @@ function TownMap:draw() love.graphics.setColor(1, 1, 1, 1) end end - -- blinking cursor on the selected location. markerXY is the 8x8 cell's - -- top-left; the cursor asset is a 16x16 hollow frame centered on its own - -- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm - -- draws the box cursor CENTERED on the selected location). Drawing it at - -- the cell top-left put the square in the frame's top-left quadrant (#152). + -- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454 local showCursor = true if GameVersion.generation() == 1 then showCursor = self.blink < 25 @@ -416,7 +411,7 @@ function TownMap:draw() if selected and showCursor then local x, y = markerXY(selected) if self.bg.cursor then - love.graphics.draw(self.bg.cursor, x - 4, y - 4) + love.graphics.draw(self.bg.cursor, x - 4, y - 3) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 64eed26f..cabda30f 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -826,7 +826,10 @@ function BattleState:drawPic(mon, back) if trainerBack then -- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of -- TrainerPalettes (Chris shares Cal's). - colors = Palettes.trainerColors(self.palettes, "PLAYER") or colors + -- engine/gfx/color.asm:683-696 + local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER" + colors = Palettes.trainerColors(self.palettes, row) + or Palettes.trainerColors(self.palettes, "PLAYER") or colors elseif enemyTrainer then -- The opponent's class row out of the same TrainerPalettes table. colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass) @@ -1258,7 +1261,15 @@ function BattleState:afterAnimFor(side) return "ANIM_PLAYER_DAMAGE" end -function BattleState:animForMove(moveId, side, param) +-- engine/battle_anims/anim_commands.asm:1200 PlayHitSound +function BattleState:playHitSound(effectiveness) + if not effectiveness or effectiveness == 0 then return end + if effectiveness > 10 then self:playSfx("Sfx_SuperEffective") + elseif effectiveness < 10 then self:playSfx("Sfx_NotVeryEffective") + else self:playSfx("Sfx_Damage") end +end + +function BattleState:animForMove(moveId, side, param, effectiveness) local key = self.anims and self.anims.moves and self.anims.moves[moveId] local started = self:startAnim(key, { turn = self:turnFor(side), animId = moveId, isMove = true, param = param, @@ -1267,7 +1278,8 @@ function BattleState:animForMove(moveId, side, param) -- BattleAnimRunScript (anim_commands.asm:55-72): after the move script -- restores HUDs it immediately runs wBattleAfterAnim (the hit shake). -- Queue it so stepAnim chains without waiting on the next event. - self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side } + self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side, + effectiveness = effectiveness } end return started end @@ -1278,6 +1290,7 @@ function BattleState:startPendingAfterAnim() if not pending then return false end self.pendingAfterAnim = nil if self:animForId(pending.name, pending.side) then + self:playHitSound(pending.effectiveness) -- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there. self.afterAnimPlayed = true return true @@ -1575,6 +1588,11 @@ function BattleState:advanceQueue() self:showPages(text) return end + -- data/text/battle.asm:184 + if event.kind == "money" then + self:showPages(event.text or "") + return + end -- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick -- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715). if event.kind == "shiny-flash" then @@ -1692,12 +1710,14 @@ function BattleState:advanceQueue() if event.kind == "move" and not event.missed then self.afterAnimPlayed = nil self.pendingAfterAnim = nil - if not self:animForMove(event.move, event.side, event.animParam) then + if not self:animForMove(event.move, event.side, event.animParam, + event.effectiveness) then -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim -- (anim_commands.asm:55-72 .disabled fallthrough). local options = self.game and self.game.options if options and options.battleScene == false then if self:animForId(self:afterAnimFor(event.side), event.side) then + self:playHitSound(event.effectiveness) self.afterAnimPlayed = true end end @@ -1725,6 +1745,7 @@ function BattleState:advanceQueue() and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then self.afterAnimPlayed = nil else + self:playHitSound(event.effectiveness) self:animForId(hit, from) end end diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 10c5ab1b..0bdfaec8 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -41,6 +41,8 @@ ItemPcMenu.isOpaque = true local PC_ITEM_CAPACITY = 50 local MAX_STACK = 99 +local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end + -- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks -- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE -- carries DECORATION and ends on TURN OFF. @@ -167,18 +169,24 @@ function ItemPcMenu:rebuild() for id, count in pairs(pc) do if (count or 0) > 0 then local def = self:def(id) - rows[#rows + 1] = { - id = id, count = count, - name = (def and def.name) or id, - index = def and def.index or math.huge, - } + local remaining = count + while remaining > 0 do + local n = math.min(remaining, MAX_STACK) + rows[#rows + 1] = { + id = id, count = n, + name = (def and def.name) or id, + index = def and def.index or math.huge, + } + remaining = remaining - n + end end end -- wPCItems keeps acquisition order; without that recorded, item id order is -- the stable choice, the same sort the PACK uses. table.sort(rows, function(a, b) if a.index ~= b.index then return a.index < b.index end - return a.id < b.id + if a.id ~= b.id then return a.id < b.id end + return a.count > b.count end) self.rows = rows if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end @@ -200,20 +208,18 @@ function ItemPcMenu:ensureVisible() math.max(0, self:listTotal() - VISIBLE_ROWS))) end --- ReceiveItem over wPCItems: a new id needs one of the fifty stacks, a grown --- one may not pass 99. False is the no-carry the deposit turns into +-- ReceiveItem over wPCItems: the add tops up every existing stack of that id +-- and spills the rest into a new one, so it only needs a free stack when the +-- room in place is short. False is the no-carry the deposit turns into -- _PlayersPCNoRoomDepositText. +-- engine/items/items.asm:156 PutItemInPocket function ItemPcMenu:pcAdd(id, qty) local pc = self.save.pcItems local held = pc[id] or 0 - if held == 0 then - local stacks = 0 - for _, count in pairs(pc) do - if (count or 0) > 0 then stacks = stacks + 1 end - end - if stacks >= PC_ITEM_CAPACITY then return false end - end - if held + qty > MAX_STACK then return false end + local used = 0 + for _, count in pairs(pc) do used = used + stacksFor(count) end + local need = stacksFor(held + qty) - stacksFor(held) + if used + need > PC_ITEM_CAPACITY then return false end pc[id] = held + qty return true end diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 23d33dc5..1dfdbe75 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -156,7 +156,9 @@ function TitleState.new(game, opts) self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY self.entranceSfx = title.entranceSfx + -- engine/menus/intro_menu.asm:951-966 self.timeoutFrames = tonumber(title.timeoutFrames) + or ((self.trailMode == "silver") and (73 * 60 + 36) or (84 * 60 + 16)) self.onTimeout = opts.onTimeout -- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its -- colour 0 backs the whole band. @@ -295,12 +297,17 @@ function TitleState:update(_dt) return end - -- TitleScreenTimer / TitleScreenMain's run-down back into the attract - -- loop (engine/menus/intro_menu.asm:1125-1236). - if self.timeoutFrames and self.onTimeout - and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then - self.onTimeout() - return + -- engine/menus/intro_menu.asm:1023-1059 + if self.timeoutFrames and self.onTimeout then + if self.fadeStart then + if self.frameCounter - self.fadeStart >= 60 then self.onTimeout() end + return + end + if self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then + self.fadeStart = self.frameCounter + Music.fadeOut(8) + return + end end local input = self.game.input diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3c503fc9..4554dc5d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1232,6 +1232,10 @@ function OverworldState:update(dt) self.player.inputLocked = false end end + if self.spinArrive and not self.player.spinFrames then + self.spinArrive = nil + self.player.inputLocked = false + end -- EnterMapAnim's .done tail re-enables the companion once the swoop or the -- spin-down has landed (player_animations.asm:40) @@ -1311,7 +1315,7 @@ function OverworldState:update(dt) local scripted = self.runner:isRunning() or #self.scriptMoves > 0 or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut - or self.flyAnim or self.flyArrive + or self.flyAnim or self.flyArrive or self.spinArrive if not scripted and not self.transitioning then self:checkTrainerSight() -- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and @@ -1321,7 +1325,7 @@ function OverworldState:update(dt) scripted = self.runner:isRunning() or #self.scriptMoves > 0 or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut - or self.flyAnim or self.flyArrive + or self.flyAnim or self.flyArrive or self.spinArrive end -- a scriptMove's onDone can push a text box on the frame it retires, and -- DisplayTextID owns the loop from there (home/text_script.asm:3) @@ -4802,6 +4806,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.doorWarp = nil local arriveWarp = self.arriveWarp self.arriveWarp = nil + if self.spinArrive then + self.spinArrive = nil + self.player.inputLocked = false + end -- PlayMapChangeSound (home/overworld.asm) plays before the tail-called -- GBFadeOutToBlack, so the SFX starts with the fade (#961) if doorWarp then @@ -4849,6 +4857,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.player.spinFrames = 48 self.player.spinTotal = 48 self.player.spinDrop = true + -- engine/overworld/player_animations.asm:19 + self.spinArrive = true + self.player.inputLocked = true end if doorWarp then -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any @@ -5329,8 +5340,9 @@ function OverworldState:drawWorld() -- cry with no bubble still pauses the world for its beat) if self.emote.bubble == false then return end local npc = self.emote.npc - local ex = npc.px - cam.x + 4 - local ey = npc.py - cam.y - 14 + -- engine/overworld/emotion_bubbles.asm:41 + local ex = npc.px - cam.x + local ey = npc.py - cam.y - 20 local bubble = Game.data.field.emotionBubbles local drawn = false if bubble and bubble.path then diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index c6c9a58f..ce3b82ec 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -551,6 +551,7 @@ function World.new(game) lastSfx = nil, pokePic = nil, pendingSceneScript = false, + startedOverworld = false, -- GBC color state (engine/gfx/color.asm). `daytime` is the resolved -- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides -- World:hour for drivers and tests, so the palette, the hour windows and @@ -9045,10 +9046,9 @@ function World:setMap(mapId, cx, cy, facing, opts) if not opts.seamless then self.pendingSceneScript = true end - -- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on - -- every map entry, connections included -- which is why a player who keeps - -- warping is never rung (src/core/gen2/Phone.lua's receive timer). - if self.game and self.game.save then + -- engine/overworld/events.asm:98 + if not self.startedOverworld and self.game and self.game.save then + self.startedOverworld = true require("src.core.gen2.Phone").onMapLoad(self.game.save, self:stepContext().phone) end diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua index 94311bab..c9745fc1 100644 --- a/tests/drivers/fill_extended_auto_menu_test.lua +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -23,10 +23,10 @@ return function(game) fitRow.step(game, 1) assert(options.battleFit == "fill", "battle size switched to FILL") - assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE") - assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO") + assert(options.battleBg == "black", "FILL + EXTENDED preserves the stored background") + assert(bgRow.value(game) == "AUTO (FILL HUD)", "adaptive background is labeled AUTO") assert(bgRow.step(game, 1) == false, "AUTO background row is locked") - assert(options.battleBg == "white", "locked AUTO retains the WHITE value") + assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value") menu.index = bgIndex menu.scroll = math.max(0, bgIndex - 5) @@ -37,11 +37,11 @@ return function(game) fitRow.step(game, -1) assert(options.battleFit == "fixed", "battle size switched back to FIXED") - assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice") - assert(bgRow.step(game, 1) == true and options.battleBg == "black", - "FIXED can select BLACK") + assert(bgRow.value(game) == "BLACK", "FIXED exposes the stored BLACK choice") assert(bgRow.step(game, 1) == true and options.battleBg == "world", "FIXED can select WORLD") + assert(bgRow.step(game, 1) == true and options.battleBg == "white", + "FIXED can select WHITE") U.wait(2) local fixedPath = DIR .. "/fixed_extended_background_choices.png" os.remove(fixedPath) diff --git a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua index 8cdd3917..73436f5c 100644 --- a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua +++ b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -48,7 +48,7 @@ end -- #1461: saves that passed through 0.1.82-0.1.9x already have the player's -- own id written onto traded mons, so stamping correctly from now on does --- not help them. The repair runs on every load, not behind a format gate. +-- not help them. The repair is a one-shot pre-format-5 migration. do local SaveData = require("src.core.SaveData") local save = newSave() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 6ed91552..5fb84893 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -229,16 +229,16 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL") "DUX post-trade script completes") shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset") --- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText, --- ball hidden (GivePokemon -> AddPartyMon AskName; script still --- prints GotMonText after the silent give_pokemon row) === +-- === 9) Celadon Eevee: no confirm prompt, GotMonText then AskName, +-- ball hidden (GivePokemon -> SetPokedexOwnedFlag prints GotMonText +-- before AddPartyMon's AskName) === local EEVEE_MAP, EEVEE_BALL = "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" Game.save = SaveData.newGame() check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes") -shownIs({ "_DoYouWantToNicknameText", "_GotMonText" }, - "Eevee gives immediately (nickname ask, then GotMonText)") +shownIs({ "_GotMonText", "_DoYouWantToNicknameText" }, + "Eevee gives immediately (GotMonText, then nickname ask)") eq(#Game.save.party, 1, "Eevee joins the party") eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE") eq(Game.save.party[1].level, 25, "Eevee is level 25") @@ -261,13 +261,13 @@ shownIs({}, "old save: silent") eq(#Game.save.party, 0, "old save: no Eevee re-gift") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden") --- === 11) GivePokemon party full, box has room: AskName + SentToBoxText --- (SendNewMonToBox), then script GotMonText; ball hidden === +-- === 11) GivePokemon party full, box has room: GotMonText, then AskName +-- + SentToBoxText (SendNewMonToBox); ball hidden === Game.save = SaveData.newGame() for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-party Eevee script completes") -shownIs({ "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText" }, - "full-party gift: nickname, sent-to-box, then GotMonText") +shownIs({ "_GotMonText", "_DoYouWantToNicknameText", "_SentToBoxText" }, + "full-party gift: GotMonText, nickname, then sent-to-box") eq(#Game.save.party, 6, "full-party gift leaves party size at 6") local Boxes = require("src.pokemon.Boxes") local boxed = false diff --git a/tests/pet_cries_test.lua b/tests/pet_cries_test.lua index 89f07aaa..ee53b278 100644 --- a/tests/pet_cries_test.lua +++ b/tests/pet_cries_test.lua @@ -20,7 +20,8 @@ local yellow = GameVersion.isYellow() -- pokered/scripts/*.asm, PlayCry line in brackets: -- SSAnne1FRooms.asm:66 [:70], SSAnneB1FRooms.asm:82 [:86], -- VermilionPidgeyHouse.asm:15 [:19], VermilionCity.asm:224 [:228], --- PokemonFanClub.asm:71 [:76] and :84 [:89]. +-- PokemonFanClub.asm:71 [:76] and :84 [:89], +-- MrFujisHouse.asm:56 [:60] and :63 [:67], LavenderCuboneHouse.asm:10 [:14]. local PETS = { { map = "SS_ANNE_1F_ROOMS", const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", object = "SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF", @@ -40,6 +41,15 @@ local PETS = { { map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_SEEL", object = "POKEMONFANCLUB_SEEL", species = "SEEL", label = "_PokemonFanClubSeelText" }, + { map = "MR_FUJIS_HOUSE", const = "TEXT_MRFUJISHOUSE_PSYDUCK", + object = "MRFUJISHOUSE_PSYDUCK", species = "PSYDUCK", + label = "_MrFujisHousePsyduckText" }, + { map = "MR_FUJIS_HOUSE", const = "TEXT_MRFUJISHOUSE_NIDORINO", + object = "MRFUJISHOUSE_NIDORINO", species = "NIDORINO", + label = "_MrFujisHouseNidorinoText" }, + { map = "LAVENDER_CUBONE_HOUSE", const = "TEXT_LAVENDERCUBONEHOUSE_CUBONE", + object = "LAVENDERCUBONEHOUSE_CUBONE", species = "CUBONE", + label = "_LavenderCuboneHouseCuboneText" }, } if yellow then