From 35b3fa6d9c9bbf0cc4c804185fa0f8013724d59a Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sun, 2 Aug 2026 08:17:16 -0400 Subject: [PATCH] CLOSES #455, CLOSES #487, CLOSES #501, CLOSES #540, CLOSES #585, CLOSES #591, CLOSES #593, CLOSES #595, CLOSES #597, CLOSES #599, CLOSES #600, CLOSES #606, CLOSES #607, CLOSES #610, CLOSES #613, CLOSES #616, CLOSES #620, CLOSES #626, CLOSES #632, CLOSES #633, CLOSES #647 --- CONTRIBUTING-mods.md | 10 + data/scripts/flavor/viridian_school_house.lua | 118 ++++++-- data/scripts/oaks_lab.lua | 31 +- data/scripts/safari.lua | 100 +++++-- data/scripts/seafoam.lua | 43 ++- data/scripts/story.lua | 31 +- docs/new-features.md | 70 ++++- main.lua | 5 + mobile/ANDROID.md | 17 +- .../love/src/jni/love/src/common/android.cpp | 35 +++ .../love/src/jni/love/src/common/android.h | 8 + .../jni/love/src/modules/system/System.cpp | 14 + .../src/jni/love/src/modules/system/System.h | 8 + .../love/src/modules/system/wrap_System.cpp | 11 + .../java/org/love2d/android/GameActivity.java | 69 +++++ scripts/build_ios.sh | 33 ++- scripts/test.sh | 1 + src/battle/BattleState.lua | 16 +- src/battle/MoveEffects.lua | 20 +- src/battle/WideBattle.lua | 16 +- src/core/ChipAudio.lua | 10 +- src/core/ChipSynth.lua | 27 +- src/core/FixedStep.lua | 14 +- src/core/Game.lua | 23 ++ src/core/HostShell.lua | 166 +++++++++++ src/core/Input.lua | 51 +++- src/core/SaveData.lua | 16 +- src/core/Sound.lua | 45 ++- src/core/TouchControls.lua | 173 +++++++++-- src/import/RomImporter.lua | 103 ++++++- src/link/Handshake.lua | 80 ++++- src/link/LinkState.lua | 16 +- src/mods/LauncherMods.lua | 15 + src/mods/ManagerState.lua | 84 +++++- src/mods/Manifest.lua | 17 +- src/mods/ModIndex.lua | 33 +-- src/mods/ModProfile.lua | 206 +++++++++++++ src/mods/ModUpdate.lua | 58 ++-- src/render/TextBox.lua | 21 +- src/render/Transition.lua | 36 ++- src/ui/BindingsMenu.lua | 25 ++ src/ui/PartyMenu.lua | 14 +- src/ui/StartMenu.lua | 13 +- src/ui/TouchControlsEditor.lua | 62 +++- src/world/PikachuFollower.lua | 48 +++ tests/drivers/audio_channels_bug626_test.lua | 136 +++++++++ .../bills_pikachu_watch_bug455_test.lua | 274 +++++++++++++++++ tests/drivers/host_console_bug606_test.lua | 224 ++++++++++++++ .../drivers/item_picker_wheel_bug595_test.lua | 228 +++++++++++++++ .../launcher_mods_bulk_bug647_test.lua | 226 ++++++++++++++ tests/drivers/mod_index_bug597_test.lua | 153 ++++++++++ tests/drivers/mod_profiles_bug593_test.lua | 197 +++++++++++++ tests/drivers/oak_dex_rating_bug600_test.lua | 214 ++++++++++++++ .../drivers/pad_double_input_bug620_test.lua | 126 ++++++++ tests/drivers/rebind_joystick_bug632_test.lua | 137 +++++++++ .../rock_tunnel_flash_blink_bug610_test.lua | 275 ++++++++++++++++++ .../route1_seam_pacing_bug487_test.lua | 128 ++++++++ tests/drivers/safari_zone_bug540_test.lua | 248 ++++++++++++++++ .../drivers/school_blackboard_bug591_test.lua | 180 ++++++++++++ tests/drivers/seafoam_holes_bug599_test.lua | 180 ++++++++++++ tests/drivers/touch_layout_bug633_test.lua | 197 +++++++++++++ tests/drivers/warp_fade_bug607_test.lua | 158 ++++++++++ tests/engine/effect_stereo_bug626.lua | 86 ++++++ tests/engine/fixed_damage_immunity_bug616.lua | 96 ++++++ tests/engine/host_hide_console_bug606.lua | 33 +++ tests/engine/oaks_lab_dex_rating_bug600.lua | 69 +++++ tests/engine/rebind_joystick_bug632.lua | 84 ++++++ tests/engine/safari_gate_bug540.lua | 233 +++++++++++++++ tests/engine/timing_parity.lua | 12 + tests/engine/touch_orientation_bug633.lua | 80 +++++ tests/input_hold_test.lua | 18 ++ tests/mod_graphics_tests.lua | 34 ++- tests/mod_link_tests.lua | 43 +++ tests/mod_ui_tests.lua | 30 ++ tests/parity_H.lua | 7 +- tests/parity_field_move_layering.lua | 10 +- tests/parity_flash_blink_bug610.lua | 90 ++++++ tests/parity_seafoam_holes.lua | 71 +++++ tests/parity_static.lua | 41 +++ ...rity_viridian_school_blackboard_bug503.lua | 62 +++- tests/parity_yellow_bills_pikachu.lua | 77 +++++ tests/run_tests.lua | 18 +- tests/save_editor_wheel_bug595_test.lua | 64 ++++ tools/save-editor/App.lua | 13 +- tools/save-editor/Kit.lua | 39 ++- tools/save-editor/State.lua | 1 + tools/save-editor/panels/Items.lua | 30 +- 87 files changed, 6349 insertions(+), 285 deletions(-) create mode 100644 src/mods/ModProfile.lua create mode 100644 tests/drivers/audio_channels_bug626_test.lua create mode 100644 tests/drivers/bills_pikachu_watch_bug455_test.lua create mode 100644 tests/drivers/host_console_bug606_test.lua create mode 100644 tests/drivers/item_picker_wheel_bug595_test.lua create mode 100644 tests/drivers/launcher_mods_bulk_bug647_test.lua create mode 100644 tests/drivers/mod_index_bug597_test.lua create mode 100644 tests/drivers/mod_profiles_bug593_test.lua create mode 100644 tests/drivers/oak_dex_rating_bug600_test.lua create mode 100644 tests/drivers/pad_double_input_bug620_test.lua create mode 100644 tests/drivers/rebind_joystick_bug632_test.lua create mode 100644 tests/drivers/rock_tunnel_flash_blink_bug610_test.lua create mode 100644 tests/drivers/route1_seam_pacing_bug487_test.lua create mode 100644 tests/drivers/safari_zone_bug540_test.lua create mode 100644 tests/drivers/school_blackboard_bug591_test.lua create mode 100644 tests/drivers/seafoam_holes_bug599_test.lua create mode 100644 tests/drivers/touch_layout_bug633_test.lua create mode 100644 tests/drivers/warp_fade_bug607_test.lua create mode 100644 tests/engine/effect_stereo_bug626.lua create mode 100644 tests/engine/fixed_damage_immunity_bug616.lua create mode 100644 tests/engine/host_hide_console_bug606.lua create mode 100644 tests/engine/oaks_lab_dex_rating_bug600.lua create mode 100644 tests/engine/rebind_joystick_bug632.lua create mode 100644 tests/engine/safari_gate_bug540.lua create mode 100644 tests/engine/touch_orientation_bug633.lua create mode 100644 tests/parity_flash_blink_bug610.lua create mode 100644 tests/parity_seafoam_holes.lua create mode 100644 tests/save_editor_wheel_bug595_test.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index d11c7a33..5f326262 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods. | `QUEST` | New story, NPCs, dialogue, cutscenes | content | | `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul | | `GRAPHICS` | Sprite / tileset / palette / font changes | content | +| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content | | `AUDIO` | Music, sfx, cries | content | | `UI` | New or modified screens, menus, overlays | content / overhaul | | `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content | @@ -128,6 +129,15 @@ not a hard error, so the list can grow without breaking old mods. keeps validating with the value it has shipped since before the taxonomy existed. +A translation may also set `"language": true` in the manifest. That is the +one claim online play acts on: an install running nothing but verified +translations may take an ONLINE MATCH or a TOURNAMENT instead of being +asked to restart vanilla. The claim is checked, not taken -- the mod +qualifies only if every record it writes lands in `text`, `strings` or +`font`, it wraps no hook, subscribes to no event and requests no +permission. Anything else and it is an ordinary content mod that happens to +ship text. + ### 4. `mod.card` The manifest is the *engine's* contract: identity, load order, dependencies, diff --git a/data/scripts/flavor/viridian_school_house.lua b/data/scripts/flavor/viridian_school_house.lua index cb690ffa..e6b0f214 100644 --- a/data/scripts/flavor/viridian_school_house.lua +++ b/data/scripts/flavor/viridian_school_house.lua @@ -9,7 +9,8 @@ -- hidden_text_predef spends the facing byte on the tx_pre id, so neither -- tile gates on facing. -local Menu = require("src.ui.Menu") +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") local TextBox = require("src.render.TextBox") -- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm): @@ -25,33 +26,112 @@ local STATUS_LABELS = { { " FRZ", "_ViridianBlackboardFrozenText" }, } +-- The headings list is a two-column menu, which src/ui/Menu.lua does not do +-- (it stacks one column), so the layout lives here (#591). .blackboardLoop: +-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box, +-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and +-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT +-- move wTopMenuItemX between those two columns and swap wMenuItemOffset +-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN +-- are not in wMenuWatchedKeys, so they only slide the cursor and loop. +local BOARD_LABELS = {} +for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end +BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT" +local BOARD_COL_X = { 1, 6 } +local BOARD_ROW_Y = 2 +local BOARD_ROWS = 3 + +local StatusBoard = {} +StatusBoard.__index = StatusBoard + +function StatusBoard.new(game, onPick, onQuit) + return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS, + onPick = onPick, onQuit = onQuit }, StatusBoard) +end + +-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2), +-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6 +-- is QUIT +function StatusBoard:selection() + return (self.col - 1) * BOARD_ROWS + self.row +end + +function StatusBoard:update() + local input = self.game.input + if input:wasPressed("up") then + -- wMenuWrappingEnabled is never set here, so both ends are hard stops + if self.row > 1 then self.row = self.row - 1 end + elseif input:wasPressed("down") then + if self.row < BOARD_ROWS then self.row = self.row + 1 end + elseif input:wasPressed("left") then + self.col = 1 + elseif input:wasPressed("right") then + self.col = 2 + elseif input:wasPressed("a") or input:wasPressed("b") then + -- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch, + -- and B and QUIT share .exitBlackboard + require("src.core.Sound").play(self.game.data, "Press_AB") + local sel = self:selection() + if input:wasPressed("b") or sel > #STATUS_LABELS then + self.onQuit() + else + self.onPick(sel) + end + end +end + +function StatusBoard:draw() + Font.drawBox(0, 0, 12, 8) + love.graphics.setColor(0, 0, 0, 1) + for i, label in ipairs(BOARD_LABELS) do + local col = i <= BOARD_ROWS and 1 or 2 + local row = i - (col - 1) * BOARD_ROWS + Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8) + end + -- wTopMenuItemX equals the column PlaceString started at, so the cursor + -- covers the blank each label leads with + Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8, + (BOARD_ROW_Y + self.row - 1) * 8) + love.graphics.setColor(1, 1, 1, 1) +end + local function blackboard(game) local text = game.data.text or {} - local items, showMenu, askHeading - function showMenu() - game.stack:push(Menu.new(game, items, - { tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 })) + local openBoard + -- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop, + -- and nothing inside the loop clears them again: after a status blurb + -- `jp .blackboardLoop` comes back with the cursor still on the row and + -- column the player just picked. One StatusBoard lives for the whole + -- reading and is re-pushed each pass, so only entering the blackboard + -- resets to the left column / top row (#591). + local board + -- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then + -- calls HandleMenuInput, so the prompt is on screen for exactly as long as + -- the headings list is. That text ends in `done`, not `prompt` + -- (data/text/text_2.asm:646), so PrintText returns with the box still up + -- and never waits for a button: TextBox opts.stay holds it open under the + -- list and these callbacks pop the pair together (#591). + local function closeBoard() + game.stack:pop() -- the headings list + game.stack:pop() -- the held "Which heading" box under it end - -- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop - -- pass, immediately before HandleMenuInput - function askHeading() + local function pick(i) + closeBoard() + game.stack:push(TextBox.new(game, + text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard)) + end + function openBoard() game.stack:push(TextBox.new(game, text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?", - showMenu)) + nil, { stay = { onShown = function() + board = board or StatusBoard.new(game, pick, closeBoard) + game.stack:push(board) + end } })) end - items = {} - for i, row in ipairs(STATUS_LABELS) do - local label, key = row[1], row[2] - items[i] = { label = label, onSelect = function() - game.stack:push(TextBox.new(game, text[key] or label, askHeading)) - end } - end - -- no onSelect: Menu's own pop closes the box, matching .exitBlackboard - items[#items + 1] = { label = " QUIT" } game.stack:push(TextBox.new(game, text._ViridianSchoolBlackboardText1 or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.", - askHeading)) + openBoard)) end -- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm): diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index 2b3058ed..7a1bed16 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -25,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall, { "jump_if_true", 20 }, -- 2 -- no picking until Oak has walked you in (OaksLabScript gating) { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 - { "jump_if_false", 22 }, -- 4 + { "jump_if_false", 23 }, -- 4 -- the Pokédex "new species" entry shows before the ask (predef -- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily -- sets the owned bits so ShowPokedexData prints height/weight/text; @@ -63,7 +63,10 @@ local function starterBall(askText, species, choseFlag, ownBall, -- out as Pokémon here. { "face_object", 5, "down" }, -- 20 { "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21 - { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22 + -- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall + -- through into the pre-pick line below (#601 remnant, reported on #600) + { "jump", "end" }, -- 22 + { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 23 } end @@ -71,10 +74,22 @@ return { talk = { -- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_ -- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk- - -- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating - -- (DisplayDexRating) is still skipped. + -- in, full Pokédex speech, rival exit, Route 22 arm). TEXT_OAKSLAB_OAK1 = { { "face_player" }, + -- OaksLabOak1Text leads with the dex-rating branch (#600): with + -- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or + -- 2+ species owned once the Pokédex is in hand, Oak asks how it is + -- coming and rates it (predef DisplayDexRating). Red keeps the + -- GOT_POKEDEX gate that Yellow's copy of this text drops + -- (data/scripts/oaks_lab_yellow.lua). + { "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + { "jump_if_true", "dex_rating" }, + { "check_dex_owned", 2 }, + { "jump_if_false", "no_rating" }, + { "check_flag", "EVENT_GOT_POKEDEX" }, + { "jump_if_true", "dex_rating" }, + { "label", "no_rating" }, { "check_item", "POKE_BALL" }, { "jump_if_true", "come_see" }, { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, @@ -160,6 +175,14 @@ return { { "label", "come_see" }, { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, + { "jump", "end" }, + + -- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text + -- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned + -- tally follows with no button wait (engine/events/pokedex_rating.asm) + { "label", "dex_rating" }, + { "show_text", "_OaksLabOak1HowIsYourPokedexComingText" }, + { "dex_rating" }, }, TEXT_OAKSLAB_CHARMANDER_POKE_BALL = diff --git a/data/scripts/safari.lua b/data/scripts/safari.lua index 3cc2ce3a..782f9f1c 100644 --- a/data/scripts/safari.lua +++ b/data/scripts/safari.lua @@ -4,9 +4,11 @@ -- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over -- 30 SAFARI BALLs and starts the 502-step game -- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502, --- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back --- so you can't slip past. Returning to the gate ends the game and the --- worker takes the leftover balls back. +-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player +-- up through the north warp into the zone. Declining walks you back +-- so you can't slip past. Returning to the gate ends the game, the +-- worker takes the leftover balls back and the auto-walk drops you 3 +-- cells below the warp you came in by (#540). -- -- Step/ball bookkeeping lives in src/world/OverworldController.lua -- (safariStep/safariGameOver, from @@ -19,7 +21,33 @@ local FEE = 500 local BALLS = 30 local STEPS = 502 -local function startGame(game, t, done, balls, introText) +-- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with +-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the +-- player up out of the gate and through the north warp, it is never left to +-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so +-- the two gate steps taken before the warp fires are charged against +-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter +-- reads 500/500 on arrival even though the script wrote 502 (#540). The +-- port's counter only runs on the nine interior maps (FieldDefaults +-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two +-- steps here instead. +local function walkIntoZone(game, ow) + local p = ow.player + -- only from the two trigger cells in front of the worker, which are the + -- columns the north warps sit on; a player who paid after TALKING to him + -- from somewhere else walks in on their own, as they do today + local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil + if not w then return end + ow:scriptMove(p, "up", 2, function() + local st = game.save.safari + if st then st.steps = st.steps - 2 end + -- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision), + -- so take that warp explicitly once the walk lands on it + ow:takeWarp(w.def) + end) +end + +local function startGame(game, ow, t, done, balls, introText) game.save.safari = { balls = balls or BALLS, steps = STEPS } game.save.safariNags = nil local TextBox = require("src.render.TextBox") @@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText) local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!" local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!" - game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done)) + game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function() + if done then done() end + walkIntoZone(game, ow) + end)) end -- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of @@ -52,7 +83,7 @@ local function yellowLowCost(game, ow, t, done, back) or "\fOh, all right, pay\nme what you have.") .. "\f" .. (t._SafariZoneLowCostText2 or "But, I can't give\nyou all 30 BALLs.") - startGame(game, t, done, balls, intro) + startGame(game, ow, t, done, balls, intro) return end local nag = game.save.safariNags or 0 @@ -62,7 +93,7 @@ local function yellowLowCost(game, ow, t, done, back) (t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?") .. (t._SafariZoneLowCostText3 or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!") - startGame(game, t, done, 1, intro) + startGame(game, ow, t, done, 1, intro) return end local nags = { @@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done) end else game.save.money = game.save.money - FEE - startGame(game, t, done) + startGame(game, ow, t, done) end end)) end)) @@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = { -- no walks you back into the zone onEnter = function(game, ow) if not game.save.safari or ow.player.cellY > 1 then return end - local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") - local t = game.data.text - game.stack:push(TextBox.new(game, - t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?", - function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then - -- back into the zone through the entrance warp - local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1] - ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up") - return - end - game.save.safari = nil - game.stack:push(TextBox.new(game, - (t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText - or "Please return any\nSAFARI BALLs you\nhave left.") - .. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText - or "Did you get a\ngood haul?\fCome again!"))) - end)) - end)) + -- QUEUED, never pushed: onEnter runs inside the arriving warp's + -- Transition midpoint, and Transition:finish pops whatever is on top + -- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is + -- swallowed, and on a build where it survived it drew over a screen + -- still faded to black (#540). Same contract as M.HALL_OF_FAME in + -- data/scripts/story.lua. + -- + -- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the + -- return-balls text, faces the player down and runs + -- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the + -- counter row 3 cells below the warp you came in by; NO prints + -- "Good Luck!" and walks one step back up through that same warp. + local rightSide = ow.player.cellX ~= 3 + local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1] + ow:queueScript({ + { "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" }, + { "jump_if_false", "stay" }, + -- the port never reaches SafariZoneGateLeavingSafariScript's own + -- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the + -- counter), so the sign-off rides on this path + { "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" }, + { "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" }, + -- no value: set_field assigns nil, which is how save.safari is cleared + { "set_field", "safari" }, + -- move_player runs through scriptMove, which skips onStepComplete, so + -- walking back down past (x,2) cannot re-fire the join trigger + { "move_player", "down", 3 }, + { "jump", "end" }, + { "label", "stay" }, + { "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" }, + { "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" }, + }) end, } diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua index dcd1afe4..788e32e0 100644 --- a/data/scripts/seafoam.lua +++ b/data/scripts/seafoam.lua @@ -11,7 +11,7 @@ -- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and -- B3F's pluggedByHolesOn) plus the generic -- OverworldState:boulderIntoHole in src/world/OverworldController.lua; --- no per-map onEnter hook is needed here. +-- no per-map onEnter hook is needed for the boulders. local M = {} @@ -23,4 +23,45 @@ M.VERMILION_GYM = { end, } +-- The PLAYER falling down those same holes (#599). field.seafoam's holes +-- carry only the BOULDER's object cell on the floor below (landsAt), not +-- the player's landing, so the player's landing is spelled out here: it +-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData, +-- which the importer does not extract. Same shape as MANSION_HOLES in +-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in +-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has +-- to be an onStep, not a collision block. +-- +-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6), +-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords +-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor +-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and +-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in +-- the original: a plugged hole still drops the player. The B3F and B4F +-- landings are water; setMap's CheckForceBikeOrSurf pass +-- (OverworldState:checkForcedMovement) mounts SURF on arrival. +local HOLE_FALLS = { + SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 }, + { 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } }, + SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 }, + { 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } }, + SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 }, + { 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } }, + SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 }, + { 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } }, +} + +for mapId, holes in pairs(HOLE_FALLS) do + M[mapId] = M[mapId] or {} + M[mapId].onStep = function(game, ow, x, y) + for _, h in ipairs(holes) do + if x == h[1] and y == h[2] then + ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) + return true + end + end + return false + end +end + return M diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 84d17361..040c32c3 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -188,7 +188,12 @@ M.BILLS_HOUSE = { end if ow.player.facing == "down" then -- the player is standing on his straight path: walk around - -- (.PokemonWalkAroundPlayerMovement) + -- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs + -- BillsHousePikachuWatchPlayer first on this branch, so a + -- Pikachu that is still following steps clear of Bill's detour + -- and turns to watch the player (#455). + require("src.world.PikachuFollower") + .onBillWalksAroundPlayer(game, ow) ow:scriptMove(npc, "right", 1, function() ow:scriptMove(npc, "up", 2, function() ow:scriptMove(npc, "left", 1, function() @@ -600,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText) } end +-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run +-- HideObject in the same breath as the battle that sets +-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a +-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a +-- save edited or migrated from a build older than the flag), and it is a +-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax +-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/ +-- item_effects.asm), so the sleeper sits in the road forever and Cycling +-- Road is unreachable (#585). Reconcile the toggle from the flag on every +-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the +-- same repair shape the Silph Co. floors use below. +local function hideBeatenSnorlax(mapId, objName, beatFlag) + return function(game, ow) + if not game.save.flags[beatFlag] then return end + local Commands = require("src.script.Commands") + Commands.hide_object({ game = game, save = game.save, overworld = ow }, + mapId, objName) + end +end + -- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via -- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax; -- objName/beatFlag let ItemEffects find the NPC and check whether it's -- already been beaten before allowing the wake. M.ROUTE_12 = { talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } }, + onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX", + "EVENT_BEAT_ROUTE12_SNORLAX"), snorlaxWake = { objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX", script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX", @@ -614,6 +641,8 @@ M.ROUTE_12 = { } M.ROUTE_16 = { talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } }, + onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX", + "EVENT_BEAT_ROUTE16_SNORLAX"), snorlaxWake = { objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX", script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX", diff --git a/docs/new-features.md b/docs/new-features.md index 70f7ff53..532cbf3a 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -209,10 +209,15 @@ duration: closes, and apply again after. Fast-forward otherwise runs one peer's queue faster than the peer it is locked to and drains a tournament shot clock faster than the opponent racing it. -- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with - mods enabled offers to switch them all off and relaunch (mods merge at - boot, so a restart is the only way). The restart is confirmed, not - silent. They stay listed as disabled, ready to switch back on. +- **Online play runs vanilla, except for your language.** Picking ONLINE + MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones + off and relaunch (mods merge at boot, so a restart is the only way). The + restart is confirmed, not silent. They stay listed as disabled, ready to + switch back on. A mod that declares itself a translation and provably + writes nothing but text stays on: the two games hash the same link + surface, so a Spanish install and an English one can battle and trade, + each reading the game in its own language and naming the other player's + party out of its own text. - **Only a meaningful split ends a match.** The per-turn state signature both peers exchange is split three ways: `actives` and `bench` carry species, HP, status, stat stages, PP and the rest of the party, and a @@ -314,12 +319,18 @@ window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces the overlay on and lets the mouse act as a finger (`=0` forces it off). The launcher's **Touch Controls** button opens a drag editor: move each -button freely, **Disable** to hide the overlay permanently (for -controllers / emulation handhelds -- distinct from the temporary -gamepad auto-hide), **Reset** for defaults, **Done** to save into -`options.lua` as normalized window fractions so rotation keeps the -relative placement. In-game, Options → **TOUCH PAD** toggles the same -on/off flag without leaving a play session. +button freely, resize the whole pad with **-/+** (60% to 160%), **Disable** +to hide the overlay permanently (for controllers / emulation handhelds -- +distinct from the temporary gamepad auto-hide), **Reset** for defaults, +**Done** to save into `options.lua` as normalized window fractions so a +different screen keeps the relative placement. + +Portrait and landscape are edited and saved separately (#633): the editor +follows whichever orientation is on screen, and **Reset** only clears that +one, so a layout that works held upright does not have to double as the +one used sideways. An `options.lua` from before this split keeps its single +layout in both orientations until one of them is edited. In-game, Options → +**TOUCH PAD** toggles the same on/off flag without leaving a play session. ## Translation support @@ -382,7 +393,9 @@ semantics - so the two windows read as one app. Six tabs: - **Items**: money, a searchable item picker (replacing the arrows that cycled one id at a time through ~250 items), the configurable bag (20 slots by default), PC storage - with no slot cap, and the eight badges as toggle chips. + with no slot cap, and the eight badges as toggle chips. The picker, the bag + and PC storage all scroll under the mouse wheel, so the whole catalog is + reachable one-handed without typing a query. - **Events**: flags, defeated trainers, taken items and per-map object toggles, with a real filter field and a two-column paged grid. - **Map**: any map rendered with the game's own renderer, warps followable, @@ -460,3 +473,38 @@ two buttons. Holding a second key or pad button while the first is still down backs out of the capture without touching a keyboard; Escape still cancels too. SELECT clears one row back to its default, and START resets every binding after a confirmation. + +Controllers a system has no mapping for (common on Linux handhelds and +off-brand pads) report bare button numbers rather than names. Those are +rebindable on the same screen and show up as JOY1, JOY2 and so on in the +controller column. Recognized controllers are read only through their +named buttons, so a rebind on those is never shadowed by the factory +layout underneath it. + +## Mod profiles (#593) + +The mod manager's PROFILES tab holds named setups. A profile remembers which +mods are on, every mod's own options, and which save slot each game version +plays, so swapping profiles swaps the whole playthrough and not just the mod +list. The setup that existed before profiles shipped becomes PROFILE 1 the +first time the manager opens. + +EXPORT.. writes the selected profile to `profiles/.g1rmodlist` in the +save directory; drop a `.g1rmodlist` someone shared into that folder and +IMPORT.. adds it. Imported profiles never overwrite an existing one (a name +clash gets a number). Mods the shared profile names but that are not installed +are reported when the profile is applied; installing them is still a manual +trip through the mods list or Find Mods. + +## Windows: no console windows on launcher actions + +Checking for updates, browsing a mod index, adding a mod repo, installing a +mod and picking a ROM all run a host tool (curl, PowerShell) in a child +process. On Windows those children used to each open their own console +window, so a session could end up buried under half a dozen of them. The +game now claims one console for itself at boot and hides it; the children +inherit that invisible console and nothing pops up. Nothing else changes: +file pickers are ordinary desktop dialogs and still appear normally, and a +run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers) +keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt +out. diff --git a/main.lua b/main.lua index 8d3a368c..4212d3b1 100644 --- a/main.lua +++ b/main.lua @@ -184,6 +184,11 @@ local function bootGame(version) end function love.load(args) + -- Before anything can shell out (update check, mod index, ROM picker), + -- claim one hidden console on Windows so those children inherit it instead + -- of each flashing their own cmd.exe window (#606). No-op elsewhere. + require("src.core.HostShell").hideHostConsole() + -- Self-updater boot shell: a fused build may mount and chainload a newer -- downloaded payload here. True means it took over, so we must stop. A -- dev / source checkout no-ops (see src/update/Boot.lua). diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index b0bc4479..6683efde 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -73,6 +73,21 @@ of the build script's permission trim). The first read runs immediately (`onRequestPermissionsResult`, `STEP_PERMISSION_REQUEST_CODE`). +### Network transport (mod index / mod updates) + +`love.system.httpDownload(url, absPath [, userAgent [, accept]])` -> +`GameActivity.httpDownload` (same JNI route as the picker and the step +bridge: `common/android.cpp` -> `modules/system/System.cpp` -> +`wrap_System.cpp`). Android ships no `curl`, which is what the desktop +builds fetch the mod index, mod release lists and mod zips with, so the +"Find mods" tab used to fail with "curl is not available on this +platform" (#597). The Java side is a blocking `HttpsURLConnection` GET +(https only, redirects followed by hand, body renamed into place only +once complete) and runs on LOVE's Lua thread, never the UI thread. +`src/core/HostShell.lua` picks the transport: curl when present, +otherwise this bridge; an APK older than the bridge simply reports no +transport, exactly as a missing curl does. + ### SDK / NDK love-android 11.5a expects: @@ -107,7 +122,7 @@ scripts, tests, and mobile build sources are excluded. | `app.name` | Pokemon Red | | `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. | | `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted | -| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept | +| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept | ## Releases diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index f295e527..d7b9230f 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -253,6 +253,41 @@ bool restartApp() return result; } +bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) +{ + if (url == nullptr || destPath == nullptr) + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + + // Old APK / new liblove skew: report "no transport" the same way a + // missing curl does, instead of aborting on a missing method (#597). + jmethodID method = env->GetStaticMethodID(activity, "httpDownload", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jstring jurl = env->NewStringUTF(url); + jstring jdest = env->NewStringUTF(destPath); + jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp"); + jstring jaccept = accept != nullptr ? env->NewStringUTF(accept) : nullptr; + + jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jdest, jua, jaccept); + + env->DeleteLocalRef(jurl); + env->DeleteLocalRef(jdest); + env->DeleteLocalRef(jua); + if (jaccept != nullptr) + env->DeleteLocalRef(jaccept); + env->DeleteLocalRef(activity); + return result; +} + /* * Helper functions for the filesystem module */ diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 3f07e496..23bb905f 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -90,6 +90,14 @@ bool syncHealthSteps(); **/ bool restartApp(); +/** + * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has + * no curl binary, so this is the transport src/core/HostShell.lua uses there + * for the mod index and mod updates (#597). userAgent / accept may be null. + * Returns whether a complete file was written. + **/ +bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept); + /* * Helper functions for the filesystem module */ diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 0bbf214b..8dd9e8de 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -230,6 +230,20 @@ bool System::restartApp() const #endif } +bool System::httpDownload(const char *url, const char *destPath, + const char *userAgent, const char *accept) const +{ +#ifdef LOVE_ANDROID + return love::android::httpDownload(url, destPath, userAgent, accept); +#else + LOVE_UNUSED(url); + LOVE_UNUSED(destPath); + LOVE_UNUSED(userAgent); + LOVE_UNUSED(accept); + return false; +#endif +} + bool System::hasBackgroundMusic() const { #if defined(LOVE_ANDROID) diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index c0fc76b4..be1ea307 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -141,6 +141,14 @@ public: **/ virtual bool restartApp() const; + /** + * Blocking HTTPS GET into an absolute host path (Android only; false + * elsewhere). Android has no curl, which is what every other platform + * fetches the mod index with (#597). + **/ + virtual bool httpDownload(const char *url, const char *destPath, + const char *userAgent = nullptr, const char *accept = nullptr) const; + /** * Gets if the user is playing music on background. * Throws an exception on unsupported platforms. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 8c4fb9cd..1b94ef7c 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -123,6 +123,16 @@ int w_restartApp(lua_State *L) return 1; } +int w_httpDownload(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + const char *dest = luaL_checkstring(L, 2); + const char *ua = luaL_optstring(L, 3, nullptr); + const char *accept = luaL_optstring(L, 4, nullptr); + luax_pushboolean(L, instance()->httpDownload(url, dest, ua, accept)); + return 1; +} + int w_hasBackgroundMusic(lua_State *L) { lua_pushboolean(L, instance()->hasBackgroundMusic()); @@ -142,6 +152,7 @@ static const luaL_Reg functions[] = { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, + { "httpDownload", w_httpDownload }, { "hasBackgroundMusic", w_hasBackgroundMusic }, { 0, 0 } }; diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 5c0380b5..04c8a827 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -31,6 +31,8 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -565,6 +567,73 @@ public class GameActivity extends SDLActivity { return true; // unreachable, but keeps the JNI signature honest } + /** + * Blocking HTTPS GET into destPath, exposed as love.system.httpDownload + * and used by src/core/HostShell.lua. Android ships no curl binary, so + * every remote fetch the desktop builds do with curl (mod index feeds, + * mod release lists, mod zips, thumbnails) comes through here (#597). + * Runs on LOVE's Lua thread, never the UI thread, so the platform's + * network-on-main-thread rule is not in play and a blocking call matches + * the curl semantics the Lua callers already expect. + * + * Redirects are followed by hand because HttpURLConnection silently drops + * a redirect that changes protocol, and only https is accepted: the feeds + * live on GitHub Pages / raw and a downgrade to http must fail, not fetch. + * The body lands in a .part file and is renamed only once complete, so a + * dropped connection can never leave a half file the caller trusts. + */ + @Keep + public static boolean httpDownload(String url, String destPath, String userAgent, String accept) { + if (url == null || destPath == null) return false; + HttpURLConnection conn = null; + File tmp = new File(destPath + ".part"); + try { + String current = url; + for (int hop = 0; hop < 5; hop++) { + URL parsed = new URL(current); + if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false; + conn = (HttpURLConnection) parsed.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setRequestProperty("User-Agent", + userAgent == null ? "gen1recomp" : userAgent); + if (accept != null) conn.setRequestProperty("Accept", accept); + int code = conn.getResponseCode(); + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String next = conn.getHeaderField("Location"); + conn.disconnect(); + conn = null; + if (next == null) return false; + current = new URL(parsed, next).toString(); + continue; + } + if (code < 200 || code > 299) return false; + InputStream in = new BufferedInputStream(conn.getInputStream()); + OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp)); + try { + byte[] buf = new byte[16384]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + } finally { + try { out.close(); } catch (IOException ignored) {} + try { in.close(); } catch (IOException ignored) {} + } + File dest = new File(destPath); + dest.delete(); + if (!tmp.renameTo(dest)) return false; + return dest.length() > 0; + } + return false; + } catch (Exception e) { + Log.d("GameActivity", "httpDownload failed: " + e.getMessage()); + return false; + } finally { + if (conn != null) conn.disconnect(); + if (tmp.exists()) tmp.delete(); + } + } + /** * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * (pending_export.sav in the app save identity) to Downloads / Drive / diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 5e8adc9d..1a418ddf 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -11,7 +11,8 @@ # --install after a --device build, install the app onto the # first connected iPhone/iPad (unlock it first) # --release Release configuration -# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION +# --version X.Y.Z stamp the engine version into game.love plus +# MARKETING_VERSION / CURRENT_PROJECT_VERSION # --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/ # --package-only Zip game.love + apply plist overlay; skip xcodebuild # @@ -341,6 +342,36 @@ pack_game_love() { || fail "game.love is missing $required" done say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" + + # This script packs its own game.love (it does not reuse build.sh's), so it + # stamps the release version the same way build.sh and build_android.sh do: + # patch a copy of Version.lua (engine set to $VERSION) under a throwaway + # staging dir and replace the entry inside the archive in place -- never the + # source tree. Stamping the Info.plist alone is not enough: the mod loader + # reads Version.engine out of game.love (src/mods/Loader.lua game_version + # gate), so an unstamped archive reports "0.0.0-dev" and every mod with a + # version floor is rejected on iOS while it loads on desktop (#613). + # VERSION is already validated as X.Y.Z above; when it is empty the packaged + # game keeps the "0.0.0-dev" default so a dev build cannot pass for a + # release. The stamp is read back out and the build fails if it did not take. + if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + say "stamping engine version $VERSION into game.love" + local stamp_dir + stamp_dir="$(mktemp -d)" + mkdir -p "$stamp_dir/src/core" + sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \ + "$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua" + (cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua) + local version_re + version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" + unzip -p "$LOVE_FILE" src/core/Version.lua \ + | grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \ + || fail "version stamp failed: game.love does not report engine $VERSION" + rm -rf "$stamp_dir" + say "stamped engine version: $VERSION" + else + say "no X.Y.Z --version, shipping default engine (no stamp)" + fi } # Ensure game.love is in the love-ios Copy Bundle Resources phase (idempotent). diff --git a/scripts/test.sh b/scripts/test.sh index 62a73349..ed3ac9d9 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -123,6 +123,7 @@ if [ -f data/generated/maps.lua ]; then run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua + run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua fi else diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a7f55aab..e8f6872e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5221,11 +5221,12 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self.enemyParty, 64, 16, -8) end - -- Safari shows only the ball count; the old man demo shows neither mon - if self.safari then - love.graphics.setColor(0, 0, 0, 1) - Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) - end + -- A safari / old-man battle has no player mon out, so no player HUD (see + -- hidePlayer below). Nothing takes its place: PrintSafariZoneSteps -- the + -- "nnn/500 / BALLx nn" box -- is start-menu only and returns early off the + -- nine interior maps (engine/overworld/player_state.asm:219-224), so no + -- ball count belongs over the battlefield. The count lives in the BALL + -- menu item instead (drawTextArea, #540). -- Party pokeball rows and the HUD chrome under them, for exactly the -- window DrawAllPokeballs owns (common_text.asm:27, with the intro text). -- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs @@ -5330,6 +5331,11 @@ function BattleState:drawTextArea() Font.drawBox(0, 12, 20, 6) Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128) + -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print + -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two + -- digits, space padded -- right after the "BALLx" label at columns + -- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540) + Font.draw(("%2d"):format(self.safari.balls), 56, 112) Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) else -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index f6cf5699..8beb22f3 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -435,8 +435,18 @@ local function drainHalf(text) end end --- fixed damage still respects type immunity (AdjustDamageForMoveType --- flags the miss before the special-damage override) +-- OHKO is the only "damage but not through normal calculations" family +-- that still runs the type chart. CalculateDamage hands OHKO_EFFECT to +-- JumpToOHKOMoveEffect (engine/battle/core.asm:4329) and, when the effect +-- did not set wMoveMissed, execution falls through to +-- AdjustDamageForMoveType (core.asm:3147), which multiplies the 65535 by +-- the 0 matchup and flags the miss. Fixed damage and Super Fang do not: +-- they are the SetDamageEffects table (data/battle/set_damage_effects.asm) +-- and core.asm:3139 jumps straight to MoveHitTest, skipping +-- CalculateDamage, AdjustDamageForMoveType and RandomizeDamage, while +-- ApplyAttackToEnemyPokemon (core.asm:4612) writes wDamage with no +-- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and +-- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616). local function immuneMsg(ctx) if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then return Strings("It doesn't affect\n%s!", displayName(ctx.target)) @@ -466,8 +476,7 @@ MoveEffects.full = { SPECIAL_DAMAGE_EFFECT = { chooseDamage = function(ctx) - local blocked = immuneMsg(ctx) - if blocked then return nil, blocked end + -- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616) local dmg = fixedDamageFor(ctx) if not dmg then return nil, "But, it failed!" end return dmg, plainInfo() @@ -475,8 +484,7 @@ MoveEffects.full = { }, SUPER_FANG_EFFECT = { chooseDamage = function(ctx) - local blocked = immuneMsg(ctx) - if blocked then return nil, blocked end + -- also SetDamageEffects: halves a Ghost's HP in Gen 1 (#616) return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo() end, }, diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 81f8e476..ad87b326 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -134,12 +134,13 @@ local function drawHUDs(battle, slide) drawStatusPanel(battle, battle.enemy, 0, 0, false) end - if battle.safari then - Font.drawBox(23, 7, 15, 4) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72) - elseif battle.player and not battle.demo and not battle.showPlayerBack - and slide == 0 then + -- No player status panel in a safari / old-man battle: no mon of the + -- player's is out. Nothing replaces it either -- the ball count is a menu + -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside + -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in + -- drawCommandMenu below like the classic layout's (#540). + if not battle.safari and battle.player and not battle.demo + and not battle.showPlayerBack and slide == 0 then drawStatusPanel(battle, battle.player, 184, 56, true) end drawIntroBalls(battle) @@ -172,6 +173,9 @@ local function drawCommandMenu(battle) Font.drawBox(0, 13, 38, 5) love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("BALLx"), 16, 112) + -- wNumSafariBalls immediately after the label, as at hlcoord 7,14 + -- (engine/battle/core.asm:2074-2079) (#540) + Font.draw(("%2d"):format(battle.safari.balls), 56, 112) Font.draw(Strings("BAIT"), 168, 112) Font.draw(Strings("THROW ROCK"), 16, 128) Font.draw(Strings("RUN"), 168, 128) diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index a7049f5d..91bcea8b 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -418,16 +418,22 @@ function ChipAudio.newCry(data, species, resolved) }) end +-- Two channels for the same reason ChipSynth.renderEffectData renders stereo: +-- a mono Source is spatialized by OpenAL at the listener position and spreads +-- over every output an interface has (#626). The siren itself is unchanged, +-- both channels carry the same sample. function ChipAudio.newLowHealthAlarm() local samples = math.floor(SAMPLE_RATE * 62 / 60) - local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1) + local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2) local phase = 0 for index = 0, samples - 1 do local frame = math.floor(index * 60 / SAMPLE_RATE) % 31 local register = frame < 11 and 0x750 or 0x6EE local frequency = 131072 / (2048 - register) phase = (phase + frequency / SAMPLE_RATE) % 1 - data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25) + local value = (phase < 0.5 and 1 or -1) * 0.25 + data:setSample(index, 1, value) + data:setSample(index, 2, value) end return love.audio.newSource(data, "static") end diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1c62a182..fb29c218 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -808,9 +808,22 @@ local function soundData(engine, samples, channels) return result end --- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is --- too short to be audible. The caller wraps it in a static love.audio.Source --- (a playback concern, hence not done here). +-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when +-- it is too short to be audible. The caller wraps it in a static +-- love.audio.Source (a playback concern, hence not done here). +-- +-- The synthesis is mono (one summed value per frame, unlike the music path's +-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only +-- spatializes 1-channel Sources, and a Source left at the default (0,0,0) +-- position, exactly where the listener sits, is rendered as an ambient sound +-- spread over EVERY output channel the device exposes at gains that differ +-- from the front pair. On an interface with more than two outputs that put +-- the SFX on outputs 5+6 as well, while the 2-channel music source +-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip +-- spatialization entirely and map onto the front pair, so duplicating the +-- sample costs one buffer's memory and makes effects route exactly like +-- music. Deliberately not sampleStereo: that honors the NR51 panning byte +-- and would newly hard-pan any effect whose header issues command 0xEE. local function renderEffectData(data, header, options) if not header then return nil end options = options or {} @@ -825,8 +838,12 @@ local function renderEffectData(data, header, options) values[count] = engine:sample() end if count < math.floor(SAMPLE_RATE / 100) then return nil end - local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1) - for index = 1, count do result:setSample(index - 1, values[index]) end + local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2) + for index = 1, count do + local value = values[index] + result:setSample(index - 1, 1, value) + result:setSample(index - 1, 2, value) + end return result end diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index fe773e27..792df70a 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -7,6 +7,18 @@ local FixedStep = {} FixedStep.STEP = 1 / 60 local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall +-- Phase the accumulator is re-seeded with once an absorbed hitch frame has +-- been paid for. Half a step is the balanced point: a frame has to come in +-- ~8ms short before it drops a step and ~8ms long before it doubles one up. +-- Zero -- what this used to leave behind -- has no margin at all on the long +-- side, so the accumulator settles a hair BELOW one step and parks there, and +-- ordinary sub-millisecond vsync wobble then flips it between 0 and 2 steps +-- every few frames for the rest of the session. That is why pacing stayed +-- visibly broken after a route/city seam and nowhere else: crossConnection in +-- src/world/OverworldController.lua is the only caller of discardCatchup, and +-- warps go through Transition instead (issue #487). +local RESEED_PHASE = 0.5 + function FixedStep:init(callback) self.accum = 0 self.callback = callback @@ -27,7 +39,7 @@ function FixedStep:update(dt) -- the burst it would otherwise release doesn't play out as a slide. if self.suppressCatchup then self.suppressCatchup = false - self.accum = 0 + self.accum = self.STEP * RESEED_PHASE self.callback(self.STEP) return end diff --git a/src/core/Game.lua b/src/core/Game.lua index b7f77b99..832507ce 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -643,15 +643,38 @@ local function isAccelerometer(joystick) return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil end +-- BindingsMenu's raw-stick capture rides the same top-state routing as the +-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize +-- as a gamepad reaches the capture: a recognized pad raises BOTH +-- joystickpressed and gamepadpressed for one press, and the joystick half +-- would otherwise beat its own gamepadpressed to the armed row and record +-- "JOY1" for a button the player can plainly see is A. Same predicate as +-- Input's, kept local here so Game never reaches into Input's internals. +local function isRawStick(joystick) + return not (joystick and joystick.isGamepad and joystick:isGamepad()) +end + function Game:joystickpressed(joystick, button) if isAccelerometer(joystick) then return end TouchControls:noteGamepad() + local top = self.stack and self.stack:top() + if isRawStick(joystick) and top and top.onJoystickPressed then + top:onJoystickPressed(button) + return + end Input:joystickpressed(joystick, button) end function Game:joystickreleased(joystick, button) if isAccelerometer(joystick) then return end + -- same observe-after-Input contract as Game:keyreleased (#589): the + -- capture watches the release, it never owns it, so a held-state flag + -- Input saw go down before the capture armed cannot be stranded Input:joystickreleased(joystick, button) + local top = self.stack and self.stack:top() + if isRawStick(joystick) and top and top.onJoystickReleased then + top:onJoystickReleased(button) + end end function Game:joystickaxis(joystick, axis, value) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index a6e3559e..32850e64 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -13,6 +13,55 @@ function HostShell.envPrefix() return "" end +-- Windows: every host tool we shell out to (curl for the update and mod-index +-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is +-- spawned through io.popen / os.execute, which run it under cmd.exe. A +-- GUI-subsystem process owns no console, so each of those children allocates +-- its own -- one console window flashing per call, several stacking up during +-- a mod install or an update (#606). #74 fixed the same storm for the +-- per-file cache mkdir by dropping the shell entirely (src/import/CacheFs.lua); +-- the callers above genuinely need one, so we do the other half: allocate a +-- single console for ourselves, once, and hide it. A child inherits the +-- parent's console when the parent has one, so every later spawn attaches to +-- that invisible console and pops up nothing. GUI dialogs the children raise +-- (the PowerShell OpenFileDialog) are desktop windows and still appear. +-- +-- Skipped when a console already exists, which is the developer case +-- (lovec.exe, what scripts/run.ps1 prefers, or t.console), so printed output +-- keeps landing in the terminal the game was launched from. POKEPORT_CONSOLE=1 +-- opts out entirely and restores the old behaviour. Memoized; non-Windows and +-- FFI-less builds no-op. Called once from love.load before anything shells +-- out (main.lua). +local consoleHidden = nil + +function HostShell.hideHostConsole() + if consoleHidden ~= nil then return consoleHidden end + consoleHidden = false + if os.getenv("POKEPORT_CONSOLE") == "1" then return consoleHidden end + + local okFfi, ffi = pcall(require, "ffi") + if not okFfi or ffi.os ~= "Windows" then return consoleHidden end + + -- kernel32 (AllocConsole/GetConsoleWindow) and user32 (ShowWindow) are + -- already loaded in any LOVE process, so ffi.C resolves both -- the same + -- assumption CacheFs makes for CreateDirectoryA. + pcall(ffi.cdef, [[ + void *GetConsoleWindow(void); + int AllocConsole(void); + int ShowWindow(void *hWnd, int nCmdShow); + ]]) + local ok, hidden = pcall(function() + if ffi.C.GetConsoleWindow() ~= nil then return false end + if ffi.C.AllocConsole() == 0 then return false end + local hwnd = ffi.C.GetConsoleWindow() + if hwnd == nil then return false end + ffi.C.ShowWindow(hwnd, 0) -- SW_HIDE + return true + end) + consoleHidden = (ok and hidden) or false + return consoleHidden +end + -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed function HostShell.popen(command, mode) local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") @@ -69,4 +118,121 @@ function HostShell.restart() ffi.C.execv(appimage, ffi.cast("char *const *", argv)) end +-- ------- HTTP transport ---------------------------------------------------- +-- +-- Every remote fetch (mod index, mod releases, thumbnails) used to shell out +-- to curl, which macOS / Windows 10+ / desktop Linux all ship and Android does +-- not: adding a mod index on Android died with "curl is not available on this +-- platform" (#597). Android goes through the GameActivity.httpDownload JNI +-- bridge instead (HttpsURLConnection, using the INTERNET permission link play +-- already needs), surfaced by our vendored liblove as +-- love.system.httpDownload(url, absPath, userAgent, accept). Both transports +-- block the calling thread and deal in whole files, so callers keep exactly +-- the contract they had with curl. + +-- Shell quoting for one curl argument; cmd.exe has no single-quote form. +function HostShell.quote(s) + s = tostring(s) + if love and love.system and love.system.getOS + and love.system.getOS() == "Windows" then + return '"' .. s:gsub('"', '') .. '"' + end + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +function HostShell.haveCurl() + local pipe = HostShell.popen("curl --version") + if not pipe then return false end + local readOk, out = pcall(function() return pipe:read("*a") end) + pcall(function() pipe:close() end) + return readOk and out ~= nil and out:find("curl", 1, true) ~= nil +end + +-- The bridge only exists in our Android liblove. An older APK reports nil +-- here and falls back to the "no transport" error the callers already show; +-- the iOS build compiles the same wrapper but always returns false, so gate +-- on the OS as well and keep its error message honest. +local function haveBridge() + if not (love and love.system and type(love.system.httpDownload) == "function") then + return false + end + return love.system.getOS and love.system.getOS() == "Android" +end + +-- Is any transport available at all? Callers gate on this, never on curl. +function HostShell.canFetch() + return HostShell.haveCurl() or haveBridge() +end + +-- Download url to an absolute host path. Returns true, or nil plus an error. +-- The curl branch deliberately ignores curl's exit code, as the download paths +-- always did: callers judge the result by the file they got. +function HostShell.httpDownload(url, absPath, userAgent, accept) + if type(url) ~= "string" or url == "" then return nil, "missing url" end + if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end + userAgent = userAgent or "gen1recomp" + if HostShell.haveCurl() then + local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 " + .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + if accept then + cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " + end + cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url) + local pipe = HostShell.popen(cmd) + if not pipe then return nil, "could not start download" end + pcall(function() pipe:read("*a") end) + pcall(function() pipe:close() end) + return true + end + if not haveBridge() then + return nil, "no network transport on this platform" + end + local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept) + if ok and done then return true end + return nil, "download failed" +end + +-- GET returning the body. curl streams it through a pipe; the Android bridge +-- can only write a file, so there we fetch into the save directory (the only +-- writable root on Android) and read it back. +function HostShell.httpGet(url, userAgent, accept) + if type(url) ~= "string" or url == "" then return nil, "missing url" end + userAgent = userAgent or "gen1recomp" + if HostShell.haveCurl() then + local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " + .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + if accept then + cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " + end + cmd = cmd .. HostShell.quote(url) + local pipe = HostShell.popen(cmd) + if not pipe then return nil, "could not run curl" end + local readOk, out = pcall(function() return pipe:read("*a") end) + pcall(function() pipe:close() end) + if not readOk then return nil, "fetch failed: " .. tostring(out) end + if not out or out == "" then return nil, "empty response from " .. url end + return out + end + if not haveBridge() then + return nil, "no network transport on this platform" + end + if not (love.filesystem and love.filesystem.getSaveDirectory) then + return nil, "fetch needs LOVE" + end + local dirOk, saveDir = pcall(love.filesystem.getSaveDirectory) + if not dirOk or not saveDir or saveDir == "" then + return nil, "no save directory" + end + local name = "http_fetch.tmp" + pcall(love.filesystem.remove, name) + local ok, err = HostShell.httpDownload(url, saveDir .. "/" .. name, userAgent, accept) + if not ok then return nil, err end + local readOk, body = pcall(love.filesystem.read, name) + pcall(love.filesystem.remove, name) + if not readOk or type(body) ~= "string" or body == "" then + return nil, "empty response from " .. url + end + return body +end + return HostShell diff --git a/src/core/Input.lua b/src/core/Input.lua index 9bfeb482..aca80914 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -41,7 +41,14 @@ local STICK_OFF = 0.3 -- Generic SDL joysticks expose the left stick as the first two numbered -- axes and the D-pad as a hat. This is common on Linux handhelds whose --- controller has no game-controller database entry. +-- controller has no game-controller database entry. The indices below are +-- the desktop XInput order and are only meaningful for such pads: raw +-- numbering is per-driver, and SDL's iOS/MFi driver packs only the buttons +-- a pad actually reports, which slides the D-pad down onto 7..10 (#620). +-- These are the raw DEFAULTS only: applyBindings layers the player's +-- "joyN" pad bindings over them (#632), and only a stick SDL does NOT +-- recognize as a gamepad is ever served out of this table -- see +-- joystickpressed below. local RAW_BUTTON_BINDINGS = { [1] = "a", [2] = "b", [7] = "select", [8] = "start", [9] = "select", [10] = "start", @@ -66,9 +73,10 @@ end -- BindingsMenu:storeBinding) -- without this the menu records a choice -- that never actually reaches gameplay. function Input:applyBindings(overlay) - local keys, pads = {}, {} + local keys, pads, joys = {}, {}, {} for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end + for index, action in pairs(RAW_BUTTON_BINDINGS) do joys[index] = action end for actionId, binding in pairs(overlay or {}) do if type(binding) == "table" then if binding.key then keys[binding.key] = actionId end @@ -77,8 +85,20 @@ function Input:applyBindings(overlay) keys[binding] = actionId end end + -- A pad binding named "joyN" is the Nth button of a stick SDL has no + -- game-controller-database entry for, captured on the joystick path by + -- src/ui/BindingsMenu.lua (#632). It deliberately rides the existing + -- pad slot: the CONTROLS row, the swap in BindingsMenu:storeBinding and + -- START's reset-all then all stay one code path, and this loop is the + -- only place that has to know what the name means. Laid over the raw + -- defaults AFTER them, so a rebind wins the button it claims. + for padName, action in pairs(pads) do + local n = tonumber(padName:match("^joy(%d+)$")) + if n then joys[n] = action end + end self.keyBindings = keys self.padBindings = pads + self.joyBindings = joys end -- Purely event-driven state (press sets true, release sets false) has no @@ -194,13 +214,30 @@ function Input:gamepadreleased(joystick, button) end end +-- LOVE raises love.joystickpressed for EVERY stick, including ones SDL +-- recognizes as gamepads, which raise love.gamepadpressed for the same +-- physical press as well. Answering both meant the fixed raw table +-- re-asserted the factory A/B/START/SELECT map underneath the player's +-- rebinds, so swapping A and B in CONTROLS pressed both at once and any +-- controller rebind of those four looked ignored; on iOS the MFi driver's +-- packing put the D-pad on 7..10, so a D-pad press also fired SELECT or +-- START (#620, #632). A recognized pad is served by the gamepad path +-- alone; the raw path exists for sticks with no game-controller-database +-- entry. A nil joystick is a raw stick: that is how +-- tests/input_hold_test.lua and the drivers drive this path. +local function isRawStick(joystick) + return not (joystick and joystick.isGamepad and joystick:isGamepad()) +end + function Input:joystickpressed(joystick, button) - local btn = RAW_BUTTON_BINDINGS[button] + if not isRawStick(joystick) then return end + local btn = self.joyBindings[button] if btn then press(self, btn, "joy:" .. button) end end function Input:joystickreleased(joystick, button) - local btn = RAW_BUTTON_BINDINGS[button] + if not isRawStick(joystick) then return end + local btn = self.joyBindings[button] if btn then release(self, btn, "joy:" .. button) end end @@ -240,6 +277,7 @@ function Input:gamepadaxis(joystick, axis, value) end function Input:joystickaxis(joystick, axis, value) + if not isRawStick(joystick) then return end if axis == 1 then self:gamepadaxis(joystick, "leftx", value) elseif axis == 2 then @@ -247,7 +285,12 @@ function Input:joystickaxis(joystick, axis, value) end end +-- Same duplicate-event rule as joystickpressed (#620, #632): a recognized +-- pad's D-pad already arrived as dpup/dpdown/dpleft/dpright through the +-- gamepad map, so letting the hat answer too would re-assert the factory +-- directions on top of a direction rebind. function Input:joystickhat(joystick, hat, direction) + if not isRawStick(joystick) then return end local source = "hat:" .. hat for _, btn in ipairs(self.hatDirs[hat] or {}) do release(self, btn, source) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index caf35205..6f93dc93 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -266,6 +266,15 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + -- Named setups the player can switch between (#593; src/mods/ModProfile.lua + -- owns the shape, src/mods/ManagerState.lua the UI): each row is + -- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }. + -- activeProfile names the row the live set currently matches (nil for + -- ad-hoc, so it has no default entry here; mergeOptions preserves it). + -- modProfilesSeeded records that the pre-profiles setup was already + -- migrated into PROFILE 1, so deleting every profile does not re-seed one. + modProfiles = {}, + modProfilesSeeded = false, -- GitHub release checks for mods with a manifest "github" field -- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours. modUpdateCache = {}, @@ -281,8 +290,11 @@ function SaveData.defaultOptions() modIndexCache = {}, -- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua). -- enabled=false hides it permanently (distinct from auto-hide-on-gamepad). - -- positions are optional normalized centers {x=0..1, y=0..1} per control - -- (dpad/a/b/start/select); nil means the default layout. + -- layouts.portrait / layouts.landscape each hold optional normalized + -- centers {x=0..1, y=0..1} per control (dpad/a/b/start/select) plus a + -- size scale; nil positions mean that orientation draws the default + -- layout (#633). Pre-#633 files stored one top-level positions table; + -- TouchControls.normalizeConfig folds it into both orientations on load. touchControls = { enabled = true }, } end diff --git a/src/core/Sound.lua b/src/core/Sound.lua index d1bd9d05..2c5deb14 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -82,6 +82,40 @@ local function isChipDef(def) return type(def) == "table" and (def.chip ~= nil or def.address ~= nil) end +-- OpenAL only spatializes 1-channel Sources, and one left at the default +-- (0,0,0) position sits on top of the listener, which OpenAL renders as an +-- ambient sound spread over every output channel the device has: on an +-- interface with more than two outputs the SFX also came out of outputs 5+6 +-- while the 2-channel music stayed on 1+2 (#626). A Source cannot change its +-- channel count after the fact, so a mono file def is re-decoded and its +-- sample duplicated into a stereo buffer, which OpenAL never spatializes. +-- Chip SFX and cries are already stereo at the source (ChipSynth +-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM +-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg +-- SFX. Every step is guarded: a headless love stub without love.sound, or a +-- decoder that will not hand back SoundData, keeps the original Source. +local function widenMono(source, file) + if not (source and love.sound and love.sound.newSoundData) then + return source + end + local ok, channels = pcall(function() return source:getChannelCount() end) + if not ok or channels ~= 1 then return source end + local built, widened = pcall(function() + local mono = love.sound.newSoundData(file) + local frames = mono:getSampleCount() + local stereo = love.sound.newSoundData(frames, mono:getSampleRate(), + mono:getBitDepth(), 2) + for index = 0, frames - 1 do + local value = mono:getSample(index) + stereo:setSample(index, 1, value) + stereo:setSample(index, 2, value) + end + return love.audio.newSource(stereo, "static") + end) + if built and widened then return widened end + return source +end + -- a file def carries an optional playback rate; a bare string is shorthand -- for { file = } local function newFileSource(def) @@ -89,6 +123,7 @@ local function newFileSource(def) if type(file) ~= "string" then return nil, "no chip program and no file" end local ok, s = pcall(love.audio.newSource, file, "static") if not ok or not s then return nil, ok and "no source" or tostring(s) end + s = widenMono(s, file) -- keep mono defs off the surround channels (#626) if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end return s end @@ -233,12 +268,16 @@ function Sound.playPikaCry(data, n) local src = cache[key] if src == false then return nil end if not src then - local ok, s = pcall(love.audio.newSource, - ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static") - if not ok then + local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n) + local ok, s = pcall(love.audio.newSource, path, "static") + if not ok or not s then cache[key] = false return nil end + -- the importer writes these clips as 8-bit mono (RomExtractor + -- extractPikachuCries), so they need the same widening as the chip + -- effects to stay off a multi-output device's surround channels (#626) + s = widenMono(s, path) s:setVolume(volumeFor(key)) cache[key] = s src = s diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 8678dce8..4a6dae2c 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -14,8 +14,12 @@ -- -- Player preferences (options.touchControls) can permanently disable the -- overlay and/or override per-control positions as normalized window --- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes --- those; applyOptions reads them at boot and whenever options change. +-- fractions. Positions and a size multiplier are stored per orientation +-- (#633): options.touchControls.layouts.portrait / .landscape, picked from +-- the safe rect's aspect, so laying the pad out in landscape never moves +-- the portrait one. The launcher editor (src/ui/TouchControlsEditor.lua) +-- writes those; applyOptions reads them at boot and whenever options +-- change. -- -- Controls press GB buttons through Input:overlayPressed/Released -- their -- own input source, not a keyboard alias -- so a held overlay direction @@ -47,6 +51,15 @@ local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 } local BUTTONS = { "a", "b", "start", "select" } local CONTROLS = { "dpad", "a", "b", "start", "select" } +-- Per-orientation layout buckets (#633). Orientation comes from the safe +-- rect, not the device: sw > sh is landscape, so a resized desktop window +-- under POKEPORT_TOUCH exercises the same path a phone rotation does. +local ORIENTATIONS = { "portrait", "landscape" } + +-- Control size multiplier bounds for the editor's -/+ (#633). 1.0 is the +-- historical size, so an install that never touches it draws as before. +local SCALE_MIN, SCALE_MAX, SCALE_STEP = 0.6, 1.6, 0.1 + local IMAGES = { dpad = "assets/touch/dpad.png", dpad_up = "assets/touch/dpad_up.png", @@ -65,6 +78,33 @@ local function clamp01(v) return v end +local function clampScale(v) + if type(v) ~= "number" or v ~= v then return 1 end + if v < SCALE_MIN then return SCALE_MIN end + if v > SCALE_MAX then return SCALE_MAX end + return v +end + +-- Copy a persisted positions table, dropping unknown / non-numeric entries. +-- Always a fresh table: two orientations seeded from the same pre-#633 +-- layout must not alias, or dragging one would still move the other. +local function normalizePositions(src) + if type(src) ~= "table" then return nil end + local pos = {} + for _, name in ipairs(CONTROLS) do + local p = src[name] + if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then + pos[name] = { x = clamp01(p.x), y = clamp01(p.y) } + end + end + if not next(pos) then return nil end + return pos +end + +local function orientationFor(sw, sh) + return (sw or 0) > (sh or 0) and "landscape" or "portrait" +end + local function wantsOverlay() local env = os.getenv("POKEPORT_TOUCH") if env == "1" then return true end @@ -73,22 +113,26 @@ local function wantsOverlay() return osName == "Android" or osName == "iOS" end --- Normalize a persisted touchControls table into {enabled, positions}. +-- Normalize a persisted touchControls table into +-- {enabled, layouts = {portrait = {positions, scale}, landscape = {...}}}. -- Unknown / garbage keys are dropped so a bad options.lua cannot brick --- the overlay. +-- the overlay. Pre-#633 files stored one top-level positions table and no +-- scale; that layout seeds both orientations, so an upgrading player keeps +-- what they had until they edit one of them. function TouchControls.normalizeConfig(tc) - local out = { enabled = true, positions = nil } - if type(tc) ~= "table" then return out end + local out = { enabled = true, layouts = { portrait = {}, landscape = {} } } + -- a nil / garbage table still yields full buckets (scale defaulted), so + -- no caller ever has to nil-check a bucket's scale + if type(tc) ~= "table" then tc = {} end if tc.enabled == false then out.enabled = false end - if type(tc.positions) == "table" then - local pos = {} - for _, name in ipairs(CONTROLS) do - local p = tc.positions[name] - if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then - pos[name] = { x = clamp01(p.x), y = clamp01(p.y) } - end - end - if next(pos) then out.positions = pos end + local saved = type(tc.layouts) == "table" and tc.layouts or nil + for _, o in ipairs(ORIENTATIONS) do + local b = saved and saved[o] + if type(b) ~= "table" then b = { positions = tc.positions, scale = tc.scale } end + out.layouts[o] = { + positions = normalizePositions(b.positions), + scale = clampScale(b.scale), + } end return out end @@ -96,11 +140,14 @@ end -- Pure default layout in LOVE units for a usable rect of size ww x wh at -- origin (ox, oy). Shared by layout() and the editor's Reset path so -- defaults stay in one place. ox/oy default to 0 for the headless tests --- and for callers that already pass a full-window size. -function TouchControls.defaultLayout(ww, wh, ox, oy) +-- and for callers that already pass a full-window size. scale (#633) is +-- the orientation's size multiplier: every width and the margin derive +-- from dpadW, so scaling it moves the default centers with the art +-- instead of letting bigger buttons hang off the edge. +function TouchControls.defaultLayout(ww, wh, ox, oy, scale) ox, oy = ox or 0, oy or 0 local short = math.min(ww, wh) - local dpadW = math.min(180, short * 0.34) + local dpadW = math.min(180, short * 0.34) * clampScale(scale) local abW = dpadW * 0.46 local ssW = dpadW * 0.30 local margin = dpadW * 0.12 @@ -127,7 +174,13 @@ end function TouchControls:init() self.active = wantsOverlay() self.enabled = true + -- per-orientation buckets (#633); self.positions / self.scale mirror the + -- one currently on screen so layout(), the editor and the tests keep a + -- single lookup + self.layouts = { portrait = {}, landscape = {} } + self.orientation = nil self.positions = nil + self.scale = 1 self.preview = false self.controllerHidden = false self.touches = {} @@ -157,20 +210,30 @@ end function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled - self.positions = cfg.positions + self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil + -- prime positions/scale for the orientation on screen so callers that + -- read them before the next layout() (editor chrome, tests) see the file + self:currentBucket() if not self.enabled then self.controllerHidden = false self:reset() end end +-- Snapshot for the editor's save path: enabled plus both orientation +-- buckets, matching what options.lua stores (#633). function TouchControls:config() - return { - enabled = self.enabled ~= false, - positions = self.positions, - } + local out = { enabled = self.enabled ~= false, layouts = {} } + for _, o in ipairs(ORIENTATIONS) do + local b = self.layouts and self.layouts[o] or nil + out.layouts[o] = { + positions = b and b.positions or nil, + scale = clampScale(b and b.scale), + } + end + return out end -- Preview mode: force-draw the overlay for the layout editor, ignoring @@ -197,13 +260,33 @@ local function clampZone(zone, x0, y0, x1, y1) zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy)) end +-- The bucket for the orientation currently on screen (#633), created on +-- demand. Mirrors it into self.orientation / self.positions / self.scale, +-- which layout(), the editor chrome and the tests read. +function TouchControls:currentBucket() + local _, _, sw, sh = SafeArea.rect() + local o = orientationFor(sw, sh) + self.layouts = self.layouts or { portrait = {}, landscape = {} } + local b = self.layouts[o] + if type(b) ~= "table" then + b = {} + self.layouts[o] = b + end + b.scale = clampScale(b.scale) + self.orientation = o + self.positions = b.positions + self.scale = b.scale + return b +end + -- Layout in LOVE units (density-independent on mobile), recomputed when -- the window or safe area changes (rotation, resize, notch insets). -- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy -- diagonal), START/SELECT flanking the bottom center -- all inside the -- device safe area so thumbs clear the home indicator / cutouts. -- Custom positions (normalized 0..1 within the safe rect) override centers --- while sizes stay derived from the short edge. +-- while sizes stay derived from the short edge, times the orientation's +-- size setting (#633). function TouchControls:layout() local ox, oy, sw, sh = SafeArea.rect() if self.layoutW == sw and self.layoutH == sh @@ -212,10 +295,13 @@ function TouchControls:layout() end self.layoutW, self.layoutH = sw, sh self.layoutOx, self.layoutOy = ox, oy - self.L = TouchControls.defaultLayout(sw, sh, ox, oy) - if self.positions then + -- orientation picks which saved layout applies; rotating swaps buckets + -- because sw/sh swapped, which is already the cache key above (#633) + local bucket = self:currentBucket() + self.L = TouchControls.defaultLayout(sw, sh, ox, oy, bucket.scale) + if bucket.positions then for _, name in ipairs(CONTROLS) do - local p = self.positions[name] + local p = bucket.positions[name] local zone = self.L[name] if p and zone then zone.cx = ox + p.x * sw @@ -242,19 +328,45 @@ function TouchControls:setControlCenter(name, cx, cy) if not zone then return end zone.cx, zone.cy = cx, cy clampZone(zone, ox, oy, ox + sw, oy + sh) - self.positions = self.positions or {} - self.positions[name] = { + -- writes land in the orientation on screen only (#633) + local bucket = self:currentBucket() + bucket.positions = bucket.positions or {} + self.positions = bucket.positions + bucket.positions[name] = { x = sw > 0 and (zone.cx - ox) / sw or 0, y = sh > 0 and (zone.cy - oy) / sh or 0, } end +-- Editor Reset: defaults for the orientation on screen only (#633), so +-- resetting landscape never throws away the portrait layout. function TouchControls:clearPositions() + local bucket = self:currentBucket() + bucket.positions = nil + bucket.scale = 1 self.positions = nil + self.scale = 1 self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil end +-- Control size multiplier for the orientation on screen (#633). Widths and +-- the default centers both derive from it in defaultLayout; custom centers +-- keep their normalized spot and re-clamp inside the safe rect on the next +-- layout(). +function TouchControls:setScale(scale) + local bucket = self:currentBucket() + bucket.scale = clampScale(scale) + self.scale = bucket.scale + self.layoutW, self.layoutH = nil, nil + self.layoutOx, self.layoutOy = nil, nil + return self.scale +end + +function TouchControls:nudgeScale(delta) + return self:setScale((self.scale or 1) + delta) +end + local function inCircle(zone, x, y, slop) local r = zone.w * 0.5 * slop local dx, dy = x - zone.cx, y - zone.cy @@ -446,5 +558,8 @@ function TouchControls:draw() end TouchControls.CONTROLS = CONTROLS +TouchControls.ORIENTATIONS = ORIENTATIONS +TouchControls.SCALE_MIN, TouchControls.SCALE_MAX = SCALE_MIN, SCALE_MAX +TouchControls.SCALE_STEP = SCALE_STEP return TouchControls diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8d6b7766..80f37416 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1490,15 +1490,25 @@ function RomImporter:gamepadaxis(_, axis, value) end end +-- Same gate as src/core/Input.lua's isMappedPad: a pad SDL can map already +-- reached gamepadpressed this frame, so re-entering it from the raw event +-- would fire the virtual cursor's click twice off one A press (#620). +local function isMappedPad(joystick) + return joystick ~= nil and joystick.isGamepad ~= nil and joystick:isGamepad() +end + function RomImporter:joystickpressed(joystick, button) + if isMappedPad(joystick) then return end if button == 1 then self:gamepadpressed(joystick, "a") end end function RomImporter:joystickreleased(joystick, button) + if isMappedPad(joystick) then return end if button == 1 then self:gamepadreleased(joystick, "a") end end function RomImporter:joystickaxis(joystick, axis, value) + if isMappedPad(joystick) then return end if axis == 1 then self:gamepadaxis(joystick, "leftx", value) elseif axis == 2 then @@ -1506,7 +1516,8 @@ function RomImporter:joystickaxis(joystick, axis, value) end end -function RomImporter:joystickhat(_, hat, direction) +function RomImporter:joystickhat(joystick, hat, direction) + if isMappedPad(joystick) then return end for _, dir in ipairs(self._rawHatDirs[hat] or {}) do self._padDir[dir] = nil end @@ -1772,6 +1783,11 @@ function RomImporter:_resetFrameRects() self.modRects = nil self.modDeleteRects = nil self.modImportRect = nil + -- Enable all / Disable all share that header and the same rule (#647): they + -- are only drawn when the row is wide enough, so a stale rect would otherwise + -- stay clickable over whatever the next tab (or the next window size) draws. + self.modEnableAllRect = nil + self.modDisableAllRect = nil -- Same rule as the toggles above, and it started to bite once FIND MODS gave -- the mods tab a neighbour: these two were rebuilt by the mods panel but -- never cleared, so switching tabs left the last mod row's Update / Versions @@ -2679,6 +2695,8 @@ function RomImporter:mousepressed(x, y, button) self:_findInstall(c.indexEntry) elseif c.kind == "update" then self:_confirmModUpdate(c.id, c.release) + elseif c.kind == "enableAll" then + self:_setAllMods(true, true) else self:_toggleMod(c.id, true) end @@ -2853,6 +2871,14 @@ function RomImporter:mousepressed(x, y, button) if inside(self.modImportRect, x, y) then self:chooseMod(); return end + -- Enable all / Disable all sit in that same fixed header, so they dispatch on + -- press like the import button rather than arming a drag (#647). + if inside(self.modEnableAllRect, x, y) then + self:_setAllMods(true); return + end + if inside(self.modDisableAllRect, x, y) then + self:_setAllMods(false); return + end for _, r in ipairs(self.modDeleteRects or {}) do if inside(r, x, y) then if armedDelete(armed, "mod", r.id, nil) then @@ -4074,6 +4100,49 @@ function RomImporter:_toggleMod(id, confirmed) self:_refreshMods() end +-- Enable all / Disable all (#647). One options write for the whole list +-- (LauncherMods.setAllEnabled) and one relist afterwards, so the count, the +-- switches and every status chip resolve together instead of per mod. +-- Enabling routes through the same experimental confirm _toggleMod arms: that +-- opt-in is the only warning an experimental mod ever gets, and a bulk button +-- must not be the way around it. Disabling needs no confirm -- it is the +-- recovery action, and Delete is the only destructive one on this panel. +function RomImporter:_setAllMods(want, confirmed) + local LauncherMods = require("src.mods.LauncherMods") + local ids, experimental = {}, false + for _, m in ipairs(self.mods or {}) do + if m.enabled ~= want then + ids[#ids + 1] = m.id + if want and m.experimental then experimental = true end + end + end + if #ids == 0 then + self.modNotice = { ok = true, text = want + and Strings("Every mod is already enabled.") + or Strings("Every mod is already disabled.") } + return + end + if want and experimental and not confirmed then + self._modConfirm = { + kind = "enableAll", + title = "Experimental mods", + yesLabel = "Enable all", + lines = { + "Some of these mods are marked experimental.", + "They may be unfinished or unstable.", + "Enable everything anyway?", + }, + } + return + end + self._modConfirm = nil + LauncherMods.setAllEnabled(ids, want) + self:_refreshMods() + self.modNotice = { ok = true, text = want + and Strings("Enabled %d mods.", #ids) + or Strings("Disabled %d mods.", #ids) } +end + -- GitHub Update / Check for updates / Versions. Soft-fails into modNotice. -- Update button: when a newer release is known, confirm then install; when -- already current, force-refresh the 6h cache and report / offer update. @@ -4247,8 +4316,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end love.graphics.setFont(self.hintFont) col(PAL.warning) - love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods), - x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2) + local countText = Strings("%d of %d enabled", enabledCount, #mods) + local countX = x + nameW + 14 * s + love.graphics.print(countText, countX, + y + (headerH - self.hintFont:getHeight()) / 2) local btnLabel = "Import mod .zip" local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) @@ -4258,6 +4329,32 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) self.modImportRect = self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true) + -- Enable all / Disable all (#647): bulk switches beside Import mod .zip, so + -- coming back from a cable-club session (which asks for a vanilla fingerprint, + -- src/link/Fingerprint.lua) costs one click instead of one switch per mod. + -- The header is a single row shared with the count, so the chips are dropped + -- rather than overlapped when the column is too narrow to hold them (a + -- phone-width layout); the per-mod switches below are always the full path. + self.modEnableAllRect, self.modDisableAllRect = nil, nil + if #mods > 0 then + local enaLabel, disLabel = Strings("Enable all"), Strings("Disable all") + love.graphics.setFont(self.hintFont) + local bulkH = self.hintFont:getHeight() + 10 * s + local bulkY = y + (headerH - bulkH) / 2 + local enaW = self.hintFont:getWidth(enaLabel) + 24 * s + local disW = self.hintFont:getWidth(disLabel) + 24 * s + local bulkGap = 8 * s + local room = btnX - (countX + self.hintFont:getWidth(countText) + bulkGap) + if room >= enaW + disW + 2 * bulkGap then + local disX = btnX - bulkGap - disW + local enaX = disX - bulkGap - enaW + self.modEnableAllRect = + self:_chipButton(enaX, bulkY, enaLabel, { w = enaW, h = bulkH }) + self.modDisableAllRect = + self:_chipButton(disX, bulkY, disLabel, { w = disW, h = bulkH }) + end + end + local top = y + headerH + 14 * s -- notice line: the last install/delete result, else the platform hint diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index 6872a5cc..aec8437e 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -40,7 +40,8 @@ function Handshake.mods(game) if not ok or not status then return mods end for _, manifest in ipairs(status.loaded or {}) do mods[#mods + 1] = { id = manifest.id, version = manifest.version, - affectsLink = manifest.affects_link ~= false } + affectsLink = manifest.affects_link ~= false, + language = manifest.language == true } end table.sort(mods, function(a, b) return tostring(a.id) < tostring(b.id) end) return mods @@ -67,15 +68,82 @@ function Handshake.linkModified(game) return false end +-- #501: the registries a declared translation may write, and nothing else. +-- `text` is the ROM's dialogue, `strings` the engine's own authored text +-- (src/mods/Schemas.lua R.text / R.strings) and `font` the glyphs a +-- language with accents needs. None of the three is hashed into the +-- fingerprint (src/link/Fingerprint.lua header), so two peers reading the +-- same game in different languages stay in lockstep -- which is also what +-- the cable did: TradeCenter_PrintPartyListNames (pokered +-- engine/link/cable_club.asm) names the peer's party out of the local +-- ROM's table, only the trainer name and the party bytes travel. +-- text_pointers is deliberately out: its rows carry mart inventories and +-- nurse/pc flags, which are gameplay, not language. +local LANGUAGE_REGISTRIES = { text = true, strings = true, font = true } + +Handshake.LANGUAGE_REGISTRIES = LANGUAGE_REGISTRIES + +-- The manifest's `language = true` is the author's claim; this is the +-- check. A mod counts as a translation only if every op it appended +-- landed in a language registry, it subscribed no code (a hook or listener +-- runs inside the battle the two peers are lockstepping) and it asked for +-- no permission. Online play meets strangers, so nothing here may rest on +-- the manifest alone. A patched client can still lie about its own mods -- +-- the fingerprint, not this, is what keeps the shared simulation honest; +-- this gate is what keeps an honest install from being told to turn its +-- language off. +local function translationOnly(mod, id) + local record = mod.mods and mod.mods[id] + local manifest = record and record.manifest + if manifest and #(manifest.permissions or {}) > 0 then return false end + for name, registry in pairs(mod.content or {}) do + if not LANGUAGE_REGISTRIES[name] then + for _, list in pairs(registry.ops or {}) do + for _, entry in ipairs(list) do + if entry.owner == id then return false end + end + end + end + end + for _, chain in pairs((mod.hooks and mod.hooks.chains) or {}) do + for _, entry in ipairs(chain) do + if entry.owner == id then return false end + end + end + for _, list in pairs((mod.events and mod.events.listeners) or {}) do + for _, entry in ipairs(list) do + if entry.owner == id then return false end + end + end + return true +end + +-- the enabled mods that keep this install out of online play, in the +-- id order Handshake.mods sorts: everything except verified translations. +-- LinkState:offerVanillaRestart names these and switches off exactly these. +function Handshake.onlineBlockers(game) + local mod = loader(game) + local blockers = {} + for _, entry in ipairs(Handshake.mods(game)) do + local allowed = entry.language and not entry.affectsLink + and mod ~= nil and translationOnly(mod, entry.id) + if not allowed then blockers[#blockers + 1] = entry end + end + return blockers +end + -- online play (the relay-based online match / tournament flows in -- LinkState/Tournament) meets strangers, not a coordinating friend, so it -- skips the LAN path's per-peer compatibility negotiation entirely and --- just requires vanilla on both ends: no mod-added Pokemon, no surprises. --- Mods only ever get baked in at boot (Loader:load), so this is a gate on --- attempting to go online, not a live mod toggle -- the player disables --- mods via the mod manager and relaunches. +-- just requires a vanilla simulation on both ends: no mod-added Pokemon, no +-- surprises. #501 carves out translations, because a language is not a +-- simulation: a mod that only rewrites text is invisible to the wire and +-- may stay on, so an English player and a Spanish one can meet the way two +-- regional carts always could. Mods only ever get baked in at boot +-- (Loader:load), so this is a gate on attempting to go online, not a live +-- mod toggle -- the player disables mods via the mod manager and relaunches. function Handshake.onlineAllowed(game) - return #Handshake.mods(game) == 0 + return #Handshake.onlineBlockers(game) == 0 end -- mode is nil on the guest: it pairs and announces itself before the host diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 016a4802..59d15520 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -110,17 +110,19 @@ function LinkState:exitWith(message, reason) end end --- Online play meets strangers, so it requires vanilla on both ends --- (Handshake.onlineAllowed). Mods merge into the shared Data registries at --- boot and there is no unmerge, so switching them off has to go through a --- relaunch -- but the player should not have to go find the mod manager and --- work out which mods count. This turns every enabled mod off, records --- them so the mod manager can put them back, and relaunches. The restart +-- Online play meets strangers, so it requires a vanilla simulation on both +-- ends (Handshake.onlineAllowed). Mods merge into the shared Data +-- registries at boot and there is no unmerge, so switching them off has to +-- go through a relaunch -- but the player should not have to go find the +-- mod manager and work out which mods count. This turns the blocking mods +-- off, records them so the mod manager can put them back, and relaunches. +-- Verified translations are not blockers (#501), so a player keeps their +-- language across the restart and only the gameplay mods go. The restart -- is confirmed rather than silent: it drops unsaved progress. function LinkState:offerVanillaRestart() local game = self.game local loader = game.mods - local mods = Handshake.mods(game) + local mods = Handshake.onlineBlockers(game) local names = {} for i, mod in ipairs(mods) do if i > 2 then break end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 3a7e9062..189f49c3 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -265,6 +265,21 @@ function LauncherMods.setEnabled(id, enabled) return true end +-- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons +-- (#647). Writes exactly the options.mods shape setEnabled does, but loads and +-- saves once for the whole list: saveOptions rewrites the whole options file per +-- call, so looping setEnabled over a big mods folder is one disk write per mod +-- and leaves a half-applied state behind if one of them fails. +function LauncherMods.setAllEnabled(ids, enabled) + local options = SaveData.loadOptions() + options.mods = options.mods or {} + for _, id in ipairs(ids or {}) do + options.mods[id] = enabled and true or false + end + SaveData.saveOptions(options) + return true +end + -- ------- install (love.filesystem) -- Read a .zip source into bytes. A string is an external absolute path (like diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index cf750abe..945b7adc 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -11,6 +11,7 @@ local Version = require("src.core.Version") local Theme = require("src.ui.Theme") local OptionRows = require("src.ui.OptionRows") local Strings = require("src.core.Strings") +local ModProfile = require("src.mods.ModProfile") local ManagerState = {} ManagerState.__index = ManagerState @@ -180,6 +181,16 @@ end function ManagerState:enter() self:refresh() + -- #593: the setup that existed before profiles shipped becomes PROFILE 1 + -- the first time the manager opens, so switching away from it is always a + -- round trip. Seeded here rather than at boot because profiles are only + -- ever read on this screen. + local seeded = ModProfile.ensureFirst(self:optionsTable(), + self.status.available, self:modOptionsTable()) + if seeded then + self:persistOptions() + self:refresh() + end if Runtime.safeMode then self.banner = "SAFE MODE - ALL MODS OFF" end @@ -213,6 +224,12 @@ function ManagerState:optionsTable() return (save and save.options) or {} end +-- Per-mod option values as persisted (options.modOptions; setOption below is +-- the only writer). A profile captures this alongside the enable set. +function ManagerState:modOptionsTable() + return self:optionsTable().modOptions or {} +end + function ManagerState:manifestMap() return self.byId or {} end @@ -283,6 +300,10 @@ function ManagerState:profileRows() glyph = opts.activeProfile == p.name and GLYPH.errored or " " } end rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") } + -- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist + -- in the save dir, IMPORT reads back every file dropped in that folder. + rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") } + rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") } rows[#rows + 1] = { adhoc = true, label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } return rows @@ -478,6 +499,10 @@ function ManagerState:activate() self:applyProfile(row.profile) elseif row.saveAs then self:saveCurrentAs() + elseif row.exportProfile then + self:exportActiveProfile() + elseif row.importProfile then + self:importProfiles() elseif row.adhoc then self:optionsTable().activeProfile = nil self:notify("AD-HOC SET ACTIVE") @@ -712,9 +737,26 @@ function ManagerState:applyProfile(p) end end self:commitToggle(combined) + -- #593: a profile is the whole setup, not just the enable set. Options go + -- through setOption so loader.modOptions and mod.options_changed stay in + -- step; save slots move only to slots that version already registered + -- (SaveData.setActiveSlot would otherwise conjure one). + for modId, bucket in pairs(p.options or {}) do + if self.byId[modId] then + for key, value in pairs(bucket) do self:setOption(modId, key, value) end + end + end + for _, move in ipairs(ModProfile.slotMoves(p)) do + require("src.core.SaveData").setActiveSlot(move[1], move[2]) + end self:optionsTable().activeProfile = p.name self:persistOptions() - self:notify("PROFILE STAGED") + local missing = ModProfile.missingIds(p, self.byId) + if #missing > 0 then + self:notify(#missing .. " MODS MISSING") + else + self:notify("PROFILE STAGED") + end end function ManagerState:saveCurrentAs() @@ -725,16 +767,15 @@ function ManagerState:saveCurrentAs() onDone = function(name) local opts = self:optionsTable() opts.modProfiles = opts.modProfiles or {} - local enabled = {} - for _, m in ipairs(self.status.available or {}) do - enabled[m.id] = m.enabled and true or false - end + local snap = ModProfile.capture(self.status.available, + self:modOptionsTable()) local existing = self:findProfile(name) if existing then - existing.enabled = enabled + existing.enabled, existing.options, existing.slots = + snap.enabled, snap.options, snap.slots else - opts.modProfiles[#opts.modProfiles + 1] = - { name = name, enabled = enabled } + snap.name = name + opts.modProfiles[#opts.modProfiles + 1] = snap end opts.activeProfile = name self:persistOptions() @@ -743,6 +784,33 @@ function ManagerState:saveCurrentAs() })) end +-- #593 sharing. Export drops the active profile (or the focused one) in the +-- save directory's profiles/ folder as .g1rmodlist; import reads every +-- file already there. Deliberately folder-based rather than a native file +-- picker: the same shape SaveFileIO uses for exports/, and it works on the +-- mobile builds where no picker exists. +function ManagerState:exportActiveProfile() + local row = self:focusedRow() + local p = (row and row.profile) + or self:findProfile(self:optionsTable().activeProfile) + if not p then return self:notify("NO PROFILE") end + local ok = ModProfile.export(p) + self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED") +end + +function ManagerState:importProfiles() + local opts = self:optionsTable() + opts.modProfiles = opts.modProfiles or {} + local n = 0 + for _, path in ipairs(ModProfile.files()) do + if ModProfile.import(path, opts.modProfiles) then n = n + 1 end + end + if n == 0 then return self:notify("NO MODLISTS") end + self:persistOptions() + self:snapCursor() + self:notify(n .. " IMPORTED") +end + function ManagerState:renameProfile(p) local NamingScreen = require("src.ui.NamingScreen") self.game.stack:push(NamingScreen.new(self.game, { diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 12ff83a7..97bed0ac 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -178,9 +178,21 @@ function Manifest.validate(raw, path) "experimental must be a boolean") local experimental = raw.experimental == true + -- #501: a translation declares itself here. Neither the ROM's dialogue + -- nor the engine's own strings are hashed into the link surface + -- (src/link/Fingerprint.lua header), so an English install and a Spanish + -- one run the same lockstep simulation, exactly as two regional carts on + -- a real cable did. The flag is only the author's claim; + -- Handshake.onlineBlockers checks it against the ops the mod actually + -- appended before online play trusts it. + assert(raw.language == nil or type(raw.language) == "boolean", + "language must be a boolean") + local language = raw.language == true + -- overhauls and total conversions are assumed to move the link - -- fingerprint unless the manifest says otherwise; content packs are not - local affectsLink = profile ~= "content" + -- fingerprint unless the manifest says otherwise; content packs and + -- declared translations are not + local affectsLink = profile ~= "content" and not language if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end local function optionalFile(value, field) @@ -211,6 +223,7 @@ function Manifest.validate(raw, path) github = github, experimental = experimental, profile = profile, + language = language, affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 08b5984f..481669cc 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -520,36 +520,19 @@ function ModIndex.writeCache(feed, index) return ok end --- ------- host I/O (curl, via ModUpdate's shell plumbing) - -local function shq(s) - s = tostring(s) - if love and love.system and love.system.getOS - and love.system.getOS() == "Windows" then - return '"' .. s:gsub('"', '') .. '"' - end - return "'" .. s:gsub("'", "'\\''") .. "'" -end +-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge) -- Plain GET returning the body. No GitHub Accept header: the feed and the -- description markdown are static files on Pages, and the public feed is --- explicitly unauthenticated. +-- explicitly unauthenticated. The transport itself lives in +-- src/core/HostShell.lua because Android has no curl and has to fetch through +-- love.system.httpDownload instead (#597). function ModIndex.httpGet(url) - local ModUpdate = require("src.mods.ModUpdate") - if not ModUpdate.haveCurl() then - return nil, "curl is not available on this platform" - end local HostShell = require("src.core.HostShell") - local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " - .. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " " - .. shq(url) - local pipeOk, pipe = pcall(HostShell.popen, cmd) - if not pipeOk or not pipe then return nil, "could not run curl" end - local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - if not readOk then return nil, "fetch failed: " .. tostring(out) end - if not out or out == "" then return nil, "empty response from " .. url end - return out + if not HostShell.canFetch() then + return nil, "no network transport on this platform" + end + return HostShell.httpGet(url, "gen1recomp-mod-index") end -- fetch(source, opts) -> index, err, meta diff --git a/src/mods/ModProfile.lua b/src/mods/ModProfile.lua new file mode 100644 index 00000000..f41181f8 --- /dev/null +++ b/src/mods/ModProfile.lua @@ -0,0 +1,206 @@ +-- Mod profiles (#593): a named snapshot of one whole setup -- which mods are +-- enabled, each mod's own options, and which save slot every game version +-- plays -- plus the shareable .g1rmodlist file the manager writes and reads. +-- Pure data and filesystem: src/mods/ManagerState.lua owns the UI and the +-- staged toggles, and is the only caller. +-- +-- Records live in options.modProfiles (src/core/SaveData.lua defaultOptions), +-- so a profile survives New Game exactly like the mod enable-state does: +-- { name = "PROFILE 1", +-- enabled = { [modId] = true|false }, -- missing means enabled +-- options = { [modId] = { key = value } },-- mirrors options.modOptions +-- slots = { red = "slot1", blue = "slot2", yellow = nil } } +-- Exports are SaveSerializer-encoded (data-only parse, an imported file can +-- never execute code) under profiles/.g1rmodlist in the save dir, the +-- same shape SaveFileIO uses for exports/ (src/import/SaveFileIO.lua). + +local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") + +local ModProfile = {} + +ModProfile.EXT = ".g1rmodlist" +ModProfile.DIR = "profiles" +ModProfile.FORMAT = "g1rmodlist" +ModProfile.FORMAT_VERSION = 1 + +-- deterministic order; GameVersion.VERSIONS is a map, and a profile file has +-- to encode the same way twice for a diff to mean anything +local VERSION_ORDER = { "red", "blue", "yellow" } + +local function fsOr(fs) + return fs or (love and love.filesystem) or nil +end + +-- Capture the live setup. `available` is ManagerState.status.available (the +-- loader's status manifests, m.enabled = the desired set including staged +-- flips); `modOptions` is options.modOptions. +function ModProfile.capture(available, modOptions) + local enabled, options = {}, {} + for _, m in ipairs(available or {}) do + enabled[m.id] = m.enabled and true or false + local bucket = modOptions and modOptions[m.id] + if type(bucket) == "table" then + local copy = {} + for k, v in pairs(bucket) do + local t = type(v) + if t == "string" or t == "number" or t == "boolean" then copy[k] = v end + end + options[m.id] = copy + end + end + local slots = {} + for _, id in ipairs(VERSION_ORDER) do + local ok, slot = pcall(SaveData.activeSlot, id) + if ok and slot then slots[id] = slot end + end + return { enabled = enabled, options = options, slots = slots } +end + +-- The slot moves a profile may actually make: a slot id has to be registered +-- for that version already, because SaveData.setActiveSlot registers an +-- unknown id and would conjure a phantom slot the player never made. +function ModProfile.slotMoves(p) + local moves = {} + for _, version in ipairs(VERSION_ORDER) do + local want = p.slots and p.slots[version] + if want then + local ok, list = pcall(SaveData.listSlots, version) + if ok then + for _, row in ipairs(list or {}) do + if row.id == want then moves[#moves + 1] = { version, want } end + end + end + end + end + return moves +end + +-- ids the profile wants enabled that are not installed here. Import cannot +-- fetch them (community-index install is deferred), so the manager reports +-- them rather than silently playing a different setup than the sharer's. +function ModProfile.missingIds(p, byId) + local out = {} + for id, on in pairs(p.enabled or {}) do + if on and not (byId or {})[id] then out[#out + 1] = id end + end + table.sort(out) + return out +end + +function ModProfile.encode(p) + return SaveSerializer.encode({ + format = ModProfile.FORMAT, + formatVersion = ModProfile.FORMAT_VERSION, + profile = { name = p.name, enabled = p.enabled, + options = p.options, slots = p.slots }, + }) +end + +-- Shape-validate rather than trust: a shared file is untrusted input, and the +-- manager indexes p.enabled/p.options straight into its row models. +function ModProfile.decode(body) + if type(body) ~= "string" then return nil, "EMPTY FILE" end + local ok, data = pcall(SaveSerializer.decode, body) + if not ok or type(data) ~= "table" then return nil, "BAD FILE" end + if data.format ~= ModProfile.FORMAT then return nil, "NOT A MODLIST" end + local raw = data.profile + if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then + return nil, "BAD FILE" + end + local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} } + for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do + if type(id) == "string" then p.enabled[id] = on and true or false end + end + for id, bucket in pairs(type(raw.options) == "table" and raw.options or {}) do + if type(id) == "string" and type(bucket) == "table" then + local copy = {} + for k, v in pairs(bucket) do + local t = type(v) + if type(k) == "string" and (t == "string" or t == "number" or t == "boolean") then + copy[k] = v + end + end + p.options[id] = copy + end + end + for version, slot in pairs(type(raw.slots) == "table" and raw.slots or {}) do + if GameVersion.VERSIONS[version] and type(slot) == "string" then + p.slots[version] = slot + end + end + return p +end + +-- profiles/.g1rmodlist; letters, digits and dashes only so a shared +-- name can never escape the folder +local function fileFor(name) + return ModProfile.DIR .. "/" .. + tostring(name):upper():gsub("[^%w%-]", "_") .. ModProfile.EXT +end +ModProfile.fileFor = fileFor + +function ModProfile.export(p, fs) + fs = fsOr(fs) + if not (fs and fs.write) then return false, "NO FILESYSTEM" end + if fs.createDirectory then fs.createDirectory(ModProfile.DIR) end + local rel = fileFor(p.name) + local ok, err = fs.write(rel, ModProfile.encode(p)) + if not ok then return false, tostring(err) end + local base = fs.getSaveDirectory and fs.getSaveDirectory() or "" + if base ~= "" then return true, base .. "/" .. rel end + return true, rel +end + +-- every .g1rmodlist sitting in profiles/, sorted, as relative paths +function ModProfile.files(fs) + fs = fsOr(fs) + local out = {} + if not (fs and fs.getDirectoryItems) then return out end + if fs.getInfo and not fs.getInfo(ModProfile.DIR) then return out end + for _, name in ipairs(fs.getDirectoryItems(ModProfile.DIR) or {}) do + if name:sub(-#ModProfile.EXT) == ModProfile.EXT then + out[#out + 1] = ModProfile.DIR .. "/" .. name + end + end + table.sort(out) + return out +end + +-- Read one export and land it in `profiles` (options.modProfiles) under a +-- non-colliding name, so importing twice never overwrites what the player +-- already tuned. Returns the stored record, or nil + a short reason. +function ModProfile.import(path, profiles, fs) + fs = fsOr(fs) + if not (fs and fs.read) then return nil, "NO FILESYSTEM" end + local p, err = ModProfile.decode(fs.read(path)) + if not p then return nil, err end + local taken = {} + for _, existing in ipairs(profiles) do taken[existing.name] = true end + local name, n = p.name, 1 + while taken[name] do + n = n + 1 + name = (p.name:sub(1, 8) .. n) + end + p.name = name + profiles[#profiles + 1] = p + return p +end + +-- First run after this update: the setup the player already has becomes +-- PROFILE 1, then they can add more (#593). Seeded once and remembered by +-- options.modProfilesSeeded, so deleting every profile does not resurrect it. +function ModProfile.ensureFirst(opts, available, modOptions) + if opts.modProfilesSeeded then return nil end + opts.modProfilesSeeded = true + opts.modProfiles = opts.modProfiles or {} + if #opts.modProfiles > 0 then return nil end + local p = ModProfile.capture(available, modOptions) + p.name = "PROFILE 1" + opts.modProfiles[1] = p + opts.activeProfile = p.name + return p +end + +return ModProfile diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index 71d87b6b..ce69328b 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -254,39 +254,21 @@ function ModUpdate.statusFor(installedVersion, releases) return "current", best end --- ------- host I/O (curl) +-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge) -local function shq(s) - s = tostring(s) - if love and love.system and love.system.getOS - and love.system.getOS() == "Windows" then - return '"' .. s:gsub('"', '') .. '"' - end - return "'" .. s:gsub("'", "'\\''") .. "'" -end - -local function curlCapture(url) +-- GitHub's API wants a User-Agent and answers the versioned Accept. +local function apiFetch(url) local HostShell = require("src.core.HostShell") - local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " - .. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " " - .. "-H " .. shq("Accept: application/vnd.github+json") .. " " - .. shq(url) - local pipeOk, pipe = pcall(HostShell.popen, cmd) - if not pipeOk or not pipe then return nil, "could not run curl" end - local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - if not readOk then return nil, "curl read failed: " .. tostring(out) end - if not out or out == "" then return nil, "empty response from GitHub" end - return out + return HostShell.httpGet(url, "gen1recomp-mod-updater", + "application/vnd.github+json") end +-- Still just the curl probe it always was. "Can we fetch at all" is +-- HostShell.canFetch, which also knows about the Android bridge (#597); every +-- gate below asks that instead. function ModUpdate.haveCurl() local HostShell = require("src.core.HostShell") - local pipeOk, pipe = pcall(HostShell.popen, "curl --version") - if not pipeOk or not pipe then return false end - local readOk, out = pcall(function() return pipe:read("*a") end) - pcall(function() pipe:close() end) - return readOk and out ~= nil and out:find("curl", 1, true) ~= nil + return HostShell.haveCurl() end -- Fetch release list. opts.force bypasses the 6h cache. @@ -302,15 +284,16 @@ function ModUpdate.fetchReleases(repo, modId, opts) return cached.releases, nil, { fromCache = true } end end - if not ModUpdate.haveCurl() then + local HostShell = require("src.core.HostShell") + if not HostShell.canFetch() then -- Stale cache is better than nothing when offline local cached = ModUpdate.readCache(repo) if cached and cached.releases then return cached.releases, nil, { fromCache = true, stale = true } end - return nil, "curl is not available on this platform" + return nil, "no network transport on this platform" end - local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo)) + local body, curlErr = apiFetch(ModUpdate.apiReleasesUrl(repo)) if not body then local cached = ModUpdate.readCache(repo) if cached and cached.releases then @@ -331,10 +314,10 @@ function ModUpdate.downloadZip(url, destName) if not (love and love.filesystem) then return nil, "download needs LOVE" end - if not ModUpdate.haveCurl() then - return nil, "curl is not available on this platform" - end local HostShell = require("src.core.HostShell") + if not HostShell.canFetch() then + return nil, "no network transport on this platform" + end local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip") name = tostring(name):gsub("[/\\]", "_") local saveOk, saveDir = pcall(function() @@ -344,12 +327,9 @@ function ModUpdate.downloadZip(url, destName) return nil, "no save directory" end local abs = saveDir .. "/" .. name - local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o " - .. shq(abs) .. " " .. shq(url) - local pipeOk, pipe = pcall(HostShell.popen, cmd) - if not pipeOk or not pipe then return nil, "could not start download" end - pcall(function() pipe:read("*a") end) - pcall(function() pipe:close() end) + -- Either transport can fail quietly; the getInfo size check below is what + -- actually decides whether we got a file (#597). + HostShell.httpDownload(url, abs, "gen1recomp-mod-updater") local infoOk, info = pcall(love.filesystem.getInfo, name) if not infoOk or not info or (info.size or 0) == 0 then pcall(love.filesystem.remove, name) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 515c690a..df502512 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -33,6 +33,13 @@ local MAX_COLS = 18 -- up: StateStack updates the top state only, so the overworld and its -- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives -- its spin off it, data/scripts/story5.lua, #249). +-- opts.stay: text that ends in `done` rather than `prompt` returns from +-- PrintText with the box still on screen while the caller keeps running +-- (_ViridianSchoolBlackboardText2, data/text/text_2.asm:646). Such a box +-- waits for nothing, shows no blinking arrow, and never pops itself -- +-- whoever pushed it owns the pop. stay.onShown fires once, on the frame +-- the last page finishes typing, which is where the caller pushes whatever +-- goes on top of it (#591). function TextBox.new(game, text, onDone, opts) local self = setmetatable({}, TextBox) self.game = game @@ -41,6 +48,7 @@ function TextBox.new(game, text, onDone, opts) self.defaultNo = opts and opts.defaultNo self.choiceNoSound = opts and opts.noSound self.auto = opts and opts.auto + self.stay = opts and opts.stay local box = Theme.textBox or {} self.boxTx = box.tx or BOX_TX self.boxTy = box.ty or BOX_TY @@ -187,6 +195,16 @@ function TextBox:update(dt) return end if self.done then + -- opts.stay: the box is finished but stays up under whatever the caller + -- pushed over it; StateStack updates the top state only, so this runs + -- exactly once (#591) + if self.stay then + if not self.stayShown then + self.stayShown = true + if self.stay.onShown then self.stay.onShown() end + end + return + end if self.auto then if not self.autoStarted then self.autoStarted = true @@ -356,7 +374,8 @@ function TextBox:draw() Font.drawCode(code, self.textX + (j - 1) * 8, y) end end - if (self.waiting or (self.done and not self.choice and not self.auto)) + if (self.waiting or (self.done and not self.choice and not self.auto + and not self.stay)) and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) diff --git a/src/render/Transition.lua b/src/render/Transition.lua index 1163322d..23784ca0 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -17,6 +17,26 @@ local FRAMES = Timing.WARP_FADE_OUT local FRAMES_IN = Timing.WARP_FADE_IN local FLASH_FRAMES = 7 +-- Every fade in home/fade.asm holds each palette write with +-- `ld c, 8 / call DelayFrames`, so eight frames is the step of all of them. +local FADE_STEP_FRAMES = 8 + +-- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack +-- (home/fade.asm:43-46) walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1 +-- with b = 4, and FadePal4 IS the normal palette while FadePal1 is solid +-- black (:64-67) -- so the screen sits untouched through the first eight +-- frames and then drops a whole shade per step, reaching black for the last +-- hold. The port tweened the veil instead, which reads as a dissolve rather +-- than a Game Boy fade (#607). A record retimed shorter than two steps +-- (mods) keeps the old ramp so it still reaches black. +local function fadeAlpha(t, len) + if not len or len <= 0 then return 1 end + local steps = math.floor(len / FADE_STEP_FRAMES) + if steps < 2 then return t / len end + local step = math.min(steps - 1, math.floor(t / (len / steps))) + return step / (steps - 1) +end + -- The two fades as transitions records, so a mod retimes a warp fade the -- same way it retimes a battle wipe. BattleTransition.registerInto pulls -- these in with its eight wipes -- one registrant owns the registry. @@ -76,10 +96,17 @@ function Transition:update(dt) end end -function Transition:draw() +-- The veil alpha for this frame, exposed the way BattleReturn:alpha is so +-- the staircase can be asserted without stubbing love.graphics. +function Transition:alpha() local len = (self.phase == "out") and self.frames or self.framesIn - local alpha = (len and len > 0) and (self.t / len) or 1 - if self.phase == "in" then alpha = 1 - alpha end + local a = fadeAlpha(self.t, len) + if self.phase == "in" then a = 1 - a end + return a +end + +function Transition:draw() + local alpha = self:alpha() -- Survey zoom draws the overworld into a window-filling world canvas -- while the UI pass stays the classic 160x144 letterbox. A rect on the -- UI canvas only darkens that center box (issue #121); when the world @@ -161,7 +188,8 @@ end -- writes one palette then holds it with `ld c, 8 / call DelayFrames` -- (home/fade.asm:30-41), three times over. Stepping the veil the same way -- keeps the fade reading like a Game Boy palette fade rather than a tween. -local FADE_STEP_FRAMES = 8 +-- The hold is the same eight frames the warp fade steps on, so both share +-- FADE_STEP_FRAMES at the top of the file. function BattleReturn:alpha() if self.t < self.hold then return 1 end diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 5cea86ba..606a5ead 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -123,8 +123,10 @@ function BindingsMenu:beginCapture(item) self.pending = nil self.onKeyPressed = BindingsMenu.captureKey self.onGamepadPressed = BindingsMenu.capturePad + self.onJoystickPressed = BindingsMenu.captureJoy self.onKeyReleased = BindingsMenu.captureKeyRelease self.onGamepadReleased = BindingsMenu.capturePadRelease + self.onJoystickReleased = BindingsMenu.captureJoyRelease end function BindingsMenu:endCapture() @@ -132,8 +134,10 @@ function BindingsMenu:endCapture() self.pending = nil self.onKeyPressed = nil self.onGamepadPressed = nil + self.onJoystickPressed = nil self.onKeyReleased = nil self.onGamepadReleased = nil + self.onJoystickReleased = nil end -- Escape is the capture's way out, so it is never captured: every other @@ -169,6 +173,27 @@ function BindingsMenu:capturePadRelease(button) end end +-- A stick SDL has no game-controller-database entry for reports plain +-- button numbers instead of names, so its capture stores "joyN" in the +-- SAME pad slot every other controller uses (#632). The row's right +-- column, storeBinding's swap and START's reset-all then need no special +-- case, and src/core/Input.lua's applyBindings is the only place that has +-- to decode the name. `raw` keeps the two release hooks apart: p.value is +-- "joy3" here and an SDL button name there, so neither can claim the +-- other's release. Game only routes a raw stick to these hooks, so a +-- recognized pad still records its readable SDL name. +function BindingsMenu:captureJoy(button) + if self.pending then return self:endCapture() end + self.pending = { slot = "pad", value = "joy" .. button, raw = true } +end + +function BindingsMenu:captureJoyRelease(button) + local p = self.pending + if p and p.raw and p.value == "joy" .. button then + self:storeBinding("pad", p.value) + end +end + function BindingsMenu:storeBinding(slot, value) local item = self.capture self:endCapture() diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 0579d656..09a95f56 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -373,9 +373,17 @@ function PartyMenu:update(dt) or Strings("A blinding FLASH\nlights the area!"), function() self:close() -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave rebuilds it (#383) - self.game.stack:push(Transition.whiteFlash(self.game, nil, - function() ow:setDark(false) end)) + -- in a baked atlas, so lighting the cave drops every resident map + -- and rebakes this one (#383). It runs HERE, before the blink, + -- because start_sub_menus.asm .flash clears wMapPalOffset before + -- PrintText and blinks last of all: the cave is already lit by the + -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the + -- blink's completion instead left that rebuild's whole cost -- + -- seconds of per-pixel atlas baking on a phone -- on screen as a + -- solid white frame with nothing under it, which reads as a + -- lockup (#610). + ow:setDark(false) + self.game.stack:push(Transition.whiteFlash(self.game)) end)) return elseif action == "surf" then diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index ecbfe50d..7dec6aef 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -149,9 +149,16 @@ function StartMenu.new(game) end -- inside the Safari Zone the start menu also shows remaining steps and - -- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with - -- "steps/500" and "BALL xx" - if game.save.safari then + -- SAFARI BALLs (PrintSafariZoneSteps, engine/overworld/player_state.asm: + -- 219-224): a 9x5 border at the top-left with "steps/500" and "BALL xx". + -- It opens with `cp SAFARI_ZONE_EAST / ret c`, so only the nine interior + -- maps ($D9..$E1) get it -- SAFARI_ZONE_GATE is $9C and falls under that + -- early out, and used to show "502/500" while the player was still + -- standing at the counter (#540). Same map set as the step counter's + -- (FieldDefaults safari.stepMaps via OverworldState:inSafariStepZone). + local ow = game.overworld + if game.save.safari and ow and ow.map and ow.inSafariStepZone + and ow:inSafariStepZone() then local baseDraw = menu.draw menu.draw = function(self) baseDraw(self) diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 73f54bed..6d088189 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -1,8 +1,13 @@ -- Launcher touch-controls editor (#327): drag each on-screen button to a --- new spot, toggle the overlay off entirely, Reset to defaults, Done to --- persist into options.lua. Opened from the game panel's "Touch Controls" --- button; main.lua suspends the launcher the same way it does for the --- save editor. +-- new spot, resize them, toggle the overlay off entirely, Reset to +-- defaults, Done to persist into options.lua. Opened from the game +-- panel's "Touch Controls" button; main.lua suspends the launcher the same +-- way it does for the save editor. +-- +-- Everything edited here belongs to the orientation currently on screen +-- (#633): portrait and landscape keep separate positions and sizes, so a +-- player lays out each rotation once. Rotate the device (or resize the +-- desktop window past square) and the editor switches buckets live. -- -- Draws in full window LOVE units -- the same space TouchControls uses -- after Renderer:endFrame -- so what you drag here is what you get in play. @@ -63,9 +68,11 @@ end local function persist() local opts = SaveData.loadOptions() local cfg = TouchControls:config() + -- replaces the whole table, so a pre-#633 top-level positions key is + -- dropped once the player saves under the new shape opts.touchControls = { enabled = cfg.enabled, - positions = cfg.positions, + layouts = cfg.layouts, } SaveData.saveOptions(opts) end @@ -113,6 +120,8 @@ function Editor.draw() local fullW, fullH = love.graphics.getDimensions() local ox, oy, ww, wh = SafeArea.rect() local s = math.max(0.75, math.min(1.4, wh / 768)) + -- resolve the orientation bucket before any chrome reads scale (#633) + local bucket = TouchControls:currentBucket() Editor.rects = {} -- radial-ish navy field (two stacked fills; matches launcher atmosphere) @@ -186,13 +195,41 @@ function Editor.draw() chromeBtn(toggle, on and "Disable" or "Enable", on and PAL.red or PAL.green) + -- size card (#633): -/+ resize every control in the orientation on + -- screen; the heading names it so it is plain the other one is untouched + local sizeY = cardY + cardH + 10 * s + col(PAL.card, 0.88) + roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s) + col(PAL.stroke, 0.4) + roundRect("line", cardX, sizeY, cardW, cardH, 12 * s) + + love.graphics.setFont(Editor.fonts.body) + col(PAL.label) + local orient = TouchControls.orientation == "landscape" + and "Landscape" or "Portrait" + love.graphics.print("Button size (" .. orient .. ")", + cardX + 16 * s, sizeY + 12 * s) + love.graphics.setFont(Editor.fonts.btn) + col(PAL.white) + love.graphics.print( + string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)), + cardX + 16 * s, sizeY + 34 * s) + + local stepW = 52 * s + local plus = { x = cardX + cardW - 16 * s - stepW, + y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH } + local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH } + Editor.rects.sizeUp, Editor.rects.sizeDown = plus, minus + chromeBtn(minus, "-", { 60, 70, 110 }) + chromeBtn(plus, "+", { 60, 70, 110 }) + -- hint love.graphics.setFont(Editor.fonts.body) col(PAL.label, 0.9) local hint = on - and "Drag each button to reposition. Layout is saved when you tap Done." + and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done." or "Controls are hidden in-game. Enable them to show and edit the layout." - love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left") + love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left") -- the overlay itself (preview mode; dimmed when disabled) TouchControls:draw() @@ -214,6 +251,12 @@ local function beginDrag(id, x, y) if inside(Editor.rects.done, x, y) then close(); return end if inside(Editor.rects.reset, x, y) then resetLayout(); return end if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end + if inside(Editor.rects.sizeDown, x, y) then + TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return + end + if inside(Editor.rects.sizeUp, x, y) then + TouchControls:nudgeScale(TouchControls.SCALE_STEP); return + end local name = TouchControls:hitTest(x, y) if not name then return end @@ -271,6 +314,11 @@ function Editor.keypressed(key) close() elseif key == "r" then resetLayout() + -- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633) + elseif key == "-" or key == "kp-" then + TouchControls:nudgeScale(-TouchControls.SCALE_STEP) + elseif key == "=" or key == "+" or key == "kp+" then + TouchControls:nudgeScale(TouchControls.SCALE_STEP) end end diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index c85323a7..708f386b 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -789,6 +789,14 @@ function PikachuFollower.onBillsHouseEnter(game, ow) return end if game.save.flags.EVENT_MET_BILL_2 then return end + -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused + -- walk while CheckPikachuStatusCondition comes back clear + -- (engine/pikachu/pikachu_status.asm:140, carry when the starter's status + -- byte is nonzero); a statused starter keeps trailing the player instead, + -- and that is the one state that lets onBillWalksAroundPlayer below fire, + -- since the confused beat ends in DisablePikachuFollowingPlayer (#455). + local starter = PikachuFollower.starterInParty(game.save) + if starter and starter.status then return end local npc = findFollower(ow) if not npc then return end ow.pikachuBillsScene = true @@ -797,6 +805,46 @@ function PikachuFollower.onBillsHouseEnter(game, ow) end) end +-- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156), reached +-- from BillsHouseScript2 (scripts/BillsHouse.asm:58-69) only when the player +-- faces down -- so Bill has to walk around them -- and Pikachu is still +-- following, i.e. the confused beat above was skipped. Both tables go +-- through TryApplyPikachuMovementData +-- (engine/events/try_pikachu_movement.asm:1-15), which keeps the data only +-- when the caller's b equals GetPikachuFacingDirectionAndReturnToE +-- (engine/pikachu/pikachu_follow.asm:1110-1151). That byte is not the +-- sprite's facing: it is Pikachu's position relative to the player, UP when +-- Pikachu's MapY sits above the player's, DOWN below, and on a shared row +-- LEFT when west, RIGHT when east ($ff on the same cell). So WatchPlayer1 +-- (b = SPRITE_FACING_UP) means "Pikachu stands above the player" and +-- WatchPlayer2 (b = SPRITE_FACING_RIGHT) means "Pikachu stands level with +-- and east of the player". ApplyPikachuMovementData_ walks a whole table +-- synchronously (engine/pikachu/pikachu_movement.asm:9-18), so the second +-- call does observe the finished walk; the two are mutually exclusive by +-- geometry rather than by timing, since both routes end at +-- (playerX - 1, playerY), west of the player, where the RIGHT test fails. +-- Each closes on PIKAMOVEMENT_LOOK_RIGHT, leaving Pikachu watching the +-- player while Bill talks; PIKAMOVEMENT_DELAY is dropped here as in the +-- beats above (#455). +function PikachuFollower.onBillWalksAroundPlayer(game, ow) + if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") + or ow.pikachuBillsScene then + return + end + local npc = findFollower(ow) + if not npc or not ow.player then return end + local steps + if npc.cellY < ow.player.cellY then -- PikachuMovement_WatchPlayer1 + steps = { { "left", 1 }, { "down", 1 } } + elseif npc.cellY == ow.player.cellY + and npc.cellX > ow.player.cellX then -- PikachuMovement_WatchPlayer2 + steps = { { "up", 1 }, { "left", 2 }, { "down", 1 } } + else + return + end + movePikachu(ow, npc, steps, function() npc.facing = "right" end) +end + function PikachuFollower.onBillEnteredMachine(game, ow) if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end local npc = findFollower(ow) diff --git a/tests/drivers/audio_channels_bug626_test.lua b/tests/drivers/audio_channels_bug626_test.lua new file mode 100644 index 00000000..8dd01857 --- /dev/null +++ b/tests/drivers/audio_channels_bug626_test.lua @@ -0,0 +1,136 @@ +-- Manual check that audio stays on the front output pair on interfaces with +-- more than two outputs (#626). OpenAL only spatializes 1-channel Sources, +-- and one left at the default (0,0,0) position sits on the listener and is +-- spread over every output a device has; the fix renders one-shots as +-- 2-channel buffers so they map onto outputs 1+2 like the music already did. +-- Positions come from pokered data/maps/objects/PalletTown.asm (warps at +-- (5,5), (13,5), (12,11), kept clear of) plus data/generated/maps.lua for the +-- walkable/blocked cells. Channel counts a machine can check; the routing +-- only an ear on a multi-output interface can, hence the hand-off. +-- Do NOT set POKEPORT_SPEED: fast-forward scales only the logic clock and +-- desyncs the audio ordering this driver depends on. +-- POKEPORT_DRIVER=tests/drivers/audio_channels_bug626_test.lua POKEPORT_IDENTITY=bug626 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Sound = require("src.core.Sound") + local Music = require("src.core.Music") + local ChipAudio = require("src.core.ChipAudio") + + -- pokered data/maps/objects/PalletTown.asm: the three warps sit at (5,5), + -- (13,5) and (12,11), so (10,8) is open ground clear of all of them and of + -- the two walking NPCs' start cells ((3,8) girl, (11,14) fisher). + local MAP = "PALLET_TOWN" + local STAND = { x = 10, y = 8, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function channels(src) + if not src then return nil end + local ok, n = pcall(function() return src:getChannelCount() end) + return ok and n or nil + end + + if not love.audio then + check("love.audio is available", false) + U.log("This run has no audio device, so nothing below can be judged.") + U.log("Rerun on a desktop love build with the interface connected.") + while true do coroutine.yield() end + end + check("love.audio is available", true) + + -- a muted save reads exactly like the bug being fixed: silence everywhere + local opts = game.save and game.save.options or {} + check("sfxVol is not zero (" .. tostring(opts.sfxVol or 7) .. ")", + (opts.sfxVol or 7) ~= 0) + check("musicVol is not zero (" .. tostring(opts.musicVol or 7) .. ")", + (opts.musicVol or 7) ~= 0) + + -- an unknown key makes Sound.play return nil, which would read as a channel + -- FAIL for the wrong reason + local sfxTable = game.data.audio and game.data.audio.sfx or {} + local cryTable = game.data.audio and game.data.audio.cries or {} + local songTable = game.data.audio and game.data.audio.songs or {} + check("sfx key Press_AB is in this cache", sfxTable.Press_AB ~= nil) + check("sfx key Collision is in this cache", sfxTable.Collision ~= nil) + check("cry PIKACHU is in this cache", cryTable.PIKACHU ~= nil) + check("song Music_PalletTown is in this cache", + songTable.Music_PalletTown ~= nil) + + -- the machine-checkable half of the fix: every one-shot is a stereo buffer + local sfx = Sound.play(game.data, "Press_AB") + check("menu beep source is stereo", channels(sfx) == 2) + U.wait(30) + + local cry = Sound.playCry(game.data, "PIKACHU") + check("cry source is stereo", channels(cry) == 2) + U.wait(60) + + -- the siren is built by hand in ChipAudio, not through ChipSynth, so it is + -- its own path and its own regression risk + local ok, alarm = pcall(ChipAudio.newLowHealthAlarm) + check("low-health siren is stereo", ok and channels(alarm) == 2) + if ok and alarm and alarm.stop then pcall(alarm.stop, alarm) end + + -- reach the moment: overworld with the town theme running and a beep fired + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + Music.play(game.data, "Music_PalletTown", true) + U.wait(60) + + local ow = game.overworld + check("overworld is up on " .. MAP, ow ~= nil) + + -- bump a wall so the collision beep (OverworldController: Sound.play + -- "Collision") sounds at least once before the hand-off. The blocked + -- neighbour is looked up rather than hardcoded, so a map edit degrades to + -- "walked a step" instead of a silent no-op. + local bumped = false + if ow and ow.map then + local dirs = { + { 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" }, + } + for _, d in ipairs(dirs) do + local cx, cy = ow.player.cellX + d[1], ow.player.cellY + d[2] + if not ow.map:isWalkableCell(cx, cy) then + U.hold(game, d[3], 24) + U.wait(20) + bumped = true + U.log("walked into the wall at", cx, cy, "facing", d[3]) + break + end + end + if not bumped then + -- open ground on all four sides: walk a step instead, the human can + -- find a wall themselves in a moment + U.hold(game, "down", 24) + U.wait(20) + U.log("no wall next to (" .. STAND.x .. ", " .. STAND.y .. + "), took a step instead; bump one by hand") + end + end + check("a collision beep was fired before hand-off", bumped) + + U.tap(game, "start") + U.wait(40) + U.tap(game, "b") + U.wait(20) + + U.log("Pallet Town is playing and you have the pad; bump walls and open") + U.log("START to fire beeps, and press a cry with the PC or party screens.") + U.log("On a multi-output interface the music and every beep and cry should") + U.log("come out of outputs 1 and 2 only, with 3/4 and 5/6 dead silent.") + U.log("The near miss to listen for is an SFX still faintly there on 5+6") + U.log("under the music: that is the old ambient spread and means a one-shot") + U.log("path was missed, a mod file def or a Yellow PCM clip or the siren.") + U.log("Also listen for a beep arriving on output 3 alone, which would mean") + U.log("something positioned a mono source instead of widening it to stereo.") + U.log("Selecting outputs 5+6 in macOS will not move the game there either") + U.log("way; that is device-level routing, not something the app can ask for.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/bills_pikachu_watch_bug455_test.lua b/tests/drivers/bills_pikachu_watch_bug455_test.lua new file mode 100644 index 00000000..9f54544a --- /dev/null +++ b/tests/drivers/bills_pikachu_watch_bug455_test.lua @@ -0,0 +1,274 @@ +-- Manual check that Yellow's Pikachu watches the player while Bill walks +-- around them in Bill's House (#455). BillsHouseScript2 (pokeyellow +-- scripts/BillsHouse.asm:58-69) only reaches BillsHousePikachuWatchPlayer +-- (scripts/BillsHouse_2.asm:133-156) while Pikachu still follows, which needs +-- BillsHouseScript0's CheckPikachuStatusCondition to have skipped the entry +-- beat, so this driver arrives with a statused starter. No POKEPORT_SPEED: +-- it scales the logic clock only and desyncs audio against the scripted walk. +-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_watch_bug455_test.lua POKEPORT_IDENTITY=bug455 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local PikachuFollower = require("src.world.PikachuFollower") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + -- Red and Blue have no follower and no Pikachu beats at all, so every line + -- below would fail for the wrong reason on the wrong cache + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Re-run with POKEPORT_VERSION=yellow.") + idle() + end + + -- pokeyellow data/maps/objects/BillsHouse.asm: BILLSHOUSE_BILL_POKEMON sits + -- at (6,5), the two warps at (2,7) and (3,7). Standing at (6,4) facing down + -- both faces the monster and puts the player on Bill's straight path up, the + -- one branch that runs the walk-around (and so the watch beat). + local MAP = "BILLS_HOUSE" + local MONSTER = "BILLSHOUSE_BILL_POKEMON" + local STAND = { x = 6, y = 4, facing = "down" } + + -- the scripted rows and the port entry point the scene hangs off; a renamed + -- key reads on screen as "talking to Bill does nothing special" + local story = dofile("data/scripts/story.lua") + local house = story.BILLS_HOUSE or {} + check("story.BILLS_HOUSE.talk.TEXT_BILLSHOUSE_BILL_POKEMON is present", + type((house.talk or {}).TEXT_BILLSHOUSE_BILL_POKEMON) == "function") + check("story.BILLS_HOUSE.onEnter is present", + type(house.onEnter) == "function") + check("PikachuFollower.onBillWalksAroundPlayer exists", + type(PikachuFollower.onBillWalksAroundPlayer) == "function") + + -- both boxes of the scene come out of the cache; a missing key falls back to + -- a hardcoded string in story.lua, which still runs but is not what shipped + local t = game.data.text or {} + check("_BillsHouseBillImNotAPokemonText resolved from the cache", + type(t._BillsHouseBillImNotAPokemonText) == "string") + check("_BillsHouseBillUseSeparationSystemText resolved from the cache", + type(t._BillsHouseBillUseSeparationSystemText) == "string") + + -- ShouldPikachuSpawn's inputs (pikachu_follow.asm) plus the one that gates + -- this bug: a starter carrying a status byte, which is what makes + -- CheckPikachuStatusCondition set carry and BillsHouseScript0 stand down + local pika = Pokemon.new(game.data, "PIKACHU", 12) + pika.status = "PAR" + game.save.party = { pika } + game.save.player.name = "bryan" + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_MET_BILL = nil + game.save.flags.EVENT_MET_BILL_2 = nil + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.flags.EVENT_GOT_SS_TICKET = nil + game.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = nil + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + + local ow = game.overworld + if not check("Bill's House loaded", ow ~= nil and ow.map + and ow.map.id == MAP) then + idle() + end + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + local function monster() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == MONSTER then return n end + end + return nil + end + + local npc = follower() + if not check("follower spawned with pikachuFollower set", npc ~= nil) then + U.log("party PIKACHU hp:", tostring(game.save.party[1].hp), + "status:", tostring(game.save.party[1].status), + "EVENT_GOT_STARTER:", tostring(game.save.flags.EVENT_GOT_STARTER)) + idle() + end + + local mon = monster() + check("the monster object loaded on " .. MAP, mon ~= nil) + if mon then + U.log("monster at", mon.cellX, mon.cellY, "(objects file says 6, 5)") + end + + -- the whole point of the arrival: the confused beat must not have run, so + -- Pikachu is still on the follow loop rather than parked beside Bill + check("the entry beat was skipped for a statused starter", + ow.pikachuBillsScene ~= true) + check("no question bubble is up from the entry beat", ow.emote == nil) + + local function facingMonster() + if not mon then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == mon + end + + if mon and not facingMonster() then + -- a map edit or a mod moved the object: take any free walkable neighbour, + -- preferring the cell above it since that is the facing-down branch. + -- {dx, dy, facing} is the offset from the monster to the stand cell plus + -- the direction that looks back at it, so +1 on y means facing up. + local sides = { + { 0, -1, "down" }, { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" }, + } + for _, s in ipairs(sides) do + local cx, cy = mon.cellX + s[1], mon.cellY + s[2] + if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + U.log(("cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(20) + ow = game.overworld + npc, mon = follower(), monster() + break + end + end + end + check("the player is standing against the monster", facingMonster()) + check("the player faces down, the branch Bill walks around", + ow.player.facing == "down") + U.log("player at", ow.player.cellX, ow.player.cellY, + "| Pikachu at", npc and npc.cellX, npc and npc.cellY) + + -- TryApplyPikachuMovementData keys off where Pikachu stands relative to the + -- player, not off its sprite facing: above the player is WatchPlayer1 (left, + -- down), level and east is WatchPlayer2 (up, left twice, down). Anything + -- else drops the data and no watch route runs at all. + if npc then + local route = npc.cellY < ow.player.cellY and "WatchPlayer1" + or (npc.cellY == ow.player.cellY and npc.cellX > ow.player.cellX) + and "WatchPlayer2" or nil + check("Pikachu stands where one of the two watch tables applies", + route ~= nil) + U.log("geometry picks", route or "neither table") + end + + -- a muted run makes the text beeps and Bill's step sfx indistinguishable + -- from silence (Sound.setVolumeLevel reads save.options.sfxVol) + local sfxVol = game.save.options and game.save.options.sfxVol + U.log("save.options.sfxVol:", tostring(sfxVol)) + if (sfxVol or 0) == 0 then + U.log("sfx volume is 0, so no beep can be heard whether or not one plays;") + U.log("raise it in OPTIONS before judging the sound half") + end + + -- record every cell the follower occupies so a route that runs and is then + -- yanked back by the follow loop can be told from one that holds + local trail = {} + local function sample() + if not npc then return end + local last = trail[#trail] + if not last or last.x ~= npc.cellX or last.y ~= npc.cellY + or last.f ~= npc.facing then + trail[#trail + 1] = { x = npc.cellX, y = npc.cellY, f = npc.facing } + end + end + local function step(n) + for _ = 1, n do + sample() + U.wait(1) + end + sample() + end + sample() + + -- talk, take YES (ChoiceBox defaults to it), then close the separation + -- system line: that close is what runs the walk-around branch. Stop + -- pressing the moment the boxes are gone so a stray A cannot re-interact. + -- FAST text: the reveal is per-character (TextBox drawChars reads + -- save.options.textSpeed) and Bill's opener alone is four pages, so at + -- the MEDIUM default the mash budget below runs out mid-box + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + U.tap(game, "a") + step(20) + for _ = 1, 200 do + if game.stack:top() == ow then break end + U.tap(game, "a") + step(8) + end + check("the dialogue finished and the map is running the walk", + game.stack:top() == ow) + + -- let both scripted walks play out; Bill's is four legs, Pikachu's two + for _ = 1, 400 do + if #ow.scriptMoves == 0 and not ow.runner:isRunning() then break end + step(1) + end + step(40) + + local cells = {} + for _, c in ipairs(trail) do + cells[#cells + 1] = ("(%d,%d %s)"):format(c.x, c.y, tostring(c.f)) + end + U.log("Pikachu's cells through the scene:", table.concat(cells, " ")) + + if npc then + check("Pikachu ended west of the player, clear of Bill's detour", + npc.cellY == ow.player.cellY and npc.cellX < ow.player.cellX) + -- read the facing out of the recorded trail rather than off the sprite: + -- by now an idle glance (pokeyellow Func_fc803) may already have turned + -- its head, and that is vanilla, not the route failing to end on LOOK_RIGHT + local lookedRight = false + for _, c in ipairs(trail) do + if c.y == ow.player.cellY and c.x < ow.player.cellX + and c.f == "right" then + lookedRight = true + end + end + check("PIKAMOVEMENT_LOOK_RIGHT left it facing the player", lookedRight) + check("it moved at all (the watch route ran)", #trail > 1) + end + check("Bill reached the machine (the separator is armed)", + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR == true) + + -- hold still for a moment and see whether the follow loop reclaims it; + -- cells only, because the idle glances (PikachuFollower's port of + -- pokeyellow Func_fc803, a random look every ~0x20 frames) legitimately + -- turn its head while it waits + local settled = npc and { x = npc.cellX, y = npc.cellY } + step(90) + if npc and settled then + check("it stayed put once the scene ended", + npc.cellX == settled.x and npc.cellY == settled.y) + end + + if U.shot(game, SHOT_DIR .. "/bug455_bills_watch.png") then + U.log("captured", SHOT_DIR .. "/bug455_bills_watch.png") + end + + U.log("The scene has already played; the screen is where it left off.") + U.log("Pikachu should have stepped one cell west and one south out of Bill's") + U.log("way, then turned to face right and held there watching you while Bill") + U.log("climbed into the machine. Two near misses to watch for: Pikachu") + U.log("parked beside Bill with a question bubble the moment you walked in,") + U.log("which means the status gate never took and the watch beat cannot") + U.log("happen; or Pikachu walking the route and snapping straight back to") + U.log("your heel, which reads as a twitch instead of a reposition.") + U.log("Input is yours now. Stepping out to Route 25 puts the monster back") + U.log("(Route25ToggleBillsScript), so walk back in to (6,4) facing down and") + U.log("talk to it again to replay the whole beat as often as you like.") + + idle() +end diff --git a/tests/drivers/host_console_bug606_test.lua b/tests/drivers/host_console_bug606_test.lua new file mode 100644 index 00000000..4128cedd --- /dev/null +++ b/tests/drivers/host_console_bug606_test.lua @@ -0,0 +1,224 @@ +-- Manual check that launcher actions no longer flash console windows (#606). +-- src/core/HostShell.lua claims one hidden console at boot so every curl / +-- PowerShell child inherits it instead of allocating (and showing) its own. +-- No pokered counterpart: this is desktop launcher plumbing, so the standing +-- spot below is only to give the window something to be (pokered +-- data/maps/objects/PalletTown.asm: (10, 8) is clear of the three +-- object_events and the three warps). +-- POKEPORT_DRIVER=tests/drivers/host_console_bug606_test.lua POKEPORT_IDENTITY=bug606 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Leave POKEPORT_SPEED unset: fast-forward desyncs audio and logic ordering, +-- and it would also step this driver several times per rendered frame while a +-- blocking fetch is in flight. +return function(game) + local U = dofile("tests/drivers/util.lua") + local HostShell = require("src.core.HostShell") + local RomImporter = require("src.import.RomImporter") + + local isWin = love.system.getOS() == "Windows" + local passed, failed = 0, 0 + local report = {} + + -- On Windows the packaged game is love.exe, which has no console, so print() + -- goes nowhere a reporter can read -- exactly the platform this bug lives on. + -- Mirror every line into the save dir and put the count on screen so the + -- PASS/FAIL block is still legible there. + local REPORT = "bug606_report.txt" + pcall(love.filesystem.write, REPORT, "") + local function say(...) + U.log(...) + local parts = {} + for i = 1, select("#", ...) do parts[#parts + 1] = tostring((select(i, ...))) end + local line = table.concat(parts, " ") + report[#report + 1] = line + pcall(love.filesystem.append, REPORT, line .. "\n") + end + + local function check(label, ok) + if ok then passed = passed + 1 else failed = failed + 1 end + say(ok and "PASS" or "FAIL", label) + return ok + end + + local function fileHas(path, needle) + local ok, body = pcall(love.filesystem.read, path) + return ok and type(body) == "string" and body:find(needle, 1, true) ~= nil + end + + check("HostShell exposes hideHostConsole", type(HostShell.hideHostConsole) == "function") + + -- Wiring: an unwired helper fixes nothing, and it has to run before the + -- self-updater boot shell, which is the first thing that can shell out. + local okMain, mainSrc = pcall(love.filesystem.read, "main.lua") + local atCall = okMain and type(mainSrc) == "string" + and mainSrc:find("hideHostConsole", 1, true) or nil + local atBoot = okMain and type(mainSrc) == "string" + and mainSrc:find("Boot.run", 1, true) or nil + check("love.load claims the console before anything shells out", + atCall ~= nil and atBoot ~= nil and atCall < atBoot) + + local hid = HostShell.hideHostConsole() + check("calling it twice answers the same (memoized, so no second console)", + HostShell.hideHostConsole() == hid) + if not isWin then + check("no console is allocated off Windows", hid == false) + end + say("host:", love.system.getOS(), "-- console claimed:", tostring(hid), + "-- POKEPORT_CONSOLE:", tostring(os.getenv("POKEPORT_CONSOLE"))) + + -- The ROM picker answers by writing the chosen path to stdout and letting + -- commandOutput read it back through HostShell.popen; if that write ever + -- breaks the launcher just silently refuses every ROM, which is the second + -- way this fix can go wrong. + check("the Windows ROM picker still writes its pick to stdout", + fileHas("src/import/RomImporter.lua", "[Console]::Write($d.FileName)")) + + local okFfi, ffi = pcall(require, "ffi") + if isWin and okFfi then + -- HostShell already declared GetConsoleWindow if it got that far; a repeat + -- cdef raises, so each one gets its own pcall. + pcall(ffi.cdef, "void *GetConsoleWindow(void);") + pcall(ffi.cdef, "int IsWindowVisible(void *hWnd);") + local okCall, hwnd = pcall(function() return ffi.C.GetConsoleWindow() end) + if hid then + check("the process owns a console for its children to inherit", + okCall and hwnd ~= nil) + -- The near-miss: AllocConsole worked but ShowWindow did not resolve, so + -- the console exists and sits on screen for the whole session. + local okVis, visible = pcall(function() return ffi.C.IsWindowVisible(hwnd) end) + check("and that console window is hidden", okVis and visible == 0) + else + say("This run did not claim a console: either POKEPORT_CONSOLE=1 is set,") + say("or it was started from a terminal (lovec.exe, what scripts\\run.ps1") + say("prefers), which already had one. Re-run under love.exe to judge #606.") + end + end + + -- Fire the three host tools the launcher actually spawns, through the same + -- funnel they use (src/core/HostShell.lua:popen). Each of these was one + -- console flash before the fix. + local probes = isWin and { + { "curl, the update and mod-index fetch", "curl --version", "curl" }, + { "powershell, the ROM and mod pickers", + 'powershell -NoProfile -Command "[Console]::Write(\'pokeport-ok\')"', "pokeport-ok" }, + { "cmd, the update downloader's start /b", "cmd /c echo pokeport-ok", "pokeport-ok" }, + } or { + { "curl, the update and mod-index fetch", "curl --version", "curl" }, + { "the shell the pickers run under", "echo pokeport-ok", "pokeport-ok" }, + } + + local function runProbes(quiet) + for _, p in ipairs(probes) do + local out + local pipe = HostShell.popen(p[2]) + if pipe then + out = pipe:read("*a") + pipe:close() + end + local ok = type(out) == "string" and out:find(p[3], 1, true) ~= nil + if quiet then + say(ok and "PASS" or "FAIL", p[1] .. " still answers") + else + check(p[1] .. " still answers", ok) + end + U.wait(20) -- space them out so a flash is countable by eye + end + end + runProbes(false) + + -- The launcher's boot release check is skipped under POKEPORT_DRIVER + -- (RomImporter's updaterAllowed), so start it by hand: it is a love.thread + -- whose curl calls popped their own consoles too, and the hidden one is + -- process-wide, so the thread is covered without touching check_worker.lua. + local okCheck, Check = pcall(require, "src.update.Check") + if okCheck and Check then + pcall(Check.start) + for _ = 1, 240 do + local st = Check.state() + if st.status ~= "checking" then break end + U.wait(1) + end + local st = Check.state() + check("the update check ran and reached a verdict", + st.status ~= "checking" and st.status ~= "idle") + say("update check says:", st.status, st.error and ("(" .. tostring(st.error) .. ")") or "") + end + + -- Prove the window is rendering before the launcher takes the frame over. + local MAP, STAND = "PALLET_TOWN", { x = 10, y = 8, facing = "down" } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the ground under us; any free neighbour will do + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.teleport(game, MAP, cx, cy, "down") + U.wait(10) + break + end + end + end + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + check("the window rendered", U.shot(game, SHOT_DIR .. "/bug606_console.png")) + + -- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is + -- a scripted run), so bring a real interactive one up and take over the + -- handlers it normally owns. This is the panel the reporter needs: Mods > + -- Find mods adds a repo and installs, and a column that is not imported yet + -- shows Choose ROM. + local imp = RomImporter.new(function() end, { launcher = true }) + imp.play = function() U.log("Play is inert here: the game is already booted.") end + if okCheck and Check then imp.Check = Check end -- so the update banner draws + if os.getenv("POKEPORT_PICKER") == "1" then + -- every column offers Choose ROM again, so the picker can be opened even on + -- a machine where all three games are already imported. Cancelling the + -- dialog is enough; only picking a file would re-extract. + imp.forceImport = true + imp.ready = {} + end + + love.draw = function() + imp:update(love.timer.getDelta()) + imp:draw() + local w, h = love.graphics.getWidth(), love.graphics.getHeight() + love.graphics.setColor(0, 0, 0, 0.72) + love.graphics.rectangle("fill", 0, h - 54, w, 54) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.print(("#606 checks: %d passed, %d failed"):format(passed, failed), 10, h - 48) + love.graphics.print("F9 runs curl / powershell / the update check again", 10, h - 34) + love.graphics.print("full log: " .. love.filesystem.getSaveDirectory() .. "/" .. REPORT, + 10, h - 20) + end + love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end + love.textinput = function(t) imp:textinput(t) end + -- backspace and enter in the "add an index" field arrive here, not through + -- textinput; f9 is kept for the loop below so the launcher never sees it + love.keypressed = function(key) + if key ~= "f9" then imp:keypressed(key) end + end + + say("The launcher on screen is the real one. Open Mods > Find mods, add an") + say("index, install something, and click Choose ROM on a column that is not") + say("imported yet (POKEPORT_PICKER=1 brings that button back on every column).") + say("Right: not one black console window appears or blinks, at any point,") + say("while the file dialog still opens normally and the fetches still land.") + say("Near-miss one: a single console sits there permanently instead of many") + say("flashes, which means it was allocated but ShowWindow never resolved.") + say("Near-miss two: the picker dialog opens, you choose a ROM, and the") + say("launcher just carries on as if you had cancelled -- that is the") + say("PowerShell stdout write, not the console.") + + local held = false + while true do + -- keep F9 for the reporter and hand every other key to the launcher + local down = love.keyboard and love.keyboard.isDown("f9") + if down and not held then + say("re-running the host tools; watch the desktop, not the window") + runProbes(true) + if okCheck and Check then pcall(Check.start) end + end + held = down + coroutine.yield() + end +end diff --git a/tests/drivers/item_picker_wheel_bug595_test.lua b/tests/drivers/item_picker_wheel_bug595_test.lua new file mode 100644 index 00000000..fba0b86c --- /dev/null +++ b/tests/drivers/item_picker_wheel_bug595_test.lua @@ -0,0 +1,228 @@ +-- Manual check that the save editor's item lists scroll under the wheel (#595). +-- The ADD ITEM picker lists every non-badge id in pokered constants/item_constants.asm, +-- which is far more than one screenful, and typing a query used to be the only way +-- past the first page. This is the editor app, not the game, so there is no +-- POKEPORT_DRIVER hook: this file runs itself, checks the seams, seeds a save with +-- a stocked bag and PC, then opens the real editor on it. Do not set POKEPORT_SPEED. +-- luajit tests/drivers/item_picker_wheel_bug595_test.lua + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +love = require("tests.love_stub") + +local Kit = require("Kit") +local App = require("App") +local Ops = require("Ops") +local SaveData = require("src.core.SaveData") + +-- unbuffered, or a redirected run would show the editor's own output before +-- the checks that were printed ahead of it +io.stdout:setvbuf("line") + +local function log(...) print("[driver]", ...) end + +local fails = 0 +local function check(label, ok) + if not ok then fails = fails + 1 end + log(ok and "PASS" or "FAIL", label) + return ok +end + +-- ------------------------------------------------------------------ Kit seam +-- Kit.scroll is the whole fix; every panel rule below is a consequence of it. +check("Kit.scroll exists", type(Kit.scroll) == "function") + +Kit.beginFrame(50, 50, false, -1) +check("a notch inside a list body advances three rows", + Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 3) +check("and is consumed, so a second list cannot eat the same notch", + Kit.wheelY == 0) +Kit.endFrame() + +Kit.beginFrame(50, 50, false, -1) +check("the last page is the floor", + Kit.scroll(0, 0, 100, 100, 245, 250, 10) == 240) +Kit.endFrame() + +Kit.beginFrame(500, 500, false, -1) +check("a list the pointer is not over ignores the wheel", + Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 0) +Kit.endFrame() + +Kit.beginFrame(50, 50, false, -1) +Kit.blockClicks = true +check("the species-picker modal shield stops the wheel like it stops a click", + Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 0) +Kit.blockClicks = false +Kit.endFrame() + +-- ------------------------------------------------------------- seeded save +-- A save with one page of items proves nothing: stock the bag to its cap and +-- push a couple of pages into PC storage so both pagers have somewhere to go. +local SEED_DIR = (os.getenv("TMPDIR") or "/tmp/"):gsub("/*$", "/") .. "pokeport-bug595" +local SEED = SEED_DIR .. "/save.lua" +os.execute('mkdir -p "' .. SEED_DIR .. '" 2>/dev/null') + +local seedFile = io.open(SEED, "wb") +if seedFile then + seedFile:write(SaveData.encode(SaveData.newGame())) + seedFile:close() +end +check("a scratch save was written to " .. SEED, seedFile ~= nil) + +local ok, err = pcall(App.load, SEED, { version = "red" }) +check("the editor loads it headless (data/generated present): " .. tostring(err), ok) + +local S = ok and App.getState() or nil +if S then + S.tab = "items" + local stocked, pcKinds = 0, 0 + S.save.pcItems = S.save.pcItems or {} + for _, id in ipairs(S.cat.items) do + if not Ops.isBadgeId(id) then + stocked = stocked + 1 + if stocked <= 20 then + S.save.inventory[id] = 3 + elseif pcKinds < 30 then + pcKinds = pcKinds + 1 + S.save.pcItems[id] = 5 + end + end + end + local out = io.open(SEED, "wb") + if out then out:write(SaveData.encode(S.save)) out:close() end + App.load(SEED, { version = "red" }) + S = App.getState() + S.tab = "items" + + -- The panel is layout over Ops, so its rects are not written down anywhere: + -- read them back off the Kit.scroll calls the draw makes, which is also the + -- only honest way to point the fake mouse at the right list. If a rect goes + -- missing the hand-off below still happens, with the count in the log. + local rects = {} + local realScroll = Kit.scroll + Kit.scroll = function(x, y, w, h, offset, total, perPage) + rects[#rects + 1] = { x = x, y = y, w = w, h = h, total = total } + return realScroll(x, y, w, h, offset, total, perPage) + end + local fields = {} + local realField = Kit.textfield + Kit.textfield = function(id, x, y, w, h, value, placeholder) + fields[id] = { x = x, y = y, w = w, h = h } + return realField(id, x, y, w, h, value, placeholder) + end + local counters = {} + local realRight = Kit.textRight + Kit.textRight = function(name, str, ...) + counters[#counters + 1] = tostring(str) + return realRight(name, str, ...) + end + + App.draw() + check("the Items tab hands three lists to Kit.scroll (picker, bag, PC)", + #rects == 3) + local pick, bag, pc = rects[1], rects[2], rects[3] + + local function pointAt(r) + love.mouse.getPosition = function() return r.x + r.w / 2, r.y + r.h / 2 end + end + local function counterLine() + for _, str in ipairs(counters) do + if str:match("^%d+%-%d+ of %d+$") then return str end + end + return nil + end + + if pick then + check("the picker caption carries a position counter, not a +N more", + counterLine() ~= nil) + log("the picker counter reads", tostring(counterLine()), + "over a catalog of " .. tostring(pick.total) .. " ids") + + pointAt(pick) + counters = {} + App.wheelmoved(0, -1) + App.draw() + check("one notch over the picker advances it three rows", + S.itemPickOffset == 3) + check("and neither the bag nor the PC column moved with it", + S.bagOffset == 0 and S.pcOffset == 0) + log("the counter now reads", tostring(counterLine())) + end + + if bag then + pointAt(bag) + local before = S.itemPickOffset + App.wheelmoved(0, -1) + App.draw() + check("a notch over the BAG column moves the bag pager's own offset", + S.bagOffset > 0) + check("and leaves the picker where it was", S.itemPickOffset == before) + end + + if pc then + pointAt(pc) + local before = S.bagOffset + App.wheelmoved(0, -1) + App.draw() + check("a notch over PC STORAGE moves the PC list", S.pcOffset > 0) + check("and leaves the bag where it was", S.bagOffset == before) + end + + -- Typing has to rewind the view: a query that matches ids near the top of a + -- catalog the view had already scrolled past would otherwise show an empty + -- body over a non-zero offset, which reads exactly like "no item matches". + local field = fields["item-query"] + if pick and field then + pointAt(pick) + for _ = 1, 12 do + App.wheelmoved(0, -1) + App.draw() + end + local deep = S.itemPickOffset + love.mouse.getPosition = function() return field.x + 4, field.y + 4 end + App.mousepressed(field.x + 4, field.y + 4, 1) + App.draw() + App.textinput("P") + App.draw() + check("a keystroke in the search box rewinds the deep-scrolled list to the top", + deep > 0 and S.itemQuery == "P" and S.itemPickOffset == 0) + end + + -- The map tab spends the wheel on zoom and must keep it. + S.tab = "items" + local zoom = S.mapZoom + App.wheelmoved(0, -1) + App.draw() + check("a notch outside the Map tab leaves the map camera alone", S.mapZoom == zoom) + S.tab = "map" + App.wheelmoved(0, 1) + check("the Map tab still zooms on the wheel", S.mapZoom > zoom) + + Kit.scroll, Kit.textfield, Kit.textRight = realScroll, realField, realRight + App.unload() +end + +log(fails == 0 and "all seam checks passed" or (fails .. " seam checks failed")) + +-- --------------------------------------------------------------- hand-off +log("Opening the editor on that seeded save. Click the Items tab, put the pointer") +log("over the ADD ITEM list and roll the wheel: rows step three at a time and the") +log("counter on the caption line tracks them, stopping on the last page; clicking a") +log("row still picks it for the two add buttons. Over BAG or PC STORAGE the wheel") +log("moves the same rows the Prev/Next pager does and the pager label keeps up.") +log("The near miss to watch for is both columns jumping on one notch, or the panel") +log("scrolling underneath the species picker while that modal is open on Party.") + +local love_bin = os.getenv("LOVE") or "love" +os.execute(love_bin .. ' . --editor --save "' .. SEED .. '"') + +log("Editor closed. Reopen it on the same stocked save any time with:") +log(" " .. love_bin .. ' . --editor --save "' .. SEED .. '"') + +-- Park rather than exit: the driver is never the thing that ends the session, +-- so the terminal stays put until the reader Ctrl-Cs it. +while true do + os.execute("sleep 3600") +end diff --git a/tests/drivers/launcher_mods_bulk_bug647_test.lua b/tests/drivers/launcher_mods_bulk_bug647_test.lua new file mode 100644 index 00000000..244063df --- /dev/null +++ b/tests/drivers/launcher_mods_bulk_bug647_test.lua @@ -0,0 +1,226 @@ +-- Manual check for the launcher's Enable all / Disable all chips (#647). +-- POKEPORT_DRIVER skips the launcher (main.lua boots straight into the game), +-- so this stands a second launcher up on the MODS tab and hands the mouse over. +-- No pokered cite applies: this is launcher chrome, nothing in the ROM-side +-- game is involved, and there is no map position to derive. +-- POKEPORT_DRIVER=tests/drivers/launcher_mods_bulk_bug647_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Do not set POKEPORT_SPEED: it multiplies the act+step loop per rendered +-- frame, so the driver would run several steps between two launcher draws and +-- read rects from a frame that was never on screen (and it desyncs audio too). +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local LauncherMods = require("src.mods.LauncherMods") + local SaveData = require("src.core.SaveData") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Left-click the middle of a launcher rect. The button argument is load + -- bearing: the confirm modal's dispatch returns early on anything but 1. + local function click(importer, r) + importer:mousepressed(r.x + r.width / 2, r.y + r.height / 2, 1) + end + + -- ---- preconditions, before anything is drawn or written + + check("LauncherMods.setAllEnabled is the one-write bulk path", + type(LauncherMods.setAllEnabled) == "function") + check("RomImporter:_setAllMods exists for the chips to call", + type(RomImporter._setAllMods) == "function") + check("a window is up to draw the launcher into", + love.window and love.window.isOpen and love.window.isOpen() + and love.graphics.getWidth() > 0) + + -- The panel reads the same options.mods the loader writes, so a read-only or + -- quarantined options file would make every click look like it did nothing. + local options = SaveData.loadOptions() + local savedMods = {} + for id, v in pairs(options.mods or {}) do savedMods[id] = v end + check("options.lua round-trips (the chips persist through it)", + SaveData.saveOptions(options) ~= nil) + + local mods = LauncherMods.list() + check("at least two mods are installed to act on (found " + .. #mods .. ")", #mods >= 2) + if #mods < 2 then + U.log("mods/ in the save dir (or the repo's mods/ on a source run) is what") + U.log("gets scanned one level deep; drop two mod folders there and rerun.") + end + local names = {} + for _, m in ipairs(mods) do names[#names + 1] = m.id end + U.log("installed mods:", table.concat(names, ", ")) + + -- The experimental confirm is half of what #647 has to get right, and none of + -- the bundled example mods are flagged, so plant one rather than let that + -- branch go unseen. Manifest.validate is pure (no filesystem), but the real + -- loader wants the entry chunk to exist if the human later presses Play. + local SEED = "driver_experimental_647" + local seeded = false + local haveExperimental = false + for _, m in ipairs(mods) do + if m.experimental then haveExperimental = true end + end + if not haveExperimental then + love.filesystem.createDirectory("mods") + love.filesystem.createDirectory("mods/" .. SEED) + local manifest = ([[{ + "id": "%s", + "name": "Driver Experimental Mod", + "version": "1.0.0", + "entry": "main.lua", + "experimental": true, + "description": "Planted by the #647 driver so the Enable all confirm has something to warn about." +} +]]):format(SEED) + local okM = love.filesystem.write("mods/" .. SEED .. "/manifest.json", manifest) + love.filesystem.write("mods/" .. SEED .. "/main.lua", "return {}\n") + seeded = okM and true or false + check("planted an experimental mod so the confirm can fire", seeded) + U.log("it lives in " .. love.filesystem.getSaveDirectory() .. "/mods/" .. SEED) + U.log("remove it with: rm -rf '" .. love.filesystem.getSaveDirectory() + .. "/mods/" .. SEED .. "'") + mods = LauncherMods.list() + end + + -- ---- stand the launcher up and take the callbacks off main.lua + + local co = coroutine.running() + local CALLBACKS = { + "update", "draw", "mousepressed", "mousereleased", "mousemoved", + "keypressed", "keyreleased", "textinput", "wheelmoved", "filedropped", + "focus", + } + local prev = {} + for _, name in ipairs(CALLBACKS) do prev[name] = love[name] end + local function restoreHost() + for name, fn in pairs(prev) do love[name] = fn end + end + + -- Play hands back to the game main.lua already booted (POKEPORT_VERSION), not + -- to whichever column was clicked: the driver cannot reach main.lua's local + -- bootGame, and only one cache is mounted. + local importer = RomImporter.new(function() + restoreHost() + U.log("launcher closed, the already-booted game takes the window back") + end, { launcher = true }) + importer.tab = "mods" + + love.update = function(dt) + importer:update(dt) + if coroutine.status(co) == "suspended" then + local ok, err = coroutine.resume(co, game) + if not ok then print("driver error: " .. tostring(err)) end + end + end + love.draw = function() importer:draw() end + love.mousepressed = function(x, y, button) importer:mousepressed(x, y, button) end + love.mousereleased = function() end + love.mousemoved = function() end + love.keypressed = function(key) importer:keypressed(key) end + love.keyreleased = function() end + love.textinput = function(text) importer:textinput(text) end + -- RomImporter.new already chained the previous handler onto love.wheelmoved; + -- point it straight at the launcher so a scroll cannot also reach the game. + love.wheelmoved = function(dx, dy) importer:wheelmoved(dx, dy) end + love.filedropped = function(file) importer:filedropped(file) end + love.focus = function(f) importer:focus(f) end + + -- rects are built inside draw(), so every read below needs a rendered frame + -- between the change and the check + U.wait(3) + + local ena, dis = importer.modEnableAllRect, importer.modDisableAllRect + local imp = importer.modImportRect + check("Enable all / Disable all have rects on the MODS tab", + ena ~= nil and dis ~= nil) + if ena and dis and imp then + check("both sit left of Import mod .zip", + ena.x + ena.width <= imp.x and dis.x + dis.width <= imp.x) + check("Enable all is left of Disable all and they do not overlap", + ena.x + ena.width <= dis.x) + check("they are on the header row, centred against the import button", + math.abs((ena.y + ena.height / 2) - (imp.y + imp.height / 2)) <= 2) + end + + -- Disable all: one write, count to zero, and a notice that names the number. + if dis then + click(importer, dis) + U.wait(2) + local after = LauncherMods.list() + local on = 0 + for _, m in ipairs(after) do if m.enabled then on = on + 1 end end + check("Disable all switched every mod off", on == 0) + check("the notice counts what it did (" .. + tostring(importer.modNotice and importer.modNotice.text) .. ")", + importer.modNotice ~= nil + and importer.modNotice.text:match("^Disabled %d+ mods%.$") ~= nil) + end + + -- Enable all with an experimental mod in the list must stop at the confirm, + -- and Cancel must leave the list exactly as it was. + if ena then + click(importer, ena) + U.wait(2) + local c = importer._modConfirm + check("Enable all armed the experimental confirm instead of enabling", + c ~= nil and c.kind == "enableAll") + local stillOff = 0 + for _, m in ipairs(LauncherMods.list()) do + if not m.enabled then stillOff = stillOff + 1 end + end + check("nothing was enabled while the confirm is up", + stillOff == #LauncherMods.list()) + if c and importer._modConfirmNo then + click(importer, importer._modConfirmNo) + U.wait(2) + local on = 0 + for _, m in ipairs(LauncherMods.list()) do if m.enabled then on = on + 1 end end + check("Cancel closed the confirm and left the list untouched", + importer._modConfirm == nil and on == 0) + end + end + + -- #433 shape: a rect that outlives its panel stays clickable over the next + -- tab. _resetFrameRects has to nil both chips every frame. + importer.tab = "red" + U.wait(3) + check("switching to the RED tab drops both chip rects", + importer.modEnableAllRect == nil and importer.modDisableAllRect == nil) + importer.tab = "mods" + U.wait(3) + + -- Narrow column: the chips are dropped, never squeezed onto the count. + local ww, wh, flags = love.window.getMode() + love.window.setMode(480, wh, flags) + U.wait(4) + check("at minimum window width the chips vanish rather than overlap the count", + importer.modEnableAllRect == nil and importer.modDisableAllRect == nil) + love.window.setMode(ww, wh, flags) + U.wait(4) + check("the chips come back when the window is wide again", + importer.modEnableAllRect ~= nil and importer.modDisableAllRect ~= nil) + + -- put the enable-state back the way the human found it + local restore = SaveData.loadOptions() + restore.mods = savedMods + SaveData.saveOptions(restore) + importer:_refreshMods() + importer.modNotice = nil + U.wait(2) + + U.log("The MODS tab is up with the enable-state back as you left it. Enable all") + U.log("and Disable all are the two chips immediately left of Import mod .zip;") + U.log("clicking one should flip every switch at once, move the \"N of M enabled\"") + U.log("count, and print \"Disabled N mods.\" underneath, with Enable all stopping") + U.log("at an Experimental mods confirm first. Watch for the chips crowding or") + U.log("overlapping that count as you drag the window narrower (they should just") + U.log("disappear), and for a click on empty space in the RED tab, where the chips") + U.log("used to be, doing something anyway. Quit and rerun to check it persisted.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/mod_index_bug597_test.lua b/tests/drivers/mod_index_bug597_test.lua new file mode 100644 index 00000000..c55c93f8 --- /dev/null +++ b/tests/drivers/mod_index_bug597_test.lua @@ -0,0 +1,153 @@ +-- Manual check that pulling a mod index works without curl (#597): every +-- remote fetch now goes through src/core/HostShell.lua, which prefers curl and +-- falls back to love.system.httpDownload (the Android GameActivity bridge). +-- Not a map moment, it lives in the launcher's FIND MODS panel, so this driver +-- runs the same transport calls that panel makes and then leaves F9 as a +-- refetch key; the player position is only there to give the window something +-- to be (pokered data/maps/objects/PalletTown.asm: (10, 8) is clear of the +-- three object_events and of the three warps). +-- POKEPORT_DRIVER=tests/drivers/mod_index_bug597_test.lua POKEPORT_IDENTITY=bug597 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Do not set POKEPORT_SPEED: fast-forward desyncs audio and logic ordering, +-- and it would also step this driver several times per rendered frame while a +-- blocking fetch is in flight. +return function(game) + local U = dofile("tests/drivers/util.lua") + local HostShell = require("src.core.HostShell") + local ModIndex = require("src.mods.ModIndex") + local ModUpdate = require("src.mods.ModUpdate") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Source-text checks, because the failure this fixes is a string a human + -- reads on a device: the old red "curl is not available on this platform" + -- must be gone from both callers, replaced by the transport-neutral wording. + local function fileHas(path, needle) + local ok, body = pcall(love.filesystem.read, path) + return ok and type(body) == "string" and body:find(needle, 1, true) ~= nil + end + + check("HostShell exposes the shared transport", + type(HostShell.canFetch) == "function" + and type(HostShell.httpGet) == "function" + and type(HostShell.httpDownload) == "function") + check("ModIndex no longer speaks of curl to the player", + not fileHas("src/mods/ModIndex.lua", "curl is not available") + and fileHas("src/mods/ModIndex.lua", "no network transport")) + check("ModUpdate no longer speaks of curl to the player", + not fileHas("src/mods/ModUpdate.lua", "curl is not available") + and fileHas("src/mods/ModUpdate.lua", "no network transport")) + check("ModUpdate.haveCurl still answers (the panel's old gate)", + type(ModUpdate.haveCurl) == "function") + + -- The Android half only reaches a device through a rebuilt liblove/APK, so + -- confirm the vendored tree really carries the bridge before anyone blames + -- the phone for a stale build. + check("GameActivity.httpDownload is in the vendored Android tree", + fileHas("mobile/android/love/src/main/java/org/love2d/android/GameActivity.java", + "public static boolean httpDownload")) + check("liblove registers love.system.httpDownload", + fileHas("mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp", + '{ "httpDownload", w_httpDownload }')) + + local haveCurl = HostShell.haveCurl() + check("this machine has a transport (desktop: curl)", HostShell.canFetch()) + U.log("transport here:", haveCurl and "curl" or "the Android bridge / none") + + -- Sources come from options.modIndexes, i.e. whatever the human added in the + -- launcher. POKEPORT_INDEX_URL is the way to point a fresh identity at a + -- feed without adding it first. + local sources = ModIndex.sources() + local envUrl = os.getenv("POKEPORT_INDEX_URL") + local source = sources[1] + if envUrl and envUrl ~= "" then + source = ModIndex.resolveSource(envUrl) or source + end + check("a mod index is configured", source ~= nil) + if source then U.log("index:", source.label or source.feed) end + + local index, fetchErr, meta, seconds + if source then + -- force = true is exactly what the panel's Refresh button does, and both + -- transports block the calling thread, so this frame will be a long one. + local t0 = love.timer.getTime() + index, fetchErr, meta = ModIndex.fetch(source, { force = true }) + seconds = love.timer.getTime() - t0 + check("the feed fetched and parsed", index ~= nil and fetchErr == nil) + if index then + check("the listing has mods in it", #(index.mods or {}) > 0) + U.log(("%d mods in %.1fs%s"):format(#(index.mods or {}), seconds, + (meta and meta.fromCache) and " (from cache, so the live fetch failed)" or "")) + else + U.log("fetch error:", tostring(fetchErr)) + end + end + + -- Thumbnails are the second half of the bug: the cards can list while every + -- picture stays blank if the download writes but the rename or size check + -- loses the file. + if index then + local shot + for _, entry in ipairs(index.mods or {}) do + local url = ModIndex.joinUrl(entry._base, entry.thumbnail) + if url then + local path, err = ModIndex.downloadThumbnail(url, entry.id) + local info = path and love.filesystem.getInfo(path) + check("a thumbnail downloaded with bytes in it", + path ~= nil and info ~= nil and (info.size or 0) > 0) + if not path then U.log("thumbnail error:", tostring(err)) end + shot = path + break + end + end + if not shot then U.log("no entry in this index carries a thumbnail") end + end + + -- Give the window something to be, and prove it is drawing at all. + local MAP, STAND = "PALLET_TOWN", { x = 10, y = 8, facing = "down" } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the grass under us; any free neighbour will do + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.teleport(game, MAP, cx, cy, "down") + U.wait(10) + break + end + end + end + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + check("the window rendered", U.shot(game, SHOT_DIR .. "/bug597_modindex.png")) + + U.log("The fetch above already ran; F9 runs it again, timed, so you can") + U.log("watch a Refresh cost a second or two rather than stall. The real") + U.log("check is the launcher: quit this window, run scripts/run.sh, and in") + U.log("Mods > Find mods add an index URL -- cards should fill in within a") + U.log("couple of seconds with their thumbnails drawn, same as before.") + U.log("Near-misses: cards list but every thumbnail stays blank (the .part") + U.log("file never got renamed), or on a phone the message reads \"no network") + U.log("transport on this platform\", which means the APK still has the old") + U.log("liblove and needs a full scripts/build_android.sh, not a repackage.") + + local held = false + while true do + local down = love.keyboard and love.keyboard.isDown("f9") + if down and not held and source then + local t0 = love.timer.getTime() + local again, err = ModIndex.fetch(source, { force = true }) + local dt = love.timer.getTime() - t0 + if again then + U.log(("refetch: %d mods in %.1fs"):format(#(again.mods or {}), dt)) + else + U.log(("refetch failed after %.1fs: %s"):format(dt, tostring(err))) + end + end + held = down + coroutine.yield() + end +end diff --git a/tests/drivers/mod_profiles_bug593_test.lua b/tests/drivers/mod_profiles_bug593_test.lua new file mode 100644 index 00000000..4288b1fc --- /dev/null +++ b/tests/drivers/mod_profiles_bug593_test.lua @@ -0,0 +1,197 @@ +-- Manual check of the mod manager's PROFILES tab: seeded PROFILE 1, applying a +-- profile back over a staged toggle, and EXPORT.. writing a .g1rmodlist (#593). +-- The manager has no pokered analogue; the stand cell comes from pokered +-- data/maps/objects/PalletTown.asm (warp_event 5, 5 is Red's front door, so the +-- cell below it is the doorstep), with a walkable-neighbour fallback. +-- POKEPORT_DRIVER=tests/drivers/mod_profiles_bug593_test.lua POKEPORT_IDENTITY=bug593 POKEPORT_DEV=1 love . +-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so taps, +-- the notice timer and the confirm blip stop lining up. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local ModProfile = require("src.mods.ModProfile") + + local MAP = "PALLET_TOWN" + local STAND = { x = 5, y = 6, facing = "up" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- park somewhere ordinary first: the manager is opened over the overworld in + -- real play (F10, src/core/Game.lua:446) and draws on top of it + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit or a mod moved the doorstep: any free neighbour will do, + -- nothing here depends on the exact cell + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("doorstep (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, "up") + U.wait(10) + break + end + end + end + + local opts = (game.save and game.save.options) or {} + local status = (game.mods and game.mods.status and game.mods:status()) + or game.modStatus or { available = {} } + -- two installed mods is the floor for this check: with one mod a profile + -- swap cannot be told apart from the mod simply staying on + check("at least two mods installed under mods/", #(status.available or {}) >= 2) + for _, m in ipairs(status.available or {}) do + U.log("installed mod:", m.id, m.enabled and "on" or "off") + end + check("sfxVol is not zero, so the A press blips", (opts.sfxVol or 0) > 0) + + -- open the manager the way F10 does (Game:keypressed pushes the same id) + Screens.push(game, "ManagerState") + U.wait(10) + local mgr = game.stack:top() + check("the mod manager is on top of the stack", mgr and mgr.screenId == "ManagerState") + if not (mgr and mgr.screenId == "ManagerState") then + while true do coroutine.yield() end + end + + -- right once: MODS -> PROFILES + U.tap(game, "right") + U.wait(6) + local rows = mgr:rowsForScreen() + check("right lands on the PROFILES tab", mgr.tab == 2) + local labels = {} + for i, r in ipairs(rows) do labels[i] = r.label end + U.log("profile rows:", table.concat(labels, " / ")) + + local first = rows[1] + check("PROFILE 1 was seeded from the setup that was already live", + first and first.profile ~= nil and first.label == "PROFILE 1") + check("it is marked active with the ! gutter glyph", + first and first.glyph == "!" and opts.activeProfile == "PROFILE 1") + -- the near-miss: profileRows grew by EXPORT.. and IMPORT.., so a snapCursor + -- or moveCursor still assuming the old three-row tail parks the cursor on the + -- wrong row and A fires the wrong action + check("the cursor sits on the first profile, not somewhere down the tail", + mgr.cursor == 1 and (mgr:focusedRow() or {}).label == "PROFILE 1") + local tail = {} + for i = #rows - 3, #rows do tail[#tail + 1] = rows[i] and rows[i].label end + check("the tail reads SAVE CURRENT AS.. / EXPORT.. / IMPORT.. / [AD-HOC]", + tail[1] == "SAVE CURRENT AS.." and tail[2] == "EXPORT.." + and tail[3] == "IMPORT.." and (tail[4] or ""):find("AD-HOC", 1, true) ~= nil) + + -- stage a mod off on the MODS tab so applying PROFILE 1 has something to undo. + -- Pick one nothing else depends on and that is not experimental, so the toggle + -- commits straight away instead of opening the cascade or confirm overlay. + local target + for _, m in ipairs(status.available or {}) do + if m.enabled and not m.experimental then + local needed = false + for _, other in ipairs(status.available or {}) do + for _, spec in ipairs(other.dependencySpecs or {}) do + if spec.id == m.id then needed = true end + end + end + if not needed then target = m break end + end + end + check("found an enabled mod with no dependents to toggle", target ~= nil) + + -- refresh() rebuilds status.available, so hold the id and re-look it up + -- through mgr.byId rather than keeping the manifest table from before + local targetId = target and target.id + local function live() + return targetId and mgr.byId[targetId] or nil + end + + if targetId then + U.tap(game, "left") + U.wait(6) + for _ = 1, 40 do + local row = mgr:focusedRow() + if row and row.mod and row.mod.id == targetId then break end + U.tap(game, "down") + U.wait(2) + end + local row = mgr:focusedRow() + check("cursor reached " .. targetId .. " on the MODS tab", + row and row.mod and row.mod.id == targetId) + U.tap(game, "select") + U.wait(6) + check("SELECT staged " .. targetId .. " off", + mgr.overlay == nil and (live() or {}).enabled == false) + + U.tap(game, "right") + U.wait(6) + check("back on PROFILES with the cursor on PROFILE 1", + mgr.tab == 2 and mgr.cursor == 1 + and (mgr:focusedRow() or {}).label == "PROFILE 1") + U.tap(game, "a") + U.wait(10) + check("applying PROFILE 1 re-staged " .. targetId, + (live() or {}).enabled == true) + U.log("footer notice reads:", tostring(mgr.notice)) + check("the footer says PROFILE STAGED", mgr.notice == "PROFILE STAGED") + end + + -- walk down to EXPORT.. by taps, so a cursor that skips shows up here. + -- From the top of the list that is every profile row after the first plus + -- SAVE CURRENT AS.., i.e. #profiles + 1 presses. + local profileCount = 0 + for _, r in ipairs(mgr:rowsForScreen()) do + if r.profile then profileCount = profileCount + 1 end + end + local wantSteps = profileCount + 1 + local steps = 0 + for _ = 1, 10 do + local row = mgr:focusedRow() + if row and row.exportProfile then break end + U.tap(game, "down") + U.wait(2) + steps = steps + 1 + end + local exportRow = mgr:focusedRow() + check(("EXPORT.. is %d presses below PROFILE 1"):format(wantSteps), + exportRow ~= nil and exportRow.exportProfile == true and steps == wantSteps) + U.tap(game, "a") + U.wait(10) + U.log("footer notice reads:", tostring(mgr.notice)) + check("the footer says SAVED PROFILE 1", mgr.notice == "SAVED PROFILE 1") + + local rel = ModProfile.fileFor("PROFILE 1") + local info = love.filesystem.getInfo and love.filesystem.getInfo(rel) + check(rel .. " exists in the save dir", info ~= nil) + if info then + local body = love.filesystem.read(rel) + local back = ModProfile.decode(body) + check("the exported file decodes back to PROFILE 1", + back ~= nil and back.name == "PROFILE 1") + U.log("wrote", love.filesystem.getSaveDirectory() .. "/" .. rel, + info.size and (info.size .. " bytes") or "") + end + + -- leave the cursor at the top of the list for whoever takes over + for _ = 1, steps do + U.tap(game, "up") + U.wait(2) + end + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.shot(game, SHOT_DIR .. "/bug593_profiles.png") + U.log("captured", SHOT_DIR .. "/bug593_profiles.png") + + U.log("The PROFILES tab is open with the cursor back on PROFILE 1, and the") + U.log("steps above already ran once: a mod was switched off, PROFILE 1 put it") + U.log("back, and EXPORT.. wrote the file. Redo it by hand: LEFT for MODS,") + U.log("SELECT to flip a mod, RIGHT, A on PROFILE 1. The row should carry the") + U.log("! glyph and the footer should flash PROFILE STAGED. The failure to") + U.log("watch for is the cursor landing a row off after a tab switch or a") + U.log("delete, so A on what looks like [AD-HOC] fires EXPORT.. instead.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/oak_dex_rating_bug600_test.lua b/tests/drivers/oak_dex_rating_bug600_test.lua new file mode 100644 index 00000000..90af7c2b --- /dev/null +++ b/tests/drivers/oak_dex_rating_bug600_test.lua @@ -0,0 +1,214 @@ +-- Eye/ear check on Prof. Oak rating the Pokedex in his lab (#600): with the +-- Pokedex in hand and 2+ species owned he must lead with the rating branch, +-- not the line he reads while handing the Pokedex over. pokered +-- scripts/OaksLab.asm OaksLabOak1Text + engine/events/pokedex_rating.asm. +-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so +-- the rating jingle stops landing where the text does. +-- POKEPORT_DRIVER=tests/drivers/oak_dex_rating_bug600_test.lua POKEPORT_IDENTITY=bug600 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Commands = require("src.script.Commands") + local TextBox = require("src.render.TextBox") + local oaksLab = require("data.scripts.oaks_lab") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- pokered data/maps/objects/OaksLab.asm: OAKSLAB_OAK1 stands at (5, 2) + -- facing DOWN behind the table, so the desk talk is from the cell below + -- him; the leftover balls sit at (6, 3) / (7, 3) / (8, 3) with the row + -- below them walkable (the rival walks it in OaksLabRivalTakePokeBall). + local MAP = "OAKS_LAB" + local OAK = "OAKSLAB_OAK1" + local STAND = { x = 5, y = 3, facing = "up" } + local BALL_STAND = { x = 8, y = 4 } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function rowAt(rows, i) + return rows and rows[i] or {} + end + + -- ------------------------------------------------- script rows are there + local oak1 = oaksLab.talk.TEXT_OAKSLAB_OAK1 + local firstRating, ratingLabel, dexRating, firstItemCheck + for i, row in ipairs(oak1 or {}) do + if row[1] == "check_dex_owned" and not firstRating then firstRating = i end + if row[1] == "label" and row[2] == "dex_rating" then ratingLabel = i end + if row[1] == "dex_rating" then dexRating = i end + if row[1] == "check_item" and not firstItemCheck then firstItemCheck = i end + end + check("TEXT_OAKSLAB_OAK1 gates on 2+ owned species", + firstRating ~= nil and rowAt(oak1, firstRating)[2] == 2) + check("it has a dex_rating branch that ends in the rating predef", + ratingLabel ~= nil and dexRating ~= nil and dexRating > ratingLabel) + -- the branch has to be read before the POKe BALL / around-the-world forks + -- or the old wrong line still wins + check("the branch is read before the POKe BALL check", + firstRating ~= nil and firstItemCheck ~= nil + and firstRating < firstItemCheck) + check("Commands has check_dex_owned and dex_rating", + type(Commands.check_dex_owned) == "function" + and type(Commands.dex_rating) == "function") + + -- the leftover-ball beat shares this file (#601 remnant, reported on #600) + local ball = oaksLab.talk.TEXT_OAKSLAB_CHARMANDER_POKE_BALL + local lastMon = rowAt(ball, 21)[2] + check("the leftover ball reads the last-mon line", + type(lastMon) == "string" and lastMon:find("last Pok", 1, true) ~= nil) + check("and stops there instead of falling into the pre-pick line", + rowAt(ball, 22)[1] == "jump" and rowAt(ball, 22)[2] == "end" + and rowAt(ball, 23)[2] == "_OaksLabThoseArePokeBallsText") + check("the pre-pick jump still lands on that line", + rowAt(ball, 4)[1] == "jump_if_false" and rowAt(ball, 4)[2] == 23) + + -- -------------------------------------------------------- text and audio + local t = game.data.text + local COMING = "_OaksLabOak1HowIsYourPokedexComingText" + local WRONG = "_OaksLabOak1PokemonAroundTheWorldText" + check(COMING .. " resolves", type(t[COMING]) == "string" and t[COMING] ~= "") + check("it is the how-is-it-coming line", + type(t[COMING]) == "string" and t[COMING]:find("DEX", 1, true) ~= nil) + check(WRONG .. " resolves too (the near miss to tell it from)", + type(t[WRONG]) == "string" and t[WRONG] ~= "") + check("_DexCompletionText resolves (the seen/owned tally)", + type(t._DexCompletionText) == "string") + local sfx = game.data.audio and game.data.audio.sfx + check("Pokedex_Rating is in the sfx table", + sfx ~= nil and sfx.Pokedex_Rating ~= nil) + + local opts = game.save.options or {} + if (opts.sfxVol or 0) == 0 then + U.log("SFX VOLUME IS 0. The jingle after the rating line is half of this") + U.log("check. Raise SFX VOL in OPTION and rerun.") + else + check("sfx is audible (SFX VOL " .. tostring(opts.sfxVol) .. ")", true) + end + + -- ------------------------------------------------------ save at the beat + game.save.party = { + Pokemon.new(game.data, "CHARMANDER", 8), + Pokemon.new(game.data, "PIDGEY", 6), + } + if not game.save.player.name or game.save.player.name == "" then + game.save.player.name = "RED" + end + local flags = game.save.flags + flags.EVENT_GOT_POKEDEX = true + flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + flags.EVENT_GOT_STARTER = true + flags.EVENT_CHOSE_CHARMANDER = true + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + flags.EVENT_OAK_GOT_PARCEL = true + -- deliberately left unset: with it the rating fires off the converted-save + -- path instead, and the owned>=2 gate this issue is about goes untested + flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS = nil + -- a driver save skips SaveData.new on some paths, so do not assume the dex + local dex = game.save.pokedex or { seen = {}, owned = {} } + game.save.pokedex = dex + dex.owned = { CHARMANDER = true, PIDGEY = true, RATTATA = true } + dex.seen = { CHARMANDER = true, PIDGEY = true, RATTATA = true, + SPEAROW = true, WEEDLE = true } + local owned = 0 + for _ in pairs(dex.owned) do owned = owned + 1 end + check(("Pokedex in hand, %d species owned, no converted-save shortcut") + :format(owned), + flags.EVENT_GOT_POKEDEX == true and owned >= 2 + and flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS == nil) + -- the tier line is picked by decade in OverworldState:dexRating + local tier = ("_DexRatingText_Own%dTo%d") + :format(math.floor(owned / 10) * 10, math.floor(owned / 10) * 10 + 9) + check(tier .. " resolves for that count", type(t[tier]) == "string") + + -- ----------------------------------------------------- park him at Oak's + -- Oak1 is hidden until the parcel beat shows him (data/scripts/story2.lua + -- swaps OAKSLAB_OAK2 for OAKSLAB_OAK1 via Commands.show_object), and that + -- lives in save.objectToggles, not in the event flags seeded above; the + -- rival is long gone by this beat for the same reason + local toggles = game.save.objectToggles or {} + game.save.objectToggles = toggles + toggles[MAP] = toggles[MAP] or {} + toggles[MAP][OAK] = true + toggles[MAP].OAKSLAB_OAK2 = false + toggles[MAP].OAKSLAB_RIVAL = false + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + local function oakNpc(ow) + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.name == OAK then return n end + end + return nil + end + + local function facingOak() + local ow = game.overworld + local o = ow and oakNpc(ow) + if not o then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == o + end + + local ow = game.overworld + local o = ow and oakNpc(ow) + check("Oak is loaded on " .. MAP, o ~= nil) + if o and not facingOak() then + -- a map edit or a mod moved him: {dx, dy, facing} is the offset from Oak + -- to the stand cell plus the direction that looks back at him + for _, s in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } }) do + local cx, cy = o.cellX + s[1], o.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("desk cell (%d,%d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("standing below Oak at the desk", facingOak()) + + -- press A here so the log can tell a missing branch from a press that never + -- reached Oak; on screen those look the same + U.tap(game, "a") + U.wait(8) + local top = game.stack:top() + local isBox = getmetatable(top) == TextBox + check("pressing A opened a text box", isBox) + if isBox then + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + local joined = table.concat(shown, " / ") + U.log("box reads:", joined) + check("Oak opens on the rating branch, not the hand-over line", + joined:find("DEX", 1, true) ~= nil + and joined:find("wait for", 1, true) == nil) + end + U.wait(20) + U.shot(game, DIR .. "/bug600_oak_rating.png") + U.log("captured", DIR .. "/bug600_oak_rating.png") + + U.log("Oak has been talked to; that box on screen is the start of it. He") + U.log("says \"Good to see you! How is your #DEX coming? Here, let me take a") + U.log("look!\" and runs straight on into the seen/owned tally and PROF.OAK's") + U.log("Rating with no press of yours in between, and the jingle sounds only") + U.log("after the rating line has printed, not over it (#576). If he instead") + U.log("says \"#MON around the world wait for you\" or \"Come see me") + U.log("sometimes\", the branch never fired.") + U.log("The pad is yours: A again re-runs it any time. For the other half,") + U.log(("walk to (%d,%d) and press A up at the leftover ball: Oak turns to") + :format(BALL_STAND.x, BALL_STAND.y)) + U.log("face you and says only \"That's PROF.OAK's last Pokemon!\", with no") + U.log("second box about POKe BALLs after it.") + U.log("To see the gate itself, console: game.save.pokedex.owned = { CHARMANDER") + U.log("= true } and talk again -- one species owned is the around-the-world line.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pad_double_input_bug620_test.lua b/tests/drivers/pad_double_input_bug620_test.lua new file mode 100644 index 00000000..eb41e101 --- /dev/null +++ b/tests/drivers/pad_double_input_bug620_test.lua @@ -0,0 +1,126 @@ +-- Hands-on check that a mapped controller sends one press per button (#620). +-- SDL raises love.gamepadpressed AND love.joystickpressed for the same +-- physical button on any pad it has a database entry for; the raw fallback in +-- src/core/Input.lua now stands down for those pads. No pokered behavior is +-- involved, this is platform input plumbing only. Do not set POKEPORT_SPEED: +-- fast-forward reorders the logic clock against real input and audio. +-- POKEPORT_DRIVER=tests/drivers/pad_double_input_bug620_test.lua POKEPORT_IDENTITY=bug620 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + + -- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5 is the player's + -- house door, so the cell below it is open ground with no object on it. + -- Any walkable cell would do, the bag is what is being looked at. + local MAP = "PALLET_TOWN" + local STAND = { x = 5, y = 6, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- A pad nobody plugged in fails exactly like a pad whose events are being + -- dropped, so name the stick before anything else. + local pads, mapped = {}, 0 + if love.joystick then + for _, j in ipairs(love.joystick.getJoysticks()) do + local isPad = j.isGamepad and j:isGamepad() + if isPad then mapped = mapped + 1 end + pads[#pads + 1] = j:getName() .. (isPad and " (SDL-mapped)" or " (raw stick)") + end + end + check("a controller is connected", #pads > 0) + check("SDL maps at least one of them, which is the pad #620 is about", + mapped > 0) + if #pads > 0 then U.log("pads:", table.concat(pads, ", ")) end + + -- The gate itself, driven with a stub that answers isGamepad like a mapped + -- pad does. Cross-file contract with main.lua's love.joystick* forwarders: + -- these three entry points must all ignore a mapped pad, because its press + -- already arrived through gamepadpressed this frame. Raw index 9 is SELECT + -- in RAW_BUTTON_BINDINGS, which is the button iOS's MFi packing slid the + -- D-pad onto. + local stub = { isGamepad = function() return true end } + local input = game.input + input:joystickpressed(stub, 9) + input:joystickhat(stub, 1, "l") + input:joystickaxis(stub, 1, -0.9) + local rawSelect = input.sources.select and input.sources.select["joy:9"] + local rawHat = (input.hatDirs[1] or {})[1] ~= nil + local rawStick = input.stickDir ~= nil + check("a mapped pad's raw button index is ignored", not rawSelect) + check("a mapped pad's raw hat is ignored", not rawHat) + check("a mapped pad's raw axis is ignored", not rawStick) + -- leave nothing held behind if one of those did land (nil = raw stick) + if rawSelect then input:joystickreleased(nil, 9) end + if rawHat then input:joystickhat(nil, 1, "c") end + if rawStick then input:joystickaxis(nil, 1, 0) end + + -- The launcher half runs before Game exists, so this driver can never reach + -- it; confirm its copy of the gate is still in the file at least. + local src = love.filesystem.read("src/import/RomImporter.lua") or "" + check("the launcher keeps its own copy of the gate", + src:find("isMappedPad", 1, true) ~= nil) + + -- The swap beep is the audible half of a wrong press. + local opts = game.save.options or {} + check("sfxVol is not zero (" .. tostring(opts.sfxVol or 7) .. ")", + (opts.sfxVol or 7) ~= 0) + check("renderer is up", game.renderer ~= nil) + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + game.save.inventory = game.save.inventory or {} + game.save.inventory.POTION = 3 + game.save.inventory.ANTIDOTE = 2 + -- one item can be marked for a swap but never traded with anything, which + -- hides half the near-miss + check("the bag holds at least two swappable items", + #Bag.order(game.save) >= 2) + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the door: any free walkable neighbour is as good + local sides = { { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } } + for _, s in ipairs(sides) do + local cx, cy = STAND.x + s[1], STAND.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(10) + break + end + end + end + + local list = Screens.push(game, "BagMenu") + U.wait(20) + -- ListMenu:update checks up/down before SELECT in one elseif chain and the + -- bag sets no pageJump, so left is inert there and a stray SELECT in the + -- same step is what actually fires (src/ui/ListMenu.lua, swap_items.asm). + check("the bag list answers SELECT with a swap", + list ~= nil and list.onSelectKey ~= nil) + check("left and right do nothing in this list", list ~= nil and not list.pageJump) + + U.tap(game, "down") -- park on the second item so a swap has somewhere to go + U.wait(20) + U.shot(game, "bug620_bag.png") + + U.log("The bag is open with the cursor on the second item. Press D-pad left") + U.log("once on the pad: the cursor should stay put and the list should not") + U.log("change at all. If the press also counts as SELECT the item under the") + U.log("cursor gets picked up for a swap and draws differently, and a second") + U.log("left press trades it with its neighbour with a beep. Up and down move") + U.log("normally, B closes the bag. The launcher half of #620 needs a boot") + U.log("with data/generated missing, which no driver can reach.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rebind_joystick_bug632_test.lua b/tests/drivers/rebind_joystick_bug632_test.lua new file mode 100644 index 00000000..8d347e6c --- /dev/null +++ b/tests/drivers/rebind_joystick_bug632_test.lua @@ -0,0 +1,137 @@ +-- Manual check that a rebind on a real controller actually takes (#632). +-- LOVE raises love.joystickpressed for EVERY stick, gamepads included, so +-- Input's fixed raw table used to re-assert the factory A/B/START/SELECT map +-- underneath a CONTROLS rebind: only a plugged-in pad makes that duplicate +-- event happen at all, which is why no test can stand in for a hand here. +-- Rebinding is port-only (gap C2), so there is no pokered file to cite; the +-- map position below comes from pokered data/maps/objects/PalletTown.asm. +-- POKEPORT_DRIVER=tests/drivers/rebind_joystick_bug632_test.lua POKEPORT_IDENTITY=bug632 POKEPORT_TOUCH=0 love . +-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, and a +-- press that lands between two stepped frames is the very thing being judged. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local BindingsMenu = require("src.ui.BindingsMenu") + local Input = require("src.core.Input") + + -- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5 is the player's + -- own front door, so the path cell below it is open ground with nothing to + -- walk into by accident while the pad is being mashed. + local MAP = "PALLET_TOWN" + local STAND = { x = 5, y = 6, facing = "down" } + local ROW_A = 5 -- BindingsMenu's BUTTONS order: up, down, left, right, A + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- A pad that SDL has no database entry for and a pad it recognizes behave + -- differently on purpose, and only the second one is plugged in here. + local sticks = (love.joystick and love.joystick.getJoysticks + and love.joystick.getJoysticks()) or {} + local recognized = nil + for _, js in ipairs(sticks) do + if js.isGamepad and js:isGamepad() then recognized = recognized or js end + U.log("stick:", js:getName(), + (js.isGamepad and js:isGamepad()) and "(SDL gamepad)" or "(raw)") + end + check("a controller is plugged in", #sticks > 0) + check("at least one of them is an SDL-recognized gamepad", recognized ~= nil) + + -- The three halves of the fix that can be read off in-process: the raw + -- lookup table applyBindings now builds, the guard that keeps a recognized + -- pad off the raw path, and BindingsMenu's raw capture hooks. + check("Input:applyBindings built the raw joyBindings table", + type(Input.joyBindings) == "table" and Input.joyBindings[1] == "a") + check("BindingsMenu has the raw-stick capture hooks", + type(BindingsMenu.captureJoy) == "function" + and type(BindingsMenu.captureJoyRelease) == "function") + check("Game routes joystick presses", type(game.joystickpressed) == "function") + + -- Probe the guard with a stand-in gamepad rather than the live pad, so the + -- log says which of the two paths answered without asking for a press yet. + local fakePad = { isGamepad = function() return true end } + Input:reset() + Input:joystickpressed(fakePad, 1) + check("a recognized pad's duplicate joystick press is ignored", + not Input:isDown("a")) + Input:joystickhat(fakePad, 1, "u") + check("and its duplicate hat event is ignored too", not Input:isDown("up")) + -- a nil joystick is how the raw path is reachable without a raw stick on + -- the desk; tests/input_hold_test.lua drives it the same way + Input:joystickpressed(nil, 1) + check("a stick SDL does not recognize still presses A", Input:isDown("a")) + Input:joystickreleased(nil, 1) + Input:reset() + + -- Guide and the stick clicks are in no binding table, which is what makes + -- them dead buttons rather than something that quietly fires START. + check("Guide is unbound", Input.padBindings.guide == nil) + check("the left stick click is unbound", Input.padBindings.leftstick == nil) + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + -- a map edit or a mod could put something on the door path; any free + -- neighbour is just as good, the position only has to be somewhere quiet + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(10) + break + end + end + end + + -- Open the screen the same way the START menu does, one push per level, so + -- backing out with B lands on OPTION and then on the overworld exactly as + -- it would have if the human had walked the menus. + Screens.push(game, "OptionsMenu") + U.wait(10) + Screens.push(game, "BindingsMenu") + U.wait(10) + + local menu = game.stack:top() + local isBindings = getmetatable(menu) == BindingsMenu + check("the CONTROLS screen is open", isBindings) + + if isBindings then + for _ = 1, ROW_A - 1 do + U.tap(game, "down") + U.wait(6) + end + check("the cursor is parked on the A row", + menu.index == ROW_A and menu.items[ROW_A].button.id == "a") + U.log("the A row currently reads", menu.items[ROW_A].right) + check("the B row reads its defaults too", + menu.items[ROW_A + 1] ~= nil + and menu.items[ROW_A + 1].right:find("/", 1, true) ~= nil) + end + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + if U.shot(game, SHOT_DIR .. "/bug632_controls.png") then + check("the window rendered", true) + U.log("captured", SHOT_DIR .. "/bug632_controls.png") + else + check("the window rendered", false) + end + + U.log("CONTROLS is open with the cursor on the A row. Press A to arm it,") + U.log("then press and release the pad's B button: the rows should end up") + U.log("reading Z/B and X/A. Back out with B twice and open START with the") + U.log("pad. The physical B should confirm and the physical A should cancel,") + U.log("one action per press. If A both confirms and cancels in the same") + U.log("frame (a menu that opens and shuts, or text jumping two pages), the") + U.log("raw table is still answering alongside the gamepad map. Guide and a") + U.log("left-stick click should do nothing at all out in the overworld.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rock_tunnel_flash_blink_bug610_test.lua b/tests/drivers/rock_tunnel_flash_blink_bug610_test.lua new file mode 100644 index 00000000..5893d43a --- /dev/null +++ b/tests/drivers/rock_tunnel_flash_blink_bug610_test.lua @@ -0,0 +1,275 @@ +-- Manual check that FLASH in a dark cave costs one short blink and not a long +-- white hang (#610). pokered engine/menus/start_sub_menus.asm .flash clears +-- wMapPalOffset, prints _FlashLightsAreaText over the party menu and only then +-- runs GBPalWhiteOutWithDelay3, so the cave is already lit when the blink +-- starts; ADVANCED rebakes its atlas there (#383), which must not ride the blink. +-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_flash_blink_bug610_test.lua \ +-- POKEPORT_IDENTITY=bug610 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots610 love . +-- No POKEPORT_SPEED: fast-forward desynchronizes audio and logic ordering. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PartyMenu = require("src.ui.PartyMenu") + local PaletteFX = require("src.render.PaletteFX") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots610" + + 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 + + -- wall clock, not os.clock: the bake this bug is about blocks the frame, and + -- CPU time would hide any of it that waits on the GPU + local now = (love and love.timer and love.timer.getTime) + and love.timer.getTime or os.clock + + -- one frame, remembering the longest frame seen since the last reset and + -- what was drawn on it: "the hitch happened under the party menu" is the + -- whole claim of the fix, and it is not visible in a total + local worst, worstWhere = 0, "nothing" + local function label() + local top = game.stack:top() + if top == nil then return "nothing" end + if getmetatable(top) == PartyMenu then return "the party menu" end + if top.pages then return "the FLASH message" end + if top == game.overworld then return "the map" end + if type(top.t) == "number" and type(top.frames) == "number" then + return "the white blink" + end + return "a menu" + end + local function step(n) + for _ = 1, n do + local where = label() + local t0 = now() + coroutine.yield() + local dt = now() - t0 + if dt > worst then worst, worstWhere = dt, where end + end + end + local function resetWorst() worst, worstWhere = 0, "nothing" end + + -- U.tap spends a frame of its own outside step(), and on the last A of the + -- FLASH message that is precisely the frame the work lands on, so press + -- through the tracked step instead of losing it + local function tap(btn) + table.insert(game.input.pressQueue, btn) + step(1) + game.input.state[btn] = false + end + + -- the blink is the transition record's own frame count, not a state class: + -- Transition keeps WhiteFlash local, so recognize it by shape (a counter, a + -- length, no text pages) the way the stack itself sees it + local function isBlink(s) + return s ~= nil and s.pages == nil and type(s.t) == "number" + and type(s.frames) == "number" + end + + game.save.flags.EVENT_GOT_STARTER = true + -- every list-time badge gate in PartyMenu: FLASH is the subject, STRENGTH is + -- the reference blink (it always prints and always blinks), SURF is there so + -- the Route 10 pond can be tried too + game.save.inventory.BOULDERBADGE = true + game.save.inventory.RAINBOWBADGE = true + game.save.inventory.SOULBADGE = true + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.options.colors = "redpp" -- ADVANCED, the mode whose rebake is dear + PaletteFX.setMode("redpp") + + local function giveMon() + local mon = Pokemon.new(game.data, "PIKACHU", 30) + mon.moves = { { id = "FLASH", pp = 5 }, { id = "STRENGTH", pp = 15 }, + { id = "SURF", pp = 15 } } + game.save.party = { mon } + return mon + end + giveMon() + + -- ---- what has to be true before any of this can be judged --------------- + check(PaletteFX.gbcPack() ~= nil, + "the ADVANCED colour pack is installed (without it nothing rebakes " + .. "and this driver proves nothing)") + check(PaletteFX.usesGbcPack(), "and the active mode is the baking one") + + local darkDef = game.data.field.darkMaps + local listed = false + for _, m in ipairs(darkDef and darkDef.maps or {}) do + if m == "ROCK_TUNNEL_1F" then listed = true end + end + check(listed, "ROCK_TUNNEL_1F is in field.darkMaps.maps") + + local flashText = game.data.text._FlashLightsAreaText + check(type(flashText) == "string" and flashText ~= "", + "_FlashLightsAreaText extracted from the ROM") + if type(flashText) == "string" then + U.log("the message reads:", (flashText:gsub("\n", " / "))) + end + + local rec = (game.data.transitions and game.data.transitions.white_flash) + or require("src.render.Transition").STYLES.white_flash + U.log("white_flash is", tostring(rec and rec.frames), "frames") + check(rec and rec.frames and rec.frames <= 12, + "the blink is a handful of frames, so anything longer on screen is " + .. "work riding it") + + local vol = game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the blink and the menu beeps are silent, turn SFX up", + "in OPTION before judging any of this by ear") + else + U.log("sfxVol =", tostring(vol)) + end + + -- ---- park just inside the Route 10 entrance ----------------------------- + -- pokered data/maps/objects/RockTunnel1F.asm: warp_event 15, 3 is the + -- Route 10 mouth, so (15, 4) is walkable floor one cell inside it, with the + -- warp still one step north for the second half of the check. + local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" } + local WARP = { x = 15, y = 3 } + + local function enter(lit) + game.save.flashLit = lit or nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + step(12) + local ow = game.overworld + -- a map edit or a mod can drop a rock on the entrance floor: fall back to + -- any walkable neighbour of the warp that is not the warp itself + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + local sides = { { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } } + for _, s in ipairs(sides) do + local cx, cy = WARP.x + s[1], WARP.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + STAND.x, STAND.y = cx, cy + U.teleport(game, MAP, cx, cy, STAND.facing) + step(12) + ow = game.overworld + break + end + end + end + return ow + end + + local ow = enter(nil) + check(ow ~= nil and ow.dark == true, "the tunnel is dark on arrival") + check(ow.map:warpAtCell(WARP.x, WARP.y) ~= nil, + ("the Route 10 warp is still at (%d, %d), so the walk back out is " + .. "reachable"):format(WARP.x, WARP.y)) + local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey + U.log("atlas key, dark:", tostring(key)) + check(type(key) == "string" and key:find("#dark", 1, true) ~= nil, + "and it is drawing the dark bake, not a shaded lit one") + check(U.shot(game, DIR .. "/bug610_1_dark.png"), + "a frame reached disk, so there is a window to watch") + + -- ---- run the moment once, watching where the cost lands ----------------- + -- START -> POKeMON -> the mon -> FLASH, looked up by row rather than counted + -- so a mod that inserts a row cannot silently pick something else + tap("start") + step(12) + local menu = game.stack:top() + local row + for i, it in ipairs(menu and menu.items or {}) do + if tostring(it.label):upper():find("MON", 1, true) then row = i break end + end + check(row ~= nil, "the START menu lists POKeMON") + for _ = 2, (row or 1) do tap("down"); step(2) end + tap("a") + step(12) + local pm = game.stack:top() + check(getmetatable(pm) == PartyMenu, "POKeMON opens the party menu") + tap("a") + step(8) + local sub + for i, it in ipairs((pm and pm.subItems) or {}) do + if it.action == "flash" then sub = i break end + end + check(sub ~= nil, "FLASH is listed for this PIKACHU on a dark floor") + for _ = 2, (sub or 1) do tap("down"); step(2) end + tap("a") + step(6) + check(game.stack:top() ~= nil and game.stack:top().pages ~= nil, + "FLASH puts its message up") + check(ow.dark == true, "the tunnel is still dark while that message types") + + -- drain the text, then watch every frame from the last A to the map coming + -- back: this is the window the bug filled with a white hang + resetWorst() + local blinkStart, blinkEnd, litAtBlink, keyAtBlink + for _ = 1, 400 do + local top = game.stack:top() + if top and top.pages then + tap("a") + step(3) + elseif isBlink(top) then + if not blinkStart then + blinkStart = now() + litAtBlink = (ow.dark == false) + keyAtBlink = ow.map.renderer and ow.map.renderer.gbcAtlasKey + end + step(1) + else + if blinkStart and not blinkEnd then blinkEnd = now() end + break + end + end + step(20) + + check(blinkStart ~= nil, "the blink ran") + -- the fix itself, stated as an invariant: OverworldState:setDark drops every + -- resident map and rebakes this one, and that has to be finished before the + -- first white frame is drawn (start_sub_menus.asm .flash order) + check(litAtBlink == true, + "the cave was already lit on the blink's first frame, not lit by it") + check(type(keyAtBlink) == "string" + and keyAtBlink:find("#dark", 1, true) == nil, + "and the lit atlas was already baked and bound by then") + if blinkStart and blinkEnd then + U.log(("the white was on screen for %.2f s"):format(blinkEnd - blinkStart)) + end + U.log(("the longest single frame in the whole sequence was %.2f s, drawn " + .. "over %s"):format(worst, worstWhere)) + check(worstWhere ~= "the white blink", + "the expensive frame was not one of the white ones") + check(ow.dark == false, "the tunnel is lit now") + U.shot(game, DIR .. "/bug610_2_lit.png") + + -- ---- and it stays lit across the Route 10 round trip -------------------- + -- pokered data/maps/objects/Route10.asm: warp_event 8, 17 is the north + -- tunnel mouth, so (8, 18) is the grass one step below it + U.teleport(game, "ROUTE_10", 8, 18, "up") + step(12) + ow = enter(true) + check(ow.dark == false, "coming back in from Route 10 the cave is still lit") + key = ow.map.renderer and ow.map.renderer.gbcAtlasKey + check(type(key) == "string" and key:find("#dark", 1, true) == nil, + "on the lit bake, with no second rebake to blink through") + U.shot(game, DIR .. "/bug610_3_relit.png") + + U.log(fails == 0 and "all #610 machine checks passed" + or (fails .. " #610 machine check(s) FAILED, read up")) + U.log("shots in", DIR) + + -- ---- hand the pad over, dark, FLASH unused ------------------------------ + giveMon() + enter(nil) + + U.log("You are back in the dark tunnel in ADVANCED colours with FLASH unused:") + U.log("START, POKeMON, A, FLASH. The message should end over the party list, any") + U.log("hitch should happen with that list still on screen, and then a blink about") + U.log("as short as STRENGTH's on the same menu hands back a lit cave. A white that") + U.log("sits noticeably longer than the STRENGTH one means the rebake is riding the") + U.log("blink again. Then walk up to the ladder, out to Route 10 and back in: lit,") + U.log("no second blink.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/route1_seam_pacing_bug487_test.lua b/tests/drivers/route1_seam_pacing_bug487_test.lua new file mode 100644 index 00000000..981b0e15 --- /dev/null +++ b/tests/drivers/route1_seam_pacing_bug487_test.lua @@ -0,0 +1,128 @@ +-- Hands-on check for #487: walk pacing after a map-connection seam. +-- Parks the player in Viridian City's south exit column (pokered +-- data/maps/objects/ViridianCity.asm, border block $f, south connection to +-- Route 1) so DOWN alone crosses into ROUTE_1, then gives input back. +-- POKEPORT_DRIVER=tests/drivers/route1_seam_pacing_bug487_test.lua POKEPORT_IDENTITY=bug487 POKEPORT_TOUCH=0 love . +-- Never add POKEPORT_SPEED: fast-forward scales the logic clock only, so it +-- reorders audio against logic and hides the very wobble being judged. +return function(game) + local U = dofile("tests/drivers/util.lua") + local FrameCap = require("src.core.FrameCap") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Viridian City is 20x18 blocks == 40x36 cells; the exit path south runs + -- down column cellX 20 to the bottom row cellY 35, where crossConnection + -- (src/world/OverworldController.lua) swaps in ROUTE_1 and calls + -- FixedStep:discardCatchup -- the only caller, which is why #487 showed up + -- at route/city seams and nowhere else. Warps take the Transition path + -- instead, so a Viridian door is the control case. + local MAP, EXIT_X, START_Y = "VIRIDIAN_CITY", 20, 31 + + local maps = game.data.maps + local vc, r1 = maps and maps[MAP], maps and maps.ROUTE_1 + check("VIRIDIAN_CITY and ROUTE_1 are both in data/generated/maps.lua", + vc ~= nil and r1 ~= nil) + check("Viridian's south connection still points at Route 1", + vc ~= nil and vc.connections and vc.connections.south + and vc.connections.south.map == "ROUTE_1") + + -- the door the human re-checks afterwards: ViridianCity.asm warp_event + -- 21, 15 -> VIRIDIAN_SCHOOL_HOUSE + local doorOk = false + for _, w in ipairs((vc and vc.warps) or {}) do + if w.x == 21 and w.y == 15 then doorOk = true end + end + check("the school house door at (21, 15) is still a warp", doorOk) + + -- Judging pacing needs real frame times, and main.lua's driver branch feeds + -- Game:update a pinned 1/60 so the accumulator never sees display jitter. + -- Under POKEPORT_DRIVER this is expected to read FAIL: the position below + -- is still useful, the verdict has to come from a plain run. + check("the running build feeds FixedStep real frame times", + os.getenv("POKEPORT_DRIVER") == nil) + check("no fast-forward multiplier is set", + (tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1 + and (game.save.options.speed or 1) == 1) + check("a window is up to watch (this is a visual call)", + love.window ~= nil and love.window.isOpen and love.window.isOpen()) + U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap), + "and vsync is", + (love.window.getVSync and tostring(love.window.getVSync())) or "unknown") + + -- Probe copy of the timing module so the reseed can be asserted without + -- disturbing the live loop this driver is running inside. discardCatchup + -- still zeroes accum on the way out; the fix is that the frame absorbing + -- the hitch hands it back mid-step instead of parked on a step boundary. + local probe = loadfile("src/core/FixedStep.lua")() + probe:init(function() end) + probe:discardCatchup() + probe:update(0.25) + check("an absorbed hitch frame leaves the accumulator mid-step", + probe.accum > probe.STEP * 0.25 and probe.accum < probe.STEP * 0.75) + + -- a party and the starter flag so the overworld behaves like a real save + game.save.flags.EVENT_GOT_STARTER = true + if #game.save.party == 0 then + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + end + + U.teleport(game, MAP, EXIT_X, START_Y, "down") + local ow = game.overworld + + -- a map edit or a mod could block column 20; walk down whichever nearby + -- column is clear all the way to the bottom row instead + local function columnIsClear(x) + for y = START_Y, 35 do + if not ow.map:isWalkableCell(x, y) then return false end + end + return true + end + local col = EXIT_X + if not columnIsClear(col) then + for _, dx in ipairs({ -1, 1, -2, 2, -3, 3 }) do + if columnIsClear(EXIT_X + dx) then col = EXIT_X + dx break end + end + U.log(("column %d is blocked, using column %d"):format(EXIT_X, col)) + U.teleport(game, MAP, col, START_Y, "down") + ow = game.overworld + end + check("the exit column is walkable from here to the south edge", + columnIsClear(col)) + + -- cross it once so the seam is known reachable from this cell, then come + -- back and leave the player a few steps short of it + local crossed = false + for _ = 1, 200 do + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + coroutine.yield() + if ow.map.id == "ROUTE_1" and ow.player.cellY >= 0 then crossed = true break end + end + game.input.state.down = false + U.wait(5) + check("holding DOWN from that cell reaches Route 1", crossed) + + U.teleport(game, MAP, col, START_Y, "down") + U.wait(10) + + U.log(("Standing in Viridian City at cell (%d, %d), four steps above the"):format(col, START_Y)) + U.log("Route 1 seam. Hold DOWN and keep holding it for about ten seconds") + U.log("once you are on Route 1: past the single hitch on the crossing frame") + U.log("the scroll should stay dead even, a pixel a frame, the same as the") + U.log("walk through the city. Broken, the sprite stalls for a frame then") + U.log("jumps two pixels, again and again, and never settles down. The easy") + U.log("misread is grading the crossing frame itself: that one hitch is") + U.log("expected and this change does not touch it, so watch the seconds") + U.log("after. Then step into the school house door at (21, 15) to confirm") + U.log("warps look the way they always did, and run the walk once more with") + U.log("MAX FPS set to 30 in Options, where the wobble also showed up.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/safari_zone_bug540_test.lua b/tests/drivers/safari_zone_bug540_test.lua new file mode 100644 index 00000000..b84869a7 --- /dev/null +++ b/tests/drivers/safari_zone_bug540_test.lua @@ -0,0 +1,248 @@ +-- Driver: the Safari Zone entrance walk and the safari battle HUD (#540). +-- Paying runs SafariZoneEntranceAutoWalk (pokered scripts/SafariZoneGate.asm), +-- so the two gate steps are charged and the counter reads 500/500; leaving +-- early is a queued script, not a box drawn over a black transition; and the +-- ball count belongs in the battle menu (engine/battle/core.asm:2074-2079). +-- No POKEPORT_SPEED: fast-forward desyncs the audio clock from the walk. +-- POKEPORT_DRIVER=tests/drivers/safari_zone_bug540_test.lua POKEPORT_IDENTITY=bug540 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Commands = require("src.script.Commands") + local BattleState = require("src.battle.BattleState") + + -- pokered data/maps/objects/SafariZoneGate.asm: warps 3,0 / 4,0 lead to + -- SAFARI_ZONE_CENTER 1 / 2, and scripts/SafariZoneGate.asm's + -- .PlayerNextToSafariZoneWorker1CoordsArray is (3,2)/(4,2), so walking up + -- the right-hand column from the south warp crosses the join trigger and + -- lands on the warp the auto-walk takes. + local GATE = "SAFARI_ZONE_GATE" + local CENTER = "SAFARI_ZONE_CENTER" + local START = { x = 4, y = 4, facing = "up" } + local TRIGGER = { x = 4, y = 2 } + 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 + + -- ---- machine-checkable preconditions ------------------------------------ + -- a renamed text key, a moved warp or a missing encounter table all end as + -- "nothing happened", which is indistinguishable from the bug on screen + local t = game.data.text + local KEYS = { + "_SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText", + "_SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText", + "_SafariZoneGateSafariZoneWorker1GoodLuckText", + "_SafariZoneGateSafariZoneWorker1LeavingEarlyText", + "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText", + "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText", + } + local missingText = {} + for _, k in ipairs(KEYS) do + if type(t[k]) ~= "string" or t[k] == "" then missingText[#missingText + 1] = k end + end + check("every gate text key resolves to a string", #missingText == 0) + if #missingText > 0 then U.log(" missing:", table.concat(missingText, ", ")) end + + -- the leaving-early script is queued rather than pushed, so every opcode it + -- uses has to exist or the queue runs off the end in silence + local OPS = { "ask", "jump_if_false", "show_text", "set_field", + "move_player", "jump", "label", "warp" } + local missingOps = {} + for _, op in ipairs(OPS) do + if type(Commands[op]) ~= "function" then missingOps[#missingOps + 1] = op end + end + check("the queued leaving-early script's commands all exist", #missingOps == 0) + if #missingOps > 0 then U.log(" missing:", table.concat(missingOps, ", ")) end + + local gateDef = game.data.maps[GATE] + local centerDef = game.data.maps[CENTER] + local northWarp + for _, w in ipairs(gateDef and gateDef.warps or {}) do + if w.x == TRIGGER.x and w.y == 0 then northWarp = w end + end + check(GATE .. " has the north warp at (4, 0) into " .. CENTER, + northWarp ~= nil and northWarp.destMap == CENTER) + local dest = centerDef and centerDef.warps and centerDef.warps[2] + check(CENTER .. " warp 2 is the arrival cell", + dest ~= nil and dest.x == 15 and dest.y == 25) + if dest then U.log(" arrival cell:", dest.x, dest.y) end + + local enc = game.data.encounters and game.data.encounters[CENTER] + local slots = enc and enc.grass and enc.grass.slots + check(CENTER .. " has a grass encounter table", slots ~= nil and #slots > 0) + + -- the auto-walk out of the gate ends on a warp, so the door sfx is part of + -- what the moment is judged on + local sfx = game.data.audio and game.data.audio.sfx + local vol = game.save.options and game.save.options.sfxVol + check("Go_Outside sfx is loaded", sfx ~= nil and sfx.Go_Outside ~= nil) + check(("sfx volume is %s, the door sound is audible"):format(tostring(vol)), + love.audio ~= nil and (vol or 0) > 0) + if (vol or 0) == 0 then + U.log(" raise SFX VOL in OPTION or the walk out will be silent") + end + U.log("BATTLE LAYOUT is currently", + (game.save.options and game.save.options.battleLayout) == "wide" + and "WIDE" or "OG") + + -- ---- entry: pay, then watch him walk himself in -------------------------- + -- FAST text: the join spiel plus the payment text run five pages, and at + -- the MEDIUM default (TextBox drawChars reads save.options.textSpeed) the + -- mash loop below runs out of taps before the auto-walk is ever queued + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.money = 3000 -- the ¥500 fee, plus change for a few more runs + if #game.save.party == 0 then + game.save.party = { + Pokemon.new(game.data, "NIDORINO", 25), + Pokemon.new(game.data, "PIDGEY", 14), + Pokemon.new(game.data, "SANDSHREW", 18), + } + U.log("party was empty; added three mons so the pokeball row has content") + end + game.save.safari = nil + + U.teleport(game, GATE, START.x, START.y, START.facing) + U.wait(15) + local ow = game.overworld + check("the start menu box is gated off in the gate itself", + ow ~= nil and ow.inSafariStepZone ~= nil and not ow:inSafariStepZone()) + + -- a map edit that blocks the right-hand column would stop the trigger ever + -- firing; fall back to the other trigger cell's column + if ow and ow.map and not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y) then + U.log(("(%d, %d) is not walkable; using the left trigger column instead") + :format(TRIGGER.x, TRIGGER.y)) + U.teleport(game, GATE, 3, START.y, START.facing) + U.wait(10) + ow = game.overworld + end + + U.log("He is about to pay and walk in on his own; the screen is yours after.") + for _ = 1, 12 do + U.hold(game, "up", 8) + if game.stack:top() ~= game.overworld then break end + end + check("stepping up in front of the worker opened the join prompt", + game.stack:top() ~= game.overworld) + + -- mash A through the join text, the YES/NO box (YES is the default index) + -- and the payment text; the auto-walk starts on its own once that closes + for _ = 1, 150 do + U.tap(game, "a") + U.wait(5) + local cur = game.overworld + if cur and cur.map and cur.map.id == CENTER then break end + end + U.wait(45) + + ow = game.overworld + local st = game.save.safari + check("the safari game started", st ~= nil) + check("he warped through on his own and is in " .. CENTER, + ow ~= nil and ow.map and ow.map.id == CENTER) + if ow and ow.map and ow.map.id == CENTER then + check(("he arrived at the warp cell (%d, %d)") + :format(ow.player.cellX, ow.player.cellY), + ow.player.cellX == 15 and ow.player.cellY == 25) + end + if st then + check(("the counter reads %d/500 with %d balls") + :format(st.steps or -1, st.balls or -1), + st.steps == 500 and st.balls == 30) + end + check("the start menu box is live now that he is inside", + ow ~= nil and ow.inSafariStepZone and ow:inSafariStepZone()) + + U.tap(game, "start") + U.wait(20) + if U.shot(game, SHOT_DIR .. "/bug540_start_menu.png") then + U.log("captured", SHOT_DIR .. "/bug540_start_menu.png") + end + U.tap(game, "b") + U.wait(15) + + -- ---- the safari battle HUD ---------------------------------------------- + local function firstGrassCell(map) + for y = 0, (map.heightCells or 0) - 1 do + for x = 0, (map.widthCells or 0) - 1 do + if map:isGrassCell(x, y) and map:isWalkableCell(x, y) then return x, y end + end + end + end + + ow = game.overworld + if ow and ow.map and not ow.map:isGrassCell(ow.player.cellX, ow.player.cellY) then + local gx, gy = firstGrassCell(ow.map) + if gx then + U.log("stepping over to the grass at", gx, gy) + U.teleport(game, CENTER, gx, gy, "up") + U.wait(10) + else + U.log("FAIL no walkable grass cell found on " .. CENTER) + end + end + + local function liveBattle() + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == BattleState then return s end + end + return nil + end + + local DIRS = { "up", "down", "left", "right" } + local battle + for i = 1, 400 do + U.hold(game, DIRS[(i - 1) % #DIRS + 1], 10) + battle = liveBattle() + if battle then break end + end + check("a safari battle started", battle ~= nil and battle.safari ~= nil) + if battle and battle.enemy and battle.enemy.mon then + U.log(" encounter:", battle.enemy.mon.species, "at level", + battle.enemy.mon.level) + end + if not battle then + U.log("no encounter after 400 steps -- walk into the grass yourself") + end + + -- catch the "Wild X appeared!" beat first, then clear it: the count is + -- judged in both, absent over the field and present in the menu + U.wait(60) + if U.shot(game, SHOT_DIR .. "/bug540_battle_intro.png") then + U.log("captured", SHOT_DIR .. "/bug540_battle_intro.png") + end + -- the cry and the slide run on their own clocks, so press until the phase + -- flips rather than guessing a frame count + for _ = 1, 60 do + if battle and battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(10) + end + check("the BALL/BAIT/THROW ROCK/RUN menu is up", + battle ~= nil and battle.phase == "menu") + U.wait(15) + if U.shot(game, SHOT_DIR .. "/bug540_battle_menu.png") then + U.log("captured", SHOT_DIR .. "/bug540_battle_menu.png") + end + + -- ---- hand off ------------------------------------------------------------ + U.log("Controls are yours. The count belongs inside the menu, reading") + U.log("\"BALLx 30\" on one line with the cursor to its left, and nothing") + U.log("floating over the field above the party pokeball row. Watch for the") + U.log("30 landing a cell left and colliding with the x, or a cell right and") + U.log("leaving a gap before BAIT; toggle BATTLE LAYOUT in OPTION and check") + U.log("WIDE too, where the old 15x4 panel used to sit at tile (23,7).") + U.log("Then RUN, walk back down onto the south warp: \"Leaving early?\"") + U.log("should sit over the drawn gate interior, worker and counter and") + U.log("shelves visible, not a black screen. Answer NO to bounce back in and") + U.log("do it again, or YES for the return-balls text and a walk down to") + U.log("(4,3) below the worker, rather than being left on the warp at (4,0).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/school_blackboard_bug591_test.lua b/tests/drivers/school_blackboard_bug591_test.lua new file mode 100644 index 00000000..d197d7cc --- /dev/null +++ b/tests/drivers/school_blackboard_bug591_test.lua @@ -0,0 +1,180 @@ +-- Manual check on the Viridian School blackboard headings layout (#591). +-- pokered engine/events/hidden_events/school_blackboard.asm .blackboardLoop draws +-- a 12x8 box and places StatusAilmentText1 at hlcoord 1,2 and StatusAilmentText2 at +-- hlcoord 6,2, with ViridianSchoolBlackboardText2 (data/text/text_2.asm, `done`) +-- still on screen under it. Logic half: tests/parity_viridian_school_blackboard_bug503.lua. +-- POKEPORT_DRIVER=tests/drivers/school_blackboard_bug591_test.lua POKEPORT_IDENTITY=bug591 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio/logic ordering) +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + -- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController + -- requires it lazily on the first map load, so the flavor registrations are + -- not in place yet when the driver starts + local MapScripts = require("data.scripts.init") + + local MAP = "VIRIDIAN_SCHOOL_HOUSE" + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- pokered data/events/hidden_events.asm:164, hidden_events_for + -- VIRIDIAN_SCHOOL_HOUSE: hidden_text_predef 3, 0 is the blackboard on the + -- north wall. That cell is the wall itself, so the stand cell is the + -- walkable one below it, looking up (data/maps/objects/ViridianSchoolHouse.asm + -- puts the two warps at (2,7)/(3,7) and no object in the way). + local BOARD = { x = 3, y = 0 } + local STAND = { x = 3, y = 1, facing = "up" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local hooks = MapScripts.get(MAP) + check(MAP .. " registers an onInteract hook", + hooks ~= nil and type(hooks.onInteract) == "function") + -- a renamed extractor label reads on screen as "A did nothing", which is the + -- #503 failure and not the layout question this driver is about + for _, key in ipairs({ "_ViridianSchoolBlackboardText1", + "_ViridianSchoolBlackboardText2", "_ViridianBlackboardSleepText", + "_ViridianBlackboardPoisonText", "_ViridianBlackboardPrlzText", + "_ViridianBlackboardBurnText", "_ViridianBlackboardFrozenText" }) do + local s = game.data.text[key] + check(key .. " resolves to a string", type(s) == "string" and s ~= "") + end + + local opts = game.save.options or {} + U.log("audio device present:", love.audio ~= nil, + " SFX VOL (0-7):", tostring(opts.sfxVol)) + if not love.audio or opts.sfxVol == 0 then + U.log("WARNING: sfx output is off, so the Press_AB beep on every heading pick", + "will be silent; raise SFX VOL in OPTION first") + end + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil) + check("the blackboard cell is solid, as the wall it hangs on", + ow ~= nil and not ow.map:isWalkableCell(BOARD.x, BOARD.y)) + + -- a map edit or a mod could move the board: any walkable neighbour will do, + -- {dx, dy, facing} is the offset from the board plus the look back at it + local SIDES = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + local function facingBoard() + local p = game.overworld.player + local fx, fy = p:facingCell() + return fx == BOARD.x and fy == BOARD.y + end + if not facingBoard() then + for _, s in ipairs(SIDES) do + local cx, cy = BOARD.x + s[1], BOARD.y + s[2] + if game.overworld.map:isWalkableCell(cx, cy) + and not game.overworld:npcAtCell(cx, cy) then + U.log(("(%d,%d) was not faced from the stand cell, standing on") + :format(BOARD.x, BOARD.y), cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("standing where the blackboard is faced", facingBoard()) + + local function boxText(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " / ") + end + + -- the headings list is the flavor script's own StatusBoard, not a TextBox and + -- not src/ui/Menu.lua (which has no second column), so identify it by .labels + local function board() + local top = game.stack:top() + if top and top.labels and top.selection then return top end + return nil + end + local function reachBoard() + for _ = 1, 60 do + if board() then return true end + U.tap(game, "a") + U.wait(8) + end + return false + end + + U.tap(game, "a") + U.wait(30) + local top = game.stack:top() + check("A on the blackboard opened a text box", getmetatable(top) == TextBox) + if getmetatable(top) == TextBox then + U.log("intro box reads:", boxText(top)) + check("it is _ViridianSchoolBlackboardText1", + boxText(top):find("blackboard", 1, true) ~= nil) + end + + check("the intro clears into the headings list", reachBoard()) + local b = board() + if b then + local labels = {} + for i, label in ipairs(b.labels) do labels[i] = (label:gsub("^%s+", "")) end + U.log("list rows:", table.concat(labels, " / ")) + check("SLP/PSN/PAR then BRN/FRZ/QUIT, in ViridianBlackboardStatusPointers order", + table.concat(labels, "/") == "SLP/PSN/PAR/BRN/FRZ/QUIT") + -- the whole point of #591: the prompt box is held open underneath rather + -- than dismissed before the list goes up + local under = game.stack.states[#game.stack.states - 1] + check("the prompt box is still on the stack under the list", + getmetatable(under) == TextBox and under.stay ~= nil) + if getmetatable(under) == TextBox then + U.log("prompt under the list reads:", boxText(under)) + end + U.shot(game, SHOT_DIR .. "/bug591_blackboard_list.png") + end + + if b then + U.tap(game, "right") + U.wait(10) + check("RIGHT keeps the row and lands on BRN (wMenuItemOffset 3)", + b:selection() == 4) + U.shot(game, SHOT_DIR .. "/bug591_right_column.png") + U.tap(game, "down") + U.wait(10) + check("DOWN inside the right column reaches FRZ", b:selection() == 5) + U.shot(game, SHOT_DIR .. "/bug591_right_down.png") + + U.tap(game, "a") + U.wait(30) + top = game.stack:top() + check("A on FRZ prints its blurb", getmetatable(top) == TextBox) + if getmetatable(top) == TextBox then + U.log("FRZ blurb reads:", boxText(top)) + check("it is _ViridianBlackboardFrozenText, the entry RIGHT+DOWN points at", + boxText(top):find("frozen", 1, true) ~= nil) + end + U.shot(game, SHOT_DIR .. "/bug591_frz_blurb.png") + + check("the blurb loops back to the list (jp .blackboardLoop)", reachBoard()) + -- wCurrentMenuItem / wMenuItemOffset are never cleared inside the loop, so + -- the cursor comes back where it was left + check("the cursor comes back on FRZ, not reset to SLP", + board() ~= nil and board():selection() == 5) + U.shot(game, SHOT_DIR .. "/bug591_back_to_list.png") + U.log("screenshots in", SHOT_DIR) + end + + U.log("The list on screen is the moment: a 12x8 box in the top-left with two") + U.log("columns, SLP/PSN/PAR and BRN/FRZ/QUIT on the same three rows, and the") + U.log("\"Which heading do you want to read?\" box still up at the bottom with no") + U.log("blinking arrow in it. The near-miss to watch for is the prompt vanishing") + U.log("(or blinking) once the list appears, and a cursor sitting on the S of SLP") + U.log("instead of the blank in front of it. RIGHT/LEFT jump columns keeping the") + U.log("row, UP/DOWN stop at the ends without wrapping, and QUIT or B should drop") + U.log("both boxes and let you walk again with no stale dialogue left behind.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/seafoam_holes_bug599_test.lua b/tests/drivers/seafoam_holes_bug599_test.lua new file mode 100644 index 00000000..b931f45d --- /dev/null +++ b/tests/drivers/seafoam_holes_bug599_test.lua @@ -0,0 +1,180 @@ +-- Eye/ear check that the Seafoam Islands floor holes drop the PLAYER, not just +-- the boulders (#599). Each floor's script sets wDungeonWarpDestinationMap and +-- calls IsPlayerOnDungeonWarp over its SeafoamNHolesCoords list (pokered +-- scripts/SeafoamIslands1F.asm, B2F.asm); the landing cells come from +-- data/maps/special_warps.asm DungeonWarpData. The data half is asserted in +-- tests/parity_seafoam_holes.lua. +-- POKEPORT_DRIVER=tests/drivers/seafoam_holes_bug599_test.lua POKEPORT_IDENTITY=bug599 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own +-- real-time accumulator in Game:update, so it desyncs the ordering being judged. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Sound = require("src.core.Sound") + local Pokemon = require("src.pokemon.Pokemon") + local mapScripts = require("data.scripts.init") + + -- Hole cells from pokered scripts/SeafoamIslands1F.asm Seafoam1HolesCoords + -- (17,6)/(24,6) and scripts/SeafoamIslandsB2F.asm Seafoam3HolesCoords + -- (19,6)/(22,6); the landings are the DungeonWarpData rows those floors + -- index. data/generated/maps.lua agrees: those four cells carry CAVERN $22 + -- and are walkable, which is why the fall is an onStep and not a collision. + -- The B2F pair is here because its landing on B3F is water: the arrival has + -- to come up already surfing, not standing on the sea. + local FALLS = { + { from = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6, + to = "SEAFOAM_ISLANDS_B1F", lx = 18, ly = 7, water = false, + shot = DIR .. "/bug599_1f_to_b1f.png" }, + { from = "SEAFOAM_ISLANDS_B2F", hx = 19, hy = 6, + to = "SEAFOAM_ISLANDS_B3F", lx = 18, ly = 7, water = true, + shot = DIR .. "/bug599_b2f_to_b3f.png" }, + } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the eye and the ear cannot check ------------------------------ + -- Silence is half the claim (a hole is not a door), and at SFX 0 a dead + -- audio path and a fixed bug sound exactly alike. + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL SFX volume is 0, so the door chime this fall must NOT play") + U.log(" could not play anyway. Set SFX to 7 in OPTION and rerun.") + end + check(("SFX volume %d"):format(sfxVol), sfxVol > 0) + + for _, f in ipairs(FALLS) do + local hooks = mapScripts.get(f.from) + check(f.from .. " has a hole onStep hook", hooks ~= nil and hooks.onStep ~= nil) + end + + -- a party so a wild encounter on the B3F water is a battle to back out of + -- rather than a blackout that throws the hand-off away + if not game.save.party or #game.save.party == 0 then + game.save.party = { Pokemon.new(game.data, "LAPRAS", 40), + Pokemon.new(game.data, "SNORLAX", 45) } + end + game.save.moveFlags = game.save.moveFlags or {} + + -- Every SFX cue raised during a fall. Go_Inside / Go_Outside is the door + -- chime WarpFound2 .indoorMaps skips for a warp pad or hole, and an ear can + -- miss it under a fade if the fade happens to be busy. + local cues, watching = {}, false + local realPlay = Sound.play + Sound.play = function(data, key, ...) + if watching then cues[#cues + 1] = tostring(key) end + return realPlay(data, key, ...) + end + + -- Stand one cell south of the hole and walk north into it. If a map edit + -- moved the floor out from under that cell, fall back to any walkable + -- neighbour that is not itself a hole, and walk from there instead. + local SIDES = { { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } } + + local function standFor(f) + local map = game.overworld and game.overworld.map + local sameMap = map and map.id == f.from + for _, s in ipairs(SIDES) do + local cx, cy = f.hx + s[1], f.hy + s[2] + local free = not sameMap + if sameMap then + free = map:isWalkableCell(cx, cy) + and (not map.warpPadOrHoleAt or map:warpPadOrHoleAt(cx, cy) ~= "hole") + end + if free then return cx, cy, s[3] end + end + return f.hx, f.hy + 1, "up" + end + + local function runFall(f) + -- teleport onto the floor first so the map is loaded and standFor can read + -- its collision, then park on the approach cell that map actually offers + U.teleport(game, f.from, f.hx, f.hy + 1, "up") + U.wait(10) + local sx, sy, dir = standFor(f) + if sx ~= f.hx or sy ~= f.hy + 1 then + U.log(("approach cell (%d, %d) is blocked, walking in from") + :format(f.hx, f.hy + 1), sx, sy, "facing", dir) + U.teleport(game, f.from, sx, sy, dir) + U.wait(10) + end + local ow = game.overworld + check(("standing on %s (%d, %d) facing %s"):format(f.from, sx, sy, dir), + ow ~= nil and ow.map.id == f.from + and ow.player.cellX == sx and ow.player.cellY == sy) + check(("the hole at (%d, %d) is a walkable CAVERN tile, not a warp event") + :format(f.hx, f.hy), + ow ~= nil and ow.map:isWalkableCell(f.hx, f.hy) + and ow.map:warpPadOrHoleAt(f.hx, f.hy) == "hole") + + cues, watching = {}, true + U.hold(game, dir, 18) + + -- snapshot the very first frame the destination map is live: on B3F the + -- current starts dragging the player the moment the step completes, so a + -- later read would report where the water took them, not where they landed + local landed + for _ = 1, 300 do + local o = game.overworld + if o and o.map.id == f.to and not landed then + landed = { x = o.player.cellX, y = o.player.cellY, + facing = o.player.facing, surfing = o.player.surfing } + end + if landed and not o.transitioning then break end + U.wait(1) + end + U.wait(20) + watching = false + + check("walking into the hole left " .. f.from, landed ~= nil) + if not landed then + U.log(" the player is still on", game.overworld and game.overworld.map.id) + return + end + check(("landed on %s at (%d, %d), wanted (%d, %d)") + :format(f.to, landed.x, landed.y, f.lx, f.ly), + landed.x == f.lx and landed.y == f.ly) + check("facing carried through the fall (" .. tostring(landed.facing) .. ")", + landed.facing == dir) + if f.water then + -- the landing is sea, so setMap's CheckForceBikeOrSurf pass + -- (OverworldState:checkForcedMovement) has to mount SURF on arrival + check("arrived already surfing on the B3F water", landed.surfing == true) + end + local doors = {} + for _, c in ipairs(cues) do + if c == "Go_Inside" or c == "Go_Outside" then doors[#doors + 1] = c end + end + check("no door chime over the fall (heard: " .. + (#cues > 0 and table.concat(cues, ", ") or "nothing") .. ")", + #doors == 0) + U.shot(game, f.shot) + U.log("captured", f.shot) + end + + for _, f in ipairs(FALLS) do runFall(f) end + + Sound.play = realPlay + + -- hand back to real input on the 1F approach cell, one step short of the hole + U.teleport(game, FALLS[1].from, FALLS[1].hx, FALLS[1].hy + 1, "up") + U.wait(10) + + U.log("Both falls have already run; press up from where you are standing to") + U.log("do the 1F one again. It should be a plain fade to black and back with") + U.log("the player on B1F at (18,7) still facing up, no door chime, and no") + U.log("step out of a doorway on the far side. The near miss to watch for is") + U.log("landing one cell off, on the boulder's own spot from field.seafoam") + U.log("landsAt, which strands you inside the rock; and dropping onto the B3F") + U.log("sea standing up instead of surfing, which shows as the player on top") + U.log("of the water until the next step. The other three holes are 1F (24,6),") + U.log("B1F (18,6)/(23,6) and B2F (22,6) if you want to walk the cascade down.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/touch_layout_bug633_test.lua b/tests/drivers/touch_layout_bug633_test.lua new file mode 100644 index 00000000..3c074030 --- /dev/null +++ b/tests/drivers/touch_layout_bug633_test.lua @@ -0,0 +1,197 @@ +-- Manual check of the per-orientation touch layouts and button resize (#633): +-- one shared positions table meant a drag in landscape moved the portrait pad, +-- and there was no size setting at all. No pokered counterpart: the on-screen +-- pad is a port-only affordance (Xelu CC0 art, src/core/TouchControls.lua), the +-- Game Boy had physical buttons, so the coords here come from the editor itself. +-- POKEPORT_DRIVER=tests/drivers/touch_layout_bug633_test.lua POKEPORT_IDENTITY=bug633 POKEPORT_TOUCH=1 POKEPORT_VERSION=red love . +-- Leave POKEPORT_SPEED unset: this one is dragged by hand, at real time, and +-- fast-forward desyncs the driver's frames from the frames that draw the editor. +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local SafeArea = require("src.core.SafeArea") + local Editor = require("src.ui.TouchControlsEditor") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- The editor lives in launcher mode, before bootGame, and a POKEPORT_DRIVER + -- run boots straight past the launcher (main.lua: POKEPORT_DRIVER counts as a + -- scripted run). So put the window in landscape ourselves, then open the + -- editor and take over the frame + pointer handlers it normally gets from + -- main.lua's TouchEditor branch. We must not set that global: love.update + -- returns early on it and the driver coroutine would stop being resumed. + local ww, wh = love.graphics.getDimensions() + if ww <= wh then + love.window.setMode(1024, 768, { resizable = true }) + U.wait(4) + ww, wh = love.graphics.getDimensions() + end + check("the window rendered, and it is wider than tall (landscape)", + ww > 0 and wh > 0 and ww > wh) + + -- Pre-#633 options.lua kept one top-level positions table. It has to seed + -- both orientations as separate copies, or the very bug being fixed comes + -- straight back through the upgrade path. + local legacy = TouchControls.normalizeConfig({ + positions = { dpad = { x = 0.2, y = 0.8 } }, + }) + local lp, ll = legacy.layouts.portrait, legacy.layouts.landscape + check("an old single-layout options.lua seeds both orientations", + lp.positions ~= nil and ll.positions ~= nil + and lp.positions.dpad.x == 0.2 and ll.positions.dpad.x == 0.2) + check("and never as one shared table", lp.positions ~= ll.positions) + check("a file with no size setting reads back as 100%", + lp.scale == 1 and ll.scale == 1) + + -- The near miss this guards: a scale that grows the art but not the default + -- centers, which parks A and B half off the right edge at 160%. Both the + -- widths and the centers come out of defaultLayout, so compare them. + local base = TouchControls.defaultLayout(ww, wh, 0, 0, 1) + local big = TouchControls.defaultLayout(ww, wh, 0, 0, TouchControls.SCALE_MAX) + local grew, moved, inside = true, true, true + for _, name in ipairs(TouchControls.CONTROLS) do + local b, g = base[name], big[name] + if not (b and g) then grew = false break end + if g.w <= b.w then grew = false end + if g.cx == b.cx and g.cy == b.cy then moved = false end + if g.cx - g.w / 2 < -0.5 or g.cx + g.w / 2 > ww + 0.5 + or g.cy - g.w / 2 < -0.5 or g.cy + g.w / 2 > wh + 0.5 then + inside = false + end + end + check("at 160% every control is wider than at 100%", grew) + check("the default centers move with the art, not just the art", moved) + check("and nothing at 160% hangs off the window", inside) + + -- The editor owns TouchControls while it is open (load re-inits it and turns + -- preview on), so open it before touching live state. + local reopened = 0 + local function openEditor() + Editor.load({ onClose = function() + -- Done persists into options.lua; reopening straight away reloads from + -- that file, which is the whole point of the check the reporter runs. + reopened = reopened + 1 + U.log("Done saved the layouts; the editor reopened from options.lua" + .. " (reopen #" .. reopened .. ").") + openEditor() + end }) + end + openEditor() + + local prevDraw = love.draw + local prevPressed, prevReleased, prevMoved = + love.mousepressed, love.mousereleased, love.mousemoved + local prevKey = love.keypressed + love.draw = function() + Editor.update(love.timer.getDelta()) + Editor.draw() + -- main.lua's own love.draw owns the driver frame capture; taking the + -- handler over means taking that with it, or U.shot waits out its spin + -- and reports a file that was never written + if game.capturePath then + local path = game.capturePath + game.capturePath = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then + f:write(imagedata:encode("png"):getString()) + f:close() + end + end) + end + end + love.mousepressed = function(x, y, button) Editor.mousepressed(x, y, button or 1) end + love.mousereleased = function(x, y, button) Editor.mousereleased(x, y, button or 1) end + love.mousemoved = function(x, y) Editor.mousemoved(x, y) end + love.keypressed = function(key) Editor.keypressed(key) end + local function restore() + love.draw = prevDraw + love.mousepressed, love.mousereleased = prevPressed, prevReleased + love.mousemoved, love.keypressed = prevMoved, prevKey + end + + check("the overlay art loaded (every control has an image)", + TouchControls:ensureImages() and TouchControls.img ~= nil + and TouchControls.img.dpad ~= nil and TouchControls.img.a ~= nil + and TouchControls.img.b ~= nil and TouchControls.img.start ~= nil + and TouchControls.img.select ~= nil) + U.wait(4) -- chrome hit rects exist only after the panel has drawn once + + check("the editor is in the landscape bucket", + TouchControls.orientation == "landscape") + local plus, minus = Editor.rects.sizeUp, Editor.rects.sizeDown + if not check("the size card drew its -/+ buttons", plus ~= nil and minus ~= nil) then + U.log("Nothing to click: the size card never drew, so the rest is by hand.") + restore() + while true do coroutine.yield() end + end + + -- Drag the d-pad to mid-screen through the editor's own touch path. No + -- yields inside the drag: Editor.update follows the live mouse while a drag + -- is open, and a rendered frame in the middle would snap it to the cursor. + local ox, oy, sw, sh = SafeArea.rect() + local L = TouchControls:layout() + local target = { x = ox + sw * 0.5, y = oy + sh * 0.5 } + -- normalizeConfig seeds BOTH buckets (a pre-#633 file's top-level positions + -- become separate copies), so portrait.positions may already be a table + -- here; the invariant is that the landscape drag does not touch it + local portraitBefore = {} + for name, p in pairs(TouchControls.layouts.portrait.positions or {}) do + portraitBefore[name] = { x = p.x, y = p.y } + end + if L.dpad then + Editor.touchpressed("probe", L.dpad.cx, L.dpad.cy) + Editor.touchmoved("probe", target.x, target.y) + Editor.touchreleased("probe", target.x, target.y) + else + -- fallback if the d-pad zone is missing (a mod replaced the layout): + -- write the same normalized center the drag would have written + TouchControls:setControlCenter("dpad", target.x, target.y) + end + U.wait(2) + local dragged = TouchControls.layouts.landscape.positions + check("the drag landed in the landscape bucket", + dragged ~= nil and dragged.dpad ~= nil) + local portraitAfter = TouchControls.layouts.portrait.positions or {} + local portraitSame = true + for name, p in pairs(portraitAfter) do + local b = portraitBefore[name] + if not b or b.x ~= p.x or b.y ~= p.y then portraitSame = false end + portraitBefore[name] = nil + end + if next(portraitBefore) ~= nil then portraitSame = false end + check("the portrait bucket is still untouched by it", portraitSame) + + for _ = 1, 3 do + Editor.mousepressed(plus.x + plus.w / 2, plus.y + plus.h / 2, 1) + U.wait(2) + end + check("three taps on + read back as 130% landscape", + math.abs((TouchControls.layouts.landscape.scale or 1) - 1.3) < 0.001) + check("portrait is still at 100%", + math.abs((TouchControls.layouts.portrait.scale or 1) - 1) < 0.001) + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + if U.shot(game, SHOT_DIR .. "/bug633_landscape.png") then + U.log("captured", SHOT_DIR .. "/bug633_landscape.png") + end + + U.log("The editor on screen is live and landscape: the d-pad has already been") + U.log("dragged to mid-screen and + tapped three times, so the card should read") + U.log("\"Button size (Landscape)\" at 130% with the d-pad, A/B and START/SELECT") + U.log("all grown together and all still fully on screen. Drag the window taller") + U.log("than wide and the heading should flip to Portrait with the pad back in") + U.log("the default bottom corners at 100%; move it there and the landscape one") + U.log("must not budge when you drag back. Done saves and reopens the editor, so") + U.log("both rotations should come back exactly as you left them. If the portrait") + U.log("pad shows up already moved or already at 130% the moment you rotate, the") + U.log("two orientations are still sharing one bucket; if A and B slide off the") + U.log("right edge as the size climbs, the size grew the art but not the centers.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/warp_fade_bug607_test.lua b/tests/drivers/warp_fade_bug607_test.lua new file mode 100644 index 00000000..4d81b4cd --- /dev/null +++ b/tests/drivers/warp_fade_bug607_test.lua @@ -0,0 +1,158 @@ +-- Manual check that a door warp fades in whole shades, not a dissolve (#607). +-- PlayMapChangeSound tail-calls GBFadeOutToBlack (pokered home/overworld.asm:703), +-- four palette writes held eight frames each (home/fade.asm:43-67), so the veil +-- is a four-step staircase across 32 frames, never a tween. Numbers are pinned +-- in tests/mod_graphics_tests.lua; this puts eyes on the screen. +-- SHOT_DIR=/tmp/shots607 POKEPORT_DRIVER=tests/drivers/warp_fade_bug607_test.lua POKEPORT_IDENTITY=bug607 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so the +-- door SFX and the fade steps stop lining up and the staircase is unjudgeable. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Transition = require("src.render.Transition") + local Sound = require("src.core.Sound") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots607" + + -- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5, REDS_HOUSE_1F, 1. + -- The mat itself is the warp cell, so the approach is the walkable cell below + -- it, facing up: one step north fires the door. + local MAP = "PALLET_TOWN" + local DEST = "REDS_HOUSE_1F" + local DOOR = { x = 5, y = 5 } + local STAND = { x = 5, y = 6, facing = "up" } + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- the door row itself: a renamed destination or a moved warp reads on screen + -- as "the fade never happened", same as the bug did + local def = game.data.maps and game.data.maps[MAP] + local door = nil + for _, w in ipairs(def and def.warps or {}) do + if w.destMap == DEST then door = w end + end + check(MAP .. " carries a warp row to " .. DEST, door ~= nil) + if door then + DOOR.x, DOOR.y = door.x, door.y + STAND.x, STAND.y = door.x, door.y + 1 + check(("the door sits at (%d, %d), as in PalletTown.asm"):format(DOOR.x, DOOR.y), + DOOR.x == 5 and DOOR.y == 5) + end + + -- the fade is silent without these two: the step SFX is what tells the ear + -- where frame 0 of the 32 was + local sfx = game.data.audio and game.data.audio.sfx or {} + check("Go_Inside and Go_Outside resolve as sfx keys", + sfx.Go_Inside ~= nil and sfx.Go_Outside ~= nil) + local vol = game.save.options and game.save.options.sfxVol or 7 + if vol == 0 then + U.log("sfxVol is 0: the door SFX will be SILENT, raise it in OPTION first") + end + check(("sfx volume %d"):format(vol), vol > 0) + + -- the record and the staircase it drives, before any of it is on screen + local fade = Transition.new(game) + check("the warp fade runs 32 frames with no fade in", + fade.frames == 32 and (fade.framesIn or 0) == 0) + fade.phase = "out" + local want = { { 0, 0 }, { 7, 0 }, { 8, 1 / 3 }, { 15, 1 / 3 }, + { 16, 2 / 3 }, { 23, 2 / 3 }, { 24, 1 }, { 31, 1 } } + local staircase = true + for _, w in ipairs(want) do + fade.t = w[1] + if fade:alpha() ~= w[2] then staircase = false end + end + check("its alpha holds four shades, eight frames apiece", staircase) + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if not (ow.map:isWalkableCell(STAND.x, STAND.y)) then + -- a map edit or a mod blocked the approach: any walkable neighbour of the + -- mat still steps onto it. {dx, dy, facing} is the offset from the door to + -- the stand cell plus the direction that walks back onto it. + local sides = { { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } } + for _, s in ipairs(sides) do + local cx, cy = DOOR.x + s[1], DOOR.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + STAND.x, STAND.y, STAND.facing = cx, cy, s[3] + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("player is standing on the doorstep", + ow.player.cellX == STAND.x and ow.player.cellY == STAND.y) + + -- walk in, shooting across the fade on the same cadence door_test.lua uses, + -- so the two runs' frames line up side by side + U.hold(game, STAND.facing, 20) + check("first frame of the transition captured", + U.shot(game, DIR .. "/door_1_mid.png")) + for i = 2, 6 do + U.wait(6) + U.shot(game, DIR .. ("/door_%d_transition.png"):format(i)) + end + U.wait(40) + check("the door landed us in " .. DEST, ow.map.id == DEST) + + -- walking back out is the same fade, and sampling it frame by frame (no + -- screenshots in this pass, those cost frames) says whether the veil really + -- plateaus or is creeping between the steps + local function liveFade() + local top = game.stack:top() + return getmetatable(top) == Transition and top or nil + end + local seen, order = {}, {} + for _ = 1, 140 do + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + local live = liveFade() + if live then + local a = live:alpha() + if not seen[a] then seen[a] = 0; order[#order + 1] = a end + seen[a] = seen[a] + 1 + elseif #order > 0 then + break + end + coroutine.yield() + end + game.input.state.down = false + U.wait(20) + U.shot(game, DIR .. "/door_9_back_outside.png") + + local shades = {} + for _, a in ipairs(order) do + shades[#shades + 1] = ("%.2f x%d"):format(a, seen[a]) + end + U.log("shades held on the way out:", table.concat(shades, ", ")) + check("the exit fade shows four shades and no more", #order == 4) + local held = true + for _, a in ipairs(order) do + if seen[a] < 7 then held = false end + end + check("each shade holds its eight frames", held) + check("we are back outside on " .. MAP, ow.map.id == MAP) + + -- hand the doorstep back so this can be re-triggered by hand + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above")) + U.log("Red is on his own doorstep facing the door; hold Up to go in, Down to") + U.log("come back out, as often as you like. The town should stay fully lit for") + U.log("about eight frames after the door sound, then drop in three whole shades") + U.log("roughly an eighth of a second apart and sit on solid black for the last") + U.log("eighth before the house cuts in. If it instead dims smoothly, or is") + U.log("already grey the instant you step on the mat and the map swaps under a") + U.log("half-lit veil, that is the dissolve #607 was about, not the fade.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/effect_stereo_bug626.lua b/tests/engine/effect_stereo_bug626.lua new file mode 100644 index 00000000..6c2d9e21 --- /dev/null +++ b/tests/engine/effect_stereo_bug626.lua @@ -0,0 +1,86 @@ +-- Every one-shot the engine synthesizes must land in a two-channel buffer +-- (#626). OpenAL only spatializes 1-channel Sources, and nothing in this +-- repo ever calls setPosition/setRelative, so a mono effect Source sat at the +-- default (0,0,0) -- on top of the listener -- which OpenAL Soft renders as +-- an ambient sound spread over EVERY output channel the device exposes. On a +-- 6-channel interface the SFX therefore also came out of outputs 5+6 at gains +-- that differ from the front pair, while the music stayed put because +-- ChipAudio.playMusic is a 2-channel queueable source. Multi-channel buffers +-- skip spatialization outright, so the channel count is the invariant to +-- guard; the routing itself only an ear on a multi-output interface can +-- settle (tests/drivers). +-- +-- The duplication is also the faithful default: pokered's stereo panning byte +-- (audio/engine_1.asm Audio1_stereo_panning -> wStereoPanning -> rAUDTERM) +-- is 0xFF, both sides, for everything that never issues command 0xEE. +-- ROM-free: ChipAsm blobs, no data/generated/. +-- luajit tests/engine/effect_stereo_bug626.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") +local ChipAudio = require("src.core.ChipAudio") + +-- a short blob effect: one audible note on a pulse channel, no loops (the +-- effect renderer forces allowLoops=false anyway) +local beep = ChipAsm.song{ + tempo = 0x100, + channels = { { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 12 }, + { note = "E", len = 12 }, + } } }, +} +local blobData = { audio = { sfx = {}, cries = {} } } + +local sd = ChipSynth.renderEffectData(blobData, beep, {}) +check(sd ~= nil, "the blob effect renders at all") +check(sd:getChannelCount() == 2, + ("an effect buffer is stereo, not %d channel(s)") + :format(sd:getChannelCount())) + +-- both channels must carry the identical sample: the synthesis stays mono +-- (Engine:sample, not the music path's Engine:sampleStereo), so the only +-- thing that changed for a listener is which outputs the sound reaches +local frames = sd:getSampleCount() +local mismatched, nonzero = 0, 0 +for index = 0, frames - 1 do + local left, right = sd:getSample(index, 1), sd:getSample(index, 2) + if left ~= right then mismatched = mismatched + 1 end + if left ~= 0 then nonzero = nonzero + 1 end +end +check(nonzero > 0, "the effect buffer is not silent") +check(mismatched == 0, + ("both channels carry the same sample (%d frames differ)") + :format(mismatched)) + +-- The low-health siren is hand-synthesized rather than going through the +-- effect renderer, so it needs its own guard (ChipAudio.newLowHealthAlarm). +-- tests/love_stub carries no love.audio (headless suites never play), so the +-- buffer is caught on its way into newSource. +local captured +love.audio = { newSource = function(sd) captured = sd; return { sd } end } +local alarmOk, alarmErr = pcall(ChipAudio.newLowHealthAlarm) +love.audio = nil +check(alarmOk, ("the low-health siren builds: %s"):format(tostring(alarmErr))) +check(captured ~= nil and captured:getChannelCount() == 2, + "the low-health siren is stereo") +if captured then + local siren = 0 + for index = 0, captured:getSampleCount() - 1 do + if captured:getSample(index, 1) ~= captured:getSample(index, 2) then + siren = siren + 1 + end + end + check(siren == 0, "both siren channels carry the same sample") +end + +T.finish("effect stereo (#626)") diff --git a/tests/engine/fixed_damage_immunity_bug616.lua b/tests/engine/fixed_damage_immunity_bug616.lua new file mode 100644 index 00000000..47456abf --- /dev/null +++ b/tests/engine/fixed_damage_immunity_bug616.lua @@ -0,0 +1,96 @@ +-- Gen 1 fixed-damage moves and Super Fang ignore the type chart (#616). +-- SPECIAL_DAMAGE_EFFECT and SUPER_FANG_EFFECT are the SetDamageEffects +-- table (data/battle/set_damage_effects.asm); engine/battle/core.asm:3139 +-- jumps straight to MoveHitTest for them, so CalculateDamage and +-- AdjustDamageForMoveType never run and ApplyAttackToEnemyPokemon +-- (core.asm:4612) stores wDamage unscaled. Night Shade therefore hits +-- Normal-types and Super Fang hits Ghosts. OHKO is the exception: it +-- returns through AdjustDamageForMoveType (core.asm:4329, 4467) and a 0x +-- matchup still zeroes its 65535 and sets wMoveMissed. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +-- the shared fixture type chart is just the GRASS/FIRE/WATER triangle +-- (tests/fixture_data/type_chart.lua) with no 0x row, so this suite adds +-- its own immune matchups: one for the SetDamageEffects moves under test +-- and a separate one for OHKO, kept apart so neither probe leaks into +-- the other's assertion +table.insert(Data.type_chart.matchups, + { attacker = "FIX_ATK_A", defender = "FIX_DEF_A", multiplier = 0 }) +table.insert(Data.type_chart.matchups, + { attacker = "FIX_ATK_B", defender = "FIX_DEF_B", multiplier = 0 }) +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +-- Night Shade's shape: 0 BP, fixed damage equal to the user's level, +-- attacking type that the fixture chart marks as 0x against the target +Data.moves.FIX_NIGHT_SHADE = { + id = "FIX_NIGHT_SHADE", index = 98, name = "FIX NSHADE", + type = "FIX_ATK_A", power = 0, accuracy = 100, pp = 15, + effect = "SPECIAL_DAMAGE_EFFECT", fixedDamage = "level", +} +Data.moves.FIX_SUPER_FANG = { + id = "FIX_SUPER_FANG", index = 97, name = "FIX SFANG", + type = "FIX_ATK_A", power = 1, accuracy = 90, pp = 10, + effect = "SUPER_FANG_EFFECT", +} +Data.moves.FIX_FISSURE = { + id = "FIX_FISSURE", index = 96, name = "FIX FISSURE", + type = "FIX_ATK_B", power = 1, accuracy = 30, pp = 5, + effect = "OHKO_EFFECT", +} + +local function mkseq(vals) -- scripted rng: pops vals, then max rolls + local i = 0 + return function(_, hi) + i = i + 1 + return vals[i] ~= nil and vals[i] or hi + end +end + +-- a battle whose target's live types are forced to `types` (curTypes is +-- what TypeChart reads, BattleState.lua:443) +local function mkbattle(level, types) + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", level) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local b = BattleState.newWild(game, "FIXMON_C", 5) + b.enemy.curTypes = types + b.rng = mkseq({ 0 }) -- accuracy hits; nothing else rolls + return b +end + +-- FIX_ATK_A is 0x against FIX_DEF_A, and Night Shade lands anyway +T.eq(TypeChart.effectiveness("FIX_ATK_A", { "FIX_DEF_A" }), 0, + "FIX_ATK_A does nothing to FIX_DEF_A") +local ns = mkbattle(12, { "FIX_DEF_A" }) +local before = ns.enemy.mon.hp +ns:performMove(ns.player, ns.enemy, { id = "FIX_NIGHT_SHADE", pp = 15 }) +T.eq(before - ns.enemy.mon.hp, 12, "Night Shade takes level damage off an immune type") + +-- same 0x matchup, and Super Fang still halves +local sf = mkbattle(12, { "FIX_DEF_A" }) +local hp = sf.enemy.mon.hp +sf:performMove(sf.player, sf.enemy, { id = "FIX_SUPER_FANG", pp = 10 }) +T.eq(hp - sf.enemy.mon.hp, math.max(1, math.floor(hp / 2)), + "Super Fang halves an immune type's HP") + +-- OHKO keeps its immunity gate: it does return through AdjustDamageForMoveType +T.eq(TypeChart.effectiveness("FIX_ATK_B", { "FIX_DEF_B" }), 0, + "FIX_ATK_B does nothing to FIX_DEF_B") +local ko = mkbattle(50, { "FIX_DEF_B" }) +local full = ko.enemy.mon.hp +ko:performMove(ko.player, ko.enemy, { id = "FIX_FISSURE", pp = 5 }) +T.eq(ko.enemy.mon.hp, full, "OHKO still cannot touch an immune type") + +Data.moves.FIX_NIGHT_SHADE = nil +Data.moves.FIX_SUPER_FANG = nil +Data.moves.FIX_FISSURE = nil +T.finish("fixed damage ignores type immunity") diff --git a/tests/engine/host_hide_console_bug606.lua b/tests/engine/host_hide_console_bug606.lua new file mode 100644 index 00000000..616409c2 --- /dev/null +++ b/tests/engine/host_hide_console_bug606.lua @@ -0,0 +1,33 @@ +-- #606: on Windows every shelled-out host tool (curl, the PowerShell ROM +-- picker, the update downloader) flashed its own cmd.exe console window, +-- because a GUI-subsystem process has no console for the child to inherit. +-- HostShell.hideHostConsole claims one hidden console at boot so the children +-- inherit it silently. The Windows half needs Windows; what this tier can pin +-- is that the helper is a harmless memoized no-op everywhere else, and that +-- main.lua actually calls it (an unwired helper fixes nothing). +-- luajit tests/engine/host_hide_console_bug606.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 HostShell = require("src.core.HostShell") + +check(type(HostShell.hideHostConsole) == "function", + "HostShell exposes hideHostConsole (#606)") + +local ok, hidden = pcall(HostShell.hideHostConsole) +check(ok, "hideHostConsole never throws") +eq(hidden, false, "non-Windows hosts allocate no console") +eq(select(2, pcall(HostShell.hideHostConsole)), false, + "the answer is memoized, so repeat calls stay a no-op") + +local f = assert(io.open("main.lua", "r")) +local src = f:read("*a") +f:close() +check(src:find("hideHostConsole", 1, true) ~= nil, + "love.load wires the console suppression up at boot (#606)") + +T.finish("host_hide_console_bug606") diff --git a/tests/engine/oaks_lab_dex_rating_bug600.lua b/tests/engine/oaks_lab_dex_rating_bug600.lua new file mode 100644 index 00000000..f20a152d --- /dev/null +++ b/tests/engine/oaks_lab_dex_rating_bug600.lua @@ -0,0 +1,69 @@ +-- Regression coverage for #600 "Wrong Prof. Oak dialogue" (T2, ROM-free). +-- +-- pret/pokered scripts/OaksLab.asm OaksLabOak1Text opens with the dex +-- rating branch: EVENT_PALLET_AFTER_GETTING_POKEBALLS, or 2+ owned species +-- (CountSetBits over wPokedexOwned) once EVENT_GOT_POKEDEX is set, sends +-- Oak to .HowIsYourPokedexComingText + predef DisplayDexRating. The port +-- skipped that branch entirely, so a player with the Pokedex kept getting +-- .PokemonAroundTheWorldText, the line Oak reads right after handing it +-- over. Yellow already had the branch (data/scripts/oaks_lab_yellow.lua); +-- Red additionally gates it on GOT_POKEDEX, which Yellow's copy drops. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local MapScripts = require("src.script.MapScripts") + +local contribution = dofile("data/scripts/oaks_lab.lua") +T.eq(#MapScripts.validateContribution(contribution), 0, + "oaks_lab contribution validates cleanly") + +local script = contribution.talk.TEXT_OAKSLAB_OAK1 +local labels = {} +for i, row in ipairs(script) do + if row[1] == "label" then labels[row[2]] = i end +end + +-- mini executor with ScriptRunner semantics: label/number/"end" jumps, +-- the three checks this branch needs, and text/dex_rating recorded +local function run(flags, owned, bag) + local pc, out, last, guard = 1, {}, nil, 0 + while pc <= #script and guard < 400 do + guard = guard + 1 + local row = script[pc] + local verb, jump = row[1], nil + if verb == "check_flag" then last = flags[row[2]] == true + elseif verb == "check_dex_owned" then last = owned >= row[2] + elseif verb == "check_item" then last = (bag[row[2]] or 0) > 0 + elseif verb == "jump" then jump = row[2] + elseif verb == "jump_if_true" then if last then jump = row[2] end + elseif verb == "jump_if_false" then if not last then jump = row[2] end + elseif verb == "show_text" then out[#out + 1] = row[2] + elseif verb == "dex_rating" then out[#out + 1] = "" + end + if jump == "end" then break + elseif type(jump) == "string" then pc = labels[jump] + elseif type(jump) == "number" then pc = jump + else pc = pc + 1 end + end + return table.concat(out, "|") +end + +local dex = "_OaksLabOak1HowIsYourPokedexComingText|" + +T.eq(run({ EVENT_GOT_POKEDEX = true }, 2, {}), dex, + "Pokédex + 2 owned rates the dex") +T.eq(run({ EVENT_GOT_POKEDEX = true }, 6, { POKE_BALL = 5 }), dex, + "the rating branch wins over the come-see-me line") +T.eq(run({ EVENT_PALLET_AFTER_GETTING_POKEBALLS = true }, 0, {}), dex, + "converted saves rate off EVENT_PALLET_AFTER_GETTING_POKEBALLS") +T.eq(run({ EVENT_GOT_POKEDEX = true, EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true }, + 5, {}), dex, "the rating branch wins over the poke ball gift") + +T.eq(run({ EVENT_GOT_POKEDEX = true }, 1, {}), + "_OaksLabOak1PokemonAroundTheWorldText", + "one owned species is still the around-the-world line") +T.eq(run({}, 3, {}), "_OaksLabOak1WhichPokemonDoYouWantText", + "no Pokédex yet: never rate, whatever the owned count (Red-only gate)") + +T.finish("oaks_lab_dex_rating_bug600") diff --git a/tests/engine/rebind_joystick_bug632.lua b/tests/engine/rebind_joystick_bug632.lua new file mode 100644 index 00000000..43413b0d --- /dev/null +++ b/tests/engine/rebind_joystick_bug632.lua @@ -0,0 +1,84 @@ +-- Raw-stick rebinding, and the recognized-pad duplicate event that used to +-- defeat every controller rebind (#632). LOVE raises love.joystickpressed +-- for EVERY stick, gamepads included, and those raise love.gamepadpressed +-- for the same press as well; Input's fixed raw table answered both, so it +-- re-asserted the factory A/B/START/SELECT map underneath a CONTROLS +-- rebind and a swapped A and B pressed at once. A recognized pad is now +-- served by the gamepad path alone, and a raw stick's buttons are +-- rebindable as "joyN" in the same pad slot every other controller uses. +-- No pokered cite: rebinding is port-only (gap C2). +-- luajit tests/engine/rebind_joystick_bug632.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Input = require("src.core.Input") +local BindingsMenu = require("src.ui.BindingsMenu") + +-- an SDL-recognized pad versus a stick with no database entry +local pad = { isGamepad = function() return true end } +local raw = { isGamepad = function() return false end } + +Input:init() + +-- ---- (a) a recognized pad is served by the gamepad path alone ------------ +Input:reset() +Input:joystickpressed(pad, 1) +Input:step() +check(not Input:isDown("a"), + "a recognized pad's duplicate joystick event is ignored (#632)") +Input:joystickhat(pad, 1, "u") +Input:step() +check(not Input:isDown("up"), + "and its duplicate hat event is ignored too (#632)") + +-- ---- (b) a raw stick keeps its defaults ---------------------------------- +Input:reset() +Input:joystickpressed(raw, 1) +Input:step() +check(Input:isDown("a"), "a raw stick's button 1 still presses A") +Input:joystickreleased(raw, 1) +check(not Input:isDown("a"), "and its release clears A") + +-- ---- (c) a "joyN" pad binding reaches the raw lookup --------------------- +Input:applyBindings({ up = { pad = "joy1" } }) +Input:reset() +Input:joystickpressed(raw, 1) +Input:step() +check(Input:isDown("up"), "a joy1 rebind wins the raw button (#632)") +check(not Input:isDown("a"), "and the raw default no longer presses A") +Input:joystickreleased(raw, 1) +Input:applyBindings(nil) + +-- ---- (d) the CONTROLS row captures a raw button -------------------------- +-- same doubles as rebind_swap_clear_bug589: a stack the menu can pop itself +-- off and an input whose queue is one fixed step of edges. data = {} keeps +-- ChoiceBox's un-guarded Sound.play on the headless no-audio path. +local game = { save = { options = {} }, data = {} } +function game:writeOptions() end +game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, +} +game.input = { wasPressed = function() return false end, + isDown = function() return false end } + +local ROW_A = 5 -- BindingsMenu's BUTTONS order +local bm = BindingsMenu.new(game) +bm:beginCapture(bm.items[ROW_A]) +bm:onJoystickPressed(3) +check(game.save.options.bindings == nil, + "press alone commits nothing, the release is the commit (#589)") +bm:onJoystickReleased(3) +eq(game.save.options.bindings.a.pad, "joy3", + "a released raw button lands in the row's pad slot (#632)") +check(bm.items[ROW_A].right:find("JOY3", 1, true) ~= nil, + "and the row's controller column reads JOY3") + +Input:init() +T.finish("rebind_joystick_bug632") diff --git a/tests/engine/safari_gate_bug540.lua b/tests/engine/safari_gate_bug540.lua new file mode 100644 index 00000000..409032af --- /dev/null +++ b/tests/engine/safari_gate_bug540.lua @@ -0,0 +1,233 @@ +-- Regression coverage for #540 "Safari Zone: wrong start tile / step count, +-- black leaving-early dialogue, wrong early-leave tile" (T2, ROM-free). +-- +-- pret/pokered scripts/SafariZoneGate.asm: +-- .success (186-198) ends `ld a, PAD_UP / ld c, 3 / +-- SafariZoneEntranceAutoWalk`, so paying walks the player up through the +-- gate's north warp instead of leaving him at the counter. Two of those +-- steps happen with EVENT_IN_SAFARI_ZONE already set, and home/overworld.asm +-- :307-310 charges every step against wSafariSteps, which is why the game +-- starts at 502 but reads 500/500 on arrival. The port only counts steps on +-- the nine interior maps, so the script pays those two itself. +-- SafariZoneGateSafariZoneWorker1LeavingEarlyText (227-254): YES prints the +-- return-balls text and auto-walks `PAD_DOWN, c = 3` down to the counter row, +-- NO prints "Good Luck!" and walks back up through the warp. +-- +-- The leaving-early prompt must be QUEUED, never pushed: onEnter runs at the +-- arriving warp Transition's midpoint and Transition:finish pops the top state +-- on the same frame, so a box pushed there is swallowed (or, on an older +-- build, drawn over a screen still faded to black). + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local MapScripts = require("src.script.MapScripts") +local ScriptRunner = require("src.script.ScriptRunner") + +-- ---- stubs for the two UI modules safari.lua requires lazily. A pushed +-- box runs its continuation immediately, so a whole prompt chain resolves +-- inside the push that started it. + +local answer = true +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, done = done } end, +} +package.loaded["src.ui.ChoiceBox"] = { + new = function(_, cb) return { done = function() cb(answer) end } end, +} + +local contribution = dofile("data/scripts/safari.lua") +local gate = contribution.SAFARI_ZONE_GATE +T.check(gate ~= nil, "the contribution still carries SAFARI_ZONE_GATE") + +local problems = MapScripts.validateContribution(gate) +T.eq(#problems, 0, "safari contribution validates cleanly") +for _, p in ipairs(problems) do T.check(false, "unexpected finding: " .. p) end + +-- ---- a minimal game/overworld pair + +local NORTH_WARP = { x = 4, y = 0, destMap = "SAFARI_ZONE_CENTER", destWarp = 2 } + +local function newWorld(cellX, cellY) + local w = { + pushed = {}, moves = {}, warps = {}, queued = {}, + player = { cellX = cellX, cellY = cellY }, + } + w.map = { + id = "SAFARI_ZONE_GATE", + warpAtCell = function(_, cx, cy) + if cy == 0 and (cx == 3 or cx == 4) then + return { index = cx == 3 and 3 or 4, def = NORTH_WARP } + end + end, + } + w.scriptMove = function(_, entity, dir, tiles, onDone) + w.moves[#w.moves + 1] = { entity = entity, dir = dir, tiles = tiles, + onDone = onDone } + end + w.takeWarp = function(_, def) w.warps[#w.warps + 1] = def end + w.startWarpTo = function(_, mapId, x, y, facing) + w.warps[#w.warps + 1] = { startWarpTo = mapId, x = x, y = y, + facing = facing } + end + w.queueScript = function(_, rows) w.queued[#w.queued + 1] = rows end + return w +end + +local function newGame(world, money) + local game = { + data = { + text = {}, + maps = { SAFARI_ZONE_CENTER = { + warps = { { x = 14, y = 25 }, { x = 15, y = 25 } } } }, + }, + save = { player = { name = "RED" }, money = money or 3000 }, + } + game.stack = { push = function(_, state) + world.pushed[#world.pushed + 1] = state + if state.done then state.done() end + end } + return game +end + +-- ---- paying walks into the zone and pays the two gate steps + +answer = true +local ow = newWorld(4, 2) +local game = newGame(ow) +T.eq(gate.onStep(game, ow, 4, 2), true, "the trigger cell claims the step") +T.check(game.save.safari ~= nil, "paying starts the game") +T.eq(game.save.safari.steps, 502, "wSafariSteps is written as 502") +T.eq(game.save.safari.balls, 30, "SAFARI_BALLS_RECEIVED is 30") +T.eq(game.save.money, 2500, "the ¥500 fee is taken") + +T.eq(#ow.moves, 1, "the payment text is followed by the entrance auto-walk") +T.eq(ow.moves[1].dir, "up", "SafariZoneEntranceAutoWalk walks PAD_UP") +T.eq(ow.moves[1].tiles, 2, "two cells reach the north warp row") +T.eq(#ow.warps, 0, "the warp is not taken until the walk lands on it") +ow.moves[1].onDone() +T.eq(game.save.safari.steps, 500, + "the two gate steps are charged, so the counter reads 500/500 on arrival") +T.eq(ow.warps[1], NORTH_WARP, + "a scripted step skips CheckWarpsNoCollision, so the script takes the warp") + +-- talking to the worker from anywhere else has no warp above the player, so +-- the auto-walk stays out of the way and he walks in himself +answer = true +local far = newWorld(2, 4) +local farGame = newGame(far) +farGame.save.safari = nil +gate.talk.TEXT_SAFARIZONEGATE_SAFARI_ZONE_WORKER1(farGame, far, nil, nil) +T.check(farGame.save.safari ~= nil, "the talk path still starts the game") +T.eq(#far.moves, 0, "no auto-walk from a cell with no warp above it") + +-- declining walks the player back off the trigger cell, unchanged +answer = false +local no = newWorld(4, 2) +local noGame = newGame(no) +gate.onStep(noGame, no, 4, 2) +T.eq(noGame.save.safari, nil, "declining starts no game") +T.eq(no.moves[1] and no.moves[1].dir, "down", "declining walks you back down") + +-- ---- leaving early: queued, never pushed + +local back = newWorld(4, 0) +local backGame = newGame(back) +backGame.save.safari = { balls = 7, steps = 300 } +gate.onEnter(backGame, back) +T.eq(#back.pushed, 0, + "onEnter pushes nothing: the arriving Transition pops the top state on the " + .. "same frame") +T.eq(#back.queued, 1, "the leaving-early prompt is queued for an idle frame") + +local rows = back.queued[1] +T.eq(#ScriptRunner.validate(rows), 0, "the queued rows validate") + +local function runRows(script, yes) + local pc, texts, out = 1, {}, { fields = {}, moves = {}, warps = {} } + local lastCheck = nil + while pc <= #script do + local row = script[pc] + local verb = row[1] + local jump = nil + if verb == "ask" then + texts[#texts + 1] = row[2] + lastCheck = yes + elseif verb == "show_text" then + texts[#texts + 1] = row[2] + elseif verb == "jump_if_false" then + if not lastCheck then jump = row[2] end + elseif verb == "jump" then + jump = row[2] + elseif verb == "set_field" then + out.fields[#out.fields + 1] = { key = row[2], value = row[3] } + elseif verb == "move_player" then + out.moves[#out.moves + 1] = { dir = row[2], tiles = row[3] } + elseif verb == "warp" then + out.warps[#out.warps + 1] = { map = row[2], x = row[3], y = row[4], + facing = row[5] } + end + if jump == "end" then break end + if jump then + local target + for i, r in ipairs(script) do + if r[1] == "label" and r[2] == jump then target = i break end + end + T.check(target ~= nil, "jump target '" .. tostring(jump) .. "' exists") + pc = target + else + pc = pc + 1 + end + end + out.texts = texts + return out +end + +local yes = runRows(rows, true) +T.eq(yes.texts[1], "_SafariZoneGateSafariZoneWorker1LeavingEarlyText", + "the worker asks first") +T.eq(yes.texts[2], "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText", + "YES takes the leftover balls back") +T.eq(yes.texts[3], "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText", + "the good-haul sign-off stays reachable on this branch") +T.eq(#yes.fields, 1, "the game state is cleared once") +T.eq(yes.fields[1].key, "safari", "save.safari is the field cleared") +T.eq(yes.fields[1].value, nil, "set_field with no value assigns nil") +T.eq(#yes.moves, 1, "YES ends with the exit auto-walk") +T.eq(yes.moves[1].dir, "down", "SafariZoneEntranceAutoWalk walks PAD_DOWN") +T.eq(yes.moves[1].tiles, 3, "three cells reach the counter row") +T.eq(#yes.warps, 0, "the YES branch never warps back into the zone") + +local stay = runRows(rows, false) +T.eq(stay.texts[2], "_SafariZoneGateSafariZoneWorker1GoodLuckText", + "NO wishes you good luck") +T.eq(#stay.moves, 0, "NO does not walk you to the counter") +T.eq(#stay.warps, 1, "NO puts you back in the zone") +T.eq(stay.warps[1].map, "SAFARI_ZONE_CENTER", "back through the entrance") +T.eq(stay.warps[1].x, 15, "the right-hand warp column comes back on column 15") +T.eq(stay.warps[1].y, 25, "the entrance row of SAFARI_ZONE_CENTER") + +-- the left-hand column pairs with the left-hand destination warp +local left = newWorld(3, 0) +local leftGame = newGame(left) +leftGame.save.safari = { balls = 7, steps = 300 } +gate.onEnter(leftGame, left) +local leftStay = runRows(left.queued[1], false) +T.eq(leftStay.warps[1].x, 14, "the left-hand warp column comes back on 14") + +-- no game running, or arriving from the town side, asks nothing +local idle = newWorld(4, 0) +local idleGame = newGame(idle) +gate.onEnter(idleGame, idle) +T.eq(#idle.queued, 0, "no safari game, no prompt") + +local south = newWorld(4, 5) +local southGame = newGame(south) +southGame.save.safari = { balls = 7, steps = 300 } +gate.onEnter(southGame, south) +T.eq(#south.queued, 0, "arriving from Fuchsia asks nothing") + +package.loaded["src.render.TextBox"] = nil +package.loaded["src.ui.ChoiceBox"] = nil + +T.finish("safari_gate_bug540") diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index 2ea492d9..9b09f8bd 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -595,4 +595,16 @@ steps = 0 FixedStep:update(1 / 60) T.eq(steps, 1, "and the clamp expires after that one frame") +-- ...and the absorbed frame must not hand the accumulator back sitting on a +-- step boundary. A residual of zero has no margin on the long side, so the +-- accumulator settles a hair under one step and stays there, flipping between +-- 0 and 2 steps on sub-millisecond frame wobble for the rest of the session, +-- which is what made pacing erratic forever after a route seam (issue #487). +FixedStep:init(function() steps = steps + 1 end) +FixedStep:discardCatchup() +FixedStep:update(0.25) +T.check(FixedStep.accum > FixedStep.STEP * 0.25 + and FixedStep.accum < FixedStep.STEP * 0.75, + "the absorbed hitch frame leaves the accumulator mid-step, not on a boundary") + T.finish("timing parity") diff --git a/tests/engine/touch_orientation_bug633.lua b/tests/engine/touch_orientation_bug633.lua new file mode 100644 index 00000000..48e87da3 --- /dev/null +++ b/tests/engine/touch_orientation_bug633.lua @@ -0,0 +1,80 @@ +-- Per-orientation touch layouts and the button size setting (#633). The +-- overlay used to keep exactly one positions table, so laying the pad out +-- in landscape rewrote the portrait layout; now each orientation owns a +-- {positions, scale} bucket picked from the safe rect's aspect, and the +-- editor's -/+ scales every control in the active one. No pokered cite: +-- the on-screen pad is a port-only affordance (Xelu CC0 art). +-- luajit tests/engine/touch_orientation_bug633.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 TC = require("src.core.TouchControls") + +-- per-orientation buckets round-trip independently, scale is clamped +local cfg = TC.normalizeConfig({ + layouts = { + portrait = { positions = { dpad = { x = 0.2, y = 0.9 } }, scale = 1.25 }, + landscape = { positions = { dpad = { x = 0.1, y = 0.5 } }, scale = 99 }, + }, +}) +eq(cfg.layouts.portrait.positions.dpad.x, 0.2, "portrait bucket kept") +eq(cfg.layouts.landscape.positions.dpad.x, 0.1, "landscape bucket kept") +eq(cfg.layouts.portrait.scale, 1.25, "portrait scale kept") +eq(cfg.layouts.landscape.scale, TC.SCALE_MAX, "out-of-range scale clamps") + +-- steer the safe rect by hand: tall = portrait, wide = landscape +love.window = love.window or {} +local oldSafe = love.window.getSafeArea +local function setRect(w, h) + love.window.getSafeArea = function() return 0, 0, w, h end + -- getDimensions bounds the safe rect (SafeArea clamps to the drawable + -- window), so keep both in step + love.graphics.getDimensions = function() return w, h end + TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil +end + +TC:init() +setRect(380, 720) +TC:applyOptions({ + touchControls = { + enabled = true, + positions = { dpad = { x = 0.25, y = 0.75 } }, + }, +}) + +-- the size setting scales every control in the active (portrait) bucket +TC:setScale(1.5) +setRect(380, 720) +local big = TC:layout() +eq(TC.orientation, "portrait", "tall safe rect is portrait") +check(big.a.w > TC.defaultLayout(380, 720).a.w, "scale grows buttons") + +-- rotating swaps buckets, and a landscape drag leaves portrait alone +TC:setScale(1) +setRect(800, 400) +TC:layout() +eq(TC.orientation, "landscape", "wide safe rect is landscape") +TC:setControlCenter("dpad", 700, 300) +eq(TC.layouts.portrait.positions.dpad.x, 0.25, + "landscape drag leaves the portrait layout alone (#633)") + +-- Reset clears only the orientation on screen +TC:clearPositions() +check(TC.layouts.landscape.positions == nil, + "Reset wipes the landscape overrides") +eq(TC.layouts.portrait.positions.dpad.x, 0.25, + "and the portrait layout survives it (#633)") + +-- config() snapshots both buckets for the editor's save path +local snap = TC:config() +eq(snap.layouts.portrait.positions.dpad.x, 0.25, "config carries portrait") +check(snap.layouts.landscape.positions == nil, "config carries landscape") + +if oldSafe then love.window.getSafeArea = oldSafe +else love.window.getSafeArea = nil end + +T.finish("touch_orientation_bug633") diff --git a/tests/input_hold_test.lua b/tests/input_hold_test.lua index 9ec07552..c8a716aa 100644 --- a/tests/input_hold_test.lua +++ b/tests/input_hold_test.lua @@ -67,6 +67,21 @@ Input:joystickpressed(nil, 1) Input:step() check(Input:isDown("a"), "raw joystick primary button presses A") +-- A pad SDL already maps sends both event pairs for one physical button, +-- and its raw indices are per-driver (iOS puts the D-pad on 7..10), so the +-- raw fallback must stand down for it (#620). +local mapped = { isGamepad = function() return true end } +Input:reset() +Input:joystickpressed(mapped, 9) +Input:joystickhat(mapped, 1, "l") +Input:joystickaxis(mapped, 1, -0.9) +Input:step() +check(not Input:isDown("select"), "mapped pad ignores raw button indices") +check(not Input:isDown("left"), "mapped pad ignores raw hat and axis") +Input:gamepadpressed(mapped, "dpleft") +Input:step() +check(Input:isDown("left"), "mapped pad still routes through gamepad events") + -- The launcher has a separate virtual cursor, so prove generic joystick -- events reach its left-stick and D-pad state too. local RomImporter = require("src.import.RomImporter") @@ -90,6 +105,9 @@ check(not importer._padDir.dpright, "raw joystick hat release clears the launcher cursor") importer:joystickpressed(nil, 1) check(clicked, "raw joystick primary button clicks the launcher cursor") +clicked = false +importer:joystickpressed(mapped, 1) +check(not clicked, "mapped pad does not double-click the launcher cursor") -- Drivers that only inject pressQueue still get a one-step hold. Input:reset() diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index f679d277..9e6c3017 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -673,7 +673,7 @@ for _, id in ipairs({ "doublecircle", "spiralin", "circle", "spiralout", "hstripes", "shrink", "vstripes", "split" }) do check(transitions:get(id) ~= nil, "the engine registers wipe " .. id) end -check(transitions:get("warp_fade").frames == 12, +check(transitions:get("warp_fade").frames == 32, "the warp fade registers as a transitions record") check(transitions:get("white_flash").frames == 7, "the white flash registers as a transitions record") @@ -689,8 +689,23 @@ end local retimed = { transitions = { warp_fade = { kind = "fade", frames = 30 } } } check(Transition.new({ data = retimed }).frames == 30, "a patched warp_fade record changes the fade length") -check(Transition.new({ data = { transitions = {} } }).frames == 12, - "an unregistered id falls back to the built-in 12 frames") +check(Transition.new({ data = { transitions = {} } }).frames == 32, + "an unregistered id falls back to the built-in 32 frames") + +-- issue #607: the warp fade is GBFadeOutToBlack's four palette writes, each +-- held eight frames (home/fade.asm:43-60) -- the map is untouched through the +-- first hold and solid black through the last, with nothing tweened between. +do + local staircase = Transition.new({ data = { transitions = {} } }) + staircase.phase = "out" + local want = { { 0, 0 }, { 7, 0 }, { 8, 1 / 3 }, { 15, 1 / 3 }, + { 16, 2 / 3 }, { 23, 2 / 3 }, { 24, 1 }, { 31, 1 } } + for _, w in ipairs(want) do + staircase.t = w[1] + check(staircase:alpha() == w[2], + ("the warp fade holds its shade at frame %d"):format(w[1])) + end +end -- issue #121: with the survey-zoom world pass active, the warp fade must -- darken the full window composite (via Renderer.worldFadeAlpha), not just @@ -711,7 +726,9 @@ do Renderer:init() local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } }) - fade.t = 6 -- mid fade-out (12 frames) + -- GBFadeOutToBlack steps every eight frames, so frame 16 of the 32 frame + -- fade is its third palette write: two thirds of the way to black (#607) + fade.t = 16 fade.phase = "out" Renderer:beginFrame(true) @@ -719,7 +736,7 @@ do Renderer:endWorldPass() check(Renderer.worldActive == true, "world pass stays marked active until endFrame") fade:draw() - check(Renderer.worldFadeAlpha == 0.5, + check(Renderer.worldFadeAlpha == 2 / 3, "warp fade hands mid-out alpha to the world composite overlay") check(#rects == 0, "warp fade does not paint the 160x144 UI letterbox while the world pass is up") @@ -730,9 +747,10 @@ do local fadeRect for _, r in ipairs(rects) do -- endFrame's letterbox clear is also a full-window black fill (a == 1); - -- the warp overlay is the half-alpha one Transition requested + -- the warp overlay is the two-thirds-alpha one Transition requested if r.mode == "fill" and r.x == 0 and r.y == 0 - and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then + and r.w == 640 and r.h == 576 and r.r == 0 + and math.abs(r.a - 2 / 3) < 1e-6 then fadeRect = r end end @@ -742,7 +760,7 @@ do -- without a world pass (opaque UI states), keep the classic UI rect Renderer:beginFrame(false) - fade.t = 6 + fade.t = 16 fade.phase = "out" rects = {} fade:draw() diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua index b5f8d75d..bb55a825 100644 --- a/tests/mod_link_tests.lua +++ b/tests/mod_link_tests.lua @@ -212,6 +212,49 @@ eq(Handshake.mods(overhaulGame)[1].affectsLink, true, "and rides the hello") eq(Handshake.hello(tweakGame, "trade").linkModified, true, "the hello carries the flag a v1 peer is judged against") +-- #501: a declared translation is invisible to the wire, so online play +-- lets an English install meet a Spanish one +local languageGame = loadMods({ + ["mods/espanol/manifest.json"] = + '{"id":"espanol","name":"espanol","version":"1.0.0","entry":"main.lua",' .. + '"language":true,"category":"LANGUAGE"}', + ["mods/espanol/main.lua"] = [[ +return function(mod) + mod.content.strings:override("But, it failed!", "Pero fallo!") +end +]], +}) +eq(Handshake.mods(languageGame)[1].language, true, + "the translation flag rides the hello") +eq(Handshake.mods(languageGame)[1].affectsLink, false, + "and a translation never claims the fingerprint") +eq(Handshake.linkModified(languageGame), false, + "it leaves the link surface alone") +eq(Handshake.onlineAllowed(languageGame), true, "so online play allows it") +eq(Fingerprint.compute(languageGame.data, Handshake.mods(languageGame)), + Fingerprint.compute(languageGame.data, {}), + "and two peers reading different languages hash the same") +eq(Handshake.onlineAllowed(tweakGame), false, + "a content mod that touches a species still forces vanilla") +eq(Handshake.onlineAllowed(bareGame), true, "vanilla still goes online") + +-- the flag is a claim, not a pass: a mod that writes gameplay is not a +-- translation however its manifest describes itself +local fakeLanguageGame = loadMods({ + ["mods/faux/manifest.json"] = + '{"id":"faux","name":"faux","version":"1.0.0","entry":"main.lua","language":true}', + ["mods/faux/main.lua"] = [[ +return function(mod) + mod.content.strings:override("But, it failed!", "It worked!") + mod.content.pokemon:patch("PIKA", { baseStats = { attack = 200 } }) +end +]], +}) +eq(Handshake.onlineAllowed(fakeLanguageGame), false, + "a self-declared translation that patches a species is still blocked") +eq(#Handshake.onlineBlockers(fakeLanguageGame), 1, + "and the mod manager restart prompt names it") + -- ------- builtin records are private per dataset -- two independent loads must not share record tables: an edit through one diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index eac4d93c..0c744950 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -1084,6 +1084,36 @@ ms:deleteProfile(easy) check(ms:findProfile("HARD") == nil and ms:optionsTable().activeProfile == nil, "delete clears the profile") +-- #593: a profile carries mod options and the per-version save slot, and +-- round-trips through the .g1rmodlist export +local ModProfile = require("src.mods.ModProfile") +ms:setOption("okmod", "hardcore", true) +ms:saveCurrentAs() +mgame.stack:top().onDone("SHARE") +mgame.stack:pop() +local shared = ms:findProfile("SHARE") +check(shared.options.okmod.hardcore == true, + "a profile snapshots per-mod options, not just the enable set") +ms:setOption("okmod", "hardcore", false) +ms:applyProfile(shared) +check(loader.modOptions.okmod.hardcore == true, + "applying a profile restores its mod options") +local wire = ModProfile.encode(shared) +local back = ModProfile.decode(wire) +check(back and back.name == "SHARE" and back.options.okmod.hardcore == true, + "a .g1rmodlist body round-trips through the data-only parser") +check(ModProfile.decode("return {}") == nil, "a non-modlist file is refused") +check(#ModProfile.missingIds({ enabled = { ghost = true } }, ms.byId) == 1, + "a profile naming an uninstalled mod reports it missing") +local seedOpts = { modProfiles = {} } +ModProfile.ensureFirst(seedOpts, ms.status.available, {}) +check(#seedOpts.modProfiles == 1 and seedOpts.modProfiles[1].name == "PROFILE 1" + and seedOpts.modProfilesSeeded == true, + "the pre-profiles setup migrates into PROFILE 1 once") +seedOpts.modProfiles = {} +ModProfile.ensureFirst(seedOpts, ms.status.available, {}) +check(#seedOpts.modProfiles == 0, "seeding never runs twice") + -- permissions rows local permy = manifest("permy", { permissions = { "network" } }) local msP = ManagerState.new(managerGame(fakeLoader({ permy }))) diff --git a/tests/parity_H.lua b/tests/parity_H.lua index 2b6a5cbe..5d519013 100644 --- a/tests/parity_H.lua +++ b/tests/parity_H.lua @@ -40,9 +40,12 @@ end check(sf.SEAFOAM_ISLANDS_B3F and #sf.SEAFOAM_ISLANDS_B3F.holes == 2, "SEAFOAM_ISLANDS_B3F still has its own 2 holes (B3F->B4F, unrelated to this change)") --- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry +-- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry. +-- The map DOES own an onStep now (the player's hole falls, #599), so the +-- assertion is about onEnter specifically, not about the map table. local seafoamScripts = require("data.scripts.seafoam") -check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil, +check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil + or seafoamScripts.SEAFOAM_ISLANDS_B2F.onEnter == nil, "data/scripts/seafoam.lua no longer hardcodes a SEAFOAM_ISLANDS_B2F onEnter hook") -- === (2) functional 1F -> B1F -> B2F -> B3F -> B4F cascade === diff --git a/tests/parity_field_move_layering.lua b/tests/parity_field_move_layering.lua index 6d39964e..d5c3bbf3 100644 --- a/tests/parity_field_move_layering.lua +++ b/tests/parity_field_move_layering.lua @@ -160,7 +160,11 @@ ow.player.surfing = false -- ===================================================================== -- .flash: `xor a / ld [wMapPalOffset], a` is undone before PrintText in -- the asm, but the map is not on screen then -- the party menu is -- so --- the lit cave may only appear once the blink hands the screen back +-- the lit cave may only appear once the blink hands the screen back. +-- The lighting itself happens as the message closes the menu, ahead of +-- the blink like the asm: in ADVANCED that call rebakes every resident +-- map, and behind the blink's completion that cost was spent with a +-- blank white frame as the newest thing on screen (#610). -- ===================================================================== Game.save.flashLit = false Game.save.party = { mkMon("PIKACHU", "FLASH") } @@ -174,7 +178,9 @@ 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") drainOne() check(isBlink(Game.stack:top()), "the blink follows the FLASH message") -eq(ow.dark, true, "the tunnel is still dark when the blink starts") +-- lit before the blink is pushed, so no rebuild can run while the white +-- frame is the newest one presented (#610); the blink hides it either way +eq(ow.dark, false, "the tunnel is lit by the time the blink starts") settle(ow) eq(Game.stack:top(), ow, "FLASH ends on the map") eq(ow.dark, false, "the tunnel is lit once the blink hands the map back") diff --git a/tests/parity_flash_blink_bug610.lua b/tests/parity_flash_blink_bug610.lua new file mode 100644 index 00000000..161d52ae --- /dev/null +++ b/tests/parity_flash_blink_bug610.lua @@ -0,0 +1,90 @@ +-- Parity test: nothing expensive may ride the FLASH white-out's completion +-- callback (#610). Transition.WhiteFlash is opaque and paints the whole +-- 160x144 solid white, so whatever runs from its onDone runs with a blank +-- white frame as the newest thing the player has been shown. Lighting the +-- cave is exactly that kind of work: in the ADVANCED colour mode +-- OverworldState:setDark drops every resident map and rebakes the tileset +-- atlas pixel by pixel (#383), which on a phone is seconds of frozen white +-- and reads as a lockup. engine/menus/start_sub_menus.asm .flash clears +-- wMapPalOffset before PrintText and calls GBPalWhiteOutWithDelay3 last of +-- all, so the port lights the cave as the message closes the menu and +-- leaves the blink a plain 7-frame blink with no work attached. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity flash blink bug610") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local PartyMenu = require("src.ui.PartyMenu") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() + +local function frame(btns) + Input.pressed = {} + for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end + StateStack:update(1 / 60) + for _, b in ipairs(btns or {}) do Input.state[b] = false end +end +local function popAll() while Game.stack:top() do Game.stack:pop() end end +local function mkMon(species, ...) + local m = Pokemon.new(Data, species, 20) + m.moves = {} + for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end + return m +end +-- dismiss exactly one box, leaving whatever it pushed on top +local function drainOne() + local box = Game.stack:top() + local guard = 0 + while Game.stack:top() == box and guard < 400 do + guard = guard + 1 + frame({ "a" }) + end +end + +Game.save.flashLit = false +Game.save.party = { mkMon("PIKACHU", "FLASH") } +Game.save.inventory = { BOULDERBADGE = true } +popAll() +Game.stack:push(OW, "ROCK_TUNNEL_1F", 15, 4, "down") +local ow = Game.stack:top() +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 +drainOne() -- dismiss _FlashLightsAreaText + +local blink = Game.stack:top() +check(blink ~= nil and blink.isOpaque == true and type(blink.frames) == "number", + "the blink follows the FLASH message") +-- the invariant this test exists for: the blink carries no work +eq(blink.onDone, nil, "the FLASH blink has no completion callback (#610)") +eq(ow.dark, false, "the cave is already lit when the blink starts") + +local guard = 0 +while Game.stack:top() ~= ow and guard < 240 do + guard = guard + 1 + frame({}) +end +eq(Game.stack:top(), ow, "FLASH ends on the map") +eq(ow.dark, false, "the cave stays lit after the blink") +eq(Game.save.flashLit, true, "FLASH is recorded on the save") + +popAll() +S.finish() diff --git a/tests/parity_seafoam_holes.lua b/tests/parity_seafoam_holes.lua new file mode 100644 index 00000000..8de99096 --- /dev/null +++ b/tests/parity_seafoam_holes.lua @@ -0,0 +1,71 @@ +-- Parity test: the Seafoam Islands floor holes drop the PLAYER a floor, +-- not just the boulders (#599). +-- +-- scripts/SeafoamIslands1F.asm / B1F.asm / B2F.asm / B3F.asm each set +-- wDungeonWarpDestinationMap and call IsPlayerOnDungeonWarp with their +-- SeafoamNHolesCoords list; data/maps/special_warps.asm DungeonWarpList / +-- DungeonWarpData turn (destination map, wCoordIndex) into the landing +-- cell. CAVERN $22 is walkable, so without an onStep the player just +-- stood on the hole. +-- +-- Self-contained; run via `luajit tests/parity_seafoam_holes.lua`. +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 seafoam holes") +local check, eq = S.check, S.eq + +local M = require("data.scripts.seafoam") + +local function owRecording() + local warps = {} + return { + player = { facing = "up" }, + startWarpTo = function(_, mapId, x, y, facing) + warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing } + end, + }, warps +end + +-- hole cell -> map, landing cell (DungeonWarpData) +local CASES = { + { "SEAFOAM_ISLANDS_1F", 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 }, + { "SEAFOAM_ISLANDS_1F", 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 }, + { "SEAFOAM_ISLANDS_B1F", 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 }, + { "SEAFOAM_ISLANDS_B1F", 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 }, + { "SEAFOAM_ISLANDS_B2F", 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 }, + { "SEAFOAM_ISLANDS_B2F", 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 }, + { "SEAFOAM_ISLANDS_B3F", 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 }, + { "SEAFOAM_ISLANDS_B3F", 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 }, +} + +for _, c in ipairs(CASES) do + local hooks = M[c[1]] + check(hooks ~= nil and hooks.onStep ~= nil, c[1] .. " has an onStep hole trigger") + local ow, warps = owRecording() + check(hooks.onStep({}, ow, c[2], c[3]), c[1] .. " consumes the hole step") + eq(#warps, 1, c[1] .. " fires exactly one fall") + eq(warps[1].mapId, c[4], c[1] .. " falls to " .. c[4]) + eq(warps[1].x, c[5], c[1] .. " lands at x=" .. c[5]) + eq(warps[1].y, c[6], c[1] .. " lands at y=" .. c[6]) + eq(warps[1].facing, "up", "facing is preserved across the fall") +end + +-- a neighboring floor cell is ignored +do + local ow, warps = owRecording() + eq(M.SEAFOAM_ISLANDS_1F.onStep({}, ow, 18, 6), false, "a floor cell is ignored") + eq(#warps, 0, "no fall fires off the hole") +end + +-- the hole tiles stay walkable CAVERN holes, and are not map warp events +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.SEAFOAM_ISLANDS_1F) then Data:load() end +local MapLoader = require("src.world.MapLoader") +for _, c in ipairs(CASES) do + local map = MapLoader.load(Data, c[1]) + check(map:isWalkableCell(c[2], c[3]), c[1] .. " hole tile is walkable") + eq(map:warpPadOrHoleAt(c[2], c[3]), "hole", c[1] .. " collision tile is CAVERN $22") + check(map:warpAtCell(c[2], c[3]) == nil, c[1] .. " hole is not a map warp event") +end + +S.finish() diff --git a/tests/parity_static.lua b/tests/parity_static.lua index 79bd4532..fa6f7f46 100644 --- a/tests/parity_static.lua +++ b/tests/parity_static.lua @@ -265,6 +265,47 @@ Flags.set(Game.save, "EVENT_BEAT_ROUTE12_SNORLAX") result = ItemEffects.use(Data, Game.save, "POKE_FLUTE", nil, nil, nil, owAdjacent) eq(result, "flute_field", "an already-beaten Snorlax doesn't wake again") +-- === 9) a beaten Snorlax never stands in the road again: the routes' +-- onEnter reconciles the object toggle from EVENT_BEAT_ROUTEnn_SNORLAX, +-- so a save that lost the toggle (mod toggle, old build) is repaired +-- on entry instead of sealing the route -- the flute refuses to wake +-- a beaten Snorlax, which is what left #585 stuck === +local OverworldState = require("src.world.OverworldController") +local function objOf(mapId, objName) + for _, obj in ipairs(Data.maps[mapId].objects or {}) do + if obj.name == objName then return obj end + end +end + +for _, route in ipairs({ + { "ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX" }, + { "ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX" }, +}) do + local mapId, objName, beatFlag = route[1], route[2], route[3] + local enter = mapScripts.get(mapId).onEnter + check(type(enter) == "function", mapId .. " has an onEnter hook") + enter = type(enter) == "function" and enter or function() end + + -- unbeaten: entering must not remove the sleeper + local save = SaveData.newGame() + enter({ save = save }, nil) + eq(save.objectToggles and save.objectToggles[mapId] + and save.objectToggles[mapId][objName], nil, + objName .. " is left alone while it has not been beaten") + check(OverworldState.objectVisible(save, mapId, objOf(mapId, objName)), + objName .. " still spawns before the flute wakes it") + + -- beaten but still toggled visible (the #585 save state): repaired + save.flags[beatFlag] = true + save.objectToggles = save.objectToggles or {} + save.objectToggles[mapId] = { [objName] = true } + enter({ save = save }, nil) + eq(save.objectToggles[mapId][objName], false, + objName .. " is hidden again once " .. beatFlag .. " is set") + check(not OverworldState.objectVisible(save, mapId, objOf(mapId, objName)), + objName .. " no longer spawns on a beaten route") +end + -- restore the real commands for later suites Commands.show_text = origShow Commands.start_battle = origStart diff --git a/tests/parity_viridian_school_blackboard_bug503.lua b/tests/parity_viridian_school_blackboard_bug503.lua index c2b131c1..c4182037 100644 --- a/tests/parity_viridian_school_blackboard_bug503.lua +++ b/tests/parity_viridian_school_blackboard_bug503.lua @@ -22,7 +22,6 @@ if not pcall(Font.encode, "A") then Font.load(Data) end require("data.scripts.init") local MapScripts = require("src.script.MapScripts") local TextBox = require("src.render.TextBox") -local Menu = require("src.ui.Menu") local S = require("tests.harness").suite("parity Viridian School blackboard/notebook (#503)") local check, eq = S.check, S.eq @@ -69,7 +68,7 @@ for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do ("(%d,%d) is not one of the readables"):format(cell[1], cell[2])) end --- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) === +-- === the blackboard: intro -> held prompt with a two-column list over it === stack = {} eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed") local intro = stack[#stack] @@ -83,16 +82,29 @@ local prompt = stack[#stack] check(getmetatable(prompt) == TextBox and pages(prompt):find("heading", 1, true) ~= nil, "the intro leads into the which-heading prompt") -prompt.onDone() +-- #591: _ViridianSchoolBlackboardText2 ends in `done`, so .blackboardLoop +-- leaves it on screen and runs HandleMenuInput under it; the port used to +-- make the player dismiss it and then hid it while the list was up +check(prompt.onDone == nil and prompt.stay ~= nil, + "the prompt is held open instead of waiting for A and popping") +prompt.stay.onShown() -local menu = stack[#stack] -check(getmetatable(menu) == Menu, "the prompt opens the status menu") -eq(#menu.items, 6, "five statuses plus QUIT") +local board = stack[#stack] +check(getmetatable(board) ~= TextBox and type(board.draw) == "function", + "the held prompt opens the headings list over itself") +eq(stack[#stack - 1], prompt, "the list sits on the still-visible prompt box") local labels = {} -for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end +for i, label in ipairs(board.labels) do labels[i] = label:gsub("^%s+", "") end eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT", "the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last") -eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it") +-- two columns: StatusAilmentText1 at hlcoord 1,2 and StatusAilmentText2 at +-- hlcoord 6,2, RIGHT keeping the row and adding wMenuItemOffset 3 +board.col, board.row = 1, 1 +eq(board:selection(), 1, "top of the left column is SLP") +board.col, board.row = 2, 1 +eq(board:selection(), 4, "RIGHT keeps the row and lands on BRN (offset 3)") +board.col, board.row = 2, 3 +eq(board:selection(), 6, "bottom of the right column is QUIT") local STATUS_KEYS = { "_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText", @@ -100,8 +112,9 @@ local STATUS_KEYS = { "_ViridianBlackboardFrozenText", } for i, key in ipairs(STATUS_KEYS) do - stack = {} - menu.items[i].onSelect() + stack = { prompt, board } -- picking pops the list AND the box under it + board.onPick(i) + eq(#stack, 1, labels[i] .. " clears the list and the prompt it sat on") local blurb = stack[#stack] check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box") local want = Data.text[key]:match("^[^\n\011\012]+") @@ -110,13 +123,32 @@ for i, key in ipairs(STATUS_KEYS) do check(type(blurb.onDone) == "function", labels[i] .. " returns to the prompt instead of dropping out (loops)") blurb.onDone() - check(getmetatable(stack[#stack]) == TextBox, - "the prompt comes back after " .. labels[i]) - stack[#stack].onDone() - check(getmetatable(stack[#stack]) == Menu, - "the menu comes back after " .. labels[i] .. " (loop, not exit)") + local back = stack[#stack] + check(getmetatable(back) == TextBox and back.stay ~= nil, + "the held prompt comes back after " .. labels[i]) + back.stay.onShown() + check(stack[#stack].labels ~= nil, + "the list comes back after " .. labels[i] .. " (loop, not exit)") end +-- #591: wCurrentMenuItem / wMenuItemOffset are zeroed once above +-- .blackboardLoop, so `jp .blackboardLoop` after a blurb comes back with the +-- cursor on the row and column that was just picked (read FRZ, land on FRZ) +stack = { prompt, board } +board.col, board.row = 2, 2 +eq(board:selection(), 5, "right column, middle row is FRZ") +board.onPick(5) +stack[#stack].onDone() +stack[#stack].stay.onShown() +eq(stack[#stack], board, "the same headings list comes back, not a fresh one") +eq(board.col, 2, "the column survives the blurb (wMenuItemOffset is not recleared)") +eq(board.row, 2, "the row survives the blurb (wCurrentMenuItem is not recleared)") + +-- QUIT and B share .exitBlackboard: both close the list and the prompt +stack = { prompt, board } +board.onQuit() +eq(#stack, 0, "QUIT/B closes the headings list and the prompt box together") + -- === the notebook: five pages, three yes/no turns, then the girl catches you == stack = {} eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed") diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index 79dc7b36..c0437c36 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -108,5 +108,82 @@ PikachuFollower.onBillExitedMachine(yellowGame, ow) check(ow.emote and ow.emote.bubble == 2 and npc.facing == "left", "Pikachu reacts when Bill comes back out") +-- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156) is the +-- other side of BillsHouseScript2: it only runs while Pikachu still follows +-- the player, and TryApplyPikachuMovementData keys each table on +-- GetPikachuFacingDirectionAndReturnToE, which is Pikachu's position +-- relative to the player rather than its facing byte, so WatchPlayer1 wants +-- Pikachu above the player and WatchPlayer2 wants it level and east (#455). +local function placePikachu(cx, cy) + npc.cellX, npc.cellY, npc.facing = cx, cy, "down" +end + +ow.pikachuBillsScene = nil +moves = {} +placePikachu(ow.player.cellX, ow.player.cellY - 1) +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +check(#moves == 1 and moves[1].dir == "left" and moves[1].tiles == 1, + "a Pikachu standing above the player steps aside for Bill's detour") +moves[1].onDone() +check(#moves == 2 and moves[2].dir == "down" and moves[2].tiles == 1, + "the watch route drops clear of Bill's detour") +moves[2].onDone() +check(npc.facing == "right", "PIKAMOVEMENT_LOOK_RIGHT watches the player") + +moves = {} +placePikachu(ow.player.cellX + 1, ow.player.cellY) +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +check(#moves == 1 and moves[1].dir == "up" and moves[1].tiles == 1, + "level with and east of the player takes the longer WatchPlayer2 route") +moves[1].onDone() +check(#moves == 2 and moves[2].dir == "left" and moves[2].tiles == 2, + "WatchPlayer2 crosses two cells west") +moves[2].onDone() +check(#moves == 3 and moves[3].dir == "down" and moves[3].tiles == 1, + "WatchPlayer2 drops back level with the player") +moves[3].onDone() +check(npc.facing == "right", "WatchPlayer2 ends watching the player too") + +-- below the player is SPRITE_FACING_DOWN and west of it is +-- SPRITE_FACING_LEFT, and neither call asks for those +moves = {} +placePikachu(ow.player.cellX, ow.player.cellY + 1) +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +check(#moves == 0, "a Pikachu trailing from the south has no watch route") + +placePikachu(ow.player.cellX - 1, ow.player.cellY) +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +check(#moves == 0, "nor does one already standing west of the player") + +-- ApplyPikachuMovementData_ finishes a table before the second +-- TryApplyPikachuMovementData reads the geometry again, but WatchPlayer1 +-- lands on (playerX - 1, playerY), so WatchPlayer2 can never chain onto it +moves = {} +placePikachu(ow.player.cellX, ow.player.cellY - 1) +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +moves[1].onDone() +moves[2].onDone() +placePikachu(ow.player.cellX - 1, ow.player.cellY) +moves = {} +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +check(#moves == 0, "the two tables stay mutually exclusive by geometry") + +-- the parked Pikachu of the confused beat is the not-following side of +-- CheckPikachuFollowingPlayer, so it never watches +ow.pikachuBillsScene = true +placePikachu(ow.player.cellX, ow.player.cellY - 1) +PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow) +check(#moves == 0, "the parked Pikachu of the confused beat stays put") + +-- BillsHouseScript0 skips the whole entry beat for a statused starter +-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46) +ow.pikachuBillsScene = nil +moves = {} +yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } } +PikachuFollower.onBillsHouseEnter(yellowGame, ow) +check(not ow.pikachuBillsScene and #moves == 0, + "a statused starter keeps following instead of walking over to Bill") +yellowGame.save.party = nil + GameVersion.set("red") S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 5443e196..763893c0 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1699,8 +1699,10 @@ do local SafeArea = require("src.core.SafeArea") local cfg = TC.normalizeConfig(nil) eq(cfg.enabled, true, "touchControls default enabled") - check(cfg.positions == nil, "touchControls default positions nil") + check(cfg.layouts.portrait.positions == nil, "touchControls default positions nil") + eq(cfg.layouts.landscape.scale, 1, "touchControls default scale 1") + -- pre-#633 file: one positions table seeds both orientations, as copies cfg = TC.normalizeConfig({ enabled = false, positions = { @@ -1711,11 +1713,15 @@ do }, }) eq(cfg.enabled, false, "normalizeConfig keeps enabled=false") - eq(cfg.positions.dpad.x, 1, "normalizeConfig clamps x high") - eq(cfg.positions.dpad.y, 0, "normalizeConfig clamps y low") - eq(cfg.positions.a.x, 0.5, "normalizeConfig keeps a") - check(cfg.positions.junk == nil, "normalizeConfig drops unknown controls") - check(cfg.positions.b == nil, "normalizeConfig drops non-numeric") + local pcfg, lcfg = cfg.layouts.portrait, cfg.layouts.landscape + eq(pcfg.positions.dpad.x, 1, "normalizeConfig clamps x high") + eq(pcfg.positions.dpad.y, 0, "normalizeConfig clamps y low") + eq(pcfg.positions.a.x, 0.5, "normalizeConfig keeps a") + check(pcfg.positions.junk == nil, "normalizeConfig drops unknown controls") + check(pcfg.positions.b == nil, "normalizeConfig drops non-numeric") + eq(lcfg.positions.dpad.x, 1, "legacy positions seed landscape too") + check(pcfg.positions ~= lcfg.positions, + "orientations never share one positions table (#633)") local L = TC.defaultLayout(400, 800) check(L.dpad.cx < 200, "default d-pad on left half") diff --git a/tests/save_editor_wheel_bug595_test.lua b/tests/save_editor_wheel_bug595_test.lua new file mode 100644 index 00000000..9c0b2485 --- /dev/null +++ b/tests/save_editor_wheel_bug595_test.lua @@ -0,0 +1,64 @@ +-- #595: the save editor's ADD ITEM list was reachable only by typing. +-- Kit.scroll is the shared wheel handler behind the fix; it takes a notch +-- only when the pointer is inside the list body, consumes it so two stacked +-- lists cannot both move, clamps to the last page, and honours the modal +-- shield. Standalone like the other panel suites (see the header of +-- tests/run_save_editor_tests.lua): +-- luajit tests/save_editor_wheel_bug595_test.lua + +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love or love_stub + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Kit = require("Kit") + +Kit.beginFrame(50, 50, false, -1) +eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 3, + "a wheel notch scrolls the list under the pointer") +eq(Kit.wheelY, 0, "and is consumed, so only one list moves") +Kit.endFrame() + +Kit.beginFrame(50, 50, false, 1) +eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 0, "wheel up scrolls back") +Kit.endFrame() + +Kit.beginFrame(50, 50, false, -1) +eq(Kit.scroll(0, 0, 100, 100, 245, 250, 10), 240, + "the last page is the floor of the list") +Kit.endFrame() + +Kit.beginFrame(500, 500, false, -1) +eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0, + "a list the pointer is not over ignores the wheel") +Kit.endFrame() + +Kit.beginFrame(50, 50, false, -1) +eq(Kit.scroll(0, 0, 100, 100, 0, 8, 10), 0, "a list that fits cannot scroll") +Kit.endFrame() + +Kit.beginFrame(50, 50, false, -1) +Kit.blockClicks = true +eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0, + "the modal shield stops the wheel exactly as it stops a click") +Kit.blockClicks = false +Kit.endFrame() +eq(Kit.wheelY, 0, "an unclaimed notch retires with the frame") + +-- The wheel has to reach the Items lists without touching the map camera, +-- which is the only thing App.wheelmoved used to drive (#595). Loading the +-- whole editor needs data/generated/, so pin the routing at the source seam +-- instead: App.wheelmoved must queue notches for Kit on every non-map tab. +local f = assert(io.open("tools/save-editor/App.lua", "r")) +local src = f:read("*a") +f:close() +check(src:find("wheelY = wheelY + (y or 0)", 1, true) ~= nil, + "App.wheelmoved queues notches for Kit on non-map tabs (#595)") +check(src:find("Kit.beginFrame(mx, my, mouseClicked, wheelY)", 1, true) ~= nil, + "App.draw hands the queued notches to Kit.beginFrame (#595)") + +T.finish("save_editor_wheel_bug595") diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index ce3a0feb..9f6f75fd 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -41,6 +41,10 @@ local S -- vanilla records over an already-merged Data local mods local mouseClicked = false +-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit +-- there like mouseClicked is: LOVE delivers events before love.draw, so a +-- notch is always spent by the frame that follows it (#595). +local wheelY = 0 -- Which game's cache Data was loaded from. main.lua checks this before -- opening the editor on a save from the other version, because the two @@ -602,7 +606,7 @@ local function drawStatusBar(x, y, w, h) and (ctrl .. "+S save . " .. ctrl .. "+R reload . Esc clear selection . Close returns to the launcher") or (ctrl .. "+S save . " .. ctrl .. - "+R reload . Esc clear selection . arrows pan map . wheel zoom") + "+R reload . Esc clear selection . arrows pan map . wheel scrolls lists") local hintW = Kit.textWidth("tiny", hint) Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint) local avail = w - 2 * pad - hintW - 14 * s @@ -620,8 +624,9 @@ function App.draw() local s = Kit.scale local mx, my = love.mouse.getPosition() - Kit.beginFrame(mx, my, mouseClicked) + Kit.beginFrame(mx, my, mouseClicked, wheelY) mouseClicked = false + wheelY = 0 -- Modal shield. Kit has no z-order, so the picker cannot simply be drawn -- last: the chrome and the panel underneath would take the same tap. The -- shield goes up before anything dispatches and comes down only for the @@ -699,9 +704,13 @@ end function App.wheelmoved(x, y) if not S then return end + -- The map tab spends the wheel on zoom; every other tab routes it through + -- Kit so whichever list the pointer is over takes it next draw (#595). if S.tab == "map" and MapBrowser.wheelmoved then MapBrowser.wheelmoved(S, y) + return end + wheelY = wheelY + (y or 0) end function App.quit() diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 8b99fc7f..9890e824 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -1,10 +1,12 @@ -- Immediate-mode widget kit for the save editor, drawn in the launcher's -- visual language (see Theme.lua and SaveEditor.dc.html). -- --- Call Kit.beginFrame(mx, my, clicked) once per love.draw() before any widget --- and Kit.endFrame() after the last one; widgets read the frame's mouse state --- to decide hover / click, and endFrame retires the text-input queue so a --- keystroke is never applied twice. +-- Call Kit.beginFrame(mx, my, clicked, wheel) once per love.draw() before any +-- widget and Kit.endFrame() after the last one; widgets read the frame's mouse +-- state to decide hover / click, and endFrame retires the text-input queue so a +-- keystroke is never applied twice. The wheel notches accumulated since the +-- last frame arrive the same way and are retired the same way: an unclaimed +-- notch dies with the frame rather than scrolling something later (#595). -- -- Hit testing is a plain rect with no z-order, so panels must draw -- overlapping controls in dispatch order and every target is >= 26px tall @@ -17,6 +19,7 @@ local PAL = Theme.PAL local Kit = {} Kit.mouseX, Kit.mouseY = 0, 0 Kit.mouseClicked = false -- left button pressed this frame +Kit.wheelY = 0 -- wheel notches queued since the last frame (#595) Kit.focus = nil -- id of the text field receiving keystrokes Kit.time = 0 Kit.fonts = {} @@ -58,9 +61,10 @@ local function canPrintf() return G and type(G.printf) == "function" end -function Kit.beginFrame(mx, my, clicked) +function Kit.beginFrame(mx, my, clicked, wheel) Kit.mouseX, Kit.mouseY = mx, my Kit.mouseClicked = clicked + Kit.wheelY = wheel or 0 if love and love.timer and love.timer.getTime then Kit.time = love.timer.getTime() end @@ -68,8 +72,10 @@ end -- Retire this frame's keystrokes. Anything typed while no field had focus is -- dropped here rather than replayed into the next field that gets clicked. +-- A wheel notch no list claimed retires with them, for the same reason. function Kit.endFrame() for i = #edits, 1, -1 do edits[i] = nil end + Kit.wheelY = 0 end -- Rebuild the font set when the window size changes. `s` matched the @@ -427,6 +433,29 @@ function Kit.pager(x, y, w, offset, total, perPage) return offset, h end +-- ----------------------------------------------------------------- scroll +-- Mouse wheel over a list body (#595): same offset contract as Kit.pager, so +-- a list can carry both and stay on one page counter. Three rules, all of +-- them consequences of Kit having no z-order: +-- * only the list the pointer is inside takes the notch, +-- * the notch is consumed, so two stacked lists cannot both eat it, +-- * Kit.blockClicks shields it exactly as it shields Kit.press, or the +-- panel under an open species picker would scroll through the modal. +local SCROLL_ROWS = 3 + +function Kit.scroll(x, y, w, h, offset, total, perPage) + local maxOffset = math.max(0, (total or 0) - (perPage or 0)) + offset = Theme.clamp(offset or 0, 0, maxOffset) + if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return offset end + if not Kit.hit(x, y, w, h) then return offset end + -- LOVE reports wheel-up as positive y; up moves the window toward the top + -- of the list, which is a smaller offset. + local rows = math.max(1, math.min(SCROLL_ROWS, perPage or SCROLL_ROWS)) + local step = (Kit.wheelY > 0) and -rows or rows + Kit.wheelY = 0 + return Theme.clamp(offset + step, 0, maxOffset) +end + -- Clip drawing to a rect (list bodies). No-ops under the headless stub. function Kit.pushClip(x, y, w, h) -- A compact mobile viewport can leave a panel with no room for a list. diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 5da15470..aeba4fb9 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -55,6 +55,7 @@ function State.new() selectedItemId = nil, selectedBagId = nil, selectedPcId = nil, + itemPickOffset = 0, -- scroll position in the ADD ITEM list (#595) bagOffset = 0, pcOffset = 0, diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index ae1f9f68..3a40b68c 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -4,9 +4,10 @@ -- -- The picker is a searchable list rather than the old pair of arrows that -- cycled one id at a time through ~250 items, which was the single worst --- interaction in the editor. Badges sit in the wallet column as toggle --- chips because they are boolean inventory flags, not stackable items, and --- must not look like quantity rows. +-- interaction in the editor. It scrolls under the mouse wheel too (#595): +-- typing used to be the only way to reach an id past the first screenful. +-- Badges sit in the wallet column as toggle chips because they are boolean +-- inventory flags, not stackable items, and must not look like quantity rows. local Bag = require("src.inventory.Bag") local Theme = require("Theme") @@ -97,8 +98,12 @@ function M.draw(S, Kit, x, y, w, h) Kit.card(x, pickY, leftW, pickH) Kit.caption(x + pad, pickY + pad, "ADD ITEM") local qy = pickY + pad + Kit.textHeight("caption") + 8 * s + local prevQuery = S.itemQuery or "" S.itemQuery = Kit.textfield("item-query", x + pad, qy, leftW - 2 * pad, 32 * s, S.itemQuery or "", "search item ids...") + -- a new query is a new list: keep the first hit on screen rather than + -- leaving the view parked wherever the old result set had scrolled to + if S.itemQuery ~= prevQuery then S.itemPickOffset = 0 end local choices = {} for _, id in ipairs(S.cat.items) do @@ -117,9 +122,14 @@ function M.draw(S, Kit, x, y, w, h) local cRowH = 28 * s local cGap = 5 * s local visible = math.max(1, math.floor((listBottom - listTop) / (cRowH + cGap))) + -- #595: the wheel drives the same offset a pager would, so the whole + -- catalog is reachable with the mouse alone. Kit.scroll clamps, which is + -- also what pulls the view back when a narrower query shortens the list. + S.itemPickOffset = Kit.scroll(x + pad, listTop, leftW - 2 * pad, + listBottom - listTop, S.itemPickOffset or 0, #choices, visible) Kit.pushClip(x + pad, listTop, leftW - 2 * pad, listBottom - listTop) - for i = 1, math.min(visible, #choices) do - local id = choices[i] + for i = 1, math.min(visible, #choices - S.itemPickOffset) do + local id = choices[S.itemPickOffset + i] local ry = listTop + (i - 1) * (cRowH + cGap) if Kit.row(x + pad, ry, leftW - 2 * pad, cRowH, id == S.selectedItemId, PAL.green, 8 * s) then @@ -130,10 +140,11 @@ function M.draw(S, Kit, x, y, w, h) x + pad + 10 * s, ry + (cRowH - Kit.textHeight("mono")) / 2, PAL.text) end Kit.popClip() - -- the overflow count rides the caption line, where it can never collide + -- the position counter rides the caption line, where it can never collide -- with the list body or the two add buttons below it if #choices > visible then - Kit.textRight("micro", ("+%d more"):format(#choices - visible), + Kit.textRight("micro", ("%d-%d of %d"):format(S.itemPickOffset + 1, + math.min(S.itemPickOffset + visible, #choices), #choices), x + leftW - pad, pickY + pad, PAL.faint) elseif #choices == 0 then Kit.text("mono", "no item matches", x + pad + 10 * s, listTop + 8 * s, PAL.faint) @@ -195,6 +206,9 @@ function M.draw(S, Kit, x, y, w, h) local rowGap = 6 * s local perPage = math.max(1, math.floor((pagerY - 12 * s - rowsTop) / (rowH + rowGap))) S.bagOffset = Ops.clamp(S.bagOffset or 0, 0, math.max(0, #order - perPage)) + -- the wheel moves the same offset the pager below does (#595) + S.bagOffset = Kit.scroll(bagX + pad, rowsTop, listW - 2 * pad, + pagerY - 12 * s - rowsTop, S.bagOffset, #order, perPage) if #order == 0 then Kit.emptyBox(bagX + pad, rowsTop, listW - 2 * pad, 70 * s, "Bag is empty.") @@ -221,6 +235,8 @@ function M.draw(S, Kit, x, y, w, h) Kit.textRight("mono", ("%d kinds"):format(#pcOrder), pcX + listW - pad, y + pad, PAL.caption) S.pcOffset = Ops.clamp(S.pcOffset or 0, 0, math.max(0, #pcOrder - perPage)) + S.pcOffset = Kit.scroll(pcX + pad, rowsTop, listW - 2 * pad, + pagerY - 12 * s - rowsTop, S.pcOffset, #pcOrder, perPage) if #pcOrder == 0 then Kit.emptyBox(pcX + pad, rowsTop, listW - 2 * pad, 70 * s, "PC storage is empty. Items sent here have no slot cap.")