diff --git a/conf.lua b/conf.lua index 11559377..56a89919 100644 --- a/conf.lua +++ b/conf.lua @@ -49,7 +49,7 @@ function love.conf(t) t.window.minwidth = 480 t.window.minheight = 360 end - t.version = "11.5" + t.version = love._os == "iOS" and "12.0" or "11.5" t.window.vsync = 1 t.modules.joystick = true t.modules.physics = false diff --git a/data/scripts/flavor/bike_shop.lua b/data/scripts/flavor/bike_shop.lua index e0273216..9c8ee186 100644 --- a/data/scripts/flavor/bike_shop.lua +++ b/data/scripts/flavor/bike_shop.lua @@ -1,10 +1,9 @@ -- BikeShop (BIKE_SHOP) flavor dialogue -- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm -- --- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle --- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent --- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the --- scope of these two flavor NPCs and is left unported here. +-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the +-- voucher exchange and the BICYCLE/CANCEL price window need more than +-- command rows (#568). return { BIKE_SHOP = { @@ -20,12 +19,15 @@ return { -- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike -- before the player owns a bike -> TheseBikesAreExpensiveText -- after the player owns a bike -> CoolBikeText + -- The check reads the bag, not the event: the port hands out the + -- BICYCLE itself (a key item, so it cannot be tossed), and that also + -- reads right on saves written before the clerk set the event (#567). TEXT_BIKESHOP_YOUNGSTER = { { "face_player" }, -- 1 - { "check_flag", "EVENT_GOT_BICYCLE" }, -- 2 - { "jump_if_true", 5 }, -- 3 + { "check_item", "BICYCLE" }, -- 2 + { "jump_if_true", 6 }, -- 3 { "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4 - { "jump", 6 }, -- 5 + { "jump", "end" }, -- 5 { "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6 }, }, diff --git a/data/scripts/flavor/cerulean_badge_house.lua b/data/scripts/flavor/cerulean_badge_house.lua index 85052404..64b2a30a 100644 --- a/data/scripts/flavor/cerulean_badge_house.lua +++ b/data/scripts/flavor/cerulean_badge_house.lua @@ -35,14 +35,25 @@ local function middleAgedMan(game, ow, npc, done) -- .loop: print WhichBadgeText, then show the badge list menu again push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function() local ListMenu = require("src.ui.ListMenu") + local Strings = require("src.core.Strings") local items = {} for _, id in ipairs(BADGE_ORDER) do local idef = game.data.items[id] items[#items + 1] = { label = idef and idef.name or id, value = id } end + -- PrintListMenuEntries prints ListMenuCancelText once it hits the + -- list's $FF terminator (home/list_menu.asm), so every + -- DisplayListMenuID list ends in an on-screen CANCEL row; picking it + -- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done + -- exit as B (#569) + items[#items + 1] = { label = Strings("CANCEL") } local menu = ListMenu.new(game, "", items, { onChoose = function(item) game.stack:pop() + if not item.value then + push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done) + return + end push(game, t[BADGE_TEXT[item.value]], loop) end, onCancel = function() diff --git a/data/scripts/flavor/game_corner.lua b/data/scripts/flavor/game_corner.lua index 2bbc8726..59738245 100644 --- a/data/scripts/flavor/game_corner.lua +++ b/data/scripts/flavor/game_corner.lua @@ -85,6 +85,47 @@ return { alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText", alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!", }), + + -- Yellow keeps all three giveaways with the same events and amounts + -- but renames the objects and their text labels: FISHING_GURU -> + -- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN -> + -- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue + -- keys above never match there, so Yellow needs its own three (#552). + TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({ + event = "EVENT_GOT_10_COINS", + amount = 10, + askLabel = "_GameCornerFishingGuru1WantToPlayText", + askFallback = "Kid, do you want\nto play?", + receivedLabel = "_GameCornerFishingGuru1Received10CoinsText", + coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText", + coinCaseFullFallback = "You don't need my\ncoins!", + alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText", + alreadyGotFallback = "Wins seem to come\nand go.", + }), + + TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({ + event = "EVENT_GOT_20_COINS_2", + amount = 20, + askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText", + askFallback = "What's up? Want\nsome coins?", + receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText", + coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText", + coinCaseFullFallback = "You have lots of\ncoins!", + alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText", + alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!", + }), + + TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({ + event = "EVENT_GOT_20_COINS", + amount = 20, + askLabel = "_GameCornerFishingGuru2ThrowingMeOffText", + askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!", + receivedLabel = "_GameCornerFishingGuru2Received20CoinsText", + coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText", + coinCaseFullFallback = "You've got your\nown coins!", + alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText", + alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!", + }), }, }, } diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index 7d21fb99..e2c7b158 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -143,17 +143,25 @@ return { -- OaksLabRivalExclamationScript: "!" over the rival { "emote", RIVAL, "shock" }, } - -- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove: - -- the rival cuts across to the ball WHILE the player standing - -- below it is bumped two tiles right (both movements run in the - -- same beat, so the walk overlaps the shove like the original) + -- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07 + -- bytes are Yellow's own step-right encoding, decoded by + -- engine/overworld/movement.asm Func_5288 -> Func_532b), and the + -- PAD_RIGHT x2 shove is NOT queued alongside it: + -- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and + -- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e. + -- as the rival begins the LAST byte, the step onto the tile the + -- player is standing on. Starting both on one row had Red stroll + -- off the Eevee while the rival was still at the top of the table + -- (#559). if py == 4 then - rows[#rows + 1] = { "walk_npc", RIVAL, - { "down", "right", "right", "right" }, { wait = false } } + rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } } + -- this one runs concurrently with the shove below + rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } } rows[#rows + 1] = { "face_player_dir", "left" } rows[#rows + 1] = { "move_player", "right", 2 } - -- let the rival finish the last stretch to (7,4) - rows[#rows + 1] = { "wait", 40 } + -- move_player blocks for both tiles, so the rival has already + -- landed on (7,4); this is just the beat before he turns up + rows[#rows + 1] = { "wait", 20 } else rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 } end diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 26208190..f38b0af6 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = { }, } +-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is +-- already yours, you are carrying the BIKE VOUCHER, or you get the sales +-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4 +-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up +-- while the clerk keeps talking in the bottom box: the original never +-- erases it before TextScriptEnd (#568). +local BikeShopWindow = {} +BikeShopWindow.__index = BikeShopWindow + +function BikeShopWindow.new(game, footer, onChoose) + local self = setmetatable({}, BikeShopWindow) + self.game = game + self.onChoose = onChoose + self.index = 1 + self.active = true + -- BikeShopClerkDoYouLikeItText stays on screen under the window for as + -- long as the menu is up. The text box pushed on top types it out and + -- pops itself; this copy of its last page takes over from there, so the + -- bottom box never blanks between the pitch and the answer. Paginated + -- with the themed column budget so the copy breaks where the box did. + local TextBox = require("src.render.TextBox") + local box = require("src.ui.Theme").textBox or {} + local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols) + self.footer = pages[#pages] + return self +end + +function BikeShopWindow:update() + -- one answer only: the boxes pushed by onChoose sit on top of this + -- state, but a second A on the same frame must not fire it twice + if not self.active then return end + local input = self.game.input + -- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp + if input:wasPressed("up") then + self.index = 1 + elseif input:wasPressed("down") then + self.index = 2 + elseif input:wasPressed("a") or input:wasPressed("b") then + local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel + require("src.core.Sound").play(self.game.data, "Press_AB") + self.active = false + self.onChoose(not cancelled and self.index == 1) + end +end + +function BikeShopWindow:draw() + local Font = require("src.render.Font") + local Strings = require("src.core.Strings") + local Theme = require("src.ui.Theme") + Font.drawBox(0, 0, 17, 6) + love.graphics.setColor(0, 0, 0, 1) + local bike = self.game.data.items.BICYCLE + Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2 + Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3 + Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row + -- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart + Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32) + if self.footer then + -- same geometry TextBox resolves against, so a themed box matches + local box = Theme.textBox or {} + local tx, ty = box.tx or 0, box.ty or 12 + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(tx, ty, box.tw or 20, box.th or 6) + love.graphics.setColor(0, 0, 0, 1) + for i, line in ipairs(self.footer) do + Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + M.BIKE_SHOP = { talk = { TEXT_BIKESHOP_CLERK = function(game, ow, npc, done) + local Bag = require("src.inventory.Bag") + local Flags = require("src.script.Flags") local TextBox = require("src.render.TextBox") - if (game.save.inventory.BICYCLE or 0) > 0 then - game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done)) - elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then - game.save.inventory.BIKE_VOUCHER = nil - game.save.inventory.BICYCLE = 1 - game.stack:push(TextBox.new(game, - ("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!") - :format(game.save.player.name), done)) - else - game.stack:push(TextBox.new(game, - "A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done)) + local t = game.data.text + local function say(text, after, opts) + game.stack:push(TextBox.new(game, text, after, opts)) end + + -- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started + -- setting the event still have the bike in the bag, so either counts. + if (game.save.inventory.BICYCLE or 0) > 0 + or Flags.get(game.save, "EVENT_GOT_BICYCLE") then + say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done) + return + end + + -- .dontHaveBike: IsItemInBag BIKE_VOUCHER + if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then + say(t._BikeShopClerkOhThatsAVoucherText, function() + -- GiveItem's `jr nc, .BagFull`: the voucher is only spent once + -- the BICYCLE is actually in the bag + if not Bag.add(game.save, "BICYCLE", 1) then + say(t._BikeShopBagFullText, done) + return + end + Bag.remove(game.save, "BIKE_VOUCHER", 1) + Flags.set(game.save, "EVENT_GOT_BICYCLE") + -- BikeShopExchangedVoucherText carries sound_get_key_item; the + -- map runs EnableAutoTextBoxDrawing, so the box still waits for + -- a button once the jingle has played (auto.wait, #247) + say(t._BikeShopExchangedVoucherText, done, { + auto = { wait = true, sound = function() + return require("src.core.Sound").play(game.data, "Get_Key_Item") + end }, + }) + end) + return + end + + -- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window + say(t._BikeShopClerkWelcomeText, function() + local pitch = t._BikeShopClerkDoYouLikeItText + game.stack:push(BikeShopWindow.new(game, pitch, function(bought) + local function comeAgain() + say(t._BikeShopComeAgainText, function() + game.stack:pop() -- the window, still up under the text + done() + end) + end + if bought then + -- a million is out of anyone's reach: BikeShopCantAffordText + say(t._BikeShopCantAffordText, comeAgain) + else + comeAgain() + end + end)) + -- PrintText BikeShopClerkDoYouLikeItText, then straight into + -- HandleMenuInput: the box types out and hands over without + -- waiting, leaving the window's copy of the line on screen + say(pitch, nil, { auto = { delay = 0 } }) + end) end, }, } diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 9d6ca76c..8e250c59 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = { -- -- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO -- Silph Scope check at the trigger -- the scope only decides whether the --- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move", --- balls dodged). An earlier version of this port turned the player back +-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls +-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier +-- version of this port turned the player back -- without the scope and never opened the battle, which made 6F -- impassable on any route that skips Rocket Hideout; vanilla lets the -- battle open and a POKE_DOLL end it (see wBattleResult below). @@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = { -- or not the scope revealed it, so the "can't be caught" state rides -- the battle instead of IsGhostBattle alone (#444) battle.noCatch = true - if not game.save.inventory.SILPH_SCOPE then + -- InitWildBattle enters disguised for the RESTLESS SOUL either way + -- (core.asm:6698-6700). The scope does not skip the disguise, it + -- buys the unveil PrintBeginningBattleText .isMarowak plays over it + -- before the battle proceeds as an ordinary wild one (#492). + if game.save.inventory.SILPH_SCOPE then + battle:makeUnveiledGhost() + else battle:makeGhost() end battle.onFinish = function(result) @@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = { end)) end, + -- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single + -- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET + -- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above + -- never matched there and the LIFT KEY never dropped (#552, a + -- regression of #105). Yellow also moves the drop off the second + -- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton + + -- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject + -- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends. + TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done) + if not ow:trainerDefeated(npc) then + ow:engageTrainer(npc, function() + -- engageTrainer records the win before it calls back, so this + -- is the end-battle text's SetEvent + ShowObject + if ow:trainerDefeated(npc) + and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then + game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true + local Commands = require("src.script.Commands") + Commands.show_object( + { game = game, save = game.save, overworld = ow }, + "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY") + end + done() + end) + return + end + -- RocketHideoutB4FRocketAfterBattleText: later talks only reprint + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._RocketHideoutB4FRocketAfterBattleText + or "Oh no! I dropped\nthe LIFT KEY!", done)) + end, + TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done) -- Giovanni has no trainer-header row (def_trainers 2); his text_asm -- owns both the engage and the BeatGiovanniScript aftermath. @@ -502,45 +541,56 @@ M.GAME_CORNER = { end)) end) end, + -- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a + -- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the + -- object const and from every one of his text labels + -- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an + -- identical body, so each line resolves under both spellings and the + -- handler is bound to both text ids just below (#552). TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") local t = game.data.text + local function line(suffix, fallback) + return t["_GameCornerClerk1" .. suffix] + or t["_GameCornerClerk" .. suffix] + or fallback + end game.stack:push(TextBox.new(game, - (t._GameCornerClerk1DoYouNeedSomeGameCoinsText - or "Do you need some\ngame coins?\f¥1000 for 50."), function() + line("DoYouNeedSomeGameCoinsText", + "Do you need some\ngame coins?\f¥1000 for 50."), function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then game.stack:push(TextBox.new(game, - t._GameCornerClerk1PleaseComePlaySometimeText - or "No? Please come\nplay sometime!", done)) + line("PleaseComePlaySometimeText", + "No? Please come\nplay sometime!"), done)) return end -- scripts/GameCorner.asm GameCornerClerk1Text: coins need -- the COIN CASE and room for at least 9 coins (Has9990Coins) if not game.save.inventory.COIN_CASE then game.stack:push(TextBox.new(game, - t._GameCornerClerk1DontHaveCoinCaseText - or "You don't have a\nCOIN CASE!", done)) + line("DontHaveCoinCaseText", + "You don't have a\nCOIN CASE!"), done)) return end if (game.save.coins or 0) >= 9990 then game.stack:push(TextBox.new(game, - t._GameCornerClerk1CoinCaseIsFullText - or "Oops! Your COIN\nCASE is full.", done)) + line("CoinCaseIsFullText", + "Oops! Your COIN\nCASE is full."), done)) return end if game.save.money < 1000 then game.stack:push(TextBox.new(game, - t._GameCornerClerk1CantAffordTheCoinsText - or "You can't afford\nthe coins!", done)) + line("CantAffordTheCoinsText", + "You can't afford\nthe coins!"), done)) return end game.save.money = game.save.money - 1000 game.save.coins = math.min(9999, (game.save.coins or 0) + 50) game.stack:push(TextBox.new(game, - (t._GameCornerClerk1ThanksHereAre50CoinsText - or "Thanks! Here are\nyour 50 coins!") + line("ThanksHereAre50CoinsText", + "Thanks! Here are\nyour 50 coins!") .. ("\fCOINS: %d"):format(game.save.coins), done)) end)) end)) @@ -548,6 +598,13 @@ M.GAME_CORNER = { }, } +-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red +-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so +-- one handler answers both ids. Without the alias the Yellow clerk fell +-- through to his extracted offer line with no yes/no box behind it (#552). +M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK = + M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1 + -- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm). -- The six mon prizes differ between Red and Blue; the three TM prizes are -- identical, so they are shared and appended to each version's mon list. diff --git a/docs/modding.md b/docs/modding.md index 5517a3a8..5aa0bec0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains and `dpiY`, so a tool can use the letterbox margins without drawing over the playfield or pushing an updating game state. +`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It +receives `(next, renderer, ctx)`; returning `true` without calling `next` hands +the mod full control of the window, while calling `next` runs the engine's +normal single-window composite so the mod can decorate around it. `ctx` carries +the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`, +`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, +`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a +palette-correct blit of either canvas into an arbitrary screen rect, and the +`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`) +for driving a second physical display. This is what lets a mod lay the two +passes out as two stacked Game Boy screens, or push one onto a second screen, +without the engine knowing the layout. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/new-features.md b/docs/new-features.md index be76940e..9480a6af 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -315,7 +315,7 @@ the overlay on and lets the mouse act as a finger (`=0` forces it off). The launcher's **Touch Controls** button opens a drag editor: move each button freely, **Disable** to hide the overlay permanently (for -controllers / emulation handhelds — distinct from the temporary +controllers / emulation handhelds -- distinct from the temporary gamepad auto-hide), **Reset** for defaults, **Done** to save into `options.lua` as normalized window fractions so rotation keeps the relative placement. In-game, Options → **TOUCH PAD** toggles the same @@ -433,3 +433,18 @@ own repositories. No index ships with the launcher and none is ever added automatically, so the tab opens on an "Add an index" prompt until you name one; paste an index URL or its `owner/repo` and it is remembered in `options.lua`. More than one index can be added, and the listings merge. + +## Soft reset (all versions) + +Holding A, B, START and SELECT together restarts the game the way flicking +a Game Boy's power switch did, dropping straight back to the title screen. +It works from anywhere, including mid-battle, which the QUIT entry on the +start menu cannot do: the original combo is how stationary and gift +Pokemon get their stats rerolled without sitting through a full relaunch. +Unsaved progress is discarded, exactly as on hardware. + +As on the original, the four buttons have to stay held for 16 straight +polls (better than a quarter of a second) and any direction in the mix +cancels it, so it is hard to hit by accident -- including on the on-screen +touch controls, where it would take four fingers held on four separate +controls. diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 5ddc77f7..7755f891 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -863,4 +863,51 @@ const char *getArg0() } // android } // love +extern "C" __attribute__((visibility("default"))) +int love_android_secondary_ready() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z"); + jboolean ready = JNI_FALSE; + if (m) + ready = env->CallStaticBooleanMethod(activity, m); + else + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return ready ? 1 : 0; +} + +extern "C" __attribute__((visibility("default"))) +void love_android_push_secondary(const void *rgba, int w, int h) +{ + if (!rgba || w <= 0 || h <= 0) + return; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V"); + if (m) + { + jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4); + env->CallStaticVoidMethod(activity, m, buf, w, h); + env->DeleteLocalRef(buf); + } + else + env->ExceptionClear(); + env->DeleteLocalRef(activity); +} + +extern "C" __attribute__((visibility("default"))) +void love_android_secondary_enable(int on) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V"); + if (m) + env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE); + else + env->ExceptionClear(); + env->DeleteLocalRef(activity); +} + #endif // LOVE_ANDROID diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index e9f34a06..11c55125 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -331,12 +331,14 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + teardownSecondaryDisplay(); super.onPause(); } @Override public void onResume() { super.onResume(); + setupSecondaryDisplay(); } /** @@ -1128,4 +1130,182 @@ public class GameActivity extends SDLActivity { return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so"; } } + + // Dual-screen: mirror the engine's bottom-screen canvas onto a secondary + // physical display. Driven from the engine through love_android_secondary_* + // in src/jni/love/src/common/android.cpp. + private static volatile SecondaryPresentation secondaryPresentation; + private static volatile boolean secondaryEnabled = false; + + @Keep + public static void setSecondaryEnabled(final boolean on) { + secondaryEnabled = on; + final GameActivity self = (GameActivity) mSingleton; + if (self == null) return; + self.runOnUiThread(new Runnable() { + @Override public void run() { + if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay(); + } + }); + } + + private static void setupSecondaryDisplay() { + GameActivity self = (GameActivity) mSingleton; + if (self == null || !secondaryEnabled || secondaryPresentation != null) return; + try { + android.hardware.display.DisplayManager dm = + (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); + if (dm == null) return; + Display chosen = null; + for (Display d : dm.getDisplays()) { + android.graphics.Point size = new android.graphics.Point(); + d.getRealSize(size); + Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName() + + " size=" + size.x + "x" + size.y); + if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) { + chosen = d; + } + } + if (chosen == null) { + Display[] pres = + dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + if (pres != null && pres.length > 0) chosen = pres[0]; + } + if (chosen == null) { + Log.d("GameActivity", "no secondary display found"); + return; + } + SecondaryPresentation p = new SecondaryPresentation(self, chosen); + p.show(); + secondaryPresentation = p; + Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId()); + } catch (Throwable t) { + Log.d("GameActivity", "secondary display setup failed: " + t); + secondaryPresentation = null; + } + } + + private static void teardownSecondaryDisplay() { + SecondaryPresentation p = secondaryPresentation; + secondaryPresentation = null; + if (p != null) { + try { p.dismiss(); } catch (Throwable t) {} + } + } + + @Keep + public static boolean hasSecondaryDisplay() { + return secondaryPresentation != null; + } + + @Keep + public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) { + SecondaryPresentation p = secondaryPresentation; + if (p != null && buf != null && w > 0 && h > 0) { + p.updateFrame(buf, w, h); + } + } + + private static class SecondaryPresentation extends android.app.Presentation { + private final FrameView frameView; + + SecondaryPresentation(Context context, Display display) { + super(context, display); + frameView = new FrameView(context); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + android.view.Window w = getWindow(); + if (w != null) { + w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + WindowManager.LayoutParams.FLAG_FULLSCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT); + } + setContentView(frameView); + applyImmersive(); + frameView.post(new Runnable() { + @Override public void run() { applyImmersive(); } + }); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) applyImmersive(); + } + + private void applyImmersive() { + android.view.Window w = getWindow(); + if (w == null) return; + if (android.os.Build.VERSION.SDK_INT >= 30) { + w.setDecorFitsSystemWindows(false); + android.view.WindowInsetsController c = w.getInsetsController(); + if (c != null) { + c.hide(android.view.WindowInsets.Type.systemBars()); + c.setSystemBarsBehavior( + android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } else { + w.getDecorView().setSystemUiVisibility( + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN + | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } + } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h) { + frameView.updateFrame(buf, w, h); + } + } + + private static class FrameView extends View { + private android.graphics.Bitmap bitmap; + private final android.graphics.Rect dst = new android.graphics.Rect(); + private final android.graphics.Paint paint = new android.graphics.Paint(); + private final Object lock = new Object(); + private int fw, fh; + + FrameView(Context context) { + super(context); + paint.setFilterBitmap(false); + paint.setAntiAlias(false); + setBackgroundColor(0xFF000000); + } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h) { + synchronized (lock) { + if (bitmap == null || fw != w || fh != h) { + if (bitmap != null) bitmap.recycle(); + bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888); + fw = w; fh = h; + } + buf.rewind(); + bitmap.copyPixelsFromBuffer(buf); + } + postInvalidate(); + } + + @Override + protected void onDraw(android.graphics.Canvas canvas) { + synchronized (lock) { + if (bitmap == null || fw == 0 || fh == 0) return; + int vw = getWidth(), vh = getHeight(); + int s = Math.min(vw / fw, vh / fh); + if (s < 1) s = 1; + int dw = fw * s, dh = fh * s; + int dx = (vw - dw) / 2, dy = (vh - dh) / 2; + dst.set(dx, dy, dx + dw, dy + dh); + canvas.drawColor(0xFF000000); + canvas.drawBitmap(bitmap, null, dst, paint); + } + } + } } diff --git a/mobile/ios/overlays/love-ios.plist b/mobile/ios/overlays/love-ios.plist index d4c9c52d..0d8e0f02 100644 --- a/mobile/ios/overlays/love-ios.plist +++ b/mobile/ios/overlays/love-ios.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Pokemon Red + gen1recomp CFBundleDocumentTypes @@ -49,10 +49,6 @@ UILaunchStoryboardName Launch Screen - UIRequiredDeviceCapabilities - - opengles-2 - UIStatusBarHidden UIViewControllerBasedStatusBarAppearance diff --git a/scripts/build.sh b/scripts/build.sh index 7bd80bbb..510f948b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -35,6 +35,7 @@ TARGET="all" NOTARY_PROFILE="notary-profile" NOTARIZE=true IOS_RELEASE=false +IOS_IPA=false say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } @@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do --notary-profile) NOTARY_PROFILE="$2"; shift ;; --no-notarize) NOTARIZE=false ;; --release) IOS_RELEASE=true ;; + --ipa) IOS_IPA=true ;; *) fail "unknown argument: $1" ;; esac shift @@ -276,6 +278,9 @@ build_android() { if [ "$VERSION_EXPLICIT" = true ]; then args+=(--version "$VERSION") fi + if [ "$IOS_IPA" = true ]; then + args+=(--ipa) + fi "$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"} } diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 869287d1..70311d34 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -50,7 +50,7 @@ DISPLAY_NAME="gen1recomp" # capabilities like HealthKit are involved), so a per-team default # lets anyone build without colliding with someone else's app # 4. simulator: the project default (no App ID registration involved) -BUNDLE_ID="${GEN1_BUNDLE_ID:-}" +BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}" if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")" fi @@ -65,6 +65,7 @@ DEVICE=false RELEASE=false PACKAGE_ONLY=false INSTALL=false +CREATE_IPA=false # Last resort for an incomplete source export, mirroring build_android.sh. MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}" MANIFESTS="" @@ -82,6 +83,7 @@ while [ $# -gt 0 ]; do --release) RELEASE=true ;; --package-only) PACKAGE_ONLY=true ;; --install) INSTALL=true ;; + --ipa) CREATE_IPA=true ;; --version) VERSION="$2"; shift ;; -h|--help) sed -n '2,24p' "$0" @@ -92,6 +94,10 @@ while [ $# -gt 0 ]; do shift done +if $CREATE_IPA; then + DEVICE=true +fi + VERSION_CODE="" if [ -n "$VERSION" ]; then if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then @@ -232,6 +238,30 @@ apply_ios_branding() { cp "$OVERLAY_PLIST" "$dest" } +apply_ios_icon() { + local source="$ROOT/assets/logo/logo.png" + local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset" + [ -f "$source" ] || fail "missing iOS icon source: $source" + [ -d "$target" ] || fail "missing iOS app icon set: $target" + local entry name size + while IFS=: read -r name size; do + sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null + done <<'EOF' +icon-1024pt@1x.png:1024 +icon-29pt@1x.png:29 +icon-29pt@2x.png:58 +icon-29pt@3x.png:87 +icon-40pt@1x.png:40 +icon-40pt@2x.png:80 +icon-40pt@3x.png:120 +icon-60pt@2x.png:120 +icon-60pt@3x.png:180 +icon-76pt@1x.png:76 +icon-76pt@2x.png:152 +icon-83.5pt@2x.png:167 +EOF +} + # --------------------------------------------------------------- game.love # Every version's import manifest has to ship or that game's ROM import fails in # the built app: decodeManifest (src/import/RomImporter.lua) errors outright when @@ -326,8 +356,9 @@ pack_game_love() { # in the built app while dev, which reads the source tree, stayed green. archive_entries="$(unzip -Z1 "$LOVE_FILE")" # shellcheck disable=SC2086 # MANIFESTS is a deliberate word list - for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ - tools/save-editor/panels/Party.lua $MANIFESTS; do + for required in src/update/Boot.lua tools/save-editor/App.lua \ + tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \ + $MANIFESTS; do printf '%s\n' "$archive_entries" | grep -qx "$required" \ || fail "game.love is missing $required" done @@ -640,6 +671,7 @@ install_to_device() { # --------------------------------------------------------------- main apply_ios_branding +apply_ios_icon say "applying iOS native bridge patches (picker/Files support)" python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed" ensure_manifests diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index fda5081d..dfa915e7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -291,10 +291,17 @@ function BattleState:picImage(img) or PaletteFX.mode == "classic" if self.grayPics or mono then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go - -- dark with the HP bars while the blackout text is up (#292). Below the - -- mono check on purpose: the forced-mono modes re-threshold the whole - -- frame downstream, and the DMG had no SGB darkening to begin with. - if self.blackedOut then return blackImage(self.data, img) end + -- dark with the HP bars while the blackout text is up (#292). The intro + -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the + -- same way: the original slides both pics in under the %11100100 + -- silhouette palette and only runs SET_PAL_BATTLE once they have landed, + -- so a still-sliding pic reads as a black silhouette, exactly like the + -- evolution movie's PAL_BLACK (#577). Below the mono check on purpose: + -- the forced-mono modes re-threshold the whole frame downstream, and the + -- DMG had no SGB darkening to begin with. + if self.blackedOut or (self.introSlide or 0) > 0 then + return blackImage(self.data, img) + end return fadeImage(img, self:activeBgp()) end @@ -645,17 +652,63 @@ function BattleState.newTrainer(game, oppClass, partyIndex) return self end +-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm) +-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real +-- species, so the SGB palette stays the disguised mon's. ghostReal keeps +-- what LoadEnemyMonData puts back when the scope unveils. +local function disguiseAsGhost(self) + self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite } + self.enemy.name = "GHOST" + self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", + monPalette(self.data, self.enemy.mon.species)) + self.introText = Strings("The GHOST\nappeared!") +end + -- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope -- the enemy is "GHOST", you're too scared to attack, and balls fail. function BattleState:makeGhost() self.ghost = true - self.enemy.name = "GHOST" - -- the ghost keeps the disguised mon's SGB palette: InitWildBattle - -- swaps only the pic, wEnemyMonSpecies2 still holds the real species - -- (engine/battle/core.asm InitWildBattle .isGhost) - self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", - monPalette(self.data, self.enemy.mon.species)) - self.introText = Strings("The GHOST\nappeared!") + disguiseAsGhost(self) +end + +-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is +-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the +-- moment the scope is in the bag, so the mon attacks, is attacked and rolls +-- for flight normally -- but InitWildBattle still enters disguised: its +-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking +-- the disguise off is PrintBeginningBattleText .isMarowak's job +-- (engine/battle/common_text.asm), which enter() queues through +-- queueScopeReveal (#492). +function BattleState:makeUnveiledGhost() + self.scopeReveal = true + disguiseAsGhost(self) +end + +-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the +-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with +-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS +-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has +-- faded into the paper (3 shifts), and the Marowak copy fades back in the +-- same way (4 shifts). Alpha over the paper is what those OBP shifts look +-- like on screen. +BattleState.GHOST_FLASH_FRAMES = 80 +BattleState.GHOST_FADE_OUT_FRAMES = 30 +BattleState.GHOST_FADE_IN_FRAMES = 40 +BattleState.GHOST_REVEAL_FRAMES = 150 + +-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!" +-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild +-- MAROWAK appeared!" under the restored name. The fall-through into +-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not +-- play for trainer intros either, so it is left out here too. +function BattleState:queueScopeReveal() + local unveiled = self.data.text and self.data.text._UnveiledGhostText + self:say(unveiled + or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!")) + self:act(function() self.ghostReveal = { t = 0 } end) + table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES }) + self:say(Strings("Wild %s\nappeared!", + self.ghostReal and self.ghostReal.name or self.enemy.name)) end -- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN, @@ -669,6 +722,12 @@ end function BattleState:makeOldManDemo(name) self.demo = true self.demoName = name or "OLD MAN" + -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: + -- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU + -- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower + -- name the caller passes IS that distinction here, so it picks the pic too + -- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557). + self.oakDemo = name == "PROF.OAK" -- Yellow's Pallet intro runs this before the player owns any mon -- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the -- battle dead for lack of a party. The demo never sends out, draws, or @@ -1189,8 +1248,11 @@ function BattleState:enter() -- without a transition (link battles, scripted pushes) Music.playBattle(self.data, self.musicKind) -- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both - -- sides slide in; the trainer pics stay up until the send-outs - self.introSlide = 40 + -- sides slide in as black silhouettes. The original scrolls SCX from + -- $90 to 0 two pixels per frame (72 frames); the port covers the full + -- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is + -- introSlide*2 below). The trainer pics stay up until the send-outs. + self.introSlide = 80 self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the -- HUD corner/underline tiles under them (PlacePlayerHUDTiles / @@ -1214,7 +1276,8 @@ function BattleState:enter() -- fade, the ground-padding measurement and battle_sprite_scales. local backPath, backTrueColor = require("src.pokemon.Sprites").playerPath(self.data, "back", - { kind = "battle", demo = self.demo, battle = self }) + { kind = "battle", demo = self.demo, oakDemo = self.oakDemo, + battle = self }) self.playerBackPic = getImage(backPath, namedPalette(self.data, "MEWMON"), backTrueColor) self.showPlayerBack = self.playerBackPic ~= nil @@ -1229,11 +1292,16 @@ function BattleState:enter() -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry -- sounds with the "Wild X appeared!" box instead of waiting on the A -- press that clears its `prompt` (#303). The Silph-Scope-less tower - -- ghost gets no cry at all (common_text.asm:43-48). - if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then + -- ghost gets no cry at all (common_text.asm:43-48), and neither does the + -- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492). + if self.kind ~= "trainer" and self.kind ~= "link" + and not self.ghost and not self.scopeReveal then queueEnemyCry() end self:say(self.introText) + -- the unveil rides on that same box, before _InitBattleCommon clears the + -- intro chrome below (#492) + if self.scopeReveal then self:queueScopeReveal() end -- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is -- dismissed both HUD blocks are cleared and ClearSprites drops the -- pokeball OAM, so the intro chrome never returns for the rest of the @@ -2663,6 +2731,35 @@ function BattleState:updateFx() end end end + -- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row + -- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic + -- and the nick swap back the way LoadEnemyMonData restores them, and the + -- real mon fades in (#492). + local gr = self.ghostReveal + if gr and self.enemy then + gr.t = gr.t + 1 + local pf = self:picFxFor(self.enemy) + local flashEnd = BattleState.GHOST_FLASH_FRAMES + local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES + if gr.t <= flashEnd then + pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1 + elseif gr.t <= outEnd then + pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3 + else + if not gr.swapped then + gr.swapped = true + local real = self.ghostReal + if real then + self.enemy.name = real.name or self.enemy.name + self.enemy.sprite = real.sprite or self.enemy.sprite + end + end + pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4) + end + if gr.t >= BattleState.GHOST_REVEAL_FRAMES then + self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil + end + end -- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then -- the pic draws at full size again if self.growIn then @@ -3387,10 +3484,12 @@ function BattleState:enemyMonFainted() -- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443) local shiftSwitchMon = nil if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then - -- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, - -- then para "Will PLAYER" / "change POKéMON?" with YES/NO. - self:say(Strings("%s is\nabout to use", self.trainer.name)) - self:say(Strings("%s!", nextName)) + -- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to + -- use" then a CONT to the nick, not a fresh box -- the box scrolls + -- "X is" off so "about to use" stays above the name, instead of the + -- page ending on a bare nick (#565). Then para "Will PLAYER" / + -- "change POKéMON?" with YES/NO. + self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName)) self:sayChoice( Strings("Will %s\nchange POKéMON?", self.game.save.player.name), function(yes) @@ -3508,8 +3607,18 @@ function BattleState:enemyMonFainted() -- PrintEndBattleText prints one text box; a `para` (\f) inside it -- starts a fresh page, which is a message row of its own here (five -- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText) + -- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText + -- (wNameBuffer then ": ", data/text/text_1.asm) and only then the + -- saved pointer, so the loss line opens with the trainer class tag on + -- its first line (RIVAL1/2/3 tag with the rival's name: + -- TrainerNamePointers aims those entries at wTrainerName). The tag + -- prints once, so a `para` page carries no second copy (#566). + local tag = self.trainer and self.trainer.name for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do - if page ~= "" then self:sayNext(page) end + if page ~= "" then + self:sayNext(tag and (tag .. ": " .. page) or page) + tag = nil + end end end self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize)) @@ -4344,6 +4453,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale) end if battler.fainted then return end + -- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those + -- palette shifts look like, and the test has to sit ahead of the plain + -- fast path below (#492) + local fadePf = self.picFx and self.picFx[battler] + if fadePf and fadePf.fade then + local cr, cg, cb, ca = love.graphics.getColor() + love.graphics.setColor(cr, cg, cb, ca * fadePf.fade) + love.graphics.draw(img, x, y, 0, scale, scale) + love.graphics.setColor(cr, cg, cb, ca) + return + end + local pf = self.picFx and self.picFx[battler] if not pf or (not pf.kind and not pf.hidden and not pf.minimized and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then @@ -5109,7 +5230,7 @@ function BattleState:drawClassic() if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then sx = self.frame % 4 < 2 and 2 or -2 end - local slide = (self.introSlide or 0) * 4 -- intro slide-in offset + local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame) if self:colorMode() then -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index ff346f8e..fa455ae6 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -183,6 +183,21 @@ local function drawCommandMenu(battle) Font.drawBox(0, 13, 20, 5) Font.drawBox(20, 13, 18, 5) love.graphics.setColor(0, 0, 0, 1) + -- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks + -- the WILD mon in battle.player as a placeholder (BattleState:1209). The + -- classic layout's demo branch never names anyone (DisplayBattleMenu, + -- core.asm:2038-2049, just draws the menu), so naming it here printed + -- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the + -- prompt side blank and run the same scripted hand the classic does. + if battle.demo then + Font.draw(Strings("FIGHT"), 176, 112) + Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112) + Font.draw(Strings("ITEM"), 176, 128) + Font.draw(Strings("RUN"), 240, 128) + -- next to FIGHT for the first 80 frames, then ITEM + Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128) + return + end Font.draw(Strings("What will"), 8, 112) local who = battle.player and battle.player.name or "" Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128) @@ -305,22 +320,40 @@ function WideBattle.draw(battle) if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then sx = battle.frame % 4 < 2 and 2 or -2 end - local slide = (battle.introSlide or 0) * 4 + local slide = (battle.introSlide or 0) * 2 -- Each side keeps its original sprite pixels and placement math: the two -- 160x144 OAM regions are translated apart and clipped into the wider -- battlefield rather than either monster being scaled. wideRegion tells - -- drawBattlerPic its own side window is already the clip. + -- drawBattlerPic its own side window is already the clip. A shake moves + -- each region's clip with its contents, so a pic pushed toward a region + -- edge is not sheared off it (a vertical shake used to clip the player's + -- feet at FIELD_BOTTOM). battle.wideRegion = true - inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, + inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) - inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, + inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) battle.wideRegion = nil - drawHUDs(battle, slide) + -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the + -- shakes move IS the whole screen: PredefShakeScreenHorizontally, + -- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow + -- displace the HUDs and the message window along with the pics, exactly as + -- drawClassic offsets its whole BG canvas. Shaking only the two pic + -- regions left the foe gliding sideways across a nailed-down screen on + -- every applying-attack shake -- TAIL WHIP and every other status move + -- (#562). The OAM anim layer stays put, as it does in drawClassic. + local function shaken(fn) + if sx == 0 and sy == 0 then return fn() end + g.push() + g.translate(sx, sy) + fn() + g.pop() + end + shaken(function() drawHUDs(battle, slide) end) drawAnimationLayer(battle) - drawTextArea(battle) + shaken(function() drawTextArea(battle) end) if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then g.setColor(1, 1, 1, 0.85) diff --git a/src/core/Game.lua b/src/core/Game.lua index 02a7b7f2..1322f2d6 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -179,6 +179,22 @@ function Game:step(dt) -- this logic tick, not one tick later. With no wrapper this is a no-op. ModRuntime.call("input.step", function() end, self, dt) self.input:step() + -- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the + -- audio, whites the palettes out and falls through into Init, i.e. the + -- power-on boot, so everything unsaved is gone and the title sequence + -- comes back -- exactly what returnToTitle does for the START menu's QUIT. + -- The check lives here, above stack:update, so it fires from any state: + -- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563). + -- Input:reset clears the four still-physically-held buttons so the title + -- screen does not read A as a menu choice on its first frame. + -- a tool mod (or a harness) may hand Game a stand-in input with no + -- chord bookkeeping; those simply never soft reset + if self.input.softResetStep and self.input:softResetStep() then + Input:reset() + TouchControls:reset() + self:returnToTitle() + return + end -- serviced unconditionally: a link battle's ENet transport must not -- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily -- on top of BattleState (see LinkBattle.new) diff --git a/src/core/Input.lua b/src/core/Input.lua index 47199f9d..cc35a3d9 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -250,8 +250,56 @@ function Input:isDown(btn) return self.state[btn] or false end +-- True when the on-screen overlay is one of the live sources holding this +-- button (see overlayPressed above). Player:turnWindow widens the +-- turn-in-place tap window on touch, where a press and release can never be +-- as short as a physical d-pad's (#415). +function Input:isTouchDown(btn) + local sources = self.sources[btn] + return (sources and sources["touch:" .. btn]) and true or false +end + function Input:wasPressed(btn) return self.pressed[btn] or false end +-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read +-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts +-- only while A, B, SELECT and START are the only buttons down; any d-pad +-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore +-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still +-- works mid-battle and mid-cutscene where ordinary input is thrown away. +-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset, +-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16 +-- consecutive polls. That hold is also what keeps the on-screen overlay +-- safe: it already takes four separate fingers on four separate controls, +-- and they all have to stay put for better than a quarter of a second. +local SOFT_RESET_FRAMES = 16 + +function Input:softResetHeld() + if not (self.state.a and self.state.b + and self.state.start and self.state.select) then + return false + end + return not (self.state.up or self.state.down + or self.state.left or self.state.right) +end + +-- Ticked once per fixed step by Game:step; true on the step the countdown +-- runs out. hSoftReset is never re-seeded on release in the original, so +-- its count leaks across a whole session; a port that copied that would +-- eventually reset on a stray four-button press, so the counter re-arms as +-- soon as the combo drops. +function Input:softResetStep() + if not self:softResetHeld() then + self.softResetFrames = nil + return false + end + local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1 + self.softResetFrames = left + if left > 0 then return false end + self.softResetFrames = nil + return true +end + return Input diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 180949b7..232fce2e 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon() completed = completed + 1 self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) end + -- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm) + -- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for + -- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the + -- ticked loop so the progress denominator stays as it was. + if self.symbols["ProfOakPicBack"] then + self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png") + end local balls = self:symbol("PokeballTileGraphics") self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64), 32, 8, "battle/balls.png", true) @@ -1826,6 +1833,35 @@ function RomExtractor:extractField() end end + -- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per + -- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in + -- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4 + -- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no + -- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9, + -- which is ROW order over a raw 25-tile sheet, hence no columns flag. + -- Script 26 shares script 11's base. The pikaframe overlays each script + -- draws ON TOP of the base are a second full pose out of the same blob and + -- stay unripped: PikachuFollower.picLift stands in for their motion + -- (#561, still on #407's stand-in). + local PIKAPIC_BASE = { + "Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b", + "Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e", + "Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924", + "Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587", + "Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf", + "Pic_e5000", "Pic_f0abf", "Pic_f0cf4", + } + if self.symbols[PIKAPIC_BASE[1]] then + for script, label in ipairs(PIKAPIC_BASE) do + local path = "pikachu/pikapic_" .. script .. ".png" + if label:sub(1, 4) == "GFX_" then + self:raw2bpp(label, 40, 40, path, { matte = true }) + else + self:writeCompressedPic(label, path) + end + end + end + self:raw1bpp("LedgeHoppingShadow", 8, 8, "fx/shadow.png", true); tick() for _, spec in ipairs({ diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 64d9cc48..9d83d649 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -63,11 +63,22 @@ local REQUIRED_FILES = { -- them re-imports on its own, without dragging the other versions through a -- CACHE_FORMAT bump. local VERSION_REQUIRED_FILES = { - yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439 + yellow = { + "assets/generated/battle/trainers/jessie_james.png", -- #439 + -- Oak's own back pic and the pikapic base frames only exist in caches + -- built after their manifest symbols landed, so an older Yellow cache + -- has to re-import to stop falling back to the old man's back pic and + -- to the battle front pic (#557, #561). Both are gated on manifest + -- symbols in RomExtractor, so these markers must only ever list files + -- tools/rom_manifest_yellow.json can actually produce -- otherwise the + -- cache reads as incomplete and re-importing cannot clear it. + "assets/generated/battle/profoakb.png", + "assets/generated/pikachu/pikapic_1.png", + }, } --- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from --- the Claude Design project): a dark neon arcade panel, one column per game. +-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup): +-- a dark neon arcade panel, one column per game. -- Red, Blue, and Yellow share the same importer flow once listed in -- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw. local PAL = { @@ -851,6 +862,8 @@ function RomImporter.new(onComplete, opts) -- Android SAF create-document: which game's SAVE FILES card should show -- "Save exported." when export_done.flag appears on focus. androidPendingExportVersion = nil, + iosPendingKind = nil, + iosPendingVersion = nil, -- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no -- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs; -- right stick scrolls the save-slot / mods lists. @@ -1035,7 +1048,11 @@ local function resetPointerCursor(self) if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then return end - self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow") + if not self.arrowCursor then + local ok, cursor = pcall(love.mouse.getSystemCursor, "arrow") + if not ok then return end + self.arrowCursor = cursor + end love.mouse.setCursor(self.arrowCursor) end @@ -1231,6 +1248,14 @@ function RomImporter:chooseMod() self:rescanModsAction() return end + if self.ios and love.system.getPickedFile then + self.iosPendingKind = "mod" + if not pickFile("mod") then + self.iosPendingKind = nil + self.modNotice = { ok = false, text = "Could not open the file picker." } + end + return + end if self.android then local name = findPendingMod(true, self.pickSkip) if name then @@ -1292,6 +1317,16 @@ end -- Android mirrors ROM / mod import via love.system.pickFile("sav"). function RomImporter:chooseSaveImport(version) if self.workState == "working" then return end + if self.ios and love.system.getPickedFile then + self.iosPendingKind = "sav" + self.iosPendingVersion = version + if not pickFile("sav") then + self.iosPendingKind = nil + self.iosPendingVersion = nil + self.saveNotice[version] = { ok = false, text = "Could not open the file picker." } + end + return + end if self.android then local name = findPendingSav(true, self.pickSkip) if name then @@ -1344,7 +1379,7 @@ function RomImporter:exportSave(version) return end self.androidPendingExportVersion = version - if love.system.createFile and love.system.createFile(suggested) then + if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then self.pickPending = true self.pickTimer = 0 self.saveNotice[version] = { ok = true, @@ -1386,6 +1421,14 @@ function RomImporter:choose(version) self:_setNxInboxNotice(self.chooseVersion) return end + if self.ios and love.system.getPickedFile then + self.iosPendingKind = "rom" + if not pickFile("rom") then + self.iosPendingKind = nil + self:setError("Could not open the file picker.") + end + return + end if self.mobileFileBridge or self.android then -- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or -- a fresh SAF pick). Never reuse an already-imported cart's file -- that @@ -1500,6 +1543,37 @@ end function RomImporter:update(dt) self.pulse = self.pulse + dt self:_updatePadCursor(dt) + if self.ios and love.system.getPickedFile and self.workState ~= "working" then + local path = love.system.getPickedFile() + if path then + local kind = self.iosPendingKind or "rom" + local version = self.iosPendingVersion + self.iosPendingKind = nil + self.iosPendingVersion = nil + if kind == "mod" then + self:_installMod(path) + elseif kind == "sav" then + self:_importSave(version or self:_savedropTarget(), path) + else + self:startPath(path) + end + elseif love.system.getPickError then + local errorText = love.system.getPickError() + if errorText then + local kind = self.iosPendingKind or "rom" + local version = self.iosPendingVersion or self:_savedropTarget() + self.iosPendingKind = nil + self.iosPendingVersion = nil + if kind == "mod" then + self.modNotice = { ok = false, text = errorText } + elseif kind == "sav" then + self.saveNotice[version] = { ok = false, text = errorText } + else + self:setError(errorText) + end + end + end + end self:_pollPickedFiles(dt) if self.workState ~= "working" or not self.worker then return end local started = love.timer.getTime() @@ -2643,7 +2717,7 @@ function RomImporter:draw() love.graphics.setFont(self.slotNameFont) col(PAL.white) - love.graphics.printf("Other versions — " .. tostring(v.name), + love.graphics.printf("Other versions: " .. tostring(v.name), dx + pad, dy + 10 * s, dw - pad * 2, "left") love.graphics.setFont(self.hintFont) local info = self:_modUpdateInfo(v.id) @@ -2750,8 +2824,11 @@ function RomImporter:draw() if self._hoverEnabled and not self._padCursorActive and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then if self._anyHover then - self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand") - love.mouse.setCursor(self.handCursor) + if not self.handCursor then + local ok, cursor = pcall(love.mouse.getSystemCursor, "hand") + if ok then self.handCursor = cursor end + end + if self.handCursor then love.mouse.setCursor(self.handCursor) end else resetPointerCursor(self) end @@ -3874,7 +3951,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged) local metaH = self.labelFont:getHeight() local rowPadV = 10 * s local chipBtnH = self.hintFont:getHeight() + 10 * s - -- LOADED sits top-right; Edit/Delete sit bottom-right — row must fit both + -- LOADED sits top-right; Edit/Delete sit bottom-right -- row must fit both -- without stacking on the same y (chip buttons are taller than the old text). local loadedH = self.warningFont:getHeight() + 6 * s local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH, diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index f69a66ac..42f5bc66 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1186,11 +1186,13 @@ R.field = { nest = f.opt(f.any) }, flyOrder = f.list(f.str), -- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle - -- back pic, the catch tutorial's old man, and the front pic the intro, - -- trainer card and Hall of Fame share. Every key is optional so a - -- conversion can replace one pic and inherit the rest. + -- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of + -- it (#557), and the front pic the intro, trainer card and Hall of Fame + -- share. Every key is optional so a conversion can replace one pic and + -- inherit the rest. playerPics = f.rec{ - back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) }, + back = f.opt(f.str), demoBack = f.opt(f.str), + oakBack = f.opt(f.str), front = f.opt(f.str) }, -- the new-game and boot config a total conversion replaces boot = f.rec{ startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index 6c974cdd..fc89cdc9 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -46,20 +46,32 @@ end -- opts.kind: "battle" | "intro" | "trainer_card" | "hof" -- opts.demo: the catch tutorial, where the old man fights in the player's -- place and stands in for the back pic +-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where +-- PROF.OAK fights in the player's place behind his own back pic -- opts.battle: the live battle, for kind == "battle" -- Returns path, trueColor. function Sprites.playerPath(data, side, opts) opts = opts or {} side = side == "back" and "back" or "front" -- one key per pic, so a conversion can replace the back and inherit the - -- rest; fieldValue folds data.field over FieldDefaults per key + -- rest; fieldValue folds data.field over FieldDefaults per key. The two + -- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557). local key = side == "front" and "front" + or (opts.oakDemo and "oakBack") or (opts.demo and "demoBack" or "back") local path = FieldDefaults.fieldValue(data, "playerPics", key) + -- ProfOakPicBack is a Yellow-only rip, so a cache imported before it + -- existed has no file there; fall back to the old man rather than hand a + -- missing path to getImage (#557) + if key == "oakBack" and path + and not require("src.render.Assets").exists(path) then + path = FieldDefaults.fieldValue(data, "playerPics", "demoBack") + end local ctx = { side = side, kind = opts.kind or "battle", demo = opts.demo and true or false, + oakDemo = opts.oakDemo and true or false, battle = opts.battle, trueColor = false, data = data, diff --git a/src/render/Pipelines.lua b/src/render/Pipelines.lua index 1aa579ab..d4ad618e 100644 --- a/src/render/Pipelines.lua +++ b/src/render/Pipelines.lua @@ -120,7 +120,7 @@ end -- shape (love.graphics.newCanvas), so accept either and nothing else. local function isCanvas(v) if type(v) == "userdata" then - return type(v.typeOf) == "function" and v:typeOf("Canvas") == true + return type(v.getWidth) == "function" and type(v.getHeight) == "function" end if type(v) == "table" then return type(v.getWidth) == "function" and type(v.getHeight) == "function" diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 5c4ee919..8c5557d8 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -32,7 +32,7 @@ Renderer.MAX_UI_HEIGHT = 576 -- would otherwise take the frame down with it. local function isCanvas(v) if type(v) == "userdata" then - return type(v.typeOf) == "function" and v:typeOf("Canvas") == true + return type(v.getWidth) == "function" and type(v.getHeight) == "function" end if type(v) == "table" then return type(v.getWidth) == "function" and type(v.getHeight) == "function" @@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass) return merged end +-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin +-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/ +-- zoneSy convert zone coords (canvas-space) into screen units. Public so a +-- render.compose mod can composite the world/UI canvases into its own layout. +function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil + if not shader then + love.graphics.setScissor(boxX, boxY, boxW, boxH) + love.graphics.draw(canvas, bx, by, 0, sx, sy) + love.graphics.setScissor() + return + end + love.graphics.setShader(shader) + -- a colors == false zone is the trueColor opt-out: its rect draws with + -- no shader at all. Nothing sets one without a mod, so a vanilla zone + -- list never toggles and issues exactly the calls it always did. + local bare = false + for _, z in ipairs(zoneList) do + local plain = z.colors == false + if plain ~= bare then + bare = plain + love.graphics.setShader(not plain and shader or nil) + end + if not plain then PaletteFX.sendColors(shader, z.colors) end + if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, + z.w * zoneSx, z.h * zoneSy, + boxX, boxY, boxW, boxH, dpiX, dpiY) then + love.graphics.draw(canvas, bx, by, 0, sx, sy) + end + end + love.graphics.setScissor() + love.graphics.setShader() +end + -- zones: optional list of SGB palette regions (see PaletteFX) in -- 160x144 UI space, applied to the UI pass. worldZones: optional -- regions in world-canvas pixels (overworld survey zoom colors each @@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones) zones = withTrueColor(zones, "ui") worldZones = withTrueColor(worldZones, "world") + -- render.compose: hand a mod the finished world + UI canvases (and their + -- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen + -- bridge, letting it lay the two passes out however it likes -- e.g. as two + -- stacked Game Boy screens, or driving one onto a second physical display. + -- The mod returns true to take over the whole window; anything else (or no + -- mod wrapping the hook) falls through to the normal composite below. + if Runtime.wantsHook("render.compose") then + local ctx = { + renderer = self, + worldCanvas = self.worldCanvas, uiCanvas = self.canvas, + worldOverride = self.worldOverride, + worldActive = self.worldActive and true or false, + zones = zones, worldZones = worldZones, + ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, + vpw = vpw, vph = vph, uiw = uiw, uih = uih, + scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY, + secondScreen = require("src.render.SecondScreen"), + } + if Runtime.call("render.compose", function() return false end, self, ctx) == true then + self.worldActive = false + self.uprightActive = false + self.worldOverride = nil + PaletteFX.setPass(nil) + return { + width = ww, height = wh, gameX = ox, gameY = oy, + gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, + } + end + end + -- A post-process pipeline needs the whole composite in a canvas for the -- same reason GBC FX does, so either one alone is enough to take the -- present path; with neither, the frame draws straight to the screen @@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones) }) end - -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by), - -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy - -- convert zone coords (canvas-space) into screen units. + -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite + -- call sites below stay unchanged. local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy, bx, by, boxX, boxY, boxW, boxH) - local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil - if not shader then - love.graphics.setScissor(boxX, boxY, boxW, boxH) - love.graphics.draw(canvas, bx, by, 0, sx, sy) - love.graphics.setScissor() - return - end - love.graphics.setShader(shader) - -- a colors == false zone is the trueColor opt-out: its rect draws with - -- no shader at all. Nothing sets one without a mod, so a vanilla zone - -- list never toggles and issues exactly the calls it always did. - local bare = false - for _, z in ipairs(zoneList) do - local plain = z.colors == false - if plain ~= bare then - bare = plain - love.graphics.setShader(not plain and shader or nil) - end - if not plain then PaletteFX.sendColors(shader, z.colors) end - if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, - z.w * zoneSx, z.h * zoneSy, - boxX, boxY, boxW, boxH, dpiX, dpiY) then - love.graphics.draw(canvas, bx, by, 0, sx, sy) - end - end - love.graphics.setScissor() - love.graphics.setShader() + return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) end if self.worldOverride then @@ -602,7 +641,11 @@ function Renderer:endFrame(zones, worldZones) -- runs, so dialogs, menus and the HUD sit on top as usual. love.graphics.setColor(1, 1, 1, 1) love.graphics.setScissor(0, 0, ww, wh) - love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY) + if love.system and love.system.getOS and love.system.getOS() == "iOS" then + love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY) + else + love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY) + end love.graphics.setScissor() -- the screen-space overlays the flat path draws over its composite local fade = self.worldFadeAlpha diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua new file mode 100644 index 00000000..c8a6efc3 --- /dev/null +++ b/src/render/SecondScreen.lua @@ -0,0 +1,59 @@ +-- Bridge to native secondary-display output (Android Presentation). The C +-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp. +-- Everything is guarded: off Android, or if the symbols cannot be resolved, +-- this stays inert and the renderer keeps the in-window stacked layout. + +local SecondScreen = {} +local C = nil + +local function log(msg) + pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) +end + +do + local ok, ffi = pcall(require, "ffi") + if not (ok and ffi) then + log("ffi unavailable (not LuaJIT); second display disabled") + else + pcall(ffi.cdef, [[ + int love_android_secondary_ready(); + void love_android_push_secondary(const void *rgba, int w, int h); + void love_android_secondary_enable(int on); + ]]) + local okLib, lib = pcall(ffi.load, "love") + if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then + C = lib + log("bridge linked via ffi.load('love')") + elseif pcall(function() return ffi.C.love_android_secondary_ready end) then + C = ffi.C + log("bridge linked via default namespace") + else + log(("bridge symbols not found (ffi.load ok=%s); second display disabled") + :format(tostring(okLib))) + end + end +end + +function SecondScreen.usable() + return C ~= nil +end + +function SecondScreen.available() + if not C then return false end + local ok, r = pcall(C.love_android_secondary_ready) + return ok and r ~= 0 +end + +function SecondScreen.push(imageData, w, h) + if not C or not imageData then return false end + return pcall(function() + C.love_android_push_secondary(imageData:getFFIPointer(), w, h) + end) +end + +function SecondScreen.setEnabled(on) + if not C then return end + pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) +end + +return SecondScreen diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 8ffea44d..ca7462d1 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -66,6 +66,7 @@ local function withdraw(game) end game.stack:push(ListMenu.new(game, Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, { + noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) local mon = box[item.value] if not mon then return end @@ -112,6 +113,7 @@ local function deposit(game) table.insert(items, { label = monLabel(game, mon), value = i }) end game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, { + noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) local mon = game.save.party[item.value] if not mon then return end @@ -157,6 +159,7 @@ local function release(game) end game.stack:push(ListMenu.new(game, Strings("BOX %d (RELEASE)", game.save.currentBox), items, { + noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(_, list) local mon = box[list.index] if not mon then return end @@ -189,6 +192,7 @@ local function changeBox(game) }) end game.stack:push(ListMenu.new(game, "CHANGE BOX", items, { + noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) -- the original asks BEFORE switching ("When you change a #MON -- BOX, data will be saved. OK?"); declining aborts the change diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 3ada61b3..2f41dec5 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts) -- bottom text box -- same row budget as the mart, without the money box. self.messageBox = opts.messageBox self.money = opts.money -- () -> current money for the box + -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag + -- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm), + -- so their lists opt out of the A/B beep the same way Menu's noSound does + self.noSound = opts.noSound or false self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) return self end @@ -101,6 +105,17 @@ local function syncScroll(self) if self.index - self.scroll < 1 then self.scroll = self.index - 1 end end +-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched +-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT +-- (home/list_menu.asm), so the SELECT swap key stays silent (#570) +local function beep(self) + -- game.data is nil under the UI harnesses that drive a list with a stub + -- game (tests/engine/rebind_capture_bug510.lua), where there is no audio + -- cache to play out of + if self.noSound or not (self.game and self.game.data) then return end + require("src.core.Sound").play(self.game.data, "Press_AB") +end + -- edge press or key-repeat tick for a held direction local function navPressed(self, dir) if dir == "up" then @@ -126,6 +141,7 @@ function ListMenu:update(dt) local input = self.game.input if #self.items == 0 then if input:wasPressed("a") or input:wasPressed("b") then + beep(self) self.game.stack:pop() if self.onCancel then self.onCancel() end end @@ -148,10 +164,12 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then + beep(self) self.game.stack:pop() if self.onCancel then self.onCancel() end return elseif input:wasPressed("a") then + beep(self) local item = self.items[self.index] if self.onChoose then self.onChoose(item, self) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 296bd50f..8b0fd0a9 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -108,20 +108,34 @@ function Menu:draw() love.graphics.setColor(0, 0, 0, 1) local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) or #self.items + -- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior + -- row and let any slack fall as a blank row under the top edge -- + -- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 / + -- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The + -- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and + -- row r counts back up from there. Anchoring from the top instead only + -- agrees when th is exactly visible * rowStep + 2, which is Menu.new's + -- default but NOT what a caller sizing its own box passes: BagMenu's + -- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's + -- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto + -- the bottom border (#564, #572). for row = 1, visible do local item = self.items[self.scroll + row] if not item then break end Font.draw(item.label, (self.tx + 2) * 8, - (self.ty + row * self.rowStep - (self.rowStep - 1)) * 8) + (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8) end local cursorRow = self.index - self.scroll Font.drawCode(Theme.cursor, (self.tx + 1) * 8, - (self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8) + (self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8) -- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState - -- use, sat on the bottom border like TextBox's page-advance cursor + -- use, sat on the bottom border like TextBox's page-advance cursor. It + -- has to be the border row, not ty + th - 2: that is the last interior + -- row, which the last choice now occupies, and Menu.new widens the box to + -- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564). if self.maxVisible and self.scroll + self.maxVisible < #self.items then Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8, - (self.ty + self.th - 2) * 8) + (self.ty + self.th - 1) * 8) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 847b1017..82cf5b43 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -448,10 +448,21 @@ function OptionsMenu:update(dt) elseif row and row.step then changed = row.step(self.game, dir) and true or false elseif input:wasPressed("a") then -- CANCEL + -- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the + -- only spot in this menu that plays SFX_PRESS_AB: A on a setting row + -- and the Left/Right toggles stay silent (#570). game.data is nil + -- under the stub games the UI harnesses drive. + if self.game.data then + require("src.core.Sound").play(self.game.data, "Press_AB") + end self.game.stack:pop() if self.onCancel then self.onCancel() end end elseif input:wasPressed("b") or input:wasPressed("start") then + -- .exitMenu again: B and START leave the same way, sound and all + if self.game.data then + require("src.core.Sound").play(self.game.data, "Press_AB") + end self.game.stack:pop() if self.onCancel then self.onCancel() end end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index feff3630..0579d656 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -310,6 +310,14 @@ function PartyMenu:update(dt) local input = self.game.input local party = self.party or self.game.save.party + -- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu + -- (engine/menus/start_sub_menus.asm .chosePokemon) both run through + -- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570). + -- game.data is nil under the stub games the UI harnesses drive. + if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + if self.submenu then local n = #self.subItems if input:wasPressed("up") then diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index eb07c939..5e31ca39 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -73,6 +73,7 @@ local function withdraw(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { messageBox = true, + noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) local Bag = require("src.inventory.Bag") @@ -110,6 +111,7 @@ local function deposit(game) end game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { messageBox = true, + noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) if pcFull(game, pc, item.value) then @@ -130,6 +132,7 @@ local function toss(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { messageBox = true, + noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) local def = game.data.items[item.value] if (def and def.keyItem) or item.value:find("^HM_") then diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 92c385f9..656b890c 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts) footer = Strings("SEEN %d OWNED %d", seen, owned), pageJump = true, -- Left/Right page jumps like the original onCancel = opts.onCancel, -- B returns to the start menu when opened from it - onChoose = function(item) + onChoose = function(item, dexList) if not item.value then return end -- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm -- PokedexMenuItemsText); CRY keeps the side menu open like the - -- original, QUIT returns to the list + -- original. B is what returns to the list: HandlePokedexSideMenu + -- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends + -- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571) local Menu = require("src.ui.Menu") local Screens = require("src.ui.Screens") local entries = { @@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts) or Strings("Printer error!\n%s", tostring(err)))) end } end - entries[#entries + 1] = { label = Strings("QUIT") } + entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function() + -- .exitPokedex: drop the dex list too and hand back to whoever + -- opened it (the start menu, whose saved cursor is still on + -- POKéDEX -- wBattleAndStartSavedMenuItem) + dexList:close() + if opts.onCancel then opts.onCancel() end + end } game.stack:push(Menu.new(game, entries, { tx = 12, ty = 8, tw = 8, th = #entries * 2 + 2 })) diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index f62efdfc..00b038d5 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -89,6 +89,11 @@ local PLAYER_SPRITES = { local PLAYER_PICS = { back = "assets/generated/battle/redb.png", demoBack = "assets/generated/battle/oldmanb.png", + -- LoadPlayerBackPic keys the demo pic off wBattleType, not off "is a + -- demo": BATTLE_TYPE_PIKACHU (Yellow's Pallet catch scene) puts PROF.OAK + -- in the player's place with his own back pic, not the old man's (#557). + -- Red/Blue never reach it -- only the Yellow script names that thrower. + oakBack = "assets/generated/battle/profoakb.png", front = "assets/generated/trainer_card/red.png", } @@ -185,7 +190,7 @@ FieldDefaults.CONSTANTS = { neighborHops = 2, -- connection hops drawn around the current map stepFrames = 16, -- 1px per frame, 16 frames per tile bikeStepFrames = 8, -- the bicycle doubles walking speed - turnFrames = 2, -- the extra OverworldLoop pass after a turn + turnFrames = 4, -- tap window before a turn commits to a step }, -- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm) encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 }, diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 81b76b00..3d282e17 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1155,6 +1155,13 @@ function OverworldState:handleInput() end end + -- .noDirectionButtonsPressed (home/overworld.asm) is the only place that + -- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found + -- no direction held -- is what re-arms the next turn in place. The early + -- returns above (mid-step, A, START) skip it exactly as the original's + -- jumps to .moveAhead and .displayDialogue do (#415). + self.player.turnArmed = true + -- Cycling Road's downhill pull: with no d-pad held the bike rolls -- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN). -- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B @@ -1891,9 +1898,20 @@ function OverworldState:tryHiddenObject(fx, fy) end end - -- bench guys (data/events/bench_guys.asm) + -- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte + -- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own + -- macro comment says the SPRITE_FACING_* values parked there "do not + -- actually prevent the player from interacting with them in any + -- direction" (data/events/hidden_events.asm). The facing that does decide + -- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers, + -- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as + -- `textFacing`. Gating on the hidden_event byte instead silenced the four + -- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia, + -- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from + -- the right (#488). for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do - if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then + local want = h.textFacing or h.facing + if h.x == fx and h.y == fy and (not want or want == facing) then local text = OverworldState.benchGuyText(Game.data, save, h.text) if text then Game.stack:push(TextBox.new(Game, text)) @@ -3829,7 +3847,15 @@ end -- dev-mode hot reload). The neighbors go too: their strips render the -- same tileset. When the active map is the one that changed, the player -- is clamped back in bounds, the NPC pool is reused so runtime handles --- survive, and the tile-pair table is re-read. +-- survive, and the tile-pair table is re-read. keepMusic: a reload is not a +-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only +-- re-reads the map view and the tileset tile patterns after the Pokedex / +-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone +-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing +-- belongs to the state on top, so a COLORS cycle during a battle +-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must +-- not drop the route theme over the battle song (#484). The out-of-bounds +-- fallback below is a real map change and keeps its map music. function OverworldState:reloadMap(mapId, reason) MapLoader.invalidate(mapId) for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end @@ -3837,7 +3863,8 @@ function OverworldState:reloadMap(mapId, reason) local p = self.player local x, y, facing = p.cellX, p.cellY, p.facing Collision.load(Game.data) - self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" }) + self:setMap(mapId, x, y, facing, + { seamless = true, via = "reload", keepMusic = true }) if not self.map:inBounds(x, y) then local heal = self:healPoint() Logger.warn("map %s reloaded out from under the player; sending to %s", @@ -4568,9 +4595,9 @@ function OverworldState:drawUI() -- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm -- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a -- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The - -- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the - -- front pic stands in for every frame of the script; PikachuFollower - -- .picLift lifts it on the runs that draw the alternate pose, and the + -- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the + -- pikaframe overlays on top of it are not, so PikachuFollower + -- .picLift lifts the base on the runs that draw the alternate pose, and the -- script's own duration times the beat (#407, #424). Palette zone -- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above. if self.emote and self.emote.pikaPic then diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 636751dc..c85323a7 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -518,9 +518,12 @@ end -- pikachu_emotions.asm): pick a scripted emotion, then play its bubble -- and voiced PCM clip, and raise the framed Pikachu picture the original -- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm --- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The --- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted, --- so the front pic stands in for all twenty of them (#407). +-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each +-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the +-- pikaframe overlays it alternates with are a second full-body pose out of +-- the same blob and are still unported, so picLift below stands in for +-- their motion and the battle front pic covers caches built before the +-- rip (#407). -- --------------------------------------------------------------------- -- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm @@ -715,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done) -- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame; -- Sprites.path keeps a mod's replacement skin in play. local Sprites = require("src.pokemon.Sprites") - local pic = Sprites.path(game.data, "PIKACHU", "front", - { kind = "overworld" }) - local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1] + -- The chosen script's own base frame (its first pikapic_loadgfx, ripped as + -- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches + -- built before the rip do not carry it, so both fall back to the battle + -- front pic that stood in for every script before (#561). + local script = PIKAPIC_SCRIPT[emotion] or emotion + local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png" + if not require("src.render.Assets").exists(pic) then + pic = Sprites.path(game.data, "PIKACHU", "front", + { kind = "overworld" }) + end + local anim = PIKAPIC[script] or PIKAPIC[1] local hold = anim.dur * PIKAPIC_TICK ow.emote = { npc = npc, frames = hold, bubble = bi or false, pikaPic = pic, @@ -790,10 +801,22 @@ function PikachuFollower.onBillEnteredMachine(game, ow) if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end local npc = findFollower(ow) if not npc then return end + -- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two + -- movement tables are named the wrong way round in the cartridge source: + -- hl is seeded with ..._EnterCellSeparatorDown, then + -- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0) + -- jr nz, .applyPikachuMovement + -- keeps that seeded table when the player is NOT facing down, and only the + -- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown. + -- So facing down takes the long way round the cell separator and every + -- other facing walks straight up. Following the label names instead of the + -- branch inverts the scene (#455). local steps = ow.player.facing == "down" - and { { "up", 3 } } - or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } } + and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } } + or { { "up", 3 } } movePikachu(ow, npc, steps, function() + -- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble + npc.facing = "up" billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE") end) end diff --git a/src/world/Player.lua b/src/world/Player.lua index 7c0e58e1..e1a206f3 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -15,11 +15,21 @@ local STEP_FRAMES = 16 -- a turn in place blocks movement for the one extra OverworldLoop pass the -- original spends after a direction change: .handleDirectionButtonPress ends -- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two --- DelayFrame calls before the next JoypadOverworld, so the sample that can --- commit to a step lands exactly 2 fixed steps after the turn -- the same --- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above --- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415) -local TURN_FRAMES = 2 +-- DelayFrame calls before the next JoypadOverworld, so the step can only +-- commit at the following poll -- the same 2-frames-per-iteration cadence +-- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per +-- AdvancePlayerSprite). +-- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1 +-- frames after the d-pad physically goes down and the release deadline lands +-- 2 or 3 frames after that. We sample on the frame the button goes down +-- with none of that poll latency, so a flat 2 handed every tap the tightest +-- case the original could produce; 4 covers the grid instead of +-- undercutting it (#415). +local TURN_FRAMES = 4 +-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release +-- run well past it even before the OS batches the touch events, so the +-- overlay gets a longer window than a physical pad (#415). +local TOUCH_TURN_FRAMES = 8 function Player.new(data, cx, cy, facing) local self = setmetatable({}, Player) @@ -67,6 +77,11 @@ function Player.new(data, cx, cy, facing) self.progress = 0 self.stepFlip = false self.turnTimer = 0 + -- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a + -- turn in place happen on a poll whose previous pass found no direction + -- held. It starts armed, tryMove spends it, and OverworldState:handleInput + -- re-arms it from a standstill. + self.turnArmed = true self.inputLocked = false return self end @@ -75,14 +90,35 @@ function Player:position() return self.cellX, self.cellY end +-- How long a fresh turn holds the step off for; see TURN_FRAMES. The +-- overlay is detected per source rather than by whether the touch controls +-- are on screen, so a phone with a controller attached still gets the +-- physical pad's window (Input:isTouchDown, src/core/Input.lua). +function Player:turnWindow() + local frames = self.turnFrames or TURN_FRAMES + local input = require("src.core.Game").input + if input and input.isTouchDown and input:isTouchDown(self.facing) then + return math.max(frames, TOUCH_TURN_FRAMES) + end + return frames +end + -- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil. function Player:tryMove(dir, map, entities) if self.moving or self.inputLocked then return nil end if self.facing ~= dir then self.facing = dir - self.turnTimer = self.turnFrames or TURN_FRAMES self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle - return "turned" + -- .handleDirectionButtonPress only reaches the turn while + -- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is + -- the one place that sets it (home/overworld.asm), so a facing change + -- made without the d-pad ever coming up steps straight away rather than + -- paying the turn delay at every corner (#415) + if self.turnArmed then + self.turnArmed = false + self.turnTimer = self:turnWindow() + return "turned" + end end if self.turnTimer > 0 then return nil end local ok, why = Collision.canMove(map, entities, self, dir) diff --git a/tests/drivers/badge_cancel_bug569_test.lua b/tests/drivers/badge_cancel_bug569_test.lua new file mode 100644 index 00000000..28012a7f --- /dev/null +++ b/tests/drivers/badge_cancel_bug569_test.lua @@ -0,0 +1,135 @@ +-- The Cerulean badge man's list needs a CANCEL row (#569). +-- CeruleanBadgeHouseMiddleAgedManText loops DisplayListMenuID over +-- .BadgeItemList and takes `jr c, .done` out (pokered +-- scripts/CeruleanBadgeHouse.asm); PrintListMenuEntries prints +-- ListMenuCancelText at the list's $FF terminator, so CANCEL is a real row +-- and choosing it sets carry the same way B does. +-- POKEPORT_DRIVER=tests/drivers/badge_cancel_bug569_test.lua POKEPORT_IDENTITY=bug569 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local ListMenu = require("src.ui.ListMenu") + local TextBox = require("src.render.TextBox") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- pokered data/maps/objects/CeruleanBadgeHouse.asm: the middle-aged man is + -- object_event 5, 3, STAY, so he never wanders off the tile; the floor to + -- his west is the natural approach. + local MAP = "CERULEAN_BADGE_HOUSE" + local NPC = "CERULEANBADGEHOUSE_MIDDLE_AGED_MAN" + local STAND = { x = 4, y = 3, facing = "right" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- a renamed or unextracted text label reads on screen exactly like the + -- missing row did: nothing happens + local t = game.data.text + for _, key in ipairs({ + "_CeruleanBadgeHouseMiddleAgedManText", + "_CeruleanBadgeHouseMiddleAgedManWhichBadgeText", + "_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText", + "_CeruleanBadgeHouseBoulderBadgeText", + }) do + check(key .. " resolves to a string", type(t[key]) == "string") + end + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + local function manIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == NPC then return n end + end + return nil + end + + local function facingTheMan() + local ow = game.overworld + local man = ow and manIn(ow) + if not man then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == man + end + + local ow = game.overworld + local man = ow and manIn(ow) + check("the badge man is on " .. MAP, man ~= nil) + + if man and not facingTheMan() then + -- a map edit moved him: take any free walkable neighbour instead. + -- {dx, dy, facing} is the offset from him to the stand cell plus the + -- direction that looks back, so +1 on x means facing left. + local sides = { + { -1, 0, "right" }, { 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" }, + } + for _, s in ipairs(sides) do + local cx, cy = man.cellX + s[1], man.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("approach cell is blocked, standing on", cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("player is standing in front of the badge man", facingTheMan()) + + -- talk, then mash through the greeting and the "Which badge?" prompt + U.tap(game, "a") + U.wait(20) + -- the greeting runs three pages before the "Which badge?" prompt, so give + -- the mash room; an A landing mid-type only speeds that page up + local list + for _ = 1, 60 do + local top = game.stack:top() + if getmetatable(top) == ListMenu then list = top break end + if getmetatable(top) == TextBox then U.tap(game, "a") end + U.wait(10) + end + check("talking to him opened the badge list", list ~= nil) + + if list then + local labels = {} + for i, item in ipairs(list.items) do labels[i] = item.label end + U.log("badge list rows:", table.concat(labels, " ")) + check("the list has the eight badges plus one more row", #list.items == 9) + local last = list.items[#list.items] + check("the last row is CANCEL", last.label == "CANCEL") + check("the CANCEL row carries no badge to describe", last.value == nil) + + -- scroll it into view so the screenshot shows the row, not just the data + for _ = 1, #list.items do + if list.index == #list.items then break end + U.tap(game, "down") + U.wait(3) + end + check("cursor reached CANCEL", list.index == #list.items) + check("badge list screenshot", + U.shot(game, SHOT_DIR .. "/bug569_badge_list.png")) + + U.tap(game, "a") + U.wait(25) + local top = game.stack:top() + check("CANCEL closed the list", getmetatable(top) ~= ListMenu) + check("CANCEL printed the goodbye line, like B does", + getmetatable(top) == TextBox) + check("goodbye screenshot", + U.shot(game, SHOT_DIR .. "/bug569_after_cancel.png")) + U.tap(game, "a") + U.wait(25) + end + + U.log("The goodbye line has been dismissed; press A on the man to run the") + U.log("list again. CANCEL should be the ninth row under EARTHBADGE and") + U.log("should close the whole conversation with \"Visit any time\" -- the") + U.log("near miss is a CANCEL that only closes the list and drops you back") + U.log("on the \"Which badge?\" prompt. The badge rows above it are the") + U.log("control: each prints its description and returns to the list.") + U.log("Screenshots are under " .. SHOT_DIR) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/battle_colors_bug484_test.lua b/tests/drivers/battle_colors_bug484_test.lua new file mode 100644 index 00000000..b9452631 --- /dev/null +++ b/tests/drivers/battle_colors_bug484_test.lua @@ -0,0 +1,196 @@ +-- Ear check: cycling COLORS with 2 during a battle keeps the battle song (#484). +-- The COLORS change rebuilds the live map's atlas through reloadMap, which used +-- to re-enter the map and fire PlayMapMusic; ReloadMapData (home/reload_tiles.asm) +-- only re-reads the view and tile patterns, map music comes from LoadMapData +-- alone (home/overworld.asm). The invariants are in tests/engine/battle_colors_bug484.lua. +-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug484_test.lua POKEPORT_IDENTITY=bug484 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own +-- real-time accumulator in Game:update, so it desyncs the ordering being judged. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Music = require("src.core.Music") + local PaletteFX = require("src.render.PaletteFX") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + -- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and + -- (15,13) and the sign at (9,27), so the top of the road is empty; the + -- battle is pushed straight in, the cell is only somewhere to stand. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 6, facing = "down" } + local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the ear cannot check ----------------------------------------- + local opts = game.save.options or {} + local musicVol = opts.musicVol or 7 + local sfxVol = opts.sfxVol or 7 + if musicVol == 0 then + U.log("FAIL MUSIC volume is 0. Everything below is about which song is") + U.log(" playing, and at 0 there is no song. Set MUSIC to 7 in OPTION") + U.log(" and run this again, or the whole check is worthless.") + end + if sfxVol == 0 then + U.log("FAIL SFX volume is 0, so the battle menu's own click is gone too") + U.log(" and there is no way to tell a dead audio path from a fixed bug.") + U.log(" Set SFX to 7 in OPTION.") + end + check(("MUSIC volume %d, SFX volume %d"):format(musicVol, sfxVol), + musicVol > 0 and sfxVol > 0) + + local battleSongs = (game.data.audio or {}).battle or {} + local mapSongs = (game.data.audio or {}).mapSongs or {} + check(MAP .. " has a map theme to wrongly cut in (" .. + tostring(mapSongs[MAP]) .. ")", mapSongs[MAP] ~= nil) + check("the wild battle theme exists (" .. tostring(battleSongs.wild) .. ")", + battleSongs.wild ~= nil) + check("they are different songs, so the swap would be audible", + battleSongs.wild ~= nil and battleSongs.wild ~= mapSongs[MAP]) + U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", ")) + + -- Every music cue in the run, forwarded so playback is untouched. A map + -- cue raised while a battle owns the screen is the bug, and it is the one + -- thing an ear can miss if the two songs happen to open alike. + local cues = {} + local realPlay, realPlayMap = Music.play, Music.playMap + Music.play = function(data, song, loop, ctx) + cues[#cues + 1] = { song = song, reason = ctx and ctx.reason or "direct" } + return realPlay(data, song, loop, ctx) + end + Music.playMap = function(data, mapId, onBike, surfing) + cues[#cues + 1] = { song = "(playMap)", reason = "map", mapId = mapId } + return realPlayMap(data, mapId, onBike, surfing) + end + local function mapCuesSince(mark) + local hits = {} + for i = mark + 1, #cues do + if cues[i].reason == "map" then hits[#hits + 1] = cues[i].song end + end + return hits + end + local function lastSong() + for i = #cues, 1, -1 do + if cues[i].song ~= "(playMap)" then return cues[i].song end + end + return nil + end + + -- ---- get into a battle -------------------------------------------------- + game.save.party = {} + for _, slot in ipairs(PARTY) do + table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2])) + end + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the road: any free cell on Route 1 will do, the + -- battle is pushed rather than walked into + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(20) + ow = game.overworld + break + end + end + end + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + U.wait(60) -- let the route theme actually get going first + U.log(" route theme:", tostring(lastSong())) + + local battle = BattleState.newWild(game, "RATTATA", 5) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + for _ = 1, 120 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu") + + local songInBattle = lastSong() + check("the battle theme is what is playing (" .. tostring(songInBattle) .. ")", + songInBattle == battleSongs.wild) + U.shot(game, DIR .. "/bug484_1_" .. tostring(PaletteFX.mode) .. ".png") + + -- ---- press 2, for real -------------------------------------------------- + local before = PaletteFX.mode + local mark = #cues + game:keypressed("2") + U.wait(30) + check("pressing 2 in a battle still changed COLORS (" + .. PaletteFX.modeLabel(before) .. " -> " + .. PaletteFX.modeLabel(PaletteFX.mode) .. ")", + PaletteFX.mode ~= before) + local strays = mapCuesSince(mark) + check("no map music cue came out of it (#484)", #strays == 0) + check("the battle theme is still the current song", + lastSong() == songInBattle) + U.shot(game, DIR .. "/bug484_2_" .. tostring(PaletteFX.mode) .. ".png") + + -- the whole ladder, because a partial fix can leak on one mode only + local seen = { [before] = true, [PaletteFX.mode] = true } + local leaked = {} + for _ = 2, #PaletteFX.MODES do + local m = #cues + game:keypressed("2") + U.wait(20) + seen[PaletteFX.mode] = true + for _, s in ipairs(mapCuesSince(m)) do + leaked[#leaked + 1] = PaletteFX.modeLabel(PaletteFX.mode) .. " (" .. s .. ")" + end + end + local modes = 0 + for _ in pairs(seen) do modes = modes + 1 end + check(("all %d COLORS modes were reachable from inside the battle"):format(modes), + modes == #PaletteFX.MODES) + check("no mode on the ladder started the route theme", #leaked == 0) + if #leaked > 0 then U.log(" leaked on:", table.concat(leaked, ", ")) end + check("still the battle theme after the full ladder", + lastSong() == songInBattle) + U.shot(game, DIR .. "/bug484_3_" .. tostring(PaletteFX.mode) .. ".png") + + -- ---- over to you -------------------------------------------------------- + U.log("You are at the battle menu with the wild theme running. Press 2 a few") + U.log("times: the wash behind the two pics, RATTATA's shading and the HP bars") + U.log("move mode to mode (shots 1 and 2 are one press apart), and the music") + U.log("underneath keeps going without a seam. #484 dropped Route 1's theme") + U.log("over it on the first press. The near miss to listen for is the battle") + U.log("theme restarting from its opening bar rather than being replaced, and") + U.log("the near miss to look for is nothing moving at all -- a hotkey switched") + U.log("off in battle is not a fix. A on the menu clicks, so that click is your") + U.log("proof the audio path is up at all.") + U.log("Shots: " .. DIR .. "/bug484_*.png") + + -- keeps reporting after the hand-off: a map cue with a battle on top is the + -- bug whether or not the two songs are easy to tell apart by ear + local reported = #cues -- only what happens from the hand-off on + while true do + local top = game.stack:top() + local inBattle = top == battle or (top and top.battle == battle) + if inBattle and #cues > reported then + for i = reported + 1, #cues do + if cues[i].reason == "map" then + U.log("FAIL map music cue during the battle:", tostring(cues[i].song), + tostring(cues[i].mapId)) + end + end + end + reported = #cues + coroutine.yield() + end +end diff --git a/tests/drivers/bike_shop_bug568_test.lua b/tests/drivers/bike_shop_bug568_test.lua new file mode 100644 index 00000000..de37a218 --- /dev/null +++ b/tests/drivers/bike_shop_bug568_test.lua @@ -0,0 +1,186 @@ +-- Look at the two boxes a test cannot judge: the bike shop's BICYCLE/CANCEL +-- price window (#568) and a Pokemon Center bench guy answering A (#488). +-- Oracle: scripts/BikeShop.asm BikeShopClerkText (TextBoxBorder 0,0 b=4 c=15 +-- over BikeShopMenuText/BikeShopMenuPrice) and data/events/bench_guys.asm. +-- The logic halves are asserted in tests/parity_bike_shop_clerk_bug568.lua +-- and tests/parity_bench_guy_facing_bug488.lua. +-- POKEPORT_DRIVER=tests/drivers/bike_shop_bug568_test.lua POKEPORT_IDENTITY=bug568 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function boxOnScreen() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local lines = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return top, table.concat(lines, " / ") + end + + -- A missing string or a renamed label goes quiet in exactly the way both + -- bugs did, so resolve everything first and say so before anyone looks. + local t = game.data.text + for _, label in ipairs({ "_BikeShopClerkWelcomeText", + "_BikeShopClerkDoYouLikeItText", "_BikeShopCantAffordText", + "_BikeShopComeAgainText", "_VermilionPokecenterGuyText" }) do + check(label .. " resolves", type(t[label]) == "string" and t[label] ~= "") + end + check("BICYCLE is an item the window can name", + game.data.items.BICYCLE ~= nil) + + local seat = (game.data.field.hiddenExtras.benchGuys.VERMILION_POKECENTER + or {})[1] + check("the Vermilion bench guy is in the generated data", seat ~= nil) + check("his seat is faced from the left (BenchGuyTextPointers)", + seat and seat.textFacing == "left") + + if failures > 0 then + U.log(failures, "check(s) failed above; the screens below will not tell") + U.log("you anything the log has not already said.") + end + + -- ------------------------------------------------------------- #488 + -- data/events/hidden_events.asm parks the seat at (0,4); it is the bench + -- wall itself, so the player stands on the floor tile east of it. The + -- cell north of the seat is walkable too and is the control: in the + -- original, PrintBenchGuyText only answers a LEFT facing, so an A press + -- from up there does nothing, and that is correct. + local BENCH = { map = "VERMILION_POKECENTER", x = seat and seat.x or 0, + y = seat and seat.y or 4 } + + U.teleport(game, BENCH.map, BENCH.x, BENCH.y - 1, "down") + U.wait(10) + U.tap(game, "a") + U.wait(20) + check("facing the seat from the north stays silent, like the original", + boxOnScreen() == nil) + + -- the seat's own approach, with a fallback in case a map edit walls it in + local stand = { x = BENCH.x + 1, y = BENCH.y, facing = "left" } + if not game.overworld.map:isWalkableCell(stand.x, stand.y) then + for _, side in ipairs({ { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" } }) do + local cx, cy = BENCH.x + side[1], BENCH.y + side[2] + if game.overworld.map:isWalkableCell(cx, cy) then + U.log("the floor east of the seat is gone; standing at", cx, cy) + stand = { x = cx, y = cy, facing = side[3] } + break + end + end + end + U.teleport(game, BENCH.map, stand.x, stand.y, stand.facing) + U.wait(10) + U.tap(game, "a") + U.wait(90) -- let the first page finish typing before the shot + local box, text = boxOnScreen() + check("A from the east opens the bench guy's box (#488)", box ~= nil) + if box then + U.log("bench guy reads:", text) + check("it is his line about no universally strong POKeMON", + text:find("universally", 1, true) ~= nil) + check("screenshot on disk", + U.shot(game, SHOT_DIR .. "/bug488_bench_guy.png")) + U.log("captured", SHOT_DIR .. "/bug488_bench_guy.png") + U.tap(game, "a") + U.wait(20) + end + + -- ------------------------------------------------------------- #568 + -- pokered data/maps/objects/BikeShop.asm: the clerk stands at (6,2) + -- behind the counter row, so the player talks across it from (6,4). + -- Clear the bike and the voucher so the talk takes the sales pitch. + game.save.inventory.BICYCLE = nil + game.save.inventory.BIKE_VOUCHER = nil + game.save.flags.EVENT_GOT_BICYCLE = nil + + U.teleport(game, "BIKE_SHOP", 6, 4, "up") + U.wait(10) + + local ow = game.overworld + local clerk + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "BIKESHOP_CLERK" then clerk = n end + -- the middle-aged woman paces; park her so she cannot wander into shot + if n.def and n.def.name == "BIKESHOP_MIDDLE_AGED_WOMAN" then + n.frozen = true + end + end + check("the clerk is on the map", clerk ~= nil) + + -- talking across a counter reads one cell further, so the stand cell is + -- two south of him; fall back to any free neighbour if the shop is redrawn + local function facingTheClerk() + local w = game.overworld + local fx, fy = w.player:facingCell() + if w:npcAtCell(fx, fy) == clerk then return true end + if not w.map:isCounterCell(fx, fy) then return false end + local Collision = require("src.world.Collision") + local bx, by = Collision.target(fx, fy, w.player.facing) + return w:npcAtCell(bx, by) == clerk + end + if clerk and not facingTheClerk() then + for _, side in ipairs({ { 0, 2, "up" }, { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } }) do + local cx, cy = clerk.cellX + side[1], clerk.cellY + side[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("(6,4) no longer reaches the counter; standing at", cx, cy) + U.teleport(game, "BIKE_SHOP", cx, cy, side[3]) + U.wait(10) + break + end + end + end + check("the player is at the counter", facingTheClerk()) + + U.tap(game, "a") + U.wait(20) + local _, welcome = boxOnScreen() + check("A opens the welcome line", welcome ~= nil) + if welcome then U.log("clerk reads:", welcome) end + + -- mash through the welcome box; the pitch box then types itself out and + -- pops on its own, handing the screen to the price window + local window + for _ = 1, 200 do + local top = game.stack:top() + if top ~= game.overworld and getmetatable(top) ~= TextBox then + window = top + break + end + U.tap(game, "a") + U.wait(4) + end + check("the price window is up and holding input (#568)", window ~= nil) + if window then + check("the cursor starts on BICYCLE", window.index == 1) + check("it kept the pitch line for the bottom box", + type(window.footer) == "table" and #window.footer > 0) + check("screenshot on disk", + U.shot(game, SHOT_DIR .. "/bug568_price_window.png")) + U.log("captured", SHOT_DIR .. "/bug568_price_window.png") + end + + U.log("The window on screen is the shop menu: BICYCLE over CANCEL at the") + U.log("top left, \194\1651000000 beside the BICYCLE row, cursor on BICYCLE, and") + U.log("\"It's a cool BIKE! Do you want it?\" still in the bottom box under it.") + U.log("Input is live. Up and down move between the two rows and clamp -- the") + U.log("cursor must not wrap. A buys, B or CANCEL backs out; either way the") + U.log("window has to stay on screen behind the closing lines and only") + U.log("disappear with \"Come back again some time!\". A window that vanishes") + U.log("the moment you answer, or a bottom box that blanks while the menu is") + U.log("up, is the near miss to watch for. Talk to him again to replay it.") + U.log("The boy by the door is the control: he has no menu, just a line.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/eevee_shove_bug559_test.lua b/tests/drivers/eevee_shove_bug559_test.lua new file mode 100644 index 00000000..62c43921 --- /dev/null +++ b/tests/drivers/eevee_shove_bug559_test.lua @@ -0,0 +1,216 @@ +-- Manual check that the rival shoves Red off the EEVEE ball on the right +-- beat (#559). pokeyellow scripts/OaksLab.asm: OaksLabChoseStarterScript +-- queues DOWN + RIGHT x3, and .asm_1c564 polls until wNPCNumScriptedSteps +-- reads 1 before it simulates the PAD_RIGHT x2 -- so the push starts as the +-- rival steps onto the player's own tile, not when he leaves the table. +-- No POKEPORT_SPEED: the whole point is which frame the player starts on. +-- No POKEPORT_IDENTITY either -- a sandbox save dir has no Yellow cache and +-- boots stale Red data underneath a Yellow-looking run. +-- POKEPORT_DRIVER=tests/drivers/eevee_shove_bug559_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local MapScripts = require("src.script.MapScripts") + local Commands = require("src.script.Commands") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + -- Red and Blue load data/scripts/oaks_lab.lua instead (three starter + -- balls, no shove at all), so every line below would fail for the wrong + -- reason on the Red cache. + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Yellow is the only version with an EEVEE ball. Re-run with") + U.log("POKEPORT_VERSION=yellow.") + idle() + end + + -- pokeyellow data/maps/objects/OaksLab.asm: OAKSLAB_RIVAL (4,3), + -- OAKSLAB_EEVEE_POKE_BALL (7,3), OAKSLAB_OAK1 (5,2). The player talks to + -- the ball from (7,4), and that y == 4 is what selects the shove branch + -- in both the original (.asm_1c564 tests wYCoord) and the port. + local MAP = "OAKS_LAB" + local BALL = "OAKSLAB_EEVEE_POKE_BALL" + local RIVAL_INDEX = 1 + local RIVAL_HOME = { x = 4, y = 3 } + local STAND = { x = 7, y = 4, facing = "up" } + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + -- data/scripts/init.lua picks oaks_lab_yellow over oaks_lab on the Yellow + -- cache; if the Red module won, the ball is one of three starters and + -- nothing below means anything. Asked after the teleport because the + -- registry only fills when OverworldState first requires it. + local labModule = require("data.scripts.oaks_lab_yellow") + local bound = MapScripts.talkScript(MAP, "TEXT_OAKSLAB_EEVEE_POKE_BALL") + U.log("the map dispatches a", type(bound), "for the ball") + check("the Yellow ball handler is the one bound to the map", + bound ~= nil and bound == labModule.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL) + + local ow = game.overworld + local ctx = { game = game, save = game.save, overworld = ow } + + local function npcNamed(name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- The scene the shove belongs to: Oak has asked the player to choose and + -- the rival is still standing at the table. Oak himself starts hidden on + -- this map, so put him back the way OaksLabScript does before its speech. + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true + game.save.flags.EVENT_GOT_STARTER = nil + game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + Commands.show_object(ctx, MAP, "OAKSLAB_RIVAL") + Commands.show_object(ctx, MAP, "OAKSLAB_OAK1") + U.wait(2) + + local rival = ow:npcByIndex(RIVAL_INDEX) + if rival and (rival.cellX ~= RIVAL_HOME.x or rival.cellY ~= RIVAL_HOME.y) then + -- he wanders nowhere on this map (STAY), but a converted save can drop + -- him mid-walk; pin him back on the table corner he starts from + U.log("rival was at", rival.cellX, rival.cellY, "- pinning him to", + RIVAL_HOME.x, RIVAL_HOME.y) + Commands.place_npc(ctx, RIVAL_INDEX, RIVAL_HOME.x, RIVAL_HOME.y, "down") + end + if not check("the rival is on the map at (4,3)", + rival ~= nil and rival.cellX == RIVAL_HOME.x + and rival.cellY == RIVAL_HOME.y) then + idle() + end + + local ball = npcNamed(BALL) + if not check("the EEVEE ball object is on the map", ball ~= nil) then + U.log("Nothing to talk to. A save past EVENT_GOT_STARTER has already") + U.log("hidden it, and this scene cannot be replayed on that save.") + idle() + end + + -- derive the talking cell from the ball rather than trusting (7,4): a + -- later map edit degrades to another free neighbour instead of parking + -- the player inside the table + local DIRS = { { "up", 0, 1 }, { "down", 0, -1 }, + { "left", 1, 0 }, { "right", -1, 0 } } + local function facingBall() + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == ball + end + if not facingBall() then + for _, d in ipairs(DIRS) do + local cx, cy = ball.cellX + d[2], ball.cellY + d[3] + if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + U.log("standing at", cx, cy, "facing", d[1], "instead") + U.teleport(game, MAP, cx, cy, d[1]) + U.wait(6) + ow = game.overworld + ctx.overworld = ow + ball = npcNamed(BALL) + rival = ow:npcByIndex(RIVAL_INDEX) + break + end + end + end + check("player is facing the ball", facingBall()) + local p = ow.player + U.log("player at", p.cellX, p.cellY, "facing", p.facing, + "| rival at", rival.cellX, rival.cellY, + "| ball at", ball.cellX, ball.cellY) + -- y == 4 is the branch under test; from anywhere else both the original + -- and the port just walk the rival over with no shove at all + check("the player is on the row that triggers the shove (y == 4)", + p.cellY == 4) + + if U.shot(game, SHOT_DIR .. "/bug559_before.png") then + U.log("captured", SHOT_DIR .. "/bug559_before.png") + end + + -- ---- talk to the ball and watch the two walks frame by frame ---- + U.tap(game, "a") + + local shoveFrame, rivalAtShove + local trace, lastCell = {}, nil + for f = 1, 900 do + local cell = rival.cellX .. "," .. rival.cellY + if cell ~= lastCell then + lastCell = cell + trace[#trace + 1] = "f" .. f .. " rival " .. cell + end + if not shoveFrame and ow.player.moving then + shoveFrame = f + rivalAtShove = { x = rival.cellX, y = rival.cellY } + end + if shoveFrame and not ow.player.moving and ow.player.cellX >= STAND.x + 2 then + break + end + U.wait(1) + end + + U.log("rival trace:", table.concat(trace, " | ")) + if not check("the player was pushed at all", shoveFrame ~= nil) then + U.log("The rival's walk ran but nothing moved the player, so the scene") + U.log("never reached move_player. Check the runner did not stall on the") + U.log("emote row.") + idle() + end + U.log("the push started on driver frame", shoveFrame, "with the rival at", + rivalAtShove.x, rivalAtShove.y) + + -- The whole of #559: at the frame the player starts moving the rival must + -- already be alongside him on row 4, one step from the tile he is being + -- pushed off. Before the fix both walks started together and the rival + -- was still up at the table (4,3)/(4,4). + check("the rival had reached row 4 before the push", rivalAtShove.y == 4) + check("he was one step from the player's tile, not still at the table", + rivalAtShove.x >= ball.cellX - 1) + + if U.shot(game, SHOT_DIR .. "/bug559_shove.png") then + U.log("captured", SHOT_DIR .. "/bug559_shove.png") + end + + -- let the two walks settle, then the beat the script waits before it turns + -- the rival up to face the player (the row after move_player) + for _ = 1, 240 do + if not ow.player.moving and not rival.moving and rival.facing == "up" then + break + end + U.wait(1) + end + U.log("after the push: player at", ow.player.cellX, ow.player.cellY, + "| rival at", rival.cellX, rival.cellY, "facing", rival.facing) + check("the player ended two tiles right of where he stood", + ow.player.cellX == STAND.x + 2 and ow.player.cellY == STAND.y) + check("the rival ended on the ball's row-4 tile facing up", + rival.cellX == ball.cellX and rival.cellY == 4 + and rival.facing == "up") + + if U.shot(game, SHOT_DIR .. "/bug559_after.png") then + U.log("captured", SHOT_DIR .. "/bug559_after.png") + end + + U.log("The shove has already happened; the box on screen is the rival") + U.log("gloating. Press A through his five lines to see the rest. Watch the") + U.log("shot pair: the rival walks down and across the table first, and Red") + U.log("only slides right as the rival arrives on top of him, both moving") + U.log("together for that last tile. The near miss to look for is Red") + U.log("stepping away early, with the rival still up at (4,3) or halfway") + U.log("along row 4 behind him -- that is #559 unchanged.") + U.log("Control case: on a save where Oak has not asked you to choose yet,") + U.log("the same A press only prints \"That's a POKe BALL\" and nobody") + U.log("moves, which is the handler working correctly.") + + idle() +end diff --git a/tests/drivers/ghost_unveil_bug492_test.lua b/tests/drivers/ghost_unveil_bug492_test.lua new file mode 100644 index 00000000..bfbb6226 --- /dev/null +++ b/tests/drivers/ghost_unveil_bug492_test.lua @@ -0,0 +1,175 @@ +-- Manual check of the Silph Scope unveil in the POKEMON_TOWER_6F battle (#492). +-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60) +-- enters disguised even with the scope and plays UnveiledGhostText + MarowakAnim +-- over the GHOST before "Wild MAROWAK appeared!"; the port used to open the +-- battle with MAROWAK already loaded. Sequencing half: +-- tests/engine/ghost_unveil_bug492.lua. Never under POKEPORT_SPEED -- the +-- unveil is the thing being timed. +-- POKEPORT_DRIVER=tests/drivers/ghost_unveil_bug492_test.lua POKEPORT_IDENTITY=bug492 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- machine checks ---------------------------------------------------- + -- A missing text key, a renamed asset and an unarmed branch all look like + -- the bug from outside: the battle just opens with MAROWAK on screen. + U.log("#492 Silph Scope unveil: machine checks") + check("SILPH_SCOPE is a real item", game.data.items.SILPH_SCOPE ~= nil) + check("MAROWAK is a real species", game.data.pokemon.MAROWAK ~= nil) + check("BattleState:makeUnveiledGhost exists", + type(BattleState.makeUnveiledGhost) == "function") + local unveil = game.data.text._UnveiledGhostText + check("_UnveiledGhostText extracted", type(unveil) == "string" and unveil ~= "") + if type(unveil) == "string" then + U.log("the unveil line reads:", (unveil:gsub("[\n\011\012]", " / "))) + end + check("_PokemonTower6FBeGoneText extracted", + type(game.data.text._PokemonTower6FBeGoneText) == "string") + -- the disguise pic resolves through the same override chain the battle uses + local Assets = require("src.render.Assets") + local GHOST_PIC = "assets/generated/battle/front/ghost.png" + check("the ghost front pic is in the cache", + Assets.exists(Assets.resolve(GHOST_PIC))) + + local opts = game.save.options or {} + U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol)) + if opts.sfxVol == 0 or opts.musicVol == 0 then + U.log("a volume is at 0: raise it in OPTION before judging what you hear") + end + U.log("no intro cry here is CORRECT -- .isMarowak never reaches PlayCry.") + U.log("The CHANNELER at (12, 10) is the control: her GASTLY cries normally.") + + -- ---- reach the trigger ------------------------------------------------- + -- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords: + -- dbmapcoord 10, 16. Row 17 is solid wall and (9, 16) is the stairwell + -- warp down to 5F, so the approach is from (10, 15) facing down. + local MAP = "POKEMON_TOWER_6F" + local TRIGGER = { x = 10, y = 16 } + local STAND = { x = 10, y = 15, facing = "down", step = "down" } + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "RED" + game.save.inventory.SILPH_SCOPE = 1 + game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil) + + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the approach: take any walkable neighbour of the + -- trigger that is not the stairwell warp, and step back toward it. + -- {dx, dy, facing} is the offset from the trigger to the stand cell plus + -- the direction that walks back onto it. + local sides = { { 0, -1, "down" }, { 1, 0, "left" }, + { -1, 0, "right" }, { 0, 1, "up" } } + for _, s in ipairs(sides) do + local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y), + cx, cy, "stepping", s[3]) + STAND = { x = cx, y = cy, facing = s[3], step = s[3] } + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + + -- walk onto the trigger; MapScripts onStep fires on the completed step + U.hold(game, STAND.step, 20) + U.wait(20) + check("the Be-gone text opened", game.stack:top() ~= game.overworld) + + -- ---- into the battle --------------------------------------------------- + local battle + for _ = 1, 300 do + local top = game.stack:top() + if getmetatable(top) == BattleState then battle = top break end + U.tap(game, "a") + U.wait(4) + end + check("the MAROWAK battle opened", battle ~= nil) + if not battle then + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + while true do coroutine.yield() end + end + + battle.onFinish = battle.onFinish or function() end + check("the scope armed the unveil, not a plain wild battle", + battle.scopeReveal == true) + check("IsGhostBattle stays false with the scope in the bag", not battle.ghost) + check("the battle OPENED as the GHOST, not as MAROWAK (#492)", + battle.enemy and battle.enemy.name == "GHOST") + check("the real nick is held back for the unveil", + battle.ghostReal ~= nil and battle.ghostReal.name == "MAROWAK") + + -- ---- watch the unveil -------------------------------------------------- + -- A turns the "GHOST appeared!" and unveil boxes; stop pressing the moment + -- MarowakAnim arms so nothing mashes past "Wild MAROWAK appeared!". + for _ = 1, 600 do + if battle.ghostReveal then break end + U.tap(game, "a") + U.wait(4) + end + check("MarowakAnim started", battle.ghostReveal ~= nil) + + local FLASH = BattleState.GHOST_FLASH_FRAMES + local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES + local shotFlash, swapT, nameDuringFlash = false, nil, true + for _ = 1, 400 do + local gr = battle.ghostReveal + local t = gr and gr.t or nil + if t and t <= FLASH + FADE_OUT and battle.enemy.name ~= "GHOST" then + nameDuringFlash = false + end + if t and not swapT and battle.enemy.name ~= "GHOST" then swapT = t end + if t and t >= 25 and not shotFlash then + shotFlash = U.shot(game, DIR .. "/bug492_ghost_flashing.png") + end + if not gr and swapT then break end + U.wait(1) + end + + check("the GHOST was still on screen through the flash and fade-out (#492)", + nameDuringFlash) + check(("the swap landed after the fade-out (frame %s of %d)") + :format(tostring(swapT), FLASH + FADE_OUT), + swapT ~= nil and swapT > FLASH + FADE_OUT) + check("the foe is MAROWAK once the animation finishes", + battle.enemy.name == "MAROWAK") + check("captured the flashing ghost", shotFlash) + + -- the "Wild MAROWAK appeared!" box that closes .isMarowak + U.wait(30) + check("captured the unveiled MAROWAK", + U.shot(game, DIR .. "/bug492_marowak_revealed.png")) + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- hand off ---------------------------------------------------------- + U.log("The pad is yours in the battle the unveil just finished. What you") + U.log("should have seen: the box says the GHOST appeared, the SILPH SCOPE") + U.log("line follows, and only THEN does the ghost flash, dissolve and come") + U.log("back as MAROWAK before \"Wild MAROWAK appeared!\". The near miss to") + U.log("watch for is the sprite swapping early -- MAROWAK already standing") + U.log("there while the unveil text is still typing, which is #492 itself.") + U.log("To see it again: run from the battle (that steps you one cell right),") + U.log("walk back to (10, 15) and step down onto (10, 16). Beating it sets") + U.log("EVENT_BEAT_GHOST_MAROWAK and the trigger is spent for this save.") + U.log("Screenshots: " .. DIR .. "/bug492_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/menu_click_bug570_test.lua b/tests/drivers/menu_click_bug570_test.lua new file mode 100644 index 00000000..bd833be3 --- /dev/null +++ b/tests/drivers/menu_click_bug570_test.lua @@ -0,0 +1,234 @@ +-- Ear check: the menu click on backing out of POKéDEX / POKéMON / ITEM / +-- OPTION, on the first A on an item in ITEM, and on A over CANCEL in OPTION +-- (#570). HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for the +-- PAD_A | PAD_B half of a menu's watched keys; DisplayOptionMenu beeps only +-- at .exitMenu (engine/menus/main_menu.asm). Silence where the original is +-- silent counts too, so this walks the mute cases as well. Invariants are in +-- tests/engine/menu_click_bug570.lua. +-- POKEPORT_DRIVER=tests/drivers/menu_click_bug570_test.lua POKEPORT_IDENTITY=bug570 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own +-- real-time accumulator in Game:update, so it desyncs the exact ordering of +-- press and click that this run exists to judge. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local ListMenu = require("src.ui.ListMenu") + local Menu = require("src.ui.Menu") + local OptionsMenu = require("src.ui.OptionsMenu") + local PartyMenu = require("src.ui.PartyMenu") + local Pokemon = require("src.pokemon.Pokemon") + local Sound = require("src.core.Sound") + local Strings = require("src.core.Strings") + + -- pokered data/maps/objects/PalletTown.asm: OAK at (8,5), the girl at (3,8), + -- the fisher at (11,14), warps at (5,5), (13,5) and (12,11). (10,7) is clear + -- of all of them and off every mat; the menus are what is being judged, the + -- cell only has to be somewhere the start menu opens. + local MAP = "PALLET_TOWN" + local STAND = { x = 10, y = 7, facing = "down" } + local PARTY = { { "BULBASAUR", 12 }, { "PIDGEY", 9 } } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the ear cannot check ----------------------------------------- + local opts = game.save.options or {} + local musicVol = opts.musicVol or 7 + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL SFX volume is 0. Every claim below is about a click that is or") + U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION") + U.log(" and run this again, or the whole check is worthless.") + end + if musicVol == 0 then + U.log("FAIL MUSIC volume is 0, so Pallet Town's theme is gone and there is") + U.log(" nothing to tell a dead audio device from a missing click.") + U.log(" Set MUSIC to 7 in OPTION.") + end + check(("SFX volume %d, MUSIC volume %d"):format(sfxVol, musicVol), + sfxVol > 0 and musicVol > 0) + + -- an unresolved sfx key is silent in exactly the way the bug was + local sfx = (game.data.audio or {}).sfx or {} + for _, key in ipairs({ "Press_AB", "Start_Menu", "Swap" }) do + check("sfx " .. key .. " is in the cache", sfx[key] ~= nil) + end + + -- every cue the run makes, forwarded so playback is untouched + local cues = {} + local realPlay = Sound.play + Sound.play = function(data, name) + cues[#cues + 1] = name + return realPlay(data, name) + end + local function since(mark) + local clicks, others = 0, {} + for i = mark + 1, #cues do + if cues[i] == "Press_AB" then clicks = clicks + 1 + else others[#others + 1] = cues[i] end + end + return clicks, others + end + + -- one press, then long enough for the click to be its own sound + local function press(btn, want, label) + local mark = #cues + U.tap(game, btn) + U.wait(28) + local clicks, others = since(mark) + local note = "" + if #others > 0 then note = " (also: " .. table.concat(others, ", ") .. ")" end + check(("%s: %s gave %d click%s, expected %d%s") + :format(label, btn:upper(), clicks, clicks == 1 and "" or "s", + want, note), + clicks == want) + return clicks + end + + local function top() return game.stack:top() end + local function isA(class) return getmetatable(top()) == class end + + -- ---- set the save up so every start-menu row is live -------------------- + game.save.party = {} + for _, slot in ipairs(PARTY) do + table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2])) + end + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_POKEDEX = true -- StartMenu hides POKéDEX without it + game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} } + for _, id in ipairs({ "BULBASAUR", "PIDGEY", "RATTATA" }) do + game.save.pokedex.seen[id] = true + game.save.pokedex.owned[id] = true + end + Bag.add(game.save, "POTION", 3) + Bag.add(game.save, "ANTIDOTE", 2) + game.save.startMenuIndex = 1 + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit blocked the cell: any free walkable neighbour will do + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(20) + ow = game.overworld + break + end + end + end + check("standing on " .. MAP, ow ~= nil and ow.map.id == MAP) + -- the girl and the fisher both WALK: pin them so the map under the menus + -- looks the same on every run and in every screenshot + for _, n in ipairs((ow or {}).npcs or {}) do n.frozen = true end + + -- ---- walk the menus ----------------------------------------------------- + press("start", 0, "opening the start menu is silent (its own Start_Menu " + .. "cue is not the A/B click)") + check("the start menu is up", isA(Menu)) + + -- rows come and go with save state and the ui.start_menu.items hook, so + -- find each one by label rather than counting cursor steps + local function rowIndex(label) + for i, item in ipairs(top().items or {}) do + if item.label == label then return i end + end + return nil + end + local function openRow(label) + local menu = top() + local i = rowIndex(label) + if not i then + check("the start menu has a " .. label .. " row", false) + return false + end + menu.index = i + menu:clampScroll() + press("a", 1, "A on " .. label .. " clicks") + return true + end + + if openRow(Strings("POKéDEX")) then + check("the POKéDEX list opened", isA(ListMenu)) + U.shot(game, DIR .. "/bug570_1_pokedex.png") + press("b", 1, "backing out of POKéDEX clicks (#570)") + check("and the start menu came back", isA(Menu)) + end + + if openRow(Strings("POKéMON")) then + check("the party menu opened", getmetatable(top()) == PartyMenu) + press("b", 1, "backing out of POKéMON clicks (#570)") + check("and the start menu came back", isA(Menu)) + end + + if openRow(Strings("ITEM")) then + check("the bag opened", isA(ListMenu)) + check("the bag has items to click on", #(top().items or {}) > 0) + press("a", 1, "the first A on an item in ITEM clicks (#570)") + check("the USE / TOSS submenu opened", isA(Menu)) + U.shot(game, DIR .. "/bug570_2_bag_usetoss.png") + press("b", 1, "backing out of USE / TOSS clicks") + check("back on the item list", isA(ListMenu)) + -- SELECT is inside DisplayListMenuID's watched mask but outside its + -- PAD_A | PAD_B sound test (home/list_menu.asm), so arming a swap is + -- silent and completing it makes the swap chirp, not the menu click + press("select", 0, "SELECT arms a swap without clicking") + press("select", 0, "and completing it chirps instead of clicking") + press("b", 1, "backing out of ITEM clicks (#570)") + check("and the start menu came back", isA(Menu)) + end + + if openRow(Strings("OPTION")) then + check("the options screen opened", getmetatable(top()) == OptionsMenu) + press("right", 0, "the Left/Right toggles are silent") + press("left", 0, "in both directions") + -- up from the first row wraps onto CANCEL, which sits one past the rows + press("up", 0, "moving the cursor is silent") + check("the cursor is on CANCEL", + top().index == #top().rows + 1) + U.shot(game, DIR .. "/bug570_3_option_cancel.png") + press("a", 1, "A on CANCEL in OPTION clicks (#570)") + check("and the start menu came back", isA(Menu)) + end + + if openRow(Strings("OPTION")) then + press("b", 1, "B out of OPTION clicks too (#570)") + check("and the start menu came back", isA(Menu)) + end + + press("start", 0, "START closes the start menu silently: draw_start_menu's " + .. "mask watches it, but it is not in the PAD_A | PAD_B sound branch") + check("back on the map", top() == game.overworld) + + -- ---- over to you -------------------------------------------------------- + U.log("The run above already pressed all of it; do it again by ear. Open START,") + U.log("go into POKéDEX, POKéMON, ITEM and OPTION and back out of each with B.") + U.log("Every one of those B presses makes the same short click that A on the") + U.log("row made going in, the first A on an item in ITEM makes it, and A on") + U.log("CANCEL in OPTION closes it with it. #570 was silence on all of those.") + U.log("The near miss is a click at the wrong moment: OPTION's Left/Right") + U.log("toggles and its A on a setting row stay silent, START opens and closes") + U.log("the start menu with its own longer cue and no click, and SELECT in the") + U.log("bag chirps a swap rather than clicking.") + U.log("Shots: " .. DIR .. "/bug570_*.png") + + -- keeps printing the cue stream after the hand-off, so a click that lands a + -- beat late reads as a late line rather than as the right sound + local reported = #cues + while true do + if #cues > reported then + for i = reported + 1, #cues do + U.log("cue", cues[i], "frame", U.frame()) + end + reported = #cues + end + coroutine.yield() + end +end diff --git a/tests/drivers/menu_windows_bug564_test.lua b/tests/drivers/menu_windows_bug564_test.lua new file mode 100644 index 00000000..ad7ed620 --- /dev/null +++ b/tests/drivers/menu_windows_bug564_test.lua @@ -0,0 +1,211 @@ +-- Look at the boxed menus whose rows moved down one tile (#564 start menu, +-- #572 the PC and the Pokédex entry window) and watch QUIT close the whole +-- Pokédex (#571). Row geometry comes from pokered draw_start_menu.asm +-- (hlcoord 12,2 inside a TextBoxBorder at 10,0) and players_pc.asm +-- (hlcoord 2,2 inside a 16x10 box); the exits come from pokedex.asm +-- HandlePokedexSideMenu / .exitPokedex. Screenshots land in $SHOT_DIR. +-- POKEPORT_DRIVER=tests/drivers/menu_windows_bug564_test.lua POKEPORT_IDENTITY=bug564 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- The bedroom PC is hidden_event 0, 1, OpenRedsPC, SPRITE_FACING_UP + -- (pokered data/events/hidden_objects.asm), so the only approach is the + -- floor tile below it. + local MAP = "REDS_HOUSE_2F" + local STAND = { x = 0, y = 2, facing = "up" } + local DEX_SPECIES = "BULBASAUR" -- dex #1, so it is the list's first row + local BAG_ITEM = "POTION" + + -- a save the start menu will draw all seven rows for: dex flag on, + -- something owned so the dex list has a real entry, a party for LINK, + -- and one bag item so ITEM opens onto a row that has a USE/TOSS box + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} } + game.save.pokedex.seen[DEX_SPECIES] = true + game.save.pokedex.owned[DEX_SPECIES] = true + game.save.party = { + Pokemon.new(game.data, DEX_SPECIES, 12), + Pokemon.new(game.data, "PIKACHU", 10), + } + Bag.add(game.save, BAG_ITEM, 3) + + check("dex entry for " .. DEX_SPECIES .. " exists in the loaded data", + game.data.pokemon[DEX_SPECIES] ~= nil) + check(BAG_ITEM .. " resolves as an item", + game.data.items[BAG_ITEM] ~= nil) + + -- pcTiles is what OverworldController checks before it opens the PC; an + -- empty list here and a mis-aimed player look identical from outside + local extras = game.data.field and game.data.field.hiddenExtras + local pcTile = extras and (extras.pcTiles[MAP] or {})[1] + check("the bedroom PC tile is in the field data", pcTile ~= nil) + if pcTile then + U.log("PC tile at", pcTile.x, pcTile.y, "facing", pcTile.facing) + -- a map or field-data edit moves the tile: stand under it wherever it is + STAND = { x = pcTile.x, y = pcTile.y + 1, facing = "up" } + end + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- fall back to any free walkable neighbour that still looks at the tile + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = pcTile.x + s[1], pcTile.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log("standing on", cx, cy, "facing", s[3], "instead") + STAND = { x = cx, y = cy, facing = s[3] } + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("player is standing at the PC", + game.overworld ~= nil + and game.overworld.player.cellX == STAND.x + and game.overworld.player.cellY == STAND.y) + + -- walk a Menu's cursor onto a row by label, with real Down presses + local function moveTo(menu, label) + for _ = 1, #menu.items + 1 do + if menu.items[menu.index] and menu.items[menu.index].label == label then + return true + end + U.tap(game, "down") + U.wait(2) + end + return false + end + + -- --------------------------------------------------------------- #564 + U.tap(game, "start") + U.wait(20) + local start = game.stack:top() + check("START opened the start menu", start ~= nil and start.items ~= nil) + if start and start.items then + local labels = {} + for i, it in ipairs(start.items) do labels[i] = it.label end + U.log("start menu rows:", table.concat(labels, " ")) + end + check("start menu screenshot", + U.shot(game, SHOT_DIR .. "/bug564_start_menu.png")) + + -- --------------------------------------------------------------- #571 + check("POKéDEX is on the start menu", moveTo(start, "POKéDEX")) + U.tap(game, "a") + U.wait(20) + local dex = game.stack:top() + check("the dex list opened", dex ~= nil and dex.screenId == "PokedexMenu") + U.tap(game, "a") + U.wait(20) + local side = game.stack:top() + local isSide = side ~= nil and side ~= dex and side.items ~= nil + check("A on " .. DEX_SPECIES .. " opened the DATA/CRY/AREA/QUIT window", + isSide) + check("Pokédex entry window screenshot", + U.shot(game, SHOT_DIR .. "/bug572_pokedex_entry_menu.png")) + + if isSide then + check("QUIT is on the entry window", moveTo(side, "QUIT")) + U.tap(game, "a") + U.wait(30) + local top = game.stack:top() + -- the machine-checkable half of #571: the stack has to unwind past the + -- dex list, not just pop the side menu + check("QUIT left the Pokédex entirely", top ~= dex and top ~= side) + check("QUIT landed on the start menu", + top ~= nil and top.screenId == "StartMenu") + if top and top.items and top.items[top.index] then + U.log("start menu cursor is on:", top.items[top.index].label) + check("the cursor is still on POKéDEX", + top.items[top.index].label == "POKéDEX") + end + check("start menu after QUIT screenshot", + U.shot(game, SHOT_DIR .. "/bug571_after_quit.png")) + end + + -- --------------------------------------------------------------- #572 + -- the bag's USE/TOSS box, reached from the same start menu + local menu = game.stack:top() + if menu and menu.items and moveTo(menu, "ITEM") then + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") -- A on the first bag row opens USE/TOSS + U.wait(20) + local useToss = game.stack:top() + local ok = useToss and useToss.items and #useToss.items == 2 + and useToss.items[1].label == "USE" + check("the bag's USE/TOSS box opened", ok and true or false) + check("USE/TOSS screenshot", + U.shot(game, SHOT_DIR .. "/bug572_bag_use_toss.png")) + if ok then + -- pokered data/text_boxes.asm USE_TOSS_MENU_TEMPLATE is (13,10) to + -- (19,14) with its text at 15,11: two rows exactly fill 11 and 13, + -- with no blank row under the top border. Mirror Menu:draw's own + -- bottom anchor rather than re-deriving one here -- an assertion that + -- carries its own copy of the formula passes on whatever it assumes + -- instead of on what the menu actually draws (#564, #572). + local n = #useToss.items + local bottom = useToss.ty + useToss.th - 2 + local firstRow = bottom - (n - 1) * useToss.rowStep + local lastRow = bottom + check("TOSS is inside the box, not on its bottom border", + lastRow <= useToss.ty + useToss.th - 2) + check("USE and TOSS land on the pokered rows (11 and 13)", + firstRow == 11 and lastRow == 13) + U.log("USE/TOSS box ty=" .. useToss.ty .. " th=" .. useToss.th + .. ", rows land on " .. firstRow + .. " and " .. lastRow + .. ", bottom interior row is " .. bottom) + end + U.tap(game, "b") + U.wait(10) + U.tap(game, "b") + U.wait(20) + end + + -- back out to the overworld and open the bedroom PC + for _ = 1, 4 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(15) + end + check("back in the overworld", game.stack:top() == game.overworld) + + U.tap(game, "a") + U.wait(25) + local pc = game.stack:top() + local isPC = pc ~= nil and pc.screenId == "PlayerPC" + check("the bedroom PC opened", isPC) + check("player's PC screenshot", + U.shot(game, SHOT_DIR .. "/bug572_player_pc.png")) + + U.log("The player's PC is open on screen; B closes it and START reopens") + U.log("the start menu. In all of these the last choice should sit on the") + U.log("row just above the bottom border and the blank row should be under") + U.log("the top border, with the ▶ level with its label -- the near miss is") + U.log("text that clears the top edge but leaves a gap at the bottom, or a") + U.log("cursor that drifts a row off its own label. The text boxes the") + U.log("Pokémon and item lists draw are the control: they never moved.") + U.log("the bag's USE/TOSS box is the tight one: pokered draws it a row") + U.log("shorter than the rest, so USE and TOSS fill 11 and 13 with no blank") + U.log("row at all. If TOSS is sitting on the border there, that is the bug.") + U.log("Screenshots are under " .. SHOT_DIR) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/oak_pikapic_bug557_bug561_test.lua b/tests/drivers/oak_pikapic_bug557_bug561_test.lua new file mode 100644 index 00000000..f0c5183c --- /dev/null +++ b/tests/drivers/oak_pikapic_bug557_bug561_test.lua @@ -0,0 +1,262 @@ +-- Manual check of Yellow's two extra pic rips: PROF.OAK's own back pic in +-- the Pallet catch scene (#557, LoadPlayerBackPic's BATTLE_TYPE_PIKACHU +-- branch, pokeyellow engine/battle/core.asm:6384-6391) and the framed +-- portrait TalkToPikachu raises (#561, each PikaPicAnimScript's own base +-- frame, data/pikachu/pikachu_pic_animation.asm). Both are gated on files +-- the importer writes, so the asset check comes before any staging. No +-- POKEPORT_IDENTITY: a sandbox save dir carries no Yellow cache at all. +-- POKEPORT_DRIVER=tests/drivers/oak_pikapic_bug557_bug561_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local BattleState = require("src.battle.BattleState") + local Sprites = require("src.pokemon.Sprites") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local PROF_BACK = "assets/generated/battle/profoakb.png" + local OLDMAN_BACK = "assets/generated/battle/oldmanb.png" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Neither pic exists in Red or Blue. Re-run with") + U.log("POKEPORT_VERSION=yellow.") + idle() + end + + -- ---- the two rips, before anything is staged ---- + -- The importer writes these only when the manifest carries the symbol, and + -- both call sites fall back silently when the file is absent, so a missing + -- file looks on screen exactly like the bug being unfixed. + local oakPicOk = love.filesystem.getInfo(PROF_BACK) ~= nil + check("Oak's back pic is in the cache (" .. PROF_BACK .. ")", oakPicOk) + if not oakPicOk then + U.log("The importer skipped it: tools/rom_manifest_yellow.json has no") + U.log("ProfOakPicBack entry, so RomExtractor's gate never fires and") + U.log("Sprites.playerPath falls back to " .. OLDMAN_BACK .. ".") + U.log("Re-importing will not help until the symbol is in the manifest.") + end + + local pikapicFound = {} + for script = 1, 28 do + local path = "assets/generated/pikachu/pikapic_" .. script .. ".png" + if love.filesystem.getInfo(path) then + pikapicFound[#pikapicFound + 1] = script + end + end + local pikapicOk = #pikapicFound > 0 + check("the pikapic base frames are in the cache (assets/generated/pikachu/)", + pikapicOk) + if pikapicOk then + U.log("found base frames for scripts:", table.concat(pikapicFound, " ")) + else + U.log("The importer skipped all 28: the Pic_e4000..Pic_f0cf4 labels are") + U.log("absent from tools/rom_manifest_yellow.json, so PikachuFollower") + U.log("keeps standing the battle front pic in the frame, which is #561") + U.log("unchanged. Not staging the talk: it cannot look right yet.") + end + U.log("tests/engine/yellow_oak_back_pikapic.lua asserts both symbol lists.") + + -- ---- #557: the Pallet catch scene ---- + -- Same two calls data/scripts/story2.lua:255 makes once Oak has said + -- "That was close!" Standing in Pallet Town first so the battle draws + -- over a real map, as it does in play. + U.teleport(game, "PALLET_TOWN", 10, 8, "up") + U.wait(10) + game.save.player.name = "bryan" + + local backPath = Sprites.playerPath(game.data, "back", + { kind = "battle", demo = true, oakDemo = true }) + U.log("the demo resolves its back pic to:", tostring(backPath)) + check("it is Oak's pic, not the old man's", backPath == PROF_BACK) + + local battle = BattleState.newWild(game, "PIKACHU", 5) + battle:makeOldManDemo("PROF.OAK") + check("the thrower is named PROF.OAK", battle.demoName == "PROF.OAK") + check("the battle asks for the BATTLE_TYPE_PIKACHU back pic", + battle.oakDemo == true) + + -- record what the scripted throw actually prints, so the name half can be + -- judged without reading glyph codes off the canvas + local said = {} + local realSay = battle.say + battle.say = function(self, text) + said[#said + 1] = tostring(text) + return realSay(self, text) + end + + game.stack:push(battle) + U.wait(10) + + -- clear the "Wild PIKACHU appeared!" box; the scripted cursor script only + -- starts once the intro chrome is gone + for _ = 1, 90 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + if not check("the demo battle reached its scripted menu", + battle.phase == "menu") then + U.log("phase is", tostring(battle.phase), "- the intro never cleared.") + idle() + end + U.wait(20) + if U.shot(game, SHOT_DIR .. "/bug557_back_pic.png") then + U.log("captured", SHOT_DIR .. "/bug557_back_pic.png") + end + + -- The other half of the issue title, "referred to as Pikachu". + -- DisplayBattleMenu's simulated branch puts BATTLE_MENU_TEMPLATE on screen + -- and nothing else (data/text_boxes.asm:31 -- FIGHT/PKMN/ITEM/RUN from + -- column 8), so no name is printed at all: there is no player mon in this + -- battle to name. The classic layout ports that branch; WideBattle's menu + -- has no demo case and prints "What will do?" over the + -- hidden placeholder battler, which is a level 5 PIKACHU. + local options = game.save.options or {} + U.log("battle layout option:", tostring(options.battleLayout or "classic")) + local wide = battle:isWideBattleLayout() + check("the layout on screen leaves the scripted menu unnamed", not wide) + if wide then + U.log("The wide layout is on, so the menu reads \"What will PIKACHU do?\"") + U.log("beside Oak's throw. Switch OPTIONS to the classic battle layout") + U.log("and re-run to see the box the original draws.") + end + + -- the rest runs itself: 130 frames of cursor, the forced ITEM list, then + -- the throw. Never tap through it -- the timing is the port of + -- DisplayBattleMenu's simulated keystrokes. + local function thrownLine() + for _, text in ipairs(said) do + if text:find("POK", 1, true) and text:find("PROF.OAK", 1, true) then + return text + end + end + return nil + end + for _ = 1, 600 do + if thrownLine() then break end + U.wait(1) + end + local line = thrownLine() + check("the throw is announced under PROF.OAK's name", line ~= nil) + if line then U.log("the box reads:", (line:gsub("\n", " / "))) end + U.wait(45) + if U.shot(game, SHOT_DIR .. "/bug557_throw.png") then + U.log("captured", SHOT_DIR .. "/bug557_throw.png") + end + + if oakPicOk then + U.log("Both #557 shots are taken. The back pic in the lower left is the") + U.log("man throwing the ball: Oak in his lab coat, and the throw line") + U.log("names PROF.OAK. The near miss is a back pic that reads as a") + U.log("person and looks plausible while still being oldmanb.png --") + U.log("compare against the catch tutorial north of Viridian, which") + U.log("SHOULD be the old man and proves the demo path works at all.") + else + U.log("The shots are evidence of the FAIL, not of a fix: the back pic in") + U.log("the lower left is the bald OLD MAN from the Route 5 tutorial,") + U.log("exactly as #557 reported him. The throw line above it does name") + U.log("PROF.OAK, so the thrower half is right and only the pic is stuck.") + end + + -- ---- #561: the framed portrait ---- + if not pikapicOk then + U.log("Skipping the Pikachu portrait: see the FAIL above.") + idle() + end + + for _ = 1, 300 do + if game.stack:top() ~= battle then break end + U.wait(1) + end + U.wait(20) + + -- ShouldPikachuSpawn (pokeyellow engine/pikachu/pikachu_follow.asm): the + -- lab gift happened and a healthy Pikachu leads the party. Pallet's + -- objects sit at (10,4), (3,8) and (11,14), so (10,8) and the road cells + -- around it are clear. + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(12) + + local ow = game.overworld + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + local npc = follower() + if not check("the follower spawned", npc ~= nil) then idle() end + + -- one step then a turn back, so the follower is on the cell just vacated + local DIRS = { { "down", 0, 1 }, { "up", 0, -1 }, + { "left", -1, 0 }, { "right", 1, 0 } } + local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + local stepDir + for _, d in ipairs(DIRS) do + local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3] + if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + stepDir = d[1] + break + end + end + if not check("a walkable neighbour to step into exists", stepDir ~= nil) then + idle() + end + U.hold(game, stepDir, 24) + U.wait(6) + U.tap(game, OPPOSITE[stepDir]) + U.wait(6) + + local function facingFollower() + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == npc + end + if not facingFollower() then + for _, d in ipairs(DIRS) do + if npc.cellX == ow.player.cellX + d[2] + and npc.cellY == ow.player.cellY + d[3] then + U.tap(game, d[1]) + U.wait(6) + break + end + end + end + check("player is facing the follower", facingFollower()) + + U.tap(game, "a") + U.wait(8) + local emote = ow.emote + if not check("the A press raised a portrait", emote ~= nil and emote.pikaPic) then + U.log("Nothing answered the press, which is #407 territory, not #561.") + idle() + end + U.log("the frame is drawing:", tostring(emote.pikaPic)) + check("it is this script's own base frame, not the battle front pic", + emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil) + if U.shot(game, SHOT_DIR .. "/bug561_portrait.png") then + U.log("captured", SHOT_DIR .. "/bug561_portrait.png") + end + + U.log("The portrait box is up over the map now. Inside the 5x5 frame is a") + U.log("posed Pikachu drawn for that emotion, not the battle front pic") + U.log("standing to attention -- that stand-in is the screenshot on #561.") + U.log("Face it and press A again for another emotion; the pose should") + U.log("change with it. A frame that never changes between emotions is the") + U.log("near miss: the path resolved but every script picked one base.") + + idle() +end diff --git a/tests/drivers/soft_reset_bug563_test.lua b/tests/drivers/soft_reset_bug563_test.lua new file mode 100644 index 00000000..157a4da8 --- /dev/null +++ b/tests/drivers/soft_reset_bug563_test.lua @@ -0,0 +1,190 @@ +-- Soft reset from inside a battle (#563): A+B+SELECT+START held together +-- drops back to the title, the way _Joypad's `cp PAD_BUTTONS` (pokered +-- engine/joypad.asm:6) reads the raw pad ahead of the wJoyIgnore masking +-- that a battle turn leans on. Counting and touch-overlay halves are +-- asserted in tests/engine/soft_reset_bug563.lua; this is the ear and eye +-- half -- music cutting to the title theme, unsaved progress gone. +-- POKEPORT_DRIVER=tests/drivers/soft_reset_bug563_test.lua POKEPORT_IDENTITY=bug563 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Input = require("src.core.Input") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local CHORD = { "a", "b", "start", "select" } + + -- The driver presses GB buttons by name, so it proves the chord is read + -- but says nothing about which keys reach it. Print the live map for the + -- hand-off instead of hard-coding key names into the notes. + local function keysFor(btn) + local keys = {} + for key, action in pairs(Input.keyBindings or {}) do + if action == btn then keys[#keys + 1] = key end + end + table.sort(keys) + return table.concat(keys, " / ") + end + + U.log("#563 soft reset: machine checks") + check("Input:softResetHeld exists", type(Input.softResetHeld) == "function") + check("Input:softResetStep exists", type(Input.softResetStep) == "function") + for _, btn in ipairs(CHORD) do + local keys = keysFor(btn) + check(btn:upper() .. " has a key bound (" .. keys .. ")", keys ~= "") + end + + local musicVol = game.save.options and game.save.options.musicVol + if musicVol == 0 then + U.log("musicVol is 0: the battle music cutting to the title theme is the") + U.log("main thing to listen for, so raise it in OPTION before judging") + else + U.log("musicVol", tostring(musicVol), + "-- the battle theme should stop dead and the title theme start") + end + + -- ---- a battle to reset out of ----------------------------------------- + + game.save.party = { + Pokemon.new(game.data, "SQUIRTLE", 30), + Pokemon.new(game.data, "PIDGEOTTO", 28), + } + game.save.player.name = "RED" + + -- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground. The + -- battle is pushed straight in rather than encountered, so the cell only + -- has to be somewhere the player can legally stand. + local START = { x = 5, y = 5 } + U.teleport(game, "ROUTE_1", START.x, START.y, "down") + U.wait(10) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(START.x, START.y) then + -- a map edit moved the path: take the nearest walkable cell instead + local sx, sy + for r = 1, 6 do + for dy = -r, r do + for dx = -r, r do + if not sx and ow.map:isWalkableCell(START.x + dx, START.y + dy) then + sx, sy = START.x + dx, START.y + dy + end + end + end + if sx then break end + end + if sx then + U.log(("(%d, %d) is blocked, standing on"):format(START.x, START.y), sx, sy) + U.teleport(game, "ROUTE_1", sx, sy, "down") + U.wait(10) + ow = game.overworld + end + end + check("the overworld is up on ROUTE_1", ow ~= nil) + + -- something to reset away from: a fight in progress and 2 unsaved mon + local function enterBattle() + local battle = BattleState.newWild(game, "RATTATA", 12) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + return battle + end + + local battle = enterBattle() + check("the battle reached the screen", game.stack:top() == battle) + check("battle screenshot reached disk", + U.shot(game, DIR .. "/bug563_battle.png")) + + -- ---- hold the chord ---------------------------------------------------- + + -- A real hold is one press edge and then sustained state, so queue the + -- edge once and only keep `state` alive after that. Re-queueing every + -- frame would feed the battle 40 A presses and run the fight away before + -- the countdown ever finished. Stops the moment the title appears: the + -- driver has no key-up, so a hold left running past the reset would press + -- A on the title's first frame and start a new game by itself. + local function holdChord(frames, extra) + local buttons = { unpack(CHORD) } + if extra then buttons[#buttons + 1] = extra end + for frame = 1, frames do + for _, btn in ipairs(buttons) do + if frame == 1 then table.insert(game.input.pressQueue, btn) end + game.input.state[btn] = true + end + coroutine.yield() + local top = game.stack:top() + if top and top.screenId == "TitleState" then break end + end + for _, btn in ipairs(buttons) do game.input.state[btn] = false end + end + + -- Control case first: `cp PAD_BUTTONS` is an equality, so the same four + -- buttons plus a direction is not the combo however long it is held. If + -- this one resets, the port is masking the read instead of comparing it. + holdChord(40, "up") + U.wait(5) + check("four buttons plus UP for 40 frames does NOT reset", + game.stack:top() == battle) + + -- 16 polls is the countdown Init seeds hSoftReset with (home/init.asm:81); + -- 60 frames leaves room for it without hiding an off-by-one, which the + -- engine suite pins exactly. + holdChord(60) + U.wait(20) + + local top = game.stack:top() + check("the chord dropped the battle for the title", + top ~= nil and top.screenId == "TitleState") + check("the battle state is gone from the stack", top ~= battle) + check("no button is left held into the title", not game.input:isDown("a")) + check("title screenshot reached disk", U.shot(game, DIR .. "/bug563_title.png")) + + -- the title must not act on the still-held buttons on its own first frames + U.wait(60) + check("the title is still up a second later, not a menu it auto-picked", + game.stack:top() == top) + + -- ---- hand off inside a fresh battle ------------------------------------ + + -- Handing the pad over needs a party again. A player who did this for + -- real would be picking CONTINUE here and getting whatever was last saved, + -- which is the point of the reset -- this is only the driver restocking. + game.save.party = { + Pokemon.new(game.data, "SQUIRTLE", 30), + Pokemon.new(game.data, "PIDGEOTTO", 28), + } + U.teleport(game, "ROUTE_1", START.x, START.y, "down") + U.wait(10) + local again = enterBattle() + check("a second battle is up for the manual attempt", game.stack:top() == again) + + U.log("The driver already did this once: /tmp/shots/bug563_battle.png is the") + U.log("fight it was in, bug563_title.png is where it landed. There is a") + U.log("fresh RATTATA battle on screen now for you to repeat it by hand.") + U.log("Hold " .. keysFor("a") .. " (A) + " .. keysFor("b") .. " (B) + " + .. keysFor("start") .. " (START) + " .. keysFor("select") .. " (SELECT)") + U.log("together for about a third of a second. The battle theme should cut") + U.log("out and the title come back with its own music; CONTINUE from there") + U.log("gives you the last save, so the fight and anything after it is gone.") + U.log("Add any arrow key to the four and nothing should happen at all. A") + U.log("quick tap of all four in passing must not reset either, and letting") + U.log("one go a beat early must start the count over rather than resume it.") + U.log("Control case if it does nothing: QUIT in the START menu runs the same") + U.log("return-to-title, so if QUIT works the pad read is what to look at.") + U.log("For the on-screen overlay, re-run with POKEPORT_TOUCH=1: the mouse is") + U.log("one finger there, so the combo cannot be reached at all on desktop.") + U.log(("checks: %d passed, %d failed"):format(pass, fail)) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/trainer_battle_text_bug565_bug566_test.lua b/tests/drivers/trainer_battle_text_bug565_bug566_test.lua new file mode 100644 index 00000000..4a6067a9 --- /dev/null +++ b/tests/drivers/trainer_battle_text_bug565_bug566_test.lua @@ -0,0 +1,223 @@ +-- Manual check of the two trainer-battle text boxes, #565 and #566. +-- #565 _TrainerAboutToUseText (data/text/text_2.asm:911-921): the SHIFT +-- offer is one box with a `cont`, so the nick scrolls in UNDER "about to +-- use" instead of landing alone on a fresh page. +-- #566 TrainerEndBattleText (home/trainers.asm:381-386) prints +-- _TrainerNameText -- the name then ": " -- ahead of the loss line. +-- The string half is asserted in tests/engine/trainer_shift_prompt_bug565.lua +-- and tests/engine/trainer_loss_line_bug566.lua; this is the look of it. +-- POKEPORT_DRIVER=tests/drivers/trainer_battle_text_bug565_bug566_test.lua POKEPORT_IDENTITY=bug565 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Font = require("src.render.Font") + local ChoiceBox = require("src.ui.ChoiceBox") + + -- pokered data/maps/objects/Route3.asm: ROUTE3_YOUNGSTER2 is object 3, at + -- (14, 4), STAY, facing DOWN, OPP_YOUNGSTER party 1 (RATTATA + EKANS). Its + -- TrainerHeader gives sight range 3, so the cone is (14,5)..(14,7); y = 7 is + -- the ledge row and is solid, so the walk-in is east-to-west along y = 6. + local MAP, MAP_LABEL = "ROUTE_3", "Route3" + local NPC_NAME = "ROUTE3_YOUNGSTER2" + local OBJ_INDEX = 3 + local START = { x = 16, y = 6, facing = "left" } + + local fails = 0 + local function check(label, ok) + if not ok then fails = fails + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Everything the two boxes are assembled from. A renamed text label, an + -- opponent whose party shrank to one mon or a species constant that no + -- longer resolves all show up on screen as exactly the silence the bugs + -- did, so name them here before anyone squints at a screenshot. + local header = game.data:trainerHeader(MAP_LABEL, OBJ_INDEX) + check("Route3 object " .. OBJ_INDEX .. " has a trainer header", header ~= nil) + local lossText = header and header.won and game.data.text[header.won] + check("its EndBattleText resolves to a string", + type(lossText) == "string" and lossText ~= "") + + local trainer = game.data.trainers.OPP_YOUNGSTER + check("OPP_YOUNGSTER is a known trainer class", trainer ~= nil) + local party = trainer and trainer.parties[1] + check("its party 1 has a reserve to send out (SHIFT needs one)", + party ~= nil and #party >= 2) + local secondName + if party and party[2] then + local species = game.data.pokemon[party[2].species] + secondName = species and species.name + end + check("the reserve's species resolves to a name", secondName ~= nil) + + local TAG = trainer and trainer.name + local firstLine = TAG and lossText + and (TAG .. ": " .. lossText:match("^[^\n\f\v]*")) + if firstLine then + -- the tag is prose the box has to hold: 18 columns, TextBox MAX_COLS + local spans = Font.split(firstLine) + check("the tagged first line fits the 18-column box (" .. #spans .. " cols)", + Font.spansFitting(spans, 18 * 8) >= #spans) + U.log("loss box should read:", + ((TAG .. ": " .. lossText):gsub("[\n\v]", " / "):gsub("\f", " || "))) + end + + -- SHIFT only offers the switch when the player holds more than one party + -- slot and the active mon is alive (core.asm:1366-1443) + game.save.player.name = "RED" + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 40), + Pokemon.new(game.data, "BLASTOISE", 40), + } + game.save.party[1].moves[1] = { id = "SLASH", pp = 20 } + game.save.options = game.save.options or {} + game.save.options.battleStyle = "shift" + check("two party slots and SHIFT style are set", + #game.save.party > 1 and game.save.options.battleStyle == "shift") + check("the window is open and drawing", + love.window and love.window.isOpen and love.window.isOpen()) + + if fails > 0 then + U.log(fails .. " preflight check(s) failed; the boxes below cannot be trusted.") + end + + U.teleport(game, MAP, START.x, START.y, START.facing) + U.wait(15) + + local function findNpc() + for _, n in ipairs((game.overworld and game.overworld.npcs) or {}) do + if n.def and n.def.name == NPC_NAME then return n end + end + end + local npc = findNpc() + check("the YOUNGSTER is on the map", npc ~= nil) + + -- A map edit that moves him invalidates the hand-derived walk-in, so fall + -- back to standing next to wherever he ended up and talking: engageTrainer + -- is the same call the line-of-sight approach makes. + local talkedTo = false + if npc and (npc.cellX ~= 14 or npc.cellY ~= 4) then + local ow = game.overworld + -- {dx, dy, facing to look back at him} + for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } }) do + local cx, cy = npc.cellX + side[1], npc.cellY + side[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("he has moved to", npc.cellX, npc.cellY, "- standing at", cx, cy) + U.teleport(game, MAP, cx, cy, side[3]) + U.wait(10) + talkedTo = true + U.tap(game, "a") + break + end + end + end + + local function battleNow() + local top = game.stack:top() + if type(top) == "table" and top.kind == "trainer" then return top end + end + + -- walk west into the sight cone; two cells, plus slack for the "!" bubble + if not talkedTo then + for _ = 1, 6 do + if game.overworld and game.overworld.engaging then break end + U.hold(game, "left", 18) + U.wait(4) + end + end + check("the trainer engaged", + (game.overworld and game.overworld.engaging) or battleNow() ~= nil + or talkedTo) + + -- Play it out. Nothing here is fast-forwarded: the taps are A presses at + -- the same cadence a player makes them, and the loop stops on the row + -- under test before it can press the box away. + local function currentRow() + local b = battleNow() + local row = b and b.current + return b, row and row.text + end + local function pump(pred, maxFrames) + local cool = 0 + for _ = 1, maxFrames or 4000 do + if pred() then return true end + if cool > 0 then + cool = cool - 1 + U.wait(1) + else + U.tap(game, "a") + local b = battleNow() + cool = (b and (b.phase == "menu" or b.phase == "moveSelect")) and 8 or 5 + end + end + return pred() + end + + local ok = pump(function() + local _, text = currentRow() + return text ~= nil and text:find("about to use", 1, true) ~= nil + end) + check("the SHIFT offer box came up after the first KO", ok) + + -- Hold at the `cont`: the box has typed "X is" / "about to use" and is + -- blinking the arrow, waiting for A before it scrolls (ContText). + pump(function() + local b = currentRow() + return b and b.msgWaiting + end, 400) + U.shot(game, SHOT_DIR .. "/bug565_1_cont_wait.png") + local b = battleNow() + if b and b.current then + U.log("offer box holds:", (b.current.text:gsub("\n", " / "):gsub("\v", " CONT "))) + end + U.tap(game, "a") + + -- and after the scroll: "about to use" on top, the nick beneath it + pump(function() + local bb = battleNow() + return bb and (bb.charIndex or 0) >= (bb.total or 1) and not bb.msgWaiting + end, 400) + U.shot(game, SHOT_DIR .. "/bug565_2_scrolled.png") + + -- decline the switch (B is NO, ChoiceBox) and finish the fight + pump(function() return getmetatable(game.stack:top()) == ChoiceBox end, 600) + U.wait(10) + U.tap(game, "b") + U.wait(10) + + local tagged = TAG and (TAG .. ": ") or "YOUNGSTER: " + ok = pump(function() + local _, text = currentRow() + return text ~= nil and text:sub(1, #tagged) == tagged + end) + check("the loss line came up tagged with the trainer class", ok) + pump(function() + local bb = battleNow() + return bb and bb.msgPrompt + end, 400) + U.shot(game, SHOT_DIR .. "/bug566_loss_line.png") + local lb = battleNow() + if lb and lb.current then + U.log("loss box holds:", (lb.current.text:gsub("\n", " / "))) + end + + U.log("Shots are in " .. SHOT_DIR .. ".") + U.log("bug565_1 should read \"YOUNGSTER is\" / \"about to use\" with the") + U.log("blinking arrow; bug565_2 the same box after the scroll, \"about to") + U.log("use\" over \"EKANS!\". The near miss is a box whose two lines are") + U.log("\"about to use\" and nothing, or a page holding only \"EKANS!\".") + U.log("bug566 should read \"YOUNGSTER: I don't\" / \"believe it!\" -- the") + U.log("colon and the class, not a bare \"I don't believe it!\".") + U.log("The prize line follows it; the tag never repeats on that one.") + U.log("A is yours now: press through the money line back to the route.") + U.log("ROUTE3_YOUNGSTER1 at (10,6) has three mons, so the SHIFT box comes") + U.log("up twice. ROUTE3_YOUNGSTER4 at (22,9) is the control: one mon, so") + U.log("he must show the tagged loss line and no SHIFT offer at all.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/turn_in_place_bug415_test.lua b/tests/drivers/turn_in_place_bug415_test.lua index 1d796ce5..03f45925 100644 --- a/tests/drivers/turn_in_place_bug415_test.lua +++ b/tests/drivers/turn_in_place_bug415_test.lua @@ -1,40 +1,255 @@ --- Driver: a quick directional tap should turn the player in place, not --- also take a step (#415). Player:tryMove (src/world/Player.lua) only --- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how --- many fixed steps a facing change blocks movement for before a held --- direction is allowed to commit to a step. This is an input-timing feel --- bug: no assertion here can tell a quick tap from a held one the way a --- human thumb can, so this hands the pad over rather than scripting taps --- at a fixed, unrealistic frame count. --- +-- A tap on the d-pad turns the player in place; only a held direction steps +-- (#415). home/overworld.asm .handleDirectionButtonPress only reaches the +-- turn while wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed +-- (the poll that finds nothing held) is the one place that sets it, so the +-- delay is paid once per press and never at a corner. This scripts the +-- window it can measure and then hands the pad over, because no scripted +-- press has a human thumb's length. -- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \ -- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . - return function(game) local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) return ok end - -- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn - -- (src/core/SaveData.lua bedroom fallback). Small enough that an - -- accidental step is obvious -- a stray step toward the stairs at - -- (7,1)/(7,2) or into the desk/bed furniture is immediately visible. - U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down") - U.wait(10) - local ow = game.stack:top() - check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F") - check("player starts facing down", ow.player.facing == "down") + -- pokered data/maps/objects/PalletTown.asm: PALLETTOWN_GIRL is the one + -- WALK/ANY_DIR object on the town's open west side, at cell (3, 8) with + -- floor on three sides -- the ordinary "someone standing next to you" + -- shape the report is about. object_event coords are already walk-grid + -- cells (src/world/NPC.lua), so they carry over unchanged. + local MAP = "PALLET_TOWN" + local MAP_LABEL = "PalletTown" + local NPC_NAME = "PALLETTOWN_GIRL" + local NPC_TEXT = "TEXT_PALLETTOWN_GIRL" + local GIRL = { x = 3, y = 8 } - U.log("Standing in the middle of the bedroom, facing down.") - U.log("Tap Up once, quickly, then tap Right once, quickly.") - U.log("Right: each tap only turns you to face that way -- your feet") - U.log("stay on the same tile both times. Wrong (#415): a quick tap") - U.log("still slides you one tile in the new direction, same as holding.") - U.log("Now hold Up for a beat: turning into a real step, then a walk,") - U.log("should feel identical to before -- no extra pause was added there.") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Held-direction primitives that never lift the d-pad. U.hold releases at + -- the end of every call, which is exactly what the corner case below must + -- not do: a release re-arms the turn and hides the thing being measured. + local function press(btn) + table.insert(game.input.pressQueue, btn) + game.input.state[btn] = true + end + local function release(btn) + game.input.state[btn] = false + end + + -- an unresolved text label, a renamed object and a press that never landed + -- all look like the same nothing-happened the bug looked like + local girlText = game.data:resolveText(MAP_LABEL, NPC_TEXT) + check(NPC_TEXT .. " resolves to a string", + type(girlText) == "string" and girlText ~= "") + + U.teleport(game, MAP, GIRL.x, GIRL.y - 1, "up") + U.wait(10) + local ow = game.overworld + check("Pallet Town is loaded", ow ~= nil and ow.map.id == MAP) + + local function girlIn(state) + for _, n in ipairs(state.npcs or {}) do + if n.def and n.def.name == NPC_NAME then return n end + end + return nil + end + + -- She is a WALK object, so pin her before deriving anything from her cell: + -- NPC:update returns early on frozen, and snapping the pixels back undoes + -- any step already in flight when the driver got here. + local function pin(state) + local npc = girlIn(state) + if npc then + npc.frozen = true + npc.moving = false + npc.cellX, npc.cellY = GIRL.x, GIRL.y + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.facing = "down" + end + return npc + end + + local girl = pin(ow) + check("the girl object loaded and is pinned at (3, 8)", + girl ~= nil and girl.cellX == GIRL.x and girl.cellY == GIRL.y) + + -- Stand on her north side facing away, so the first tap has to turn. If a + -- map edit takes that cell, fall back to any free walkable neighbour and + -- face away from her there; {dx, dy, away, toward} is the offset from her + -- to the stand cell plus the two directions that matter from it. + local stand = { x = GIRL.x, y = GIRL.y - 1, away = "up", toward = "down" } + local function free(cx, cy) + return ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) + end + if girl and not free(stand.x, stand.y) then + for _, s in ipairs({ { 0, 1, "down", "up" }, { -1, 0, "left", "right" }, + { 1, 0, "right", "left" } }) do + local cx, cy = girl.cellX + s[1], girl.cellY + s[2] + if free(cx, cy) then + U.log(("(%d, %d) is not free; standing at"):format(stand.x, stand.y), + cx, cy, "facing", s[3]) + stand = { x = cx, y = cy, away = s[3], toward = s[4] } + break + end + end + end + U.teleport(game, MAP, stand.x, stand.y, stand.away) + U.wait(10) + ow = game.overworld + pin(ow) + local p = ow.player + check("standing beside the girl, facing away from her", + p.cellX == stand.x and p.cellY == stand.y and p.facing == stand.away) + + -- What the fix moved, read off the running game rather than repeated from + -- a comment: turnFrames is the dataset constant a mod can restamp + -- (src/world/FieldDefaults.lua) and the touch figure comes back out of + -- Player:turnWindow with a live overlay source. turnWindow is the fix's + -- own entry point, so a build without it falls back to the one window it + -- had for every source and the two checks below say so. + local function window() + return p.turnWindow and p:turnWindow() or p.turnFrames + end + local padWindow = window() + game.input:overlayPressed(p.facing) + game.input:step() + local touchWindow = window() + game.input:overlayReleased(p.facing) + game.input:step() + check("the pad turn window is 4 fixed steps, up from 2", padWindow == 4) + check("the on-screen d-pad gets 8", touchWindow == 8) + U.log(("turn window: %d fixed steps on keyboard and pad (%d ms), %d on the ") + :format(padWindow, math.floor(padWindow * 1000 / 60), touchWindow) + .. ("on-screen d-pad (%d ms); hold past it and the step commits.") + :format(math.floor(touchWindow * 1000 / 60))) + + -- A press no longer than the window turns and stops there. + for _ = 1, padWindow do + press(stand.toward) + coroutine.yield() + end + release(stand.toward) + U.wait(6) + check(("a %d-step press turned toward the girl"):format(padWindow), + p.facing == stand.toward) + check("and left the feet on the same tile", + p.cellX == stand.x and p.cellY == stand.y) + + -- The same press held well past the window does step. Sideways, because + -- the girl blocks the step toward her and would mask it. + local openDir, openBack + for _, s in ipairs({ { "left", "right", -1, 0 }, { "right", "left", 1, 0 }, + { "up", "down", 0, -1 }, { "down", "up", 0, 1 } }) do + if s[1] ~= stand.toward and free(stand.x + s[3], stand.y + s[4]) then + openDir, openBack = s[1], s[2] + break + end + end + if openDir then + U.hold(game, openDir, 40) + U.wait(10) + check(("a 40-step hold of %s walks off the tile"):format(openDir), + p.cellX ~= stand.x or p.cellY ~= stand.y) + + -- The corner: swap direction without ever lifting the d-pad. Walk back + -- on openBack first and break on the landing poll, so the pad is still + -- down and the turn is still spent when the swap arrives. + local walked = false + for _ = 1, 240 do + press(openBack) + coroutine.yield() + if p.moving then + walked = true + elseif walked then + break + end + end + check(("the return hold of %s is walking"):format(openBack), walked) + + -- Nothing has re-armed wCheckFor180DegreeTurn, so the new facing steps + -- on the poll it arrives on with no turn delay. Pre-fix, every corner + -- paid the window again. Perpendicular and walkable from wherever the + -- return hold landed, so a blocked cell can't read as a stall. + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + local swap + for _, d in ipairs({ "up", "down", "left", "right" }) do + local dd = DELTA[d] + if d ~= openDir and d ~= openBack + and free(p.cellX + dd[1], p.cellY + dd[2]) then + swap = d + break + end + end + if swap then + local wasFacing = p.facing + release(openBack) + press(swap) -- same poll, so the d-pad never reads empty + coroutine.yield() + U.log(("corner: swapped %s for %s without a release, facing went %s to %s") + :format(openBack, swap, wasFacing, p.facing)) + check("the swapped direction steps on the poll it arrives on", + p.facing == swap and p.moving and p.turnTimer == 0) + release(swap) + else + U.log("no perpendicular open cell here; skipped the corner check") + release(openBack) + end + for _ = 1, 200 do + if not p.moving then break end + coroutine.yield() + end + else + U.log("no open cell beside the stand tile; skipped the hold and corner") + end + + -- Back beside her, facing her, and talk: a silent A here would look exactly + -- like a facing that missed. + U.teleport(game, MAP, stand.x, stand.y, stand.toward) + U.wait(10) + ow = game.overworld + p = ow.player + girl = pin(ow) + local fx, fy = p:facingCell() + check("the facing cell is the girl's", + girl ~= nil and ow:npcAtCell(fx, fy) == girl) + + U.tap(game, "a") + U.wait(30) + local top = game.stack:top() + local isBox = getmetatable(top) == TextBox + check("A opened her text box", isBox) + if isBox then + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + U.log("box reads:", table.concat(shown, " / ")) + end + if U.shot(game, SHOT_DIR .. "/bug415_turn_in_place.png") then + U.log("captured", SHOT_DIR .. "/bug415_turn_in_place.png", + "so the window did render; a blank screen later is not the driver") + end + U.tap(game, "b") + U.wait(20) + + U.log("You are on the girl's north side in Pallet Town, facing her, with") + U.log("open ground north, east and west. Tap a direction: Red should") + U.log("pivot on the spot and stay on his tile. Hold the same direction") + U.log("and he walks, with no extra pause before the first step. Then walk") + U.log("a lap and change direction without letting go; corners should stay") + U.log("smooth, and a stall there is the half of #415 that is not the tap.") + U.log("The near miss to watch for is a tap that turns AND slides one tile,") + U.log("which still reads as a turn unless you watch Red's feet against the") + U.log("fence. The girl is the control: she blocks the step, so tapping") + U.log("toward her looked fine even while this was broken. That is the") + U.log("Yellow complaint in the report -- the follower Pikachu does not") + U.log("block (src/world/PikachuFollower.lua), so a tap meant to turn and") + U.log("talk to it walked onto its cell and straight past it instead.") while true do coroutine.yield() diff --git a/tests/drivers/wide_move_anim_bug562_test.lua b/tests/drivers/wide_move_anim_bug562_test.lua new file mode 100644 index 00000000..b9fc8e93 --- /dev/null +++ b/tests/drivers/wide_move_anim_bug562_test.lua @@ -0,0 +1,390 @@ +-- Manual check that BATTLE LAYOUT = WIDE draws move animations intact (#562). +-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the shake +-- predefs displace is the whole screen; the wide composition moved only the +-- two pic regions, which slid the monsters across a nailed-down HUD and text +-- box. TAIL WHIP is the reported case (wAnimationType 6, +-- AnimationShakeScreenHorizontallySlow b=3, animations.asm:526). Composition +-- half: tests/engine/wide_battle_shake_bug562.lua. Never under POKEPORT_SPEED. +-- POKEPORT_DRIVER=tests/drivers/wide_move_anim_bug562_test.lua POKEPORT_IDENTITY=bug562 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local WideBattle = require("src.battle.WideBattle") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- One move per shake family the wide composition has to carry. `hitX` is + -- the applying-attack amplitude PlayApplyingAttackAnimation plays after the + -- move (AnimationTypePointerTable, animations.asm:475); `animX` is a shake + -- the move's own subanimation runs. wAnimationType 4, 5 and 6 are the + -- player's turn -- the vertical 1 and the wide 2 / 3 are the foe's, and the + -- foe's rows are reported below rather than driven. + -- TAIL WHIP type 6, AnimationShakeScreenHorizontallySlow b=3 (silent; + -- every player status move takes this path) + -- BUBBLEBEAM type 5, fast horizontal b=2, with the damage thud + -- EARTHQUAKE SE_SHAKE_SCREEN inside its own animation (fastShakeProg 8), + -- shared with FISSURE and SEISMIC TOSS + -- WITHDRAW the control: a stat-UP effect leaves wAnimationType at 0 + -- (effects.asm, PlayCurrentMoveAnimation), so nothing moves. + -- It also fills the fourth grid slot, which the 2x2 needs. + local MOVES = { + { slot = 1, id = "TAIL_WHIP", type = 6, hitX = 3 }, + { slot = 2, id = "BUBBLEBEAM", type = 5, hitX = 2 }, + { slot = 3, id = "EARTHQUAKE", type = 4, animX = 8 }, + { slot = 4, id = "WITHDRAW", type = 0, hitX = 0, hitY = 0 }, + } + + -- ---- machine checks ---------------------------------------------------- + -- A move missing from the table, a layout that never switched and a battle + -- that never reached WIDE all look like "the animation is fine" from a chair. + U.log("#562 wide-layout move animations: machine checks") + for _, m in ipairs(MOVES) do + check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil) + end + local anims = game.data.battle_anims and game.data.battle_anims.moveAnims + check("battle_anims carries TAIL WHIP's own animation", + anims ~= nil and anims.TAIL_WHIP ~= nil) + check("...and EARTHQUAKE's, which carries SE_SHAKE_SCREEN itself", + anims ~= nil and anims.EARTHQUAKE ~= nil) + check("animations are on in OPTIONS (the shakes are gated on them)", + game.save.options.animations ~= false) + + game.save.options.battleLayout = "wide" + check("BATTLE LAYOUT is WIDE", game.save.options.battleLayout == "wide") + + local vol = game.save.options and game.save.options.sfxVol + U.log("sfxVol", tostring(vol), + vol == 0 and "-- SILENT, raise it in OPTION before judging the sound" + or "-- BUBBLEBEAM and EARTHQUAKE carry the damage thud; TAIL WHIP is silent") + + -- ---- fixture ----------------------------------------------------------- + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 40) + squirtle.moves = {} + for _, m in ipairs(MOVES) do + squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp } + end + game.save.party = { squirtle } + game.save.player.name = "RED" + + -- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground; the + -- battle is pushed straight in rather than encountered + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(5, 5) then + -- a map edit moved the path: take the nearest walkable cell instead + local sx, sy + for r = 1, 6 do + for dy = -r, r do + for dx = -r, r do + if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then + sx, sy = 5 + dx, 5 + dy + end + end + end + if sx then break end + end + if sx then + U.log("(5, 5) is blocked, standing on", sx, sy) + U.teleport(game, "ROUTE_1", sx, sy, "down") + U.wait(10) + ow = game.overworld + end + end + check("the overworld is up on ROUTE_1", ow ~= nil) + + -- a Normal-type foe: PIDGEY is Flying and would sit out EARTHQUAKE entirely + local battle = BattleState.newWild(game, "RATTATA", 20) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + check("the battle is drawing the wide surface", battle:wideLayout() == true) + local uw = battle:uiSize() + check(("the surface is %dpx wide (got %s)"):format(WideBattle.WIDTH, tostring(uw)), + uw == WideBattle.WIDTH) + + -- a foe that survives the whole run: every family needs its own turn, and + -- EARTHQUAKE off a level 40 SQUIRTLE takes a large bite + battle.enemy.mon.stats.hp = 900 + battle.enemy.mon.hp = 900 + battle.enemy.shownHP = 900 + -- WHIRLWIND ends a wild battle outright ("run"), taking the untested + -- families with it; curMoves aliases mon.moves, so one pass covers both + for i = #battle.enemy.mon.moves, 1, -1 do + local id = battle.enemy.mon.moves[i].id + if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then + table.remove(battle.enemy.mon.moves, i) + end + end + + -- ---- sample the offsets the composition is fed ------------------------- + -- One row per performMove, EITHER side: a row that stayed open across the + -- foe's reply would credit the player's move with the foe's shake. The + -- split at the first applyHitFx separates a shake the move's own animation + -- ran (SE_SHAKE_SCREEN) from the applying-attack one that follows it. + local rows, cur = {}, nil + local realPerform = battle.performMove + battle.performMove = function(self, user, target, moveInst, ...) + cur = { move = moveInst and moveInst.id, isPlayer = user.isPlayer, + animX = 0, animY = 0, hitX = 0, hitY = 0, hitFrames = 0 } + rows[#rows + 1] = cur + return realPerform(self, user, target, moveInst, ...) + end + local realFx = battle.applyHitFx + battle.applyHitFx = function(self, hit) + if cur and cur.animType == nil then cur.animType = hit.animType or 0 end + return realFx(self, hit) + end + + local function sample() + local fx = battle.fx + if not (cur and fx) then return end + local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0) + if cur.animType == nil then + cur.animX, cur.animY = math.max(cur.animX, dx), math.max(cur.animY, dy) + return + end + if dx > 0 or dy > 0 then cur.hitFrames = cur.hitFrames + 1 end + cur.hitX, cur.hitY = math.max(cur.hitX, dx), math.max(cur.hitY, dy) + end + + -- has this row finished its applying-attack effect? Type 4 runs a pic + -- blink and no screen program at all, so it is done as soon as it fires. + local function settled(row) + return row ~= nil and row.animType ~= nil + and (battle.fx == nil or battle.fx.shakeProg == nil) + end + + -- step n frames sampling every one, pressing A every `mash` frames but only + -- while a box is up: an A on the FIGHT menu starts a turn behind our back + local function pump(n, mash, stop) + for i = 1, n do + if mash and i % mash == 0 and battle.phase == "messages" then + table.insert(game.input.pressQueue, "a") + end + U.wait(1) + game.input.state.a = false + sample() + if stop and stop() then return end + end + end + + local function toMenu() + pump(1500, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) + return battle.phase == "menu" + end + + -- The wide move list is a 2x2 grid, and a press that would land on an + -- absent fourth move holds the cursor still (WideBattle.moveGridIndex), so + -- the route is searched through the engine's own navigation rather than + -- assumed: walking the column never reaches slots 2 and 4, and walking the + -- row can dead-end on a three-move party. + local DIRS = { "up", "down", "left", "right" } + local function routeTo(from, slot, count) + local seen, queue = { [from] = true }, { { at = from, path = {} } } + while #queue > 0 do + local node = table.remove(queue, 1) + if node.at == slot then return node.path end + for _, dir in ipairs(DIRS) do + local nxt = WideBattle.moveGridIndex(node.at, count, dir) + if nxt and not seen[nxt] then + seen[nxt] = true + local path = { unpack(node.path) } + path[#path + 1] = dir + queue[#queue + 1] = { at = nxt, path = path } + end + end + end + return nil + end + + -- FIGHT is slot 1 of the wide command grid; A opens moveSelect. A press on + -- a frame the battle is not reading input is simply lost, so retry. + local function useMove(slot) + for _ = 1, 80 do + if battle.phase == "moveSelect" then break end + if battle.phase == "menu" then + if battle.menuIndex ~= 1 then + U.tap(game, battle.menuIndex > 2 and "up" or "left") + else + U.tap(game, "a") + end + else + U.tap(game, "a") + end + U.wait(4) + end + if battle.phase ~= "moveSelect" then return false end + -- recompute the route every press: a press that lands on a frame the + -- battle is not reading is lost, and the cursor simply did not move + local count = #(battle.player.curMoves or {}) + for _ = 1, 30 do + if battle.moveIndex == slot then break end + local path = routeTo(battle.moveIndex, slot, count) + if not path or #path == 0 then break end + U.tap(game, path[1]) + U.wait(3) + end + if battle.moveIndex ~= slot then return false end + for _ = 1, 20 do + if battle.phase ~= "moveSelect" then return true end + U.tap(game, "a") + U.wait(3) + end + return false + end + + local function lastPlayerRow(id) + for i = #rows, 1, -1 do + if rows[i].move == id and rows[i].isPlayer then return rows[i] end + end + return nil + end + + local SHOTS = { TAIL_WHIP = "bug562_wide_tailwhip.png", + BUBBLEBEAM = "bug562_wide_bubblebeam.png", + EARTHQUAKE = "bug562_wide_earthquake.png" } + -- the foe's vertical drop (type 1) plays on ITS turn, so it is taken + -- whenever one goes past and chased down after the table below if not + local vertShot = false + + check("the battle reached its FIGHT menu", toMenu()) + + for _, m in ipairs(MOVES) do + local shotAt, sent, r + -- BUBBLEBEAM whiffs 1 in 6 and the foe's TAIL WHIP stacks accuracy drops, + -- so give each move a few turns rather than reading a miss (which plays no + -- applying animation at all) as a regression; hand the accuracy back first + for _ = 1, 8 do + battle.player.stages.accuracy = 0 + sent = useMove(m.slot) + if not sent then + U.log((" could not reach slot %d for %s (phase %s, move index %s, result %s)") + :format(m.slot, m.id, tostring(battle.phase), + tostring(battle.moveIndex), tostring(battle.result))) + break + end + pump(1500, 8, function() + local fx = battle.fx + local row = lastPlayerRow(m.id) + if not shotAt and row == cur and SHOTS[m.id] and fx + and (fx.shakeX or 0) ~= 0 then + shotAt = U.shot(game, DIR .. "/" .. SHOTS[m.id]) + end + if not vertShot and fx and (fx.shakeY or 0) ~= 0 + and cur and not cur.isPlayer then + vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png") + end + if row and battle.phase == "menu" and #battle.queue == 0 then return true end + return settled(row) + end) + toMenu() + r = lastPlayerRow(m.id) + -- a type-0 move never queues an applying-attack row, so one clean turn + -- is all it owes + if r and (r.animType ~= nil or m.type == 0) then break end + U.log(" " .. m.id .. " did not connect that turn; using it again") + end + check("chose " .. m.id .. " from the wide move grid", sent) + if sent and check(m.id .. " was performed", r ~= nil) then + U.log((" %s: type %s, own animation %dpx across / %dpx down, applying " + .. "%dpx across / %dpx down over %d frames") + :format(m.id, tostring(r.animType), r.animX, r.animY, + r.hitX, r.hitY, r.hitFrames)) + check(("%s is wAnimationType %d (got %d)") + :format(m.id, m.type, r.animType or 0), + (r.animType or 0) == m.type) + if m.hitX then + check(("%s shakes the screen %dpx sideways (got %d)") + :format(m.id, m.hitX, r.hitX), r.hitX == m.hitX) + end + if m.hitY then + check(("%s leaves the vertical alone (got %d)"):format(m.id, r.hitY), + r.hitY == m.hitY) + end + if m.animX then + check(("%s's own animation shakes %dpx sideways (got %d)") + :format(m.id, m.animX, r.animX), r.animX == m.animX) + end + if SHOTS[m.id] then + check("captured " .. m.id .. " mid-shake", shotAt == true) + end + end + end + + -- The vertical drop (type 1) only plays on the foe's turn, so it is taken by + -- spending harmless WITHDRAW turns until the RATTATA picks a plain damaging + -- move. Which move it picks is an AI roll, so a run where it never does is + -- a thin run, not a regression. + local function foeTypeOne() + for _, row in ipairs(rows) do + if not row.isPlayer and row.animType == 1 then return true end + end + return false + end + for _ = 1, 6 do + if vertShot then break end + if not useMove(4) then break end + pump(1200, 8, function() + local fx = battle.fx + if not vertShot and fx and (fx.shakeY or 0) ~= 0 + and cur and not cur.isPlayer then + vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png") + end + return battle.phase == "menu" and #battle.queue == 0 + end) + toMenu() + end + + local foe = {} + for _, row in ipairs(rows) do + if not row.isPlayer and row.animType then + foe[#foe + 1] = ("%s type %s (%dpx across, %dpx down)") + :format(tostring(row.move), tostring(row.animType), + math.max(row.animX, row.hitX), + math.max(row.animY, row.hitY)) + end + end + U.log("the RATTATA's rows: " .. (#foe > 0 and table.concat(foe, "; ") or "none")) + if vertShot then + U.log("captured the foe's 8px vertical drop as well") + elseif foeTypeOne() then + check("the foe's vertical drop was captured", false) + else + U.log("the RATTATA never used a plain damaging move in these turns, so") + U.log("there was no vertical drop to capture; watch for one by hand") + end + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- hand off ---------------------------------------------------------- + U.log("The pad is yours at the wide FIGHT menu, all four panels on screen.") + U.log("Slot 1 TAIL WHIP creeps the WHOLE screen 1px at a time out to 3px and") + U.log("back, twice, in silence: foe, both status panels and the message box") + U.log("move as one sheet. Slot 2 BUBBLEBEAM snaps it 2px across with the") + U.log("damage thud, slot 3 EARTHQUAKE shakes it 8px from inside its own") + U.log("animation. Let the RATTATA hit back for the vertical one: a plain") + U.log("TACKLE or QUICK ATTACK drops the same sheet 8px. The near miss to") + U.log("watch for is the one #562 reported: the monsters sliding while the HUD") + U.log("panels and the text box stay nailed down, or a pic sheared off at the") + U.log("seam between the two side windows on the way.") + U.log("Slot 4 WITHDRAW is the control: it moves nothing at all, on purpose.") + U.log("So are the animations' own sprites (the bubbles, the EARTHQUAKE rocks)") + U.log("-- those are OAM and correctly do NOT ride the shake, same as in OG.") + U.log("Flip OPTION -> BATTLE LAYOUT to OG and repeat: it should look the same.") + U.log("Screenshots: " .. DIR .. "/bug562_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/battle_colors_bug484.lua b/tests/engine/battle_colors_bug484.lua new file mode 100644 index 00000000..7e741096 --- /dev/null +++ b/tests/engine/battle_colors_bug484.lua @@ -0,0 +1,158 @@ +-- Cycling COLORS during a battle must keep the battle song (#484). The +-- pack change rebuilds the live map's baked atlas through +-- OverworldState:reloadMap, and that used to re-enter the map: setMap's +-- PlayMapMusic put the route theme over the battle theme. ReloadMapData +-- (home/reload_tiles.asm) only re-reads the map view and the tileset +-- patterns; map music comes from LoadMapData alone (home/overworld.asm, +-- gated on BIT_NO_MAP_MUSIC), so a reload is not a map entry. +-- The other half matters as much: the hotkey must still work in a battle, +-- so a "fix" that gates key 2 off while BattleState is on top fails here. +-- luajit tests/engine/battle_colors_bug484.lua + +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 OverworldState = require("src.world.OverworldController") + +-- OverworldController takes its collaborators as file-locals, and Game is +-- only bound when a real state enters; swapping the upvalues is how +-- tests/mod_world_tests.lua drives one of these methods with no dataset. +local function setUpvalue(fn, name, value) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then + debug.setupvalue(fn, i, value) + return true + end + i = i + 1 + end +end + +local emitted = {} +for _, up in ipairs({ + { "MapLoader", { invalidate = function() end, + invalidateAll = function() end } }, + { "Collision", { load = function() end } }, + { "Game", { data = {} } }, + { "Logger", { warn = function() end, error = function() end } }, + { "Runtime", { emit = function(name, payload) + emitted[#emitted + 1] = { name = name, payload = payload } + end } }, +}) do + check(setUpvalue(OverworldState.reloadMap, up[1], up[2]), + "reloadMap still closes over " .. up[1]) +end + +-- A state stub with just what reloadMap reads: the live map, the player's +-- cell, and a setMap that records the opts instead of loading anything. +-- `shrank` makes the reloaded map report the player's cell out of bounds +-- once, which is the only way past the first setMap. +local function newState(mapId, shrank) + local calls, entered = {}, 0 + return { + map = { id = mapId, inBounds = function() return true end }, + player = { cellX = 5, cellY = 5, facing = "down" }, + neighbors = {}, + calls = calls, + setMap = function(self, id, x, y, facing, opts) + calls[#calls + 1] = { id = id, x = x, y = y, facing = facing, opts = opts } + entered = entered + 1 + local ok = not (shrank and entered == 1) + self.map = { id = id, inBounds = function() return ok end } + end, + healPoint = function() return { map = "PALLET_TOWN", x = 3, y = 6 } end, + } +end + +local live = newState("ROUTE_1") +OverworldState.reloadMap(live, "ROUTE_1", "colors") +eq(#live.calls, 1, "reloading the live map re-enters it exactly once") +local opts = live.calls[1].opts or {} +check(opts.keepMusic == true, + "the reload keeps whatever song is playing (#484): a COLORS cycle " + .. "during a battle must not start the route theme") +eq(opts.via, "reload", "and it is still tagged as a reload, not a warp") +check(opts.seamless == true, "with no transition wipe") +eq(live.calls[1].x, 5, "the player is put back on the cell they were on") +eq(live.calls[1].y, 5, "on both axes") + +-- the escape hatch under it is a genuine map change, and PlayMapMusic +-- belongs there: the player is being sent to a heal point +local shrunk = newState("ROUTE_1", true) +OverworldState.reloadMap(shrunk, "ROUTE_1", "colors") +eq(#shrunk.calls, 2, "a map that shrank under the player sends them away") +eq(shrunk.calls[2].id, "PALLET_TOWN", "to their heal point") +check(not (shrunk.calls[2].opts or {}).keepMusic, + "and that one starts the destination's own music") + +local other = newState("ROUTE_1") +OverworldState.reloadMap(other, "VIRIDIAN_CITY", "colors") +eq(#other.calls, 0, "reloading a map that is not live touches nothing") +check(emitted[#emitted] and emitted[#emitted].name == "map.reloaded", + "map.reloaded still fires either way") + +-- The hotkey half, end to end through the real Game and PaletteFX: with a +-- battle on top the COLORS key is not gated off, and every press moves one +-- rung down the ladder. +local Game = require("src.core.Game") +local PaletteFX = require("src.render.PaletteFX") + +local reloads = {} +local ow = { + map = { id = "ROUTE_1" }, + reloadMap = function(_, id, reason) + reloads[#reloads + 1] = { id = id, reason = reason } + end, +} +Game.overworld = ow -- PaletteFX.setMode reaches the live overworld this way + +local battle = { isOpaque = true } -- stands in for BattleState on the stack +local session = { + overworld = ow, + save = { options = {} }, + stack = { states = { ow, battle }, + top = function(self) return self.states[#self.states] end }, + writeOptions = function() end, +} + +local seen, start = {}, PaletteFX.mode +for _ = 1, #PaletteFX.MODES do + Game.keypressed(session, "2") + seen[#seen + 1] = PaletteFX.mode +end +eq(#reloads, #PaletteFX.MODES, + "every COLORS press in a battle still rebuilds the map's atlas") +eq(reloads[1].reason, "colors", "tagged as the COLORS cycle") +eq(reloads[1].id, "ROUTE_1", "on the map the player is standing on") + +local distinct = {} +for _, mode in ipairs(seen) do distinct[mode] = true end +local count = 0 +for _ in pairs(distinct) do count = count + 1 end +eq(count, #PaletteFX.MODES, + "the palette visibly cycles: one press per mode walks the whole ladder") +eq(PaletteFX.mode, start, "and lands back where it started") +eq(session.save.options.colors, PaletteFX.mode, + "the choice is written to options like it is in the overworld") + +-- the pre-existing gate is unchanged: a script driving the overworld still +-- holds the key off, because reloadMap rebuilds the live NPC array +local busy = { + overworld = ow, + save = { options = {} }, + stack = { states = { ow }, top = function(self) return self.states[1] end }, + writeOptions = function() end, +} +ow.runner = { isRunning = function() return true end } +local before = PaletteFX.mode +local reloadsBefore = #reloads +Game.keypressed(busy, "2") +eq(PaletteFX.mode, before, "a running map script still holds COLORS off") +eq(#reloads, reloadsBefore, "and nothing reloads under it") + +T.finish("battle_colors_bug484") diff --git a/tests/engine/ghost_unveil_bug492.lua b/tests/engine/ghost_unveil_bug492.lua new file mode 100644 index 00000000..6c633e3e --- /dev/null +++ b/tests/engine/ghost_unveil_bug492.lua @@ -0,0 +1,128 @@ +-- The Silph Scope unveil in the POKEMON_TOWER_6F battle (#492). +-- +-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60): +-- with the scope in the bag the battle still ENTERS disguised -- InitWildBattle +-- takes its .isGhost branch on wCurOpponent == RESTLESS_SOUL either way -- and +-- the scope only buys the unveil that is played over the disguise: +-- EnemyAppearedText, UnveiledGhostText, LoadEnemyMonData, MarowakAnim, then +-- WildMonAppearedText. The port used to hand the scope-carrying player a +-- battle that opened with MAROWAK already on screen and no unveil at all. +-- +-- The story3 dispatch half (which branch the trigger picks, and that the ball +-- dodge survives the unveil) is tests/parity_marowak.lua and +-- tests/parity_marowak_ball.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") + +T.check(type(BattleState.makeUnveiledGhost) == "function", + "BattleState:makeUnveiledGhost exists") +T.check(BattleState.makeUnveiledGhost ~= BattleState.makeGhost, + "the unveiled battle is not just the scope-less ghost battle") + +local REAL_SPRITE = { marker = "the MAROWAK front pic" } + +local function newBattle() + return setmetatable({ + kind = "wild", + queue = {}, + frame = 0, + data = { text = {}, pokemon = {} }, + enemy = { name = "MAROWAK", sprite = REAL_SPRITE, + mon = { species = "MAROWAK", level = 30 } }, + }, BattleState) +end + +-- ------------------------------------------------------- entering disguised +local b = newBattle() +b:makeUnveiledGhost() +T.eq(b.enemy.name, "GHOST", + "with the scope the battle still OPENS as the GHOST (#492)") +T.check(b.enemy.sprite ~= REAL_SPRITE, + "and wears the ghost pic, not the MAROWAK one") +T.check(b.scopeReveal == true, "the unveil is armed") +T.check(not b.ghost, + "but IsGhostBattle stays false: the mon can be attacked and flees normally") +T.eq(b.ghostReal.name, "MAROWAK", "the real nick is kept for LoadEnemyMonData") +T.eq(b.ghostReal.sprite, REAL_SPRITE, "...and the real pic with it") + +local ghostSprite = b.enemy.sprite + +-- ------------------------------------------------------------- the row order +-- .isMarowak prints over the "GHOST appeared!" box enter() already queued: the +-- unveil line, MarowakAnim (a wait row parked over the fx state machine), then +-- "Wild MAROWAK appeared!" under the restored name. +b:queueScopeReveal() +T.eq(#b.queue, 4, "the unveil is four queue rows") +T.check(type(b.queue[1].text) == "string" + and b.queue[1].text:find("SILPH SCOPE", 1, true) ~= nil, + "row 1 is UnveiledGhostText") +T.check(type(b.queue[2].fn) == "function", "row 2 starts MarowakAnim") +T.eq(b.queue[3].wait, BattleState.GHOST_REVEAL_FRAMES, + "row 3 holds the queue for the length of the animation") +T.check(type(b.queue[4].text) == "string" + and b.queue[4].text:find("MAROWAK", 1, true) ~= nil, + "row 4 is WildMonAppearedText under the real name") +T.check(b.queue[4].text:find("GHOST", 1, true) == nil, + "...and not under the disguise") + +-- ------------------------------------------------------------ the animation +-- MarowakAnim (engine/battle/ghost_marowak_anim.asm): FlashSprite8Times, the +-- rOBP1 fade-out, the pic swap, the fade-in. The whole point of #492 is that +-- the swap comes AFTER the ghost has faded off the paper, so every frame up to +-- there still shows the GHOST. +b.queue[2].fn() +T.check(b.ghostReveal ~= nil, "the fn arms the reveal state") + +local FLASH = BattleState.GHOST_FLASH_FRAMES +local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES +local swapFrame, fades, stillGhost = nil, {}, true +for frame = 1, BattleState.GHOST_REVEAL_FRAMES do + b:updateFx() + local pf = b.picFx and b.picFx[b.enemy] + fades[frame] = pf and pf.fade + if not swapFrame and b.enemy.name ~= "GHOST" then swapFrame = frame end + if frame <= FLASH + FADE_OUT and b.enemy.name ~= "GHOST" then + stillGhost = false + end +end + +T.check(stillGhost, + "the GHOST is on screen for the whole flash and fade-out (#492)") +T.eq(swapFrame, FLASH + FADE_OUT + 1, + "the pic and nick swap the frame after the ghost has faded out") +T.eq(b.enemy.name, "MAROWAK", "the real nick is back by the end") +T.eq(b.enemy.sprite, REAL_SPRITE, "and the real pic with it") +T.check(b.enemy.sprite ~= ghostSprite, "the ghost pic is gone") + +-- FlashSprite8Times xors rOBP1 with $80 every 10 frames, so the body alternates +-- between two shades rather than sitting opaque +T.eq(fades[5], 1, "the flash opens on the ghost at full strength") +T.eq(fades[15], 0.5, "...drops a shade 10 frames in") +T.eq(fades[25], 1, "...and comes back") +T.check(fades[FLASH + FADE_OUT] == 0, + "the ghost is fully faded out when the swap lands") +T.check(fades[FLASH + FADE_OUT + 1] > 0 + and fades[FLASH + FADE_OUT + 1] < 1, + "the MAROWAK fades IN rather than popping on") +T.eq(fades[BattleState.GHOST_REVEAL_FRAMES - 1], 1, + "and reaches full strength before the box turns") + +T.check(b.ghostReveal == nil, "the reveal state clears itself") +T.check(b.scopeReveal == nil, "so does the flag that gated the intro cry") +local pf = b.picFx and b.picFx[b.enemy] +T.check(pf == nil or pf.fade == nil, + "no leftover alpha on the pic for the rest of the battle") + +-- ------------------------------------------------ the scope-less ghost still +-- Nothing above may change the battle you get WITHOUT the scope: still a real +-- ghost battle, still no unveil queued. +local noScope = newBattle() +noScope:makeGhost() +T.check(noScope.ghost == true, "without the scope IsGhostBattle is true") +T.eq(noScope.enemy.name, "GHOST", "and the disguise is the same one") +T.check(noScope.scopeReveal == nil, "with no unveil armed") +T.eq(#noScope.queue, 0, "and no unveil rows queued") + +T.finish("ghost unveil") diff --git a/tests/engine/menu_click_bug570.lua b/tests/engine/menu_click_bug570.lua new file mode 100644 index 00000000..aaf4eb9c --- /dev/null +++ b/tests/engine/menu_click_bug570.lua @@ -0,0 +1,156 @@ +-- Menu button sounds (#570). HandleMenuInput_ (home/window.asm) replays +-- SFX_PRESS_AB whenever the watched keys it just returned on include +-- PAD_A | PAD_B, unless BIT_NO_MENU_BUTTON_SOUND is set in wMiscFlags; +-- DisplayListMenuID watches PAD_A | PAD_B | PAD_SELECT (home/list_menu.asm), +-- so SELECT is answered but never beeps; DisplayOptionMenu plays it only at +-- .exitMenu (engine/menus/main_menu.asm), i.e. B, START and A on CANCEL. +-- Silence is half the contract: a click the original does not make is as +-- wrong as one it makes and the port swallows. +-- luajit tests/engine/menu_click_bug570.lua + +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") + +-- The UI modules reach for Sound lazily (require inside the press branch), +-- so seeding package.loaded before they load is enough to see every cue. +local played = {} +package.loaded["src.core.Sound"] = { + play = function(_, key) played[#played + 1] = key end, +} + +local ListMenu = require("src.ui.ListMenu") +local Menu = require("src.ui.Menu") +local OptionsMenu = require("src.ui.OptionsMenu") +local PartyMenu = require("src.ui.PartyMenu") + +-- A stub game with the three things a menu touches: a stack it can pop +-- itself off, an input queue that is one fixed step of edges, and a +-- non-nil `data` (the beep helpers skip themselves when data is nil, which +-- is how the harness-driven screens stay silent). +local function newGame() + local game = { data = {}, save = { options = {}, party = {}, + flags = {}, inventory = {} } } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + function game:writeOptions() end + return game +end + +-- one fixed step with `btn` on its edge; returns the cues it produced +local function press(state, btn) + played = {} + state.game.input.queue = { [btn] = true } + state:update(1 / 60) + state.game.input.queue = {} + return played +end + +local function beeps(state, btn) + local cues = press(state, btn) + for _, key in ipairs(cues) do + if key ~= "Press_AB" then + check(false, "unexpected cue " .. tostring(key) .. " on " .. btn) + end + end + return #cues +end + +-- ITEM and POKéDEX are both DisplayListMenuID lists, and cancelling either +-- was the silent half of the report. +local function newList(opts) + local game = newGame() + local list = ListMenu.new(game, "BAG", + { { label = "POTION", value = "POTION" }, + { label = "ANTIDOTE", value = "ANTIDOTE" } }, opts) + game.stack:push(list) + return list, game +end + +local list = newList({ onChoose = function() end }) +eq(beeps(list, "a"), 1, "the first A on an item in ITEM clicks once (#570)") +eq(beeps(list, "b"), 1, "B out of a list clicks once (ITEM, POKéDEX cancel)") + +local moves = newList({ onChoose = function() end, + onSelectKey = function() end }) +eq(beeps(moves, "select"), 0, + "SELECT is watched by DisplayListMenuID but outside its PAD_A | PAD_B " + .. "sound test, so the swap key stays silent") +eq(beeps(moves, "up"), 0, "moving the cursor is silent") +eq(beeps(moves, "down"), 0, "moving the cursor is silent both ways") + +-- Both PC sessions set BIT_NO_MENU_BUTTON_SOUND for their whole run +-- (engine/menus/pc.asm, engine/menus/players_pc.asm), so their lists are +-- the control case: the same code path, deliberately mute. +local pc = newList({ noSound = true, onChoose = function() end }) +eq(beeps(pc, "a"), 0, "a PC list holds BIT_NO_MENU_BUTTON_SOUND: A is mute") +eq(beeps(pc, "b"), 0, "and so is backing out of it") + +-- an emptied bag still answers A and B, and HandleMenuInput_ does not care +-- that the list has no rows +local emptyGame = newGame() +local empty = ListMenu.new(emptyGame, "BAG", {}, {}) +emptyGame.stack:push(empty) +eq(beeps(empty, "a"), 1, "A out of an empty list still clicks") + +-- OPTION: .exitMenu is the only PlaySound in DisplayOptionMenu +local function newOptions() + local game = newGame() + local om = OptionsMenu.new(game) + game.stack:push(om) + return om +end + +local om = newOptions() +om.index = 1 +eq(beeps(om, "a"), 0, "A on a setting row re-loops in the original: silent") +eq(beeps(om, "right"), 0, "and the Left/Right toggles are silent too") +eq(beeps(om, "left"), 0, "in both directions") +om.index = #om.rows + 1 -- CANCEL sits one past the hook-built rows +eq(beeps(om, "a"), 1, "A on CANCEL in OPTION clicks once (#570)") +eq(beeps(newOptions(), "b"), 1, "B out of OPTION clicks once (#570)") +eq(beeps(newOptions(), "start"), 1, "START leaves OPTION the same way") + +-- POKéMON: HandlePartyMenuInput runs through HandleMenuInput_ +local function newParty() + local game = newGame() + local pm = PartyMenu.new(game, {}) + game.stack:push(pm) + return pm +end + +eq(beeps(newParty(), "b"), 1, "B out of POKéMON clicks once (#570)") +eq(beeps(newParty(), "a"), 1, "and A in POKéMON clicks") +eq(beeps(newParty(), "down"), 0, "moving between slots is silent") + +-- The start menu itself already beeped before the fix; pinned here because +-- an over-eager patch that beeps on every watched key would break it. +-- draw_start_menu.asm's mask includes PAD_START, and START is outside the +-- PAD_A | PAD_B sound test, so closing the menu with it is silent. +local function newStartMenu() + local game = newGame() + local m = Menu.new(game, { { label = "POKéDEX", onSelect = function() end }, + { label = "ITEM", onSelect = function() end } }, + { startCloses = true }) + game.stack:push(m) + return m +end + +eq(beeps(newStartMenu(), "a"), 1, "A on a start-menu row clicks") +eq(beeps(newStartMenu(), "b"), 1, "B closing the start menu clicks") +eq(beeps(newStartMenu(), "start"), 0, + "START closes the start menu silently: it is watched but not in the " + .. "PAD_A | PAD_B branch") + +T.finish("menu_click_bug570") diff --git a/tests/engine/menu_row_offset_bug564.lua b/tests/engine/menu_row_offset_bug564.lua new file mode 100644 index 00000000..84e4b189 --- /dev/null +++ b/tests/engine/menu_row_offset_bug564.lua @@ -0,0 +1,141 @@ +-- Boxed-menu row geometry (#564 start menu, #572 the PC and Pokédex windows). +-- pokered's menu boxes hug their choices: the last one lands on the bottom +-- interior row, so a double-spaced menu whose box is n*2+2 tall starts at +-- ty+2 -- draw_start_menu.asm (TextBoxBorder at 10,0 b=$0e, then hlcoord +-- 12,2 with wTopMenuItemY 2), players_pc.asm (0,0 b=8 c=14, hlcoord 2,2). +-- The port drew every row one tile high, which parked the text against the +-- top border and left the bottom interior row blank. +-- ROM-free: builds the real screens over tests/fixture_data. +-- luajit tests/engine/menu_row_offset_bug564.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq, same = T.check, T.eq, T.same +local Data = T.fixtures.load() + +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local Screens = require("src.ui.Screens") +local StateStack = require("src.core.StateStack") +local Theme = require("src.ui.Theme") + +local function newGame() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local save = SaveData.newGame() + save.flags = save.flags or {} + save.flags.EVENT_GOT_POKEDEX = true + save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } } + save.inventory = { FIX_POTION = 3 } + return { + data = Data, save = save, stack = stack, + input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + }, + } +end + +-- Draw the menu with the font stubbed out and report, in tile rows, where +-- the labels, the ▶ cursor and the "more below" arrow actually landed. +local function layout(menu) + local realDraw, realCode = Font.draw, Font.drawCode + local out = { rows = {}, cols = {} } + Font.draw = function(_, x, y) + out.rows[#out.rows + 1] = y / 8 + out.cols[#out.cols + 1] = x / 8 + return 8 + end + Font.drawCode = function(code, x, y) + if code == Theme.cursor then + out.cursor, out.cursorCol = y / 8, x / 8 + elseif code == Theme.moreArrow then + out.arrow = y / 8 + end + end + local ok, err = pcall(menu.draw, menu) + Font.draw, Font.drawCode = realDraw, realCode + if not ok then error(err, 0) end + return out +end + +-- Every boxed menu owes the same two things to its border, and the second +-- is the one #564/#572 were about: a menu that starts at ty+1 leaves the +-- bottom interior row empty and the text crowded under the top edge. +local function boxRules(menu, label) + local at = layout(menu) + local first, last = at.rows[1], at.rows[#at.rows] + check(first and first >= menu.ty + 1 and last <= menu.ty + menu.th - 2, + label .. ": every choice sits inside the border (rows " + .. tostring(first) .. ".." .. tostring(last) .. ", interior " + .. (menu.ty + 1) .. ".." .. (menu.ty + menu.th - 2) .. ")") + eq(last, menu.ty + menu.th - 2, + label .. ": the last choice lands on the bottom interior row") + eq(at.cursor, at.rows[menu.index - menu.scroll], + label .. ": the cursor shares its row with the highlighted choice") + eq(at.cursorCol, menu.tx + 1, label .. ": the cursor sits one tile in") + return at +end + +-- ---------------------------------------------------------------- #564 +-- draw_start_menu.asm: box at (10,0) 14 rows tall, first choice at row 2, +-- seven double-spaced choices, so the last is on row 14 and the blank row +-- is the one under the top border. +local game = newGame() +local start = Screens.push(game, "StartMenu") +eq(#start.items, 7, "vanilla start menu has seven rows with the dex") +local at = boxRules(start, "start menu") +same(at.rows, { 2, 4, 6, 8, 10, 12, 14 }, "start menu rows match hlcoord 12,2") +eq(at.cols[1], start.tx + 2, "start menu labels sit two tiles in") + +start.index = 3 +eq(layout(start).cursor, 6, "cursor follows the third row down to row 6") + +-- The "more below" glyph rides the bottom border, because the bottom +-- interior row now belongs to a choice (#564). +local Menu = require("src.ui.Menu") +local many = {} +for i = 1, 10 do many[i] = { label = "ROW" .. i } end +local scrolled = Menu.new(game, many, + { tx = 9, ty = 0, tw = 11, maxVisible = 8 }) -- StartMenu's own opts +local sat = layout(scrolled) +eq(sat.arrow, scrolled.ty + scrolled.th - 1, + "the more-below arrow is on the border row") +check(sat.arrow > sat.rows[#sat.rows], + "the arrow is clear of the last visible choice") + +-- ---------------------------------------------------------------- #572 +-- players_pc.asm: TextBoxBorder (0,0) b=8 c=14 -> a 16x10 box whose four +-- choices start at hlcoord 2,2. +local pc = Screens.push(newGame(), "PlayerPC") +eq(pc.th, 10, "player's PC box is 10 tiles tall") +same(boxRules(pc, "player's PC").rows, { 2, 4, 6, 8 }, + "player's PC rows match hlcoord 2,2") + +-- The Pokédex side menu (pokedex.asm PokedexMenuItemsText): DATA / CRY / +-- AREA / QUIT, opened by choosing a seen species off the dex list. +local dexGame = newGame() +local dexList = Screens.push(dexGame, "PokedexMenu", {}) +local entry = dexList.items[1] +eq(entry.value, "FIXMON_A", "the fixture dex list opens on a seen species") +dexList.onChoose(entry, dexList) +local side = dexGame.stack:top() +check(side ~= dexList, "choosing an entry pushes the side menu") +same(boxRules(side, "Pokédex side menu").rows, { 10, 12, 14, 16 }, + "side menu rows fill its box") + +-- The bag's USE / TOSS box is the one menu pokered draws without a blank +-- leading row: USE_TOSS_MENU_TEMPLATE (data/text_boxes.asm) is (13,10) to +-- (19,14) with the text at 15,11, so two double-spaced choices exactly +-- fill rows 11 and 13. #284 sized the port's box to that, which is why +-- the shared "first choice at ty+2" rule pushes TOSS onto the border here. +local bagGame = newGame() +local bag = Screens.push(bagGame, "BagMenu") +bag.onChoose(bag.items[1], bag) +local useToss = bagGame.stack:top() +eq(#useToss.items, 2, "the bag submenu is USE / TOSS") +boxRules(useToss, "bag USE/TOSS") + +T.finish("menu_row_offset_bug564") diff --git a/tests/engine/pokedex_quit_bug571.lua b/tests/engine/pokedex_quit_bug571.lua new file mode 100644 index 00000000..796254e8 --- /dev/null +++ b/tests/engine/pokedex_quit_bug571.lua @@ -0,0 +1,94 @@ +-- QUIT on a Pokédex entry closes the whole Pokédex (#571). +-- HandlePokedexSideMenu hands ShowPokedexMenu b=1 for QUIT and b=2 for B +-- (engine/menus/pokedex.asm); only b=2 loops back to .doPokemonListMenu, +-- b=1 falls through to .exitPokedex, which drops the dex and returns to +-- whoever opened it with wBattleAndStartSavedMenuItem intact. The port +-- popped the side menu and left the list up, so QUIT looked like B. +-- ROM-free: real StateStack and real screens over tests/fixture_data. +-- luajit tests/engine/pokedex_quit_bug571.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +local Data = T.fixtures.load() + +local SaveData = require("src.core.SaveData") +local Screens = require("src.ui.Screens") +local StateStack = require("src.core.StateStack") + +local function newGame() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local save = SaveData.newGame() + save.flags = save.flags or {} + save.flags.EVENT_GOT_POKEDEX = true + save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } } + return { + data = Data, save = save, stack = stack, + input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + }, + } +end + +local function press(game, btn) + game.input.queue = { [btn] = true } + game.stack:update(1 / 60) + game.input.queue = {} +end + +-- what the stack is carrying, named the way Screens tags its screens +local function stackIds(game) + local ids = {} + for i, state in ipairs(game.stack.states) do + ids[i] = tostring(state.screenId or "sideMenu") + end + return table.concat(ids, " > ") +end + +local function openSideMenu(game) + local start = Screens.push(game, "StartMenu") + eq(start.items[start.index].label, "POKéDEX", + "the start menu opens with the cursor on POKéDEX") + press(game, "a") + local list = game.stack:top() + eq(list.screenId, "PokedexMenu", "POKéDEX opens the dex list") + eq(list.items[list.index].value, "FIXMON_A", + "the cursor is on a seen species, so A opens the side menu") + press(game, "a") + local side = game.stack:top() + check(side ~= list, "A on the entry pushed the DATA/CRY/AREA/QUIT menu") + return list, side +end + +-- ---------------------------------------------------------------- QUIT +local game = newGame() +local list, side = openSideMenu(game) +-- the start menu popped itself when POKéDEX was chosen (Menu pops before +-- onSelect unless the row is keepOpen), so this is dex list + side menu +eq(#game.stack.states, 2, "dex list and side menu are what is on the stack") +eq(side.items[#side.items].label, "QUIT", "QUIT is the last side-menu row") +side.index = #side.items +press(game, "a") + +eq(#game.stack.states, 1, + "QUIT unwound the side menu and the dex list (" .. stackIds(game) .. ")") +local top = game.stack:top() +eq(top.screenId, "StartMenu", "QUIT lands back on the start menu") +check(top ~= list, "the dex list is not what is left on the stack") +eq(top.items[top.index].label, "POKéDEX", + "the start menu cursor is still on POKéDEX (wBattleAndStartSavedMenuItem)") + +-- ---------------------------------------------------------------- B +-- The control case: B is b=2, which re-shows the list rather than leaving. +-- If QUIT and B ever behave the same again, one of these two fails. +local bGame = newGame() +local bList = select(1, openSideMenu(bGame)) +press(bGame, "b") +eq(#bGame.stack.states, 1, "B off the side menu keeps the dex list up") +eq(bGame.stack:top(), bList, "B returns to the dex list, not the start menu") + +T.finish("pokedex_quit_bug571") diff --git a/tests/engine/render_compose_seam.lua b/tests/engine/render_compose_seam.lua new file mode 100644 index 00000000..cfb34caf --- /dev/null +++ b/tests/engine/render_compose_seam.lua @@ -0,0 +1,62 @@ +-- Unit coverage for the render.compose seam (D14: a public-API test names +-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents +-- it). render.compose lets a mod take over window composition: a wrap that +-- returns true without calling next owns the whole window; a wrap that calls +-- next lets the engine's normal single-window composite run and decorates it. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local bus = Hooks.new() +Runtime.hooks = bus + +-- the shape Renderer:endFrame hands the hook +local function fakeCtx() + return { + renderer = {}, worldCanvas = {}, uiCanvas = {}, + worldActive = true, zones = {}, worldZones = nil, + ww = 480, wh = 432, ox = 0, oy = 12, scale = 3, + dpiX = 1, dpiY = 1, secondScreen = {}, + } +end + +-- takeover: a mod drawing its own layout returns true and never calls next, +-- so the engine's vanilla composite is skipped entirely +do + local vanillaRan, gotCtx = false, nil + bus:wrap("render.compose", function(next, renderer, ctx) + gotCtx = ctx + return true + end, 0, "ds-mod") + local handled = Runtime.call("render.compose", + function() vanillaRan = true; return false end, + { tag = "renderer" }, fakeCtx()) + T.eq(handled, true, "a mod returning true signals full window takeover") + T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)") + T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil, + "the hook receives the frame ctx (metrics, canvases, secondScreen)") + bus.chains["render.compose"] = nil +end + +-- decorate: a mod calling next lets the engine composite run, and the +-- engine's not-handled return (false) flows back through the chain +do + local vanillaRan = false + bus:wrap("render.compose", function(next, renderer, ctx) + return next() + end, 0, "ds-mod") + local handled = Runtime.call("render.compose", + function() vanillaRan = true; return false end, + { tag = "renderer" }, fakeCtx()) + T.eq(vanillaRan, true, "calling next runs the engine composite") + T.eq(handled, false, "the engine's not-handled return flows back through next") + bus.chains["render.compose"] = nil +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks + +T.finish("render_compose_seam") diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua new file mode 100644 index 00000000..65fb2244 --- /dev/null +++ b/tests/engine/soft_reset_bug563.lua @@ -0,0 +1,197 @@ +-- Soft reset: A+B+SELECT+START held together drops the game back to the +-- title from anywhere, mid-battle included (#563). _Joypad +-- (engine/joypad.asm:6) tests the raw read with `cp PAD_BUTTONS` -- an +-- equality, so a d-pad direction in the mix cancels it -- and does so ahead +-- of the wJoyIgnore / BIT_DISABLE_JOYPAD masking further down, which is why +-- the combo still works where ordinary input is being thrown away. +-- TrySoftReset then decrements hSoftReset, seeded with 16 by Init +-- (home/init.asm:81), one poll at a time. The on-screen overlay's half is +-- here too: one finger only ever claims one control. +-- luajit tests/engine/soft_reset_bug563.lua + +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 Input = require("src.core.Input") +local TouchControls = require("src.core.TouchControls") +local Game = require("src.core.Game") + +-- pokered's 16 polls; the port counts fixed steps, which are the same 60Hz +local HOLD_STEPS = 16 + +-- The keys the hand-off tells a player to press. Pinned because the combo +-- is only reachable if all four GB buttons have a key, and because the +-- letter A is bound to LEFT here -- a player reaching for "A" on the +-- keyboard adds a direction and cancels the chord instead of arming it. +Input:init() +eq(Input.keyBindings["z"], "a", "Z is the A button") +eq(Input.keyBindings["x"], "b", "X is the B button") +eq(Input.keyBindings["escape"], "start", "Escape is START") +eq(Input.keyBindings["tab"], "select", "Tab is SELECT") +eq(Input.keyBindings["a"], "left", "the letter A is LEFT, not the A button") + +-- ---- the chord itself -------------------------------------------------- + +local function holdKeys(...) + Input:init() + for _, key in ipairs({ ... }) do Input:keypressed(key) end +end + +holdKeys("z", "x", "escape", "tab") +check(Input:softResetHeld(), "all four buttons down arms the combo") + +for _, missing in ipairs({ "z", "x", "escape", "tab" }) do + local keys = {} + for _, key in ipairs({ "z", "x", "escape", "tab" }) do + if key ~= missing then keys[#keys + 1] = key end + end + holdKeys(unpack(keys)) + check(not Input:softResetHeld(), + "three of the four is not the combo (without " .. missing .. ")") +end + +-- `cp PAD_BUTTONS` is an equality: any direction held alongside the four +-- takes the raw read off PAD_BUTTONS and TrySoftReset is never reached. +for _, dir in ipairs({ "up", "down", "left", "right" }) do + holdKeys("z", "x", "escape", "tab", dir) + check(not Input:softResetHeld(), dir .. " held alongside cancels the combo") +end + +-- ---- the 16-step countdown --------------------------------------------- + +-- one Game:step's worth of the chord; returns whether the reset fired +local function stepChord() + Input:step() + return Input:softResetStep() +end + +holdKeys("z", "x", "escape", "tab") +for i = 1, HOLD_STEPS - 1 do + check(not stepChord(), "step " .. i .. " of the hold does not reset yet") +end +check(stepChord(), "the " .. HOLD_STEPS .. "th consecutive step resets") + +-- hSoftReset is never re-seeded on release in the original, so its count +-- leaks across a session; the port re-arms instead, or a session's worth of +-- stray four-button presses would eventually add up to a reset. +holdKeys("z", "x", "escape", "tab") +for _ = 1, HOLD_STEPS - 1 do stepChord() end +Input:keyreleased("tab") +check(not stepChord(), "lifting SELECT one step short cancels") +Input:keypressed("tab") +for i = 1, HOLD_STEPS - 1 do + check(not stepChord(), + "re-pressing it restarts the count from 16, not from 1 (step " .. i .. ")") +end +check(stepChord(), "and a full fresh hold resets") + +-- same re-arm for the direction case: a thumb brushing the d-pad mid-hold +holdKeys("z", "x", "escape", "tab") +for _ = 1, HOLD_STEPS - 1 do stepChord() end +Input:keypressed("up") +check(not stepChord(), "a direction one step short cancels") +Input:keyreleased("up") +for _ = 1, HOLD_STEPS - 1 do + check(not stepChord(), "and the count starts over") +end +check(stepChord(), "reaching 16 again from the restart") + +-- ---- Game:step routes it above the state stack -------------------------- + +-- Doubles for the two services Game:step touches on the reset path. The +-- real stack is not needed: what is being pinned is that the combo is read +-- before stack:update, so it fires from a battle, a menu or a cutscene and +-- not just from the overworld (#563). +local function fakeGame() + local game = { input = Input, save = {}, returned = 0 } + game.stack = { + updates = 0, + update = function(self) self.updates = self.updates + 1 end, + } + function game:returnToTitle() self.returned = self.returned + 1 end + return game +end + +local game = fakeGame() +holdKeys("z", "x", "escape", "tab") +for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end +eq(game.returned, 1, "Game:step falls back to the title on the 16th step") +eq(game.stack.updates, HOLD_STEPS - 1, + "and the state on top never gets that step, whatever it was") +check(not Input:isDown("a"), + "the still-physically-held A is cleared, so the title screen does not " + .. "read it as a menu choice on its first frame") + +-- A tool mod may hand Game a stand-in input; Game guards on the method +-- existing rather than assuming it, so those simply never soft reset. +local bare = fakeGame() +bare.input = { step = function() end, isDown = function() return false end } +for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end +eq(bare.returned, 0, "an input with no chord bookkeeping never resets") + +-- ---- the on-screen overlay --------------------------------------------- + +-- Force the overlay live the way a phone would have it. img is only ever +-- tested for truthiness on the input path (drawing is what reads it), so a +-- placeholder keeps this suite off love.graphics.newImage. +Input:init() +TouchControls:init() +TouchControls.active, TouchControls.enabled = true, true +TouchControls.img = { stub = true } +local L = TouchControls:layout() + +-- One finger, one control: touchpressed returns on its first hit, so no +-- single touch can ever arm more than a quarter of the chord however the +-- layout editor has moved the controls around. +for _, btn in ipairs({ "a", "b", "start", "select" }) do + Input:init() + TouchControls:reset() + TouchControls:touchpressed(1, L[btn].cx, L[btn].cy) + local held = 0 + for _ in pairs(TouchControls.held) do held = held + 1 end + eq(held, 1, "a finger on " .. btn:upper() .. " presses that button alone") + check(not Input:softResetHeld(), "...which is not the combo") +end + +-- Four fingers on four controls is the only way there, and it still has to +-- survive the same 16 steps -- better than a quarter second of everything +-- staying put. +Input:init() +TouchControls:reset() +local ids = { a = 1, b = 2, start = 3, select = 4 } +for btn, id in pairs(ids) do + TouchControls:touchpressed(id, L[btn].cx, L[btn].cy) +end +check(Input:softResetHeld(), "four fingers on A, B, START and SELECT arm it") +for i = 1, HOLD_STEPS - 1 do + check(not stepChord(), "the overlay holds the same countdown (step " .. i .. ")") +end +check(stepChord(), "and resets on the 16th") + +-- the accidental version: one finger slips off part way through +Input:init() +TouchControls:reset() +for btn, id in pairs(ids) do + TouchControls:touchpressed(id, L[btn].cx, L[btn].cy) +end +for _ = 1, HOLD_STEPS - 1 do stepChord() end +TouchControls:touchreleased(ids.select, L.select.cx, L.select.cy) +check(not stepChord(), "a finger leaving SELECT one step short cancels") +check(not Input:softResetHeld(), "and the chord is no longer armed") + +-- the fifth finger a real two-handed grip has on the d-pad +Input:init() +TouchControls:reset() +for btn, id in pairs(ids) do + TouchControls:touchpressed(id, L[btn].cx, L[btn].cy) +end +TouchControls:touchpressed(5, L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4) +check(not Input:softResetHeld(), + "a thumb on the d-pad cancels it the same way a keyboard direction does") + +Input:init() +TouchControls:reset() +T.finish("soft_reset_bug563") diff --git a/tests/engine/trainer_loss_line_bug566.lua b/tests/engine/trainer_loss_line_bug566.lua new file mode 100644 index 00000000..ad802bd5 --- /dev/null +++ b/tests/engine/trainer_loss_line_bug566.lua @@ -0,0 +1,107 @@ +-- A beaten trainer's own line is spoken, so it carries their name tag. +-- +-- TrainerEndBattleText (home/trainers.asm:381-386) prints _TrainerNameText +-- -- wNameBuffer then ": " (data/text/text_1.asm:16-19) -- and only then +-- the saved EndBattleText pointer, so the loss line opens +-- "BUG CATCHER: ...". The port printed the pointer alone, leaving an +-- unattributed sentence on the battle screen (#566). SaveTrainerName reads +-- TrainerNamePointers, whose RIVAL1/2/3 entries aim at wTrainerName, so the +-- rival tags with his name and never with the class id. +-- +-- The tag belongs to the box, not to every page: a `para` inside the loss +-- text is a second page and prints untagged. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local BattleState = require("src.battle.BattleState") + +local function freshGame() + local save = SaveData.newGame() + save.player.name = "RED" + save.player.rival = "GARY" + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + return { data = Data, save = save, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } +end + +-- KO the whole enemy party and take the win branch of enemyMonFainted +local function beat(game, oppClass, endText) + local battle = BattleState.newTrainer(game, oppClass, 1) + battle.participants = {} + battle.playVictoryMusic = function() end + battle.endBattleText = endText + for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end + battle.enemyIndex = #battle.enemyParty + battle.enemy.mon = battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + local texts = {} + for _, row in ipairs(battle.queue) do + if row.text then texts[#texts + 1] = row.text end + end + return battle, texts +end + +-- index of the first queued message containing `needle` +local function findText(texts, needle) + for i, text in ipairs(texts) do + if text:find(needle, 1, true) then return i end + end +end + +do + local game = freshGame() + local battle, texts = beat(game, "OPP_FIX_YOUNGSTER", + "Well fixed!\fBut I will win next time.") + T.eq(battle.result, "win", "the last enemy mon fainting ends the battle") + + local loss = findText(texts, "Well fixed!") + T.check(loss ~= nil, "the trainer's loss line is queued") + T.eq(texts[loss], battle.trainer.name .. ": Well fixed!", + "the loss line opens with the trainer class and a colon") + T.eq(texts[loss + 1], "But I will win next time.", + "the `para` page is a page of the same box, so it is not tagged again") + + -- TrainerBattleVictory (core.asm:915-949) order: TrainerDefeatedText, + -- the pic scroll, PrintEndBattleText, then MoneyForWinningText + local defeated = findText(texts, "defeated") + local money = findText(texts, "for winning") + T.check(defeated and defeated < loss, "the tag follows 'RED defeated ...'") + T.check(money and money > loss + 1, "the prize line still comes last") +end + +-- RIVAL1/2/3 tag with the rival's name, not the class id: newTrainer +-- overlays wRivalName the way GetTrainerName_ does. +do + local game = freshGame() + Data.trainers.OPP_RIVAL1 = { + id = "OPP_RIVAL1", index = 2, name = "RIVAL1", baseMoney = 15, + parties = { { { level = 5, species = "FIXMON_C" } } }, + } + local battle, texts = beat(game, "OPP_RIVAL1", "You're still learning!") + T.eq(battle.trainer.name, "GARY", "the rival battles under his own name") + local loss = findText(texts, "still learning") + T.check(loss ~= nil, "the rival's loss line is queued") + T.eq(texts[loss], "GARY: You're still learning!", + "the rival tag is his name, never the RIVAL1 class id") +end + +-- A scripted battle that prints its own follow-up leaves endBattleText nil; +-- nothing may invent a tagged empty box for it. +do + local game = freshGame() + local _, texts = beat(game, "OPP_FIX_YOUNGSTER", nil) + for _, text in ipairs(texts) do + T.check(not text:match("^FIX YOUNGSTER: %s*$"), + "no empty tagged box when the trainer has no loss line") + end +end + +T.finish("trainer loss line bug566") diff --git a/tests/engine/trainer_shift_prompt_bug565.lua b/tests/engine/trainer_shift_prompt_bug565.lua new file mode 100644 index 00000000..5e38e685 --- /dev/null +++ b/tests/engine/trainer_shift_prompt_bug565.lua @@ -0,0 +1,80 @@ +-- SHIFT's "about to use" offer is one box with a CONT, not two boxes. +-- +-- _TrainerAboutToUseText (data/text/text_2.asm:911-921) is +-- " is" / line "about to use" / cont "!": the +-- cont scrolls "X is" off the top so the nick arrives UNDER the words that +-- explain it. Splitting that into two say() rows put the nick alone on a +-- fresh page, which reads as a bare Pokemon name with no sentence around +-- it (#565). +-- +-- The rendered half is checked through BattleState:startMessage, the same +-- parser the battle box types from, so this fails if \v ever stops meaning +-- ContText there. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local BattleState = require("src.battle.BattleState") + +-- SHIFT only offers the switch when the player has more than one party +-- slot and the active mon is alive (core.asm:1366-1443) +local save = SaveData.newGame() +save.player.name = "RED" +save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) } +save.options = save.options or {} +save.options.battleStyle = "shift" + +local game = { data = Data, save = save, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } + +local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) +T.check(#battle.enemyParty >= 2, "the fixture trainer has a reserve to send out") +battle.participants = {} + +-- KO the lead so enemyMonFainted queues the send-out for slot 2 +battle.enemyParty[1].hp = 0 +battle.enemy.mon = battle.enemyParty[1] +battle:enemyMonFainted() + +local texts = {} +for _, row in ipairs(battle.queue) do + if row.text then texts[#texts + 1] = row.text end +end + +local offer +for _, text in ipairs(texts) do + if text:find("about to use", 1, true) then offer = text end +end +T.check(offer ~= nil, "SHIFT queues the about-to-use offer") + +local nick = Data.pokemon[battle.enemyParty[2].species].name +T.eq(offer, battle.trainer.name .. " is\nabout to use\v" .. nick .. "!", + "one box: name, the line break, then a CONT to the nick") + +-- the regression itself: the nick must never be a message of its own +for _, text in ipairs(texts) do + T.neq(text, nick .. "!", "no bare-nickname box follows the offer") +end + +-- and what the box actually renders: three lines, the third scrolled in, +-- so the two visible rows when the nick lands are "about to use" / nick +battle:startMessage({ text = offer }) +T.eq(#battle.lines, 3, "the offer types as three lines") +T.check(not battle.lines[2].cont, "line 2 follows a plain line break") +-- guarded so a regression reports the missing line instead of dying on it +if battle.lines[3] then + T.check(battle.lines[3].cont, "line 3 is a ContText scroll, not a new page") + T.same(battle.lines[2].codes, Font.encode("about to use"), + "the line above the nick still says 'about to use'") + T.same(battle.lines[3].codes, Font.encode(nick .. "!"), + "the scrolled-in line is the nick") +end + +T.finish("trainer shift prompt bug565") diff --git a/tests/engine/turn_in_place_bug415.lua b/tests/engine/turn_in_place_bug415.lua new file mode 100644 index 00000000..0bce6917 --- /dev/null +++ b/tests/engine/turn_in_place_bug415.lua @@ -0,0 +1,156 @@ +-- A tap on the d-pad turns in place; only a held direction steps (#415). +-- home/overworld.asm .handleDirectionButtonPress reaches the turn only while +-- wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed -- the poll +-- that finds nothing held -- is the one place that sets it. So the turn +-- delay is spent once per press, not at every facing change: a direction +-- swapped mid-walk keeps stepping. Player.turnArmed is that flag and +-- Player:turnWindow is how long the fresh turn holds the step off. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Collision = require("src.world.Collision") +local FieldDefaults = require("src.world.FieldDefaults") +local Input = require("src.core.Input") +local Player = require("src.world.Player") + +Collision.load(Data) + +-- the fixture dataset ships one player sheet under its own id; Player.new +-- builds a SpriteRenderer off whatever field.playerSprites names +Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" } + +-- Collision.canMove only ever asks a map these four things, so open floor +-- is cheaper to state directly than to stand a real MapLoader up (that +-- pulls the tileset atlas in, which is the asset pipeline, not this). +local map = { + def = { tileset = "FIX_OUT" }, + inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end, + isWalkableCell = function() return true end, + isWaterCell = function() return false end, + cellTile = function() return 0 end, +} + +local function newPlayer(facing) + local p = Player.new(Data, 5, 5, facing or "down") + Input:init() + local Game = require("src.core.Game") + Game.input = Input + return p +end + +-- One fixed step of the overworld's input half: OverworldState:handleInput +-- polls the held direction and re-arms turnArmed when it finds none, then +-- Player:update ticks turnTimer down. `dir` nil is a released d-pad. +local function step(p, dir) + local result + if dir then + result = p:tryMove(dir, map, {}) + else + p.turnArmed = true + end + p:update() + return result +end + +-- Hold `dir` from a standstill until the step it starts lands, and stop on +-- the landing poll: one more held poll would chain straight into the next +-- tile, which is the walk this suite is not measuring. +local function walkOneTile(p, dir) + local fromX, fromY = p.cellX, p.cellY + for _ = 1, 120 do + step(p, dir) + if not p.moving and (p.cellX ~= fromX or p.cellY ~= fromY) then + return true + end + end + return false +end + +-- ---- the tap: one poll with the direction down, then nothing ------------- + +do + local p = newPlayer("down") + T.eq(step(p, "up"), "turned", "the first poll of a fresh press turns") + T.eq(p.facing, "up", "and the facing lands on the tapped direction") + for _ = 1, 20 do step(p, nil) end + T.eq(p.cellY, 5, "a released d-pad never commits the step") + T.eq(p.cellX, 5, "and never drifts sideways either") + T.eq(p.facing, "up", "the turn survives the release") +end + +-- ---- the hold: the same press kept down steps once the window closes ----- + +do + local window = FieldDefaults.world(Data, "turnFrames") + T.eq(window, 4, "the dataset's turn window is 4 fixed steps") + + -- one short of the window: still turning, still on the tile + local short = newPlayer("down") + T.eq(step(short, "up"), "turned", "hold: the first poll turns") + for _ = 1, window - 1 do + T.eq(step(short, "up"), nil, "a hold inside the window takes no step") + end + T.eq(short.cellY, 5, "so the tile is unchanged after " .. window .. " polls") + + -- the poll after the window closes is the one that moves + T.eq(step(short, "up"), "moved", "the poll past the window commits the step") + T.check(short.moving, "and the step animation is running") +end + +-- ---- the corner: a direction swapped without ever letting go ------------- +-- +-- This is what turnArmed buys. Walking a corner never lifts the d-pad, so +-- .noDirectionButtonsPressed never runs and the original charges no turn +-- delay for the new facing; gating on the timer alone stalled every corner. + +do + local p = newPlayer("down") + T.check(walkOneTile(p, "up"), "the held direction walks a whole tile") + T.eq(p.cellY, 4, "one tile north") + + local turned = step(p, "right") -- swap without a release in between + T.eq(p.facing, "right", "the swap changes the facing") + T.eq(turned, "moved", "and steps straight away: no second turn delay") + T.eq(p.turnTimer, 0, "the corner charges no turn window") +end + +-- ---- re-arming: the flag only comes back from a standstill --------------- + +do + local p = newPlayer("down") + T.eq(p.turnArmed, true, "a fresh player starts armed") + step(p, "left") + T.eq(p.turnArmed, false, "the turn spends the arm") + step(p, "left") + T.eq(p.turnArmed, false, "a still-held direction does not re-arm it") + step(p, nil) + T.eq(p.turnArmed, true, "the poll that finds nothing held re-arms it") +end + +-- ---- the on-screen d-pad gets the longer window ------------------------- +-- +-- A finger cannot produce a 4-frame press, so Player:turnWindow widens it +-- for the overlay only. Keyed on the live source (Input:isTouchDown), not +-- on whether the overlay is visible, so a phone with a pad plugged in keeps +-- the physical window. + +do + local p = newPlayer("down") + p.facing = "up" + T.eq(p:turnWindow(), 4, "keyboard and pad presses get the short window") + + Input:overlayPressed("up") + Input:step() + T.check(Input:isTouchDown("up"), "the overlay registers as a touch source") + T.eq(p:turnWindow(), 8, "an overlay press gets the long window") + + Input:keypressed("up") + Input:overlayReleased("up") + Input:step() + T.check(Input:isDown("up"), "the keyboard still holds the direction") + T.check(not Input:isTouchDown("up"), "but the touch source is gone") + T.eq(p:turnWindow(), 4, "so the window drops back to the short one") +end + +T.finish("turn in place") diff --git a/tests/engine/wide_battle_shake_bug562.lua b/tests/engine/wide_battle_shake_bug562.lua new file mode 100644 index 00000000..4f367a12 --- /dev/null +++ b/tests/engine/wide_battle_shake_bug562.lua @@ -0,0 +1,180 @@ +-- BATTLE LAYOUT = WIDE, screen shakes (#562). +-- +-- A battle sets rWY to 0 (engine/battle/core.asm), so the window +-- PredefShakeScreenVertically / PredefShakeScreenHorizontally / +-- AnimationShakeScreenHorizontallySlow displace IS the whole screen: pics, +-- both HUDs and the message window move together, which is what +-- BattleState:drawClassic does by offsetting its whole BG canvas. The wide +-- composition used to apply fx.shakeX/shakeY to the two pic regions only, so +-- an applying-attack shake slid the monsters across a nailed-down HUD and +-- text box -- TAIL WHIP's slow 3px creep (wAnimationType 6) being the case +-- the report named. +-- +-- Headless: WideBattle.draw is played into a recording love.graphics and the +-- Font / HudTiles entry points, so what is asserted is where each piece of +-- the composition landed, not pixels. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local WideBattle = require("src.battle.WideBattle") +local Font = require("src.render.Font") +local HudTiles = require("src.render.HudTiles") + +-- ---------------------------------------------------------------- recorder +-- The stub's push/pop keep no transform, so the translate stack is tracked +-- here: `tx, ty` is the offset in force when a draw call is issued. Font and +-- HudTiles are patched in place (WideBattle captured these very tables at +-- require time, so swapping package.loaded would not reach it). +local g = love.graphics +local realPush, realPop = g.push, g.pop +local tx, ty, stack = 0, 0, {} +local scissors, marks + +g.push = function(...) stack[#stack + 1] = { tx, ty }; realPush(...) end +g.pop = function() + local s = table.remove(stack) + if s then tx, ty = s[1], s[2] end + realPop() +end +g.translate = function(dx, dy) tx, ty = tx + (dx or 0), ty + (dy or 0) end +-- restoreScissor calls setScissor() with no arguments to clear; only a real +-- rect is a region being opened +g.setScissor = function(x, y, w, h) + if x then scissors[#scissors + 1] = { x = x, y = y, w = w, h = h } end +end + +local function mark(name) marks[#marks + 1] = { name = name, tx = tx, ty = ty } end +Font.drawBox = function() mark("box") end +Font.draw = function() mark("text") end +Font.drawCode = function() mark("code") end +Font.split = function() return {} end +Font.spansFitting = function() return math.huge end +HudTiles.tile = function() mark("tile") end +HudTiles.drawHPBar = function() mark("hpbar") end + +-- A battle stripped to what the composition reads. drawPicsLayer and +-- drawAnimLayer only record the offset they were handed, which is the whole +-- question: are the two pic regions and everything else moving as one? +local function battleWith(fx, sprites) + return { + data = {}, + frame = 0, + phase = "messages", + current = {}, + shown = {}, + fx = fx, + enemy = { name = "GENGAR", shownHP = 60, + mon = { level = 25, stats = { hp = 60 } } }, + player = { name = "NIDORINO", shownHP = 55, + mon = { level = 24, stats = { hp = 55 } } }, + animPlaying = sprites ~= nil, + animPlayer = sprites and { stepIndex = 1, steps = { { sprites = sprites } } }, + drawPicsLayer = function() mark("pics") end, + drawAnimLayer = function() mark("anim") end, + growInScale = function() return nil end, + drawBallRow = function() end, + statusLabel = function() return "" end, + } +end + +local function draw(fx, sprites) + tx, ty, stack = 0, 0, {} + scissors, marks = {}, {} + WideBattle.draw(battleWith(fx, sprites)) + return scissors, marks +end + +local function marksNamed(list, name) + local out = {} + for _, m in ipairs(list) do if m.name == name then out[#out + 1] = m end end + return out +end + +-- Every piece of the composition drawn in wide-surface coordinates sits at +-- (dx, dy). The two pic passes and the anim layer carry their own anchor on +-- top of the shake and are checked against it by hand. +local SELF_ANCHORED = { pics = true, anim = true } +local function everythingAt(list, dx, dy, label) + local seen = 0 + for _, m in ipairs(list) do + if not SELF_ANCHORED[m.name] then + seen = seen + 1 + if m.tx ~= dx or m.ty ~= dy then + T.check(false, ("%s: %s drew at (%d, %d), wanted (%d, %d)") + :format(label, m.name, m.tx, m.ty, dx, dy)) + return + end + end + end + T.check(seen >= 6, + label .. ": the HUDs, the status bars and the message box all drew") + T.check(true, ("%s: all %d pieces moved to (%d, %d) together") + :format(label, seen, dx, dy)) +end + +-- ------------------------------------------------------------ still screen +-- The baseline the shaken frames are measured against: the player's 160x144 +-- region is inset 32px from the top of the wider field, the enemy's starts at +-- x=160, and both draw their classic-space pics at the wide anchors. +local s, m = draw(nil) +T.eq(s[1].x, 0, "unshaken, the player region clips from the left edge") +T.eq(s[1].y, 32, "unshaken, the player region starts below the foe's HUD") +T.eq(s[2].x, 160, "unshaken, the enemy region clips from the halfway mark") +T.eq(s[2].y, 0, "unshaken, the enemy region starts at the top") +local pics = marksNamed(m, "pics") +T.eq(#pics, 2, "one pics pass per side") +T.eq(pics[1].tx, 20, "unshaken, the player pic sits on the player anchor") +T.eq(pics[1].ty, 8, "unshaken, the player pic sits on the player baseline") +T.eq(pics[2].tx, 136, "unshaken, the enemy pic sits on the enemy anchor") +everythingAt(m, 0, 0, "no shake") + +-- -------------------------------------------------- horizontal shake (#562) +-- TAIL WHIP is wAnimationType 6, AnimationShakeScreenHorizontallySlow b=3: +-- the screen creeps out to 3px and back, twice, in silence. Before the fix +-- the two pic regions took the 3px and drawHUDs / drawTextArea did not, so +-- the foe glided sideways over a stationary HUD and message box. +s, m = draw({ shakeX = 3, shakeY = 0 }) +T.eq(s[1].x, 3, "a horizontal shake carries the player region's clip with it") +T.eq(s[2].x, 163, "...and the enemy region's") +pics = marksNamed(m, "pics") +T.eq(pics[1].tx, 23, "the player pic rides the shake") +T.eq(pics[2].tx, 139, "the enemy pic rides the shake") +everythingAt(m, 3, 0, "3px horizontal shake") + +-- ---------------------------------------------------- vertical shake (#562) +-- wAnimationType 1 (PredefShakeScreenVertically b=8) drops the whole window. +-- The region rects move with their contents, so a pic pushed toward +-- FIELD_BOTTOM is not sheared off its own window edge on the way down. +s, m = draw({ shakeX = 0, shakeY = 8 }) +T.eq(s[1].y, 40, "a vertical shake carries the player region's clip down") +T.eq(s[1].h, WideBattle.FIELD_BOTTOM - 32, + "the player region keeps its height, so the pic is not cropped short") +T.eq(s[2].y, 8, "the enemy region's clip drops the same 8px") +pics = marksNamed(m, "pics") +T.eq(pics[1].ty, 16, "the player pic drops with it") +everythingAt(m, 0, 8, "8px vertical shake") + +-- --------------------------------------------------- the animations-off path +-- With ANIMATION = OFF an extracted anim that carries a shake flag falls back +-- to fx.shake, a +-2 alternation read off battle.frame; it moves the screen +-- through the same path. +s, m = draw({ shake = 24 }) +T.eq(s[1].x, 2, "the animations-off fallback shakes the regions too") +everythingAt(m, 2, 0, "animations-off fallback") + +-- ------------------------------------------------------- the OAM anim layer +-- Battle animation sprites are OAM, not BG, so the window shake never moves +-- them -- drawClassic draws its anim layer outside the offset for the same +-- reason. Their own offset is WideBattle.animationOffset, and that stays put +-- while the screen under them shakes. +local sprites = { { x = 104 }, { x = 144 } } +local ax, ay = WideBattle.animationOffset(sprites) +s, m = draw({ shakeX = 3, shakeY = 0 }, sprites) +local anim = marksNamed(m, "anim") +T.eq(#anim, 1, "the anim layer drew") +T.eq(anim[1].tx, ax, "the anim layer keeps its own anchor, not the shake") +T.eq(anim[1].ty, ay, "...on both axes") +T.eq(s[#s].x, 0, "the anim region spans the field from the left edge") +T.eq(s[#s].w, WideBattle.WIDTH, "...the full wide surface") + +T.finish("wide battle shake") diff --git a/tests/engine/yellow_oak_back_pikapic.lua b/tests/engine/yellow_oak_back_pikapic.lua new file mode 100644 index 00000000..ccbc416b --- /dev/null +++ b/tests/engine/yellow_oak_back_pikapic.lua @@ -0,0 +1,143 @@ +-- Yellow's two extra pic rips and the plumbing that reaches for them. +-- +-- #557: LoadPlayerBackPic (pokeyellow engine/battle/core.asm:6384-6391) +-- loads OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for +-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene, and DisplayBattleMenu +-- splits the displayed thrower name on the same wBattleType. The port +-- carries that split as makeOldManDemo's name argument. +-- +-- #561: TalkToPikachu's framed portrait draws the chosen PikaPicAnimScript's +-- own base frame (data/pikachu/pikachu_pic_animation.asm), not the battle +-- front pic that stood in for all of them. +-- +-- Both rips are gated on manifest symbols, so tools/rom_manifest_yellow.json +-- is as much a part of these fixes as the Lua is: without the symbol the +-- extractor writes nothing, the runtime existence check falls back, and the +-- screen looks exactly like the bug report. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local FieldDefaults = require("src.world.FieldDefaults") +local Sprites = require("src.pokemon.Sprites") + +local PROF_BACK = "assets/generated/battle/profoakb.png" +local OLDMAN_BACK = "assets/generated/battle/oldmanb.png" +local RED_BACK = "assets/generated/battle/redb.png" + +local function readFile(path) + local handle = io.open(path, "r") + if not handle then return nil end + local text = handle:read("*a") + handle:close() + return text +end + +-- --------------------------------------------------------------------- +-- #557: the back pic keys and the wBattleType split +-- --------------------------------------------------------------------- + +T.eq(FieldDefaults.fieldValue(nil, "playerPics", "oakBack"), PROF_BACK, + "playerPics carries a third back pic for the PROF.OAK demo") +T.eq(FieldDefaults.fieldValue(nil, "playerPics", "demoBack"), OLDMAN_BACK, + "the old man's back pic is untouched") + +-- The love stub's filesystem is a table of written files, so writing the +-- path IS "this cache was imported after the rip landed". +love.filesystem.write(PROF_BACK, "png") +T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }), + PROF_BACK, "the Pallet catch demo fights behind Oak's own back pic") +T.eq(Sprites.playerPath(nil, "back", { demo = true }), OLDMAN_BACK, + "the Route 5 catch tutorial still gets the old man") +T.eq(Sprites.playerPath(nil, "back", {}), RED_BACK, + "an ordinary battle still gets the player") + +love.filesystem.remove(PROF_BACK) +T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }), + OLDMAN_BACK, + "a cache built before the rip falls back to the old man, not a missing file") + +-- makeOldManDemo only fills fields when a player battler already exists, so +-- a stub with one stays clear of Pokemon.new and the fixture dataset. +local oak = { player = true } +BattleState.makeOldManDemo(oak, "PROF.OAK") +T.eq(oak.demoName, "PROF.OAK", "the Yellow demo names PROF.OAK as the thrower") +T.eq(oak.oakDemo, true, "and asks for his back pic") + +local oldMan = { player = true } +BattleState.makeOldManDemo(oldMan) +T.eq(oldMan.demoName, "OLD MAN", "the unnamed demo is still the old man") +T.eq(oldMan.oakDemo, false, "and does not reach for Oak's pic") + +-- --------------------------------------------------------------------- +-- the symbol tables the two rips are gated on +-- --------------------------------------------------------------------- + +local extractor = readFile("src/import/RomExtractor.lua") +T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable") +extractor = extractor or "" + +local yellowManifest = readFile("tools/rom_manifest_yellow.json") +T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable") +yellowManifest = yellowManifest or "" + +local generator = readFile("tools/make_yellow_manifest.py") +T.check(generator ~= nil, "tools/make_yellow_manifest.py is readable") +generator = generator or "" + +-- Every symbol below is real in pokeyellow.sym; what decides whether the +-- extractor can see it is whether the generator injects it. Red never +-- referenced any of them, so make_yellow_manifest's Red-name remap cannot +-- pick them up on its own -- they have to be listed in YELLOW_EXTRA_SYMBOLS. +-- One line per group rather than per label: a missing symbol list reads as +-- one fault to fix, not fifty-odd. +local function missingFrom(text, labels) + local missing = {} + for _, label in ipairs(labels) do + if not text:find('"' .. label .. '"', 1, true) then + missing[#missing + 1] = label + end + end + return missing +end + +local function requireSymbols(labels, why) + local gone = missingFrom(yellowManifest, labels) + T.check(#gone == 0, why .. ": rom_manifest_yellow.json is missing " + .. (#gone == 0 and "nothing" or table.concat(gone, " "))) + gone = missingFrom(generator, labels) + T.check(#gone == 0, why .. ": make_yellow_manifest.py never asks for " + .. (#gone == 0 and "nothing" or table.concat(gone, " ")) + .. " (add them to YELLOW_EXTRA_SYMBOLS)") +end + +T.check(extractor:find('self.symbols["ProfOakPicBack"]', 1, true) ~= nil, + "the extractor gates Oak's back pic on the ProfOakPicBack symbol") +requireSymbols({ "ProfOakPicBack" }, "#557") + +-- Read the label list out of the extractor itself rather than repeating it: +-- the contract under test is that the manifest answers whatever +-- extractField asks for, so a later edit to PIKAPIC_BASE stays covered. +local pikapicBlock = extractor:match("local PIKAPIC_BASE = {(.-)\n }") +T.check(pikapicBlock ~= nil, "RomExtractor's PIKAPIC_BASE table is readable") +local pikapic, seen = {}, {} +for label in (pikapicBlock or ""):gmatch('"([%w_]+)"') do + if not seen[label] then + seen[label] = true + pikapic[#pikapic + 1] = label + end +end +-- 28 PikaPicAnimScripts, script 26 sharing script 11's base pic +T.eq(#pikapic, 27, "PIKAPIC_BASE names 27 distinct base pics") +requireSymbols(pikapic, "#561") + +-- Red and Blue have neither pic, so neither manifest may grow one: a stray +-- entry there would rip Yellow art out of the wrong ROM. +for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do + local text = readFile(path) + T.check(text ~= nil, path .. " is readable") + T.check(text == nil or text:find("ProfOakPicBack", 1, true) == nil, + path .. " has no ProfOakPicBack, matching pokered") +end + +T.finish("yellow oak back pic and pikapic bases") diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 66d5ca7c..6d8063a8 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -291,7 +291,7 @@ do check(data.field.palettes.byMap.PALLET_TOWN == "PALLET", "seed fills the missing siblings of a stamped record") check(data.constants.world.stepFrames == 4, "seed leaves a stamped constant") - check(data.constants.world.turnFrames == 2, "seed fills the missing siblings") + check(data.constants.world.turnFrames == 4, "seed fills the missing siblings") check(data.field.fishing.OLD_ROD.always.species == "MAGIKARP", "seed installs the whole missing key") local before = data.field.fishing diff --git a/tests/parity_battle_intro_chrome.lua b/tests/parity_battle_intro_chrome.lua index 368c357b..af1b12f8 100644 --- a/tests/parity_battle_intro_chrome.lua +++ b/tests/parity_battle_intro_chrome.lua @@ -98,8 +98,9 @@ check(wild.queue[1] and wild.queue[1].fn ~= nil, check(wild.queue[2] and wild.queue[2].text == wild.introText, "the intro text is still the second queue row") --- let the silhouette slide land so the intro box is genuinely up -for _ = 1, 45 do wild:update(1 / 60) end +-- let the silhouette slide land so the intro box is genuinely up (the slide +-- now runs 80 frames at 2px/frame, matching the original ~2px/frame) +for _ = 1, 85 do wild:update(1 / 60) end eq(wild.introSlide or 0, 0, "the silhouette slide has landed") eq(wild.introBalls, true, "the window is still open under the intro text") eq(currentText(wild), wild.introText, "the intro box is the row on screen") @@ -164,7 +165,7 @@ local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1) tr.onFinish = function() end tr:enter() eq(tr.introBalls, true, "the trainer intro opens the same window") -for _ = 1, 45 do tr:update(1 / 60) end +for _ = 1, 85 do tr:update(1 / 60) end local ok3, err3, rows3 = snapshotHUD(tr) check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3)) diff --git a/tests/parity_bench_guy_facing_bug488.lua b/tests/parity_bench_guy_facing_bug488.lua new file mode 100644 index 00000000..0de5d1ad --- /dev/null +++ b/tests/parity_bench_guy_facing_bug488.lua @@ -0,0 +1,154 @@ +-- Parity (#488): the Vermilion, Saffron and Fuchsia Pokemon Center bench +-- guys answer an A press. +-- +-- Oracle: engine/events/hidden_events/bench_guys.asm PrintBenchGuyText +-- compares wSpritePlayerStateData1FacingDirection against the facing byte +-- in BenchGuyTextPointers, which is SPRITE_FACING_LEFT for all twelve +-- seats (data/events/bench_guys.asm). The SPRITE_FACING_* value in a map's +-- hidden_event row is wHiddenEventFunctionArgument instead, and pokered's +-- own macro comment says those "do not actually prevent the player from +-- interacting with them in any direction" (data/events/hidden_events.asm). +-- Four maps park SPRITE_FACING_UP there. Gating the seat on that byte +-- rather than on the pointer table's facing asked for a facing that cannot +-- reach the bench at all: (0,4) is a wall cell whose only walkable +-- neighbour is (1,4) to the east, so those four guys were mute. +-- +-- The manifest has carried both bytes as `facing` (hidden_event) and +-- `textFacing` (BenchGuyTextPointers) all along; this pins which one the +-- interact path is allowed to read. +-- +-- Self-contained: `luajit tests/parity_bench_guy_facing_bug488.lua`; also +-- globbed by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity bench guy facing (#488)") +local check, eq = S.check, S.eq + +local MapLoader = require("src.world.MapLoader") +local OverworldState = require("src.world.OverworldController") +local SaveData = require("src.core.SaveData") +local TextBox = require("src.render.TextBox") +require("src.render.Font").load(Data) + +-- tryHiddenObject reads the `Game` upvalue OverworldState:enter() normally +-- sets during a real boot; rewire it at the one closure under test, the +-- way tests/parity_D.lua does for the Safari counters. +local pushed +local fakeGame = { + data = Data, + save = SaveData.newGame(), + stack = { push = function(_, state) pushed = state end }, +} +local function bindGame(fn, game) + local i = 1 + while true do + local name = debug.getupvalue(fn, i) + if not name then return false end + if name == "Game" then debug.setupvalue(fn, i, game); return true end + i = i + 1 + end +end +check(bindGame(OverworldState.tryHiddenObject, fakeGame), + "tryHiddenObject binds the Game upvalue") + +local benchGuys = Data.field.hiddenExtras.benchGuys + +-- what the box that opens actually reads, so a seat that opens the wrong +-- guy's line still fails +local function boxText(box) + local lines = {} + for _, page in ipairs(box and box.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return table.concat(lines, " ") +end + +local function pressA(mapId, x, y, facing) + pushed = nil + local self_ = setmetatable({ + map = { id = mapId }, + player = { facing = facing }, + }, { __index = OverworldState }) + local handled = self_:tryHiddenObject(x, y) + return handled, pushed +end + +-- The four seats whose hidden_event byte is SPRITE_FACING_UP -- the ones +-- that went silent -- plus Pewter as the control: it stores +-- SPRITE_FACING_LEFT in both places, so it talked either way and proves +-- the harness itself reaches the bench-guy branch. +local SEATS = { + { "VERMILION_POKECENTER", "up", "universally" }, + { "SAFFRON_POKECENTER", "up", "TEAM ROCKET" }, + { "FUCHSIA_POKECENTER", "up", "SAFARI ZONE" }, + { "CINNABAR_POKECENTER", "up", "canceling" }, + { "PEWTER_POKECENTER", "left", "JIGGLYPUFF" }, +} + +for _, seat in ipairs(SEATS) do + local mapId, hiddenByte, phrase = seat[1], seat[2], seat[3] + local h = benchGuys[mapId] and benchGuys[mapId][1] + check(h ~= nil, mapId .. " has a bench guy entry") + if h then + eq(h.facing, hiddenByte, mapId .. " hidden_event byte is " .. hiddenByte) + eq(h.textFacing, "left", + mapId .. " BenchGuyTextPointers facing is SPRITE_FACING_LEFT") + + -- the bug: facing the seat from the only cell you can stand on + local handled, box = pressA(mapId, h.x, h.y, "left") + check(handled, mapId .. " bench guy answers A from the east (#488)") + check(getmetatable(box) == TextBox, mapId .. " bench guy opens a text box") + check(boxText(box):find(phrase, 1, true) ~= nil, + ("%s bench guy says his own line (%s)"):format(mapId, phrase)) + + -- PrintBenchGuyText's facing test: every other direction falls through + for _, wrong in ipairs({ "up", "down", "right" }) do + local ok = pressA(mapId, h.x, h.y, wrong) + check(not ok, + ("%s bench guy stays quiet when faced %s"):format(mapId, wrong)) + end + end +end + +-- Every labelled seat, not just the five above: reachable from the east, +-- silent from anywhere else. A new center inherits the invariant. +local labelled = 0 +for mapId, list in pairs(benchGuys) do + for _, h in ipairs(list) do + if h.text then + labelled = labelled + 1 + eq(h.textFacing, "left", mapId .. " seat is faced from the left") + check((pressA(mapId, h.x, h.y, "left")), + mapId .. " seat answers A from the east") + check(not (pressA(mapId, h.x, h.y, "up")), + mapId .. " seat ignores an A press from below") + end + end +end +eq(labelled, 12, "all twelve BenchGuyTextPointers seats are covered") + +-- The geometry behind the symptom: the seat cell is the bench wall, the +-- floor tile east of it is where the player stands, and nothing is ever +-- walkable to the south or west. A player who walks up from (0,3) and +-- faces down gets nothing here, exactly as in the original -- the seat is +-- keyed to one facing, not to whichever side you happen to arrive from. +for mapId, list in pairs(benchGuys) do + for _, h in ipairs(list) do + if h.text then + local map = MapLoader.load(Data, mapId) + check(not map:isWalkableCell(h.x, h.y), + mapId .. " seat cell (0,4) is the bench itself, not floor") + check(map:isWalkableCell(h.x + 1, h.y), + mapId .. " the cell east of the seat is where the player stands") + check(not map:isWalkableCell(h.x, h.y + 1), + mapId .. " nothing stands south of the seat") + check(not map:isWalkableCell(h.x - 1, h.y), + mapId .. " nothing stands west of the seat") + end + end +end + +S.finish() diff --git a/tests/parity_bike_shop_clerk_bug568.lua b/tests/parity_bike_shop_clerk_bug568.lua new file mode 100644 index 00000000..07c0e69e --- /dev/null +++ b/tests/parity_bike_shop_clerk_bug568.lua @@ -0,0 +1,253 @@ +-- Parity (#568): the bike shop clerk runs the original sale, price window +-- and all. +-- +-- Oracle: scripts/BikeShop.asm BikeShopClerkText. Three ways in -- +-- EVENT_GOT_BICYCLE, a BIKE VOUCHER in the bag, or the sales pitch -- and +-- the pitch is a menu, not a line: TextBoxBorder hlcoord 0,0 b=4 c=15 +-- holding BikeShopMenuText / BikeShopMenuPrice, PrintText +-- BikeShopClerkDoYouLikeItText, HandleMenuInput over two rows, then +-- BikeShopCantAffordText for BICYCLE and BikeShopComeAgainText for either +-- answer. Nothing erases that window before TextScriptEnd, so it stays up +-- underneath both closing lines. +-- +-- The voucher path's atomicity is pokered's `jr nc, .BagFull`: the voucher +-- is only spent once GiveItem actually took the BICYCLE. +-- +-- Drives the real StateStack and Input; the window's pixels are the +-- driver's half (tests/drivers/bike_shop_bug568_test.lua). +-- +-- Self-contained: `luajit tests/parity_bike_shop_clerk_bug568.lua`; also +-- globbed by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity bike shop clerk (#568)") +local check, eq = S.check, S.eq + +local Bag = require("src.inventory.Bag") +local Flags = require("src.script.Flags") +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local TextBox = require("src.render.TextBox") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +require("src.render.Font").load(Data) + +local clerk = mapScripts.talkScript("BIKE_SHOP", "TEXT_BIKESHOP_CLERK") +check(type(clerk) == "function", + "the clerk is a hand-ported talk handler, not extracted flavor text") + +-- what each box was built with, in order +local boxes = {} +local realNew = TextBox.new +TextBox.new = function(game, text, onDone, opts) + boxes[#boxes + 1] = text + return realNew(game, text, onDone, opts) +end + +local done +local function begin(save) + Game.save = save + StateStack:init() + boxes, done = {}, false + clerk(Game, { map = { id = "BIKE_SHOP", def = { label = "BikeShop" } }, + npcs = {}, entities = {} }, + { def = {}, facePlayer = function() end }, + function() done = true end) +end + +local function step(frames, btn) + for _ = 1, frames do + Input.pressed = btn and { [btn] = true } or {} + StateStack:update(1 / 60) + Input.pressed = {} + end +end + +-- mash A until the stack drains (the whole conversation) or we give up +local function mash(limit) + for _ = 1, limit or 2000 do + if not StateStack:top() then return true end + step(1, "a") + end + return false +end + +-- the price window is the one state on this stack that is not a text box +local function windowState() + for _, state in ipairs(StateStack.states) do + if getmetatable(state) ~= TextBox then return state end + end +end + +local function inStack(state) + for _, s in ipairs(StateStack.states) do + if s == state then return true end + end + return false +end + +local t = Data.text + +-- ------------------------------------------------- you already have one +do + local save = SaveData.newGame() + save.inventory.BICYCLE = 1 + Flags.set(save, "EVENT_GOT_BICYCLE") + begin(save) + check(mash(), "the how-do-you-like-it talk ends") + eq(#boxes, 1, "one box, no menu") + eq(boxes[1], t._BikeShopClerkHowDoYouLikeYourBicycleText, + "he asks how you like your new BICYCLE") + check(done, "and hands input back") +end + +-- a save from before the clerk set the event still owns the bike +do + local save = SaveData.newGame() + save.inventory.BICYCLE = 1 + begin(save) + check(mash(), "the older-save talk ends") + eq(boxes[1], t._BikeShopClerkHowDoYouLikeYourBicycleText, + "the bag alone is enough to skip the sale") +end + +-- ------------------------------------------------------- the exchange +do + local save = SaveData.newGame() + save.inventory.BIKE_VOUCHER = 1 + begin(save) + check(mash(), "the voucher exchange runs to the end") + eq(#boxes, 2, "two boxes: oh-that's-a-voucher, then the exchange") + eq(boxes[1], t._BikeShopClerkOhThatsAVoucherText, "he spots the voucher") + eq(boxes[2], t._BikeShopExchangedVoucherText, "and makes the trade") + eq(save.inventory.BICYCLE, 1, "the BICYCLE is in the bag") + check(not save.inventory.BIKE_VOUCHER, "the BIKE VOUCHER is spent") + check(Flags.get(save, "EVENT_GOT_BICYCLE"), "EVENT_GOT_BICYCLE is set") + check(done, "and the talk hands input back") +end + +-- .BagFull: GiveItem refused, so nothing is spent (jr nc, .BagFull) +do + local save = SaveData.newGame() + save.inventory.BIKE_VOUCHER = 1 + local filler = 0 + for id in pairs(Data.items) do + if id ~= "BICYCLE" and id ~= "BIKE_VOUCHER" and Bag.add(save, id, 1) then + filler = filler + 1 + end + end + check(filler > 0 and not Bag.add(save, "BICYCLE", 1), "the bag is full") + begin(save) + check(mash(), "the full-bag talk ends") + eq(boxes[2], t._BikeShopBagFullText, "he tells you to make room") + check(not save.inventory.BICYCLE, "no BICYCLE while there is no room") + eq(save.inventory.BIKE_VOUCHER, 1, "and the voucher is not burned") + check(not Flags.get(save, "EVENT_GOT_BICYCLE"), "nor the event set") +end + +-- --------------------------------------------------------- the sale +-- Welcome, the pitch, the BICYCLE/CANCEL window, can't-afford, come-again. +do + local save = SaveData.newGame() + local money = save.money + begin(save) + + -- the welcome line waits for a button like any other box + eq(boxes[1], t._BikeShopClerkWelcomeText, "he welcomes you to the shop") + eq(#StateStack.states, 1, "and nothing else is up yet") + for _ = 1, 400 do + if #StateStack.states > 1 then break end + step(1, "a") + end + eq(boxes[2], t._BikeShopClerkDoYouLikeItText, "then the pitch") + + -- the pitch box types out and pops itself (PrintText straight into + -- HandleMenuInput), leaving the window holding the screen + local window + for _ = 1, 400 do + window = windowState() + if window and StateStack:top() == window then break end + step(1) + end + check(window ~= nil, "a price window is pushed under the pitch (#568)") + check(StateStack:top() == window, "and it takes over when the pitch pops") + eq(#StateStack.states, 1, "the pitch box is gone, the window is not") + check(type(window.footer) == "table" and #window.footer > 0, + "the window keeps the pitch line in the bottom box, so it never blanks") + eq(window.index, 1, "the cursor starts on BICYCLE (wCurrentMenuItem 0)") + + -- HandleMenuInput with wMenuWrappingEnabled clear: two rows, no wrap + step(1, "up") + eq(window.index, 1, "up on the first row stays put") + step(1, "down") + eq(window.index, 2, "down moves to CANCEL") + step(1, "down") + eq(window.index, 2, "down again stays on CANCEL, it does not wrap") + step(1, "up") + eq(window.index, 1, "and up comes back to BICYCLE") + + -- buy it: ¥1000000 is out of reach, then he sees you out + step(1, "a") + eq(boxes[3], t._BikeShopCantAffordText, "BICYCLE answers with can't-afford") + check(inStack(window), "the window is still up behind that line") + for _ = 1, 400 do + if boxes[4] then break end + step(1, "a") + end + eq(boxes[4], t._BikeShopComeAgainText, "then come back again some time") + check(inStack(window), "and the window is still up behind that one too") + + check(mash(), "the sale ends") + eq(#boxes, 4, "four text boxes plus the window: the original's five") + check(not inStack(window), "the window comes down with the last line") + eq(#StateStack.states, 0, "the stack is back to the overworld") + check(done, "and the talk hands input back") + eq(save.money, money, "browsing costs nothing") + check(not save.inventory.BICYCLE, "and sells nothing") + check(not Flags.get(save, "EVENT_GOT_BICYCLE"), "the event stays clear") +end + +-- CANCEL skips the can't-afford line (.cancel jumps straight to come-again) +do + begin(SaveData.newGame()) + local window + for _ = 1, 800 do + window = windowState() + if window and StateStack:top() == window then break end + step(1, "a") + end + check(window ~= nil, "the window comes up on the cancel run too") + step(1, "down") + step(1, "a") + eq(boxes[3], t._BikeShopComeAgainText, + "CANCEL goes straight to come-again, no can't-afford") + check(mash(), "the cancel run ends") + eq(#boxes, 3, "three boxes on the cancel path") +end + +-- B is the same .cancel branch (bit B_PAD_B, a) +do + begin(SaveData.newGame()) + local window + for _ = 1, 800 do + window = windowState() + if window and StateStack:top() == window then break end + step(1, "a") + end + check(window ~= nil, "the window comes up on the B run too") + step(1, "b") + eq(boxes[3], t._BikeShopComeAgainText, "B backs out the same way CANCEL does") + check(mash(), "the B run ends") + eq(#boxes, 3, "three boxes when you back out") +end + +TextBox.new = realNew +S.finish() diff --git a/tests/parity_bike_shop_youngster_bug567.lua b/tests/parity_bike_shop_youngster_bug567.lua new file mode 100644 index 00000000..a33abb83 --- /dev/null +++ b/tests/parity_bike_shop_youngster_bug567.lua @@ -0,0 +1,122 @@ +-- Parity (#567): the bike shop boy's line depends on whether the voucher +-- has been redeemed. +-- +-- Oracle: scripts/BikeShop.asm BikeShopYoungsterText -- CheckEvent +-- EVENT_GOT_BICYCLE, jr nz .gotBike. Before the exchange he grumbles that +-- BIKEs are way expensive; after it he admires yours. The port reads the +-- bag instead of the event, because the BICYCLE is a key item and cannot +-- be tossed, and because saves written before the clerk started setting +-- the event still have the bike. +-- +-- The near miss this pins is the one #535 hit: a row-list branch whose +-- jump target is off by one runs off the end of the list and prints +-- nothing at all, which looks exactly like the NPC having no script. +-- +-- Self-contained: `luajit tests/parity_bike_shop_youngster_bug567.lua`; +-- also globbed by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity bike shop youngster (#567)") +local check, eq = S.check, S.eq + +local Commands = require("src.script.Commands") +local Flags = require("src.script.Flags") +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local StateStack = require("src.core.StateStack") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +require("src.render.Font").load(Data) + +local script = mapScripts.talkScript("BIKE_SHOP", "TEXT_BIKESHOP_YOUNGSTER") +check(type(script) == "table", "the boy is a row-list talk script") + +-- an unreachable or out-of-range jump is a silent NPC, so validate first +do + local problems = ScriptRunner.validate(script) + eq(#problems, 0, + "the row list validates (" .. (problems[1] or "no findings") .. ")") +end + +local shown = {} +local origShow = Commands.show_text +Commands.show_text = function(ctx, textId, subs) + shown[#shown + 1] = textId + return origShow(ctx, textId, subs) +end + +local function talk() + shown = {} + StateStack:init() + local ow = { map = { id = "BIKE_SHOP", def = { label = "BikeShop" } }, + npcs = {}, entities = {} } + local runner = ScriptRunner.new(Game, ow) + runner:run(script, { npc = { def = {}, facePlayer = function() end }, + overworld = ow }) + local guard = 0 + while runner:isRunning() and guard < 3000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + runner:update() + end + Input.pressed = {} + return not runner:isRunning() +end + +local EXPENSIVE = "_BikeShopYoungsterTheseBikesAreExpensiveText" +local COOL = "_BikeShopYoungsterCoolBikeText" + +check(Data.text[EXPENSIVE] and Data.text[EXPENSIVE]:find("expensive", 1, true), + "the pre-bike line is the way-expensive one") +check(Data.text[COOL] and Data.text[COOL]:find("cool", 1, true), + "the post-bike line admires your BIKE") + +-- ------------------------------------------------- before the exchange +do + Game.save = SaveData.newGame() + check(talk(), "the empty-handed talk completes") + eq(table.concat(shown, ","), EXPENSIVE, + "with no BICYCLE he complains about the price, once") +end + +-- holding the voucher is still "before": the exchange has not happened +do + Game.save = SaveData.newGame() + Game.save.inventory.BIKE_VOUCHER = 1 + check(talk(), "the voucher-in-hand talk completes") + eq(table.concat(shown, ","), EXPENSIVE, + "a voucher is not a BICYCLE, so he still says they are expensive (#567)") +end + +-- ------------------------------------------------- after the exchange +do + Game.save = SaveData.newGame() + Game.save.inventory.BICYCLE = 1 + Flags.set(Game.save, "EVENT_GOT_BICYCLE") + check(talk(), "the post-exchange talk completes") + eq(table.concat(shown, ","), COOL, + "with the BICYCLE he admires it, once, and nothing follows (#567)") + check(talk() and table.concat(shown, ",") == COOL, + "and he keeps saying it on every later visit") +end + +-- a save written before the clerk set EVENT_GOT_BICYCLE still has the bike +do + Game.save = SaveData.newGame() + Game.save.inventory.BICYCLE = 1 + check(talk(), "the old-save talk completes") + eq(table.concat(shown, ","), COOL, + "the bag decides, so an older save reads right too") +end + +Commands.show_text = origShow +S.finish() diff --git a/tests/parity_cerulean_badge_house.lua b/tests/parity_cerulean_badge_house.lua index b2d5827c..66148fa8 100644 --- a/tests/parity_cerulean_badge_house.lua +++ b/tests/parity_cerulean_badge_house.lua @@ -60,4 +60,30 @@ dismissText() eq(#states, 0, "backing out after viewing a badge leaves no menu behind") eq(done, true, "backing out after viewing a badge completes the NPC script") +-- PrintListMenuEntries writes ListMenuCancelText where the item list's $FF +-- terminator lands (home/list_menu.asm), so a DisplayListMenuID list always +-- offers CANCEL on screen; choosing it is `cp c / jp c, ExitListMenu`, which +-- sets carry, so the badge script's `jr c, .done` treats it exactly like B +-- (pokered scripts/CeruleanBadgeHouse.asm). The row was missing (#569). +local check = S.check +local cancelled = false +script(game, nil, nil, function() cancelled = true end) +dismissText() +dismissText() + +local menu = game.stack:top() +eq(#menu.items, 9, "eight badges plus the CANCEL row") +eq(menu.items[#menu.items].label, "CANCEL", "CANCEL is the last row") +check(menu.items[#menu.items].value == nil, + "the CANCEL row carries no badge, so it cannot print a description") +for i = 1, 8 do + check(menu.items[i].value ~= nil, "badge row " .. i .. " still picks a badge") +end + +menu.onChoose(menu.items[#menu.items]) +eq(#states, 1, "choosing CANCEL closes the list and leaves the goodbye line") +dismissText() +eq(#states, 0, "choosing CANCEL leaves no menu behind") +eq(cancelled, true, "choosing CANCEL takes the same .done exit as B") + S.finish() diff --git a/tests/parity_game_corner_clerk_bug552.lua b/tests/parity_game_corner_clerk_bug552.lua new file mode 100644 index 00000000..adbb45c7 --- /dev/null +++ b/tests/parity_game_corner_clerk_bug552.lua @@ -0,0 +1,183 @@ +-- Parity (#552): the Game Corner coin clerk asks before he sells. +-- +-- Oracle: scripts/GameCorner.asm GameCornerClerk1Text -- the offer, a +-- YesNoChoice, then ¥1000 off the wallet for 50 coins, with the COIN CASE +-- and Has9990Coins gates in between. Yellow spells the same object and +-- the same six text labels without the "1" (GameCornerClerkText, +-- pokeyellow/scripts/GameCorner.asm), so the Red/Blue text id never +-- matched there: the clerk fell through to his extracted offer line with +-- no prompt behind it, and no coins ever changed hands. +-- +-- Drives the real StateStack and Input, so the YES/NO box under test is +-- the one the player sees rather than a stand-in. +-- +-- Self-contained: `luajit tests/parity_game_corner_clerk_bug552.lua`; also +-- globbed by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity game corner clerk (#552)") +local check, eq = S.check, S.eq + +local ChoiceBox = require("src.ui.ChoiceBox") +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local TextBox = require("src.render.TextBox") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +require("src.render.Font").load(Data) + +local red = mapScripts.talkScript("GAME_CORNER", "TEXT_GAMECORNER_CLERK1") +local yellow = mapScripts.talkScript("GAME_CORNER", "TEXT_GAMECORNER_CLERK") +check(type(red) == "function", "the Red/Blue clerk has a hand-ported handler") +check(yellow == red, + "Yellow's TEXT_GAMECORNER_CLERK resolves to the same handler (#552)") + +-- record what each box was built with; the handler resolves its labels at +-- call time, so wrapping .new sees exactly the string that reached the box +local shown = {} +local realNew = TextBox.new +TextBox.new = function(game, text, onDone, opts) + shown[#shown + 1] = text + return realNew(game, text, onDone, opts) +end + +local sawChoice = false + +-- One conversation, start to finish. `answer` is which row of the YES/NO +-- box to take; the loop mashes A the way a player does and nudges the +-- cursor down once when the choice box comes up. +local function talk(save, answer) + Game.save = save + StateStack:init() + shown, sawChoice = {}, false + local done = false + local ow = { map = { id = "GAME_CORNER", def = Data.maps.GAME_CORNER }, + npcs = {}, entities = {} } + yellow(Game, ow, { def = {} }, function() done = true end) + + local moved = false + for _ = 1, 2000 do + local top = StateStack:top() + if not top then break end + if getmetatable(top) == ChoiceBox then + sawChoice = true + if answer == "no" and not moved then + moved = true + Input.pressed = { down = true } + else + Input.pressed = { a = true } + end + else + Input.pressed = { a = true } + end + StateStack:update(1 / 60) + end + Input.pressed = {} + return done +end + +local function newSave(money, coins, caseToo) + local save = SaveData.newGame() + save.money = money + save.coins = coins + if caseToo ~= false then save.inventory.COIN_CASE = 1 end + return save +end + +local function said(fragment) + for _, text in ipairs(shown) do + if type(text) == "string" and text:find(fragment, 1, true) then return true end + end + return false +end + +-- ------------------------------------------------------- the sale itself +do + local save = newSave(1000, 0) + check(talk(save, "yes"), "the buy conversation runs to the end") + check(sawChoice, "the clerk opens a real YES/NO box, not just a line (#552)") + eq(#shown, 2, "two boxes: the offer and the receipt") + check(said("¥1000 for 50"), "the offer quotes ¥1000 for 50 coins") + eq(save.money, 0, "¥1000 leaves the wallet") + eq(save.coins, 50, "50 coins land in the case") + check(said("COINS: 50"), "the receipt prints the new coin total") +end + +-- ---------------------------------------------------------------- saying no +do + local save = newSave(1000, 0) + check(talk(save, "no"), "declining runs to the end") + check(sawChoice, "NO is a choice the player actually makes") + check(said("play sometime"), "he sees you out with the come-play-sometime line") + eq(save.money, 1000, "declining costs nothing") + eq(save.coins, 0, "and buys nothing") +end + +-- ------------------------------------------------------------- the gates +do + local save = newSave(999, 0) + talk(save, "yes") + check(said("afford"), "under ¥1000 he says you can't afford it") + eq(save.money, 999, "and takes nothing") + eq(save.coins, 0, "and gives nothing") +end + +do + local save = newSave(1000, 9990) + talk(save, "yes") + check(said("full"), "a case at 9990 coins is full (Has9990Coins)") + eq(save.money, 1000, "a full case costs nothing") + eq(save.coins, 9990, "and adds nothing") +end + +do + local save = newSave(1000, 0, false) + talk(save, "yes") + check(said("COIN CASE"), "no COIN CASE, no sale") + eq(save.money, 1000, "and no money changes hands") +end + +-- ------------------------------------------------- Yellow's label spelling +-- Yellow's six labels drop the "1" and the port falls back to them, so a +-- Yellow cache reaches the same prompt. Swap the cache's strings to the +-- Yellow spelling to prove the fallback is the one doing the work. +do + local SUFFIXES = { + "DoYouNeedSomeGameCoinsText", "ThanksHereAre50CoinsText", + "PleaseComePlaySometimeText", "CantAffordTheCoinsText", + "CoinCaseIsFullText", "DontHaveCoinCaseText", + } + local saved = {} + for _, suffix in ipairs(SUFFIXES) do + saved[suffix] = Data.text["_GameCornerClerk1" .. suffix] + Data.text["_GameCornerClerk" .. suffix] = + "YELLOW " .. suffix .. "\n¥1000 for 50." + Data.text["_GameCornerClerk1" .. suffix] = nil + end + + local save = newSave(1000, 0) + check(talk(save, "yes"), "the Yellow-spelled clerk runs to the end") + check(sawChoice, "and still opens the YES/NO box") + check(said("YELLOW DoYouNeedSomeGameCoinsText"), + "the offer comes from _GameCornerClerkDoYouNeedSomeGameCoinsText") + check(said("YELLOW ThanksHereAre50CoinsText"), + "so does the receipt") + eq(save.money, 0, "Yellow's clerk takes the ¥1000") + eq(save.coins, 50, "and hands over the 50 coins") + + for _, suffix in ipairs(SUFFIXES) do + Data.text["_GameCornerClerk1" .. suffix] = saved[suffix] + Data.text["_GameCornerClerk" .. suffix] = nil + end +end + +TextBox.new = realNew +S.finish() diff --git a/tests/parity_lift_key_yellow_bug552.lua b/tests/parity_lift_key_yellow_bug552.lua new file mode 100644 index 00000000..1fd59398 --- /dev/null +++ b/tests/parity_lift_key_yellow_bug552.lua @@ -0,0 +1,187 @@ +-- Parity (#552, a regression of #105): the Rocket Hideout B4F grunt drops +-- the LIFT KEY on Yellow too, and the ball he drops reaches the bag. +-- +-- Oracle: pokeyellow/scripts/RocketHideoutB4F.asm. Yellow replaces +-- Rocket1/Rocket2 with Jessie & James and leaves one grunt, spelled +-- ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET where Red and +-- Blue say ROCKET3, so the hand-ported Red/Blue key never matched and the +-- talk fell through to the generic trainer path: battle, no drop, no key, +-- and the B1F elevator stays locked for good. Yellow also moves the drop +-- off the follow-up talk -- RocketHideoutB4FRocketEndBattleText is +-- text_promptbutton + text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY +-- and ShowObject TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the moment the +-- battle ends, not on the next conversation. +-- +-- The Red/Blue half stays in tests/parity_lift_key.lua; this one owns the +-- Yellow spelling and the pickup that #105 was originally about. +-- +-- Self-contained: `luajit tests/parity_lift_key_yellow_bug552.lua`; also +-- globbed by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity Yellow lift key (#552)") +local check, eq = S.check, S.eq + +local mapScripts = require("data.scripts.init") +local OverworldState = require("src.world.OverworldController") +local SaveData = require("src.core.SaveData") +local TextBox = require("src.render.TextBox") +require("src.render.Font").load(Data) + +local MAP = "ROCKET_HIDEOUT_B4F" +local BALL = "ROCKETHIDEOUTB4F_LIFT_KEY" + +local ballObj +for _, o in ipairs(Data.maps[MAP].objects) do + if o.name == BALL then ballObj = o end +end +check(ballObj ~= nil, "the LIFT KEY ball is an object_event on B4F") +check(ballObj and ballObj.hidden == true, "it starts hidden") +eq(ballObj and ballObj.item, "LIFT_KEY", "and it carries the LIFT KEY") + +-- Yellow's object_event names the same grunt trainer party the Red/Blue +-- Rocket3 does (OPP_ROCKET, 18), so only the text id differs +local script = mapScripts.talkScript(MAP, "TEXT_ROCKETHIDEOUTB4F_ROCKET") +check(type(script) == "function", + "TEXT_ROCKETHIDEOUTB4F_ROCKET resolves to a hand-ported handler (#552)") +check(type(mapScripts.talkScript(MAP, "TEXT_ROCKETHIDEOUTB4F_ROCKET3")) == "function", + "the Red/Blue ROCKET3 handler is still registered alongside it") + +-- ------------------------------------------------------------------ setup +local function newWorld(defeated) + local save = SaveData.newGame() + save.objectToggles = {} + save.itemsTaken = {} + save.defeatedTrainers = {} + local pushed = {} + local game = { + data = Data, + save = save, + stack = { push = function(_, state) pushed[#pushed + 1] = state end }, + } + local npc = { + id = MAP .. "_obj_4", + def = { name = "ROCKETHIDEOUTB4F_ROCKET", index = 4, + text = "TEXT_ROCKETHIDEOUTB4F_ROCKET", + trainerClass = "OPP_ROCKET", trainerParty = 18 }, + } + if defeated then save.defeatedTrainers[npc.id] = true end + local ow = { + map = { id = MAP, def = Data.maps[MAP] }, + npcs = {}, entities = {}, + engagements = 0, + trainerDefeated = function(_, who) + return save.defeatedTrainers[who.id] == true + end, + } + -- engageTrainer writes the win before it runs its callback, which is + -- where the end-battle text's SetEvent + ShowObject belong + ow.engageTrainer = function(self, who, after) + self.engagements = self.engagements + 1 + save.defeatedTrainers[who.id] = true + if after then after() end + end + return game, ow, npc, save, pushed +end + +-- ------------------------------------------- winning the battle drops it +do + local game, ow, npc, save, pushed = newWorld(false) + local done = false + check(not OverworldState.objectVisible(save, MAP, ballObj), + "the ball is not on the floor before the fight") + script(game, ow, npc, function() done = true end) + + eq(ow.engagements, 1, "an undefeated grunt still starts his battle") + check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true, + "EVENT_ROCKET_DROPPED_LIFT_KEY is set the moment the battle ends") + eq(save.objectToggles[MAP] and save.objectToggles[MAP][BALL], true, + "ShowObject reveals ROCKETHIDEOUTB4F_LIFT_KEY (TOGGLE_..._ITEM_5)") + check(OverworldState.objectVisible(save, MAP, ballObj), + "the LIFT KEY ball is on the floor without a second conversation") + check(done, "the talk finishes and hands input back") + eq(#pushed, 0, "the end-battle text is the battle's, not a second box") +end + +-- --------------------------------------------- losing it drops nothing +do + local game, ow, npc, save = newWorld(false) + ow.engageTrainer = function(self, _, after) + self.engagements = self.engagements + 1 + if after then after() end -- blacked out: no win recorded + end + script(game, ow, npc, function() end) + check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY, + "a lost battle drops no key") + check(not OverworldState.objectVisible(save, MAP, ballObj), + "and the ball stays hidden") +end + +-- ------------------------------------- later talks only reprint the line +do + local game, ow, npc, save, pushed = newWorld(true) + save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true + save.objectToggles[MAP] = { [BALL] = true } + local done = false + script(game, ow, npc, function() done = true end) + eq(ow.engagements, 0, "a beaten grunt does not re-engage") + eq(#pushed, 1, "he says his after-battle line") + eq(pushed[1] and getmetatable(pushed[1]), TextBox, "in a text box") + if pushed[1] and pushed[1].onDone then pushed[1].onDone() end + check(done, "closing that box hands input back to the overworld") + eq(save.objectToggles[MAP][BALL], true, "the ball is not re-toggled") +end + +-- ------------------------------------------ the ball itself, #105's half +-- talkTo's item-ball branch is what turns the revealed object into a bag +-- entry; without it the fix above only puts a sprite on the floor. Same +-- `Game` upvalue rewiring tests/parity_D.lua uses for the Safari counters. +do + local game, _, _, save, pushed = newWorld(true) + save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true + save.objectToggles[MAP] = { [BALL] = true } + + local function bind(fn, upvalue, value) + local i = 1 + while true do + local name = debug.getupvalue(fn, i) + if not name then return false end + if name == upvalue then debug.setupvalue(fn, i, value); return true end + i = i + 1 + end + end + check(bind(OverworldState.talkTo, "Game", game), "talkTo binds the Game upvalue") + check(bind(OverworldState.talkTo, "mapScripts", mapScripts), + "talkTo binds the map-script registry") + + local ball = { + id = MAP .. "_obj_" .. ballObj.index, + def = ballObj, + cellX = ballObj.x, cellY = ballObj.y, + } + local world = setmetatable({ + map = { id = MAP, def = Data.maps[MAP] }, + npcs = { ball }, entities = { ball }, + }, { __index = OverworldState }) + + world:talkTo(ball) + eq(save.inventory.LIFT_KEY, 1, "picking the ball up puts the LIFT KEY in the bag (#105)") + eq(save.itemsTaken[ball.id], true, "the ball is marked taken") + eq(#world.npcs, 0, "and it leaves the floor") + check(not OverworldState.objectVisible(save, MAP, ballObj), + "a taken ball does not respawn on the next map load") + eq(#pushed, 1, "one box: the found-item line") + local lines = {} + for _, page in ipairs(pushed[1] and pushed[1].pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + check(table.concat(lines, " "):find("LIFT KEY", 1, true) ~= nil, + "the box names the LIFT KEY") + eq(Data.items.LIFT_KEY and Data.items.LIFT_KEY.keyItem, true, + "LIFT KEY is a key item, so it cannot be tossed away again") +end + +S.finish() diff --git a/tests/parity_marowak.lua b/tests/parity_marowak.lua index bcfa9c2b..d0488dd6 100644 --- a/tests/parity_marowak.lua +++ b/tests/parity_marowak.lua @@ -33,13 +33,16 @@ package.loaded["src.render.TextBox"] = { } -- A recording stand-in for BattleState: newWild captures the species and --- level, makeGhost sets the flag the real one sets, and the test drives --- onFinish by hand. +-- level, the two disguise entry points set the flags the real ones set, and +-- the test drives onFinish by hand. The unveiled branch is the scope's +-- (#492) -- it disguises the pic exactly like makeGhost but leaves +-- IsGhostBattle false, so `ghost` stays down and `scopeReveal` comes up. local madeBattles = {} package.loaded["src.battle.BattleState"] = { newWild = function(_, species, level) local b = { species = species, level = level, ghost = false } b.makeGhost = function(self) self.ghost = true end + b.makeUnveiledGhost = function(self) self.scopeReveal = true end madeBattles[#madeBattles + 1] = b return b end, @@ -107,13 +110,17 @@ do check(battle.ghost, "and it is ghost-disguised without the scope") end --- ---- 3. scope: same battle, not disguised ------------------------------- +-- ---- 3. scope: same battle, unveiled instead of ghosted ------------------ +-- PrintBeginningBattleText .isMarowak (common_text.asm:49-60) still enters +-- disguised and plays the unveil over it; only IsGhostBattle flips (#492). do local game, pushed = gameWith({ SILPH_SCOPE = 1 }) local ow = owWith() local battle = trigger(game, pushed, ow, 10, 16) check(battle ~= nil and not battle.ghost, - "with the scope the battle is not a ghost") + "with the scope the battle is not a ghost battle") + check(battle ~= nil and battle.scopeReveal, + "but it still opens disguised, with the unveil armed (#492)") end -- ---- 4. a win sets the event and shows the departed text ---------------- diff --git a/tests/parity_marowak_ball.lua b/tests/parity_marowak_ball.lua index 75383ece..d385db42 100644 --- a/tests/parity_marowak_ball.lua +++ b/tests/parity_marowak_ball.lua @@ -42,6 +42,9 @@ do newWild = function(_, species, level) local b = { species = species, level = level, ghost = false } b.makeGhost = function(self) self.ghost = true end + -- the scope's branch (#492): disguised on entry, but IsGhostBattle + -- false, which is exactly the state the dodge below has to survive + b.makeUnveiledGhost = function(self) self.scopeReveal = true end made[#made + 1] = b return b end, @@ -70,7 +73,8 @@ do check(noScope.noCatch, "and noCatch is set") local withScope = trigger({ SILPH_SCOPE = 1 }) - check(not withScope.ghost, "with the scope the disguise is gone") + check(not withScope.ghost, "with the scope IsGhostBattle is false") + check(withScope.scopeReveal, "and the unveil plays instead (#492)") check(withScope.noCatch, "but noCatch survives it -- balls are dodged either way") diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index dc206c9d..79dc7b36 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -68,13 +68,36 @@ moves[2].onDone() check(ow.emote and ow.emote.bubble == 1, "Pikachu shows its confused reaction after reaching Bill") +-- BillsHouseScript3 seeds hl with ..._EnterCellSeparatorDown and only swaps +-- in ..._EnterCellSeparatorNotDown on the facing-down fallthrough, so the +-- table names are inverted relative to the branch that picks them: facing +-- down takes the five-step detour, every other facing walks straight up +-- (pokeyellow scripts/BillsHouse.asm:100-133) (#455). ow.player.facing = "down" PikachuFollower.onBillEnteredMachine(yellowGame, ow) -check(#moves == 3 and moves[3].dir == "up" and moves[3].tiles == 3, - "Pikachu walks to the cell separator after Bill enters it") +check(#moves == 3 and moves[3].dir == "up" and moves[3].tiles == 1, + "facing down, Pikachu starts the long way round the cell separator") moves[3].onDone() -check(ow.emote and ow.emote.bubble == 1, - "Pikachu wonders at the cell separator") +check(#moves == 4 and moves[4].dir == "left" and moves[4].tiles == 1, + "the detour steps aside before climbing") +moves[4].onDone() +check(#moves == 5 and moves[5].dir == "up" and moves[5].tiles == 2, + "the detour climbs past the separator") +moves[5].onDone() +check(#moves == 6 and moves[6].dir == "right" and moves[6].tiles == 1, + "the detour steps back in beside it") +moves[6].onDone() +check(ow.emote and ow.emote.bubble == 1 and npc.facing == "up", + "Pikachu looks up and wonders at the cell separator") + +-- the other branch: any non-down facing takes the straight three-step route +local before = #moves +ow.player.facing = "up" +PikachuFollower.onBillEnteredMachine(yellowGame, ow) +check(#moves == before + 1 and moves[before + 1].dir == "up" + and moves[before + 1].tiles == 3, + "facing up, Pikachu walks straight to the cell separator") +moves[before + 1].onDone() npc.goalX, npc.goalY = 9, 9 PikachuFollower.update(yellowGame, ow) diff --git a/tests/rom_importer_cursor_test.lua b/tests/rom_importer_cursor_test.lua index 21e8ad13..2f8a41f3 100644 --- a/tests/rom_importer_cursor_test.lua +++ b/tests/rom_importer_cursor_test.lua @@ -36,4 +36,13 @@ ri:play("red") eq(booted, "red", "android play still boots") eq(currentCursor, "hand", "android play leaves the cursor alone") +booted = nil +currentCursor = "hand" +ri.android = false +ri.arrowCursor = nil +love.mouse.getSystemCursor = function() error("CreateSystemCursor is not currently supported") end +ri:play("red") +eq(booted, "red", "unsupported system cursors still allow boot") +eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone") + S.finish() diff --git a/tests/rom_importer_ios_save_export_test.lua b/tests/rom_importer_ios_save_export_test.lua new file mode 100644 index 00000000..692945b9 --- /dev/null +++ b/tests/rom_importer_ios_save_export_test.lua @@ -0,0 +1,64 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer iOS save export") +local eq = S.eq +local check = S.check + +love.system = love.system or {} +local saved = { + createFile = love.system.createFile, + read = love.filesystem.read, + write = love.filesystem.write, + getSaveDirectory = love.filesystem.getSaveDirectory, +} + +local writes = {} +love.filesystem.read = function(name) + eq(name, "exports/red.sav", "export reads the generated save") + return "save-data" +end +love.filesystem.write = function(name, data) + writes[#writes + 1] = { name = name, data = data } + return true +end +love.filesystem.getSaveDirectory = function() return "/tmp/gen1recomp-save" end + +local createCalls = {} +love.system.createFile = function(name, saveDirectory) + createCalls[#createCalls + 1] = { name = name, saveDirectory = saveDirectory } + return true +end + +package.loaded["src.import.SaveFileIO"] = { + exportActiveSlot = function(version) + eq(version, "red", "exports the selected game save") + return true, "exports/red.sav" + end, +} + +local RomImporter = require("src.import.RomImporter") +local ri = setmetatable({ + android = true, + ios = true, + workState = nil, + saveNotice = {}, +}, RomImporter) + +ri:exportSave("red") + +eq(#writes, 1, "stages the save for iOS export") +eq(writes[1].name, "pending_export.sav", "uses the native bridge staging name") +eq(writes[1].data, "save-data", "preserves the exported save data") +eq(#createCalls, 1, "opens the system destination picker") +eq(createCalls[1].name, "red.sav", "suggests the original save name") +eq(createCalls[1].saveDirectory, "/tmp/gen1recomp-save", "passes the exact staging directory") +check(ri.saveNotice.red.ok, "reports that the destination picker opened") + +love.system.createFile = saved.createFile +love.filesystem.read = saved.read +love.filesystem.write = saved.write +love.filesystem.getSaveDirectory = saved.getSaveDirectory +package.loaded["src.import.SaveFileIO"] = nil + +S.finish() diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index f1ea4888..dfbd0dcc 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -628,5 +628,324 @@ do Kit.focus = nil end +do + -- #541: changing species took the editor down. The inspector's arrows + -- called MonOps.setSpecies on whatever record came next in the catalog, and + -- the stat recalculation indexes baseStats. unconditionally + -- (src/pokemon/Stats.lua, ported from home/move_mon.asm CalcStat) because + -- the asm's BaseStats is a fixed 151-entry table with no partial rows. The + -- editor's catalog is NOT that table: it is every key in Data.pokemon after + -- the mod merge, and a partial record survives that merge as a warning + -- rather than a rejection (src/mods/Schemas.lua R.pokemon). So the sweep + -- below is the real check -- every id the catalog offers has to either + -- assign or refuse in words, and neither may raise. + local S = State.new() + S.data = Data + S.save = SaveData.newGame() + + -- a mod-shaped partial record: registered, listed, missing the stats the + -- Gen1 formulas read + Data.pokemon.TESTMON_PARTIAL = { name = "TESTMON", dex = 0, + baseStats = { hp = 40, attack = 30 }, growthRate = "MEDIUM_FAST", + types = { "NORMAL" }, learnset = {} } + S.cat = Catalog.build(Data) + + check(Ops.speciesUsable(S, "PIKACHU"), "a complete record is usable") + check(Ops.speciesUsable(S, "TESTMON_PARTIAL") == false, + "a record missing base stats is not usable") + check(Ops.speciesUsable(S, "NO_SUCH_SPECIES") == false, + "an id that is not in the data at all is not usable") + + local mon = MonOps.create(Data, "WARTORTLE", 20) + S.editingMon = mon + local statsBefore = mon.stats.attack + check(Ops.setSpecies(S, mon, "TESTMON_PARTIAL") == false, + "setSpecies refuses a record the formulas cannot use") + eq(mon.species, "WARTORTLE", "a refused setSpecies leaves the mon alone") + eq(mon.stats.attack, statsBefore, "a refused setSpecies leaves the stats alone") + check(S.status:match("base stats") ~= nil, "a refused setSpecies explains itself") + check(S.dirty == false, "a refused setSpecies does not dirty the save") + + -- the crash itself: walk the whole catalog the way the arrows did + local landed = {} + local walkOk, walkErr = pcall(function() + for _ = 1, #S.cat.species do + Ops.stepSpecies(S, mon, 1) + landed[mon.species] = true + assert(Ops.speciesUsable(S, mon.species), + "stepSpecies parked on " .. tostring(mon.species)) + end + end) + check(walkOk, "cycling the whole catalog never errors: " .. tostring(walkErr)) + check(landed.TESTMON_PARTIAL == nil, "cycling steps over an unusable record") + check(landed.PIKACHU, "cycling still reaches ordinary species") + + local backOk, backErr = pcall(function() + for _ = 1, #S.cat.species do Ops.stepSpecies(S, mon, -1) end + end) + check(backOk, "cycling backwards never errors: " .. tostring(backErr)) + + -- and every real id assigns, so "nothing crashes" cannot be bought by + -- refusing everything + local assigned, refused = 0, 0 + for _, id in ipairs(S.cat.species) do + if id ~= "TESTMON_PARTIAL" then + local ok, err = pcall(Ops.setSpecies, S, mon, id) + check(ok, "setSpecies " .. id .. ": " .. tostring(err)) + if ok and mon.species == id then assigned = assigned + 1 else refused = refused + 1 end + end + end + eq(refused, 0, "no real species is refused") + check(assigned > 140, "the whole dex assigns (" .. assigned .. " species)") + + Data.pokemon.TESTMON_PARTIAL = nil +end + +do + -- #541 search predicate. The picker replaced the arrows, so the filter is + -- the only way to reach a species now and its rules are worth pinning: ids + -- and names substring-match case-insensitively, a dex number matches whole + -- (a substring match would answer "25" with ELECTABUZZ, #125), and the + -- query is plain text, not a Lua pattern. + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + + check(Ops.speciesMatches(S, "PIKACHU", "pika"), "lowercase query matches an id") + check(Ops.speciesMatches(S, "PIKACHU", "KACH"), "a mid-word substring matches") + check(Ops.speciesMatches(S, "PIKACHU", "25"), "a bare dex number matches") + check(Ops.speciesMatches(S, "PIKACHU", "025"), "the padded dex number matches") + check(Ops.speciesMatches(S, "ELECTABUZZ", "25") == false, + "a dex number does not substring-match #125") + check(Ops.speciesMatches(S, "PIKACHU", ""), "an empty query matches everything") + + eq(#Ops.speciesSearch(S, ""), #S.cat.species, "an empty search lists the catalog") + -- a dot is a literal, not a pattern wildcard: MR.MIME is the one species + -- whose printed name carries one, so it is the whole result + local dots = Ops.speciesSearch(S, ".") + eq(#dots, 1, "a dot matches literally instead of matching everything") + eq(dots[1], "MR_MIME", "and the literal it matched is MR.MIME's name") + eq(#Ops.speciesSearch(S, "%a"), 0, "a pattern class is literal too") + check(#Ops.speciesSearch(S, "zzzznope") == 0, "a miss returns nothing") + local pika = Ops.speciesSearch(S, "pikachu") + eq(#pika, 1, "an exact name search narrows to one") + eq(pika[1], "PIKACHU", "and it is the right one") +end + +do + -- #541 end to end through App: open the picker off a selected slot, type, + -- commit with Enter. Enter and Escape have to be taken before the focused + -- field sees them -- Kit maps both to the same "\r" edit, which cannot tell + -- "commit the top match" from "give up". + local Kit = require("Kit") + local SpeciesPicker = require("SpeciesPicker") + local tmpPath = os.tmpname() .. "-picker-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "WARTORTLE", 20) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "party" + + check(Ops.openSpeciesPicker(S, Kit) == false, + "the picker refuses to open with no slot selected") + check(S.speciesPicker == nil, "and it stayed closed") + check(S.status:match("Pick a slot") ~= nil, "and it said why") + + Ops.selectParty(S, 1) + check(Ops.openSpeciesPicker(S, Kit) == true, "the picker opens on a selected slot") + check(S.speciesPicker ~= nil, "the picker is up") + eq(S.speciesPicker.query, "", "it opens with an empty query") + eq(Kit.focus, "species-picker", "it opens with the field focused (#529 keyboard)") + + local ok, err = pcall(App.draw) + check(ok, "the picker draws headlessly: " .. tostring(err)) + + App.textinput("PIKACHU") + ok, err = pcall(App.draw) + check(ok, "the picker draws while typing: " .. tostring(err)) + eq(S.speciesPicker.query, "PIKACHU", "typing reaches the picker's field") + eq(#SpeciesPicker.results(S), 1, "the list narrowed to the typed species") + + App.keypressed("return") + eq(S.save.party[1].species, "PIKACHU", "Enter commits the top match") + check(S.speciesPicker == nil, "and closes the picker") + check(S.dirty, "and the save is dirty") + + -- Escape leaves without touching the mon + Ops.openSpeciesPicker(S, Kit) + App.textinput("BULBASAUR") + App.draw() + App.keypressed("escape") + check(S.speciesPicker == nil, "Escape closes the picker") + eq(S.save.party[1].species, "PIKACHU", "Escape did not commit anything") + check(S.editingMon ~= nil, "Escape closed the picker, not the selection") + + -- a query nothing matches cannot commit + Ops.openSpeciesPicker(S, Kit) + App.textinput("zzzznope") + App.draw() + App.keypressed("return") + check(S.speciesPicker ~= nil, "Enter on an empty result set keeps the picker up") + check(S.status:match("No species matches") ~= nil, "and says so") + eq(S.save.party[1].species, "PIKACHU", "and changes nothing") + Ops.closeSpeciesPicker(S, Kit) + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + +do + -- #541 modal shield. Kit hit-tests without a z-order, so the picker cannot + -- simply be drawn last: the chrome and the panel underneath would take the + -- same tap. App raises Kit.blockClicks around everything it draws before + -- the picker and lowers it for the picker's own layer, which is asserted + -- here by watching what Kit.press sees over one frame rather than by + -- clicking coordinates the design is free to move. + local Kit = require("Kit") + local tmpPath = os.tmpname() .. "-shield-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "WARTORTLE", 20) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "party" + Ops.selectParty(S, 1) + + -- the shield itself: a raised shield refuses a click that hits + Kit.beginFrame(15, 15, true) + Kit.blockClicks = false + check(Kit.press(10, 10, 100, 20) == true, "an unshielded press takes the click") + Kit.blockClicks = true + check(Kit.press(10, 10, 100, 20) == false, "a shielded press refuses it") + Kit.blockClicks = false + Kit.endFrame() + + local realPress = Kit.press + local seen = {} + Kit.press = function(...) + seen[#seen + 1] = Kit.blockClicks + return realPress(...) + end + + Ops.openSpeciesPicker(S, Kit) + seen = {} + App.draw() + check(#seen > 0, "the opening frame dispatched clicks at all") + local allShielded = true + for _, v in ipairs(seen) do allShielded = allShielded and (v == true) end + check(allShielded, + "on the opening frame even the picker's own layer is shielded, so the " + .. "click that opened it cannot read as a tap outside") + + seen = {} + App.draw() + check(seen[1] == true, "the frame under an open picker is shielded") + check(seen[#seen] == false, "the picker's own layer is not") + check(Kit.blockClicks == false, "the shield is down again once the frame ends") + + Kit.press = realPress + + -- a Close taken with the picker up must not leak the shield into the next + -- session, which would open deaf to every click + Kit.blockClicks = true + App.unload() + check(Kit.blockClicks == false, "unload lowers the shield") + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + +do + -- #497: the editor drew a desktop layout into a phone window. Kit.layout + -- scaled off height alone, and a phone in portrait (720x1560) is TALLER + -- than the 768px desktop reference while being barely half as wide, so the + -- scale came back clamped at 1.6 and every right-aligned cluster in the + -- chrome landed on top of the block to its left. Both axes now pay. + local Kit = require("Kit") + local Theme = require("Theme") + local function about(got, want, msg) + check(math.abs(got - want) < 0.001, + msg .. string.format(" (got %.4f, want %.4f)", got, want)) + end + + about(Kit.layout(720, 1560), 0.72, "portrait phone scales off its width") + check(Kit.layout(720, 1560) < 1.0, + "a portrait phone no longer draws a larger-than-desktop layout") + about(Kit.layout(1560, 720), 720 / 768, "landscape phone still scales off height") + about(Kit.layout(360, 640), 0.62, "a tiny window stops at the floor") + + -- desktop and laptop sizes have to be pixel-identical to before the fix: + -- everything at or above the 1000px reference width lands on the height + -- term, exactly as it always did + for _, size in ipairs({ { 1280, 800 }, { 1024, 768 }, { 1920, 1080 }, + { 1440, 900 }, { 2560, 1440 } }) do + about(Kit.layout(size[1], size[2]), Theme.clamp(size[2] / 768, 0.7, 1.6), + ("%dx%d keeps its old height-only scale"):format(size[1], size[2])) + end +end + +do + -- #497 draw pass at the two shapes the report came in on (720x1560 and + -- 1560x720, an A20s held either way). The layout is what a human has to + -- judge, but a panel that lays itself out at a negative width is machine + -- visible: LOVE rejects a negative scissor, so the inspector's own clip + -- catches the collapse the roster used to cause by keeping an absolute + -- 300px floor on a 720px-wide window. + local Kit = require("Kit") + local Theme = require("Theme") + local tmpPath = os.tmpname() .. "-phone-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "CHARIZARD", 100), + MonOps.create(Data, "PIDGEY", 5) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + local oldDimensions = love.graphics.getDimensions + local oldScissor = love.graphics.setScissor + love.graphics.setScissor = function(_, _, width, height) + if width and (width < 0 or height < 0) then + error(("negative scissor %sx%s"):format(tostring(width), tostring(height))) + end + end + + for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 }, + { 1280, 800 } }) do + love.graphics.getDimensions = function() return size[1], size[2] end + App.load(tmpPath, { version = "red" }) + local S = App.getState() + local label = ("%dx%d"):format(size[1], size[2]) + for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do + S.tab = tab + local ok, err = pcall(App.draw) + check(ok, ("the %s tab draws at %s: %s"):format(tab, label, tostring(err))) + end + S.tab = "party" + Ops.selectParty(S, 1) + local ok, err = pcall(App.draw) + check(ok, ("the inspector draws at %s: %s"):format(label, tostring(err))) + Ops.openSpeciesPicker(S, Kit) + ok, err = pcall(App.draw) + check(ok, ("the species picker draws at %s: %s"):format(label, tostring(err))) + ok, err = pcall(App.draw) + check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err))) + Ops.closeSpeciesPicker(S, Kit) + end + + love.graphics.getDimensions = oldDimensions + love.graphics.setScissor = oldScissor + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + print(string.format("save editor tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 164e8ca5..f102e459 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -4,7 +4,7 @@ Yellow is a separate pret tree (pokeyellow), not a `_YELLOW` flip of pokered. Most of the ~3268 Red manifest symbols still exist under the same names in pokeyellow.sym (~3123 with shifted addresses). The remainder need aliases, -synthetic addresses (Mew in BaseStats), or omission (FightIntro* — Yellow's +synthetic addresses (Mew in BaseStats), or omission (FightIntro* -- Yellow's intro movie is different; RomExtractor must skip those). Map/object/sprite/tileset/text metadata diverge enough that those sections are @@ -48,7 +48,7 @@ from yellow_symbol_aliases import ( # noqa: E402 try: from rom_data import CANONICAL_YELLOW_SHA1 -except ImportError: # pragma: no cover — constant lands with GameVersion work +except ImportError: # pragma: no cover -- constant lands with GameVersion work CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" DEV = "/Users/bryanbassett/Documents/development" @@ -88,6 +88,21 @@ YELLOW_EXTRA_SYMBOLS = ( "SurfingPikachu1Graphics1", "SurfingPikachu1Graphics2", "SurfingPikachu1Graphics3", + # Oak's own battle back pic. LoadPlayerBackPic (engine/battle/core.asm) + # picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for + # BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). + "ProfOakPicBack", + # Base frames for the framed portrait TalkToPikachu draws, one per + # PikaPicAnimScript (data/pikachu/pikachu_pic_animation.asm). These are + # raw address labels because the pikapic blobs carry no named symbols; + # RomExtractor's PIKAPIC_BASE table indexes them positionally, so the + # order here is not load bearing but every entry must resolve (#561). + "Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b", + "Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e", + "Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924", + "Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587", + "Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf", + "Pic_f0abf", "Pic_f0cf4", ) # Yellow-only dialogue whose bank labels carry no leading underscore, so diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 1acadb2f..8b94d472 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -21661,6 +21661,22 @@ 29, 18588 ], + "GFX_e6020": [ + 57, + 24608 + ], + "GFX_e6e6f": [ + 57, + 28271 + ], + "GFX_e718f": [ + 57, + 29071 + ], + "GFX_e74af": [ + 57, + 29871 + ], "GamblerPic": [ 19, 21537 @@ -23085,6 +23101,98 @@ 7, 20102 ], + "Pic_e4000": [ + 57, + 16384 + ], + "Pic_e411c": [ + 57, + 16668 + ], + "Pic_e4272": [ + 57, + 17010 + ], + "Pic_e4383": [ + 57, + 17283 + ], + "Pic_e458b": [ + 57, + 17803 + ], + "Pic_e467b": [ + 57, + 18043 + ], + "Pic_e476e": [ + 57, + 18286 + ], + "Pic_e49d1": [ + 57, + 18897 + ], + "Pic_e4b39": [ + 57, + 19257 + ], + "Pic_e4c3e": [ + 57, + 19518 + ], + "Pic_e5000": [ + 57, + 20480 + ], + "Pic_e523f": [ + 57, + 21055 + ], + "Pic_e548e": [ + 57, + 21646 + ], + "Pic_e56d1": [ + 57, + 22225 + ], + "Pic_e5924": [ + 57, + 22820 + ], + "Pic_e5b7d": [ + 57, + 23421 + ], + "Pic_e5ddd": [ + 57, + 24029 + ], + "Pic_e6340": [ + 57, + 25408 + ], + "Pic_e6587": [ + 57, + 25991 + ], + "Pic_e67d6": [ + 57, + 26582 + ], + "Pic_e77cf": [ + 57, + 30671 + ], + "Pic_f0abf": [ + 60, + 19135 + ], + "Pic_f0cf4": [ + 60, + 19700 + ], "PickUpItemText": [ 0, 9199 @@ -23505,6 +23613,10 @@ 19, 24890 ], + "ProfOakPicBack": [ + 61, + 17618 + ], "PsychicPic": [ 19, 22312 diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 0faf902f..ce3a0feb 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -32,6 +32,8 @@ local Items = require("Items") local Events = require("Events") local MapBrowser = require("MapBrowser") local Dex = require("Dex") +-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541) +local SpeciesPicker = require("SpeciesPicker") local App = {} local S @@ -222,8 +224,11 @@ function App.unload() -- Kit is never evicted from package.loaded, so a Close taken while a text -- field still owns focus would leak Kit.focus and a raised soft keyboard -- (against a rect that is gone) into the launcher and the next session - -- (#529). + -- (#529). A Close taken on the frame the species picker went up would + -- likewise leave its modal shield raised, and the next session would open + -- deaf to every click (#541). Kit.blur() + Kit.blockClicks = false end function App.save() @@ -345,35 +350,11 @@ local function drawTitleBar(x, y, w, h) { 159, 180, 221 }) cx = cx + badge + 10 * s - local wordH = Kit.textHeight("wordmark") - local brandH = Kit.textHeight("brand") - local blockY = y + (h - (wordH + 2 * s + brandH)) / 2 - love.graphics.setFont(Kit.fonts.wordmark) - Theme.col(PAL.heading, 1) - local wordW = Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s) - love.graphics.setFont(Kit.fonts.brand) - Theme.col(PAL.caption, 1) - local brandW = Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx, - blockY + wordH + 2 * s, 1 * s) - cx = cx + math.max(wordW, brandW) + 12 * s - - -- version chip: which game this save belongs to (from the launcher slot, - -- or the save's own header in a standalone run) - if S.version then - local name = S.version:upper() - local c = (S.version == "blue") and PAL.blue or PAL.red - local cw = Kit.textWidth("chip", name) + 16 * s - local ch = 22 * s - local cy = y + (h - ch) / 2 - Theme.col(c, 0.1) - love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s) - Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1) - Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2, cw, c) - cx = cx + cw + 12 * s - end - - -- right-aligned action cluster, laid out from the right edge inward so the - -- file chip can absorb whatever is left + -- The right-aligned action cluster is laid out from the right edge inward + -- BEFORE anything on the left is drawn: the buttons are the one thing in + -- this bar that must always be reachable, so on a phone the identity block, + -- the version chip and the file chip are what yield. Measuring them last + -- is why they used to paint straight through the buttons (#497). local btnH = 38 * s local btnY = y + (h - btnH) / 2 local rightEdge = x + w - pad @@ -395,6 +376,41 @@ local function drawTitleBar(x, y, w, h) local reloadX = openX - gap - reloadW local saveX = reloadX - gap - saveW + local wordH = Kit.textHeight("wordmark") + local brandH = Kit.textHeight("brand") + local blockY = y + (h - (wordH + 2 * s + brandH)) / 2 + local wordW = math.max( + Theme.spacedWidth(Kit.fonts.wordmark, "SAVE EDITOR", 2 * s), + Theme.spacedWidth(Kit.fonts.brand, "GEN1RECOMP", 1 * s)) + if cx + wordW + 12 * s < saveX then + love.graphics.setFont(Kit.fonts.wordmark) + Theme.col(PAL.heading, 1) + Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s) + love.graphics.setFont(Kit.fonts.brand) + Theme.col(PAL.caption, 1) + Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx, + blockY + wordH + 2 * s, 1 * s) + cx = cx + wordW + 12 * s + end + + -- version chip: which game this save belongs to (from the launcher slot, + -- or the save's own header in a standalone run) + if S.version then + local name = S.version:upper() + local c = (S.version == "blue") and PAL.blue or PAL.red + local cw = Kit.textWidth("chip", name) + 16 * s + local ch = 22 * s + local cy = y + (h - ch) / 2 + if cx + cw + 12 * s < saveX then + Theme.col(c, 0.1) + love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s) + Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1) + Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2, + cw, c) + cx = cx + cw + 12 * s + end + end + -- Save is the only green-filled control in the chrome; a corrupt load -- renders it steel with the reason parked in the status bar rather than -- hiding it (rule 3 of the design spec). @@ -481,9 +497,11 @@ local function railDetail(x, pillX) widths.glyph = widths.glyph + tile + 12 * s end local avail = pillX - 14 * s - (x + 22 * s) - if widths.full <= avail then return "full" end - if widths.nocount <= avail then return "nocount" end - return "glyph" + -- the widths come back too: the caller needs the glyph-mode figure to decide + -- whether the pill still has room, and measuring the rail twice is waste + if widths.full <= avail then return "full", widths end + if widths.nocount <= avail then return "nocount", widths end + return "glyph", widths end local function drawTabRail(x, y, w, h) @@ -496,7 +514,15 @@ local function drawTabRail(x, y, w, h) local ph = 26 * s local pw = Kit.textWidth("small", label) + 28 * s local px = x + w - pad - pw - local detail = railDetail(x, px) + local detail, widths = railDetail(x, px) + -- Last stop before the tiles and the pill collide: at phone widths even the + -- 2-letter glyph tiles need the room the pill is sitting in, and the pill + -- only repeats a line the status bar already prints on load, so the pill is + -- what goes (#497). With it gone the tiles get the full bar back. + local showPill = widths.glyph <= px - 14 * s - (x + 22 * s) + if not showPill then + detail = railDetail(x, x + w - pad) + end local tile = 40 * s local cx = x + pad @@ -546,15 +572,19 @@ local function drawTabRail(x, y, w, h) cx = cx + cellW end - local py = y + h - 14 * s - ph - Theme.col(pillColor, clean and 0.08 or 0.1) - love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2) - Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1) - Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2, pw, - pillColor) - if target and Kit.press(px, py, pw, ph) then - S.tab = target - Ops.say(S, "Jumped to the tab holding the first quarantine warning") + if showPill then + local py = y + h - 14 * s - ph + Theme.col(pillColor, clean and 0.08 or 0.1) + love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2) + Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1) + Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2, + pw, pillColor) + -- paint and hit target are suppressed together, so a hidden pill cannot + -- still eat a tap meant for the DEX tile + if target and Kit.press(px, py, pw, ph) then + S.tab = target + Ops.say(S, "Jumped to the tab holding the first quarantine warning") + end end end @@ -592,6 +622,11 @@ function App.draw() local mx, my = love.mouse.getPosition() Kit.beginFrame(mx, my, mouseClicked) mouseClicked = false + -- Modal shield. Kit has no z-order, so the picker cannot simply be drawn + -- last: the chrome and the panel underneath would take the same tap. The + -- shield goes up before anything dispatches and comes down only for the + -- picker's own layer at the bottom of this function (#541). + Kit.blockClicks = (S.speciesPicker ~= nil) Theme.field(width, height) @@ -613,6 +648,8 @@ function App.draw() end drawStatusBar(0, height - statusH, width, statusH) + Kit.blockClicks = false + SpeciesPicker.draw(S, Kit, width, height) Kit.endFrame() -- Only now, with the whole frame painted, is it safe to drop the editor. @@ -621,6 +658,18 @@ end function App.keypressed(key) if not S then return end + -- The picker takes Enter and Escape before the focused field does: Kit maps + -- both to the same "\r" edit (a blur), which cannot tell "commit the top + -- match" apart from "give up" (#541). + if S.speciesPicker then + if key == "return" or key == "kpenter" then + SpeciesPicker.commitFirst(S, Kit) + return + elseif key == "escape" then + Ops.closeSpeciesPicker(S, Kit) + return + end + end -- A focused text field eats the keys it cares about (typing "s" into the -- map filter must not trigger Save). if Kit.keypressed(key) then return end diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 8fb484c6..355c50c1 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -71,16 +71,22 @@ function Kit.endFrame() for i = #edits, 1, -1 do edits[i] = nil end end --- Rebuild the font set when the window size changes. `s` matches the --- launcher's height/768 scale so both windows step together. +-- Rebuild the font set when the window size changes. `s` matched the +-- launcher's height/768 scale alone until #497: a phone in portrait +-- (720x1560) is TALLER than the desktop reference and barely half as wide, so +-- a height-only scale drew a 1.6x desktop layout into a 720px window and every +-- right-aligned cluster in the chrome landed on top of the block to its left. +-- The layout needs roughly 1000 logical px of width, so the window now pays +-- for both axes. Every desktop and landscape size still lands on the height +-- term, which is why they stay pixel-identical to before. function Kit.layout(width, height) - local s = Theme.clamp(height / 768, 0.7, 1.6) + local s = Theme.clamp(math.min(width / 1000, height / 768), 0.62, 1.6) local key = ("%dx%d"):format(width, height) if Kit._fontKey ~= key then Kit._fontKey = key Kit.fonts = Theme.fonts(s) - Kit.scale = s end + Kit.scale = s return s end @@ -124,7 +130,15 @@ function Kit.hover(x, y, w, h) return Kit.hit(x, y, w, h) end +-- Kit hit-tests without a z-order, so an overlay cannot just be drawn last: +-- every widget underneath it would still take the same click. A modal raises +-- this shield over the layers it covers (App.draw does it around the chrome +-- and the panel while the species picker is up) and lowers it for its own +-- layer (#541). +Kit.blockClicks = false + function Kit.press(x, y, w, h) + if Kit.blockClicks then return false end return Kit.mouseClicked and Kit.hit(x, y, w, h) end diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index e6212e29..694418d8 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -156,16 +156,119 @@ function Ops.setLevel(S, mon, level) return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level)) end +-- A catalog id is only usable as a real mon when its record carries what the +-- Gen1 formulas read: Stats.calc indexes baseStats. unconditionally +-- (src/pokemon/Stats.lua, home/move_mon.asm CalcStat), because the asm's +-- BaseStats is a fixed 151-entry table and every row is complete. The +-- editor's list is NOT that table -- it is every key in Data.pokemon after +-- the mod merge -- and a mod loaded at api 1 can leave a partial record in +-- there, since the schema violation downgrades to a warning rather than a +-- rejection (src/mods/Schemas.lua R.pokemon). So the editor tests the record +-- instead of trusting the list: without this, picking such a species walked +-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window +-- down (#541). +local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" } + +function Ops.speciesUsable(S, id) + local def = id and S.data.pokemon[id] + if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end + for _, key in ipairs(BASE_STAT_KEYS) do + if type(def.baseStats[key]) ~= "number" then return false end + end + return true +end + +-- The one funnel every species change goes through (the picker, the stepper, +-- anything later). MonOps asserts and recalculates, so an unusable record is +-- refused before it runs, and the round trip itself is fenced: a record that +-- passes the check above but still trips a formula has to leave the mon +-- exactly as it was and speak in the status bar, not take the editor with it. +function Ops.setSpecies(S, mon, id) + if not mon then return false end + if id == mon.species then + return Ops.say(S, ("Already a %s"):format(tostring(id))) + end + if not Ops.speciesUsable(S, id) then + return Ops.say(S, ("%s has no usable base stats, cannot assign it") + :format(tostring(id))) + end + -- MonOps.recalc replaces mon.stats with a fresh table rather than editing + -- it in place, so holding the old reference is a real rollback. + local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp + local wasStats, wasHp = mon.stats, mon.hp + local ok, err = pcall(MonOps.setSpecies, S.data, mon, id) + if not ok then + mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp + mon.stats, mon.hp = wasStats, wasHp + return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err))) + end + return Ops.mark(S, ("Species set to %s"):format(id)) +end + +-- Kept for the keyboard and test path; the inspector opens the searchable +-- picker instead of walking the catalog one arrow at a time (#541). Skips +-- ids Ops.setSpecies would refuse, so one bad record cannot park the walk. function Ops.stepSpecies(S, mon, delta) if not mon then return false end local list = S.cat.species + local n = #list + if n == 0 then return Ops.say(S, "No species in the catalog") end local idx = 1 for i, id in ipairs(list) do if id == mon.species then idx = i break end end - local nextId = list[((idx - 1 + delta) % #list) + 1] - MonOps.setSpecies(S.data, mon, nextId) - return Ops.mark(S, ("Species set to %s"):format(nextId)) + for step = 1, n do + local nextId = list[((idx - 1 + delta * step) % n) + 1] + if nextId ~= mon.species and Ops.speciesUsable(S, nextId) then + return Ops.setSpecies(S, mon, nextId) + end + end + return Ops.say(S, "No other species in the catalog can be assigned") +end + +-- Search predicate behind the picker's field: the id, the display name, and a +-- bare dex number ("25" finds PIKACHU), all case-insensitive and plain (no +-- pattern magic, so a "." typed by accident matches a literal dot). +function Ops.speciesMatches(S, id, query) + if not query or query == "" then return true end + local q = tostring(query):lower() + if id:lower():find(q, 1, true) then return true end + local def = S.data.pokemon[id] + local name = def and def.name + if name and tostring(name):lower():find(q, 1, true) then return true end + local dex = tonumber(def and def.dex) + -- dex matches exactly, in either the bare or the padded form the inspector + -- prints ("25" and "025" both find PIKACHU). A substring match here would + -- pull in ELECTABUZZ (#125) on a search for 25, which reads as a bug. + return dex ~= nil and (q == tostring(dex) or q == ("%03d"):format(dex)) +end + +function Ops.speciesSearch(S, query) + local out = {} + for _, id in ipairs(S.cat.species) do + if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end + end + return out +end + +-- The picker is modal editor chrome, not a save mutation, so its flag lives +-- with the other view state on S (State.new). Ops owns the door only because +-- both the inspector and App need one and neither should require the other. +-- `opened` marks the frame the picker went up: the click that opened it is +-- still live when the overlay draws later in that same frame (#541). +function Ops.openSpeciesPicker(S, Kit) + if not S.editingMon then + return Ops.say(S, "Pick a slot first, then choose its species") + end + S.speciesPicker = { query = "", offset = 0, opened = true } + -- focus the field on open so the mobile soft keyboard rises with it (#529) + if Kit then Kit.focus = "species-picker" end + return true +end + +function Ops.closeSpeciesPicker(S, Kit) + S.speciesPicker = nil + if Kit and Kit.blur then Kit.blur() end end function Ops.setDv(S, mon, key, value) diff --git a/tools/save-editor/README.md b/tools/save-editor/README.md index 89143106..900a069b 100644 --- a/tools/save-editor/README.md +++ b/tools/save-editor/README.md @@ -41,9 +41,13 @@ If the file isn't there (or you want another copy), use **Open...**, drop a | `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router | | `panels/` | one file per tab; pure layout that dispatches into Ops | -The design reference is `SaveEditor.dc.html` in the Claude Design project -that this port transcribes; its measurements are in the same pixel space -`App.lua` draws in. +`panels/SpeciesPicker.lua` is the one exception to "one file per tab": it is +the modal species search the inspector opens, drawn by `App.draw` after the +panel rather than routed through the tab table. Kit has no z-order, so while +it is up `Kit.blockClicks` shields every widget underneath it. + +The design reference is the `SaveEditor.dc.html` mockup that this port +transcribes; its measurements are in the same pixel space `App.lua` draws in. Two rules the code enforces and the tests assert: diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 74572f33..5da15470 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -41,6 +41,10 @@ function State.new() -- party / inspector selectedParty = 1, editingMon = nil, -- reference into party or a box + -- species picker overlay: nil when closed, otherwise { query, offset }. + -- Modal in the literal sense -- App shields every widget under it for the + -- frame -- because Kit hit-tests without a z-order (#541). + speciesPicker = nil, -- boxes selectedBox = 1, diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 28121f9a..0e51f1b1 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -85,6 +85,10 @@ function MonEditor.draw(S, Kit, x, y, w, h) local def = S.data.pokemon[mon.species] local cx, cy = x + pad, y + pad local inner = w - 2 * pad + -- Backstop for a window too short for even the compacted rhythm below: + -- nothing this panel draws may land outside its own card (#497). Party + -- draws the inspector last, so no outer clip is lost by the pop at the end. + Kit.pushClip(x, y, w, h) -- ---------------------------------------------------------- header row local sprite = 96 * s @@ -97,17 +101,22 @@ function MonEditor.draw(S, Kit, x, y, w, h) Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s, cy + Kit.textHeight("title") - Kit.textHeight("tiny") - 2 * s, PAL.caption) - local stepW, stepH = 28 * s, 26 * s - local sx = hx + hw - 2 * stepW - 6 * s - local sy = cy + (Kit.textHeight("title") - stepH) / 2 - if Kit.stepper(sx, sy, stepW, stepH, "<", { radius = 7 * s }) then - Ops.stepSpecies(S, mon, -1) + -- One control instead of a pair of arrows: cycling walked the catalog an + -- entry at a time (151 taps to cross the dex) and ran a full MonOps + -- recalculation on every step, including on records the Gen1 formulas + -- cannot use, which is what crashed the editor (#541). This opens the + -- searchable picker; the species name itself is a second, larger target. + local pickH = 30 * s + local pickW = math.min(150 * s, math.max(90 * s, hw * 0.6)) + local px = hx + hw - pickW + local py = cy + (Kit.textHeight("title") - pickH) / 2 + local openPicker = Kit.button(px, py, pickW, pickH, "Change species", + { kind = "accent", font = "small", radius = 8 * s }) + if not openPicker then + openPicker = Kit.press(hx, cy, math.max(0, px - hx - 10 * s), + Kit.textHeight("title")) end - if Kit.stepper(sx + stepW + 6 * s, sy, stepW, stepH, ">", { radius = 7 * s }) then - Ops.stepSpecies(S, mon, 1) - end - Kit.textRight("tiny", "species", sx - 8 * s, - sy + (stepH - Kit.textHeight("tiny")) / 2, PAL.caption) + if openPicker then Ops.openSpeciesPicker(S, Kit) end -- level stepper: -5 -1 [Lv] +1 +5, matching MonOps.setLevel's 1..100 clamp local ly = cy + Kit.textHeight("title") + 14 * s @@ -139,7 +148,20 @@ function MonEditor.draw(S, Kit, x, y, w, h) statsY = statsY + Kit.textHeight("caption") + 10 * s local gap = 12 * s local cellW = (inner - gap * 4) / 5 - local cellH = 68 * s + -- Everything below the header competes for one vertical budget. At the + -- design size it is generous; in a 720px-tall window (a phone held + -- sideways) it is not, and the DV / move rows used to run past the card and + -- paint over the status bar (#497). Shrink the two flexible blocks -- the + -- stat tiles and the DV / move rows -- instead of overflowing, with floors + -- that keep every row the 26px target Kit's rule 6 promises. statsY is + -- already past the STATS caption here, so only the DVs / MOVES caption is + -- subtracted. + local actH = 34 * s + local rowGap = 8 * s + local budget = (y + h - pad) - statsY - (Kit.textHeight("caption") + 10 * s) + - 18 * s - actH - 4 * s + local cellH = Theme.clamp(budget * 0.3, 46 * s, 68 * s) + local rowH = Theme.clamp((budget - cellH) / 4 - rowGap, 26 * s, 34 * s) for i, st in ipairs(STAT_KEYS) do local bx = cx + (i - 1) * (cellW + gap) Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6) @@ -164,8 +186,6 @@ function MonEditor.draw(S, Kit, x, y, w, h) Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption) local rowY = colY + Kit.textHeight("caption") + 10 * s - local rowH = 34 * s - local rowGap = 8 * s for i, key in ipairs(DV_KEYS) do local ry = rowY + (i - 1) * (rowH + rowGap) @@ -221,7 +241,6 @@ function MonEditor.draw(S, Kit, x, y, w, h) end local actY = rowY + 4 * (rowH + rowGap) + 4 * s - local actH = 34 * s local actW = (colW - 10 * s) / 2 if Kit.button(rightX, actY, actW, actH, "Reset to learnset", { font = "small", radius = 9 * s }) then @@ -231,6 +250,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) { kind = "good", font = "small", radius = 9 * s }) then Ops.healMon(S, mon) end + Kit.popClip() end return MonEditor diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 037f7c54..ae232c85 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -15,8 +15,11 @@ local Party = {} -- Roster column width: the design's 460px at the reference size, but it gives -- ground to the inspector on a narrow window so neither column collapses. +-- The 300px floor used to be absolute, which on a phone handed the roster +-- three quarters of the panel and left the inspector laying itself out at a +-- negative width (#497); the floor now yields to a share of what there is. local function rosterWidth(w, s) - return math.max(300 * s, math.min(460 * s, w * 0.36)) + return math.max(math.min(300 * s, w * 0.42), math.min(460 * s, w * 0.36)) end -- HP colour follows the game's own health bar thresholds. diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua new file mode 100644 index 00000000..5647b141 --- /dev/null +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -0,0 +1,129 @@ +-- Type-to-search species picker (#541). The inspector used to change species +-- with a pair of arrows, which walked the catalog one entry at a time -- 151 +-- taps to cross the dex -- and ran a full MonOps recalculation on whatever +-- record happened to be next, including records the Gen1 formulas cannot use. +-- This is the replacement: one modal list, filtered as you type, committing +-- through Ops.setSpecies so an unusable record refuses instead of crashing. +-- +-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises +-- Kit.blockClicks over the chrome and the panel while this is open and lowers +-- it only for this overlay; nothing underneath can take the same tap. + +local Theme = require("Theme") +local Ops = require("Ops") +local MonEditor = require("MonEditor") +local PAL = Theme.PAL + +local Picker = {} + +local FIELD_ID = "species-picker" + +function Picker.results(S) + local p = S.speciesPicker + return Ops.speciesSearch(S, p and p.query or "") +end + +-- Enter commits the top match, which is the whole point of a search field. +function Picker.commitFirst(S, Kit) + local hits = Picker.results(S) + if not hits[1] then return Ops.say(S, "No species matches that") end + local ok = Ops.setSpecies(S, S.editingMon, hits[1]) + if ok then Ops.closeSpeciesPicker(S, Kit) end + return ok +end + +function Picker.draw(S, Kit, width, height) + local p = S.speciesPicker + if not p then return end + local s = Kit.scale + + -- The click that opened the picker is still the frame's click: the + -- inspector dispatches earlier in App.draw than this overlay does, so + -- without swallowing it the scrim below would read it as a tap outside and + -- shut the picker in the same frame it went up. App re-raises the shield + -- at the top of the next frame, so leaving it up here is safe. + if p.opened then + p.opened = nil + Kit.blockClicks = true + end + + -- the scrim doubles as the "tap outside to cancel" target + Theme.col(PAL.bgBot, 0.72) + love.graphics.rectangle("fill", 0, 0, width, height) + + local w = math.min(width - 32 * s, 520 * s) + local h = math.min(height - 32 * s, 560 * s) + local x = (width - w) / 2 + local y = (height - h) / 2 + if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then + Ops.closeSpeciesPicker(S, Kit) + return + end + + Kit.card(x, y, w, h) + local pad = 18 * s + local cx, cy = x + pad, y + pad + local inner = w - 2 * pad + + Kit.caption(cx, cy, "CHOOSE A SPECIES") + local closeW = 30 * s + if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", + { font = "small", radius = 7 * s }) then + Ops.closeSpeciesPicker(S, Kit) + return + end + cy = cy + Kit.textHeight("caption") + 10 * s + + local fieldH = 34 * s + p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, + "type a name, an id, or a dex number") + cy = cy + fieldH + 10 * s + + local hits = Picker.results(S) + local rowH = 40 * s + local rowGap = 6 * s + local pagerH = 30 * s + local listH = (y + h - pad - pagerH - 10 * s) - cy + local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) + p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) + + if #hits == 0 then + Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") + else + Kit.pushClip(cx, cy, inner, listH) + for i = 1, perPage do + local id = hits[p.offset + i] + if not id then break end + local ry = cy + (i - 1) * (rowH + rowGap) + local def = S.data.pokemon[id] + -- A record the formulas cannot use still lists, greyed: hiding it would + -- make a modded species look like it never registered (#541). + local usable = Ops.speciesUsable(S, id) + local current = (S.editingMon and S.editingMon.species == id) + if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then + if Ops.setSpecies(S, S.editingMon, id) then + Ops.closeSpeciesPicker(S, Kit) + Kit.popClip() + return + end + end + local icon = rowH - 4 * s + MonEditor.drawSprite(S, Kit, id, cx + 6 * s, ry + 2 * s, icon) + local tx = cx + 6 * s + icon + 8 * s + local tail = usable and ("#%03d"):format(tonumber(def and def.dex) or 0) + or "no data" + local tailW = Kit.textWidth("tiny", tail) + Kit.text("monoRow", + Kit.ellipsize("monoRow", id, inner - (tx - cx) - tailW - 20 * s), tx, + ry + (rowH - Kit.textHeight("monoRow")) / 2, + usable and PAL.text or PAL.faint) + Kit.textRight("tiny", tail, cx + inner - 10 * s, + ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) + end + Kit.popClip() + end + + p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) +end + +return Picker