diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 89324f90..b1a7ffc8 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -49,7 +49,7 @@ local function gift(opts) end end) end - if opts.pre then say(opts.pre, "", give) else give() end + if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end end end @@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = { }, } --- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text) +-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's +-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre +-- text (#775). Like the SilphCo2F worker (#393) that label carries no +-- leading underscore, and on Red it sits outside the extractor's symbol +-- set, so the literal from text/ViridianCity.asm rides along as the +-- fallback; Yellow resolves the ROM string instead. M.VIRIDIAN_CITY = { talk = { TEXT_VIRIDIANCITY_FISHER = gift({ flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER", + pre = "ViridianCityFisherYouCanHaveThisText", + preFallback = "Yawn!\nI must have dozed\voff in the sun." + .. "\fI had this dream\nabout a DROWZEE\veating my dream." + .. "\vWhat's this?\vWhere did this TM\vcome from?" + .. "\fThis is spooky!\nHere, you can\vhave this TM.", received = "_ViridianCityFisherReceivedTM42Text", explain = "_ViridianCityFisherTM42ExplanationText", noRoom = "_ViridianCityFisherTM42NoRoomText", diff --git a/docs/launcher.md b/docs/launcher.md index 065d40d0..c9a43616 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -195,11 +195,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of (checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps `rawImport`, so this is a zero-filled template export, which is valid), and - writes `exports//gen1recomp--.sav` in the save - directory (`exports/` and `exports//` are created as needed). On - desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`), - which the notice line shows with an "Open folder" affordance - (`love.system.openURL("file://" .. dir)`). + writes `exports//gen1recomp--.sav` under the same + root `persistFs` writes slots to: the portable game folder when `portable.txt` + marks the install, otherwise the save directory (`exports/` and + `exports//` are created as needed; #752). On desktop it returns the + absolute path (`SaveData.portableBaseDir()` when portable, else + `love.filesystem.getSaveDirectory()`), which the notice line shows with an + "Open folder" affordance (`love.system.openURL("file://" .. dir)`). On Android the bytes are also staged as `pending_export.sav` and `love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the player can save to Downloads / Drive / etc.; on return `export_done.flag` diff --git a/scripts/build_android.sh b/scripts/build_android.sh index bdb535ba..d44bface 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -212,14 +212,19 @@ pack_game_love() { tools/rom_manifest_yellow.json \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -x 'data/generated/*' -x 'assets/generated/*') - if unzip -Z1 "$LOVE_FILE" \ - | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then - fail "game.love unexpectedly contains generated ROM data" - fi - # Do not pipe unzip straight into grep here: on a large archive grep can - # finish early and make unzip report SIGPIPE under `set -o pipefail`. + # List once and match against the captured text: piping unzip straight into + # grep under `set -o pipefail` SIGPIPEs unzip as soon as grep exits early, + # and the pipeline's 141 outranks grep's own status. For the generated-data + # guard that inverted the test -- an archive that really did carry generated + # ROM data made grep match, killed unzip, and the `if` read the 141 as "no + # match" and let the build through (#774). Same listing feeds the + # required-file gates below, as in scripts/build.sh and scripts/pack_love.sh. local archive_entries archive_entries="$(unzip -Z1 "$LOVE_FILE")" + if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \ + <<< "$archive_entries"; then + fail "game.love unexpectedly contains generated ROM data" + fi grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \ || fail "game.love is missing the save editor (Edit on a save row would crash)" grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \ diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6a95b79b..f5a966f4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -528,6 +528,10 @@ BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox) local function newBattle(game) local self = setmetatable({}, BattleState) self.game = game + -- InitBattleVariables (engine/battle/init_battle_variables.asm) zeroes + -- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party + -- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768 + game.partyMenuSavedIndex = nil self.data = game.data -- ruleset from the merged registry (the requires above are the same -- records on a mod-free boot); an unknown save value falls back to the @@ -644,6 +648,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + -- the object_event trainer arg (roster index). computeMusicKind keys + -- data/scripts/victories.lua on class#party, so keep it on the battle (#782). + self.partyIndex = partyIndex or 1 self.trainer = game.data.trainers[oppClass] assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName @@ -824,6 +831,16 @@ function BattleState:say(text) table.insert(self.queue, { text = text }) end +-- A message whose ROM tail is `text_end` / `done` rather than `prompt`: +-- NextTextCommand returns straight out of PrintText on TX_END +-- (home/text.asm:328-334) and only TX_PROMPT_BUTTON blinks the arrow and +-- runs ManualTextScroll (home/text.asm:434-446), so these pages never wait +-- on the player. autoDelay is the frame hold before the queue moves on +-- (0 = the next row starts immediately, as PrintText returning does) (#765). +function BattleState:sayAuto(text, delay) + table.insert(self.queue, { text = text, auto = true, autoDelay = delay or 0 }) +end + -- Message that opens YES/NO once typed out, keeping the text visible -- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice). function BattleState:sayChoice(text, onChoose) @@ -864,6 +881,13 @@ function BattleState:sayNext(text) table.insert(self.queue, self.nextInsert, { text = text }) end +-- sayNext for a page that ends in `text_end` (see sayAuto) (#765) +function BattleState:sayNextAuto(text, delay) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, + { text = text, auto = true, autoDelay = delay or 0 }) +end + -- insert a UI push right after the current queue item (dex page, the -- level-up stat box -- anything that must keep queue order) function BattleState:uiNext(factory) @@ -1014,6 +1038,8 @@ function BattleState:startMessage(item) self.charIndex = 0 self.msgWaiting = nil self.msgPrompt = nil + self.msgAutoWait = nil + self.msgHold = nil self.scrollPx = nil self:beginMsgLine() end @@ -1268,7 +1294,25 @@ function BattleState:updateQueue() end)) return true end - if not (item and item.choice) then + if item and item.auto then + -- No prompt: this page's ROM tail is `text_end`, so PrintText is + -- already back with the box still on screen -- pokered's used-move + -- line (engine/battle/used_move_text.asm EndUsedMove1Text.. + -- EndUsedMove5Text) and the item-use line (ItemUseText00, + -- engine/items/item_effects.asm) are both of that kind. Only + -- TX_PROMPT_BUTTON waits on A/B (home/text.asm:434-446) (#765). + self.msgAutoWait = self.msgAutoWait or item.autoDelay or 0 + if self.msgAutoWait > 0 then + self.msgAutoWait = self.msgAutoWait - 1 + else + self.msgAutoWait = nil + -- the typed page stays drawn behind whatever runs next (the move + -- animation, the ball toss): PrintText leaves the textbox tilemap + -- alone and animations only touch sprites (#296) + self.msgHold = true + self.current = nil + end + elseif not (item and item.choice) then -- The page is typed out and waiting on the player: PromptText -- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll -- blinks it until A/B, so the arrow belongs on a finished page and not @@ -1320,14 +1364,17 @@ end -- gets the final-battle theme function BattleState:computeMusicKind() local isBoss = false - if self.kind == "trainer" and self.trainer then + if self.kind == "trainer" and self.oppClass then + -- wGymLeaderNo is written only by the eight gym scripts + -- (scripts/PewterGym.asm .. ViridianGym.asm), so the badge rosters in + -- victories.lua are exactly the fights that set it. The lookup must + -- include the party index: a class-wide prefix match also caught + -- Giovanni's Rocket Hideout (#1) and Silph Co (#2) battles, which never + -- touch wGymLeaderNo and take MUSIC_TRAINER_BATTLE like any other + -- trainer (#782). local victories = require("data.scripts.victories") - for key, reward in pairs(victories) do - if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then - isBoss = true - break - end - end + local reward = victories[self.oppClass .. "#" .. tostring(self.partyIndex or 1)] + isBoss = reward ~= nil and reward.badge ~= nil end -- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge -- fights only -- not Lance or the Champion) bumps the companion's @@ -1575,6 +1622,9 @@ end -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() require("src.core.Sound").stopLoop("Low_Health_Alarm") + -- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the + -- field party menu comes back on slot 1 after a battle. #768 + self.game.partyMenuSavedIndex = nil -- Free this battle's own GPU objects now rather than waiting on a GC -- finalizer: the two full-screen wavy-effect canvases (colorMode) and -- the AnimPlayer's per-instance tilesheet images/quads. The shared @@ -1601,6 +1651,19 @@ local function clearTrapping(battler) battler.trapDamage = nil end +-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the +-- disassembly only names one of them: `ld hl, wBattleAndStartSavedMenuItem / +-- ld [hli], a / ld [hl], a` writes zero to that byte AND to the byte behind +-- it, which is wPlayerMoveListIndex (wram.asm:242-244). So every player +-- send-out puts the main menu back on FIGHT and the move list back on the +-- first slot; the cursors are only remembered across sub-menus of the mon +-- that is already out (#737). Enemy send-outs run EnemySendOutFirstMon, +-- not SendOutMon, and leave both alone. +local function sendOutMonCursors(self) + self.menuIndex = 1 + self.moveIndex = 1 +end + -- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move -- selection opens, but the clear is skipped for a mon that must recharge or -- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge @@ -2084,7 +2147,7 @@ function BattleState:oldManThrow() self.phase = "messages" self.afterQueue = "finish" self.result = "run" -- nothing is kept; wBattleResult only ends the demo - self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN")) + self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN")) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") -- ItemUseBall's beat before the toss chain (like throwBall) @@ -2278,6 +2341,7 @@ function BattleState:resolveSwitch(newMon) previous = previous, }) self:markParticipant() + sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) self:animNext("POOF_ANIM", false) @@ -3252,9 +3316,13 @@ end -- damaging pipeline (EffectRegistry.runDamaging). -- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only --- after they land; these failure texts print with no animation. +-- after they land; these failure texts print with no animation. Failures +-- whose text is an ordinary sentence rather than one of the shared fail +-- lines set msgs.failed instead of relying on this sniffer -- Substitute's +-- two failure lines name the move, not the failure (#644). local function primaryEffectFailed(msgs) if not msgs or #msgs == 0 then return true end + if msgs.failed then return true end local m = msgs[1] if m == "But, it failed!" or m == "Nothing happened!" then return true end if m:find("didn't affect", 1, true) then return true end @@ -3300,7 +3368,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the @@ -3796,6 +3864,7 @@ function BattleState:enemyMonFainted() self.participants = {} self:markParticipant() self.nextInsert = 0 + sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) self:animNext("POOF_ANIM", false) @@ -3988,6 +4057,7 @@ function BattleState:openReplacementMenu() }) self:markParticipant() self.nextInsert = 0 + sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) self:animNext("POOF_ANIM", false) @@ -4026,7 +4096,7 @@ function BattleState:safariAction(choice) if choice == "ball" then st.balls = st.balls - 1 - self:say(Strings("%s used\nSAFARI BALL!", playerName)) + self:sayAuto(Strings("%s used\nSAFARI BALL!", playerName)) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") self.lastBall = "SAFARI_BALL" @@ -4348,7 +4418,7 @@ function BattleState:throwBall(ball) -- " used !" line (#291). Safari and the old man demo are -- still wIsInBattle == 1, and this port models both as kind == "wild". if self.kind == "wild" then - self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name, + self:sayAuto(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name, self.data.items[ball].name)) end self:act(function() @@ -5348,7 +5418,8 @@ end function BattleState:drawTextArea() Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) - if self.phase == "messages" and (self.current or self.animPlaying) then + if self.phase == "messages" + and (self.current or self.animPlaying or self.msgHold) then -- during the move animation self.current is nil but shown still holds -- the "used X!" lines; keep drawing them like pokered, whose move -- animations only touch sprites and never the textbox tilemap (#296) diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index e9d97e6c..5d2321ef 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -259,15 +259,29 @@ MoveEffects.primary = { return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") } end, + -- substitute.asm reaches its PlayCurrentMoveAnimation / AnimationSubstitute + -- Bankswitch only inside the success branch, after `set HAS_SUBSTITUTE_UP`; + -- .alreadyHasSubstitute and .notEnoughHP fall straight through to PrintText, + -- so both failures print with no animation at all. That is load bearing + -- here: the SUBSTITUTE animation opens with SE_SLIDE_MON_OFF, which leaves + -- the user's pic hidden (BattleState.lua slideOff end state) until the doll + -- is drawn in its place -- and with no substituteHP raised there is no doll, + -- so a failed Substitute used to erase the user's sprite for the rest of the + -- battle (#644). The failed flag rides the message list so performMove can + -- peel the announcement-time anim row without matching on printed text. SUBSTITUTE_EFFECT = function(battle, user) - if user.substituteHP then return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)) } end + if user.substituteHP then + return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)), + failed = true } + end local cost = math.floor(user.mon.stats.hp / 4) -- substitute.asm only fails on subtraction underflow (current HP -- strictly below maxHP/4); at equality the substitute is built and -- the user is left standing on exactly 0 HP (it faints only when -- the engine next checks HP, not here) if user.mon.hp < cost then - return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") } + return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"), + failed = true } end user.mon.hp = user.mon.hp - cost user.substituteHP = cost + 1 diff --git a/src/core/Game.lua b/src/core/Game.lua index 97662298..9a4c85e9 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -279,6 +279,21 @@ function Game.worldBgBattleDim(stack) return nil end +-- Is a BATTLE BG "world" battle composing itself over the live map right now? +-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the +-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is +-- drawing in. InitBattleCommon (engine/battle/core.asm) pushes wMapPalOffset, +-- InitBattleVariables (engine/battle/init_battle_variables.asm) writes 0 over +-- it and core.asm pops it back when the battle ends, so a battle in an +-- un-flashed Rock Tunnel is lit on hardware. Every other BATTLE BG gets that +-- for free -- no map draws beneath an opaque battle, so nothing re-arms the +-- per-frame shade map -- but "world" keeps the overworld drawing underneath, +-- and its arming then darkened the battle's own pics, HUD and text at colorize +-- time (#773). +function Game.worldBgBattleInStack(stack) + return Game.worldBgBattleDim(stack) ~= nil +end + -- Does anything on the stack want the surface scaled to FILL the window -- (aspect preserved, bars on the long axis) rather than sit at the fixed -- integer scale? diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 342cc0b8..93bde287 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -218,14 +218,13 @@ function LauncherView.update(imp, dt) if not imp._flex then return end FlexLove.update(dt) -- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free - -- to destroy the view (Play/Edit) or block in a native picker. + -- to destroy the view (Play/Edit) or block in a native picker. The batch + -- is resolved by RomImporter:runActions so the drop/disarm rules are + -- testable without a live FlexLove tree (#780). local queue = imp._uiActions if queue and #queue > 0 then imp._uiActions = {} - for _, fn in ipairs(queue) do - local ok, err = pcall(fn) - if not ok then print("launcher action error: " .. tostring(err)) end - end + imp:runActions(queue) end end @@ -292,9 +291,12 @@ local function queueAction(imp, key, fn, keepArm) if last and now - last < ACT_DEDUP then return end imp._actAt[key] = now -- Any press that is not a Delete's own second click disarms the pending - -- delete confirm (#433's rule, preserved from the hit-rect launcher). - if not keepArm then imp._confirmDelete = nil end - imp._uiActions[#imp._uiActions + 1] = fn + -- delete confirm (#433's rule, preserved from the hit-rect launcher). The + -- disarm itself is applied by RomImporter:runActions when the batch drains, + -- not here: one touch lands on a row AND on the chip inside it, and + -- clearing the arm as the row queued left Delete stuck on its first press + -- (#780). + imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm } end local function handler(imp, key, action, keepArm) diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 3d5e8e77..88cad0fa 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1639,9 +1639,100 @@ function RomExtractor:raw1bpp(label, width, height, relative, transparent) return image end +-- Trading animation art: gfx/trade.asm TradingAnimationGraphics is one +-- 49-tile atlas (game_boy.2bpp, built with --remove-duplicates, then +-- link_cable.2bpp), and the Game Boy and open-cable plates are painted out +-- of it through the tilemaps in data/tilemaps.asm (GameBoyTiles 6x8, +-- LinkCableTiles 12x3), whose ids are absolute vChars2 ids starting at $31 +-- because trade.asm reaches them through +-- CopyTileIDsFromList_ZeroBaseTileID. Only the developer-only Python path +-- ever wrote these files, so an imported cache had none of them and +-- TradeAnim drew the whole cinematic as plain rectangles (#750). +function RomExtractor:extractTradeArt() + local BASE, COUNT = 0x31, 49 + local gfx = self:symbol("TradingAnimationGraphics") + local atlas = ImageWriter.decode2bpp( + self.rom:bytes(gfx.bank, gfx.address, COUNT * 16), COUNT * 8, 8) + local function tileX(id) + local index = id - BASE + assert(index >= 0 and index < COUNT, + ("trade tile $%02X is outside the animation atlas"):format(id)) + return index * 8 + end + local function plate(label, tilesWide, tilesHigh, relative, matte) + local map = self:symbol(label) + local ids = self.rom:bytes(map.bank, map.address, tilesWide * tilesHigh) + local image = ImageWriter.blank(tilesWide * 8, tilesHigh * 8, 1, 1, 1, 1) + for index, id in ipairs(ids) do + ImageWriter.blit(image, atlas, + (index - 1) % tilesWide * 8, + math.floor((index - 1) / tilesWide) * 8, tileX(id), 0, 8, 8) + end + if matte then image = ImageWriter.matteColor0(image) end + self:save(image, relative) + end + plate("GameBoyTiles", 6, 8, "trade/game_boy.png", true) + plate("LinkCableTiles", 12, 3, "trade/open_cable.png", false) + for _, spec in ipairs({ + { 0x5D, "cable_conn" }, { 0x5E, "cable_seg" }, { 0x5F, "cable_corner" }, + { 0x60, "cable_end" }, { 0x61, "cable_vert" }, + }) do + local tile = ImageWriter.blank(8, 8, 1, 1, 1, 1) + ImageWriter.blit(tile, atlas, 0, 0, tileX(spec[1]), 0, 8, 8) + self:save(tile, "trade/" .. spec[2] .. ".png") + end + -- Trade_DrawCableAcrossScreen fills a whole 20-tile row with tile $5e. + local horizontal = ImageWriter.blank(160, 8, 1, 1, 1, 1) + for column = 0, 19 do + ImageWriter.blit(horizontal, atlas, column * 8, 0, tileX(0x5E), 0, 8, 8) + end + self:save(horizontal, "trade/cable_horiz.png") + + -- Trade_BallInsideLinkCableOAMBlock draws one tile four times with the + -- X/Y flips, so each of the two frames -- $7e travelling, $7f bulging, + -- the bottom row of TradingAnimationGraphics2 -- makes a 16x16 ball. + local ball = self:symbol("TradingAnimationGraphics2") + local frames = ImageWriter.decode2bpp( + self.rom:bytes(ball.bank, ball.address, 64), 16, 16, true) + for index, name in ipairs({ "cable_ball", "cable_ball_alt" }) do + local image = ImageWriter.blank(16, 16, 1, 1, 1, 0) + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = frames:getPixel((index - 1) * 8 + x, 8 + y) + image:setPixel(x, y, r, g, b, a) + image:setPixel(15 - x, y, r, g, b, a) + image:setPixel(x, 15 - y, r, g, b, a) + image:setPixel(15 - x, 15 - y, r, g, b, a) + end + end + self:save(image, "trade/" .. name .. ".png") + end + -- The ring around the travelling mon: one 16x16 quadrant per animation + -- frame (engine/gfx/mon_icons.asm TradeBubbleIconGFX), mirrored into a + -- 32x32 circle by the OAM attributes in Trade_CircleOAMBlocks. + local bubble = self:symbol("TradeBubbleIconGFX") + self:write2bpp(self.rom:bytes(bubble.bank, bubble.address, 128), + 16, 32, "trade/bubble.png", true) + + return { + gameBoy = "assets/generated/trade/game_boy.png", + openCable = "assets/generated/trade/open_cable.png", + cableHoriz = "assets/generated/trade/cable_horiz.png", + cableConn = "assets/generated/trade/cable_conn.png", + cableVert = "assets/generated/trade/cable_vert.png", + cableCorner = "assets/generated/trade/cable_corner.png", + cableEnd = "assets/generated/trade/cable_end.png", + cableBall = "assets/generated/trade/cable_ball.png", + cableBallAlt = "assets/generated/trade/cable_ball_alt.png", + bubble = "assets/generated/trade/bubble.png", + source = "ROM:TradingAnimationGraphics + ROM:TradeBubbleIconGFX" + .. " (engine/movie/trade.asm InternalClockTradeAnim)", + } +end + function RomExtractor:extractField() self:beginStage("Interface artwork") - local done, total = 0, 49 + local done, total = 0, 50 local function tick() done = done + 1 self:tick("Interface artwork", math.min(done, total), total) @@ -1832,6 +1923,8 @@ function RomExtractor:extractField() end self:save(emotes, "emotes.png"); tick() + local tradeArt = self:extractTradeArt(); tick() + -- Yellow-only: the Surfing Pikachu minigame sheets -- (gfx/surfing_pikachu.asm) at pret's canvas widths, so -- src/ui/SurfingMinigame.lua's quads can be read off the source pngs. @@ -1962,6 +2055,7 @@ function RomExtractor:extractField() local converted = {} for index, values in pairs(adjacency) do converted[tonumber(index)] = values end data.hiddenExtras.trashCans.adjacent = converted + data.tradeArt = tradeArt data.source = "canonical Pokemon Red ROM + bundled port metadata" self:write("field", data) self:tick("Interface artwork", total, total) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 749b5b9f..82b04a33 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -57,6 +57,10 @@ local REQUIRED_FILES = { "assets/generated/battle/anims/move_anim_0.png", "assets/generated/battle/anims/move_anim_1.png", "assets/generated/audio/programs.bin", + -- The trade cinematic's Game Boy / cable art. Caches built before #750 + -- carry none of it and fall back to plain rectangles, so listing one of + -- the files re-imports them without a CACHE_FORMAT bump. + "assets/generated/trade/game_boy.png", } -- Files only one version's cache carries. A version that predates one of @@ -2242,6 +2246,39 @@ function RomImporter:pressDelete(kind, id, version, commit) return false end +-- Drain one frame's queued launcher actions; LauncherView.update hands the +-- batch straight over. A touch tap fires on EVERY element whose bounds hold +-- the finger, not only the topmost one: FlexLove gates its mouse path on +-- Context.findInteractiveAtPosition (libs/flexlove/modules/behaviors/ +-- Clickable.lua) but polls touches per element with a bare bounds test +-- (EventHandler:processTouchEvents), so a phone tap on a save row's Delete +-- chip also lands on the row behind it. Control keys inside a row are the +-- row's key plus "-", so a row's own action is dropped whenever a +-- control inside that row queued in the same batch, and #433's disarm runs +-- here instead of at queue time. Without both halves an Android tap on +-- Delete selected the slot and wiped the arm it had just set, so a secondary +-- slot became the loaded one and could never be deleted (#780). +function RomImporter:runActions(queue) + for i = 1, #queue do + local entry = queue[i] + local key = type(entry.key) == "string" and entry.key or "" + local superseded = false + for j = 1, #queue do + local other = queue[j] + if j ~= i and type(other.key) == "string" + and other.key:sub(1, #key + 1) == key .. "-" then + superseded = true + break + end + end + if not superseded then + if not entry.keepArm then self._confirmDelete = nil end + local ok, err = pcall(entry.fn) + if not ok then print("launcher action error: " .. tostring(err)) end + end + end +end + -- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded -- mousepressed stays inert so Android's synthesized mouse path cannot -- double-fire a tap (#553). Touch move/press/release must still reach diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 76a0ebf1..42d45014 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -6,8 +6,10 @@ -- bytes), runs them through SaveConvert.importSav (32768-byte + checksum -- validated), then registers a fresh slot, writes it, and makes it active. -- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and --- drops it in the save directory's exports// folder, returning the --- absolute path so the launcher can offer an "open folder" affordance. +-- drops it in exports// under the same root SaveData's persistFs +-- writes slots to -- the portable game folder when portable.txt marks the +-- install, otherwise the LOVE save directory (#752) -- returning the absolute +-- path so the launcher can offer an "open folder" affordance. -- -- Every failure returns false + a friendly one-line message (never raises), so -- the card can surface it as a red notice line rather than crashing. @@ -99,9 +101,10 @@ end -- exportActiveSlot(version) -> ok, pathOrErr -- Loads the version's active slot save (SaveData.load semantics), encodes it -- back to a 32768-byte SRAM image, and writes it to --- exports//gen1recomp--.sav in the save directory --- (created if absent). Returns true + the absolute path on success, false + a --- friendly message otherwise. +-- exports//gen1recomp--.sav under the portable game +-- folder when portable mode is on, otherwise the save directory (created if +-- absent). Returns true + the absolute path on success, false + a friendly +-- message otherwise. function SaveFileIO.exportActiveSlot(version) version = version or GameVersion.get() local save = SaveData.load(version) @@ -109,7 +112,14 @@ function SaveFileIO.exportActiveSlot(version) local bytes, exportErr = SaveConvert.exportSav(save, version) if not bytes then return false, exportErr end local slotId = SaveData.activeSlot(version) or "save" - local fs = love and love.filesystem + -- Portable mode is the same seam SaveData's own persistFs uses: when + -- portable.txt marks the install every persistent write leaves the OS save + -- directory for the game folder, and an export is no exception. Writing + -- through love.filesystem here dropped the .sav in AppData while the slots + -- it came from lived on the stick, and the desktop "Open folder" affordance + -- (RomImporter:exportSave) followed the returned path straight there (#752). + local portableFs = SaveData.portableFs() + local fs = portableFs or (love and love.filesystem) if not (fs and fs.write) then return false, "no filesystem available to export to" end if fs.createDirectory then fs.createDirectory("exports") @@ -119,6 +129,14 @@ function SaveFileIO.exportActiveSlot(version) local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId) local ok, writeErr = fs.write(rel, bytes) if not ok then return false, "could not write the export: " .. tostring(writeErr) end + -- Absolute path for the notice line, resolved against whichever root took + -- the write. Portable paths use the OS separator (slotDiskPath does the + -- same); LOVE save-directory paths stay "/"-joined as before. + local portableBase = SaveData.portableBaseDir() + if portableBase then + local sep = package.config:sub(1, 1) + return true, portableBase .. sep .. rel:gsub("/", sep) + end local base = fs.getSaveDirectory and fs.getSaveDirectory() or "" if base ~= "" then return true, base .. "/" .. rel end return true, rel diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 28618cb2..14da14ef 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -941,9 +941,22 @@ function Renderer:endFrame(zones, worldZones) -- reads as the foreground instead of competing with a fully lit map. Goes -- here rather than in the letterbox clear because with the world pass -- active there is no clear -- the world already covers the surface. + -- + -- The veil covers the voids ONLY, never the battle's own letterbox: "world" + -- changes what surrounds the battle and leaves the battle screen alone + -- (BattleState:bgMode). On hardware there is no "behind the battle" to dim + -- at all -- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm), + -- which blanks the whole tilemap before the battle draws. A whole-surface + -- fill was invisible only because the classic battle paints an opaque paper + -- field over it a few lines below; a render pipeline that stages the fight + -- on the map and keys that field out got the veil straight onto its + -- sprites, HP bars and HUD, which is the 55% whole-window dim of #777 + -- (and its duplicate #772). if self.battleDim and self.battleDim > 0 then love.graphics.setColor(0, 0, 0, self.battleDim) - love.graphics.rectangle("fill", 0, 0, ww, wh) + for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do + love.graphics.rectangle("fill", r[1], r[2], r[3], r[4]) + end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index dfb02eea..580381f0 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -9,8 +9,9 @@ -- CRED_TEXT_MON text appears at once, hold 110, mon wipe -- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text -- CRED_TEXT text appears at once, hold 140 --- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per --- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next +-- The mon wipe is DisplayCreditsMon: three CreditsCopyTileMapToVRAM copies +-- (9 frames of Delay3, text still up), then the middle band scrolls left 8px +-- per frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next -- CreditsMons entry crosses right-to-left as a black silhouette -- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which -- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT @@ -52,6 +53,14 @@ local HOLD_FADE = 120 local HOLD_TEXT = 140 local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame +-- DisplayCreditsMon runs three CreditsCopyTileMapToVRAM calls (vBGMap0+$c, +-- vBGMap0, vBGMap1) before the first scroll, and each one ends in `jp Delay3` +-- (home/palettes.asm), so the credits text sits still for 9 more frames on +-- every mon screen. Dropping them ran the 15 mon screens 135 frames short and +-- brought THE END up 2.2s early against a credits theme whose length is fixed +-- by the ROM program (Music_Credits is 5880 frames and does not loop), which +-- is what made the song look like it overran the roll (#703). +local MON_PREP_FRAMES = 9 -- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile -- sequences into the extracted title/copyright.png strip (tiles $60-$72: @@ -204,12 +213,17 @@ function Credits:update(dt) self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE elseif self.phase == "hold" then if self.screen.mon then - self.phase = "wipe" - self.timer = WIPE_FRAMES - self.monImg, self.monTint = self:monSprite(self.screen.mon) + -- the text stays up through DisplayCreditsMon's VRAM copies; the + -- silhouette only starts moving once ScrollCreditsMonLeft does + self.phase = "mon_prep" + self.timer = MON_PREP_FRAMES else self:nextScreen() end + elseif self.phase == "mon_prep" then + self.phase = "wipe" + self.timer = WIPE_FRAMES + self.monImg, self.monTint = self:monSprite(self.screen.mon) elseif self.phase == "wipe" then self.monImg = nil self:nextScreen() @@ -313,7 +327,8 @@ function Credits:draw() love.graphics.rectangle("fill", 0, 0, 160, 32) love.graphics.rectangle("fill", 0, 112, 160, 32) love.graphics.setColor(1, 1, 1, 1) - if self.phase == "fade" or self.phase == "hold" then + if self.phase == "fade" or self.phase == "hold" + or self.phase == "mon_prep" then self:drawPage(self.screen, 0, self.shade) elseif self.phase == "wipe" then -- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 09a95f56..3d618c72 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -155,7 +155,11 @@ local function obpIcon(path) return love.graphics.newImage(id) end -local function drawIcon(game, mon, x, y, selected, counter) +-- `forceAlt` picks the second animation frame outright, for callers with no +-- selection cursor of their own: Trade_AnimCircledMon +-- (engine/movie/trade.asm) cycles the party sprite's two frames the whole +-- time the mon rides the link cable (#750). +function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt) local icons = game.data.icons if not icons then return end local def = game.data.pokemon[mon.species] @@ -202,7 +206,7 @@ local function drawIcon(game, mon, x, y, selected, counter) end local img = iconImages[key] if not img then return end - local alt = false + local alt = forceAlt or false if selected then local px = math.floor(mon.hp * 48 / math.max(1, mon.stats.hp)) local speed = px >= 27 and 5 or px >= 10 and 16 or 32 @@ -237,13 +241,24 @@ local function drawIcon(game, mon, x, y, selected, counter) -- whatever size the file is (unchanged path) love.graphics.draw(img, x, y) end + return true end function PartyMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, PartyMenu) self.game = game - self.index = 1 + -- PartyMenuInit (home/pokemon.asm) seeds the cursor from + -- wPartyAndBillsPCSavedMenuItem rather than from zero, and + -- HandlePartyMenuInput writes wCurrentMenuItem back into it on every + -- input, so the party cursor survives closing and reopening the menu. + -- Only a battle clears it -- InitBattleVariables and end_of_battle.asm + -- both zero the byte, which BattleState mirrors. The clamp covers a + -- party that shrank (deposit / release) while the saved index was + -- pointing past the end. #768 + local count = #(opts.party or (game.save and game.save.party) or {}) + self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1), + math.max(1, count)) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly @@ -518,8 +533,10 @@ function PartyMenu:update(dt) if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or math.max(1, #party) + self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768 elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 + self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768 elseif input:wasPressed("b") then self.game.stack:pop() if self.onCancel then self.onCancel() end @@ -567,10 +584,17 @@ function PartyMenu:update(dt) { label = Strings("STATS"), action = "stats" }, { label = Strings("CANCEL"), action = "cancel" } } else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } + -- This mon's field moves FIRST, then STATS/SWITCH + -- (start_sub_menus.asm builds the same dynamic list). The order is + -- load bearing: DisplayFieldMoveMonMenu (engine/menus/text_box.asm) + -- grows the box upward one row per field move and prints the field + -- move names ABOVE PokemonMenuEntries ("STATS/SWITCH/CANCEL"), and + -- StartMenu_Pokemon .choseOutOfBattleMove indexes wFieldMoves with + -- menu items 0..n-1 while STATS/SWITCH sit at the bottom of the + -- list. GetMonFieldMoves walks wPartyMon1Moves in slot order, so + -- the field moves keep the mon's move-list order -- which the loop + -- below already does. #768 + items = {} -- Field moves (HMs/TMs) are usable out of battle even when the mon -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. -- Battle still excludes this list via `not self.battle`. Softboiled @@ -615,6 +639,10 @@ function PartyMenu:update(dt) end end end + -- PokemonMenuEntries always closes the list, under the field moves + -- (text_box.asm .donePrintingNames). #768 + items[#items + 1] = { label = Strings("STATS"), action = "stats" } + items[#items + 1] = { label = Strings("SWITCH"), action = "switch" } end local ctx = { battle = self.battle, overworld = ow } local hooked = Runtime.call("ui.party.submenu", sameItems, @@ -685,7 +713,7 @@ function PartyMenu:draw() local def = self.game.data.pokemon[mon.species] local y = PartyMenu.entryY(i) love.graphics.setColor(1, 1, 1, 1) - drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0) + PartyMenu.drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0) love.graphics.setColor(0, 0, 0, 1) Font.draw(mon.nickname or def.name, 24, y) -- level at column 13 ( tile + digits, PrintLevel) AND the diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 7dec6aef..b43ff715 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -66,14 +66,24 @@ function StartMenu.new(game) panel .. Strings("\fWould you like to\nSAVE the game?"), nil, { choice = function(yes) if not yes then return end - -- "Now saving..." beat before the write (save.asm - -- NowSavingString), then GameSavedText + SFX_SAVE + -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." + -- is a bare PlaceString held by DelayFrames 120, then GameSavedText, + -- which ends in `done` and so never reaches TX_PROMPT_BUTTON. + -- Neither page takes a button press (#765); the second waits on + -- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then + -- DelayFrames 30. The write itself is invisible either side of the + -- "Now saving..." hold, so it stays on that box's onDone. game.stack:push(TextBox.new(game, Strings("Now saving..."), function() game:writeSave() - require("src.core.Sound").play(game.data, "Save") game.stack:push(TextBox.new(game, - Strings("%s saved\nthe game!", game.save.player.name or "RED"))) - end)) + Strings("%s saved\nthe game!", game.save.player.name or "RED"), + nil, { auto = { + sound = function() + return require("src.core.Sound").play(game.data, "Save") + end, + delay = 30, + } })) + end, { auto = { delay = 120 } })) end, })) end }) diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 96659568..6d0711da 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -3,14 +3,21 @@ -- spin in the air and land flat for points; a crooked landing wipes out -- and ends the run. The scene is built from the real ROM sheets -- (gfx/surfing_pikachu.asm, ripped at import to --- assets/generated/minigame/surf_1a/1b.png): the scalloped water tiles, --- the beach with the palm and the doll hut, the "HP:" score strip with --- the sheet digits, the cloud, and the OAM Pikachu poses -- the air --- tricks quantize to the sheet's rotation frames like the original's --- sprite anims, instead of free-rotating one pose. The original drew --- the big wave with per-scanline scroll tricks (wLYOverrides); here the --- crest profile is a curve filled with the sheet's foam/shade tiles. --- Score model keeps the original's shape (ride ticks + airtime + full +-- assets/generated/minigame/surf_1a/1b.png). +-- +-- #726: the background is the original's own metatile scroller, not a +-- procedural stand-in. SurfingPikachu1Graphics1 is copied to vChars2 +-- with LCDC's BG char base unset, so BG tile id N is simply tile N of +-- surf_1a (5 tiles per row). SurfingMinigame_ScrollAndGenerateBGMap +-- walks a jumptable of wave states, each of which hands back one +-- 8-metatile column (2x2 tiles each, so 16px wide by the 128px the BG +-- shows above the HP window) plus the two Pikachu ride heights for that +-- column. Porting those tables verbatim is what makes the water read as +-- water: the earlier stand-in tiled the wave-face tiles ($02/$07) over +-- the whole sea and drew the swell as a LOVE ellipse, which is the +-- "messed up graphics" in the report. +-- +-- Score model keeps the port's shape (ride ticks + airtime + full -- rotations); high score persists in save.surfingHighScore for the -- beach-house printer. @@ -23,10 +30,13 @@ local SurfingMinigame = {} SurfingMinigame.__index = SurfingMinigame SurfingMinigame.isOpaque = true -local PIKA_X = 44 -- fixed screen x while riding -local RUN_DISTANCE = 3200 -- scroll px from paddle-out to the beach +-- SURFING_MINIGAME_CENTER_X/FLAT_WATER_Y (surfing_pikachu.asm:1-2) are OAM +-- coordinates; screen x/y are those minus OAM_X_OFS/OAM_Y_OFS. +local FLAT_WATER_Y = 116 +local PIKA_X = 68 -- fixed screen x while riding (center 80, 24px pose) +local RUN_DISTANCE = 3072 -- 24 sections of 8 metatile columns local GRAVITY = 0.14 -local HORIZON = 24 -- sea starts under the sky strip +local BG_HEIGHT = 128 -- rows the BG shows; the HP window covers the rest -- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24) local B = { @@ -50,18 +60,158 @@ local POSES = { [315] = { 0, 0, 24, 24 }, -- tail down } --- surf_1a quads (BG tiles) -local A = { - scallop = { 16, 0, 8, 8 }, -- open-water pattern, row A - scallop2 = { 16, 8, 8, 8 }, -- row B variant - shade = { 8, 16, 8, 8 }, -- gray dither, wave belly - lip = { 24, 0, 8, 8 }, -- foam curl for the crest edge - palm = { 8, 32, 8, 8 }, -- palm fronds - beach = { 24, 32, 16, 8 }, -- black shore silhouette - hut = { 8, 40, 16, 8 }, -- the Pikachu doll hut on the sand - hp = { 20, 40, 20, 8 }, -- "HP:" score label +-- surf_1a is 5 tiles wide, so BG tile id N lives at (N%5*8, N/5*8). The +-- only quad the scene needs by hand is the window's "HP:" label, which +-- straddles a tile boundary in the sheet. +local HP_LABEL = { 20, 40, 20, 8 } + +-- SurfingMinigame_BGMetatileTable (surfing_pikachu.asm): 2x2 tiles each, +-- stored top-left, top-right, bottom-left, bottom-right. +local BG_METATILES = { + [0x00] = { 0x00, 0x00, 0x00, 0x00 }, -- sky block (blank) + [0x01] = { 0x0b, 0x0b, 0x0b, 0x0b }, -- open water + [0x02] = { 0x0b, 0x02, 0x02, 0x06 }, + [0x03] = { 0x03, 0x0b, 0x07, 0x03 }, + [0x04] = { 0x06, 0x06, 0x06, 0x06 }, + [0x05] = { 0x07, 0x07, 0x07, 0x07 }, + [0x06] = { 0x06, 0x04, 0x04, 0x08 }, + [0x07] = { 0x05, 0x07, 0x08, 0x05 }, + [0x08] = { 0x0b, 0x0b, 0x11, 0x12 }, + [0x09] = { 0x0b, 0x0b, 0x13, 0x03 }, + [0x0a] = { 0x14, 0x12, 0x04, 0x08 }, + [0x0b] = { 0x13, 0x07, 0x08, 0x05 }, + [0x0c] = { 0x06, 0x14, 0x06, 0x14 }, -- unused, identical to 11 + [0x0d] = { 0x13, 0x07, 0x13, 0x07 }, + [0x0e] = { 0x08, 0x08, 0x08, 0x08 }, -- solid blue + [0x0f] = { 0x14, 0x12, 0x14, 0x12 }, + [0x10] = { 0x0b, 0x11, 0x02, 0x14 }, + [0x11] = { 0x06, 0x14, 0x06, 0x14 }, + [0x12] = { 0x0c, 0x0c, 0x0d, 0x0d }, -- beach top block + [0x13] = { 0x0d, 0x0d, 0x0d, 0x0d }, -- beach sand block + [0x14] = { 0x0e, 0x0f, 0x10, 0x0b }, -- beach shore block + [0x15] = { 0x12, 0x13, 0x12, 0x13 }, } +-- SurfingMinigameWavePattern00..1C plus SurfingMinigameBeachPattern: one +-- column of 8 metatiles, top to bottom. +local WAVE_PATTERNS = { + [0x00] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01 }, + [0x01] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 }, + [0x02] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e }, + [0x03] = { 0x00, 0x00, 0x00, 0x10, 0x11, 0x06, 0x0e, 0x0e }, + [0x04] = { 0x00, 0x00, 0x00, 0x15, 0x15, 0x0e, 0x0e, 0x0e }, + [0x05] = { 0x00, 0x00, 0x00, 0x03, 0x05, 0x07, 0x0e, 0x0e }, + [0x06] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e }, + [0x07] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 }, + [0x08] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 }, + [0x09] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e }, + [0x0a] = { 0x00, 0x00, 0x00, 0x08, 0x0f, 0x0a, 0x0e, 0x0e }, + [0x0b] = { 0x00, 0x00, 0x00, 0x09, 0x0d, 0x0b, 0x0e, 0x0e }, + [0x0c] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e }, + [0x0d] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 }, + [0x0e] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 }, + [0x0f] = { 0x00, 0x00, 0x00, 0x01, 0x10, 0x11, 0x06, 0x0e }, + [0x10] = { 0x00, 0x00, 0x00, 0x01, 0x15, 0x15, 0x0e, 0x0e }, + [0x11] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e }, + [0x12] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 }, + [0x13] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 }, + [0x14] = { 0x00, 0x00, 0x00, 0x01, 0x08, 0x0f, 0x0a, 0x0e }, + [0x15] = { 0x00, 0x00, 0x00, 0x01, 0x09, 0x0d, 0x0b, 0x0e }, + [0x16] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 }, + [0x17] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x10, 0x11, 0x06 }, + [0x18] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x15, 0x15, 0x0e }, + [0x19] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 }, + [0x1a] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x08, 0x0f, 0x0a }, + [0x1b] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x09, 0x0d, 0x0b }, + [0x1c] = { 0x00, 0x00, 0x00, 0x14, 0x14, 0x14, 0x14, 0x14 }, + beach = { 0x00, 0x00, 0x00, 0x12, 0x13, 0x13, 0x13, 0x13 }, +} + +-- RunSurfingMinigameRoutine's .WaveFunctions jumptable, flattened: +-- { pattern, left ride height, right ride height, what to do next }. +-- next: 0 = advance one state, 1 = reset to the chooser, 2 = stay put. +-- State 0 is SurfingMinigame_ChooseNextWaveSequence and is handled in +-- code because it rolls Random and forces the Big Kahuna near the goal. +local ADV, RESET, STAY = 0, 1, 2 +local WAVE_STEPS = { + [0x01] = { 0x13, 116, 108, ADV }, [0x02] = { 0x14, 100, 92, ADV }, + [0x03] = { 0x15, 92, 92, ADV }, [0x04] = { 0x16, 100, 108, ADV }, + [0x05] = { 0x00, 116, 116, ADV }, [0x06] = { 0x17, 116, 108, ADV }, + [0x07] = { 0x18, 100, 100, ADV }, [0x08] = { 0x19, 100, 108, ADV }, + [0x09] = { 0x00, 116, 116, ADV }, [0x0a] = { 0x00, 116, 116, ADV }, + [0x0b] = { 0x00, 116, 116, ADV }, [0x0c] = { 0x00, 116, 116, ADV }, + [0x0d] = { 0x00, 116, 116, RESET }, + [0x0e] = { 0x08, 116, 108, ADV }, [0x0f] = { 0x09, 100, 92, ADV }, + [0x10] = { 0x0a, 84, 76, ADV }, [0x11] = { 0x0b, 76, 76, ADV }, + [0x12] = { 0x0c, 84, 92, ADV }, [0x13] = { 0x0d, 100, 108, ADV }, + [0x14] = { 0x00, 116, 116, ADV }, [0x15] = { 0x00, 116, 116, ADV }, + [0x16] = { 0x00, 116, 116, ADV }, [0x17] = { 0x00, 116, 116, ADV }, + [0x18] = { 0x00, 116, 116, ADV }, [0x19] = { 0x00, 116, 116, RESET }, + [0x1a] = { 0x0e, 116, 108, ADV }, [0x1b] = { 0x0f, 100, 92, ADV }, + [0x1c] = { 0x10, 84, 84, ADV }, [0x1d] = { 0x11, 84, 92, ADV }, + [0x1e] = { 0x12, 100, 108, ADV }, [0x1f] = { 0x0e, 116, 108, ADV }, + [0x20] = { 0x0f, 100, 92, ADV }, [0x21] = { 0x10, 84, 84, ADV }, + [0x22] = { 0x11, 84, 92, ADV }, [0x23] = { 0x12, 100, 108, ADV }, + [0x24] = { 0x00, 116, 116, ADV }, [0x25] = { 0x00, 116, 116, ADV }, + [0x26] = { 0x00, 116, 116, ADV }, [0x27] = { 0x00, 116, 116, ADV }, + [0x28] = { 0x00, 116, 116, RESET }, + [0x29] = { 0x13, 116, 108, ADV }, [0x2a] = { 0x14, 100, 92, ADV }, + [0x2b] = { 0x15, 92, 92, ADV }, [0x2c] = { 0x16, 100, 108, ADV }, + [0x2d] = { 0x00, 116, 116, ADV }, [0x2e] = { 0x00, 116, 116, ADV }, + [0x2f] = { 0x00, 116, 116, ADV }, [0x30] = { 0x00, 116, 116, ADV }, + [0x31] = { 0x00, 116, 116, RESET }, + [0x32] = { 0x17, 116, 108, ADV }, [0x33] = { 0x18, 100, 100, ADV }, + [0x34] = { 0x19, 100, 108, ADV }, [0x35] = { 0x17, 116, 108, ADV }, + [0x36] = { 0x18, 100, 100, ADV }, [0x37] = { 0x19, 100, 108, ADV }, + [0x38] = { 0x17, 116, 108, ADV }, [0x39] = { 0x18, 100, 100, ADV }, + [0x3a] = { 0x19, 100, 108, ADV }, [0x3b] = { 0x00, 116, 116, ADV }, + [0x3c] = { 0x00, 116, 116, ADV }, [0x3d] = { 0x00, 116, 116, ADV }, + [0x3e] = { 0x00, 116, 116, ADV }, [0x3f] = { 0x00, 116, 116, RESET }, + [0x40] = { 0x1a, 116, 108, ADV }, [0x41] = { 0x1b, 108, 108, ADV }, + [0x42] = { 0x0e, 116, 108, ADV }, [0x43] = { 0x0f, 100, 92, ADV }, + [0x44] = { 0x10, 84, 84, ADV }, [0x45] = { 0x11, 84, 92, ADV }, + [0x46] = { 0x12, 100, 108, ADV }, [0x47] = { 0x1a, 116, 108, ADV }, + [0x48] = { 0x1b, 108, 108, ADV }, [0x49] = { 0x00, 116, 116, ADV }, + [0x4a] = { 0x00, 116, 116, ADV }, [0x4b] = { 0x00, 116, 116, ADV }, + [0x4c] = { 0x00, 116, 116, RESET }, + [0x4d] = { 0x08, 116, 108, ADV }, [0x4e] = { 0x09, 100, 92, ADV }, + [0x4f] = { 0x0a, 84, 76, ADV }, [0x50] = { 0x0b, 76, 76, ADV }, + [0x51] = { 0x0c, 84, 92, ADV }, [0x52] = { 0x0d, 100, 108, ADV }, + [0x53] = { 0x00, 116, 116, ADV }, [0x54] = { 0x1a, 116, 108, ADV }, + [0x55] = { 0x1b, 108, 108, ADV }, [0x56] = { 0x1a, 116, 108, ADV }, + [0x57] = { 0x1b, 108, 108, ADV }, [0x58] = { 0x00, 116, 116, ADV }, + [0x59] = { 0x00, 116, 116, ADV }, [0x5a] = { 0x00, 116, 116, ADV }, + [0x5b] = { 0x00, 116, 116, RESET }, + [0x5c] = { 0x0e, 116, 108, ADV }, [0x5d] = { 0x0f, 100, 92, ADV }, + [0x5e] = { 0x10, 84, 84, ADV }, [0x5f] = { 0x11, 84, 92, ADV }, + [0x60] = { 0x12, 100, 108, ADV }, [0x61] = { 0x13, 116, 108, ADV }, + [0x62] = { 0x14, 100, 92, ADV }, [0x63] = { 0x15, 92, 92, ADV }, + [0x64] = { 0x16, 100, 108, ADV }, [0x65] = { 0x00, 116, 116, ADV }, + [0x66] = { 0x00, 116, 116, ADV }, [0x67] = { 0x00, 116, 116, ADV }, + [0x68] = { 0x00, 116, 116, ADV }, [0x69] = { 0x00, 116, 116, RESET }, + -- 6a..71: the forced "Big Kahuna" finale; 71 holds flat water (its + -- loader just rets, so the state never advances on its own). + [0x6a] = { 0x01, 116, 108, ADV }, [0x6b] = { 0x02, 100, 92, ADV }, + [0x6c] = { 0x03, 84, 76, ADV }, [0x6d] = { 0x04, 68, 68, ADV }, + [0x6e] = { 0x05, 68, 76, ADV }, [0x6f] = { 0x06, 84, 92, ADV }, + [0x70] = { 0x07, 100, 108, ADV }, [0x71] = { 0x00, 116, 116, STAY }, + -- 72..7b: the run-out to the beach, entered by hand at the goal + -- (SurfingMinigame_WaitToShowResults writes $72). + [0x72] = { 0x00, 116, 116, ADV }, [0x73] = { 0x1c, 116, 116, ADV }, + [0x74] = { "beach", 116, 116, ADV }, [0x75] = { "beach", 116, 116, ADV }, + [0x76] = { "beach", 116, 116, ADV }, [0x77] = { "beach", 116, 116, ADV }, + [0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV }, + [0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET }, +} +-- SurfingMinigame_WaveSequenceStarts +local SEQ_STARTS = { 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c } + +-- the #726 table-integrity check in tests/drivers reads these; nothing +-- else should +SurfingMinigame.BG_METATILES = BG_METATILES +SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS +SurfingMinigame.WAVE_STEPS = WAVE_STEPS + -- SGB-style zones: one sea palette over the frame plus a yellow -- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute -- blocks are all the SGB could do, bleed and all) @@ -91,6 +241,17 @@ function SurfingMinigame.new(game, onDone) self.resultShown = 0 self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no.. + -- SurfingPikachuMinigame_LoadGFXAndLayout prefills the BG with flat + -- water and only starts generating $a0 pixels (ten metatile columns) + -- ahead of the viewport, so the run opens on calm sea. + self.waveFn = 0 + self.cols = {} + for c = 0, 10 do + self.cols[c] = { pat = WAVE_PATTERNS[0x00], + hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + end + self.colTail = 10 + local function sheet(path) local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil @@ -98,8 +259,12 @@ function SurfingMinigame.new(game, onDone) self.bg = sheet("assets/generated/minigame/surf_1a.png") self.ob = sheet("assets/generated/minigame/surf_1b.png") if self.bg then - self.aq = {} - for k, spec in pairs(A) do self.aq[k] = newQuad(spec, self.bg) end + self.tq = {} + for n = 0, 64 do + self.tq[n] = love.graphics.newQuad((n % 5) * 8, math.floor(n / 5) * 8, + 8, 8, self.bg:getDimensions()) + end + self.hpq = newQuad(HP_LABEL, self.bg) end if self.ob then self.bq = {} @@ -122,11 +287,53 @@ function SurfingMinigame.new(game, onDone) return self end --- crest height at screen x for the current scroll (two sines so the --- wave rolls instead of looping visibly) +-- SurfingMinigame_ChooseNextWaveSequence: past section $16 the finale is +-- forced, otherwise a nonzero Random picks one of eight sequence starts. +-- Either way this column itself is flat water. +function SurfingMinigame:chooseSequence() + if math.floor(self.distance / 128) >= 0x16 then + self.waveFn = 0x6a + else + local r = math.random(0, 255) + if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end + end + return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y +end + +-- one 16px metatile column, appended on the right as the sea scrolls +function SurfingMinigame:pushColumn() + local pat, hl, hr + if self.waveFn == 0 then + pat, hl, hr = self:chooseSequence() + else + local step = WAVE_STEPS[self.waveFn] + if not step then + self.waveFn = 0 + pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y + else + pat, hl, hr = WAVE_PATTERNS[step[1]], step[2], step[3] + if step[4] == ADV then self.waveFn = self.waveFn + 1 + elseif step[4] == RESET then self.waveFn = 0 end + end + end + self.colTail = self.colTail + 1 + self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr } + self.cols[self.colTail - 24] = nil -- columns behind the viewport +end + +-- keep the generated columns covering the viewport plus the lookahead +function SurfingMinigame:generateAhead() + while self.colTail * 16 < self.distance + 176 do self:pushColumn() end +end + +-- Pikachu's screen y for a screen x, from the per-tile-column ride +-- heights the wave states hand back (SurfingMinigame_SetPikachuHeight +-- samples the same array either side of the scroll's low bit). function SurfingMinigame:seaY(x) - local s = self.distance + x - return 92 - 14 * math.sin(s / 26) - 6 * math.sin(s / 9.5) + local tile = math.floor((self.distance + x) / 8) + local col = self.cols[math.floor(tile / 2)] + if not col then return FLAT_WATER_Y - 16 end + return (tile % 2 == 0 and col.hl or col.hr) - 16 end function SurfingMinigame:finishRun() @@ -146,6 +353,13 @@ function SurfingMinigame:update() if self.banner.frames <= 0 then self.banner = nil end end if self.phase == "results" then + -- the sea keeps sliding under the card for a beat so the beach + -- run-out (states $72..$7b) actually crosses the screen, like + -- SurfingMinigame_WaitToShowResults scrolling to the sand + if self.resultShown < 150 then + self.distance = self.distance + 1.2 + self:generateAhead() + end self.resultShown = self.resultShown + 1 if self.resultShown > 30 and (input:wasPressed("a") or input:wasPressed("b")) then @@ -162,9 +376,13 @@ function SurfingMinigame:update() -- the wave scrolls by the current speed; the beach ends the run self.distance = self.distance + 0.8 + self.speed * 0.35 + self:generateAhead() if self.distance >= RUN_DISTANCE then -- rode it all the way in: distance bonus like the original's goal self.score = self.score + 500 + -- SurfingMinigame_WaitToShowResults hands the generator state $72 so + -- the sand runs out under the coast-in + self.waveFn = 0x72 self:finishRun() return end @@ -218,16 +436,11 @@ function SurfingMinigame:update() end end --- draw one 8x8 sheet tile quad at x, y -function SurfingMinigame:tile(q, x, y) - love.graphics.draw(self.bg, self.aq[q], x, y) -end - function SurfingMinigame:sgbPalettes() local P = require("src.render.PaletteFX") local zones = { P.whole(SEA_PAL) } if self.phase ~= "wipeout" and self.phase ~= "results" then - local tx = math.floor((PIKA_X - 12) / 8) + local tx = math.floor(PIKA_X / 8) local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8) zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3) end @@ -242,87 +455,52 @@ function SurfingMinigame:drawScore(x, y, n) end end +-- the BG map: metatile columns from the wave generator, scrolled by +-- distance (SurfingMinigame_ScrollAndGenerateBGMap) +function SurfingMinigame:drawBackground() + local scx = math.floor(self.distance) + local first = math.floor(scx / 16) + for c = first, first + 10 do + local col = self.cols[c] + if col then + local x = c * 16 - scx + for i = 1, 8 do + local mt = BG_METATILES[col.pat[i]] + if mt then + local y = (i - 1) * 16 + love.graphics.draw(self.bg, self.tq[mt[1]], x, y) + love.graphics.draw(self.bg, self.tq[mt[2]], x + 8, y) + love.graphics.draw(self.bg, self.tq[mt[3]], x, y + 8) + love.graphics.draw(self.bg, self.tq[mt[4]], x + 8, y + 8) + end + end + end + end +end + function SurfingMinigame:draw() local haveSheets = self.bg and self.ob - -- sky love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) if not haveSheets then -- cache predates the surf sheets: plain shapes keep it playable love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("SCORE %d", self.score), 4, 4) - love.graphics.rectangle("fill", PIKA_X - 8, - self:seaY(PIKA_X) - 16 - self.y, 16, 16) + love.graphics.rectangle("fill", PIKA_X, self:seaY(PIKA_X) - self.y, 16, 16) love.graphics.setColor(1, 1, 1, 1) return end + self:drawBackground() + -- cloud in the sky strip love.graphics.draw(self.ob, self.bq.cloud, 112, 8) - -- open water: the scalloped pattern tiles the whole sea, phase-locked - -- to the scroll so the surface slides - local shift = math.floor(self.distance) % 8 - for ty = HORIZON, 136, 8 do - local alt = (ty / 8) % 2 == 0 - for tx = -8, 160, 8 do - self:tile(alt and "scallop" or "scallop2", tx - shift, ty) - end - end - - -- the wave face: a white patch hugging the ride line (the original - -- carved it with per-scanline scroll; the ellipse stands in), with a - -- few scallops floating inside and the foam lip along its upper edge - local faceY = self:seaY(56) + 10 - love.graphics.setColor(1, 1, 1, 1) - love.graphics.ellipse("fill", 56, faceY, 46, 30) - love.graphics.ellipse("fill", 100, faceY + 16, 40, 22) - for _, spot in ipairs({ { 30, 8 }, { 70, 16 }, { 48, 22 } }) do - self:tile("scallop", 56 - 46 + spot[1] - shift, faceY - 24 + spot[2]) - end - local pikaY = self:seaY(PIKA_X) - 20 - self.y - for a = 205, 335, 18 do - local r = math.rad(a) - local lx = 56 + math.cos(r) * 44 - 4 - local ly = faceY + math.sin(r) * 28 - 4 - -- foam that would land inside Pikachu's SGB zone comes out orange; - -- leave that patch to the spray ellipse instead - if math.abs(lx - PIKA_X) > 28 or math.abs(ly - (pikaY + 12)) > 26 then - self:tile("lip", lx, ly) - end - end - self:tile("shade", 92 - shift, faceY + 20) - self:tile("shade", 116 - shift, faceY + 24) - - -- beach slides through at the start and again before the goal - local beachX - if self.distance < 160 then - beachX = -self.distance - elseif self.distance > RUN_DISTANCE - 200 then - beachX = 160 - (self.distance - (RUN_DISTANCE - 200)) - end - if beachX then - for tx = 0, 32, 8 do - self:tile("beach", beachX + tx, 128) - self:tile("beach", beachX + tx, 136) - end - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", beachX + 9, 118, 2, 10) - love.graphics.setColor(1, 1, 1, 1) - self:tile("palm", beachX + 6, 112) - self:tile("hut", beachX + 20, 118) - end - - -- Pikachu. The white spray patch under him doubles as the yellow SGB - -- zone's backdrop: shade 0 maps to white in both palettes, so the - -- attribute-block bleed never shows on the water pattern. - love.graphics.setColor(1, 1, 1, 1) - local py = self:seaY(PIKA_X) - 20 - self.y + -- Pikachu rides at the height his tile column reports + local py = self:seaY(PIKA_X) - self.y self.pikaScreenY = py -- the yellow SGB zone tracks this - love.graphics.ellipse("fill", PIKA_X, py + 12, 25, 21) if self.phase == "wipeout" then - love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 16, - self:seaY(PIKA_X) - 16) + love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 4, py) else local quad if self.phase == "ride" and self.speed <= 2 @@ -332,7 +510,7 @@ function SurfingMinigame:draw() local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45 quad = self.bq.poses[bucket] or self.bq.poses[0] end - love.graphics.draw(self.ob, quad, PIKA_X - 12, py) + love.graphics.draw(self.ob, quad, PIKA_X, py) end -- banner beats: GOOD! / YEAH- / Oh no.. @@ -340,11 +518,12 @@ function SurfingMinigame:draw() love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40) end - -- score strip, bottom right: HP: + sheet digits + -- the HP window sits under the BG rows ($7e into hWY puts it at y 126; + -- tile-aligned here): "HP:" plus the sheet digits over plain white love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 100, 134, 60, 10) - love.graphics.draw(self.bg, self.aq.hp, 102, 135) - self:drawScore(126, 135, self.score) + love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 144 - BG_HEIGHT) + love.graphics.draw(self.bg, self.hpq, 8, BG_HEIGHT + 4) + self:drawScore(32, BG_HEIGHT + 4, self.score) if self.phase == "results" then love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 423c2d0f..791f7ebe 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -10,8 +10,16 @@ local TradeAnim = {} TradeAnim.__index = TradeAnim TradeAnim.isOpaque = true +-- Trade_LoadMonSprite runs SET_PAL_POKEMON_WHOLE_SCREEN for the mon it puts +-- on screen; every other step of the sequence runs SET_PAL_GENERIC, which is +-- PAL_MEWMON (data/sgb/sgb_packets.asm PalPacket_Generic). #750 function TradeAnim:sgbPalettes(game) - return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") + local P = require("src.render.PaletteFX") + local mon = (self.phase == "show_player" and self.sent) + or (self.phase == "show_enemy" and self.received) + local colors = mon and P.monPal(game.data, mon.species) + if colors then return { P.whole(colors) } end + return P.wholeNamed(game.data, "MEWMON") end local DEFAULT_ART = { @@ -350,17 +358,17 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) love.graphics.setColor(1, 1, 1, 1) end -function TradeAnim:drawIconInBubble(sprite, x, y) - local spr = sprite - if spr then - local sw, sh = spr:getDimensions() - local s = 16 / math.max(sw, sh) - love.graphics.draw(spr, x, y, 0, s, s) - else - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", x + 4, y + 4, 8, 8) - love.graphics.setColor(1, 1, 1, 1) - end +-- Trade_WriteCircledMonOAM: the mon crosses the cable as its party-menu +-- sprite (wMonPartySpriteSpecies -> WriteMonPartySpriteOAMBySpecies), not as +-- its battle pic, and Trade_AnimCircledMon flips both it and the ring to +-- their second frame every step. The ring is four OAM blocks -- +-- Trade_CircleOAMBlocks .OAMBlock0-3 at (8,8) (24,8) (8,24) (24,24) with the +-- X/Y flips -- so the 16x32 bubble sheet holds one quadrant per frame and the +-- circle it makes is 32x32 around the 16x16 icon. The icon rides OAM +-- block 0 and the circle blocks 1-4 (Trade_WriteCircleOAMBlock counts a up +-- from 1), and the lower OAM index wins overlap on DMG, so the icon draws +-- on top of the circle's filled interior. #750 +function TradeAnim:drawIconInBubble(mon, x, y) if self.img.bubble then if not self.bubbleQuad then local iw, ih = self.img.bubble:getDimensions() @@ -370,7 +378,19 @@ function TradeAnim:drawIconInBubble(sprite, x, y) or self.bubbleQuad end local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad - love.graphics.draw(self.img.bubble, q, x - 8, y - 8) + local left, top = x - 8, y - 8 + local right, bottom = left + 32, top + 32 + love.graphics.draw(self.img.bubble, q, left, top) + love.graphics.draw(self.img.bubble, q, right, top, 0, -1, 1) + love.graphics.draw(self.img.bubble, q, left, bottom, 0, 1, -1) + love.graphics.draw(self.img.bubble, q, right, bottom, 0, -1, -1) + end + local drawn = mon and require("src.ui.PartyMenu").drawIcon( + self.game, mon, x, y, false, 0, self.cableFlash) + if not drawn then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", x + 4, y + 4, 8, 8) + love.graphics.setColor(1, 1, 1, 1) end end @@ -471,13 +491,8 @@ function TradeAnim:draw() love.graphics.translate(160, 0) self:drawRightGB() love.graphics.pop() - local sprite - if p == "transfer_lr" then - sprite = self.sentSprite - else - sprite = self.recvSprite - end - self:drawIconInBubble(sprite, self.monX, self.monY) + local mon = p == "transfer_lr" and self.sent or self.received + self:drawIconInBubble(mon, self.monX, self.monY) if self.cableFlash then love.graphics.setColor(1, 1, 1, 0.15) love.graphics.rectangle("fill", 0, 32, 160, 8) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 086140fd..98c9ca9c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2915,6 +2915,34 @@ function OverworldState:trainerDefeated(npc) return false end +-- data/trainers/encounter_types.asm +local FEMALE_TRAINERS = { + OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true, + OPP_COOLTRAINER_F = true, +} +local EVIL_TRAINERS = { + OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true, + OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true, + OPP_GIOVANNI = true, OPP_ROCKET = true, +} + +-- PlayTrainerMusic (home/trainers.asm:399) picks the encounter sting from +-- the engaged class: evil list, then female list, then male by default. +-- The rivals `ret z` out of it and keep the MUSIC_MEET_RIVAL their own +-- scripts start (data/scripts/oaks_lab.lua, story5.lua). Its other gate, +-- wGymLeaderNo, is not a leader test: that byte aliases wLoneAttackNo +-- (ram/wram.asm:1264), is cleared on every map entry +-- (engine/overworld/clear_variables.asm:8), and each gym script writes it +-- only AFTER its own `call EngageMapTrainer` (scripts/PewterGym.asm:122), +-- so leaders do get the sting and nothing on a map can be suppressed by it +-- before the leader is beaten. Returns nil when the class gets no sting. +local function meetTrainerTheme(cls) + if not cls or cls:find("RIVAL") then return nil end + return EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer" + or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer" + or "Music_MeetMaleTrainer" +end + -- Run the pre-battle text -> battle -> won text -> flags sequence. function OverworldState:engageTrainer(npc, onDone) local d = npc.def @@ -2930,6 +2958,20 @@ function OverworldState:engageTrainer(npc, onDone) local BattleState = require("src.battle.BattleState") Game.stack:push(TextBox.new(Game, battleText, function() + -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text + -- FIRST and only then runs `call EngageMapTrainer` / `jp + -- StartTrainerBattle`, so a trainer challenged on foot gets the sting + -- over the battle transition rather than under the dialogue. Its + -- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging + -- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already + -- started the sting before the "!" bubble on the sight path, so it + -- must not restart. Script-driven challenges (gyms.lua leaders, + -- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122) + -- all `call EngageMapTrainer` too, and reach this same path (#764). + if not self.engaging then + local theme = meetTrainerTheme(d.trainerClass) + if theme then require("src.core.Music").play(Game.data, theme) end + end local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle @@ -3101,29 +3143,15 @@ function OverworldState:checkTrainerSight() end end --- data/trainers/encounter_types.asm -local FEMALE_TRAINERS = { - OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true, - OPP_COOLTRAINER_F = true, -} -local EVIL_TRAINERS = { - OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true, - OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true, - OPP_GIOVANNI = true, OPP_ROCKET = true, -} - function OverworldState:startTrainerApproach(npc, dist) self.engaging = true npc.frozen = true - -- the encounter sting (PlayTrainerMusic): evil / female / male by - -- class; rivals and gym leaders keep their own music - local cls = npc.def.trainerClass - if cls and not cls:find("RIVAL") then - local theme = EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer" - or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer" - or "Music_MeetMaleTrainer" - require("src.core.Music").play(Game.data, theme) - end + -- TrainerEngage (engine/overworld/trainer_sight.asm:224) sets + -- BIT_SEEN_BY_TRAINER and calls EngageMapTrainer before the "!" bubble, + -- so the sighting sting starts ahead of the walk-up; engageTrainer sees + -- self.engaging and does not restart it (#764) + local theme = meetTrainerTheme(npc.def.trainerClass) + if theme then require("src.core.Music").play(Game.data, theme) end local function fight() self:engageTrainer(npc, function() npc.frozen = false @@ -4228,7 +4256,19 @@ function OverworldState:drawWorld() -- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which -- draws with no map beneath it -- stays lit exactly like -- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original. - PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil) + -- + -- BATTLE BG "world" is the one case where a map DOES draw in a battle's + -- frame (Game.drawBaseInStack), and the shift armed here reached the + -- battle's own colorize pass, so an un-flashed Rock Tunnel battle came out + -- with FadePal2 over its pics, HUD and text (#773). The battle zeroes + -- wMapPalOffset for its whole run and restores it on the way out + -- (engine/battle/core.asm InitBattleCommon push/pop), so the map behind it + -- goes lit too for as long as the battle is up -- which is what the + -- original's saved offset means. + local battleOverWorld = Game and Game.stack + and Game.worldBgBattleInStack(Game.stack) + PaletteFX.setShadeMap((self.dark and not battleOverWorld) + and PaletteFX.DARK_BGP or nil) -- advance the water/flower tile animation (runs under dialogs too). -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). diff --git a/tests/drivers/battle_bg_world_dim_bug777_test.lua b/tests/drivers/battle_bg_world_dim_bug777_test.lua new file mode 100644 index 00000000..d1cd14ee --- /dev/null +++ b/tests/drivers/battle_bg_world_dim_bug777_test.lua @@ -0,0 +1,184 @@ +-- Eye check: BATTLE BG = WORLD dims the surround only, never the battle's own +-- 160x144 field (#777, dup #772). Renderer:endFrame used to fill the WHOLE +-- window with the 55% veil; the classic battle hid that under its opaque paper +-- field, but a pipeline that stages the fight on the map and keys the field +-- out (the Dramatic Shape Voxel Mod) got the veil straight onto its sprites +-- and HP boxes. On hardware there is nothing behind the battle to dim at all: +-- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm) over the whole +-- tilemap before the battle draws. +-- POKEPORT_DRIVER=tests/drivers/battle_bg_world_dim_bug777_test.lua POKEPORT_IDENTITY=bug777 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- POKEPORT_TOUCH=0 matters: the on-screen controls draw after endFrame and +-- would land in the void samples. No POKEPORT_SPEED around the shots. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Game = require("src.core.Game") + local Renderer = require("src.render.Renderer") + 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 + + -- Load a captured PNG back as ImageData. love.image cannot read absolute + -- paths, so go through io.open + newFileData. + local function loadShot(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData( + love.filesystem.newFileData(bytes, "shot.png")) + end) + return ok and img or nil + end + + -- Mean brightness of a pixel rect. Whole-region averages, so NPC steps and + -- flower/water animation between two shots wash out instead of flipping a + -- single-pixel compare. + local function regionMean(img, x, y, w, h) + local sum, n = 0, 0 + local x2 = math.min(x + w, img:getWidth()) - 1 + local y2 = math.min(y + h, img:getHeight()) - 1 + for yy = math.max(y, 0), y2 do + for xx = math.max(x, 0), x2 do + local r, g, b = img:getPixel(xx, yy) + sum = sum + (r + g + b) / 3 + n = n + 1 + end + end + return n > 0 and sum / n or 0, n + end + + -- The UI letterbox in framebuffer pixels, the same math endFrame uses for + -- uox/uoy/uvpw/uvph (screenshots are framebuffer-sized, so no dpi divide). + local function uiBox(img) + local pw, ph = img:getWidth(), img:getHeight() + local uiw, uih = Renderer:uiSize() + local Up = Renderer:uiScale() + local bw, bh = math.floor(uiw * Up + 0.5), math.floor(uih * Up + 0.5) + local bx = math.floor((pw - bw) / 2) + local by = math.floor((ph - bh) / 2) + return bx, by, bw, bh, pw, ph + end + + game.save.options = game.save.options or {} + game.save.options.battleFit = nil -- classic letterbox, the geometry under test + + 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(30) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + + -- ---- machine half: veil geometry, no battle needed ----------------------- + -- In the overworld the UI canvas is transparent over the map, so the veil + -- is not hidden by an opaque field the way the classic battle hides it. + -- Arm the dim through the real per-frame wiring (Game:draw reads + -- Game.worldBgBattleDim every frame) and compare against an unarmed shot: + -- the surround must darken, the letterbox interior must not. + local baseShot = DIR .. "/bug777_0_overworld.png" + local veilShot = DIR .. "/bug777_0_overworld_veiled.png" + U.shot(game, baseShot) + local realDim = Game.worldBgBattleDim + Game.worldBgBattleDim = function() return BattleState.BG_WORLD_DIM end + U.wait(2) + U.shot(game, veilShot) + Game.worldBgBattleDim = realDim + U.wait(2) + + local base, veil = loadShot(baseShot), loadShot(veilShot) + if check("both overworld shots decoded", base ~= nil and veil ~= nil) then + local bx, by, bw, bh, pw = uiBox(base) + local inBase = regionMean(base, bx, by, bw, bh) + local inVeil = regionMean(veil, bx, by, bw, bh) + U.log((" letterbox %dx%d at (%d, %d), interior mean %.3f -> %.3f") + :format(bw, bh, bx, by, inBase, inVeil)) + -- 0.75 sits between "unchanged" and the 0.55 veil's 0.45x; whole-box + -- means make a frame of tile animation worth far less than that gap. + check("the veil leaves the battle box alone (#777)", + inVeil >= inBase * 0.75) + if bx >= 8 then + local outBase = regionMean(base, 0, 0, bx, base:getHeight()) + local outVeil = regionMean(veil, 0, 0, bx, veil:getHeight()) + U.log((" left void strip mean %.3f -> %.3f"):format(outBase, outVeil)) + check("the veil still dims the surround", outVeil <= outBase * 0.75) + else + U.log(" window has no side voids at this scale, surround check skipped") + end + end + + -- ---- the real battle, all three BATTLE BG modes -------------------------- + 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") + + -- bgMode reads save.options.battleBg per frame, so one battle covers all + -- three modes; the menu is idle between shots. + local shots = {} + for _, mode in ipairs({ "white", "black", "world" }) do + game.save.options.battleBg = mode + U.wait(5) + local path = DIR .. "/bug777_" .. mode .. ".png" + U.shot(game, path) + shots[mode] = loadShot(path) + end + + if check("all three battle shots decoded", + shots.white ~= nil and shots.black ~= nil and shots.world ~= nil) then + local bx, by, bw, bh = uiBox(shots.white) + local inWhite = regionMean(shots.white, bx, by, bw, bh) + local inWorld = regionMean(shots.world, bx, by, bw, bh) + U.log((" battle box mean, WHITE %.3f vs WORLD %.3f"):format(inWhite, inWorld)) + check("WORLD leaves the battle's own screen at WHITE's brightness", + math.abs(inWhite - inWorld) < 0.02) + if bx >= 8 then + local outWhite = regionMean(shots.white, 0, 0, bx, shots.white:getHeight()) + local outWorld = regionMean(shots.world, 0, 0, bx, shots.world:getHeight()) + U.log((" void strip mean, WHITE %.3f vs WORLD %.3f"):format(outWhite, outWorld)) + check("WORLD's surround is the dimmed map, not paper", + outWorld < outWhite - 0.05) + end + end + + -- ---- over to you --------------------------------------------------------- + U.log("You are at the battle menu with BATTLE BG = WORLD: the frozen Route 1") + U.log("map sits around the battle at 55% brightness. Put bug777_white.png and") + U.log("bug777_world.png side by side: inside the battle box they must match") + U.log("exactly, same paper, same pics, same HP bars; only the frame around it") + U.log("changes. #777 dimmed the whole window instead, which with a mod that") + U.log("stages the fight on the map dropped the veil straight over the sprites") + U.log("and health boxes. The near miss to look for is the opposite failure:") + U.log("a surround that is NOT dimmed at all, which means the veil got lost") + U.log("rather than scoped.") + U.log("Shots: " .. DIR .. "/bug777_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/battle_music_bug782_test.lua b/tests/drivers/battle_music_bug782_test.lua new file mode 100644 index 00000000..b4ef78e0 --- /dev/null +++ b/tests/drivers/battle_music_bug782_test.lua @@ -0,0 +1,111 @@ +-- Manual check that Silph Co Giovanni gets the ordinary trainer theme (#782). +-- PlayBattleMusic (audio/play_battle_music.asm) only picks +-- MUSIC_GYM_LEADER_BATTLE when wGymLeaderNo is set, and scripts/SilphCo11F.asm +-- never writes it -- only the eight gym scripts do. The port keyed the boss +-- check on the trainer class alone, so this fight (OPP_GIOVANNI#2) borrowed +-- the Viridian Gym roster's theme, victory jingle, and Pikachu happiness bump. +-- The data half is asserted in tests/parity_battle_music_bug782.lua. +-- POKEPORT_DRIVER=tests/drivers/battle_music_bug782_test.lua POKEPORT_IDENTITY=bug782 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Music = require("src.core.Music") + local BattleState = require("src.battle.BattleState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local vol = game.save.options and game.save.options.musicVol + if vol == 0 then + U.log("music volume is 0 in options; raise it or nothing will be audible") + end + + -- record what the engine asks the music system for; the real playback + -- still happens underneath, so the listening half is unaffected + local played = {} + local realPlayBattle = Music.playBattle + Music.playBattle = function(data, kind, trainerId) + played[#played + 1] = { call = "battle", kind = kind } + return realPlayBattle(data, kind, trainerId) + end + local realPlayVictory = Music.playVictory + Music.playVictory = function(data, kind, trainerId) + played[#played + 1] = { call = "victory", kind = kind } + return realPlayVictory(data, kind, trainerId) + end + + -- a party that can win this quickly, so the victory jingle is reachable + game.save.party = { + Pokemon.new(game.data, "MEWTWO", 90), + Pokemon.new(game.data, "CHARIZARD", 80), + } + game.save.player.name = "RED" + + -- Giovanni's fight is a coordinate trigger, not a talk: + -- SilphCo11FDefaultScript (pokered scripts/SilphCo11F.asm + -- .PlayerCoordsArray) fires on (6,13) or (7,12), walks him three tiles + -- down from his object_event spot at (6,9) + -- (pokered data/maps/objects/SilphCo11F.asm), shows his speech and starts + -- the battle. The (6,13) pad sits behind the 11F card key door, which this + -- teleported-in save has not opened (no CARD KEY, so tryCardKeyDoor never + -- swaps the door block), so take the pad on the open side: stand on (6,12) + -- and step east onto (7,12). + U.teleport(game, "SILPH_CO_11F", 6, 12, "right") + U.wait(10) + U.hold(game, "right", 40) + U.wait(10) + + -- Giovanni's approach, then his pre-battle text box: mash A until the + -- battle state is on top of the stack + local battle + for _ = 1, 400 do + local top = game.stack:top() + if getmetatable(top) == BattleState and top.kind == "trainer" then + battle = top + break + end + U.tap(game, "a") + U.wait(3) + end + + check("the coordinate trigger engaged a trainer battle", battle ~= nil) + if battle then + check("the opponent is Giovanni (OPP_GIOVANNI#2)", + battle.oppClass == "OPP_GIOVANNI" and battle.partyIndex == 2) + check("musicKind is \"trainer\", not \"gym\"", + battle.musicKind == "trainer") + check("isGymLeader is unset (no Pikachu GYMLEADER happiness bump)", + not battle.isGymLeader) + local battleCall + for _, p in ipairs(played) do + if p.call == "battle" then battleCall = p.kind end + end + check("Music.playBattle was asked for the trainer theme", + battleCall == "trainer") + end + + U.log("You are in the Silph Co Giovanni fight. The theme playing now") + U.log("should be the ordinary Vs. Trainer battle music, not the gym-leader") + U.log("theme this fight used to borrow. Win it (MEWTWO 90 vs his level") + U.log("~40 party) and the jingle at \"defeated GIOVANNI\" should be the") + U.log("plain trainer victory fanfare, again not the gym-leader one.") + U.log("For the correct-by-contrast case, the Viridian Gym rematch") + U.log("(OPP_GIOVANNI#3) still keeps the gym-leader theme.") + + -- report the victory request when the win lands, then keep idling + local reported = false + while true do + if not reported then + for _, p in ipairs(played) do + if p.call == "victory" then + check("Music.playVictory was asked for the trainer jingle", + p.kind == "trainer") + reported = true + end + end + end + coroutine.yield() + end +end diff --git a/tests/drivers/credits_overhang_bug703_test.lua b/tests/drivers/credits_overhang_bug703_test.lua new file mode 100644 index 00000000..c2644dae --- /dev/null +++ b/tests/drivers/credits_overhang_bug703_test.lua @@ -0,0 +1,95 @@ +-- Real-time check of the credits roll vs Music_Credits (#703). +-- The song is a fixed 5880-frame program (audio/music/credits.asm: tempo 140, +-- no loop), so on hardware it outlasts THE END by ~12s. Our roll ran 135 +-- frames short of pokered because Credits:update skipped DisplayCreditsMon's +-- three CreditsCopyTileMapToVRAM calls (each `jp Delay3`, 9 frames per mon +-- screen, 15 mon screens); that stretched the overhang to ~14.3s and made the +-- music look too fast. This driver plays the whole roll in real time (~98s, +-- do NOT set POKEPORT_SPEED: the ear half needs the real clock), counts the +-- fixed frames itself, and leaves the screen on THE END with the theme still +-- going so a listener can judge the tail. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/credits_overhang_bug703_test.lua POKEPORT_IDENTITY=bug703 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + if os.getenv("POKEPORT_SPEED") then + U.log("warning: POKEPORT_SPEED is set; the listening half of this run", + "is meaningless at any speed but 1") + end + local opts = game.save and game.save.options + if opts and opts.musicVolume == 0 then + U.log("warning: options music volume is 0, nothing will be audible") + end + + -- the roll ends in an autosave; put the user's save back afterwards + local prevSave = love.filesystem.read("save.lua") + + U.teleport(game, "HALL_OF_FAME", 4, 2, "right") + game.save.party = { { species = "PIKACHU", level = 81 } } + game.overworld.runner:run({ { "record_hall_of_fame" } }) + U.wait(2) + + -- A through the induction until the credits state is on top; the full + -- hall-of-fame walk takes well over 60 taps, so give it real room + local Credits = require("src.ui.Credits") + local credits + for _ = 1, 2000 do + local top = game.stack:top() + if getmetatable(top) == Credits then credits = top break end + U.tap(game, "a") + U.wait(2) + end + if not check("credits state reached", credits ~= nil) then + while true do coroutine.yield() end + end + + -- Frame accounting, one fixed step per sample. From the frame Music_Credits + -- starts (phase leaves "white") pokered reaches the end of THE END's fade at + -- 128 + 35 screens + 16 + 20 = 5154 frames; the 15 mon screens each spend + -- 9 frames in mon_prep (the Delay3 x3) before their 27-frame wipe. + -- no screenshots inside this loop: U.shot yields extra fixed steps of its + -- own and would silently skew the count + local musicStart, theEndAt, prepFrames = nil, nil, 0 + for f = 1, 7000 do + U.wait(1) + local phase = credits.phase + if not musicStart and phase ~= "white" then musicStart = f end + if phase == "mon_prep" then prepFrames = prepFrames + 1 end + if phase == "end_hold" then theEndAt = f break end + end + + check("mon_prep ran 9 frames on each of the 15 mon screens (135 total)", + prepFrames == 135) + check("THE END finishes fading 5154 frames after the music starts", + musicStart ~= nil and theEndAt ~= nil + and theEndAt - musicStart == 5154) + U.log("music started at driver frame", musicStart, + "THE END done at", theEndAt, "mon_prep frames", prepFrames) + U.shot(game, DIR .. "/bug703_the_end.png") + + -- Music_Credits is 5880 frames long, so from here the theme has + -- 5880 - 5154 = 726 frames (~12.1s) left. That overhang is authentic: + -- the original does the same on hardware, and this fix only removed the + -- extra 2.2s our shortened roll had added on top of it. + U.log("listen: the theme should keep playing about 12 seconds past this") + U.wait(726) + U.log("the song should be ending right about now; silence after this", + "point is correct, the program has no loop") + U.wait(120) + + if prevSave then + love.filesystem.write("save.lua", prevSave) + else + love.filesystem.remove("save.lua") + end + U.log("done; screen stays on THE END (A or B would soft-reset)") + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/dig_bug196_test.lua b/tests/drivers/dig_bug196_test.lua index 8bd6cf60..5bad0d6e 100644 --- a/tests/drivers/dig_bug196_test.lua +++ b/tests/drivers/dig_bug196_test.lua @@ -68,14 +68,10 @@ return function(game) U.wait(4) -- open the party menu and pick DIG on slot 1 - -- (submenu order for a DIG-only mon: STATS / SWITCH / DIG) + -- (submenu order for a DIG-only mon: DIG / STATS / SWITCH -- #768) Screens.push(game, "PartyMenu") U.wait(5) - U.tap(game, "a") -- open the per-mon submenu - U.wait(2) - U.tap(game, "down") -- STATS -> SWITCH - U.wait(2) - U.tap(game, "down") -- SWITCH -> DIG + U.tap(game, "a") -- open the per-mon submenu, cursor on DIG U.wait(2) U.tap(game, "a") -- choose DIG U.wait(2) diff --git a/tests/drivers/fly_indigo_bug203_test.lua b/tests/drivers/fly_indigo_bug203_test.lua index ba4b79c1..3988227c 100644 --- a/tests/drivers/fly_indigo_bug203_test.lua +++ b/tests/drivers/fly_indigo_bug203_test.lua @@ -38,14 +38,11 @@ return function(game) U.teleport(game, "PALLET_TOWN", 10, 8, "down") U.wait(5) - -- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY) + -- open the party menu and pick FLY on slot 1 (submenu: FLY / STATS / SWITCH, + -- field moves on top like start_sub_menus.asm -- #768) Screens.push(game, "PartyMenu") U.wait(5) - U.tap(game, "a") -- open the per-mon submenu - U.wait(2) - U.tap(game, "down") -- STATS -> SWITCH - U.wait(2) - U.tap(game, "down") -- SWITCH -> FLY + U.tap(game, "a") -- open the per-mon submenu, cursor on FLY U.wait(2) U.tap(game, "a") -- choose FLY U.wait(5) diff --git a/tests/drivers/fly_townmap_bug195_test.lua b/tests/drivers/fly_townmap_bug195_test.lua index 1c12b228..cbbf7b92 100644 --- a/tests/drivers/fly_townmap_bug195_test.lua +++ b/tests/drivers/fly_townmap_bug195_test.lua @@ -30,14 +30,11 @@ return function(game) U.teleport(game, "PALLET_TOWN", 10, 8, "down") U.wait(5) - -- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY) + -- open the party menu and pick FLY on slot 1 (submenu: FLY / STATS / SWITCH, + -- field moves on top like start_sub_menus.asm -- #768) Screens.push(game, "PartyMenu") U.wait(5) - U.tap(game, "a") -- open the per-mon submenu - U.wait(2) - U.tap(game, "down") -- STATS -> SWITCH - U.wait(2) - U.tap(game, "down") -- SWITCH -> FLY + U.tap(game, "a") -- open the per-mon submenu, cursor on FLY U.wait(2) U.tap(game, "a") -- choose FLY U.wait(5) diff --git a/tests/drivers/party_cursor_bug768_test.lua b/tests/drivers/party_cursor_bug768_test.lua new file mode 100644 index 00000000..b39e39a7 --- /dev/null +++ b/tests/drivers/party_cursor_bug768_test.lua @@ -0,0 +1,76 @@ +-- Manual check for #768: the field party menu keeps its cursor across +-- close/reopen (wPartyAndBillsPCSavedMenuItem: PartyMenuInit reads it, +-- HandlePartyMenuInput writes it back, only a battle zeroes it via +-- InitBattleVariables / end_of_battle.asm), and the per-mon submenu lists +-- field moves ABOVE STATS/SWITCH (DisplayFieldMoveMonMenu prints the move +-- names above PokemonMenuEntries, engine/menus/text_box.asm). +-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug768_test.lua POKEPORT_IDENTITY=bug768 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Slot 1 knows FLY (movesAtLevel never grants it, so inject it, same as + -- the #203 driver); the badge gates the submenu entry. + local flyer = Pokemon.new(game.data, "PIDGEOT", 40) + flyer.moves[1] = { id = "FLY", pp = 15 } + game.save.party = { + flyer, + Pokemon.new(game.data, "PIKACHU", 30), + Pokemon.new(game.data, "SNORLAX", 77), + } + game.save.player.name = "bryan" + game.save.inventory = game.save.inventory or {} + game.save.inventory.THUNDERBADGE = true + + -- Pallet Town is OVERWORLD, so FLY is listed (CheckIfInOutsideMap) + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + -- half 1: the submenu puts the field move on top + Screens.push(game, "PartyMenu") + U.wait(5) + local pm = game.stack:top() + U.tap(game, "a") -- open the per-mon submenu on the FLY mon + U.wait(2) + local items = pm.subItems or {} + check("submenu row 1 is FLY, not STATS", + items[1] ~= nil and items[1].action == "fly") + check("STATS/SWITCH close the list under the field move", + #items == 3 and items[2].action == "stats" + and items[3].action == "switch") + U.shot(game, DIR .. "/bug768_submenu.png") + U.tap(game, "b") -- back out of the submenu + U.wait(2) + + -- half 2: the cursor survives closing and reopening the menu + U.tap(game, "down") + U.wait(2) + U.tap(game, "down") + U.wait(2) + check("cursor moved to slot 3", pm.index == 3) + U.tap(game, "b") -- close the party menu entirely + U.wait(5) + Screens.push(game, "PartyMenu") + U.wait(5) + local pm2 = game.stack:top() + check("reopened menu is still on slot 3 (SNORLAX)", + pm2 ~= pm and pm2.index == 3) + U.shot(game, DIR .. "/bug768_reopened.png") + + U.log("The party menu on screen was just reopened; the cursor should sit") + U.log("on slot 3 (SNORLAX), not slot 1. A on slot 1 shows FLY above") + U.log("STATS/SWITCH. Input is yours now: walk north into the Route 1") + U.log("grass, win or run from a wild battle, then reopen the party menu --") + U.log("the cursor should be back on slot 1 (the battle cleared it).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rock_tunnel_dark_battle_bug773_test.lua b/tests/drivers/rock_tunnel_dark_battle_bug773_test.lua new file mode 100644 index 00000000..27a18663 --- /dev/null +++ b/tests/drivers/rock_tunnel_dark_battle_bug773_test.lua @@ -0,0 +1,84 @@ +-- Driver: BATTLE BG "world" plus an un-flashed Rock Tunnel put a dark map in +-- the same frame as the battle, and OverworldState:drawWorld's rBGP shift then +-- coloured the battle itself (#773). On hardware InitBattleCommon +-- (engine/battle/core.asm) pushes wMapPalOffset, InitBattleVariables zeroes it +-- and core.asm pops it back after EndOfBattle, so the battle is lit. +-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_battle_bug773_test.lua \ +-- POKEPORT_IDENTITY=bug773 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Probe = dofile("tests/drivers/shot_probe.lua") + local PaletteFX = require("src.render.PaletteFX") + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(ok, msg) + U.log(ok and "PASS" or "FAIL", msg) + if not ok then fails = fails + 1 end + return ok + end + + game.save.flags.EVENT_GOT_STARTER = true + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 20)) + end + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.options.colors = "gbc" + game.save.options.battleBg = "world" + PaletteFX.setMode("gbc") + + local CAVE = PaletteFX.pal(game.data, "CAVE") + local caveDark = PaletteFX.permute(CAVE, PaletteFX.DARK_BGP) + + -- data/maps/objects/RockTunnel1F.asm: the Route 10 entrance warp is 15, 3, + -- so two cells south of it is floor that does not re-trigger the warp. + game.save.flashLit = nil + U.teleport(game, "ROCK_TUNNEL_1F", 15, 5, "down") + U.wait(20) + local ow = game.overworld + check(ow ~= nil and ow.dark == true, "standing in an un-flashed ROCK_TUNNEL_1F") + U.shot(game, DIR .. "/bug773_1_dark_map.png") + check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP, + "the map frame really is drawn with DARK_BGP armed") + + local ok = pcall(function() + local battle = BattleState.newWild(game, "ZUBAT", 15) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + end) + if not ok then + U.log("WARN could not force a wild battle; nothing to judge") + while true do coroutine.yield() end + end + + U.wait(120) -- through the transition wipe and the intro slide-in + U.shot(game, DIR .. "/bug773_2_battle_world_bg.png") + check(PaletteFX.shadeMap() == nil, + "no shade map is armed while the world-bg battle draws (#773)") + + -- The battle keeps its own 160x144 field in the middle of the window; the + -- dimmed map only fills the surround, so probe the centre. + local CENTRE = { 0.4, 0.4, 0.6, 0.6 } + local shot = Probe.grab() + if shot then + local c = Probe.count(shot, { litPaper = CAVE[1], darkPaper = caveDark[1] }, + 3, CENTRE) + check(c.litPaper > 0, + "the battle screen keeps its paper white -- it is not FadePal2'd") + local top = Probe.top(shot, 5, 3, CENTRE) + U.log("battle centre top colours:", Probe.fmt(top)) + else + U.log("WARN pixel probe unavailable; judge the shots by eye") + end + + U.log(fails == 0 and "#773 checks passed" or (fails .. " #773 check(s) FAILED")) + U.log("Look at " .. DIR .. "/bug773_2_battle_world_bg.png: the battle screen") + U.log("should read exactly like any other battle -- white paper, normal HUD") + U.log("and pic colours -- with the dimmed tunnel only in the surround.") + U.log("The separate uniform dim of the world backdrop is #777, not this.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/surfing_bg_bug726_test.lua b/tests/drivers/surfing_bg_bug726_test.lua new file mode 100644 index 00000000..2fd8eaec --- /dev/null +++ b/tests/drivers/surfing_bg_bug726_test.lua @@ -0,0 +1,148 @@ +-- Manual check that the Surfing Pikachu minigame background is the +-- ROM's metatile scroller, not the old procedural stand-in (#726). +-- The stand-in tiled wave-face foam tiles ($02/$07) over the whole sea +-- and drew the swell as two LOVE ellipses, which read as zigzag noise +-- with white blobs. The fix ports SurfingMinigame_BGMetatileTable, the +-- WavePattern columns and the .WaveFunctions state table from +-- ../pokeyellow/engine/minigame/surfing_pikachu.asm; this driver +-- machine-checks the transcribed tables and the ride heights, then +-- parks a human in front of the water for the part only eyes can judge. +-- Needs a Yellow cache in the active identity (a sandboxed +-- POKEPORT_IDENTITY starts empty and would sit on the launcher). +-- SHOT_DIR=/tmp/shots POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/surfing_bg_bug726_test.lua 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 SurfingMinigame = require("src.ui.SurfingMinigame") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- table integrity first, it needs no ROM state at all. surf_1a is + -- ripped as 65 tiles (src/import/RomExtractor.lua), so every tile id + -- a metatile names must be 0..64, every pattern entry must name a + -- metatile, and every wave state must name a pattern with plausible + -- ride heights (the asm range is FLAT_WATER_Y $74 down to -6 tiles). + local ok = true + for id, mt in pairs(SurfingMinigame.BG_METATILES) do + for i = 1, 4 do + if type(mt[i]) ~= "number" or mt[i] < 0 or mt[i] > 64 then + U.log("metatile", id, "slot", i, "has bad tile id", tostring(mt[i])) + ok = false + end + end + end + check("every metatile tile id lands inside the 65-tile surf_1a sheet", ok) + + ok = true + for id, pat in pairs(SurfingMinigame.WAVE_PATTERNS) do + if #pat ~= 8 then ok = false U.log("pattern", id, "is not 8 rows") end + for i = 1, 8 do + if not SurfingMinigame.BG_METATILES[pat[i]] then + U.log("pattern", id, "row", i, "names missing metatile", + tostring(pat[i])) + ok = false + end + end + end + check("every wave pattern row names a real metatile", ok) + + ok = true + for id, step in pairs(SurfingMinigame.WAVE_STEPS) do + if not SurfingMinigame.WAVE_PATTERNS[step[1]] then + U.log("wave state", id, "names missing pattern", tostring(step[1])) + ok = false + end + for i = 2, 3 do + if step[i] < 116 - 6 * 8 or step[i] > 116 then + U.log("wave state", id, "ride height", step[i], "out of range") + ok = false + end + end + end + check("every wave state names a real pattern with sane ride heights", ok) + + -- pokeyellow data/maps/objects/SummerBeachHouse.asm: the Surfin' Dude + -- is object_event 2, 3, so (2, 2) facing down talks to him. He only + -- offers the run to a party Pikachu that knows SURF. + game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) } + game.save.party[1].moves = { { id = "SURF", pp = 15 } } + U.teleport(game, "SUMMER_BEACH_HOUSE", 2, 2, "down") + U.wait(5) + + -- mash A through the pitch and the YES into the game itself + local mg + for _ = 1, 300 do + local top = game.stack:top() + if top and top.seaY then mg = top break end + U.tap(game, "a") + U.wait(4) + end + check("the minigame opened", mg ~= nil) + if not mg then + U.log("could not reach the minigame; nothing more to show") + while true do coroutine.yield() end + end + + -- the run opens on prefilled flat water: every visible column should + -- be the open-water pattern, whose bottom rows are metatile $01 + -- (tile $0b everywhere), and Pikachu should sit on the flat waterline + ok = true + for c = 0, 10 do + local col = mg.cols[c] + if not (col and col.pat[8] == 0x01 and col.hl == 116) then ok = false end + end + check("the opening sea is flat open water, pattern 00 all the way", ok) + check("Pikachu's ride line starts on the flat waterline", + mg:seaY(68) == 100) + U.shot(game, DIR .. "/bug726_1_flat.png") + + -- paddle up and ride until the generator has rolled some swells; the + -- chooser leaves flat water only on a nonzero roll, so give it room + for _ = 1, 8 do U.tap(game, "a") U.wait(3) end + local sawSwell, seaTracks = false, true + for _ = 1, 1500 do + if mg.phase ~= "ride" and mg.phase ~= "air" then break end + for c, col in pairs(mg.cols) do + if col.hl < 116 then sawSwell = true end + end + -- the ride height must always come from the column under Pikachu + local tile = math.floor((mg.distance + 68) / 8) + local col = mg.cols[math.floor(tile / 2)] + if col then + local want = (tile % 2 == 0 and col.hl or col.hr) - 16 + if mg:seaY(68) ~= want then seaTracks = false end + end + if sawSwell and mg.distance > 400 then break end + U.wait(1) + if (U.frame() % 5) == 0 then U.tap(game, "a") end + end + check("the wave generator produced swells (ride heights above flat)", + sawSwell) + check("seaY always follows the generated column heights", seaTracks) + U.shot(game, DIR .. "/bug726_2_swell.png") + + -- one jump for the air shot, then hand it over + if mg.phase == "ride" then + U.tap(game, "up") + U.hold(game, "right", 20) + U.shot(game, DIR .. "/bug726_3_air.png") + end + + U.log("shots in", DIR, "- bug726_1_flat, bug726_2_swell, bug726_3_air") + U.log("What to look for: the open sea is the flat speckled water tile,") + U.log("not a diagonal zigzag field; no white ellipse blobs and no loose") + U.log("blue squares floating on the surface; swells build from the left,") + U.log("crest with foam and flatten out again; Pikachu sits on the wave") + U.log("at every point of the swell; the HP strip sits in the bottom band") + U.log("over unbroken white. The game is still live: keep riding to the") + U.log("goal and the sand should slide in under the coast-in before the") + U.log("results card.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/switch_cursor_bug737_test.lua b/tests/drivers/switch_cursor_bug737_test.lua new file mode 100644 index 00000000..f3a509fe --- /dev/null +++ b/tests/drivers/switch_cursor_bug737_test.lua @@ -0,0 +1,86 @@ +-- Manual check that a voluntary switch resets both battle cursors (#737). +-- SendOutMon (pokered engine/battle/core.asm:1733-1735) zeroes +-- wBattleAndStartSavedMenuItem and, via the same hli/hl pair, the +-- wPlayerMoveListIndex byte behind it (wram.asm:242-244), so after any +-- player send-out the main menu reopens on FIGHT and the move list on +-- slot 1. The port used to keep both cursors where they were. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/switch_cursor_bug737_test.lua 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") + + -- CHARMANDER at 12 knows Scratch/Growl/Ember, so the move cursor can be + -- parked on slot 3 before the switch + game.save.party = { + Pokemon.new(game.data, "CHARMANDER", 12), + Pokemon.new(game.data, "SQUIRTLE", 10), + } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "PIDGEY", 4) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function mashUntil(cond, max) + for _ = 1, max or 120 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + check("intro drains to the battle menu", + mashUntil(function() return battle.phase == "menu" end)) + + -- open FIGHT and park the cursor on move slot 3, then back out + U.tap(game, "a"); U.wait(6) + U.tap(game, "down"); U.wait(4) + U.tap(game, "down"); U.wait(4) + check("move cursor parked on slot 3", battle.moveIndex == 3) + U.tap(game, "b"); U.wait(6) + + -- FIGHT/PKMN/ITEM/RUN: right to PKMN, A opens the party + U.tap(game, "right"); U.wait(4) + check("battle menu parked on PKMN", battle.menuIndex == 2) + U.tap(game, "a"); U.wait(12) + local pm = game.stack:top() + check("party menu opened", pm ~= nil and pm.onSwitch ~= nil) + + -- pick SQUIRTLE, then SWITCH from the SWITCH/STATS/CANCEL submenu + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(8) + U.tap(game, "a"); U.wait(8) + + -- the switch queues "Come back!" / "Go!" plus the enemy's free move; + -- drain back to the next command menu + check("switch turn drains back to the menu", + mashUntil(function() return battle.phase == "menu" end, 300)) + check("player is now SQUIRTLE", battle.player.mon.species == "SQUIRTLE") + + -- the machine-checkable half of #737 + check("battle menu is back on FIGHT", battle.menuIndex == 1) + check("move cursor is back on slot 1", battle.moveIndex == 1) + + U.shot(game, DIR .. "/bug737_menu_after_switch.png") + U.tap(game, "a"); U.wait(8) + U.shot(game, DIR .. "/bug737_moves_after_switch.png") + U.log("captured", DIR .. "/bug737_menu_after_switch.png", + "and", DIR .. "/bug737_moves_after_switch.png") + + U.log("The fight menu on screen is SQUIRTLE's, opened right after the") + U.log("switch. The cursor should sit on the first move; before #737 it") + U.log("kept CHARMANDER's old slot (3), and the main menu reopened on PKMN.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/text_advance_bug765_test.lua b/tests/drivers/text_advance_bug765_test.lua new file mode 100644 index 00000000..566a9e2c --- /dev/null +++ b/tests/drivers/text_advance_bug765_test.lua @@ -0,0 +1,161 @@ +-- Manual check for the pages that must NOT wait on a button (#765). +-- Only TX_PROMPT_BUTTON blinks the arrow and waits (home/text.asm:434-446); +-- the used-move line (engine/battle/used_move_text.asm) ends in `text_end` +-- and both save pages come from SaveMenu .save (engine/menus/save.asm:164-181), +-- where "Now saving..." is a bare PlaceString + DelayFrames 120 and +-- GameSavedText ends in `done`. Ordering is asserted headlessly in +-- tests/parity_battle_auto_text_bug765.lua; this run is for pacing. +-- No POKEPORT_SPEED: the save beat and the SFX_SAVE hold are the moment. +-- POKEPORT_DRIVER=tests/drivers/text_advance_bug765_test.lua POKEPORT_IDENTITY=bug765 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local TextBox = require("src.render.TextBox") + local Menu = require("src.ui.Menu") + local ChoiceBox = require("src.ui.ChoiceBox") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 50) } + game.save.player.name = "RED" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + -- ---- part 1: START -> SAVE -> YES, hands off the pad ------------------- + U.tap(game, "start") + U.wait(10) + local menu = game.stack:top() + check("START opened the menu", getmetatable(menu) == Menu) + if getmetatable(menu) == Menu then + -- walk the cursor onto SAVE by label: which rows exist depends on + -- story flags, so counting from the top is not stable + local target + for i, item in ipairs(menu.items) do + if tostring(item.label) == "SAVE" then target = i end + end + check("the menu lists SAVE", target ~= nil) + -- the cursor position survives closing the menu + -- (wBattleAndStartSavedMenuItem), so it may start above OR below SAVE + for _ = 1, #menu.items do + if not target or menu.index == target then break end + U.tap(game, menu.index < target and "down" or "up") + U.wait(4) + end + U.tap(game, "a") + U.wait(10) + end + + -- the player/badges/dex/time panel types out, page-breaks (\f) into the + -- confirmation, then the YES/NO box opens; A through all of it, YES last + local chose = false + for _ = 1, 30 do + local top = game.stack:top() + if getmetatable(top) == ChoiceBox then + U.tap(game, "a") + chose = true + break + end + U.tap(game, "a") + U.wait(20) + end + check("the SAVE confirmation was reached and answered YES", chose) + + -- the answered box holds 15 frames before it pops and runs the choice + -- (DisplayTwoOptionMenu, engine/menus/text_box.asm:322-334), so wait it out + for _ = 1, 60 do + if getmetatable(game.stack:top()) ~= ChoiceBox then break end + U.wait(1) + end + + -- from here NOTHING is pressed: both boxes must clear themselves + U.wait(2) + local saving = game.stack:top() + check("the Now saving... box is up", getmetatable(saving) == TextBox) + local savingPopped + for f = 1, 300 do + if game.stack:top() ~= saving then savingPopped = f break end + U.wait(1) + end + check("it held about 2s and popped with no button (DelayFrames 120)", + savingPopped ~= nil and savingPopped > 60) + local savedPopped = false + for _ = 1, 600 do + local top = game.stack:top() + if getmetatable(top) ~= TextBox and getmetatable(top) ~= Menu then + savedPopped = true + break + end + U.wait(1) + end + check("the saved-the-game box cleared itself after SFX_SAVE", savedPopped) + + -- ---- part 2: a wild battle's used-move line ---------------------------- + local wild = BattleState.newWild(game, "RATTATA", 2) + wild.onFinish = function() end + local ow = game.overworld + if ow then ow:pushBattle(wild) end + for _ = 1, 400 do + if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the wild battle reached the screen", game.stack:top() == wild) + + -- the intro page ends in `prompt` (WildMonAppearedText), so it still + -- waits; A through it and the send-out, then pick FIGHT + first move + for _ = 1, 600 do + if wild.phase == "menu" then break end + if wild.msgPrompt then U.tap(game, "a") end + U.wait(1) + end + check("the intro still holds on its arrow and A walks it to the menu", + wild.phase == "menu") + U.tap(game, "a") -- FIGHT + U.wait(10) + U.tap(game, "a") -- first move + + -- from here NOTHING is pressed: "BULBASAUR used X!" must flow straight + -- into its animation with the line still on screen and no arrow + local sawUsed, promptedOnUsed, handedOff = false, false, false + for _ = 1, 900 do + local cur = wild.current + local t = cur and cur.text + if t and t:find("used", 1, true) then + sawUsed = true + if wild.msgPrompt then promptedOnUsed = true end + elseif sawUsed then + handedOff = true + break + end + U.wait(1) + end + check("the used-move line reached the screen", sawUsed) + check("it never raised the prompt arrow", not promptedOnUsed) + check("it handed off by itself, no A pressed", handedOff) + check("the line stays drawn under the animation (msgHold)", + wild.msgHold == true or wild.animPlaying == true) + + -- ...and the pages after it still wait: a level-50 BULBASAUR one-shots a + -- level-2 RATTATA, so the faint line (BattleMonFaintedText class, ends in + -- `prompt`) comes up next and must hold on its arrow + local promptAfter = false + for _ = 1, 900 do + if wild.msgPrompt then promptAfter = true break end + U.wait(1) + end + check("the page after it still holds on the arrow", promptAfter) + + U.log("Handing off. What just happened, and what to look for on a replay:") + U.log("the save flow ran with no button after YES: \"Now saving...\" held") + U.log("about 2 seconds, then \"RED saved the game!\" played the save jingle") + U.log("and cleared itself. In the battle, \"BULBASAUR used !\" flowed") + U.log("straight into the move animation with the line still up and no") + U.log("blinking arrow; the faint line after it is waiting on A right now.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/trade_anim_bug750_test.lua b/tests/drivers/trade_anim_bug750_test.lua new file mode 100644 index 00000000..ab52cb67 --- /dev/null +++ b/tests/drivers/trade_anim_bug750_test.lua @@ -0,0 +1,152 @@ +-- Manual check that the trade cinematic draws real art, not rectangles (#750). +-- The ROM importer never wrote assets/generated/trade/*, so on a player's +-- cache every tryImage in TradeAnim.new returned nil and the whole +-- InternalClockTradeAnim sequence fell back to love.graphics.rectangle: +-- an outlined box for the Game Boy, a flat bar for the cable, a blank +-- screen during the open-cable phase. The machine half below asserts the +-- ten art files are in the cache at the sizes trade.asm implies and that +-- the running TradeAnim actually loaded them; the shots are for the human +-- half. A cache imported before this fix re-imports on launch (the +-- REQUIRED_FILES entry), so a FAIL on the file checks means the re-import +-- has not happened yet. +-- SHOT_DIR=/tmp/trade750 POKEPORT_DRIVER=tests/drivers/trade_anim_bug750_test.lua POKEPORT_IDENTITY=bug750 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/trade750" + local Pokemon = require("src.pokemon.Pokemon") + local TradeAnim = require("src.ui.TradeAnim") + local TextBox = require("src.render.TextBox") + local PartyMenu = require("src.ui.PartyMenu") + local ChoiceBox = require("src.ui.ChoiceBox") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Expected sizes: GameBoyTiles is a 6x8-tile plate and LinkCableTiles a + -- 12x3 one (pokered data/tilemaps.asm), Trade_DrawCableAcrossScreen fills + -- a 20-tile row, the ball and its bulge frame are one tile mirrored into + -- 2x2 OAM blocks, and TradeBubbleIconGFX is two 16x16 quadrant frames. + local FILES = { + { "assets/generated/trade/game_boy.png", 48, 64 }, + { "assets/generated/trade/open_cable.png", 96, 24 }, + { "assets/generated/trade/cable_horiz.png", 160, 8 }, + { "assets/generated/trade/cable_conn.png", 8, 8 }, + { "assets/generated/trade/cable_seg.png", 8, 8 }, + { "assets/generated/trade/cable_corner.png", 8, 8 }, + { "assets/generated/trade/cable_end.png", 8, 8 }, + { "assets/generated/trade/cable_vert.png", 8, 8 }, + { "assets/generated/trade/cable_ball.png", 16, 16 }, + { "assets/generated/trade/cable_ball_alt.png", 16, 16 }, + { "assets/generated/trade/bubble.png", 16, 32 }, + } + for _, spec in ipairs(FILES) do + local ok, image = pcall(love.graphics.newImage, spec[1]) + if check(spec[1] .. " is in the cache", ok and image ~= nil) then + local w, h = image:getDimensions() + check(("%s is %dx%d"):format(spec[1], spec[2], spec[3]), + w == spec[2] and h == spec[3]) + end + end + check("field.lua carries tradeArt paths", + game.data.field and game.data.field.tradeArt + and game.data.field.tradeArt.gameBoy ~= nil) + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + -- pokered data/maps/objects/VermilionTradeHouse.asm: the LITTLE_GIRL + -- (SPEAROW -> DUX FARFETCH'D) stands at (3, 5) facing up, so (3, 6) + -- facing up puts the player in front of her. + game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) } + U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up") + U.wait(5) + + U.tap(game, "a") + U.wait(10) + for _ = 1, 200 do + if topIs(ChoiceBox) then break end + U.tap(game, "a") + U.wait(2) + end + check("trade offer choice appeared", topIs(ChoiceBox)) + U.tap(game, "a") -- YES + U.wait(6) + for _ = 1, 60 do + if topIs(PartyMenu) then break end + U.wait(1) + end + check("party menu opened", topIs(PartyMenu)) + U.tap(game, "a") -- pick the SPEAROW + U.wait(6) + for _ = 1, 200 do + if topIs(TradeAnim) then break end + U.tap(game, "a") + U.wait(2) + end + local anim = game.stack:top() + if not check("TradeAnim is on the stack", getmetatable(anim) == TradeAnim) then + U.log("cannot reach the cinematic; nothing more to verify") + while true do coroutine.yield() end + end + + -- the running state loaded the art rather than falling back + check("TradeAnim loaded the Game Boy plate", anim.img.gameBoy ~= nil) + check("TradeAnim loaded the open cable plate", anim.img.openCable ~= nil) + check("TradeAnim loaded the cable ball", anim.img.cableBall ~= nil) + check("TradeAnim loaded the bubble ring", anim.img.bubble ~= nil) + + -- step the phases at real speed and shoot the moments the reporter's + -- video shows broken + local function ffUntil(phase, cap) + for _ = 1, cap or 3000 do + if anim.phase == phase or anim.phase == "done" then break end + if anim.waitingText or topIs(TextBox) then + U.wait(1) + else + anim:update(1 / 60) + end + end + U.wait(1) + end + + ffUntil("open_cable", 800) + while anim.phase == "open_cable" and anim.scx > 0 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/bug750_open_cable.png") + + ffUntil("ball_enter", 200) + while anim.phase == "ball_enter" and anim.ballX < 0x80 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/bug750_ball_enter.png") + + ffUntil("transfer_lr", 400) + for _ = 1, 24 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/bug750_transfer_lr.png") + + ffUntil("transfer_rl", 4000) + for _ = 1, 140 do + if anim.phase ~= "transfer_rl" then break end + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/bug750_transfer_rl.png") + + U.log("shots in", DIR) + U.log("open_cable should be the link cable plate with its open end, not a") + U.log("blank screen; ball_enter a small ball riding the cable; the two") + U.log("transfer shots a real Game Boy body with the cable plugged in and") + U.log("the mon's 16x16 party icon inside a round 32x32 ring -- no outlined") + U.log("rectangles, no flat gray bar, no squashed battle pic.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/trainer_fanfare_bug764_test.lua b/tests/drivers/trainer_fanfare_bug764_test.lua new file mode 100644 index 00000000..6b74ac69 --- /dev/null +++ b/tests/drivers/trainer_fanfare_bug764_test.lua @@ -0,0 +1,89 @@ +-- Manual check that challenging a trainer by talking to them plays the +-- encounter sting (#764). TalkToTrainer (pokered home/trainers.asm:88) +-- prints the before-battle text and then EngageMapTrainer -> +-- PlayTrainerMusic; the port only did that on the sight-line path, so a +-- trainer approached from the side or back went into battle in map music. +-- POKEPORT_DRIVER=tests/drivers/trainer_fanfare_bug764_test.lua POKEPORT_IDENTITY=bug764 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + + -- pokered data/maps/objects/ViridianForest.asm: YOUNGSTER2 (the first + -- Bug Catcher) stands at (30, 33) facing LEFT, so his sight line runs + -- west; the cell below him, (30, 34), is outside it and lets us talk + -- our way into the battle instead of being spotted. + local MAP = "VIRIDIAN_FOREST" + local TRAINER = "VIRIDIANFOREST_YOUNGSTER2" + local STAND = { x = 30, y = 34, facing = "up" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- record every song the engine starts; wrapping keeps real playback so + -- the human half of this check still has something to hear + local Music = require("src.core.Music") + local played = {} + local realPlay = Music.play + Music.play = function(data, song, ...) + played[#played + 1] = song + return realPlay(data, song, ...) + end + + U.newGame(game) + check("music volume is audible (save.options.musicVol)", + (game.save.options.musicVol or 0) > 0) + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(30) + + local ow = game.overworld + local npc + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.name == TRAINER then npc = n end + end + check("Bug Catcher object loaded on " .. MAP, npc ~= nil) + if npc then + check("standing on his blind side, facing him", + ow:npcAtCell(ow.player:facingCell()) == npc) + check("he did not spot us on the way in", not ow.engaging) + end + + -- talk; the sting must start only once the before-battle text closes + -- (TalkToTrainer prints first, then engages) + played = {} + U.tap(game, "a") + U.wait(30) + check("no sting while the dialogue is up", #played == 0) + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.shot(game, SHOT_DIR .. "/bug764_dialogue.png") + + -- close the text; a Bug Catcher is neither female-list nor evil-list, + -- so PlayTrainerMusic lands on the male sting. A presses both finish + -- the typewriter and turn pages, so keep tapping until the sting lands + -- or the drain gives up. + local sting + for _ = 1, 8 do + U.tap(game, "a") + U.wait(30) + for _, song in ipairs(played) do + if song:find("Music_Meet", 1, true) then sting = song end + end + if sting then break end + end + check("closing the text started an encounter sting", sting ~= nil) + check("it is the male trainer sting", sting == "Music_MeetMaleTrainer") + U.shot(game, SHOT_DIR .. "/bug764_transition.png") + U.log("songs started since the A press:", table.concat(played, ", ")) + + U.log("The Bug Catcher's line has just closed and the battle is opening.") + U.log("You should have heard the male trainer sting begin the moment the") + U.log("text box shut, carrying over the battle transition. Before #764") + U.log("the forest theme played straight through into the fight. To hear") + U.log("the sight path for comparison, lose or run, step west across his") + U.log("eyeline, and the same sting should fire once at the \"!\" bubble.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/viridian_fisher_bug775_test.lua b/tests/drivers/viridian_fisher_bug775_test.lua new file mode 100644 index 00000000..dd6acdc2 --- /dev/null +++ b/tests/drivers/viridian_fisher_bug775_test.lua @@ -0,0 +1,81 @@ +-- Manual check of the Viridian fisher's TM42 gift pre text (#775). +-- pokered ViridianCityFisherText (scripts/ViridianCity.asm) prints +-- .YouCanHaveThisText ("Yawn! I must have dozed off...") before GiveItem; +-- the port had no pre entry, so A jumped straight to "received TM42!". +-- POKEPORT_DRIVER=tests/drivers/viridian_fisher_bug775_test.lua POKEPORT_IDENTITY=bug775 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + + -- pokered data/maps/objects/ViridianCity.asm: the FISHER stays at (6, 23) + -- facing down, so stand one cell below him and look up + local MAP = "VIRIDIAN_CITY" + local STAND = { x = 6, y = 24, facing = "up" } + 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 + + U.newGame(game) + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + local TextBox = require("src.render.TextBox") + local function boxText() + 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 table.concat(lines, " / ") + end + + check("no TM42 in the bag before talking", + (game.save.inventory.TM_DREAM_EATER or 0) == 0) + + U.tap(game, "a") + U.wait(30) + + local first = boxText() + check("pressing A opened a text box", first ~= nil) + U.log("first box reads:", first or "(none)") + check("it opens on the pre text, not the receipt", + first ~= nil and first:find("Yawn!", 1, true) ~= nil) + check("the DROWZEE dream paragraph is in it", + first ~= nil and first:find("DROWZEE", 1, true) ~= nil) + check("the receipt has not fired yet", + first == nil or first:find("received", 1, true) == nil) + check("the flag is still unset mid pre text", + not game.save.flags.EVENT_GOT_TM42) + U.shot(game, SHOT_DIR .. "/bug775_pre.png") + + -- type out and dismiss every page of the pre text, then the receipt and + -- the explanation behind it + for _ = 1, 40 do + if not boxText() then break end + U.tap(game, "a") + U.wait(15) + end + check("TM42 reached the bag", (game.save.inventory.TM_DREAM_EATER or 0) == 1) + check("EVENT_GOT_TM42 is set", game.save.flags.EVENT_GOT_TM42 == true) + + -- second talk: the flag routes to the DREAM EATER explanation, no re-gift + U.tap(game, "a") + U.wait(30) + local again = boxText() + U.log("second talk reads:", again or "(none)") + check("a second talk shows the explanation, not Yawn! again", + again ~= nil and again:find("Yawn!", 1, true) == nil) + U.shot(game, SHOT_DIR .. "/bug775_repeat.png") + + U.log("The screen is on the fisher's repeat-visit line now. The first") + U.log("talk should have read three pages: Yawn / the DROWZEE dream /") + U.log("\"Here, you can have this TM.\", and only then the TM42 receipt.") + U.log("Shots are in " .. SHOT_DIR .. " as bug775_pre.png / bug775_repeat.png.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/battle_fit_option.lua b/tests/engine/battle_fit_option.lua index d0918446..22ca3e25 100644 --- a/tests/engine/battle_fit_option.lua +++ b/tests/engine/battle_fit_option.lua @@ -75,6 +75,19 @@ T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)), T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim") T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe") +-- #773: the same walk decides whether the dark-cave shade shift may be armed +-- for this frame. A battle zeroes wMapPalOffset (init_battle_variables.asm), +-- so a world-bg battle over an un-flashed Rock Tunnel must suppress it. +T.check(Game.worldBgBattleInStack(stack(overworld, battleBg("world"))), + "a world-bg battle claims the frame, so the dark shift stays off it") +T.check(not Game.worldBgBattleInStack(stack(overworld, battleBg("white"))), + "a white-bg battle draws with no map under it and claims nothing") +T.check(Game.worldBgBattleInStack(stack(overworld, battleBg("world"), partyMenu)), + "a menu opened over the world-bg battle does not hand the shift back") +T.check(not Game.worldBgBattleInStack(stack(overworld)), + "a plain dark map still arms it") +T.check(not Game.worldBgBattleInStack(nil), "and no stack is safe") + T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1, "the dim is a fraction, not a full blackout") diff --git a/tests/engine/build_zip_pipe_guard_bug774.lua b/tests/engine/build_zip_pipe_guard_bug774.lua new file mode 100644 index 00000000..92b34572 --- /dev/null +++ b/tests/engine/build_zip_pipe_guard_bug774.lua @@ -0,0 +1,117 @@ +-- #774: packager archive checks must never pipe an `unzip -Z1` listing +-- straight into `grep -q`. grep -q exits on the first match, unzip takes +-- SIGPIPE (141), and under the scripts' `set -o pipefail` the pipeline +-- reports 141 -- so an `if` guard reads a real match as "no match". For +-- build_android.sh's generated-data guard that failed open on exactly the +-- archive it exists to reject (one carrying the user's extracted ROM +-- data). The fix everywhere is to capture the listing once and grep the +-- captured text (build.sh, pack_love.sh, build_ios.sh, build_android.sh +-- all do); this suite keeps the class of bug from regressing. The +-- `unzip -p ... Version.lua | grep` readbacks are fine -- a 1.4 KB single +-- write fits the pipe buffer, so unzip returns before grep can close the +-- read end -- and the scan below deliberately matches only -Z1 listings. +-- Self-contained: luajit tests/engine/build_zip_pipe_guard_bug774.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local function listShellFiles() + local out = {} + local p = io.popen('find scripts -name "*.sh" -type f') + if not p then return out end + for line in p:lines() do + out[#out + 1] = line + end + p:close() + table.sort(out) + return out +end + +-- ------------------------------------------------------------- static scan +-- Join backslash continuations first: the buggy form in build_android.sh +-- spread the pipeline across two lines. +local scripts = listShellFiles() +check(#scripts > 0, "found shell scripts under scripts/ to scan") + +local violations = {} +for _, file in ipairs(scripts) do + local body = readFile(file) + if body then + local joined = body:gsub("\\\n%s*", " ") + if joined:find("unzip %-Z1[^\n|]*|%s*grep %-%a*q") then + violations[#violations + 1] = file + end + end +end + +check(#violations == 0, + "no script pipes an unzip -Z1 listing into grep -q (#774: pipefail turns" + .. " the SIGPIPE into an inverted guard)" + .. (#violations > 0 and (":\n " .. table.concat(violations, "\n ")) or "")) + +-- --------------------------------------------------------- replay the guard +-- Run build_android.sh's own forbidden-content pattern, in the captured +-- form the script now uses, over a throwaway archive that really does +-- carry a data/generated entry, and over a clean one. This pins the +-- capture-then-grep idiom's behavior rather than trusting the scan alone. +local androidBody = readFile("scripts/build_android.sh") or "" +local pattern = androidBody:match("grep %-Eq '([^']*generated[^']*)'") +check(pattern ~= nil, + "build_android.sh still greps a generated-data pattern over the listing") + +local function haveCommand(name) + local probe = io.popen("command -v " .. name .. " 2>/dev/null") + if not probe then return false end + local out = probe:read("*a") + probe:close() + return out ~= nil and out:match("%S") ~= nil +end + +if pattern and haveCommand("zip") and haveCommand("unzip") + and haveCommand("bash") then + local tmpDir = (os.getenv("TMPDIR") or "/tmp"):gsub("[/\\]+$", "") + local stage = ("%s/pokeport_bug774_%d_%d"):format( + tmpDir, os.time(), math.random(1, 999999)) + os.execute(('mkdir -p "%s/pay/data/generated"'):format(stage)) + os.execute(('touch "%s/pay/data/generated/x.lua" "%s/pay/main.lua"') + :format(stage, stage)) + os.execute(('cd "%s/pay" && zip -qr ../bad.love .'):format(stage)) + os.execute(('cd "%s/pay" && zip -qr ../clean.love main.lua'):format(stage)) + + local function guardVerdict(archive) + -- exactly the script's shape: pipefail on, listing captured once, + -- grep runs over the captured text so nothing can take a SIGPIPE + local cmd = ("bash -c 'set -euo pipefail\n" + .. 'archive_entries="$(unzip -Z1 "%s")"\n' + .. "if grep -Eq '\\''%s'\\'' <<< \"$archive_entries\"; then" + .. " echo CAUGHT; else echo CLEAN; fi' 2>/dev/null"):format( + archive, pattern) + local p = io.popen(cmd) + if not p then return nil end + local out = p:read("*a") or "" + p:close() + return out:match("%S+") + end + + check(guardVerdict(stage .. "/bad.love") == "CAUGHT", + "the captured-listing guard rejects an archive carrying data/generated" + .. " (#774: the piped form let this ship in an APK)") + check(guardVerdict(stage .. "/clean.love") == "CLEAN", + "the captured-listing guard passes an archive without generated data") + + os.execute(('rm -rf "%s"'):format(stage)) +else + print("[#774] zip/unzip/bash not all present: guard replay skipped," + .. " the static scan above still ran") +end + +T.finish() diff --git a/tests/engine/launcher_delete_confirm.lua b/tests/engine/launcher_delete_confirm.lua index 7c1e31ea..f47bc507 100644 --- a/tests/engine/launcher_delete_confirm.lua +++ b/tests/engine/launcher_delete_confirm.lua @@ -1,8 +1,8 @@ -- Launcher Delete affordance (src/import/RomImporter.lua): the two-click arm -- that guards both save-slot and mod deletes (#433). Every Delete control in -- the FlexLove view routes through RomImporter:pressDelete, and every other --- queued action clears self._confirmDelete (LauncherView's queueAction), so --- the guarantees live on this seam: the first press only arms, the second +-- queued action clears self._confirmDelete (RomImporter:runActions as the +-- batch drains, #780), so the guarantees live on this seam: the first press only arms, the second -- press on the SAME target commits, any other target or a cleared arm asks -- again, and a stale arm expires instead of committing much later. -- luajit tests/engine/launcher_delete_confirm.lua diff --git a/tests/engine/launcher_touch_dispatch_bug780.lua b/tests/engine/launcher_touch_dispatch_bug780.lua new file mode 100644 index 00000000..5ca79bb4 --- /dev/null +++ b/tests/engine/launcher_touch_dispatch_bug780.lua @@ -0,0 +1,102 @@ +-- Launcher action drain (src/import/RomImporter.lua): on a phone one tap +-- lands on a save row AND on the chip drawn inside it, because FlexLove's +-- touch path has no topmost gate (EventHandler:processTouchEvents) while its +-- mouse path does. The drain therefore drops a row's own action when a +-- control inside that row fired in the same batch, and applies #433's disarm +-- as it runs each action rather than as the view queues them -- otherwise the +-- row's select cleared the arm the same tap had set and Delete never reached +-- its second press (#780). +-- luajit tests/engine/launcher_touch_dispatch_bug780.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 clock = 1000 +love.timer.getTime = function() return clock end + +local RomImporter = require("src.import.RomImporter") + +local function launcher() + local self = setmetatable({}, RomImporter) + self.ran = {} + return self +end + +local function tapDeleteChip(self, rowKey) + -- what one Android tap on a row's Delete chip queues: the row itself, then + -- the chip drawn on top of it + return { + { key = rowKey, keepArm = false, fn = function() + table.insert(self.ran, "select") + end }, + { key = rowKey .. "-del", keepArm = true, fn = function() + table.insert(self.ran, "delete") + self:pressDelete("slot", "slot2", "red", function() + table.insert(self.ran, "deleted") + end) + end }, + } +end + +-- ------- the chip wins the tap, and two taps delete + +do + local self = launcher() + self:runActions(tapDeleteChip(self, "slot-red-slot2")) + eq(self.ran[1], "delete", "the chip's action runs, not the row's select") + eq(#self.ran, 1, "the row behind the chip is dropped from the batch") + check(self._confirmDelete ~= nil, "the first tap leaves Delete armed") + + self:runActions(tapDeleteChip(self, "slot-red-slot2")) + eq(self.ran[3], "deleted", "the second tap on the same chip commits") + eq(self._confirmDelete, nil, "the arm is spent") +end + +-- ------- a tap on the row itself still selects, and still disarms + +do + local self = launcher() + self:pressDelete("slot", "slot2", "red", function() end) + check(self._confirmDelete ~= nil, "armed") + self:runActions({ + { key = "slot-red-slot2", keepArm = false, fn = function() + table.insert(self.ran, "select") + end }, + }) + eq(self.ran[1], "select", "a tap on empty row area selects the slot") + eq(self._confirmDelete, nil, "and disarms the pending Delete (#433)") +end + +-- ------- a sibling row's chip does not swallow another row + +do + local self = launcher() + self:runActions({ + { key = "slot-red-slot1", keepArm = false, fn = function() + table.insert(self.ran, "row1") + end }, + { key = "slot-red-slot10-del", keepArm = true, fn = function() + table.insert(self.ran, "del10") + end }, + }) + eq(self.ran[1], "row1", "slot1 is not a prefix-key parent of slot10") + eq(self.ran[2], "del10", "and slot10's chip still runs") +end + +-- ------- a failing action does not sink the rest of the batch + +do + local self = launcher() + self:runActions({ + { key = "a", keepArm = false, fn = function() error("boom") end }, + { key = "b", keepArm = false, fn = function() + table.insert(self.ran, "b") + end }, + }) + eq(self.ran[1], "b", "the queue drains past a handler that threw") +end + +print("launcher touch dispatch (#780) ok") diff --git a/tests/engine/save_export_portable_bug752.lua b/tests/engine/save_export_portable_bug752.lua new file mode 100644 index 00000000..736f670e --- /dev/null +++ b/tests/engine/save_export_portable_bug752.lua @@ -0,0 +1,92 @@ +-- Portable-mode save export (#752): with portable.txt beside the game the +-- launcher's Export save must land in the game folder, never in the OS save +-- directory LOVE hands out. SaveConvert is stubbed so this stays ROM-free; +-- what is under test is only which filesystem SaveFileIO.exportActiveSlot +-- writes through and which root the returned path reports. +-- luajit tests/engine/save_export_portable_bug752.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 SEP = package.config:sub(1, 1) +local tmp = os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp" +tmp = tmp:gsub("[/\\]$", "") +local base = tmp .. SEP .. "pokeport752" +if SEP == "\\" then + os.execute('rmdir /s /q "' .. base .. '" 2>nul') + os.execute('mkdir "' .. base .. '" 2>nul') +else + os.execute('rm -rf "' .. base .. '" && mkdir -p "' .. base .. '"') +end +local marker = io.open(base .. SEP .. "portable.txt", "wb") +check(marker ~= nil, "the temp portable folder is writable") +if not marker then T.finish("save_export_portable_bug752") return end +marker:write("portable\n") +marker:close() + +-- The love surface portable detection reads: a desktop OS plus a source +-- folder holding the marker (SaveData.gameFolders). A memfs stands in for +-- the OS save directory so a stray write there is visible in the test rather +-- than silently landing on the real machine. +local strayFiles = {} +love.system = love.system or {} +love.system.getOS = function() return "Linux" end +love.filesystem = { + getSource = function() return base end, + getSourceBaseDirectory = function() return base end, + getSaveDirectory = function() return "/fake/save" end, + getInfo = function() return nil end, + read = function() return nil end, + write = function(path, content) strayFiles[path] = content return true end, + remove = function() return true end, + createDirectory = function() return true end, +} + +-- SaveConvert stubbed before SaveFileIO requires it: the codec needs +-- data/generated/ crosswalks, and the byte content is irrelevant here. +package.loaded["src.save_convert.SaveConvert"] = { + SAVE_SIZE = 32768, + exportSav = function() return string.rep("\0", 32768) end, + importSav = function() return nil, "not used here" end, +} + +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +GameVersion.set("red") +check(SaveData.isPortable(), "portable.txt beside the game turns portable mode on") + +local slotId = SaveData.createSlot("red") +check(slotId ~= nil, "a slot registers in the portable folder") +SaveData.setActiveSlot("red", slotId) +check(SaveData.writeSlot("red", slotId, SaveData.newGame()), "the slot writes") + +local SaveFileIO = require("src.import.SaveFileIO") +local ok, path = SaveFileIO.exportActiveSlot("red") +eq(ok, true, "exportActiveSlot succeeds in portable mode") +local expected = base .. SEP .. "exports" .. SEP .. "red" .. SEP + .. "gen1recomp-red-" .. tostring(slotId) .. ".sav" +eq(path, expected, "the reported path is inside the portable game folder") + +local f = io.open(expected, "rb") +check(f ~= nil, "the export file exists in the portable exports/ folder") +if f then + local bytes = f:read("*a") + f:close() + eq(#bytes, 32768, "the export is exactly 32768 bytes") +end + +for name in pairs(strayFiles) do + check(not name:find("^exports"), + "no export leaked into the OS save directory: " .. name) +end + +if SEP == "\\" then + os.execute('rmdir /s /q "' .. base .. '" 2>nul') +else + os.execute('rm -rf "' .. base .. '"') +end + +T.finish("save_export_portable_bug752") diff --git a/tests/engine/trade_art_import.lua b/tests/engine/trade_art_import.lua new file mode 100644 index 00000000..71736adf --- /dev/null +++ b/tests/engine/trade_art_import.lua @@ -0,0 +1,98 @@ +-- The trade cinematic's Game Boy / cable / ball / bubble art must come out +-- of the ROM importer, not just the developer-only Python path (#750). +-- RomExtractor:extractTradeArt reads five symbols -- gfx/trade.asm +-- TradingAnimationGraphics(+2), engine/gfx/mon_icons.asm TradeBubbleIconGFX, +-- and the data/tilemaps.asm GameBoyTiles / LinkCableTiles id lists -- so +-- every shipped manifest has to carry them, the manifest generator has to +-- keep them on a regen, and RomImporter has to force pre-#750 caches to +-- re-import. Addresses below were byte-verified against the canonical +-- Red/Blue/Yellow ROMs (each payload occurs exactly once) and match +-- pokered.sym / pokeblue.sym. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +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 + +-- Red and Blue place the trade art identically; Yellow shifted it. +local RED_BLUE = { + GameBoyTiles = { 30, 23584 }, -- 1e:5c20 + LinkCableTiles = { 30, 23632 }, -- 1e:5c50 + TradeBubbleIconGFX = { 28, 23129 }, -- 1c:5a59 + TradingAnimationGraphics = { 14, 27070 }, -- 0e:69be + TradingAnimationGraphics2 = { 14, 27854 }, -- 0e:6cce +} +local YELLOW = { + GameBoyTiles = { 30, 23932 }, + LinkCableTiles = { 30, 23980 }, + TradeBubbleIconGFX = { 28, 23302 }, + TradingAnimationGraphics = { 14, 27240 }, + TradingAnimationGraphics2 = { 14, 28024 }, +} + +local MANIFESTS = { + { "tools/rom_manifest.json", RED_BLUE }, + { "tools/rom_manifest_blue.json", RED_BLUE }, + { "tools/rom_manifest_yellow.json", YELLOW }, +} + +for _, spec in ipairs(MANIFESTS) do + local path, expected = spec[1], spec[2] + local text = readFile(path) + T.check(text ~= nil, path .. " is readable") + if text then + for name, location in pairs(expected) do + local bank, addr = text:match( + '"' .. name .. '"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]') + T.eq(bank, tostring(location[1]), + path .. ": " .. name .. " bank") + T.eq(addr, tostring(location[2]), + path .. ": " .. name .. " address") + end + end +end + +-- the manifests are generated, so the generator has to keep asking for the +-- symbols or the next regen silently drops the trade art again +local gen = readFile("tools/make_rom_manifest.py") +T.check(gen ~= nil, "tools/make_rom_manifest.py is readable") +if gen then + for name in pairs(RED_BLUE) do + T.check(gen:find('"' .. name .. '"', 1, true) ~= nil, + "a regenerated manifest keeps " .. name) + end +end + +-- extractTradeArt exists, extractField calls it, and the field table +-- publishes the paths the same way the Python path's field.py does, so +-- TradeAnim's `game.data.field.tradeArt` lookup lands on both build paths +local extractor = readFile("src/import/RomExtractor.lua") +T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable") +if extractor then + T.check(extractor:find("function RomExtractor:extractTradeArt", 1, true) ~= nil, + "RomExtractor has extractTradeArt") + T.check(extractor:find("self:extractTradeArt()", 1, true) ~= nil, + "extractField runs it") + T.check(extractor:find("data.tradeArt = tradeArt", 1, true) ~= nil, + "field.lua publishes tradeArt") +end + +-- a cache imported before #750 has none of the art; listing one of the +-- files in REQUIRED_FILES is what makes it re-import +local importer = readFile("src/import/RomImporter.lua") +T.check(importer ~= nil, "src/import/RomImporter.lua is readable") +if importer then + local required = importer:match("local REQUIRED_FILES = {(.-)\n}") + T.check(required ~= nil, "REQUIRED_FILES parses") + T.check(required ~= nil and required:find( + '"assets/generated/trade/game_boy.png"', 1, true) ~= nil, + "REQUIRED_FILES makes pre-#750 caches re-import the trade art") +end + +T.finish("trade art import") diff --git a/tests/engine/trainer_talk_sting_bug764.lua b/tests/engine/trainer_talk_sting_bug764.lua new file mode 100644 index 00000000..838f5f05 --- /dev/null +++ b/tests/engine/trainer_talk_sting_bug764.lua @@ -0,0 +1,104 @@ +-- Talking a trainer into battle must start the encounter sting (#764). +-- TalkToTrainer (pokered home/trainers.asm:88) prints the before-battle +-- text and then `call EngageMapTrainer` -> PlayTrainerMusic +-- (home/trainers.asm:399): evil list, female list, male by default, rivals +-- excluded. The port only ran the sting on the sight-line path +-- (startTrainerApproach), so a trainer challenged from the side or back -- +-- and every scripted battle routed through ow:engageTrainer -- went into +-- the battle in map music. Asserts the talk path now plays the class +-- sting, skips rivals, and does not restart it when the sight path +-- (self.engaging) already did. +-- ROM-free: stubs Game/TextBox/BattleState/Music around engageTrainer. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + 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, val); return true end + i = i + 1 + end +end + +local pushed = {} +local stackStub = { + push = function(_, item) pushed[#pushed + 1] = item end, +} +local textBoxStub = { + new = function(_, text, onDone) return { text = text, onDone = onDone } end, + substitute = function(_, text) return text end, +} + +-- Music / BattleState are required lazily at the call site; stub via +-- package.loaded (same trick as tests/engine/oaks_pc_flow.lua) +local plays = {} +local realMusic = package.loaded["src.core.Music"] +package.loaded["src.core.Music"] = { + play = function(_, song) plays[#plays + 1] = song end, +} +local realBattle = package.loaded["src.battle.BattleState"] +package.loaded["src.battle.BattleState"] = { + newTrainer = function() return {} end, +} + +local fakeGame = { + data = { + text = {}, + trainerHeader = function() return nil end, + resolveText = function() return "You looked at me\nfunny!" end, + }, + stack = stackStub, +} +T.check(setUpvalue(OW.engageTrainer, "Game", fakeGame), + "Game upvalue on engageTrainer") +T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub), + "TextBox upvalue on engageTrainer") + +-- pushBattle would touch the real stack machinery; the engagement is over +-- by the time it runs, so a no-op keeps the test on the music question +local fakeSelf = setmetatable({ + map = { def = { label = "Route24" } }, + pushBattle = function() end, +}, { __index = OW }) + +-- run engageTrainer for one class and return what the sting played +local function stingFor(cls, engaging) + pushed, plays = {}, {} + fakeSelf.engaging = engaging + fakeSelf:engageTrainer({ id = "npc#1", def = { trainerClass = cls, + trainerParty = 1, index = 1 } }) + T.eq(#pushed, 1, "engageTrainer pushes the before-battle text") + T.eq(#plays, 0, "no sting while the dialogue is still up (" .. cls .. ")") + pushed[1].onDone() -- close the box; TalkToTrainer engages here + return plays[1], #plays +end + +-- PlayTrainerMusic's three buckets (data/trainers/encounter_types.asm) +T.eq(stingFor("OPP_LASS"), "Music_MeetFemaleTrainer", + "female-list class plays the female sting") +T.eq(stingFor("OPP_ROCKET"), "Music_MeetEvilTrainer", + "evil-list class plays the evil sting") +T.eq(stingFor("OPP_YOUNGSTER"), "Music_MeetMaleTrainer", + "any other class defaults to the male sting") + +-- the rivals `ret z` out of PlayTrainerMusic; their scripts run +-- MUSIC_MEET_RIVAL themselves (data/scripts/oaks_lab.lua) +local _, rivalCount = stingFor("OPP_RIVAL1") +T.eq(rivalCount, 0, "rival classes play no encounter sting here") + +-- sight path already engaged: TrainerEngage started the sting before the +-- "!" bubble, and TalkToTrainer's BIT_SEEN_BY_TRAINER guard keeps the +-- talk path from restarting it +local _, seenCount = stingFor("OPP_LASS", true) +T.eq(seenCount, 0, "self.engaging suppresses a second sting") + +if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic +else package.loaded["src.core.Music"] = nil end +if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle +else package.loaded["src.battle.BattleState"] = nil end + +T.finish("trainer_talk_sting_bug764") diff --git a/tests/engine/viridian_fisher_pre_bug775.lua b/tests/engine/viridian_fisher_pre_bug775.lua new file mode 100644 index 00000000..134fc120 --- /dev/null +++ b/tests/engine/viridian_fisher_pre_bug775.lua @@ -0,0 +1,89 @@ +-- Headless regression: the Viridian fisher's TM42 gift skipped his pre +-- text and jumped straight to "received TM42!" (#775). pokered's +-- ViridianCityFisherText (scripts/ViridianCity.asm) prints +-- .YouCanHaveThisText before GiveItem; on Red that label sits outside the +-- extractor's symbol set (no leading underscore, same class as the +-- SilphCo2F worker in #393), so the ported literal has to carry the flow +-- when the text table has no entry. ROM-free: the gift closure only +-- touches text/items/flags, so TextBox, Sound and Bag are stubbed and the +-- boxes are advanced by hand. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- story5's gift() requires these at call time, so preloading stubs is +-- enough; each tier suite is its own process, nothing leaks +local boxes = {} +package.loaded["src.render.TextBox"] = { + new = function(_, s, done) return { text = s, onDone = done } end, +} +package.loaded["src.core.Sound"] = { play = function() end } +package.loaded["src.inventory.Bag"] = { + add = function(save, item, n) + save.inventory[item] = (save.inventory[item] or 0) + n + return true + end, +} + +local story5 = require("data.scripts.story5") +local fisher = story5.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_FISHER +T.check(type(fisher) == "function", "the fisher talk entry is a gift closure") + +local function newGame(textTable) + boxes = {} + return { + data = { + text = textTable, + items = { TM_DREAM_EATER = { name = "TM42" } }, + }, + save = { + flags = {}, inventory = {}, player = { name = "RED" }, + }, + stack = { + push = function(_, box) boxes[#boxes + 1] = box end, + }, + } +end + +-- Red-like: empty text table, the fallback literal must carry the scene +local game = newGame({}) +local finished = false +fisher(game, nil, nil, function() finished = true end) + +T.eq(#boxes, 1, "talking opens exactly one box before any A press") +local pre = boxes[1].text +T.check(type(pre) == "string" and pre:sub(1, 5) == "Yawn!", + "the first box is the fisher's pre text, not the receipt") +T.check(pre:find("DROWZEE", 1, true) ~= nil, + "the fallback carries the DROWZEE dream paragraph") +T.check(pre:find("have this TM.", 1, true) ~= nil, + "and ends on the hand-over line") +T.check(not game.save.flags.EVENT_GOT_TM42, + "the flag stays unset until the pre text is dismissed") + +boxes[1].onDone() +T.eq(#boxes, 2, "dismissing the pre text opens the received box") +T.eq(boxes[2].text, "RED received\nTM42!", + "the received fallback is filled with player and item") +T.eq(game.save.inventory.TM_DREAM_EATER, 1, "TM42 reached the bag") +T.check(game.save.flags.EVENT_GOT_TM42 == true, "the event flag is set") +boxes[2].onDone() +T.eq(#boxes, 3, "the explanation box follows the receipt") +boxes[3].onDone() +T.check(finished, "the talk chain hands control back") + +-- Yellow-like: the extracted string exists, so it wins over the fallback +game = newGame({ ViridianCityFisherYouCanHaveThisText = "ROM STRING" }) +fisher(game, nil, nil, function() end) +T.eq(boxes[1].text, "ROM STRING", + "an extracted ViridianCityFisherYouCanHaveThisText beats the fallback") + +-- repeat visit: the flag routes straight to the explanation, no re-gift +game = newGame({ _ViridianCityFisherTM42ExplanationText = "EXPLAIN" }) +game.save.flags.EVENT_GOT_TM42 = true +fisher(game, nil, nil, function() end) +T.eq(#boxes, 1, "a second talk opens a single box") +T.eq(boxes[1].text, "EXPLAIN", "and it is the TM42 explanation") +T.eq(game.save.inventory.TM_DREAM_EATER, nil, "no duplicate TM42") + +T.finish("viridian_fisher_pre_bug775") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 49169fb4..5f0c6578 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -510,6 +510,21 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list") hooks:removeOwner("bad") pm.submenu = nil +-- ------- #768: the party cursor persists until a battle +-- (PartyMenuInit reads wPartyAndBillsPCSavedMenuItem, HandlePartyMenuInput +-- writes it back; InitBattleVariables / end_of_battle.asm zero it) +pgame.save.party[2] = { species = "PIKACHU", hp = 10, stats = { hp = 10 }, + level = 5, moves = { { id = "TACKLE" } } } +press(pm, "down") +check(pgame.partyMenuSavedIndex == 2, "the party cursor is saved on move") +local pm2 = PartyMenu.new(pgame) +check(pm2.index == 2, "reopening the party menu keeps the cursor (#768)") +pgame.save.party[2] = nil +check(PartyMenu.new(pgame).index == 1, + "a shrunken party clamps the saved cursor back into range") +pgame.partyMenuSavedIndex = nil -- a battle clears it (InitBattleVariables) +check(PartyMenu.new(pgame).index == 1, "a battle resets the party cursor") + -- ------- battle PKMN: SWITCH / STATS / CANCEL (#180) local switched local bgame = partyGame() @@ -555,9 +570,9 @@ do pm.game = sgame sgame.stack:push(pm) press(pm, "a") -- open the submenu - check(pm.subItems[#pm.subItems].action == "strength", - "the strength row is listed with badge + move") - pm.subIndex = #pm.subItems + check(pm.subItems[1].action == "strength", + "the strength row is listed with badge + move, above STATS/SWITCH (#768)") + pm.subIndex = 1 press(pm, "a") -- run STRENGTH local states = sgame.stack.states check(#states == 2 and states[1] == pm and states[2].pages ~= nil, diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 980b37b0..6213e265 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -114,10 +114,10 @@ eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 1)") eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 2)") eq(boulder.cellX, 18, "boulder unmoved while STRENGTH is inactive") --- activate via the party menu STRENGTH action (submenu {STATS,SWITCH,STRENGTH}) +-- activate via the party menu STRENGTH action (submenu {STRENGTH,STATS,SWITCH}) clearCaptured() local pmStr = PartyMenu.new(Game) -selectSubItem(pmStr, 3) +selectSubItem(pmStr, 1) eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive") check(onStack(pmStr), "party menu stays under the STRENGTH texts (#385)") check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown") @@ -156,7 +156,7 @@ Game.save.inventory.SOULBADGE = true ow.player.facing = "up"; ow.player.surfing = false clearCaptured() local pmSurfFail = PartyMenu.new(Game) -selectSubItem(pmSurfFail, 3) +selectSubItem(pmSurfFail, 1) check(sawText("No SURFing"), "_NoSurfingHereText when not facing water") check(pmSurfFail.submenu == true, "party menu stays open after a failed SURF") eq(ow.player.surfing, false, "no mount when SURF fails") @@ -166,7 +166,7 @@ popToOW() ow.player.facing = "down"; ow.player.surfing = false clearCaptured() local pmSurf = PartyMenu.new(Game) -selectSubItem(pmSurf, 3) +selectSubItem(pmSurf, 1) -- the got-on text prints over the menu (#385); dismissing it closes the -- menu and mounts, and the blink that follows carries the step check(onStack(pmSurf), "party menu stays under the got-on text") @@ -228,7 +228,7 @@ ow = pushOW("CERULEAN_CITY", 19, 27, "down") -- success path: facing the tree -> _UsedCutText, menu closes, tree replaced clearCaptured() local pmCut = PartyMenu.new(Game) -selectSubItem(pmCut, 3) +selectSubItem(pmCut, 1) check(not onStack(pmCut), "party menu closes after a successful CUT") check(sawText("CUT"), "_UsedCutText shown on a successful CUT") drainText() -- the tree swap is deferred until the message is dismissed @@ -239,7 +239,7 @@ popToOW() ow.player.facing = "up" clearCaptured() local pmCutFail = PartyMenu.new(Game) -selectSubItem(pmCutFail, 3) +selectSubItem(pmCutFail, 1) check(sawText("anything to CUT"), "_NothingToCutText when not facing a tree") check(pmCutFail.submenu == true, "party menu stays open after a failed CUT") ow.player.facing = "right" @@ -276,7 +276,7 @@ Game.save.forcedBike = true eq(ow:useSurfFieldMove(), "forced_bike", "forced bike refuses SURF (even facing water)") clearCaptured() local pmBike = PartyMenu.new(Game) -selectSubItem(pmBike, 3) +selectSubItem(pmBike, 1) check(sawText("Cycling is fun!\nForget SURFing!"), "_CyclingIsFunText verbatim") check(pmBike.submenu == true, "party menu stays open (.loop) after the bike refusal") eq(ow.player.surfing, false, "no mount on the Cycling Road") @@ -320,7 +320,7 @@ check(ow.map:isWaterCell(7, 12), "water south of the B4F stairs square") eq(ow:useSurfFieldMove(), "current", "B4F stairs square refuses SURF pre-boulders") clearCaptured() local pmCur = PartyMenu.new(Game) -selectSubItem(pmCur, 3) +selectSubItem(pmCur, 1) check(sawText("The current is\nmuch too fast!"), "_CurrentTooFastText verbatim") check(pmCur.submenu == true, "party menu stays open (.loop) after the current refusal") eq(ow.player.surfing, false, "no mount against the current") @@ -356,7 +356,7 @@ table.remove(ow.entities) -- .goBackToMap) and the simulated pad press steps the player ashore clearCaptured() local pmOff = PartyMenu.new(Game) -selectSubItem(pmOff, 3) +selectSubItem(pmOff, 1) check(not onStack(pmOff), "party menu closes on dismount") eq(ow.player.surfing, false, ".stopSurfing returns to walking before the step") eq(#captured, 0, "no message on a successful dismount") @@ -375,7 +375,7 @@ ow.player.px, ow.player.py = 4 * 16, 15 * 16 ow.player.facing = "down" clearCaptured() local pmNoOff = PartyMenu.new(Game) -selectSubItem(pmNoOff, 3) +selectSubItem(pmNoOff, 1) check(sawText("There's no place\nto get off!"), "_SurfingNoPlaceToGetOffText verbatim") check(onStack(pmNoOff), "the menu stays under the message (#385)") eq(ow.player.surfing, true, "still surfing after a blocked dismount") @@ -394,7 +394,7 @@ Game.save.inventory = { RAINBOWBADGE = true } ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") clearCaptured() local pmStr2 = PartyMenu.new(Game) -selectSubItem(pmStr2, 3) +selectSubItem(pmStr2, 1) local page1 = Game.stack:top() check(page1 ~= nil and page1.pages ~= nil and page1.auto ~= nil, "_UsedStrengthText box is a no-prompt (auto) page") @@ -486,8 +486,8 @@ Game.save.inventory = { ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") clearCaptured() local pmFaintStr = PartyMenu.new(Game) --- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF -selectSubItem(pmFaintStr, 4) +-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH +selectSubItem(pmFaintStr, 2) eq(Game.overworld.strengthActive, true, "fainted mon can activate STRENGTH from the party menu") check(sawText("used") and sawText("STRENGTH"), @@ -501,9 +501,9 @@ ow = pushOW("PALLET_TOWN", 4, 13, "down") ow.player.surfing = false eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon") clearCaptured() --- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon) +-- submenu order: FLY, CUT, STRENGTH, SURF (move order on mon), then STATS, SWITCH local pmFaintSurf = PartyMenu.new(Game) -selectSubItem(pmFaintSurf, 6) +selectSubItem(pmFaintSurf, 4) Game.stack:pop().onDone() -- dismiss the text: menu closes, mount (#320, #385) eq(ow.player.surfing, true, "fainted mon can SURF from the party menu") check(not onStack(pmFaintSurf), "party menu closes after fainted SURF") diff --git a/tests/parity_battle_auto_text_bug765.lua b/tests/parity_battle_auto_text_bug765.lua new file mode 100644 index 00000000..278fc448 --- /dev/null +++ b/tests/parity_battle_auto_text_bug765.lua @@ -0,0 +1,115 @@ +-- Parity test: battle pages whose ROM tail is `text_end` / `done` hand off +-- with no button press (#765). Only TX_PROMPT_BUTTON writes the '▼' and +-- runs ManualTextScroll (home/text.asm:434-446); a TX_END tail returns +-- straight out of PrintText (home/text.asm:328-334). The used-move line +-- (engine/battle/used_move_text.asm EndUsedMove1Text..EndUsedMove5Text) and +-- the item-use line (ItemUseText00, engine/items/item_effects.asm) are both +-- of that kind, so a sayAuto row must flow into the next queue row untouched +-- while a plain say page still waits on A/B like PromptText. +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("parity battle auto text (#765)") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") +local Timing = require("src.core.Timing") + +-- Silence audio: BattleState reaches both modules through require() at the +-- call site, so patching the fields here is what the battle ends up calling. +Sound.playCry = function() end +Sound.play = function() end +Sound.playMove = function() end +Sound.playMoveCry = function() end +Sound.stopLoop = function() end +Music.playBattle = function() end +Music.play = function() end + +-- stub stack + input, like the other headless battle probes +local press = {} +local function makeGame(party) + local save = SaveData.newGame() + save.party = party + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function(_, b) return press[b] == true end, + isDown = function(_, b) return press[b] == true end } } +end + +local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) }) +local battle = BattleState.newWild(game, "RATTATA", 2) +battle.onFinish = function() end +battle:enter() + +-- strip the intro so the queue under test is exactly what gets inserted; +-- afterQueue is cleared so a drained queue between probes cannot flip the +-- phase to "menu" and stop update() from pumping messages +battle.queue = {} +battle.current = nil +battle.introSlide = 0 +battle.phase = "messages" +battle.afterQueue = nil + +-- ------------------------------------------------- auto page, no delay +local ran = false +battle:sayAuto("AUTO PAGE") +battle:act(function() ran = true end) + +local promptedDuringAuto = false +for _ = 1, 300 do + if ran then break end + if battle.msgPrompt then promptedDuringAuto = true end + battle:update(1 / 60) +end +check(ran, "an auto page hands off to the next row with no button") +check(not promptedDuringAuto, "the prompt flag never rises on an auto page") +eq(battle.msgHold, true, + "the finished auto page stays held for drawTextArea (#296)") + +-- ------------------------------------------------- auto page, autoDelay +local ran2, typedFrame, ranFrame = false, nil, nil +battle:sayAuto("HELD PAGE", 30) +battle:act(function() ran2 = true end) +for f = 1, 600 do + battle:update(1 / 60) + if not typedFrame and battle.current + and battle.charIndex >= battle.total then + typedFrame = f + end + if ran2 then ranFrame = f break end +end +check(ran2, "the delayed auto page still hands off by itself") +check(typedFrame ~= nil and ranFrame ~= nil + and ranFrame - typedFrame >= 30, + "autoDelay holds the finished page for its frame count first") + +-- ------------------------------------------------- plain page still prompts +battle:say("PROMPT PAGE") +local prompted = false +for _ = 1, 300 do + battle:update(1 / 60) + if battle.msgPrompt then prompted = true break end +end +check(prompted, "a plain page still raises the blinking prompt (#317)") +-- PromptText runs ProtectedDelay3 before ManualTextScroll watches the +-- joypad (home/text.asm:213-217), so pay that hold before pressing +for _ = 1, Timing.TEXT_PRE_ADVANCE do battle:update(1 / 60) end +check(battle.current ~= nil, "and the page holds on screen with no button") +press.a = true +battle:update(1 / 60) +press.a = false +eq(battle.msgPrompt, nil, "the A press clears the prompt") +eq(battle.current, nil, "and dismisses the page") + +S.finish() diff --git a/tests/parity_battle_music_bug782.lua b/tests/parity_battle_music_bug782.lua new file mode 100644 index 00000000..9af8eb55 --- /dev/null +++ b/tests/parity_battle_music_bug782.lua @@ -0,0 +1,72 @@ +-- Parity: which trainers get the gym-leader battle theme (#782). +-- PlayBattleMusic (audio/play_battle_music.asm) picks MUSIC_GYM_LEADER_BATTLE +-- only when wGymLeaderNo is set, and the eight gym scripts +-- (scripts/PewterGym.asm .. ViridianGym.asm) are its only writers; Lance +-- shares the theme by opponent class and the Champion (OPP_RIVAL3) takes +-- MUSIC_FINAL_BATTLE. Giovanni's Rocket Hideout (OPP_GIOVANNI#1) and Silph +-- Co (OPP_GIOVANNI#2) fights never touch the byte, so they must play +-- MUSIC_TRAINER_BATTLE. The port keyed the boss check on the trainer CLASS +-- alone, so every Giovanni battle borrowed the Earth Badge roster's theme, +-- the gym victory jingle, and the Pikachu GYMLEADER happiness bump. +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("parity battle music bug782") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function makeGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "BULBASAUR", 60) } + return { data = Data, save = save, + input = { wasPressed = function() return false end, + isDown = function() return false end }, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } +end + +local function kindOf(oppClass, partyIndex) + local battle = BattleState.newTrainer(makeGame(), oppClass, partyIndex) + return battle:computeMusicKind(), battle.isGymLeader +end + +-- the two non-gym Giovanni fights: plain trainer theme, no gym-leader flag +do + local kind, gym = kindOf("OPP_GIOVANNI", 1) -- Rocket Hideout B4F + eq(kind, "trainer", "Rocket Hideout Giovanni plays the trainer theme") + check(not gym, "Rocket Hideout Giovanni is not a gym leader") + kind, gym = kindOf("OPP_GIOVANNI", 2) -- Silph Co 11F + eq(kind, "trainer", "Silph Co Giovanni plays the trainer theme") + check(not gym, "Silph Co Giovanni is not a gym leader") +end + +-- the badge fight itself keeps the gym theme and the happiness bump +do + local kind, gym = kindOf("OPP_GIOVANNI", 3) -- Viridian Gym + eq(kind, "gym", "Viridian Gym Giovanni plays the gym-leader theme") + check(gym, "Viridian Gym Giovanni sets isGymLeader") +end + +-- regression guards around the branch below the badge lookup +do + local kind, gym = kindOf("OPP_BROCK", 1) + eq(kind, "gym", "Brock plays the gym-leader theme") + check(gym, "Brock sets isGymLeader") + kind, gym = kindOf("OPP_LANCE", 1) + eq(kind, "gym", "Lance shares the gym-leader theme") + check(not gym, "Lance is not a wGymLeaderNo writer (no happiness bump)") + kind = kindOf("OPP_RIVAL3", 1) + eq(kind, "final", "the Champion plays the final-battle theme") + kind, gym = kindOf("OPP_YOUNGSTER", 1) + eq(kind, "trainer", "an ordinary trainer plays the trainer theme") + check(not gym, "an ordinary trainer is not a gym leader") +end + +S.finish() diff --git a/tests/parity_field_move_layering.lua b/tests/parity_field_move_layering.lua index d5c3bbf3..083cfcda 100644 --- a/tests/parity_field_move_layering.lua +++ b/tests/parity_field_move_layering.lua @@ -111,7 +111,7 @@ Game.save.party = { mkMon("MACHOP", "STRENGTH") } Game.save.inventory = { RAINBOWBADGE = true } local ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") local pmStr = PartyMenu.new(Game) -selectSubItem(pmStr, 3) +selectSubItem(pmStr, 1) check(isText(Game.stack:top()), "STRENGTH opens _UsedStrengthText") eq(backdrop(), pmStr, "the party menu is the backdrop of _UsedStrengthText") drainOne() @@ -133,7 +133,7 @@ Game.save.inventory = { SOULBADGE = true } ow = pushOW("PALLET_TOWN", 4, 13, "down") ow.player.surfing = false local pmSurf = PartyMenu.new(Game) -selectSubItem(pmSurf, 3) +selectSubItem(pmSurf, 1) check(isText(Game.stack:top()), "SURF opens _SurfingGotOnText") eq(backdrop(), pmSurf, "the party menu is the backdrop of _SurfingGotOnText") drainOne() @@ -148,7 +148,7 @@ eq(Game.stack:top(), ow, "SURF ends on the map") ow = pushOW("PALLET_TOWN", 4, 15, "down") ow.player.surfing = true local pmNoOff = PartyMenu.new(Game) -selectSubItem(pmNoOff, 3) +selectSubItem(pmNoOff, 1) check(isText(Game.stack:top()), "a blocked dismount opens _SurfingNoPlaceToGetOffText") eq(backdrop(), pmNoOff, "the party menu is the backdrop of the no-place message") drainOne() @@ -172,7 +172,7 @@ Game.save.inventory = { BOULDERBADGE = true } ow = pushOW("ROCK_TUNNEL_1F", 15, 4, "down") eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH") local pmFlash = PartyMenu.new(Game) -selectSubItem(pmFlash, 3) +selectSubItem(pmFlash, 1) check(isText(Game.stack:top()), "FLASH opens _FlashLightsAreaText") eq(backdrop(), pmFlash, "the party menu is the backdrop of _FlashLightsAreaText") eq(ow.dark, true, "the tunnel is still dark while the message is up") diff --git a/tests/parity_flash_blink_bug610.lua b/tests/parity_flash_blink_bug610.lua index 161d52ae..bbcbd156 100644 --- a/tests/parity_flash_blink_bug610.lua +++ b/tests/parity_flash_blink_bug610.lua @@ -66,8 +66,7 @@ eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH") local pm = PartyMenu.new(Game) Game.stack:push(pm) frame({ "a" }) -- open the field-move submenu on PIKACHU -for _ = 2, 3 do frame({ "down" }) end -frame({ "a" }) -- FLASH +frame({ "a" }) -- FLASH is the top row now (#768) drainOne() -- dismiss _FlashLightsAreaText local blink = Game.stack:top() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index b38175ab..0f2b6d41 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -62,7 +62,9 @@ local expected = 100 + 128 + 16 + 20 + 600 for _, s in ipairs(credits.screens) do expected = expected + (s.fade and 20 or 0) + (s.mon and (s.fade and 90 or 110) or (s.fade and 120 or 140)) - + (s.mon and 27 or 0) + -- DisplayCreditsMon: 3 x CreditsCopyTileMapToVRAM (Delay3) then 27 scroll + -- frames (#703) + + (s.mon and (9 + 27) or 0) end while roll.phase ~= "end_wait" and frame < expected + 120 do frame = frame + 1 diff --git a/tests/parity_substitute_anim.lua b/tests/parity_substitute_anim.lua new file mode 100644 index 00000000..8092c87b --- /dev/null +++ b/tests/parity_substitute_anim.lua @@ -0,0 +1,90 @@ +-- Parity test: Substitute's failure branches play no animation (#644). +-- SUBSTITUTE_EFFECT is a ResidualEffects1 entry +-- (data/battle/residual_effects_1.asm), so the caller never plays the move +-- animation; SubstituteEffect_ (engine/battle/move_effects/substitute.asm) +-- reaches PlayCurrentMoveAnimation / AnimationSubstitute only after +-- `set HAS_SUBSTITUTE_UP, [hl]`, while .alreadyHasSubstitute and +-- .notEnoughHP jump straight to PrintText. The animation opens with +-- SE_SLIDE_MON_OFF, which hides the user's pic until the doll replaces it, +-- so a failed Substitute that still animated left the user invisible. +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.moves and Data.moves.SUBSTITUTE) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end + +local Game = require("src.core.Game") +Game.data = Data +Game.save = require("src.core.SaveData").newGame() + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity substitute anim") +local check, eq = S.check, S.eq + +local function freshBattle() + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local tb = BattleState.newWild(Game, "PIDGEY", 10) + tb.queue, tb.nextInsert = {}, 0 + return tb +end + +local function anyAnim(tb, name) + for _, row in ipairs(tb.queue) do + if row.anim == name then return true end + end + return false +end + +local function anyText(tb, needle) + for _, row in ipairs(tb.queue) do + if row.text and row.text:gsub("\n", " "):find(needle, 1, true) then return true end + end + return false +end + +do + local anims = Data.battle_anims and Data.battle_anims.moveAnims + check(anims ~= nil, "battle_anims carries moveAnims") + check(anims == nil or anims.SUBSTITUTE ~= nil, "SUBSTITUTE has an animation") +end + +-- success: the doll animation plays and the substitute stands +do + local tb = freshBattle() + tb.enemy.mon.hp = tb.enemy.mon.stats.hp + tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false) + check(tb.enemy.substituteHP ~= nil, "a healthy user builds its substitute") + check(anyAnim(tb, "SUBSTITUTE"), "and the doll animation plays") +end + +-- .notEnoughHP: text only, no animation, and no dangling row +do + local tb = freshBattle() + tb.enemy.mon.hp = math.floor(tb.enemy.mon.stats.hp / 4) - 1 + tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false) + check(tb.enemy.substituteHP == nil, "too little HP fails the substitute") + check(not anyAnim(tb, "SUBSTITUTE"), + "a failed substitute plays no animation (#644)") + check(tb.moveAnimRow == nil, "the peeled move-anim row is not left dangling") + check(anyText(tb, "SUBSTITUTE"), "the failure text still prints") +end + +-- .alreadyHasSubstitute: same, with a doll already standing +do + local tb = freshBattle() + tb.enemy.mon.hp = tb.enemy.mon.stats.hp + tb.enemy.substituteHP = 10 + tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false) + eq(tb.enemy.substituteHP, 10, "the standing substitute is untouched") + check(not anyAnim(tb, "SUBSTITUTE"), + "a second substitute plays no animation (#644)") + check(tb.moveAnimRow == nil, "and peels its anim row") +end + +S.finish() diff --git a/tests/parity_switch_cursor_reset.lua b/tests/parity_switch_cursor_reset.lua new file mode 100644 index 00000000..ba3a1f2e --- /dev/null +++ b/tests/parity_switch_cursor_reset.lua @@ -0,0 +1,49 @@ +-- Parity: a player send-out zeroes both battle cursors (#737). SendOutMon +-- (engine/battle/core.asm:1733-1735) clears wBattleAndStartSavedMenuItem and, +-- with the same hli/hl pair, wPlayerMoveListIndex behind it (wram.asm:242-244), +-- so the menu reopens on FIGHT and the move list on the first slot. +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 then Data:load() end +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 S = require("tests.harness").suite("parity switch cursor reset") +local eq = S.eq + +local pressed = {} +local save = SaveData.newGame() +save.party = { + Pokemon.new(Data, "BULBASAUR", 10), + Pokemon.new(Data, "PIDGEY", 10), +} +local game = { + data = Data, + save = save, + input = { + wasPressed = function(_, key) return pressed[key] == true end, + isDown = function(_, key) return pressed[key] == true end, + }, + stack = { push = function() end, pop = function() end, top = function() end }, +} +local battle = BattleState.newWild(game, "RATTATA", 3) +battle.phase = "menu" +battle.menuIndex = 4 +battle.moveIndex = 3 + +battle:resolveSwitch(save.party[2]) +for i = 1, 4000 do + if battle.phase == "menu" then break end + pressed.a = (i % 4 == 0) + battle:update(1 / 60) + pressed.a = nil +end + +eq(battle.moveIndex, 1, "the move cursor is back on the first slot") +eq(battle.menuIndex, 1, "the battle menu is back on FIGHT") + +S.finish() diff --git a/tools/make_rom_manifest.py b/tools/make_rom_manifest.py index 031a849f..1b4ce7b4 100755 --- a/tools/make_rom_manifest.py +++ b/tools/make_rom_manifest.py @@ -567,6 +567,7 @@ FIELD_ASSET_SYMBOLS = { "FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3", + "GameBoyTiles", "GameFreakIntro", "GameFreakLogoGraphics", "GengarIntroTiles1", @@ -575,6 +576,7 @@ FIELD_ASSET_SYMBOLS = { "HappyEmote", "HpBarAndStatusGraphics", "LedgeHoppingShadow", + "LinkCableTiles", "MoveAnimationTiles1", "NintendoCopyrightLogoGraphics", "PlayerCharacterTitleGraphics", @@ -593,6 +595,9 @@ FIELD_ASSET_SYMBOLS = { "SlotMachineTiles2", "TheEndGfx", "TownMapCursor", + "TradeBubbleIconGFX", + "TradingAnimationGraphics", + "TradingAnimationGraphics2", "Version_GFX", "WorldMapTileGraphics", } diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index df06f74e..b8233558 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -20442,6 +20442,10 @@ 19, 21537 ], + "GameBoyTiles": [ + 30, + 23584 + ], "GameCornerPrizeRoom_h": [ 18, 20708 @@ -20818,6 +20822,10 @@ 9, 21671 ], + "LinkCableTiles": [ + 30, + 23632 + ], "LoreleiPic": [ 19, 30585 @@ -22122,10 +22130,22 @@ 28, 20288 ], + "TradeBubbleIconGFX": [ + 28, + 23129 + ], "TradeCenter_h": [ 19, 32004 ], + "TradingAnimationGraphics": [ + 14, + 27070 + ], + "TradingAnimationGraphics2": [ + 14, + 27854 + ], "TrainerAI": [ 14, 25902 diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 29ea88c7..753db374 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -20419,6 +20419,10 @@ 19, 21537 ], + "GameBoyTiles": [ + 30, + 23584 + ], "GameCornerPrizeRoom_h": [ 18, 20708 @@ -20795,6 +20799,10 @@ 9, 21671 ], + "LinkCableTiles": [ + 30, + 23632 + ], "LoreleiPic": [ 19, 30585 @@ -22099,10 +22107,22 @@ 28, 20288 ], + "TradeBubbleIconGFX": [ + 28, + 23129 + ], "TradeCenter_h": [ 19, 32004 ], + "TradingAnimationGraphics": [ + 14, + 27070 + ], + "TradingAnimationGraphics2": [ + 14, + 27854 + ], "TrainerAI": [ 14, 25902 diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index d62595fa..9bda56d2 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -21703,6 +21703,10 @@ 19, 21537 ], + "GameBoyTiles": [ + 30, + 23932 + ], "GameCornerBeauty1Text": [ 18, 19656 @@ -22251,6 +22255,10 @@ 9, 21604 ], + "LinkCableTiles": [ + 30, + 23980 + ], "LoreleiPic": [ 19, 30454 @@ -26371,6 +26379,10 @@ 28, 20420 ], + "TradeBubbleIconGFX": [ + 28, + 23302 + ], "TradeCenterOpponentText": [ 19, 32451 @@ -26379,6 +26391,14 @@ 19, 32377 ], + "TradingAnimationGraphics": [ + 14, + 27240 + ], + "TradingAnimationGraphics2": [ + 14, + 28024 + ], "TrainerAI": [ 14, 26034