From 8c8581ed0c16eb820c017598e525e39dd0e49699 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 16 Sep 2026 09:45:20 -0400 Subject: [PATCH] CLOSES #2089, CLOSES #2198, CLOSES #2227, CLOSES #2246, CLOSES #2277, CLOSES #2278, CLOSES #2280, CLOSES #2281, CLOSES #2282, CLOSES #2283, CLOSES #2286 --- data/scripts/story2.lua | 135 +++---- data/scripts/story3.lua | 34 +- .../java/org/love2d/android/GameActivity.java | 38 +- src/battle/BattleState.lua | 20 +- src/battle/MoveEffects.lua | 24 +- src/core/ChipAudio.lua | 1 + src/core/Game.lua | 103 ++--- src/core/Game2.lua | 101 +++-- src/core/GamepadMap.lua | 11 +- src/core/Input.lua | 38 +- src/core/Music.lua | 28 +- src/core/SaveData.lua | 1 + src/import/LauncherView.lua | 25 +- src/import/RomImporter.lua | 105 +++++- src/link/LinkState.lua | 154 ++++++-- src/mods/Schemas.lua | 1 + src/render/CableFrame.lua | 76 ++++ src/render/ShaderFX.lua | 51 ++- src/save_convert/Gen2Layout.lua | 60 +++ src/save_convert/Gen2MapContext.lua | 140 ++++++- src/save_convert/Gen2Save.lua | 130 ++++++- src/ui/BindingsMenu.lua | 3 + src/ui/Menu.lua | 12 +- src/ui/PartyMenu.lua | 9 +- src/ui/ShaderFXScreen.lua | 3 + src/ui/TradeAnim.lua | 139 +++++-- src/world/gen2/World.lua | 39 +- tests/drivers/fossil_menu_test.lua | 54 ++- .../gen2_bike_warp_music_bug2286_test.lua | 121 ++++++ tests/drivers/gen2_surf_palette_2280_test.lua | 51 +++ tests/drivers/heal_test.lua | 12 +- tests/drivers/heal_visual_probe.lua | 93 +++++ tests/drivers/hypnosis_miss_bug2282_test.lua | 80 ++++ tests/drivers/lift_key_sight_2277.lua | 122 ++++++ tests/drivers/link_trade_round2_2198_test.lua | 130 +++++++ tests/drivers/link_trade_screen_2198_test.lua | 80 ++++ tests/drivers/shaderfx_portable_paths.lua | 44 +++ tests/drivers/trade_anim_test.lua | 110 ++++-- tests/engine/audio_device_reset.lua | 55 +++ tests/engine/gen2_bike_warp_music_bug2286.lua | 125 +++++++ .../engine/gen2_controller_parity_bug1570.lua | 25 ++ .../engine/gen2_crystal_surf_palette_2280.lua | 79 ++++ tests/engine/gen2_fresh_export_2283.lua | 353 ++++++++++++++++++ tests/engine/gen2_save_export_map_objects.lua | 130 ++++++- tests/engine/gen2_save_import.lua | 37 +- tests/engine/gen2_save_import_message.lua | 18 +- tests/engine/launcher_update_all.lua | 163 +++++++- tests/engine/menu_keep_open_2281.lua | 92 +++++ tests/engine/pad_speed_binding_bug1922.lua | 51 ++- tests/engine/shaderfx_no_bridge_ui.lua | 13 +- tests/engine/shaderfx_portable_paths_2227.lua | 90 +++++ .../engine/speed_shoulders_triggers_test.lua | 60 ++- tests/engine/trade_anim_parity_2278.lua | 129 +++++++ tests/gen2_save_convert_cli_test.lua | 11 +- tests/gen2_save_export_test.lua | 40 +- tests/parity_E.lua | 65 +++- tests/parity_hof.lua | 7 +- tests/parity_lift_key_yellow_bug552.lua | 60 +++ tests/parity_status_move_failure.lua | 135 +++++++ tests/run_link_tests.lua | 11 + tools/gen2_sram_offsets.py | 17 +- 61 files changed, 3706 insertions(+), 438 deletions(-) create mode 100644 src/render/CableFrame.lua create mode 100644 tests/drivers/gen2_bike_warp_music_bug2286_test.lua create mode 100644 tests/drivers/gen2_surf_palette_2280_test.lua create mode 100644 tests/drivers/heal_visual_probe.lua create mode 100644 tests/drivers/hypnosis_miss_bug2282_test.lua create mode 100644 tests/drivers/lift_key_sight_2277.lua create mode 100644 tests/drivers/link_trade_round2_2198_test.lua create mode 100644 tests/drivers/link_trade_screen_2198_test.lua create mode 100644 tests/drivers/shaderfx_portable_paths.lua create mode 100644 tests/engine/gen2_bike_warp_music_bug2286.lua create mode 100644 tests/engine/gen2_crystal_surf_palette_2280.lua create mode 100644 tests/engine/gen2_fresh_export_2283.lua create mode 100644 tests/engine/menu_keep_open_2281.lua create mode 100644 tests/engine/shaderfx_portable_paths_2227.lua create mode 100644 tests/engine/trade_anim_parity_2278.lua create mode 100644 tests/parity_status_move_failure.lua diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 2105162b..2501b199 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -907,76 +907,85 @@ M.CINNABAR_LAB_FOSSIL_ROOM = { -- No fossil deposited yet: the intro always plays first (.Text), -- then either the fossil-select menu (GiveFossilToCinnabarLab) -- or NoFossilsText. - game.stack:push(TextBox.new(game, - t._CinnabarLabFossilRoomScientist1Text or - "Hiya!\fI am important\ndoctor!\fI study here rare\nPOKéMON fossils!\fYou! Have you a\nfossil for me?", - function() - -- Lab4Script_GetFossilsInBag: every carried fossil, in - -- FossilsList order - local carried = {} - for _, fossil in ipairs(FOSSIL_ORDER) do - if (game.save.inventory[fossil] or 0) > 0 then - carried[#carried + 1] = fossil - end - end - if #carried == 0 then + local introText = t._CinnabarLabFossilRoomScientist1Text or + "Hiya!\fI am important\ndoctor!\fI study here rare\nPOKéMON fossils!\fYou! Have you a\nfossil for me?" + + local carried = {} + for _, fossil in ipairs(FOSSIL_ORDER) do + if (game.save.inventory[fossil] or 0) > 0 then + carried[#carried + 1] = fossil + end + end + + if #carried == 0 then + game.stack:push(TextBox.new(game, introText, function() + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1NoFossilsText or + "No! Is too bad!", done)) + end)) + return + end + + local Menu = require("src.ui.Menu") + local menu + -- home/text_script.asm:105 + local function finish() + if menu then game.stack:pop() end + game.stack:pop() + done() + end + local function comeAgain() + game.stack:push(TextBox.new(game, + t._CinnabarLabFossilRoomScientist1ComeAgainText or + "Aiyah! You come\nagain!", finish)) + end + local items = {} + for _, fossil in ipairs(carried) do + items[#items + 1] = { + -- engine/events/cinnabar_lab.asm:29 + keepOpen = true, + label = game.data.items[fossil].name, + onSelect = function() + if menu then menu.frozen = true end + local species = FOSSIL_MONS[fossil] + local def = game.data.pokemon[species] + subs.wNameBuffer = game.data.items[fossil].name + subs.wStringBuffer = def and def.name or species game.stack:push(TextBox.new(game, - t._CinnabarLabFossilRoomScientist1NoFossilsText or - "No! Is too bad!", done)) - return - end - -- .cancelledGivingFossil: B on the menu and NO on the - -- confirm both land here - local function comeAgain() - game.stack:push(TextBox.new(game, - t._CinnabarLabFossilRoomScientist1ComeAgainText or - "Aiyah! You come\nagain!", done)) - end - local items = {} - for _, fossil in ipairs(carried) do - items[#items + 1] = { - label = game.data.items[fossil].name, - onSelect = function() - -- LoadFossilItemAndMonName: wNameBuffer = item name, - -- wStringBuffer = mon name; then .ScientistSeesFossilText - -- with YesNoChoice (cursor starts on YES) - local species = FOSSIL_MONS[fossil] - local def = game.data.pokemon[species] - subs.wNameBuffer = game.data.items[fossil].name - subs.wStringBuffer = def and def.name or species + fillFossilText( + t._CinnabarLabFossilRoomScientist1SeesFossilText or + "Oh! That is\n{RAM:wNameBuffer}!\fIt is fossil of\n{RAM:wStringBuffer}, a\nPOKéMON that is\nalready extinct!\fMy Resurrection\nMachine will make\nthat POKéMON live\nagain!", + subs), + nil, { choice = function(yes) + if not yes then comeAgain() return end + require("src.inventory.Bag").remove(game.save, fossil, 1) + game.save.labFossilMon = species + f.EVENT_GAVE_FOSSIL_TO_LAB = true + f.EVENT_LAB_STILL_REVIVING_FOSSIL = true game.stack:push(TextBox.new(game, fillFossilText( - t._CinnabarLabFossilRoomScientist1SeesFossilText or - "Oh! That is\n{RAM:wNameBuffer}!\fIt is fossil of\n{RAM:wStringBuffer}, a\nPOKéMON that is\nalready extinct!\fMy Resurrection\nMachine will make\nthat POKéMON live\nagain!", + t._CinnabarLabFossilRoomScientist1TakesFossilText or + "So! You hurry and\ngive me that!\f{PLAYER} handed\nover {RAM:wNameBuffer}!", subs), - nil, { choice = function(yes) - if not yes then comeAgain() return end - -- YES: TakesFossilText, RemoveItemByID, GoForAWalk2, - -- SetEvents GAVE_FOSSIL_TO_LAB + STILL_REVIVING - require("src.inventory.Bag").remove(game.save, fossil, 1) - game.save.labFossilMon = species - f.EVENT_GAVE_FOSSIL_TO_LAB = true - f.EVENT_LAB_STILL_REVIVING_FOSSIL = true + function() game.stack:push(TextBox.new(game, - fillFossilText( - t._CinnabarLabFossilRoomScientist1TakesFossilText or - "So! You hurry and\ngive me that!\f{PLAYER} handed\nover {RAM:wNameBuffer}!", - subs), - function() - game.stack:push(TextBox.new(game, - t._CinnabarLabFossilRoomScientist1GoForAWalkText2 or - "I take a little\ntime!\fYou go for walk a\nlittle while!", done)) - end)) - end })) - end, - } - end + t._CinnabarLabFossilRoomScientist1GoForAWalkText2 or + "I take a little\ntime!\fYou go for walk a\nlittle while!", finish)) + end)) + end })) + end, + } + end + -- text/CinnabarLabFossilRoom.asm:12 + game.stack:push(TextBox.new(game, introText, nil, { + stay = { prompt = true, onShown = function() -- GiveFossilToCinnabarLab's menu: TextBoxBorder at 0,0 -- (interior width $d, height 2 per fossil), A|B watched - local Menu = require("src.ui.Menu") - game.stack:push(Menu.new(game, items, - { tx = 0, ty = 0, tw = 15, onCancel = comeAgain })) - end)) + menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 15, + keepOnCancel = true, onCancel = comeAgain }) + game.stack:push(menu) + end }, + })) end, -- the other scientist trades SAILOR: Ponyta -> Seel -- (scripts/CinnabarLabFossilRoom.asm TRADE_FOR_SAILOR) diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 2c59a41c..36af64d4 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -382,7 +382,27 @@ M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR", -- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4). -- ------------------------------------------------------------------- +-- pokeyellow/scripts/RocketHideoutB4F.asm:401 +local function dropLiftKey(game, ow) + if game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then return end + game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true + require("src.script.Commands").show_object( + { game = game, save = game.save, overworld = ow }, + "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY") +end + +-- pokeyellow/scripts/RocketHideoutB4F.asm:308 +local function liftKeyGruntBeaten(save) + if save.flags.EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 then return true end + return (save.defeatedTrainers or {})["ROCKET_HIDEOUT_B4F_obj_4"] == true +end + M.ROCKET_HIDEOUT_B4F = { + -- (home/trainers.asm:341, pokeyellow/scripts/RocketHideoutB4F.asm:401) + onVictory = function(game, ow) + if not require("src.core.GameVersion").isYellow() then return end + if liftKeyGruntBeaten(game.save) then dropLiftKey(game, ow) end + end, talk = { TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done) if not ow:trainerDefeated(npc) then @@ -421,23 +441,17 @@ M.ROCKET_HIDEOUT_B4F = { ow:engageTrainer(npc, function() -- engageTrainer records the win before it calls back, so this -- is the end-battle text's SetEvent + ShowObject - if ow:trainerDefeated(npc) - and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then - game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true - local Commands = require("src.script.Commands") - Commands.show_object( - { game = game, save = game.save, overworld = ow }, - "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY") - end + if ow:trainerDefeated(npc) then dropLiftKey(game, ow) end done() end) return end - -- RocketHideoutB4FRocketAfterBattleText: later talks only reprint + -- pokered/scripts/RocketHideoutB4F.asm:193 (#2277) local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, game.data.text._RocketHideoutB4FRocketAfterBattleText - or "Oh no! I dropped\nthe LIFT KEY!", done)) + or "Oh no! I dropped\nthe LIFT KEY!", + function() dropLiftKey(game, ow); done() end)) end, TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done) 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 ea76c5be..544db21c 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 @@ -187,6 +187,9 @@ public class GameActivity extends SDLActivity { private Object audioDeviceCallback = null; private boolean audioFocusHeld = false; private boolean audioDeviceCallbackPrimed = false; + private static final long AUDIO_FOCUS_RECOVERY_DELAY_MS = 2000; + private final Handler audioFocusHandler = new Handler(Looper.getMainLooper()); + private Runnable audioFocusRecovery = null; /** * Native libraries required by an optional Android host extension. @@ -2193,7 +2196,6 @@ public class GameActivity extends SDLActivity { .build(); audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes(attributes) - .setWillPauseWhenDucked(true) .setOnAudioFocusChangeListener(audioFocusListener) .build(); } @@ -2202,6 +2204,8 @@ public class GameActivity extends SDLActivity { } private void abandonAudioFocus() { + cancelAudioFocusRecovery(); + if (!audioFocusHeld) { return; } @@ -2231,15 +2235,18 @@ public class GameActivity extends SDLActivity { case AudioManager.AUDIOFOCUS_LOSS: audioFocusHeld = false; nativeAudioFocusLost(); + scheduleAudioFocusRecovery(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: nativeAudioFocusLost(); break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + break; case AudioManager.AUDIOFOCUS_GAIN: case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT: case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK: audioFocusHeld = true; + cancelAudioFocusRecovery(); nativeAudioFocusGained(); break; default: @@ -2250,6 +2257,33 @@ public class GameActivity extends SDLActivity { } } + private void scheduleAudioFocusRecovery() { + cancelAudioFocusRecovery(); + audioFocusRecovery = new Runnable() { + @Override + public void run() { + audioFocusRecovery = null; + if (audioFocusHeld) { + return; + } + try { + nativeAudioFocusGained(); + } catch (UnsatisfiedLinkError e) { + Log.d("GameActivity", "audio focus recovery before liblove was ready", e); + } + } + }; + audioFocusHandler.postDelayed(audioFocusRecovery, AUDIO_FOCUS_RECOVERY_DELAY_MS); + } + + private void cancelAudioFocusRecovery() { + if (audioFocusRecovery == null) { + return; + } + audioFocusHandler.removeCallbacks(audioFocusRecovery); + audioFocusRecovery = null; + } + private void registerAudioDeviceCallback() { if (android.os.Build.VERSION.SDK_INT < 23 || audioDeviceCallback != null) { return; diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 366dfc8c..b4aea2ea 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4226,11 +4226,27 @@ local function primaryEffectFailed(msgs) if m:find("didn't affect", 1, true) then return true end if m:find("is unaffected", 1, true) then return true end if m:find("protected by MIST", 1, true) then return true end + -- engine/battle/move_effects/leech_seed.asm:28 + if m:find("evaded attack", 1, true) then return true end -- engine/battle/effects.asm:46-47 if m:lower():find("already asleep", 1, true) then return true end return false end +-- engine/battle/effects.asm:67, :159, :1158, :712, :1366 +-- engine/battle/move_effects/paralyze.asm:40, leech_seed.asm:28 +local function statusMissText(self, record, user, target) + local kind = record and record.missText + if kind == "didntAffect" then + return self:romText("_DidntAffectText", "It didn't affect\n%s!", displayName(target)) + elseif kind == "butItFailed" then + return self:romText("_ButItFailedText", "But, it failed!") + elseif kind == "evadedAttack" then + return self:romText("_EvadedAttackText", "%s\nevaded attack!", displayName(target)) + end + return self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)) +end + function BattleState:performMove(user, target, moveInst, isCalled) local move = self:moveDef(moveInst) if not move then @@ -4368,7 +4384,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer and self.kind ~= "link" and self.rng(0, 255) < 64 then self:cancelMoveAnim() - self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user))) + self:sayNext(statusMissText(self, record, user, target)) return end -- accuracy-checked status effects run MoveHitTest, which has no @@ -4381,7 +4397,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only -- after the effect lands; a miss skips it self:cancelMoveAnim() - self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user))) + self:sayNext(statusMissText(self, record, user, target)) return end local msgs = record.run(ctx) diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 4f77c1c2..cc6ba2f8 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -210,11 +210,14 @@ MoveEffects.primary = { LEECH_SEED_EFFECT = function(battle, user, target) -- leech_seed.asm has no substitute check: seeding lands through one + -- engine/battle/move_effects/leech_seed.asm:28 if target.leechSeeded then - return { romText(battle.data, "_ButItFailedText", "But, it failed!") } + return { romText(battle.data, "_EvadedAttackText", "%s\nevaded attack!", displayName(target)) } end for _, t in ipairs(target.curTypes) do - if t == "GRASS" then return { romText(battle.data, "_ButItFailedText", "But, it failed!") } end + if t == "GRASS" then + return { romText(battle.data, "_EvadedAttackText", "%s\nevaded attack!", displayName(target)) } + end end target.leechSeeded = true return { romText(battle.data, "_WasSeededText", "%s\nwas seeded!", displayName(target)) } @@ -452,6 +455,20 @@ local ACC_CHECKED = { ACCURACY_DOWN1_EFFECT = true, } +-- engine/battle/effects.asm:67, :159, move_effects/paralyze.asm:40 +-- engine/battle/effects.asm:1158, :1366, :712 +-- engine/battle/move_effects/leech_seed.asm:28 +local MISS_TEXT = { + SLEEP_EFFECT = "didntAffect", POISON_EFFECT = "didntAffect", + PARALYZE_EFFECT = "didntAffect", + CONFUSION_EFFECT = "butItFailed", DISABLE_EFFECT = "butItFailed", + ATTACK_DOWN1_EFFECT = "butItFailed", DEFENSE_DOWN1_EFFECT = "butItFailed", + DEFENSE_DOWN2_EFFECT = "butItFailed", SPEED_DOWN1_EFFECT = "butItFailed", + ACCURACY_DOWN1_EFFECT = "butItFailed", + LEECH_SEED_EFFECT = "evadedAttack", +} +MoveEffects.MISS_TEXT = MISS_TEXT + -- fixed-damage moves (engine/battle/core.asm SpecialDamage); the move -- field wins, previously imported caches fall back to the id table local FIXED_DAMAGE = { @@ -801,7 +818,8 @@ local RECORDS = {} MoveEffects.RECORDS = RECORDS for id, fn in pairs(MoveEffects.primary) do RECORDS[id] = { kind = "primary", run = shim(fn), - accuracyChecked = ACC_CHECKED[id] or nil } + accuracyChecked = ACC_CHECKED[id] or nil, + missText = MISS_TEXT[id] or nil } end for id, fn in pairs(MoveEffects.secondary) do RECORDS[id] = { kind = "secondary", run = shim(fn) } diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 2a7d47f5..67bc64bd 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -423,6 +423,7 @@ end local forceAwaitingFirstBuffer -- test-only override (see _simulate*) function ChipAudio.awaitingFirstBuffer() + if suspended then return false end if forceAwaitingFirstBuffer then return true end local m = currentMusic if not (m and m.threaded and not m.started and not m.finished) then diff --git a/src/core/Game.lua b/src/core/Game.lua index 28bf52cd..ae65b4d7 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -955,6 +955,46 @@ function Game:keyreleased(key) return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key }) end +local function padPressedBody(self, joystick, button) + TouchControls:noteGamepad() + local selectHeld = Input:isDown("select") + if not selectHeld and joystick and joystick.isGamepadDown then + local ok, down = pcall(function() + return joystick:isGamepadDown("back") + end) + selectHeld = ok and down == true + end + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) + return + end + if not selectHeld then + local action = Input:padAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end + end + if selectHeld then + local digit = GamepadMap.displayChordDigit(button) + if digit then + self:keypressed(digit) + return + end + end + Input:gamepadpressed(joystick, button) +end + +local function padReleasedBody(self, joystick, button) + Input:gamepadreleased(joystick, button) + local top = self.stack and self.stack:top() + if top and top.onGamepadReleased then top:onGamepadReleased(button) end +end + -- RFC 0020: input.gamepad covers press/release/axis (see this method, -- Game:gamepadreleased, and Game:gamepadaxis below) -- three raw callbacks -- feeding one hook, because the motivating use case (a stick-driven @@ -964,44 +1004,7 @@ end -- restores. function Game:gamepadpressed(joystick, button) local function vanilla() - -- a controller is being used: the touch overlay steps aside until the - -- next screen touch (mobile only; a no-op elsewhere) - TouchControls:noteGamepad() - -- Select held? Needed both to suppress shoulder speed hotkeys (Select+L - -- is a display chord on NX) and for the chord path below. - local selectHeld = Input:isDown("select") - if not selectHeld and joystick and joystick.isGamepadDown then - local ok, down = pcall(function() - return joystick:isGamepadDown("back") - end) - selectHeld = ok and down == true - end - local top = self.stack and self.stack:top() - if top and top.onGamepadPressed then - top:onGamepadPressed(button) - return - end - if not selectHeld then - local action = Input:padAction(button) - if action == "speedUp" then - self:_cycleSpeed(1) - return - elseif action == "speedDown" then - self:_cycleSpeed(-1) - return - end - end - -- Select+face display chords → same digit path as Game:keypressed - -- (COLORS/TILT/pipelines). Intercept before Input so face does not - -- also fire GB A/B. Dual-path: raw already ignored when isGamepad(). - if selectHeld then - local digit = GamepadMap.displayChordDigit(button) - if digit then - self:keypressed(digit) - return - end - end - Input:gamepadpressed(joystick, button) + padPressedBody(self, joystick, button) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end return ModRuntime.call("input.gamepad", vanilla, self, @@ -1010,10 +1013,7 @@ end function Game:gamepadreleased(joystick, button) local function vanilla() - -- same observe-after-Input contract as Game:keyreleased (#589) - Input:gamepadreleased(joystick, button) - local top = self.stack and self.stack:top() - if top and top.onGamepadReleased then top:onGamepadReleased(button) end + padReleasedBody(self, joystick, button) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end return ModRuntime.call("input.gamepad", vanilla, self, @@ -1024,6 +1024,15 @@ function Game:gamepadaxis(joystick, axis, value) local function vanilla() -- past-deadzone only, so resting-stick drift can't hide the overlay if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + local trigger, phase = Input:triggerAxis(axis, value) + if trigger then + if phase == "pressed" then + padPressedBody(self, joystick, trigger) + elseif phase == "released" then + padReleasedBody(self, joystick, trigger) + end + return + end Input:gamepadaxis(joystick, axis, value) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end @@ -1052,6 +1061,16 @@ function Game:joystickpressed(joystick, button) top:onJoystickPressed(button) return end + if not Input:isDown("select") then + local action = Input:joyAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end + end Input:joystickpressed(joystick, button) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 66261cbb..a1acdb5b 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2362,6 +2362,47 @@ function Game2:_cycleSpeed(dir) self:persistOptions() end +local function padPressedBody(self, joystick, button) + TouchControls:noteGamepad() + local selectHeld = Input:isDown("select") + if not selectHeld and joystick and joystick.isGamepadDown then + local ok, down = pcall(function() + return joystick:isGamepadDown("back") + end) + selectHeld = ok and down == true + end + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) + return + end + if not selectHeld then + local action = Input:padAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end + end + if selectHeld then + local digit = GamepadMap.displayChordDigit(button) + if digit then + self:keypressed(digit) + return + end + end + + Input:gamepadpressed(joystick, button) +end + +local function padReleasedBody(self, joystick, button) + Input:gamepadreleased(joystick, button) + local top = self.stack and self.stack:top() + if top and top.onGamepadReleased then top:onGamepadReleased(button) end +end + -- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS -- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been -- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written @@ -2377,42 +2418,7 @@ end -- restores. function Game2:gamepadpressed(joystick, button) local function vanilla() - -- a controller is being used: the touch overlay steps aside until the next - -- screen touch (mobile only; a no-op elsewhere) - TouchControls:noteGamepad() - local selectHeld = Input:isDown("select") - if not selectHeld and joystick and joystick.isGamepadDown then - local ok, down = pcall(function() - return joystick:isGamepadDown("back") - end) - selectHeld = ok and down == true - end - local top = self.stack and self.stack:top() - if top and top.onGamepadPressed then - top:onGamepadPressed(button) - return - end - if not selectHeld then - local action = Input:padAction(button) - if action == "speedUp" then - self:_cycleSpeed(1) - return - elseif action == "speedDown" then - self:_cycleSpeed(-1) - return - end - end - if selectHeld then - local digit = GamepadMap.displayChordDigit(button) - if digit then - self:keypressed(digit) - return - end - end - -- START opens the start menu in the overworld; it used to quit, from before - -- there was a menu to open. - - Input:gamepadpressed(joystick, button) + padPressedBody(self, joystick, button) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end return ModRuntime.call("input.gamepad", vanilla, self, @@ -2421,9 +2427,7 @@ end function Game2:gamepadreleased(joystick, button) local function vanilla() - Input:gamepadreleased(joystick, button) - local top = self.stack and self.stack:top() - if top and top.onGamepadReleased then top:onGamepadReleased(button) end + padReleasedBody(self, joystick, button) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end return ModRuntime.call("input.gamepad", vanilla, self, @@ -2434,6 +2438,15 @@ function Game2:gamepadaxis(joystick, axis, value) local function vanilla() -- past-deadzone only, so resting-stick drift cannot hide the overlay if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + local trigger, phase = Input:triggerAxis(axis, value) + if trigger then + if phase == "pressed" then + padPressedBody(self, joystick, trigger) + elseif phase == "released" then + padReleasedBody(self, joystick, trigger) + end + return + end Input:gamepadaxis(joystick, axis, value) end if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end @@ -2454,6 +2467,16 @@ function Game2:joystickpressed(joystick, button) top:onJoystickPressed(button) return end + if not Input:isDown("select") then + local action = Input:joyAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end + end Input:joystickpressed(joystick, button) end diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index 2fceeb68..f977e952 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -10,9 +10,16 @@ GamepadMap.DEFAULT_GAMEPAD_BINDINGS = { start = "start", back = "select", } +GamepadMap.TRIGGER_AXES = { + triggerleft = "triggerleft", lefttrigger = "triggerleft", + triggerright = "triggerright", righttrigger = "triggerright", +} +GamepadMap.TRIGGER_ON = 0.4 +GamepadMap.TRIGGER_OFF = 0.2 + GamepadMap.DEFAULT_PAD_ACTIONS = { - rightshoulder = "speedUp", righttrigger = "speedUp", - leftshoulder = "speedDown", lefttrigger = "speedDown", + rightshoulder = "speedUp", triggerright = "speedUp", + leftshoulder = "speedDown", triggerleft = "speedDown", } -- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is diff --git a/src/core/Input.lua b/src/core/Input.lua index 64633e23..c2eef66f 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -114,16 +114,26 @@ function Input:applyBindings(overlay) local n = tonumber(padName:match("^joy(%d+)$")) if n then joys[n] = action end end + local joyActs = {} + for padName, action in pairs(acts) do + local n = tonumber(padName:match("^joy(%d+)$")) + if n then joyActs[n] = action end + end self.keyBindings = keys self.padBindings = pads self.joyBindings = joys self.padActions = acts + self.joyActions = joyActs end function Input:padAction(button) return self.padActions and self.padActions[button] or nil end +function Input:joyAction(index) + return self.joyActions and self.joyActions[index] or nil +end + -- Purely event-driven state (press sets true, release sets false) has no -- fallback if a release event never arrives -- focus loss, a minimized -- window, or a disconnected gamepad can all swallow the key-up/button-up @@ -137,6 +147,7 @@ function Input:reset() self.stickAxis = { x = 0, y = 0 } self.stickDir = nil self.hatDirs = {} + self.triggerHeld = {} self.captureArmed = false self.captureEvents = nil end @@ -313,9 +324,33 @@ function Input:joystickreleased(joystick, button) if btn then release(self, btn, "joy:" .. button) end end +function Input:triggerAxis(axis, value) + local name = GamepadMap.TRIGGER_AXES[axis] + if not name then return nil end + self.triggerHeld = self.triggerHeld or {} + local was = self.triggerHeld[name] + if not was and value >= GamepadMap.TRIGGER_ON then + self.triggerHeld[name] = true + return name, "pressed" + elseif was and value <= GamepadMap.TRIGGER_OFF then + self.triggerHeld[name] = nil + return name, "released" + end + return name +end + -- left stick treated as a continuous held direction, same 4-way rule as -- the touch swipe d-pad: whichever axis has the larger magnitude wins. function Input:gamepadaxis(joystick, axis, value) + local trigger, phase = self:triggerAxis(axis, value) + if trigger then + if phase == "pressed" then + self:gamepadpressed(joystick, trigger) + elseif phase == "released" then + self:gamepadreleased(joystick, trigger) + end + return + end if axis == "leftx" then self.stickAxis.x = value elseif axis == "lefty" then @@ -412,7 +447,8 @@ function Input:reconcile() end end if j.getGamepadAxis then - for _, axis in ipairs({ "leftx", "lefty" }) do + local axes = { "leftx", "lefty", "triggerleft", "triggerright" } + for _, axis in ipairs(axes) do local ok2, v = pcall(j.getGamepadAxis, j, axis) if ok2 and type(v) == "number" then self:gamepadaxis(j, axis, v) end end diff --git a/src/core/Music.lua b/src/core/Music.lua index 6fa0915e..404c6189 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -341,6 +341,7 @@ function Music.stop() state.data, state.loop = nil, nil state.chip = false state.pendingRestore = nil + state.restoreLeft = nil if previous and Runtime.wants("music.stopped") then Runtime.emit("music.stopped", { song = previous }) end @@ -438,15 +439,26 @@ function Music.playVictory(data, kind, trainerId) return false end +local function deviceSuspended() + local ChipAudio = package.loaded["src.core.ChipAudio"] + return ChipAudio ~= nil and ChipAudio.isSuspended() +end + +-- scripts/RedsHouse1F.asm:35-38 +local ONE_SHOT_CEILING = 600 +Music.ONE_SHOT_CEILING = ONE_SHOT_CEILING + -- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme -- resumes when it ends, via update() function Music.playOnce(data, song) if not songDef(data, song) then return false end + if deviceSuspended() then return false end Music.play(data, song, false, { reason = "once" }) -- play() can no-op (hook silence, failed def); only arm restore when -- the jingle actually became current if state.current ~= song then return false end state.pendingRestore = true + state.restoreLeft = ONE_SHOT_CEILING return true end @@ -479,6 +491,7 @@ end function Music.restoreMap(data, reason) state.current = nil state.pendingRestore = nil + state.restoreLeft = nil local play = effectiveMapSong(data, state.mapSong) if play then Music.play(data, play, nil, { reason = reason or "map" }) end end @@ -621,9 +634,18 @@ function Music.update(data) end -- do not treat "threaded source still waiting on its first buffer" as -- ended, or playOnce jingles get restored over before they can sound - if state.pendingRestore and sourceStopped(state.source) - and not state.loopSource and not chipAwaitingFirstBuffer() then - Music.restoreMap(data) + if state.pendingRestore then + state.restoreLeft = (state.restoreLeft or ONE_SHOT_CEILING) - 1 + local ended = sourceStopped(state.source) and not state.loopSource + and not chipAwaitingFirstBuffer() + if ended then + Music.restoreMap(data) + elseif state.restoreLeft <= 0 then + require("src.core.Logger").warn( + "music: one-shot %s never finished; restoring map theme", + tostring(state.current)) + Music.restoreMap(data) + end end end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 78311903..a505fde2 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1407,6 +1407,7 @@ local function deleteSlotIn(key, slotId) remove(fs, main) remove(fs, bak) remove(fs, tmp) + remove(fs, slotDir(key) .. "/" .. slotId .. ".cart") table.remove(reg.list, idx) if reg.names then reg.names[slotId] = nil end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index e96d4486..cb04b80b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2632,14 +2632,6 @@ local function updateAllRows(imp) return (ok and type(rows) == "table") and rows or {} end -local function modsWithUpdates(imp) - local names = {} - for _, row in ipairs(updateAllRows(imp)) do - names[#names + 1] = row.name or row.id - end - return names -end - local function modsWithUpdatesCount(imp) local mods = imp.mods or {} local rev = imp._modUpdateRev or 0 @@ -2657,18 +2649,7 @@ local function modsWithUpdatesCount(imp) end local function askUpdateAllMods(imp) - local names = modsWithUpdates(imp) - if #names == 0 then - imp:pressUpdateAllMods() - return - end - local lines = { Strings("Update %d items?", #names) } - for i = 1, math.min(3, #names) do lines[#lines + 1] = names[i] end - if #names > 3 then - lines[#lines + 1] = Strings("and %d more", #names - 3) - end - imp._modConfirm = { kind = "updateAll", title = Strings("Update all"), - yesLabel = Strings("Update all"), lines = lines } + imp:pressUpdateAllMods() end local function buildModsPanel(imp, x, y, w, availH, m) @@ -3962,8 +3943,8 @@ local function buildConfirmModal(imp, m) imp:_installCartPins(c.version, c.id) elseif c.kind == "update" then imp:_confirmModUpdate(c.id, c.release) - elseif c.kind == "updateAll" then - imp:pressUpdateAllMods() + elseif c.kind == "updateAllRun" then + imp:_confirmUpdateAll() elseif c.kind == "enableAll" then imp:_setAllMods(true, true) elseif c.kind == "importOversize" then diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5cd19311..8cf8d111 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -6645,7 +6645,9 @@ function RomImporter:_updateAllCartRows() return cache.rows end local rows = {} - self._cartUpdateCache = { feed = feed, seen = seen, rows = rows } + local skipped = {} + self._cartUpdateCache = { feed = feed, seen = seen, rows = rows, + skipped = skipped } if type(feed) ~= "table" or #feed == 0 then return rows end local listed = {} for _, entry in ipairs(feed) do @@ -6663,12 +6665,17 @@ function RomImporter:_updateAllCartRows() for _, row in ipairs(installed) do local entry = listed[row.id] local mine = row.cart and repoKey(row.cart.repo) - if entry and mine and mine == repoKey(entry.github, entry.repo) - and ModIndex.canInstall(entry) + local theirs = entry and repoKey(entry.github, entry.repo) + if entry and ModUpdate.isNewer(row.version, ModIndex.displayVersion(entry)) then - rows[#rows + 1] = { kind = "cart", id = row.id, entry = entry, - name = row.title or row.id, from = row.version, - to = ModIndex.displayVersion(entry) } + if (mine == nil or mine == theirs) and ModIndex.canInstall(entry) then + rows[#rows + 1] = { kind = "cart", id = row.id, entry = entry, + name = row.title or row.id, from = row.version, + to = ModIndex.displayVersion(entry) } + elseif mine and theirs and mine ~= theirs then + skipped[#skipped + 1] = { id = row.id, name = row.title or row.id, + mine = mine, theirs = theirs } + end end end return rows @@ -6708,7 +6715,18 @@ function RomImporter:pressUpdateAllMods() updatedIds = {}, updatedCarts = {}, failures = {} } self.modNotice = nil self:_syncModUpdateInfo(true) - self:_ensureFind() + self:_setBusy(Strings("Checking for updates"), nil, + function() self:_cancelUpdateAll() end) + self:_pumpUpdateAll() + return true +end + +function RomImporter:_confirmUpdateAll() + local job = self._updateAll + if not job or job.stage ~= "confirm" then return false end + if self._modConfirm == job.confirm then self._modConfirm = nil end + job.confirm = nil + job.stage = "next" self:_setBusy(Strings("Checking for updates"), nil, function() self:_cancelUpdateAll() end) self:_pumpUpdateAll() @@ -6732,12 +6750,51 @@ function RomImporter:_pumpUpdateAll() if job.stage == "check" then if self._modInfoFetch or self._findFetch then return end + if not job.feedRefreshed then + job.feedRefreshed = true + if Platform.canFetchRemote() then + self:_refreshFindSources() + if #(self.findSources or {}) > 0 then + self:_refreshFind(true, { quiet = true }) + if self._findFetch then + self:_setBusy(Strings("Checking for updates"), nil, + function() self:_cancelUpdateAll() end) + return + end + end + end + end + self._cartUpdateCache = nil + job.feedStale = job.feedStale + or (self.findIndex and self.findIndex.stale) == true job.rows = self:_updateAllRows() + job.skippedCarts = (self._cartUpdateCache or {}).skipped job.total = #job.rows if job.cancelled or job.total == 0 then return self:_finishUpdateAll(job.cancelled) end - job.stage = "next" + job.stage = "confirm" + local lines = { Strings("Update %d items?", job.total) } + for i = 1, math.min(3, job.total) do + local row = job.rows[i] + lines[#lines + 1] = tostring(row.name or row.id) + end + if job.total > 3 then + lines[#lines + 1] = Strings("and %d more", job.total - 3) + end + job.confirm = { kind = "updateAllRun", title = Strings("Update all"), + yesLabel = Strings("Update all"), lines = lines } + self._modConfirm = job.confirm + self:_clearBusy() + return + end + + if job.stage == "confirm" then + if self._modConfirm ~= job.confirm then + self._updateAll = nil + self:_clearBusy() + pcall(self._refreshMods, self) + end return end @@ -6793,7 +6850,17 @@ function RomImporter:_finishUpdateAll(cancelled) self.modNotice = { ok = true, failures = job.failures, text = Strings("Stopped after updating %d items.", job.updated) } elseif (job.total or 0) == 0 then - self.modNotice = { ok = true, text = Strings("Everything is up to date.") } + local skipped = #(job.skippedCarts or {}) + local text + if job.feedStale then + text = Strings("Could not reach the mod index; compared against the cached listing.") + elseif skipped > 0 then + text = Strings("Nothing to update. %d cart(s) came from another repo:", + skipped) + else + text = Strings("Everything is up to date.") + end + self.modNotice = { ok = skipped == 0, text = text } elseif #job.failures == 0 then self.modNotice = { ok = true, text = Strings("Updated %d items.", job.updated) } @@ -6802,6 +6869,13 @@ function RomImporter:_finishUpdateAll(cancelled) text = Strings("Updated %d of %d. %d failed:", job.updated, job.total, #job.failures) } end + for _, cart in ipairs(job.skippedCarts or {}) do + local lines = self.modNotice.failures or {} + lines[#lines + 1] = Strings( + "%s is listed by %s but this copy came from %s; update it from Find if that is the same cart.", + tostring(cart.name), tostring(cart.theirs), tostring(cart.mine)) + self.modNotice.failures = lines + end for _, cart in ipairs(job.updatedCarts or {}) do if cart.base then local missing = #self:_cartPinsMissing(cart.base, cart.id) @@ -6904,7 +6978,7 @@ end -- never ran. The fetch now starts here and completes across later frames in -- _pumpFindFetch. Only an explicit Refresh is blocking; boot prewarm and the -- first visit keep the launcher interactive while the listing arrives. -function RomImporter:_refreshFind(force) +function RomImporter:_refreshFind(force, opts) -- The notice is the fix, not the gate (#876). This branch used to return an -- empty listing silently, and because the player had by then added a source, -- the panel skipped its "No mod index added" card and rendered the merged @@ -6935,13 +7009,14 @@ function RomImporter:_refreshFind(force) handles[i] = { source = source, h = ModIndex.beginFetch(source, { force = force == true }) } end + local quiet = (opts and opts.quiet) == true self._findFetch = { - handles = handles, force = force == true, + handles = handles, force = force == true, quiet = quiet, mods = {}, seen = {}, cats = {}, catSeen = {}, errs = {}, carts = {}, cartSeen = {}, bases = {}, baseSeen = {}, stale = false, oldest = nil, at = 1, } - if force == true then + if force == true and not quiet then self:_setBusy(Strings("Fetching mod index"), #sources == 1 and (sources[1].label or sources[1].feed) or Strings("%d indexes", #sources)) @@ -7026,9 +7101,11 @@ function RomImporter:_pumpFindFetch() baseGames = f.bases, stale = f.stale, checkedAt = f.oldest } self.findLoaded = true - if #f.errs > 0 then + if #f.errs > 0 and f.quiet then + if self._updateAll then self._updateAll.feedStale = true end + elseif #f.errs > 0 then self.findNotice = { ok = false, text = table.concat(f.errs, " - ") } - elseif f.force then + elseif f.force and not f.quiet then self.findNotice = { ok = true, text = (#f.carts > 0) and Strings("Refreshed - %d mods and %d carts listed", #f.mods, #f.carts) diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 860b6c6d..db20f2f0 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -2,6 +2,7 @@ -- the other joins by typing that address in. Direct peer-to-peer over -- lua-enet (bundled with LÖVE), no relay server. +local CableFrame = require("src.render.CableFrame") local CodeEntry = require("src.link.CodeEntry") local DiscordPresence = require("src.core.DiscordPresence") local Font = require("src.render.Font") @@ -454,7 +455,37 @@ function LinkState:openStats(mon) Screens.push(self.game, "SummaryMenu", mon) end +-- engine/link/cable_club.asm:657 +function LinkState:listLabel(mon, t, index) + local def = mon and self.game.data.pokemon[mon.species] + local label = (def and def.name) or (mon and mon.species) or "" + if t and index and not t:canPick(index) then label = label .. "X" end + return label +end + +-- engine/link/cable_club.asm:26 +function LinkState:beginRound() + self.roundBusy = false + if self.game.linkNet == self.net then self.game.linkNet = nil end + if not self.net or self.net:getStatus() == "closed" then + self:exitWith(Strings("The link was\nbroken.")) + return + end + self.trade = Protocol.TradeSession.new(self.game.data, self.game.save.party, { + subset = self.verdict == "subset", + strict = Handshake.strict(self.verdict), + peerName = self.peerName, + }) + self.net:send(self.trade:opening()) + self.index = 1 + self.theirIndex = 1 + self.side = "mine" + self.pickChoice = nil + self.confirmed = nil +end + function LinkState:updateTrade(input) + if self.roundBusy then return end if self.game.linkNet == self.net then self.game.linkNet = nil end for _, msg in ipairs(self.net:poll()) do local reply = self.trade:handle(msg) @@ -479,12 +510,19 @@ function LinkState:updateTrade(input) -- headless LinkBattle-style fake games with no writeSave are unaffected. if self.game.writeSave then self.game:writeSave() end local name = received.nickname or self.game.data.pokemon[received.species].name - Runtime.emit("link.ended", { reason = "done" }) - self.game.linkSession = nil -- this path pops without exitWith - self.net:close() - self.game.stack:pop() local game = self.game - require("src.core.Sound").play(game.data, "Trade_Machine") + -- engine/link/cable_club.asm:870 #758 + local again = self.verdict == "full" and self.net ~= nil + and self.net:getStatus() ~= "closed" + if again then + self.roundBusy = true + game.linkNet = self.net + else + Runtime.emit("link.ended", { reason = "done" }) + game.linkSession = nil + self.net:close() + game.stack:pop() + end Screens.push(game, "TradeAnim", { sent = sent, received = received, enemyName = (self.peerName or "TRAINER"), @@ -495,6 +533,9 @@ function LinkState:updateTrade(input) game.stack:push(TextBox.new(game, Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name), function() + local nextRound = function() + if again then self:beginRound() end + end if evoTo then -- via="TRADE": a trade evolution cannot be B-cancelled -- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213). @@ -502,7 +543,12 @@ function LinkState:updateTrade(input) -- species (not the pre-evo landed by t:apply) is what persists, -- keeping disk in step with the autosave above (#222). require("src.pokemon.Evolution").evolve(game, received, evoTo, - function() if game.writeSave then game:writeSave() end end, "TRADE") + function() + if game.writeSave then game:writeSave() end + nextRound() + end, "TRADE") + else + nextRound() end end)) end, @@ -537,16 +583,28 @@ function LinkState:updateTrade(input) end end elseif t.stage == "picking" and input:wasPressed("up") then - if self.side == "theirs" then + -- engine/link/cable_club.asm:537 + if self.side == "cancel" then + self.side = "mine" + self.index = math.max(1, #self.game.save.party) + elseif self.side == "theirs" then self.theirIndex = math.max(1, self.theirIndex - 1) else self.index = math.max(1, self.index - 1) end elseif t.stage == "picking" and input:wasPressed("down") then - if self.side == "theirs" then - self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1) + if self.side == "cancel" then + self.side = "cancel" + elseif self.side == "theirs" then + if self.theirIndex >= #(t.theirParty or {}) then + self.side = "cancel" + else + self.theirIndex = self.theirIndex + 1 + end + elseif self.index >= #self.game.save.party then + self.side = "cancel" else - self.index = math.min(#self.game.save.party, self.index + 1) + self.index = self.index + 1 end elseif t.stage == "picking" and input:wasPressed("right") then if t.theirParty and #t.theirParty > 0 then @@ -563,7 +621,11 @@ function LinkState:updateTrade(input) self.net:send({ type = "bye" }) self:exitWith(Strings("The trade was\ncancelled.")) elseif t.stage == "picking" and input:wasPressed("a") then - if self.side == "theirs" then + if self.side == "cancel" then + -- engine/link/cable_club.asm:537 + self.net:send({ type = "bye" }) + self:exitWith(Strings("The trade was\ncancelled.")) + elseif self.side == "theirs" then self:openStats((t.theirParty or {})[self.theirIndex]) else self.pickChoice = 1 @@ -658,48 +720,64 @@ function LinkState:draw() Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128) elseif self.stage == "trade" then - drawTitle("TRADE") + -- engine/link/cable_club.asm:635 local t = self.trade - Font.draw(Strings("YOURS"), 8, 20) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + CableFrame.box(0, 0, 20, 8) + CableFrame.box(0, 8, 20, 8) + -- engine/link/cable_club.asm:643 + local function nameOnBorder(name, y) + if not name or name == "" then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 40, y, Font.width(name), 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(name, 40, y) + end + nameOnBorder((self.game.save.player and self.game.save.player.name), 0) + nameOnBorder(self.peerName, 64) + love.graphics.setColor(0, 0, 0, 1) + -- engine/link/cable_club.asm:657 for i, mon in ipairs(self.game.save.party) do - local def = self.game.data.pokemon[mon.species] - local label = (mon.nickname or def.name):sub(1, 8) - if not t:canPick(i) then label = label .. "X" end - Font.draw(label, 16, 20 + i * 12) - if i == self.index and self.side ~= "theirs" then - Font.drawCode(CURSOR, 8, 20 + i * 12) + Font.draw(self:listLabel(mon, t, i), 16, i * 8) + if i == self.index and self.side == "mine" then + Font.drawCode(CURSOR, 8, i * 8) end end - Font.draw(Strings("THEIRS"), 84, 20) for i, mon in ipairs(t.theirParty or {}) do - local def = self.game.data.pokemon[mon.species] - Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) + Font.draw(self:listLabel(mon), 16, 64 + i * 8) + -- engine/link/cable_club.asm:617 if self.side == "theirs" and i == self.theirIndex then - Font.drawCode(CURSOR, 84, 20 + i * 12) + Font.drawCode(CURSOR, 8, 64 + i * 8) elseif t.theirPick == i then - Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12) + Font.drawCode(CURSOR_HOLLOW, 8, 64 + i * 8) end end if self.pickChoice then + -- engine/link/cable_club.asm:468 + CableFrame.box(0, 14, 20, 4) + love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("STATS"), 16, 128) Font.draw(Strings("TRADE"), 96, 128) Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128) else - local hint - if t.stage == "waitRecords" then hint = "Comparing games..." - elseif t.stage == "waitParty" then hint = "Exchanging data..." - elseif t.stage == "picking" then - if self.side == "theirs" then hint = Strings("A: stats") - else - hint = t:canPick(self.index) and "Pick one to trade" - or Strings("X: not on theirs") - end - elseif t.stage == "waitPick" then hint = "Waiting for them..." - elseif t.stage == "confirming" then - hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") - end - Font.draw(hint or "", 8, 132) + -- engine/link/cable_club.asm:601 + CableFrame.fill(11, 15, 9, 1) + CableFrame.fill(0, 16, 20, 2) + CableFrame.box(0, 15, 11, 3) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("CANCEL"), 16, 128) + -- engine/link/cable_club.asm:552 + if self.side == "cancel" then Font.drawCode(CURSOR, 8, 128) end end + if t.stage == "waitRecords" or t.stage == "waitParty" + or t.stage == "waitPick" or (t.stage == "confirming" and self.confirmed) then + -- engine/link/print_waiting_text.asm + CableFrame.box(3, 10, 13, 3) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("Waiting...!"), 32, 88) + end + love.graphics.setColor(0, 0, 0, 1) elseif self.stage == "battleWait" or self.stage == "battleRunning" then drawTitle("LINK BATTLE") diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 8f71680a..37615e58 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1416,6 +1416,7 @@ R.move_effects = { fields = { kind = f.enum{ "primary", "secondary", "full" }, accuracyChecked = f.opt(f.bool), + missText = f.opt(f.enum{ "didntAffect", "butItFailed", "evadedAttack" }), run = f.opt(f.fn), }, example = 'mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = fn })', diff --git a/src/render/CableFrame.lua b/src/render/CableFrame.lua new file mode 100644 index 00000000..ae2c54bb --- /dev/null +++ b/src/render/CableFrame.lua @@ -0,0 +1,76 @@ +-- CableClub_TextBoxBorder -- engine/link/cable_club.asm:938 + +local CableFrame = {} + +local frame = nil + +local function tryImage(path) + if not (love and love.graphics and love.graphics.newImage) then return nil end + local ok, img = pcall(require("src.render.Assets").image, path) + if ok and img then return img end + local ok2, img2 = pcall(love.graphics.newImage, path) + return ok2 and img2 or nil +end + +-- engine/link/cable_club.asm:975 +function CableFrame.load() + if frame == nil then + local img = tryImage("assets/generated/trainer_card/trainer_info.png") + if not img then + frame = false + else + local quads = {} + for i = 0, 8 do + quads[i] = love.graphics.newQuad((i % 3) * 8, math.floor(i / 3) * 8, + 8, 8, img:getDimensions()) + end + frame = { img = img, quads = quads } + end + end + return frame or nil +end + +function CableFrame.invalidate() + frame = nil +end + +-- tile $7e, the blank pattern -- engine/link/cable_club.asm:603 +function CableFrame.fill(tx, ty, tw, th) + local f = CableFrame.load() + love.graphics.setColor(1, 1, 1, 1) + if not f then + love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + return + end + for j = 0, th - 1 do + for i = 0, tw - 1 do + love.graphics.draw(f.img, f.quads[8], (tx + i) * 8, (ty + j) * 8) + end + end +end + +function CableFrame.box(tx, ty, tw, th) + local f = CableFrame.load() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + if not f then + require("src.render.Font").drawBox(tx, ty, tw, th) + return + end + local img, q = f.img, f.quads + local x1, y1 = (tx + tw - 1) * 8, (ty + th - 1) * 8 + love.graphics.draw(img, q[2], tx * 8, ty * 8) + love.graphics.draw(img, q[4], x1, ty * 8) + love.graphics.draw(img, q[6], tx * 8, y1) + love.graphics.draw(img, q[7], x1, y1) + for i = 1, tw - 2 do + love.graphics.draw(img, q[3], (tx + i) * 8, ty * 8) + love.graphics.draw(img, q[0], (tx + i) * 8, y1) + end + for j = 1, th - 2 do + love.graphics.draw(img, q[5], tx * 8, (ty + j) * 8) + love.graphics.draw(img, q[1], x1, (ty + j) * 8) + end +end + +return CableFrame diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 665e5704..22f9c031 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -21,9 +21,28 @@ function ShaderFX.presetDir() return base .. sep .. "shaders" end +local function realFullPath(rel, fallbackDir) + local fs = love and love.filesystem + local getReal = fs and fs.getRealDirectory + if getReal then + local ok, root = pcall(getReal, rel) + if ok and type(root) == "string" and root ~= "" then + local sep = package.config:sub(1, 1) + local path = (root:gsub("[/\\]+$", "")) .. sep .. rel + local f = io.open(path, "rb") + if f then + f:close() + return path + end + end + end + return fallbackDir .. rel:gsub("^shaders", "") +end + function ShaderFX.list() local dir = ShaderFX.presetDir() if not dir then return {} end + pcall(function() require("src.import.CacheFs").root() end) love.filesystem.createDirectory("shaders") local out = {} local function scan(relPath) @@ -34,7 +53,7 @@ function ShaderFX.list() if info and info.type == "directory" then scan(rel) elseif name:match("%.slangp$") then - local entry = { name = name, relPath = rel, fullPath = dir .. rel:gsub("^shaders", "") } + local entry = { name = name, relPath = rel, fullPath = realFullPath(rel, dir) } entry.converted = ShaderFX.isConverted(entry) out[#out + 1] = entry end @@ -216,24 +235,21 @@ local function extractClosure(mountRoot) end end - -- love.filesystem.write does not create intermediate directories. - local madeDirs = {} - local function ensureDir(destPath) - local dir = dirname(destPath) - if dir == "" or madeDirs[dir] then return end - madeDirs[dir] = true - love.filesystem.createDirectory(dir) - end - + local CacheFs = require("src.import.CacheFs") + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" local copied = 0 - for rel in pairs(closure) do - local destPath = "shaders/" .. rel:sub(#mountRoot + 2) - local bytes = love.filesystem.read(rel) - if bytes then - ensureDir(destPath) - if love.filesystem.write(destPath, bytes) then copied = copied + 1 end + local ok, err = pcall(function() + for rel in pairs(closure) do + local destPath = "shaders/" .. rel:sub(#mountRoot + 2) + local bytes = love.filesystem.read(rel) + if bytes then + if CacheFs.write(destPath, bytes) then copied = copied + 1 end + end end - end + end) + CacheFs.prefix = savedPrefix + if not ok then error(err, 0) end return copied end @@ -412,6 +428,7 @@ local function ensureLib() if #tried > 0 then libError = libError .. "; looked in " .. table.concat(tried, ", ") end + ShaderFX.recordError("bridge", libError) return nil, libError end diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua index ceae3e79..711aaff5 100644 --- a/src/save_convert/Gen2Layout.lua +++ b/src/save_convert/Gen2Layout.lua @@ -46,6 +46,26 @@ Gen2Layout.goldSilver = { wVariableSprites = 0x23BD, wGameTimeHours = 0x2053, wGameTimeMinutes = 0x2055, + wRedsName = 0x202C, + wGreensName = 0x2037, + wSavedAtLeastOnce = 0x2042, + wSpawnAfterChampion = 0x2043, + wCenteredObject = 0x205E, + wPlayerStruct = 0x2065, + wMapObjects = 0x22AD, + wNumPCItems = 0x247E, + wMomItemTriggerBalance = 0x27CB, + wRoamMon1MapGroup = 0x2B84, + wRoamMon2MapGroup = 0x2B8B, + wRoamMon3MapGroup = 0x2B92, + wBestMagikarpLengthFeet = 0x2B9B, + wBestMagikarpLengthInches = 0x2B9C, + wMagikarpRecordHoldersName = 0x2B9D, + wDecoBed = 0x27C1, + wDecoPoster = 0x27C4, + wScreenSave = 0x286C, + sOptions = 0x2000, + sMysteryGiftUnlocked = 0x0BE3, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, sceneVars = { @@ -154,7 +174,27 @@ Gen2Layout.crystal = { wVariableSprites = 0x23BC, wGameTimeHours = 0x2052, wGameTimeMinutes = 0x2054, + wRedsName = 0x202C, + wGreensName = 0x2037, + wSavedAtLeastOnce = 0x2042, + wSpawnAfterChampion = 0x2043, + wCenteredObject = 0x205D, + wPlayerStruct = 0x2064, + wMapObjects = 0x22AC, + wNumPCItems = 0x247F, + wMomItemTriggerBalance = 0x27A7, + wRoamMon1MapGroup = 0x2B5F, + wRoamMon2MapGroup = 0x2B66, + wRoamMon3MapGroup = 0x2B6D, + wBestMagikarpLengthFeet = 0x2B76, + wBestMagikarpLengthInches = 0x2B77, + wMagikarpRecordHoldersName = 0x2B78, + wDecoBed = 0x279D, + wDecoPoster = 0x27A0, + wScreenSave = 0x2847, wPlayerGender = 0x3E3D, + sOptions = 0x2000, + sMysteryGiftUnlocked = 0x0BE3, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, sceneVars = { @@ -283,7 +323,27 @@ Gen2Layout.crystal = { wVariableSprites = 0x15BC, wGameTimeHours = 0x1252, wGameTimeMinutes = 0x1254, + wRedsName = 0x122C, + wGreensName = 0x1237, + wSavedAtLeastOnce = 0x1242, + wSpawnAfterChampion = 0x1243, + wCenteredObject = 0x125D, + wPlayerStruct = 0x1264, + wMapObjects = 0x14AC, + wNumPCItems = 0x167F, + wMomItemTriggerBalance = 0x19A7, + wRoamMon1MapGroup = 0x1D5F, + wRoamMon2MapGroup = 0x1D66, + wRoamMon3MapGroup = 0x1D6D, + wBestMagikarpLengthFeet = 0x1D76, + wBestMagikarpLengthInches = 0x1D77, + wMagikarpRecordHoldersName = 0x1D78, + wDecoBed = 0x199D, + wDecoPoster = 0x19A0, + wScreenSave = 0x1A47, wPlayerGender = 0x3E3D, + sOptions = 0x2000, + sMysteryGiftUnlocked = 0x0BE3, boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, sceneVars = { POKECENTER_2F = 0x1700, diff --git a/src/save_convert/Gen2MapContext.lua b/src/save_convert/Gen2MapContext.lua index 1b21a1ae..d10c74c8 100644 --- a/src/save_convert/Gen2MapContext.lua +++ b/src/save_convert/Gen2MapContext.lua @@ -29,14 +29,12 @@ -- * wObjectMasks is zeroed and wObjectFollow_Leader/Follower reset to -1; -- * wCurMapObjectEventCount and wCurMapObjectEventsPointer are set to the -- new map's count and its object list's ROM address, which is what a --- later ReloadMapEvents reads. --- --- The player keeps the template's own struct and map object, standing and --- idle exactly as an in-game SAVE leaves them, with only the four map --- coordinate bytes re-anchored to the new position. --- --- Offsets are .sav file offsets. The anchor is the same one --- tools/gen2_sram_offsets.py uses (wMoney against Gen2Layout), and every +-- later ReloadMapEvents reads; +-- (home/map.asm:1829) copies back OVER the block grid on CONTINUE, so it +-- FillMapConnections (home/map.asm:1169) lays around them. +-- The player's map object and struct stay as the image already carries them +-- (the template's on an imported save, Gen2Save.blankImage's on one begun in +-- this port), standing and idle exactly as an in-game SAVE leaves them, with -- value below was summed from the pret .sym files and cross-checked against -- Gen2Layout's own rows: pokecrystal wMapGroup $DCB5 lands at 0x2843 and -- pokegold's at 0x2868, which are the numbers Gen2Layout already ships. @@ -65,6 +63,11 @@ local STRUCT_MAP_Y = 17 local STRUCT_LAST_MAP_X = 18 local STRUCT_LAST_MAP_Y = 19 +-- constants/gfx_constants.asm:6 +local SCREEN_META_WIDTH = 6 +local SCREEN_META_HEIGHT = 5 +local MAP_PADDING = 6 + Gen2MapContext.OFFSETS = { goldSilver = { objectFollow = 0x205C, -- wObjectFollow_Leader, then _Follower @@ -73,6 +76,8 @@ Gen2MapContext.OFFSETS = { objectMasks = 0x23AD, -- wObjectMasks objectEventCount = 0x27B6, -- wCurMapObjectEventCount objectEventsPointer = 0x27B7, + firstObjectSlot = 2, -- home/map.asm:941 wMap2Object + screenSave = 0x286C, }, crystal = { objectFollow = 0x205B, @@ -81,6 +86,8 @@ Gen2MapContext.OFFSETS = { objectMasks = 0x23AC, objectEventCount = 0x2792, objectEventsPointer = 0x2793, + firstObjectSlot = 1, -- pokecrystal home/map.asm:572 wMap1Object + screenSave = 0x2847, }, } @@ -136,6 +143,104 @@ local function mapObjectSlot(obj) } end +-- home/map.asm:1169 +local CONNECTION_ORDER = { "north", "south", "west", "east" } + +local function neighbourDef(data, conn) + local maps = (data and data.maps) or {} + local def = conn.mapId and maps[conn.mapId] + if type(def) == "table" then return def end + local _, byIds = findMap(data, conn.group, conn.map) + return byIds +end + +-- data/maps/attributes.asm:23 +local function connectionRect(dir, conn, w, h) + local length = math.floor(tonumber(conn.stripLength) or 0) + if length <= 0 then return nil end + local src, tgt = 0, math.floor(tonumber(conn.offset) or 0) + 3 + if tgt < 0 then src, tgt = -tgt, 0 end + if dir == "north" then + return { row = 0, col = tgt, rows = 3, cols = length, src = src } + elseif dir == "south" then + return { row = h + 3, col = tgt, rows = 3, cols = length, src = src } + elseif dir == "west" then + return { row = tgt, col = 0, rows = length, cols = 3, src = src } + elseif dir == "east" then + return { row = tgt, col = w + 3, rows = length, cols = 3, src = src } + end + return nil +end + +-- data/maps/attributes.asm:23 connection, the `dw \2_Blocks + _blk` half. +local function connectionSource(dir, rect, nw, nh) + if dir == "north" then return nw * (nh - 3) + rect.src end + if dir == "south" then return rect.src end + if dir == "west" then return nw * rect.src + nw - 3 end + return nw * rect.src +end + +-- home/map.asm:1829, :1065 +local function screenWindow(data, def, x, y) + local blocks, w, h = def.blocks, tonumber(def.width), tonumber(def.height) + if type(blocks) ~= "table" or not w or not h then + return nil, "map cache has no block data (re-import the ROM)" + end + local stride = w + MAP_PADDING + + local fill, unknown = {}, {} + for _, dir in ipairs(CONNECTION_ORDER) do + local conn = (def.connections or {})[dir] + local rect = conn and connectionRect(dir, conn, w, h) + if rect then + local nb = neighbourDef(data, conn) + local nw = nb and tonumber(nb.width) + local nh = nb and tonumber(nb.height) + if type(nb) ~= "table" or type(nb.blocks) ~= "table" or not nw or not nh then + unknown[#unknown + 1] = { rect = rect, id = conn.mapId or dir } + else + local base = connectionSource(dir, rect, nw, nh) + for r = 0, rect.rows - 1 do + for c = 0, rect.cols - 1 do + local row, col = rect.row + r, rect.col + c + if row >= 0 and col >= 0 and col < stride then + fill[row * stride + col] = nb.blocks[base + r * nw + c + 1] or 0 + end + end + end + end + end + end + + local anchor = (math.floor(y / 2) + 1) * stride + math.floor(x / 2) + 1 + local out = {} + for r = 0, SCREEN_META_HEIGHT - 1 do + for c = 0, SCREEN_META_WIDTH - 1 do + local at = anchor + r * stride + c + local row = math.floor(at / stride) - 3 + local col = at % stride - 3 + local block + if row >= 0 and row < h and col >= 0 and col < w then + block = blocks[row * w + col + 1] or 0 + else + block = fill[at] + end + if not block then + for _, miss in ipairs(unknown) do + local rr, cc = row + 3 - miss.rect.row, col + 3 - miss.rect.col + if rr >= 0 and rr < miss.rect.rows and cc >= 0 and cc < miss.rect.cols then + return nil, ("map cache has no block data for the connected map %s " + .. "(re-import the ROM)"):format(tostring(miss.id)) + end + end + block = 0 + end + out[#out + 1] = u8(block) + end + end + return out +end + -- build(data, gameVersion, group, number, x, y) -> ctx, err -- -- ctx.writes [.sav offset] = array of bytes @@ -151,9 +256,10 @@ function Gen2MapContext.build(data, gameVersion, group, number, x, y) return nil, ("unknown map %d/%d"):format(tonumber(group) or -1, tonumber(number) or -1) end local objects = def.objects or {} - if #objects > NUM_OBJECTS - 1 then + local first = O.firstObjectSlot + if #objects > NUM_OBJECTS - first then return nil, ("%s declares %d objects and a save holds %d") - :format(tostring(id), #objects, NUM_OBJECTS - 1) + :format(tostring(id), #objects, NUM_OBJECTS - first) end if type(def.objectEventsAddr) ~= "number" then return nil, "map cache has no object-table address (re-import the ROM)" @@ -162,16 +268,20 @@ function Gen2MapContext.build(data, gameVersion, group, number, x, y) local writes = {} - -- The NPC map objects, then the empty pattern ReadObjectEvents pads with. + -- home/map.asm:937 ReadObjectEvents (pokecrystal home/map.asm:568) local slots = {} + for _ = 2, first do + for _ = 1, MAPOBJECT_LENGTH do slots[#slots + 1] = 0 end + end for _, obj in ipairs(objects) do local slot = mapObjectSlot(obj) for i = 1, MAPOBJECT_LENGTH do slots[#slots + 1] = slot[i] end end - for _ = #objects + 1, NUM_OBJECTS - 1 do + for _ = #objects + first, NUM_OBJECTS - 1 do + slots[#slots + 1] = 0 slots[#slots + 1] = 0 slots[#slots + 1] = 0xFF - for _ = 3, MAPOBJECT_LENGTH do slots[#slots + 1] = 0 end + for _ = 4, MAPOBJECT_LENGTH do slots[#slots + 1] = 0 end end writes[O.mapObjects + MAPOBJECT_LENGTH] = slots @@ -206,6 +316,10 @@ function Gen2MapContext.build(data, gameVersion, group, number, x, y) math.floor(def.objectEventsAddr / 256) % 256, } + local screen, why = screenWindow(data, def, x, y) + if not screen then return nil, why end + writes[O.screenSave] = screen + return { writes = writes, mapId = id } end diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 75099adc..0e33818b 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -694,21 +694,122 @@ local function putFlagSet(t, at, set, count, indexFor) end end +-- data/default_options.asm, as engine/menus/save.asm:384 +local DEFAULT_OPTIONS = { 0x03, 0x01, 0x00, 0x01, 0x40, 0x01, 0x00, 0x00 } + +-- engine/overworld/player_object.asm:39, home/map_objects.asm:352 +local PLAYER_MAP_OBJECT = { + 0x00, 0x01, 0x00, 0x00, 0x0B, 0xFF, 0xFF, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xD5, 0x78, +} + +-- engine/overworld/player_object.asm:411, home/map_objects.asm:485, +-- data/sprites/map_objects.asm:93 +local PLAYER_STRUCT = { + 0x01, 0x00, 0x00, 0x0B, 0x02, 0x00, 0x00, 0xFF, + 0x00, 0x01, 0x00, 0x01, 0x00, 0xFF, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, +} +local STRUCT_X_AT = { 16, 18, 20 } +local STRUCT_Y_AT = { 17, 19, 21 } + +-- pokecrystal engine/overworld/player_object.asm:32 +local PLAYER_PAL_MALE, PLAYER_PAL_FEMALE = 0x80, 0x90 + +-- constants/deco_constants.asm:123,140 +local DECO_FEATHERY_BED, DECO_TOWN_MAP = 2, 16 + +-- (engine/menus/intro_menu.asm:28) and ErasePreviousSave +-- (engine/menus/save.asm:333) zero-fill, plus the bytes +-- MapSetupScript_Continue (data/maps/setup_scripts.asm:161) never rebuilds. +function Gen2Save.blankImage(gameVersion, save, data) + local L = Gen2Save.layoutFor(gameVersion) + if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + local O = require("src.save_convert.Gen2MapContext").offsetsFor(gameVersion) + if not O then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + + local x = Gen2Save.crosswalks(data) + local pos = (type(save) == "table" and save.position) or nil + local ids = pos and pos.map and x.mapIds[pos.map] + local group = (ids and ids[1]) or (pos and tonumber(pos.mapGroup)) + local number = (ids and ids[2]) or (pos and tonumber(pos.mapNumber)) + if not (group and number) then + return nil, "a save begun in this game needs the map it stands on to " + .. "build a cartridge image from, and this save does not name one" + end + local px = math.floor(tonumber(pos.x) or 0) + 4 + local py = math.floor(tonumber(pos.y) or 0) + 4 + + local t = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = 0 end + for i = 1, #DEFAULT_OPTIONS do t[L.sOptions + i - 1] = DEFAULT_OPTIONS[i] end + putU8(t, L.wSavedAtLeastOnce, 1) + putText(t, L.wRedsName, "RED", Gen2Save.NAME_LENGTH) + putText(t, L.wGreensName, "GREEN", Gen2Save.NAME_LENGTH) + putBE(t, L.wMomItemTriggerBalance, 2300, 3) + for _, at in ipairs({ L.wRoamMon1MapGroup, L.wRoamMon2MapGroup, + L.wRoamMon3MapGroup }) do + putU8(t, at, 0xFF) + putU8(t, at + 1, 0xFF) + end + putU8(t, L.wBestMagikarpLengthFeet, 3) + putU8(t, L.wBestMagikarpLengthInches, 6) + putText(t, L.wMagikarpRecordHoldersName, "RALPH", Gen2Save.NAME_LENGTH) + putU8(t, L.wNumPCItems, 0) + putU8(t, L.wNumPCItems + 1, 0xFF) + putU8(t, L.sMysteryGiftUnlocked, 0xFF) + -- engine/overworld/decorations.asm:1, :1080 + putU8(t, L.wDecoBed, DECO_FEATHERY_BED) + putU8(t, L.wDecoPoster, DECO_TOWN_MAP) + -- engine/menus/intro_menu.asm:148 SetDefaultBoxNames + if L.wBoxNames then + for i = 1, 14 do + putText(t, L.wBoxNames + (i - 1) * 9, "BOX" .. i, 9) + end + end + + for i = 1, #PLAYER_MAP_OBJECT do + t[O.mapObjects + i - 1] = PLAYER_MAP_OBJECT[i] + end + putU8(t, O.mapObjects + 2, py) + putU8(t, O.mapObjects + 3, px) + if L.wPlayerGender then + local female = ((type(save) == "table" and save.player) or {}).gender == "female" + putU8(t, O.mapObjects + 8, female and PLAYER_PAL_FEMALE or PLAYER_PAL_MALE) + end + for i = 1, #PLAYER_STRUCT do + t[O.objectStructs + i - 1] = PLAYER_STRUCT[i] + end + for _, at in ipairs(STRUCT_X_AT) do putU8(t, O.objectStructs + at, px) end + for _, at in ipairs(STRUCT_Y_AT) do putU8(t, O.objectStructs + at, py) end + + putU8(t, L.wMapGroup, group) + putU8(t, L.wMapNumber, number) + + local out = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end + return table.concat(out) +end + -- encode(save, gameVersion, template, data) -> bytes, err -- --- Writes into the cartridge image the save came from: Gen 2 SRAM holds a great --- deal this codec does not model and the real game trusts it on CONTINUE, so a --- save with no image behind it is refused rather than built from nothing. --- --- Only the primary copy is written. TryLoadSaveFile rewrites the backup from --- the primary on every successful load. +-- Writes into the cartridge image the save came from. A save begun in this +-- port has none, so Gen2Save.blankImage builds one and the map window is +-- always rebuilt for where it stands. function Gen2Save.encode(save, gameVersion, template, data) local L = Gen2Save.layoutFor(gameVersion) if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end if type(save) ~= "table" then return nil, "expected a save table" end - if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then - return nil, "this save has no cartridge image to write back into, and a " - .. "Gen 2 save built from nothing does not boot on real hardware" + local fresh = type(template) ~= "string" + if not fresh and #template < Gen2Save.SAVE_SIZE then + return nil, ("the cartridge image beside this save is %d bytes and a Gen 2 " + .. "save file is %d; it is truncated or is not a save file") + :format(#template, Gen2Save.SAVE_SIZE) + end + if fresh then + local built, why = Gen2Save.blankImage(gameVersion, save, data) + if not built then return nil, why end + template = built end local x = Gen2Save.crosswalks(data) @@ -780,9 +881,13 @@ function Gen2Save.encode(save, gameVersion, template, data) :format(overflow[1], overflow[2], overflow[3]) end if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end - if save.boxNames and L.wBoxNames then + -- engine/menus/intro_menu.asm:148 SetDefaultBoxNames + if L.wBoxNames then + local names = save.boxNames or {} for i = 1, 14 do - putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9) + local name = names[i] + if type(name) ~= "string" or name == "" then name = "BOX" .. i end + putText(t, L.wBoxNames + (i - 1) * 9, name, 9) end end @@ -831,7 +936,8 @@ function Gen2Save.encode(save, gameVersion, template, data) -- the template's window untouched, byte for byte. local templateGroup = template:byte(L.wMapGroup + 1) local templateNumber = template:byte(L.wMapNumber + 1) - if t[L.wMapGroup] ~= templateGroup or t[L.wMapNumber] ~= templateNumber then + if fresh or t[L.wMapGroup] ~= templateGroup + or t[L.wMapNumber] ~= templateNumber then local Gen2MapContext = require("src.save_convert.Gen2MapContext") local ctx, why = Gen2MapContext.build(data, gameVersion, t[L.wMapGroup], t[L.wMapNumber], t[L.wXCoord], t[L.wYCoord]) diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 4a08270f..b58b1062 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -56,11 +56,14 @@ local KEY_SHORT = { local PAD_SHORT = { dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT", leftshoulder = "LB", rightshoulder = "RB", + triggerleft = "L2", triggerright = "R2", leftstick = "LS", rightstick = "RS", guide = "GUIDE", } local function shortName(name, shorts) local s = shorts[name] if s then return s end + local n = name:match("^joy(%d+)$") + if n and #n > 2 then return "J" .. n end s = name:upper() return #s > 5 and s:sub(1, 5) or s end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 71620c18..002c51c7 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -1,8 +1,8 @@ -- Generic bordered list menu with the blinking ▶ cursor. -- items: { { label=..., onSelect=function }, ... } --- Pops itself on B (unless cancelable=false); also on START only when --- opts.startCloses is set -- pokered's wMenuWatchedKeys mask varies per --- menu and only the start menu's adds PAD_START. +-- Pops itself on B (unless cancelable=false or opts.keepOnCancel); also on +-- START only when opts.startCloses is set -- pokered's wMenuWatchedKeys mask +-- varies per menu and only the start menu's adds PAD_START. local Font = require("src.render.Font") local Theme = require("src.ui.Theme") @@ -59,6 +59,8 @@ function Menu.new(game, items, opts) -- classic centred letterbox self.anchor = opts.anchor self.onCancel = opts.onCancel + -- engine/events/cinnabar_lab.asm:70-73 + self.keepOnCancel = opts.keepOnCancel or false -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its -- menus silent (home/window.asm HandleMenuInput_) self.noSound = opts.noSound or false @@ -83,6 +85,8 @@ end function Menu:update(dt) local input = self.game.input + -- engine/events/cinnabar_lab.asm:29 + if self.frozen then return end if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or (self.noWrap and 1 or #self.items) @@ -107,7 +111,7 @@ function Menu:update(dt) if input:wasPressed("b") and not self.noSound then require("src.core.Sound").play(self.game.data, "Press_AB") end - self.game.stack:pop() + if not self.keepOnCancel then self.game.stack:pop() end if self.onCancel then self.onCancel() end end self:clampScroll() diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index dcbe6404..2211727f 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -210,7 +210,8 @@ end -- selection cursor of their own: Trade_AnimCircledMon -- (engine/movie/trade.asm) cycles the party sprite's two frames the whole -- time the mon rides the link cable (#750). -function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt) +-- `obp` overrides the OBP0 bake: engine/movie/trade.asm:385 +function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) local icons = game.data.icons if not icons then return end local def = game.data.pokemon[mon.species] @@ -254,7 +255,11 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt) local PaletteFX = require("src.render.PaletteFX") local ogColors, ogGroup if baked and PaletteFX.usesSpriteObp() then - ogColors, ogGroup = PaletteFX.ogObjNormal() + if obp then + ogColors, ogGroup = obp[1], obp[2] + else + ogColors, ogGroup = PaletteFX.ogObjNormal() + end end local key = baked and (path .. "#obp" .. (ogGroup or "")) or path if iconImages[key] == nil then diff --git a/src/ui/ShaderFXScreen.lua b/src/ui/ShaderFXScreen.lua index a703bd9b..a273fd8a 100644 --- a/src/ui/ShaderFXScreen.lua +++ b/src/ui/ShaderFXScreen.lua @@ -53,6 +53,7 @@ function ShaderFXScreen.new(game, slot) slot = slot or "main" local optKey = ShaderFX.OPTION_KEY[slot] local title = (slot == "secondary") and "SHADER FX 2" or "SHADER FX" + ShaderFX.clearBridgeQuarantine() local canConvert = ShaderFX.canConvert() local items, selected = buildItems(ShaderFX.activeEntry(slot), canConvert) @@ -126,6 +127,7 @@ function ShaderFXScreen.new(game, slot) if not ok then require("src.core.Logger").error("ShaderFXScreen: convert failed for %s: %s", item.entry.name, tostring(err)) + ShaderFX.recordError(item.entry.name, err) end applyRowState(item, canConvert) if not ok then item.right = Strings("FAILED") end @@ -145,6 +147,7 @@ function ShaderFXScreen.new(game, slot) if not convOk then require("src.core.Logger").error("ShaderFXScreen: reconvert failed for %s: %s", item.entry.name, tostring(convErr)) + ShaderFX.recordError(item.entry.name, convErr) end end local overrides = opts and opts.shaderfxParams and opts.shaderfxParams[item.entry.name] diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 2d365c5f..a3e018aa 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -13,13 +13,19 @@ TradeAnim.isOpaque = true -- Trade_LoadMonSprite runs SET_PAL_POKEMON_WHOLE_SCREEN for the mon it puts -- on screen; every other step of the sequence runs SET_PAL_GENERIC, which is -- PAL_MEWMON (data/sgb/sgb_packets.asm PalPacket_Generic). #750 +-- engine/movie/trade.asm:602 +local FLASH_BGP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } + function TradeAnim:sgbPalettes(game) local P = require("src.render.PaletteFX") local mon = (self.phase == "show_player" and self.sent) or (self.phase == "show_enemy" and self.received) local colors = mon and P.monPal(game.data, mon.species) - if colors then return { P.whole(colors) } end - return P.wholeNamed(game.data, "MEWMON") + local zones = colors and { P.whole(colors) } or P.wholeNamed(game.data, "MEWMON") + if self.cableFlash and zones and zones[1] and zones[1].colors then + zones[1].colors = P.permute(zones[1].colors, FLASH_BGP) + end + return zones end local DEFAULT_ART = { @@ -44,9 +50,26 @@ local function tryImage(path) return ok and img or nil end -local function nameOf(game, mon) - local def = game.data.pokemon[mon.species] - return mon.nickname or (def and def.name) or mon.species +-- engine/movie/trade.asm:304 +local function objSheet(path) + local P = require("src.render.PaletteFX") + if not (path and P.usesSpriteObp()) then return nil end + local ok, img = pcall(require("src.render.SpriteRenderer").obpImage, + path, P.ogObj()) + return ok and img or nil +end + +-- engine/movie/trade.asm:199 +local D0 = { [0] = 0, [1] = 0, [2] = 1, [3] = 3 } + +local function bubbleSheet(path) + local P = require("src.render.PaletteFX") + if not (path and P.usesSpriteObp()) then return nil end + local group = "tradeobp1" + .. (require("src.core.GameVersion").isBlue() and "_blue" or "") + local ok, img = pcall(require("src.render.SpriteRenderer").obpImage, + path, P.permute(P.ogBg(), D0), group) + return ok and img or nil end local function speciesName(game, mon) @@ -196,6 +219,12 @@ function TradeAnim.new(game, opts) or love.math.random(0, 65535) local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART + self.artPath = { + cableBall = art.cableBall or DEFAULT_ART.cableBall, + cableBallAlt = art.cableBallAlt or DEFAULT_ART.cableBallAlt, + bubble = art.bubble or DEFAULT_ART.bubble, + moveAnim0 = DEFAULT_ART.moveAnim0, + } self.img = { gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy), openCable = tryImage(art.openCable or DEFAULT_ART.openCable), @@ -224,6 +253,7 @@ function TradeAnim.new(game, opts) self.monY = 0 self.flash = false self.monVisible = true + self.boxVisible = false self.waitingText = false self.cableFlash = false self.activeBallBlock = nil @@ -237,8 +267,8 @@ function TradeAnim.new(game, opts) return self end +-- engine/movie/trade.asm:72 function TradeAnim:enter() - Sound.play(self.game.data, "Trade_Machine") end function TradeAnim:advance() @@ -259,6 +289,7 @@ function TradeAnim:advance() if self.onDone then self.onDone() end elseif self.phase == "show_enemy" then self.monVisible = false + self.boxVisible = false self.activeBallBlock = 4 self.activeBallX, self.activeBallY = 72, 40 self.activePoofBlock = nil @@ -306,8 +337,11 @@ end function TradeAnim:drawFrameBlock(blockId, x, y) local block = FRAME_BLOCKS[blockId] if not block then return end - if self.img.moveAnim0 then - local iw, ih = self.img.moveAnim0:getDimensions() + local baked = objSheet(self.artPath and self.artPath.moveAnim0) + local sheet = baked or self.img.moveAnim0 + if sheet then + local P = baked and require("src.render.PaletteFX") + local iw, ih = sheet:getDimensions() for _, tile in ipairs(block) do local tx = (tile.tile % 16) * 8 local ty = math.floor(tile.tile / 16) * 8 @@ -316,7 +350,11 @@ function TradeAnim:drawFrameBlock(blockId, x, y) local sy = tile.yflip and -1 or 1 local ox = tile.xflip and 8 or 0 local oy = tile.yflip and 8 or 0 - love.graphics.draw(self.img.moveAnim0, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy) + love.graphics.draw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy) + if P then + P.markUiSpriteRedraw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, + { sx = sx, sy = sy }) + end end else if blockId == 3 or blockId == 4 or blockId == 5 then @@ -361,10 +399,10 @@ function TradeAnim:update(dt) self.t = 0 end elseif self.sub == "hold" then + -- data/moves/animations.asm:1198 if skip or self.t >= 80 then self.sub = "poof" self.t = 0 - Sound.play(self.game.data, "Ball_Poof") end elseif self.sub == "poof" then local step = math.floor(self.t / 6) @@ -574,9 +612,10 @@ function TradeAnim:update(dt) if skip or self.t >= 80 then self.sub = "sends_text" self.t = 0 + -- engine/movie/trade.asm:186 self.dialogText = expand(self.game, "_TradeSendsText", { ["RAM:wLinkEnemyTrainerName"] = self.enemyName, - ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + ["RAM:wNameBuffer"] = speciesName(self.game, self.received), }) end elseif self.sub == "sends_text" then @@ -600,8 +639,9 @@ function TradeAnim:update(dt) if skip or self.t >= 80 then self.sub = "transferred_text" self.t = 0 + -- engine/movie/trade.asm:186 self.dialogText = expand(self.game, "_TradeTransferredText", { - ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + ["RAM:wNameBuffer"] = speciesName(self.game, self.received), }) end elseif self.sub == "transferred_text" then @@ -655,12 +695,13 @@ function TradeAnim:update(dt) if self.sub == "ball_bounce" then local step = math.floor(self.t / 5) + 1 if step > #BALL_BOUNCE_DISTANCES or skip then - self.sub = "poof" + -- engine/movie/trade.asm:358 + self.sub = "ball_rest" self.t = 0 - self.activeBallBlock = nil - self.monVisible = true self.cableSlideOut = nil - Sound.play(self.game.data, "Ball_Poof") + self.boxVisible = true + self.activeBallBlock = 4 + self.activeBallX, self.activeBallY = 72, 63 else local yPos = 40 for i = 1, step do @@ -673,12 +714,21 @@ function TradeAnim:update(dt) Sound.play(self.game.data, "Swap") end end + elseif self.sub == "ball_rest" then + -- engine/movie/trade.asm:361 + if skip or self.t >= 60 then + self.sub = "poof" + self.t = 0 + self.activeBallBlock = nil + end elseif self.sub == "poof" then local step = math.floor(self.t / 6) if step >= 3 or skip then self.sub = "cry" self.t = 0 self.activePoofBlock = nil + -- engine/movie/trade.asm:370 + self.monVisible = true Sound.playCry(self.game.data, self.received.species) else self.activePoofBlock = 6 + step @@ -688,8 +738,9 @@ function TradeAnim:update(dt) if skip or self.t >= 100 then self.sub = "take_care" self.t = 0 + -- engine/movie/trade.asm:186 self.dialogText = expand(self.game, "_TradeTakeCareText", { - ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + ["RAM:wNameBuffer"] = speciesName(self.game, self.received), }) end elseif self.sub == "take_care" then @@ -710,11 +761,7 @@ end local function drawCableHoriz(self, y, x0, x1) local w = math.max(0, x1 - x0) if w <= 0 then return end - if self.cableFlash then - love.graphics.setColor(0.65, 0.65, 0.65, 1) - else - love.graphics.setColor(1, 1, 1, 1) - end + love.graphics.setColor(1, 1, 1, 1) if self.img.cableHoriz then local iw, ih = self.img.cableHoriz:getDimensions() for x = x0, x1 - 1, iw do @@ -748,9 +795,11 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) end function TradeAnim:drawIconInBubble(mon, x, y) - if self.img.bubble then + local baked = bubbleSheet(self.artPath.bubble) + local bubble = baked or self.img.bubble + if bubble then if not self.bubbleQuad then - local iw, ih = self.img.bubble:getDimensions() + local iw, ih = bubble:getDimensions() self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih) self.bubbleQuadAlt = ih >= 32 and love.graphics.newQuad(0, 16, 16, 16, iw, ih) @@ -759,13 +808,23 @@ function TradeAnim:drawIconInBubble(mon, x, y) local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad local left, top = x - 8, y - 8 local right, bottom = left + 32, top + 32 - love.graphics.draw(self.img.bubble, q, left, top) - love.graphics.draw(self.img.bubble, q, right, top, 0, -1, 1) - love.graphics.draw(self.img.bubble, q, left, bottom, 0, 1, -1) - love.graphics.draw(self.img.bubble, q, right, bottom, 0, -1, -1) + local P = baked and require("src.render.PaletteFX") + love.graphics.draw(bubble, q, left, top) + love.graphics.draw(bubble, q, right, top, 0, -1, 1) + love.graphics.draw(bubble, q, left, bottom, 0, 1, -1) + love.graphics.draw(bubble, q, right, bottom, 0, -1, -1) + if P then + P.markUiSpriteRedraw(bubble, q, left, top) + P.markUiSpriteRedraw(bubble, q, right, top, { sx = -1 }) + P.markUiSpriteRedraw(bubble, q, left, bottom, { sy = -1 }) + P.markUiSpriteRedraw(bubble, q, right, bottom, { sx = -1, sy = -1 }) + end end + -- engine/movie/trade.asm:385 + local PF = require("src.render.PaletteFX") + local obp = PF.usesSpriteObp() and { PF.ogObj() } or nil local drawn = mon and require("src.ui.PartyMenu").drawIcon( - self.game, mon, x, y, false, 0, self.cableFlash) + self.game, mon, x, y, false, 0, self.cableFlash, obp) if not drawn then love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", x + 4, y + 4, 8, 8) @@ -784,9 +843,6 @@ function TradeAnim:drawGameBoy(x, y) end function TradeAnim:drawLeftGB() - if self.cableFlash then - love.graphics.setColor(0.65, 0.65, 0.65, 1) - end if self.img.cableConn then love.graphics.draw(self.img.cableConn, 88, 32) end @@ -801,9 +857,6 @@ end function TradeAnim:drawRightGB() drawCableHoriz(self, 32, 0, 112) - if self.cableFlash then - love.graphics.setColor(0.65, 0.65, 0.65, 1) - end if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end if self.img.cableVert then for i = 1, 4 do @@ -844,6 +897,7 @@ function TradeAnim:draw() local p = self.phase if p == "show_player" then + -- engine/movie/trade.asm:245 love.graphics.push() love.graphics.translate(-self.scx, 0) if self.monVisible and self.sentSprite then @@ -853,6 +907,9 @@ function TradeAnim:draw() 56 - self.scx, 16, self.sentSprite:getDimensions()) end end + love.graphics.pop() + love.graphics.push() + love.graphics.translate(self.scx, 0) self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10) love.graphics.pop() @@ -881,10 +938,18 @@ function TradeAnim:draw() if self.activeBallBlock then self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY) elseif self.sub == "suction" or self.sub == "exit_pause" then - local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall) + local path = self.flash and (self.artPath.cableBallAlt or self.artPath.cableBall) + or self.artPath.cableBall + local baked = objSheet(path) + local ball = baked + or (self.flash and (self.img.cableBallAlt or self.img.cableBall)) or self.img.cableBall if ball then love.graphics.draw(ball, self.ballX - 8, self.ballY - 16) + if baked then + require("src.render.PaletteFX").markUiSpriteRedraw( + ball, nil, self.ballX - 8, self.ballY - 16) + end else love.graphics.setColor(0, 0, 0, 1) love.graphics.circle("fill", self.ballX, self.ballY - 8, 6) @@ -916,7 +981,7 @@ function TradeAnim:draw() 56, 16, self.recvSprite:getDimensions()) end end - if self.monVisible and self.sub ~= "take_care" and self.sub ~= "delay_end" then + if self.boxVisible and self.sub ~= "take_care" and self.sub ~= "delay_end" then self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10) end if self.activeBallBlock then diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 66b26290..569c8e73 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -215,6 +215,12 @@ local MAPSETUP_NO_FADE = { [MAPSETUP.CONNECTION] = true, [MAPSETUP.SUBMENU] = true, } +-- data/maps/setup_scripts.asm:48, :154, :175, :26-30; home/audio.asm:281, :335, :412 +local MAPSETUP_MUSIC_BIKE = { + [MAPSETUP.WARP] = true, [MAPSETUP.TELEPORT] = true, + [MAPSETUP.CONTINUE] = true, [MAPSETUP.LINKRETURN] = true, +} + -- MapSetupCommands $26 UpdateRoamMons and $27 JumpRoamMons, read off the same -- eleven scripts with the same fallthroughs honoured. This is the ONLY thing -- that moves the three legendary beasts around Johto, and where each sits in @@ -272,6 +278,10 @@ local START_MAP = "PLAYERS_HOUSE_2F" local START_X, START_Y, START_FACING = 3, 3, "down" local PLAYER_SPRITE = "SPRITE_CHRIS" +-- engine/overworld/player_object.asm:29-41 +local PLAYER_PAL_MALE = { palette = 8 } +local PLAYER_PAL_FEMALE = { palette = 9 } + -- constants/event_flags.asm. HatchEggs sets this one by hand, for exactly one -- species, right after SetSeenAndCaughtMon. wEventFlags is keyed by NUMBER -- here (that is what the extractor emits), so the constant lives at the call @@ -2676,20 +2686,23 @@ function World:playMapMusic() end end --- data/maps/setup_scripts.asm:48 +-- data/maps/setup_scripts.asm:48, :117 -- home/audio.asm:335 -- home/audio.asm:281 -function World:setMapMusic(mapId, seamless) +-- engine/overworld/events.asm:993 +function World:setMapMusic(mapId, seamless, method) local data = self.game and self.game.data local audio = data and data.audio if not (audio and audio.runtime) then return end - local bike = not seamless + method = method or self.setupMethod or MAPSETUP.WARP + local bikeRow = (not seamless) and MAPSETUP_MUSIC_BIKE[method] or false + local bike = bikeRow and FieldMoves.isBiking(self.playerState) and self:playBikeMusic() if bike then return end Music.playMap(data, mapId, nil, FieldMoves.isSurfing(self.playerState), - seamless and Music.MAP_FADE or nil, + (not bikeRow) and Music.MAP_FADE or nil, self:mapMusicSong(mapId)) end @@ -3582,7 +3595,11 @@ function World:runMapSetup(method, load, fly) -- the cart puts it -- in the setup SCRIPT, not in the map load. self:roamMonsBeforeLoad(method) local wrapped = function() + -- data/maps/setup_scripts.asm + local prevMethod = self.setupMethod + self.setupMethod = method local ok = load() + self.setupMethod = prevMethod -- engine/overworld/map_objects_2.asm:1 self.playerMasked = nil -- data/maps/setup_scripts.asm:100; engine/overworld/map_setup.asm:88 @@ -5948,6 +5965,13 @@ function World:playerGender() return save and save.player and save.player.gender or nil end +-- engine/overworld/player_object.asm:29-41; pokegold player_object.asm:19 +function World:playerObjectDef() + if not self:isCrystal() then return nil end + return FieldMoves.isFemale(self:playerGender()) + and PLAYER_PAL_FEMALE or PLAYER_PAL_MALE +end + -- The Chris/Kris sheet the player wears with no state on it -- (data/sprites/player_sprites.asm:2, :9). function World:playerSpriteName() @@ -9661,14 +9685,15 @@ function World:applySpritePalette(entity) self.flashUsed) -- entity.def is the object_event, whose own palette field OVERRIDES the -- sprite's (Palettes.objectPaletteId; AddMapObject, player_object.asm:187). - -- The player has no object_event here, so it falls through to the sheet. + local def = entity.def + if entity == self.player then def = self:playerObjectDef() end local colors = Palettes.spritePalette(self.palettes, daytime, - entity.spriteDef, entity.def) + entity.spriteDef, def) if not colors then return end -- The bake cache key has to be the palette actually chosen, or the three -- beasts -- one sheet, three object palettes -- would all share the first -- bake taken. - local id = Palettes.objectPaletteId(entity.def) + local id = Palettes.objectPaletteId(def) or entity.spriteDef.paletteId or 0 entity.sprite:setObjPalette(colors, ("gen2:%s:%d"):format(tostring(daytime), id)) diff --git a/tests/drivers/fossil_menu_test.lua b/tests/drivers/fossil_menu_test.lua index bb90f9e7..f8ef9e00 100644 --- a/tests/drivers/fossil_menu_test.lua +++ b/tests/drivers/fossil_menu_test.lua @@ -5,11 +5,26 @@ -- (ComeAgainText path) to prove nothing is taken on cancel. return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" local Menu = require("src.ui.Menu") + local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") local Bag = require("src.inventory.Bag") + local failures = 0 + local function ok(cond, what) + if not cond then failures = failures + 1 end + U.log((cond and "PASS " or "FAIL ") .. what) + return cond + end + local function inStack(cls) + for _, s in ipairs(game.stack.states) do + if getmetatable(s) == cls then return true end + end + return false + end + Bag.add(game.save, "DOME_FOSSIL", 1) Bag.add(game.save, "OLD_AMBER", 1) @@ -42,15 +57,27 @@ return function(game) U.tap(game, "a") U.wait(20) U.shot(game, DIR .. "/fossil_1_intro.png") - U.log("menu reached:", mash("a", function() return topIs(Menu) end)) + ok(mash("a", function() return topIs(Menu) end), "the fossil menu opens") + -- box still on screen under the menu (engine/events/cinnabar_lab.asm:22-24) + local under = game.stack.states[#game.stack.states - 1] + ok(getmetatable(under) == TextBox, + "the intro dialogue box is still on the stack under the menu") U.shot(game, DIR .. "/fossil_2_menu.png") + U.shot(game, DIR .. "/2281_01_menu_over_textbox.png") U.tap(game, "a") -- choose the first entry (DOME FOSSIL) - U.log("confirm reached:", mash("a", function() return topIs(ChoiceBox) end)) + -- prints under it (engine/events/cinnabar_lab.asm:55-67) + U.wait(90) + ok(inStack(Menu), "the menu is still up after a fossil is picked") + ok(topIs(TextBox), "with SeesFossilText printing over it") + U.shot(game, DIR .. "/2281_02_after_select_menu_stays.png") + ok(mash("a", function() return topIs(ChoiceBox) end), "the YES/NO confirm opens") + ok(inStack(Menu), "and the menu border is still up at the confirm") U.shot(game, DIR .. "/fossil_3_confirm.png") U.tap(game, "a") -- YES - U.log("deposit texts done:", mash("a", function() - return game.stack:top() == ow - end)) + -- both boxes go only at CloseTextDisplay (home/text_script.asm:105-130) + ok(mash("a", function() return game.stack:top() == ow end), + "the deposit texts end back on the overworld with both boxes popped") + ok(#game.stack.states == 1, "and nothing is left on the stack over it") U.shot(game, DIR .. "/fossil_4_done.png") U.log("GAVE_FOSSIL_TO_LAB:", tostring(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB), "STILL_REVIVING:", tostring(game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL), @@ -65,15 +92,18 @@ return function(game) game.save.labFossilMon = nil U.tap(game, "a") U.wait(20) - U.log("menu reached again:", mash("a", function() return topIs(Menu) end)) + ok(mash("a", function() return topIs(Menu) end), "the fossil menu opens again") U.shot(game, DIR .. "/fossil_5_menu_again.png") U.tap(game, "b") -- back out - U.log("cancel text done:", mash("a", function() - return game.stack:top() == ow - end)) + -- (engine/events/cinnabar_lab.asm:70-73) + U.wait(30) + ok(inStack(Menu), "B leaves the menu border up over ComeAgainText") + ok(mash("a", function() return game.stack:top() == ow end), + "and the cancel path still unwinds to the overworld") + ok(#game.stack.states == 1, "with no ghost box left behind") U.shot(game, DIR .. "/fossil_6_cancelled.png") U.log("after cancel OLD_AMBER:", tostring(game.save.inventory.OLD_AMBER), "GAVE_FOSSIL_TO_LAB:", tostring(game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB)) - U.log("DONE") - love.event.quit() + U.log("DONE", failures == 0 and "PASS" or (failures .. " FAILURES")) + love.event.quit(failures == 0 and 0 or 1) end diff --git a/tests/drivers/gen2_bike_warp_music_bug2286_test.lua b/tests/drivers/gen2_bike_warp_music_bug2286_test.lua new file mode 100644 index 00000000..1a4283ec --- /dev/null +++ b/tests/drivers/gen2_bike_warp_music_bug2286_test.lua @@ -0,0 +1,121 @@ +-- engine/overworld/events.asm:993, data/maps/setup_scripts.asm:117 +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Music = require("src.core.Music") +local Permissions = require("src.world.gen2.Permissions") + +local RIDEABLE = { TOWN = true, ROUTE = true, CAVE = true, GATE = true } +local STEP = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +return function(game) + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/bike-warp-2286" + local fails = 0 + local function say(line) print("[2286] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + local function bail(line) + say("FAIL " .. line) + love.event.quit(1) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then return bail("the gen2 world did not boot") end + + local SOURCE = "ROUTE_31" + local def = world.maps and world.maps[SOURCE] + if not (def and def.warps) then return bail(SOURCE .. " has no warps") end + + local mapSongs = game.data.audio and game.data.audio.mapSongs or {} + local function songOf(mapId) + return world:mapMusicSong(mapId) or mapSongs[mapId] + end + local source = songOf(SOURCE) + local candidates = {} + for _, w in ipairs(def.warps) do + local d = w.destMap and world.maps[w.destMap] + if d and RIDEABLE[d.environment] and songOf(w.destMap) then + local rank = (songOf(w.destMap) ~= source) and 1 or 2 + candidates[#candidates + 1] = { warp = w, rank = rank } + end + end + table.sort(candidates, function(a, b) return a.rank < b.rank end) + for _, c in ipairs(candidates) do + say(("candidate %d,%d -> %s (%s)"):format(c.warp.x, c.warp.y, + tostring(c.warp.destMap), tostring(songOf(c.warp.destMap)))) + end + if #candidates == 0 then return bail("no rideable-to-rideable warp on " .. SOURCE) end + + world:warpToMapId(SOURCE, 0, 0, "down") + U.wait(90) + if world.map.id ~= SOURCE then + return bail("could not reach " .. SOURCE .. " (on " .. tostring(world.map.id) .. ")") + end + + local shot, crossed, walkDir, warp = false, false, nil, nil + for _, c in ipairs(candidates) do + local w = c.warp + local coll = world.map:cellCollision(w.x, w.y) + local need = Permissions.carpetDirection(coll) + local tries = {} + for dir, d in pairs(STEP) do + if (not need) or dir == need then + local x, y = w.x - d[1], w.y - d[2] + if Permissions.isWalkable(world.map:cellCollision(x, y)) then + tries[#tries + 1] = { x, y, dir } + end + end + end + for _, t in ipairs(tries) do + world:warpToMapId(SOURCE, t[1], t[2], t[3]) + U.wait(90) + world:applyPlayerState(FieldMoves.PLAYER_BIKE) + world:playBikeMusic() + U.wait(10) + if not shot then + shot = true + ok(Music.current() == "Music_Bicycle", + "riding the bike theme on " .. SOURCE .. " (got " + .. tostring(Music.current()) .. ")") + U.shot(game, DIR .. "/2286_01_gold_bike_route31.png") + end + say(("try %d,%d walking %s onto %d,%d (at %s,%s)"):format(t[1], t[2], + t[3], w.x, w.y, tostring(world.player.cellX), + tostring(world.player.cellY))) + for _ = 1, 8 do + U.hold(game, t[3], 16) + if world.map.id ~= SOURCE then + crossed, walkDir, warp = true, t[3], w + break + end + end + say((" ended at %s,%s on %s"):format(tostring(world.player.cellX), + tostring(world.player.cellY), tostring(world.map.id))) + if crossed then break end + end + if crossed then break end + end + ok(crossed, "walked " .. tostring(walkDir) .. " into the warp at " + .. (warp and (warp.x .. "," .. warp.y) or "?") + .. " (now on " .. tostring(world.map.id) .. ")") + ok(FieldMoves.isBiking(world.playerState), + "still mounted on the far side (state " .. tostring(world.playerState) .. ")") + + U.wait(8 * Music.MAP_FADE) + local want = songOf(world.map.id) + say("destination song is " .. tostring(want) + .. ", playing " .. tostring(Music.current())) + ok(Music.current() ~= "Music_Bicycle", + "the bike theme did not restart across a walked warp") + ok(Music.current() == want, "the destination map's own theme is playing") + ok(Music.mapSong() == Music.current(), + "FadeToMapMusic left wMapMusic on the new song") + U.shot(game, DIR .. "/2286_02_gold_bike_dest.png") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gen2_surf_palette_2280_test.lua b/tests/drivers/gen2_surf_palette_2280_test.lua new file mode 100644 index 00000000..3c552b86 --- /dev/null +++ b/tests/drivers/gen2_surf_palette_2280_test.lua @@ -0,0 +1,51 @@ +-- engine/overworld/player_object.asm:29-41 +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Palettes = require("src.world.gen2.Palettes") + +return function(game) + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/surf-palette-2280" + local TAG = os.getenv("POKEPORT_VERSION") or "crystal" + local fails = 0 + local function say(line) print("[2280] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen2 world did not boot") + love.event.quit(1) + return + end + + local function surfAs(gender, shot, wantId, label) + game.save.player = game.save.player or {} + game.save.player.gender = gender + world:applyPlayerState(FieldMoves.PLAYER_SURF) + U.wait(20) + local id = Palettes.objectPaletteId(world:playerObjectDef()) + or (world.player.spriteDef and world.player.spriteDef.paletteId) or 0 + ok(id == wantId, label .. " (palette id " .. tostring(id) + .. ", want " .. tostring(wantId) .. ")") + U.shot(game, DIR .. "/" .. shot) + end + + world:warpToMapId("NEW_BARK_TOWN", 13, 7, "down") + U.wait(45) + say("standing on " .. tostring(world.map.id)) + + if TAG == "crystal" then + surfAs("male", "2280_01_crystal_chris_surf.png", 0, "Chris surfs orange") + surfAs("female", "2280_02_crystal_kris_surf.png", 1, "Kris surfs blue") + else + surfAs("male", "2280_03_gold_surf.png", 1, "Gold's surf blob stays blue") + end + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/heal_test.lua b/tests/drivers/heal_test.lua index 00419114..de59dbcf 100644 --- a/tests/drivers/heal_test.lua +++ b/tests/drivers/heal_test.lua @@ -85,6 +85,11 @@ return function(game) U.shot(game, DIR .. "/heal_08_end.png") -- -------- Mom (Reds House) -------- + local SUSPEND = os.getenv("HEAL_SUSPEND") + if SUSPEND then + require("src.core.ChipAudio").setSuspended(true) + U.log("audio suspended before the Mom heal") + end require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER") for _, m in ipairs(game.save.party) do m.hp = 1 @@ -102,7 +107,7 @@ return function(game) U.shot(game, DIR .. "/heal_mom_01_fade.png") local Music = require("src.core.Music") local sawJingle, jingleDoneFrame, fadeGone, greatFrame = false - for _ = 1, 600 do + for _ = 1, SUSPEND and 1200 or 600 do if Music.oneShotPlaying() then sawJingle = true end if sawJingle and not Music.oneShotPlaying() and not jingleDoneFrame then jingleDoneFrame = U.frame() @@ -118,6 +123,9 @@ return function(game) U.wait(1) end U.shot(game, DIR .. "/heal_mom_02_great.png") + if SUSPEND then + U.shot(game, DIR .. "/2246_01_heal_completes_with_audio_suspended.png") + end U.log("mom saw_jingle:", sawJingle, "jingle_done_frame:", jingleDoneFrame, "fade_gone_frame:", fadeGone, @@ -128,5 +136,7 @@ return function(game) return game.stack:top() == ow end) U.shot(game, DIR .. "/heal_mom_03_end.png") + U.log("2246 heal driver PASS:", greatFrame ~= nil) + love.event.quit(greatFrame and 0 or 1) end diff --git a/tests/drivers/heal_visual_probe.lua b/tests/drivers/heal_visual_probe.lua new file mode 100644 index 00000000..e655930a --- /dev/null +++ b/tests/drivers/heal_visual_probe.lua @@ -0,0 +1,93 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local MODE = os.getenv("PROBE_MODE") or "log" + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local Music = require("src.core.Music") + + local mon = Pokemon.new(game.data, "CHARMANDER", 12) + mon.hp = 3 + table.insert(game.save.party, mon) + local mon2 = Pokemon.new(game.data, "PIDGEY", 8) + mon2.hp = 1 + table.insert(game.save.party, mon2) + require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER") + + U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up") + local ow = game.overworld + + local function topIsText() + return getmetatable(game.stack:top()) == TextBox + end + + U.tap(game, "a") + U.wait(20) + local started = false + for _ = 1, 400 do + if ow.fadeOverlay then started = true break end + U.tap(game, "a") + U.wait(3) + end + U.log("fade started:", started, "at frame", U.frame()) + local t0 = U.frame() + + if MODE == "log" then + local prev + for _ = 1, 900 do + local ov = ow.fadeOverlay + local a = ov and ov.alpha or nil + local bgp = ov and ov.bgp and ov:bgp() + local line = string.format( + "f=%d alpha=%s bgp=%s color=%s jingle=%s text=%s", + U.frame() - t0, a and string.format("%.3f", a) or "nil", + bgp and string.format("%02X", bgp) or "--", + ov and tostring(ov.color) or "-", + tostring(Music.oneShotPlaying()), tostring(topIsText())) + if line:gsub("^f=%d+ ", "") ~= prev then + prev = line:gsub("^f=%d+ ", "") + U.log("TRACE", line) + end + if not ov and U.frame() - t0 > 30 and topIsText() then + U.log("TRACE done at", U.frame() - t0) + break + end + U.wait(1) + end + U.log("probe log PASS") + love.event.quit(0) + return + end + + local n = 0 + local function burst(count, gap, tag) + for _ = 1, count do + n = n + 1 + local ov = ow.fadeOverlay + local name = string.format("%s/heal_f%03d_%s_a%s.png", DIR, + U.frame() - t0, tag, + ov and string.format("%02d", math.floor((ov.alpha or 0) * 99)) or "xx") + U.shot(game, name) + if gap > 0 then U.wait(gap) end + end + end + + burst(10, 1, "fadeout") + burst(8, 12, "held") + burst(10, 30, "hold2") + for _ = 1, 900 do + if not Music.oneShotPlaying() then break end + U.wait(1) + end + burst(12, 1, "fadein") + U.wait(10) + n = n + 1 + U.shot(game, string.format("%s/heal_f%03d_after.png", DIR, U.frame() - t0)) + for _ = 1, 200 do + if topIsText() then break end + U.wait(1) + end + U.shot(game, string.format("%s/heal_f%03d_greattext.png", DIR, U.frame() - t0)) + U.log("shots taken:", n) + love.event.quit(0) +end diff --git a/tests/drivers/hypnosis_miss_bug2282_test.lua b/tests/drivers/hypnosis_miss_bug2282_test.lua new file mode 100644 index 00000000..187348fa --- /dev/null +++ b/tests/drivers/hypnosis_miss_bug2282_test.lua @@ -0,0 +1,80 @@ +-- engine/battle/effects.asm:57 -- engine/battle/move_effects/leech_seed.asm:28 +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/hypnosis_miss_bug2282_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local lead = Pokemon.new(game.data, "GENGAR", 29) + lead.moves = { + { id = "HYPNOSIS", pp = 20 }, + { id = "LEECH_SEED", pp = 10 }, + { id = "GROWL", pp = 40 }, + { id = "TACKLE", pp = 35 }, + } + game.save.party = { lead } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local failures = 0 + + -- home/text.asm:209 -- a page is finished when every glyph is typed and + -- PromptText is blinking the arrow, so the whole string is on screen + local function typedFully(battle, needle) + local item = battle.current + local text = item and item.text + if not text or not text:find(needle, 1, true) then return false end + return (battle.total or 0) > 0 and (battle.charIndex or 0) >= battle.total + end + + local function fight(slot, needle, label, shot) + local battle = BattleState.newWild(game, "PONYTA", 29) + battle.onFinish = function() end + battle.rng = function(a, b) return b end + ow:pushBattle(battle) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + U.log("battle phase:", battle.phase) + U.tap(game, "a"); U.wait(20) + for _ = 2, slot do U.tap(game, "down"); U.wait(6) end + U.tap(game, "a") + local typed = false + for _ = 1, 900 do + if typedFully(battle, needle) then typed = true break end + U.wait(1) + end + if typed then + U.log("full page:", (battle.current.text:gsub("\n", " / "))) + U.shot(game, DIR .. "/" .. shot) + U.log("PASS " .. label) + else + failures = failures + 1 + U.log("FAIL " .. label .. ": page never finished typing " .. needle) + local item = battle.current + U.log(" current:", item and item.text, battle.charIndex, battle.total) + end + battle.result = "run" + for _ = 1, 200 do + if game.stack:top() ~= battle then break end + U.tap(game, "b"); U.wait(6) + end + end + + fight(1, "didn't affect", "hypnosis prints DidntAffectText", + "bug2282_1_hypnosis_miss.png") + fight(2, "evaded attack", "leech seed prints EvadedAttackText", + "bug2282_2_leech_seed_miss.png") + fight(3, "But, it failed!", "growl prints ButItFailedText", + "bug2282_3_growl_miss.png") + fight(4, "attack missed!", "tackle still prints AttackMissedText", + "bug2282_4_tackle_miss.png") + + U.log(failures == 0 and "PASS bug2282_status_miss_text" + or ("FAIL bug2282_status_miss_text: " .. failures .. " shots")) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/lift_key_sight_2277.lua b/tests/drivers/lift_key_sight_2277.lua new file mode 100644 index 00000000..fc6f8bd1 --- /dev/null +++ b/tests/drivers/lift_key_sight_2277.lua @@ -0,0 +1,122 @@ +-- pokeyellow/scripts/RocketHideoutB4F.asm:401, home/trainers.asm:341 +-- POKEPORT_IDENTITY=yellow-sep04 POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 \ +-- POKEPORT_SHOT_DIR=.bazinga/BSA/09-16-26-00-standard/shots \ +-- POKEPORT_DRIVER=tests/drivers/lift_key_sight_2277.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + + local failures = 0 + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end + return ok + end + local function bail() + U.log(failures == 0 and "DRIVER PASS" or "DRIVER FAIL", failures, "failure(s)") + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local MAP = "ROCKET_HIDEOUT_B4F" + local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET" + local BALL = "ROCKETHIDEOUTB4F_LIFT_KEY" + + local GameVersion = require("src.core.GameVersion") + if not check("running on Yellow", GameVersion.isYellow()) then bail() end + + local function npcNamed(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + end + local function gruntIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.text == TEXT then return n end + end + end + + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "MEWTWO", 80) } + game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = nil + if game.save.objectToggles then game.save.objectToggles[MAP] = nil end + + U.teleport(game, MAP, 14, 6, "up") + U.wait(10) + local ow = game.overworld + local grunt = gruntIn(ow) + if not check("the Yellow grunt is on B4F", grunt ~= nil) then bail() end + U.log(("grunt at (%d, %d) facing %s") + :format(grunt.cellX, grunt.cellY, tostring(grunt.facing))) + check("the LIFT KEY ball starts hidden", npcNamed(ow, BALL) == nil) + + local waypoints = { { 14, grunt.cellY + 1 }, { grunt.cellX, grunt.cellY + 1 } } + for _, wp in ipairs(waypoints) do + for _ = 1, 10 do + local p = game.overworld.player + if game.overworld.engaging or game.overworld.emote then break end + if p.cellX == wp[1] and p.cellY == wp[2] then break end + U.log(("player at (%d, %d) heading for (%d, %d)") + :format(p.cellX, p.cellY, wp[1], wp[2])) + if p.cellY > wp[2] then U.hold(game, "up", 16) + elseif p.cellY < wp[2] then U.hold(game, "down", 16) + elseif p.cellX > wp[1] then U.hold(game, "left", 16) + else U.hold(game, "right", 16) end + U.wait(4) + end + end + + local sighted = false + for _ = 1, 120 do + ow = game.overworld + if ow.engaging or ow.emote then sighted = true break end + U.wait(1) + end + if not check("he spots the player and engages on sight", sighted) then bail() end + + local BattleState = require("src.battle.BattleState") + local fought = false + for _ = 1, 90 do + if getmetatable(game.stack:top()) == BattleState then fought = true break end + U.tap(game, "a") + U.wait(8) + end + if not check("the walk-up runs into the battle", fought) then bail() end + + local won = false + for _ = 1, 600 do + if getmetatable(game.stack:top()) ~= BattleState then won = true break end + U.tap(game, "a") + U.wait(10) + end + if not check("the battle ends", won) then bail() end + + for _ = 1, 60 do + if game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(8) + end + U.wait(30) + + ow = game.overworld + check("EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 is set", + game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 == true) + local dropped = game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true + check("the sight win sets EVENT_ROCKET_DROPPED_LIFT_KEY (#2277)", dropped) + local ball = npcNamed(ow, BALL) + check("the LIFT KEY ball is spawned on the floor", ball ~= nil) + if ball then + U.log(("ball at (%d, %d)"):format(ball.cellX, ball.cellY)) + end + + if not U.shot(game, SHOT_DIR .. "/2277_01_lift_key_ball_after_sight_battle.png") then + failures = failures + 1 + else + U.log("captured", SHOT_DIR .. "/2277_01_lift_key_ball_after_sight_battle.png") + end + + U.log("Right: after the grunt's SIGHT battle the POKE BALL is on the floor") + U.log("to his left, with no second conversation. Wrong is an empty cell.") + bail() +end diff --git a/tests/drivers/link_trade_round2_2198_test.lua b/tests/drivers/link_trade_round2_2198_test.lua new file mode 100644 index 00000000..db13434e --- /dev/null +++ b/tests/drivers/link_trade_round2_2198_test.lua @@ -0,0 +1,130 @@ +-- TradeCenter_Trade .tradeCompleted (engine/link/cable_club.asm:870) saves, +-- SHOT_DIR=/tmp/2198 POKEPORT_IDENTITY=red-sep04 \ +-- POKEPORT_DRIVER=tests/drivers/link_trade_round2_2198_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/2198" + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local LinkState = require("src.link.LinkState") + local Net = require("src.link.Net") + local Session = require("src.link.Session") + local TextBox = require("src.render.TextBox") + local TradeAnim = require("src.ui.TradeAnim") + + local function fail(msg) + U.log("FAIL link_trade_round2_2198:", msg) + love.event.quit(1) + end + + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + U.wait(5) + + game.save.party = { + Pokemon.new(game.data, "SANDSHREW", 9), + Pokemon.new(game.data, "PIDGEY", 7), + } + local peerParty = { + Pokemon.new(game.data, "RATTATA", 8), + Pokemon.new(game.data, "EKANS", 9), + } + + local mine, theirs = Net.loopbackPair() + local ls = LinkState.new(game) + ls.net = Session.new(mine, { role = "host", kind = "link" }) + ls.peerName = "MG" + ls.verdict = "full" + ls.stage = "trade" + ls.index, ls.theirIndex, ls.side = 1, 1, "mine" + ls.trade = Protocol.TradeSession.new(game.data, game.save.party, + { peerName = "MG" }) + ls.net:send(ls.trade:opening()) + game.stack:push(ls) + + local peerTrade = Protocol.TradeSession.new(game.data, peerParty, + { peerName = "RED" }) + theirs:send(peerTrade:opening()) + local peerPicked, peerConfirmed = false, false + local peerRounds = 1 + + local function pumpPeer() + theirs:update() + for _, msg in ipairs(theirs:poll()) do + local reply = peerTrade:handle(msg) + if reply then theirs:send(reply) end + end + if peerTrade.stage == "picking" and not peerPicked then + peerPicked = true + theirs:send(peerTrade:pick(1)) + elseif peerTrade.stage == "confirming" and not peerConfirmed then + peerConfirmed = true + theirs:send(peerTrade:confirm(true)) + elseif peerTrade.stage == "done" then + peerTrade:apply(nil) + peerRounds = peerRounds + 1 + peerPicked, peerConfirmed = false, false + peerTrade = Protocol.TradeSession.new(game.data, peerParty, + { peerName = "RED" }) + theirs:send(peerTrade:opening()) + end + end + + local firstTrade = ls.trade + local ourPicked, ourConfirmed = false, false + local function ourStep() + if game.stack:top() ~= ls or ls.stage ~= "trade" then return end + local t = ls.trade + if t.stage == "picking" and not ourPicked then + ourPicked = true + ls.net:send(t:pick(1)) + elseif t.stage == "confirming" and not ourConfirmed then + ourConfirmed = true + ls.confirmed = true + ls.net:send(t:confirm(true)) + end + end + + local sawAnim = false + local reached = false + for _ = 1, 6000 do + pumpPeer() + local top = game.stack:top() + if getmetatable(top) == TradeAnim then + sawAnim = true + U.tap(game, "start") -- engine/movie/trade.asm:20 skip + elseif getmetatable(top) == TextBox then + U.tap(game, "a") + elseif top == ls then + ourStep() + if ls.trade ~= firstTrade and ls.trade.stage == "picking" then + reached = true + break + end + elseif top == game.overworld then + return fail("LinkState was torn down after the first trade") + end + U.wait(1) + end + + if not sawAnim then return fail("the trade cinematic never ran") end + if not reached then + return fail("round 2 never reached picking (trade stage " + .. tostring(ls.trade and ls.trade.stage) .. ")") + end + + U.shot(game, DIR .. "/2198_04_round2_select.png") + U.log("round 2: stage=", ls.trade.stage, + "party=", game.save.party[1] and game.save.party[1].species, + "theirs=", (ls.trade.theirParty or {})[1] and ls.trade.theirParty[1].species, + "session=", ls.net:getStatus(), "peerRounds=", peerRounds) + + local ok = game.stack:top() == ls + and game.save.party[1] and game.save.party[1].species == "RATTATA" + and ls.net:getStatus() ~= "closed" + and (ls.trade.theirParty or {})[1] ~= nil + and ls.trade.theirParty[1].species == "SANDSHREW" + and peerRounds == 2 + U.log(ok and "PASS link_trade_round2_2198" or "FAIL link_trade_round2_2198") + ls.net:close() + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/link_trade_screen_2198_test.lua b/tests/drivers/link_trade_screen_2198_test.lua new file mode 100644 index 00000000..a1d42d16 --- /dev/null +++ b/tests/drivers/link_trade_screen_2198_test.lua @@ -0,0 +1,80 @@ +-- TradeCenter_DrawPartyLists (engine/link/cable_club.asm:635) draws two +-- SHOT_DIR=/tmp/2198 POKEPORT_IDENTITY=red-sep04 \ +-- POKEPORT_DRIVER=tests/drivers/link_trade_screen_2198_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/2198" + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local LinkState = require("src.link.LinkState") + local Net = require("src.link.Net") + local Session = require("src.link.Session") + + local function fail(msg) + U.log("FAIL link_trade_screen_2198:", msg) + love.event.quit(1) + end + + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + U.wait(5) + + local nicked = Pokemon.new(game.data, "CHARMANDER", 12) + nicked.nickname = "FLUFFY" + game.save.party = { + Pokemon.new(game.data, "SANDSHREW", 9), + nicked, + Pokemon.new(game.data, "PIDGEY", 7), + } + local peerParty = { + Pokemon.new(game.data, "MACHOKE", 30), + Pokemon.new(game.data, "RATTATA", 8), + } + + local trade = Protocol.TradeSession.new(game.data, game.save.party) + trade:handle({ type = "party", mons = Protocol.packParty(peerParty) }) + if trade.stage ~= "picking" then + return fail("trade session never reached picking: " .. tostring(trade.stage)) + end + + local ls = LinkState.new(game) + local mine, theirs = Net.loopbackPair() + ls.peerTransport = theirs + ls.net = Session.new(mine, { role = "host", kind = "link" }) + ls.peerName = "MG" + ls.verdict = "full" + ls.stage = "trade" + ls.trade = trade + ls.index = 1 + ls.theirIndex = 1 + ls.side = "mine" + game.stack:push(ls) + U.wait(2) + if game.stack:top() ~= ls then + return fail("LinkState left the trade stage on its own") + end + + U.shot(game, DIR .. "/2198_01_trade_party_lists.png") + U.log("party lists: side=", ls.side, "index=", ls.index) + + -- engine/link/cable_club.asm:468 + U.tap(game, "a") + U.wait(2) + U.shot(game, DIR .. "/2198_02_stats_trade_row.png") + U.log("pickChoice=", ls.pickChoice) + U.tap(game, "b") + U.wait(2) + + -- engine/link/cable_club.asm:537 + for _ = 1, 6 do + if ls.side == "cancel" then break end + U.tap(game, "down") + U.wait(2) + end + U.shot(game, DIR .. "/2198_03_cancel_cursor.png") + U.log("after DOWN past the last mon: side=", ls.side) + + local ok = ls.side == "cancel" and ls:listLabel(game.save.party[1]) == "SANDSHREW" + and ls:listLabel(nicked) == "CHARMANDER" + U.log(ok and "PASS link_trade_screen_2198" or "FAIL link_trade_screen_2198") + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/shaderfx_portable_paths.lua b/tests/drivers/shaderfx_portable_paths.lua new file mode 100644 index 00000000..87ff4532 --- /dev/null +++ b/tests/drivers/shaderfx_portable_paths.lua @@ -0,0 +1,44 @@ +-- POKEPORT_IDENTITY=bsa2227port POKEPORT_DRIVER=tests/drivers/shaderfx_portable_paths.lua love /tmp/pm/lovegame +local U = require("tests.drivers.util") +local ShaderFX = require("src.render.ShaderFX") +local SaveData = require("src.core.SaveData") + +return function(game) + local fails = 0 + local function ok(cond, label) + if not cond then fails = fails + 1 end + print((cond and "PASS " or "FAIL ") .. label) + end + + U.wait(30) + + local saveDir = love.filesystem.getSaveDirectory() + local base = SaveData.portableBaseDir and SaveData.portableBaseDir() + print("saveDir=" .. tostring(saveDir)) + print("portableBaseDir=" .. tostring(base)) + print("presetDir=" .. tostring(ShaderFX.presetDir())) + + ok(base ~= nil, "portable.txt puts this install in portable mode") + ok(base ~= saveDir, "the portable base and the save directory are different roots") + + love.filesystem.createDirectory("shaders") + love.filesystem.createDirectory("shaders/handheld") + ok(love.filesystem.write("shaders/handheld/probe.slangp", "#reference \"x\"\n"), + "a preset written through love.filesystem lands in the save directory") + + local found + for _, e in ipairs(ShaderFX.list()) do + if e.name == "probe.slangp" then found = e end + end + ok(found ~= nil, "ShaderFX.list() enumerates the probe preset") + if found then + print("fullPath=" .. tostring(found.fullPath)) + local f = io.open(found.fullPath, "rb") + ok(f ~= nil, "io.open(entry.fullPath) opens the file the bridge is handed") + if f then f:close() end + end + + love.filesystem.remove("shaders/handheld/probe.slangp") + print(("shaderfx_portable_paths: %s"):format(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/trade_anim_test.lua b/tests/drivers/trade_anim_test.lua index 3363a9f1..8bdae329 100644 --- a/tests/drivers/trade_anim_test.lua +++ b/tests/drivers/trade_anim_test.lua @@ -1,11 +1,12 @@ -- Driver: NPC in-game trade cable animation (InternalClockTradeAnim). -- Vermilion Trade House: SPEAROW -> FARFETCH'D (DUX). -- --- SHOT_DIR=/tmp/trade_shots POKEPORT_DRIVER=tests/drivers/trade_anim_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/trade_shots POKEPORT_DRIVER=tests/drivers/trade_anim_test.lua love . return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/trade_shots" + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/trade_shots" local Pokemon = require("src.pokemon.Pokemon") local TradeAnim = require("src.ui.TradeAnim") local TextBox = require("src.render.TextBox") @@ -16,7 +17,14 @@ return function(game) return getmetatable(game.stack:top()) == cls end - game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) } + game.save.options.colors = "ogred" + require("src.render.PaletteFX").setMode("ogred") + + -- engine/movie/trade.asm:186 -- wNameBuffer holds the SPECIES, so a + -- nicknamed sender proves the dialog is not reading mon.nickname + local sent = Pokemon.new(game.data, "SPEAROW", 10) + sent.nickname = "CRINKLES" + game.save.party = { sent } U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up") U.wait(5) U.shot(game, DIR .. "/trade_00_house.png") @@ -48,7 +56,8 @@ return function(game) end local anim = game.stack:top() if getmetatable(anim) ~= TradeAnim then - U.log("FAIL: TradeAnim never appeared") + U.log("FAIL trade_anim_2278: TradeAnim never appeared") + love.event.quit(1) return end U.log("TradeAnim phase:", anim.phase) @@ -65,8 +74,23 @@ return function(game) U.wait(1) end - -- hold mid show_player (scx settled, mon visible) ffUntil("show_player", 1) + -- engine/movie/trade.asm:245 -- rWX carries the info box in from the right + -- while hSCX carries the mon in from the left + while anim.phase == "show_player" and anim.sub == "slide" and anim.scx > 92 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/2278_01_box_enters_right.png") + U.log("box enter scx=", anim.scx, "sub=", anim.sub) + + while anim.phase == "show_player" and anim.sub == "slide" and anim.scx > 60 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/2278_02_show_player_slide.png") + U.log("slide scx=", anim.scx) + while anim.phase == "show_player" and (anim.sub ~= "hold" or anim.scx > 0) do anim:update(1 / 60) end @@ -97,33 +121,36 @@ return function(game) U.wait(1) U.shot(game, DIR .. "/trade_06_transfer_lr.png") + -- engine/movie/trade.asm:602 + anim.waitingText = true + anim.cableFlash = false + U.shot(game, DIR .. "/2278_07_bgp_normal.png") + anim.cableFlash = true + U.shot(game, DIR .. "/2278_07_bgp_flash.png") + anim.cableFlash = false + anim.waitingText = false + ffUntil("went_to", 800) - for _ = 1, 600 do - if topIs(TextBox) then - local tb = game.stack:top() - if tb.done then break end - -- finish typewriter so both lines are visible - if tb.codes then tb.charIndex = #tb.codes end - if tb.pages and tb.pageIndex and tb.lineIndex then - local page = tb.pages[tb.pageIndex] - if page and tb.lineIndex < #page then - tb.lineIndex = #page - tb.shown = {} - for _, line in ipairs(page) do - local codes = require("src.render.Font").encode(line) - tb.shown[#tb.shown + 1] = codes - end - tb.charIndex = #(tb.shown[#tb.shown] or {}) - tb.done = true - end - end + -- engine/movie/trade.asm:186 -- the dialog is TradeAnim's own box, not a + -- TextBox on the stack, and it draws whole (no typewriter) + for _ = 1, 400 do + if anim.phase == "went_to" and anim.sub == "text" and anim.dialogText then break end U.wait(1) end - U.wait(2) - U.shot(game, DIR .. "/trade_07_went_to.png") - U.log("went-to:", topIs(TextBox)) + U.shot(game, DIR .. "/2278_09_went_to_species.png") + U.log("went-to text:", anim.dialogText and anim.dialogText:gsub("\n", " / ")) + + for _ = 1, 600 do + if anim.phase == "for_sends" and anim.sub == "sends_text" + and anim.dialogText then + break + end + U.wait(1) + end + U.shot(game, DIR .. "/2278_09_sends_species.png") + U.log("sends text:", anim.dialogText and anim.dialogText:gsub("\n", " / ")) ffUntil("transfer_rl", 2500) for _ = 1, 16 do anim:update(1 / 60) end @@ -131,6 +158,31 @@ return function(game) U.shot(game, DIR .. "/trade_08_transfer_rl.png") ffUntil("show_enemy", 800) + -- engine/movie/trade.asm:358 + while anim.phase == "show_enemy" and anim.sub ~= "ball_rest" do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/2278_10_ball_rest.png") + U.log("ball_rest sub=", anim.sub, "box=", anim.boxVisible, + "mon=", anim.monVisible, "t=", anim.t) + + -- engine/movie/trade.asm:361 -- the ball holds 60 frames on the box before + -- the poof, so shoot the tail of the hold too + while anim.phase == "show_enemy" and anim.sub == "ball_rest" and anim.t < 50 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/2278_10_ball_rest_hold.png") + U.log("ball_rest hold sub=", anim.sub, "t=", anim.t, + "mon=", anim.monVisible) + + while anim.phase == "show_enemy" and anim.sub == "ball_rest" do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/2278_10_poof.png") + while anim.phase == "show_enemy" and not anim.monVisible do anim:update(1 / 60) end @@ -151,5 +203,7 @@ return function(game) local mon = game.save.party[1] U.log("party:", mon and mon.species, "nick:", mon and mon.nickname, "ot:", mon and mon.ot) - U.log("done") + local ok = mon ~= nil and mon.species == "FARFETCHD" + U.log(ok and "PASS trade_anim_2278" or "FAIL trade_anim_2278") + love.event.quit(ok and 0 or 1) end diff --git a/tests/engine/audio_device_reset.lua b/tests/engine/audio_device_reset.lua index e313f475..5cb5c191 100644 --- a/tests/engine/audio_device_reset.lua +++ b/tests/engine/audio_device_reset.lua @@ -92,6 +92,7 @@ local function fixtureData() audio = { songs = { Music_PalletTown = chipSong(4), + Music_Other = chipSong(5), Music_Streamed = { file = "assets/song.ogg", loopFile = "assets/song_loop.ogg" }, }, @@ -231,6 +232,60 @@ check(newLoop ~= loop and not newLoop.playing, check(not intro.playing, "the streamed source from the dead device was stopped") eq(Music.current(), "Music_Streamed", "and the song label is unchanged") +Music.stop() +clearSources() +audioSuspend() +check(not Music.playOnce(data, "Music_Other"), + "playOnce refuses to start a jingle on a suspended device") +check(not Music.oneShotPlaying(), + "no wait is armed for a jingle that never started") +check(not ChipAudio.awaitingFirstBuffer(), + "a suspended worker never claims to be awaiting its first buffer") +ChipAudio.setSuspended(false) + +local CEILING = Music.ONE_SHOT_CEILING +check(CEILING ~= nil, "Music bounds the one-shot wait") +CEILING = CEILING or 600 + +Music.playMap(data, "PALLET_TOWN", false, false) +check(Music.playOnce(data, "Music_Other"), "playOnce starts a chip jingle") +check(Music.oneShotPlaying(), "and arms the one-shot wait") +local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest() +check(clearAwait ~= nil, "the never-finishing jingle window is forced") +for _ = 1, CEILING - 1 do Music.update(data) end +check(Music.oneShotPlaying(), "the wait is never cut short before the ceiling") +Music.update(data) +check(not Music.oneShotPlaying(), + "a one-shot jingle that never sounds still releases its waiters") +eq(Music.current(), "Music_PalletTown", "and the map theme came back") +if clearAwait then clearAwait() end + +Music.playMap(data, "PALLET_TOWN", false, false) +check(Music.playOnce(data, "Music_Other"), "playOnce starts the jingle again") +ChipAudio.stopMusic() +Music.update(data) +check(not Music.oneShotPlaying(), + "a jingle that really ends restores on the very next frame") + +do + local ScriptRunner = require("src.script.ScriptRunner") + local game = { data = data, save = { flags = {}, inventory = {} } } + local runner = ScriptRunner.new(game, nil) + Music.playMap(data, "PALLET_TOWN", false, false) + runner:run({ { "play_once", "Music_Other" }, + { "set_field", "healed", 1 } }, {}) + local await = ChipAudio._simulateAwaitingFirstBufferForTest() + check(await ~= nil and runner:isRunning(), + "the script parks on the jingle while it can never finish") + for _ = 1, CEILING + 60 do + Music.update(data) + runner:update() + end + if await then await() end + check(not runner:isRunning() and game.save.healed == 1, + "the heal script advances past a jingle that never sounds (#2246)") +end + ChipAudio = freshChipAudio(false) Music.reload() clearSources() diff --git a/tests/engine/gen2_bike_warp_music_bug2286.lua b/tests/engine/gen2_bike_warp_music_bug2286.lua new file mode 100644 index 00000000..91dd2ee5 --- /dev/null +++ b/tests/engine/gen2_bike_warp_music_bug2286.lua @@ -0,0 +1,125 @@ +-- data/maps/setup_scripts.asm:48, :89, :117; engine/overworld/events.asm:993 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +love.audio = { + newSource = function(file, mode) + return setmetatable({ file = file, mode = mode }, Source) + end, +} + +local Music = require("src.core.Music") +local World = require("src.world.gen2.World") + +local DATA = { audio = { + runtime = true, + generation = 2, + songs = { + Music_Bicycle = { file = "bicycle.wav" }, + Music_Route30 = { file = "route30.wav" }, + Music_DarkCave = { file = "darkcave.wav" }, + }, + musicOrder = { "Music_Route30", "Music_DarkCave" }, +} } + +local MAPS = { + ROUTE_30 = { music = 0x80, environment = "ROUTE" }, + DARK_CAVE = { music = 0x81, environment = "CAVE" }, +} + +-- the MAPSETUP_* bytes World:runMapSetup threads down (constants/map_setup_constants.asm) +local WARP, DOOR, FALL = 0xf1, 0xf5, 0xf6 +local CONTINUE, TRAIN, BADWARP = 0xf2, 0xf9, 0xfb + +local function newWorld(state) + local world = World.new({ data = DATA, save = { engineFlags = {} } }) + world.maps = MAPS + world.playerState = state or "bike" + Music.stop() + return world +end + +local function settle() + for _ = 1, 8 * Music.MAP_FADE do Music.update(DATA) end +end + +local world = newWorld() +world:setMapMusic("ROUTE_30", false, WARP) +eq(Music.current(), "Music_Bicycle", "a WARP-class load is PlayMapMusicBike") +world.setupMethod = DOOR +world:setMapMusic("DARK_CAVE", false) +settle() +eq(Music.current(), "Music_DarkCave", + "a walked warp fades to the destination's own song while biking") +eq(Music.mapSong(), "Music_DarkCave", + "FadeToMapMusic overwrites wMapMusic on a door load too") + +Music.stop() +world:restoreMapMusic() +eq(Music.current(), "Music_DarkCave", "a restore after it replays the map song") + +-- engine/overworld/events.asm:836 (Dig / Escape Rope) and the pit fall share +-- MapSetupScript_Door's body (setup_scripts.asm:97-101) +for _, method in ipairs({ FALL, TRAIN, BADWARP }) do + world = newWorld() + world.setupMethod = method + world:setMapMusic("DARK_CAVE", false) + settle() + eq(Music.current(), "Music_DarkCave", + ("setup %02x takes FadeToMapMusic"):format(method)) +end + +-- the rows that really do restart the theme (setup_scripts.asm:48, :154, :175) +for _, method in ipairs({ WARP, CONTINUE }) do + world = newWorld() + world.setupMethod = method + world:setMapMusic("DARK_CAVE", false) + settle() + eq(Music.current(), "Music_Bicycle", + ("setup %02x keeps PlayMapMusicBike"):format(method)) +end + +world = newWorld() +world.setupMethod = DOOR +world:setMapMusic("ROUTE_30", true) +settle() +eq(Music.current(), "Music_Route30", "a connection crossing still fades across") + +world = newWorld() +world:setMapMusic("DARK_CAVE", false) +eq(Music.current(), "Music_Bicycle", "an unmethoded load defaults to WARP") + +world = newWorld("normal") +world.setupMethod = DOOR +world:setMapMusic("DARK_CAVE", false) +settle() +eq(Music.current(), "Music_DarkCave", "on foot a door load plays the map song") + +world = newWorld() +local seen +world:runMapSetup(DOOR, function() + seen = world.setupMethod + return true +end) +world.mapSetup.load() +eq(seen, DOOR, "runMapSetup publishes its method for the load it wraps") +eq(world.setupMethod, nil, "and takes it back down again") + +T.finish("gen2_bike_warp_music_bug2286") diff --git a/tests/engine/gen2_controller_parity_bug1570.lua b/tests/engine/gen2_controller_parity_bug1570.lua index 9fa429d7..b6d84a4a 100644 --- a/tests/engine/gen2_controller_parity_bug1570.lua +++ b/tests/engine/gen2_controller_parity_bug1570.lua @@ -102,4 +102,29 @@ gold:joystickremoved(rawJoy) Input:step() check(not Input:isDown("a"), "joystickremoved drops stranded holds in Gold") +Input:init() +local dirs = {} +gold = newGold(nil) +gold._cycleSpeed = function(_, dir) dirs[#dirs + 1] = dir end +gold:gamepadaxis(gamepadJoy, "triggerright", 1.0) +eq(dirs[#dirs], 1, "R2 speeds up in Gold") +gold:gamepadaxis(gamepadJoy, "triggerright", 1.0) +eq(#dirs, 1, "on the crossing only") +gold:gamepadaxis(gamepadJoy, "triggerleft", 1.0) +eq(dirs[#dirs], -1, "L2 slows down in Gold") + +Input:applyBindings({ speedUp = { pad = "joy12" } }) +gold:joystickpressed(rawJoy, 12) +eq(dirs[#dirs], 1, "a raw-stick SPEED + binding fires in Gold") +eq(#dirs, 3, "once") +Input:step() +check(not Input:wasPressed("a"), "without also pressing a Game Boy button") + +Input:init() +captured = {} +gold = newGold(capturingTop) +gold:gamepadaxis(gamepadJoy, "triggerleft", 1.0) +eq(captured.pad, "triggerleft", "an armed CONTROLS row captures L2 in Gold") +Input:init() + T.finish() diff --git a/tests/engine/gen2_crystal_surf_palette_2280.lua b/tests/engine/gen2_crystal_surf_palette_2280.lua new file mode 100644 index 00000000..34de7859 --- /dev/null +++ b/tests/engine/gen2_crystal_surf_palette_2280.lua @@ -0,0 +1,79 @@ +-- engine/overworld/player_object.asm:29-41 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +love = require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local Palettes = require("src.world.gen2.Palettes") +local World = require("src.world.gen2.World") + +local RED = { { 255, 0, 0 }, { 200, 0, 0 }, { 120, 0, 0 }, { 0, 0, 0 } } +local BLUE = { { 0, 0, 255 }, { 0, 0, 200 }, { 0, 0, 120 }, { 0, 0, 0 } } +local PALETTES = { objects = { DAY = { RED, BLUE } } } + +-- constants/sprite_data_constants.asm:15-38 +eq(Palettes.objectPaletteId({ palette = 8 }), 0, "PAL_NPC_RED drops bit 3") +eq(Palettes.objectPaletteId({ palette = 9 }), 1, "PAL_NPC_BLUE drops bit 3") +eq(Palettes.objectPaletteId(nil), nil, "no object_event, no override") + +-- data/sprites/sprites.asm:92 +local SURF = { paletteId = 1 } +eq(Palettes.spritePalette(PALETTES, "DAY", SURF, nil), BLUE, + "SPRITE_SURF's own table palette is PAL_OW_BLUE") +eq(Palettes.spritePalette(PALETTES, "DAY", SURF, { palette = 8 }), RED, + "the map object's PAL_NPC_RED outranks the sheet") + +local function newWorld(version, gender) + GameVersion.set(version) + local world = World.new({ + data = {}, save = { engineFlags = {}, player = { gender = gender } }, + }) + world.palettes = PALETTES + world.daytime = "DAY" + world.testVersion = version + return world +end + +local male = newWorld("crystal", "male") +eq(male:playerObjectDef().palette, 8, "Crystal's Chris spawns PAL_NPC_RED") +local female = newWorld("crystal", "female") +eq(female:playerObjectDef().palette, 9, "Crystal's Kris spawns PAL_NPC_BLUE") +-- pokegold/engine/overworld/player_object.asm:19 +local gold = newWorld("gold", "male") +eq(gold:playerObjectDef(), nil, "Gold's SpawnPlayer stamps no palette") +eq(newWorld("silver", "male"):playerObjectDef(), nil, + "and neither does Silver's") + +local function baked(world, spriteDef) + GameVersion.set(world.testVersion) + local entity = { spriteDef = spriteDef, sprite = {} } + function entity.sprite:setObjPalette(colors, group) + entity.colors, entity.group = colors, group + end + world.player = entity + world:applySpritePalette(entity) + return entity +end + +local chris = baked(male, SURF) +eq(chris.colors, RED, "Chris surfs orange") +eq(chris.group, "gen2:DAY:0", "and bakes under the overridden palette id") +eq(baked(female, SURF).colors, BLUE, "Kris surfs blue") +eq(baked(gold, SURF).colors, BLUE, "Gold's surf blob stays blue") + +-- data/sprites/sprites.asm:61 +local PIKA = { paletteId = 0 } +eq(baked(male, PIKA).colors, RED, "Chris's Surfing Pikachu is red") +eq(baked(female, PIKA).colors, BLUE, "Kris's Surfing Pikachu is blue") + +local npc = { spriteDef = SURF, def = { palette = 8 }, sprite = {} } +function npc.sprite:setObjPalette(colors) npc.colors = colors end +male:applySpritePalette(npc) +eq(npc.colors, RED, "an NPC keeps taking entity.def") + +GameVersion.set("red") +T.finish("gen2_crystal_surf_palette_2280") diff --git a/tests/engine/gen2_fresh_export_2283.lua b/tests/engine/gen2_fresh_export_2283.lua new file mode 100644 index 00000000..27204e44 --- /dev/null +++ b/tests/engine/gen2_fresh_export_2283.lua @@ -0,0 +1,353 @@ +-- luajit tests/engine/gen2_fresh_export_2283.lua +-- * sOptions data/default_options.asm +-- engine/overworld/player_object.asm:19 SpawnPlayer +-- engine/menus/intro_menu.asm:28 _ResetWRAM +-- engine/overworld/decorations.asm:1 InitDecorations +-- home/map.asm:1829 LoadConnectionBlockData +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 Gen2Save = require("src.save_convert.Gen2Save") +local Gen2MapContext = require("src.save_convert.Gen2MapContext") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local SaveFileIO = require("src.import.SaveFileIO") + +local realFS = love.filesystem + +local function fixture() + return { + pokemon = {}, moves = {}, items = {}, + maps = { PLAYERS_HOUSE_2F = { + group = 24, map = 7, objectEventsAddr = 0x5CF0, + width = 4, height = 3, + blocks = { 4, 1, 3, 2, 5, 6, 5, 5, 5, 5, 7, 5 }, + objects = { + { spriteId = 240, x = 4, y = 2, movement = 1, radius = { x = 0, y = 0 }, + hours = { -1, -1 }, palette = 0, type = 0, sight = 0, + script = 0x5C5A, eventFlag = 1857 }, + { spriteId = 241, x = 4, y = 4, movement = 1, radius = { x = 0, y = 0 }, + hours = { -1, -1 }, palette = 0, type = 0, sight = 0, + script = 0x5C54, eventFlag = 1858 }, + }, + } }, + } +end + +local function save(gender) + return { + generation = 2, + player = { name = "BRYAN", id = 12345, money = 3000, gender = gender }, + rival = { name = "SILVER" }, mom = { name = "MOM" }, + position = { map = "PLAYERS_HOUSE_2F", x = 3, y = 3 }, + party = {}, boxes = {}, boxNames = {}, currentBox = 1, + } +end + +local function u8(bytes, at) return bytes:byte(at + 1) end +-- constants/charmap.asm: "A" is $80 and "0" is $F6. +local function nameAt(bytes, at, n) + local out = {} + for i = 0, n - 1 do + local c = u8(bytes, at + i) + if c == 0x50 then break end + if c >= 0xF6 then out[#out + 1] = string.char(c - 0xF6 + 48) + else out[#out + 1] = string.char(c - 0x80 + 65) end + end + return table.concat(out) +end +local function textAt(bytes, at, n) + local out = {} + for i = 0, n - 1 do + local c = u8(bytes, at + i) + if c == 0x50 then break end + out[#out + 1] = string.char(c - 0x80 + 65) + end + return table.concat(out) +end + + +for _, version in ipairs({ "gold", "crystal" }) do + local L = Gen2Save.layoutFor(version) + local O = Gen2MapContext.offsetsFor(version) + local bytes, why = Gen2Save.encode(save("male"), version, nil, fixture()) + check(bytes ~= nil, version .. ": a slot with no cartridge exports -- " .. tostring(why)) + if not bytes then break end + + eq(#bytes, Gen2Save.SAVE_SIZE, version .. ": a full battery image") + eq(Gen2Save.checksumValid(bytes, L), true, + version .. ": the check values and sum the cartridge verifies") + + -- data/default_options.asm + local options = { 0x03, 0x01, 0x00, 0x01, 0x40, 0x01, 0x00, 0x00 } + for i, want in ipairs(options) do + eq(u8(bytes, L.sOptions + i - 1), want, version .. ": sOptions byte " .. i) + end + + eq(u8(bytes, L.wSavedAtLeastOnce), 1, version .. ": the save counts as saved") + eq(textAt(bytes, L.wRedsName, 11), "RED", version .. ": RED@") + eq(textAt(bytes, L.wGreensName, 11), "GREEN", version .. ": GREEN@") + eq(u8(bytes, L.wMomItemTriggerBalance) * 65536 + + u8(bytes, L.wMomItemTriggerBalance + 1) * 256 + + u8(bytes, L.wMomItemTriggerBalance + 2), 2300, + version .. ": MOM_MONEY is the trigger balance") + for _, at in ipairs({ L.wRoamMon1MapGroup, L.wRoamMon2MapGroup, + L.wRoamMon3MapGroup }) do + eq(u8(bytes, at), 0xFF, version .. ": a roamer's map group is -1") + eq(u8(bytes, at + 1), 0xFF, version .. ": and its map number") + end + eq(u8(bytes, L.wBestMagikarpLengthFeet), 3, version .. ": 3 feet") + eq(u8(bytes, L.wBestMagikarpLengthInches), 6, version .. ": 6 inches") + eq(textAt(bytes, L.wMagikarpRecordHoldersName, 11), "RALPH", + version .. ": held by RALPH") + eq(u8(bytes, L.sMysteryGiftUnlocked), 0xFF, version .. ": mystery gift -1") + eq(u8(bytes, L.wNumPCItems), 0, version .. ": the PC is empty") + eq(u8(bytes, L.wNumPCItems + 1), 0xFF, version .. ": and terminated") + eq(u8(bytes, L.wDecoBed), 2, version .. ": DECO_FEATHERY_BED") + eq(u8(bytes, L.wDecoPoster), 16, version .. ": DECO_TOWN_MAP") + local box1 = { 0x81, 0x8E, 0x97, 0xF7, 0x50 } + for i, want in ipairs(box1) do + eq(u8(bytes, L.wBoxNames + i - 1), want, version .. ": box 1 name byte " .. i) + end + local box14 = { 0x81, 0x8E, 0x97, 0xF7, 0xFA, 0x50 } + for i, want in ipairs(box14) do + eq(u8(bytes, L.wBoxNames + 13 * 9 + i - 1), want, + version .. ": box 14 name byte " .. i) + end + + local wantObject = { 0x00, 0x01, 3 + 4, 3 + 4, 0x0B, 0xFF, 0xFF, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xD5, 0x78 } + if version == "crystal" then wantObject[9] = 0x80 end + for i, want in ipairs(wantObject) do + eq(u8(bytes, O.mapObjects + i - 1), want, + ("%s: player map object byte %d"):format(version, i - 1)) + end + + local wantStruct = { 0x01, 0x00, 0x00, 0x0B, 0x02, 0x00, 0x00, 0xFF, + 0x00, 0x01, 0x00, 0x01, 0x00, 0xFF, 0x00, 0x00, + 7, 7, 7, 7, 7, 7, 0x00, 0x40, 0x40 } + for i, want in ipairs(wantStruct) do + eq(u8(bytes, O.objectStructs + i - 1), want, + ("%s: player struct byte %d"):format(version, i - 1)) + end + for i = 25, 39 do + eq(u8(bytes, O.objectStructs + i), 0, + ("%s: player struct byte %d is zero"):format(version, i)) + end + + local first = O.mapObjects + O.firstObjectSlot * Gen2MapContext.MAPOBJECT_LENGTH + eq(u8(bytes, first), 0xFF, version .. ": NPC 1 has no struct yet") + eq(u8(bytes, first + 1), 240, version .. ": NPC 1 sprite") + eq(u8(bytes, first + 2), 2 + 4, version .. ": NPC 1 y") + eq(u8(bytes, first + 3), 4 + 4, version .. ": NPC 1 x") + eq(u8(bytes, O.objectEventCount), 2, version .. ": two object events") + eq(u8(bytes, O.objectEventsPointer), 0xF0, version .. ": pointer lo") + eq(u8(bytes, O.objectEventsPointer + 1), 0x5C, version .. ": pointer hi") + eq(u8(bytes, O.objectFollow), 0xFF, version .. ": nobody is following") + + local screen = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x04, 0x01, 0x03, 0x02, 0x00, + 0x00, 0x05, 0x06, 0x05, 0x05, 0x00, + 0x00, 0x05, 0x05, 0x07, 0x05, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + } + for i, want in ipairs(screen) do + eq(u8(bytes, O.screenSave + i - 1), want, + ("%s: screen block %d"):format(version, i)) + end + + local back, derr = Gen2Save.decode(bytes, version) + check(back ~= nil, version .. ": the image decodes -- " .. tostring(derr)) + if back then + eq(back.player.name, "BRYAN", version .. ": the player name survives") + eq(back.player.money, 3000, version .. ": and the money") + eq(back.position.mapGroup, 24, version .. ": and the map group") + eq(back.position.mapNumber, 7, version .. ": and the map number") + eq(back.position.x, 3, version .. ": and x") + eq(back.position.y, 3, version .. ": and y") + end +end + +do + local O = Gen2MapContext.offsetsFor("crystal") + local female = Gen2Save.encode(save("female"), "crystal", nil, fixture()) + check(female ~= nil, "crystal: a female save exports") + if female then + eq(u8(female, O.mapObjects + 8), 0x90, "crystal: PAL_NPC_BLUE for Kris") + end +end + +-- SetDefaultBoxNames left it (engine/menus/intro_menu.asm:148) +do + for _, version in ipairs({ "gold", "crystal" }) do + local L = Gen2Save.layoutFor(version) + local named = save("male") + named.boxNames = { [2] = "MISC", [3] = "" } + local bytes = Gen2Save.encode(named, version, nil, fixture()) + check(bytes ~= nil, version .. ": a save with one renamed box exports") + if bytes then + eq(nameAt(bytes, L.wBoxNames, 9), "BOX1", + version .. ": box 1 keeps its default name") + eq(nameAt(bytes, L.wBoxNames + 9, 9), "MISC", + version .. ": box 2 keeps the name the player gave it") + eq(nameAt(bytes, L.wBoxNames + 18, 9), "BOX3", + version .. ": an empty name is the default, not a blank box") + eq(nameAt(bytes, L.wBoxNames + 13 * 9, 9), "BOX14", + version .. ": and box 14") + end + end +end + +-- home/map.asm:1169 +local function connectedFixture() + local function neighbour(group, number, base) + local blocks = {} + for i = 1, 16 do blocks[i] = base + i end + return { + group = group, map = number, objectEventsAddr = 0x4000, + width = 4, height = 4, blocks = blocks, objects = {}, + } + end + local town = { + group = 30, map = 1, objectEventsAddr = 0x4A00, + width = 4, height = 4, objects = {}, + blocks = { + 0x11, 0x12, 0x13, 0x14, + 0x21, 0x22, 0x23, 0x24, + 0x31, 0x32, 0x33, 0x34, + 0x41, 0x42, 0x43, 0x44, + }, + connections = { + north = { group = 30, map = 2, mapId = "FIX_NORTH", + stripLength = 4, width = 4, offset = 0, xOffset = 0, yOffset = 7 }, + south = { group = 30, map = 3, mapId = "FIX_SOUTH", + stripLength = 4, width = 4, offset = 0, xOffset = 0, yOffset = 0 }, + west = { group = 30, map = 4, mapId = "FIX_WEST", + stripLength = 4, width = 4, offset = 0, xOffset = 7, yOffset = 0 }, + east = { group = 30, map = 5, mapId = "FIX_EAST", + stripLength = 4, width = 4, offset = 0, xOffset = 0, yOffset = 0 }, + }, + } + return { + pokemon = {}, moves = {}, items = {}, + maps = { + FIX_TOWN = town, + FIX_NORTH = neighbour(30, 2, 0x50), + FIX_SOUTH = neighbour(30, 3, 0x60), + FIX_WEST = neighbour(30, 4, 0x70), + FIX_EAST = neighbour(30, 5, 0x80), + }, + } +end + +local function connectedSave(x, y) + local s = save("male") + s.position = { map = "FIX_TOWN", x = x, y = y } + return s +end + +do + local northWest = { + 0x00, 0x00, 0x59, 0x5A, 0x5B, 0x5C, + 0x00, 0x00, 0x5D, 0x5E, 0x5F, 0x60, + 0x73, 0x74, 0x11, 0x12, 0x13, 0x14, + 0x77, 0x78, 0x21, 0x22, 0x23, 0x24, + 0x7B, 0x7C, 0x31, 0x32, 0x33, 0x34, + } + local southEast = { + 0x22, 0x23, 0x24, 0x85, 0x86, 0x87, + 0x32, 0x33, 0x34, 0x89, 0x8A, 0x8B, + 0x42, 0x43, 0x44, 0x8D, 0x8E, 0x8F, + 0x62, 0x63, 0x64, 0x00, 0x00, 0x00, + 0x66, 0x67, 0x68, 0x00, 0x00, 0x00, + } + for _, version in ipairs({ "gold", "crystal" }) do + local O = Gen2MapContext.offsetsFor(version) + for _, case in ipairs({ + { name = "north-west", x = 0, y = 0, want = northWest }, + { name = "south-east", x = 7, y = 7, want = southEast }, + }) do + local bytes, why = Gen2Save.encode(connectedSave(case.x, case.y), version, + nil, connectedFixture()) + check(bytes ~= nil, ("%s: a fresh save at the %s connection exports -- %s") + :format(version, case.name, tostring(why))) + if bytes then + for i, want in ipairs(case.want) do + eq(u8(bytes, O.screenSave + i - 1), want, + ("%s: %s screen block %d"):format(version, case.name, i)) + end + end + end + end + + local gone = connectedFixture() + gone.maps.FIX_EAST = nil + local bytes, why = Gen2Save.encode(connectedSave(7, 7), "gold", nil, gone) + eq(bytes, nil, "a window that reaches an uncached connection is refused") + check(type(why) == "string" and why:find("FIX_EAST", 1, true) + and why:find("re%-import the ROM"), + "and names the map it cannot read -- " .. tostring(why)) + bytes, why = Gen2Save.encode(connectedSave(0, 0), "gold", nil, gone) + check(bytes ~= nil, "a window that never reaches it still exports -- " + .. tostring(why)) +end + +do + local out, why = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {}) + eq(out, nil, "a save with no position is refused") + check(type(why) == "string" and why:find("does not name one", 1, true), + "and says so -- " .. tostring(why)) + + out, why = Gen2Save.encode(save("male"), "gold", nil, { maps = {} }) + eq(out, nil, "a save whose map this cache does not know is refused") + check(type(why) == "string" and why:find("does not name one", 1, true), + "and says so too -- " .. tostring(why)) +end + + +do + local files = {} + love.filesystem = { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + createDirectory = function() return true end, + getSaveDirectory = function() return "/fake/save" end, + } + SaveData.resetSlotState() + GameVersion.set("gold") + + local image = Gen2Save.encode(save("male"), "gold", nil, fixture()) + check(image ~= nil, "a 32 KB Gold image to import") + + local ok, slotId = SaveFileIO.importToSlot(image, "gold", true) + check(ok, "the image imports into a slot -- " .. tostring(slotId)) + local cartPath = "saves/gold/" .. tostring(slotId) .. ".cart" + check(files[cartPath] ~= nil, "and its cartridge image is kept beside it") + + eq(SaveData.deleteSlot("gold", slotId), true, "the slot is deleted") + eq(files["saves/gold/" .. tostring(slotId) .. ".lua"], nil, "its save is gone") + check(files[cartPath] == nil, + "and so is its cartridge image: slot ids are reused, so a lingering " + .. ".cart hands the next game on that id a stranger's playthrough") + + local reused = SaveData.createSlot("gold") + eq(reused, slotId, "the id really is handed out again") + check(files["saves/gold/" .. tostring(reused) .. ".cart"] == nil, + "and the fresh slot inherits no cartridge image") + + love.filesystem = realFS + SaveData.resetSlotState() + GameVersion.set("red") +end + +T.finish("gen2 fresh export 2283") diff --git a/tests/engine/gen2_save_export_map_objects.lua b/tests/engine/gen2_save_export_map_objects.lua index a9623a68..934c7cd6 100644 --- a/tests/engine/gen2_save_export_map_objects.lua +++ b/tests/engine/gen2_save_export_map_objects.lua @@ -50,6 +50,12 @@ local function fixtureData(group, number) FIX_HOUSE = { group = group, map = number, objectEventsAddr = 0x5A17, + width = 4, height = 3, + blocks = { + 0x01, 0x02, 0x03, 0x04, + 0x11, 0x12, 0x13, 0x14, + 0x21, 0x22, 0x23, 0x24, + }, objects = { { index = 1, spriteId = 0x2F, x = 3, y = 5, movement = 0x07, @@ -106,7 +112,8 @@ for _, spec in ipairs({ fixtureData(21, 15)) T.check(bytes, spec.version .. " moved export succeeds: " .. tostring(err)) - local slot1 = O.mapObjects + MAPOBJECT + -- home/map.asm:941 ReadObjectEvents (pokecrystal home/map.asm:572) + local slot1 = O.mapObjects + O.firstObjectSlot * MAPOBJECT local expected = { 0xFF, 0x2F, 5 + 4, 3 + 4, 0x07, 2 * 16 + 1, 0xFF, 20, 4 * 16 + 2, 0x00, @@ -120,10 +127,26 @@ for _, spec in ipairs({ -- An object with no event flag writes the -1 the game uses for "none". T.eq(u8(bytes, slot1 + MAPOBJECT + 12), 0xFF, spec.version .. ": no-flag object writes $FFFF") T.eq(u8(bytes, slot1 + MAPOBJECT + 13), 0xFF, spec.version .. ": no-flag object writes $FFFF hi") - -- The first empty slot carries ReadObjectEvents' 0 / -1 pattern. local empty = slot1 + 2 * MAPOBJECT T.eq(u8(bytes, empty), 0, spec.version .. ": empty slot struct id") - T.eq(u8(bytes, empty + 1), 0xFF, spec.version .. ": empty slot sprite") + T.eq(u8(bytes, empty + 1), 0, spec.version .. ": empty slot sprite") + T.eq(u8(bytes, empty + 2), 0xFF, spec.version .. ": empty slot y coord") + if O.firstObjectSlot > 1 then + T.eq(u8(bytes, O.mapObjects + MAPOBJECT + 1), 0, + spec.version .. ": the slot Gold and Silver skip stays empty") + end + + local screen = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x03, 0x04, 0x00, 0x00, 0x00, + 0x12, 0x13, 0x14, 0x00, 0x00, 0x00, + 0x22, 0x23, 0x24, 0x00, 0x00, 0x00, + } + for i, want in ipairs(screen) do + T.eq(u8(bytes, O.screenSave + i - 1), want, + ("%s: screen block %d"):format(spec.version, i)) + end -- The player: map object coordinates re-anchored, sprite untouched. T.eq(u8(bytes, O.mapObjects + 2), 1 + 4, spec.version .. ": player map object y") @@ -164,6 +187,107 @@ for _, spec in ipairs({ refused, why = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, stale) T.check(refused == nil and why:find("re%-import the ROM"), spec.version .. ": stale cache refuses with the re-import hint: " .. tostring(why)) + + local noBlocks = fixtureData(21, 15) + noBlocks.maps.FIX_HOUSE.blocks = nil + refused, why = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, noBlocks) + T.check(refused == nil and why:find("no block data", 1, true), + spec.version .. ": a cache with no blocks refuses rather than exporting a " + .. "save that continues into an empty room: " .. tostring(why)) +end + +-- home/map.asm:1169, :1829 +local function connectedData() + local function neighbour(group, number, base) + local blocks = {} + for i = 1, 16 do blocks[i] = base + i end + return { + group = group, map = number, objectEventsAddr = 0x4000, + width = 4, height = 4, blocks = blocks, objects = {}, + } + end + return { + pokemon = {}, moves = {}, items = {}, + maps = { + FIX_TOWN = { + group = 30, map = 1, objectEventsAddr = 0x4A00, + width = 4, height = 4, objects = {}, + blocks = { + 0x11, 0x12, 0x13, 0x14, + 0x21, 0x22, 0x23, 0x24, + 0x31, 0x32, 0x33, 0x34, + 0x41, 0x42, 0x43, 0x44, + }, + connections = { + north = { group = 30, map = 2, mapId = "FIX_NORTH", + stripLength = 4, width = 4, offset = 0 }, + south = { group = 30, map = 3, mapId = "FIX_SOUTH", + stripLength = 4, width = 4, offset = 0 }, + west = { group = 30, map = 4, mapId = "FIX_WEST", + stripLength = 4, width = 4, offset = 0 }, + east = { group = 30, map = 5, mapId = "FIX_EAST", + stripLength = 4, width = 4, offset = 0 }, + }, + }, + FIX_NORTH = neighbour(30, 2, 0x50), + FIX_SOUTH = neighbour(30, 3, 0x60), + FIX_WEST = neighbour(30, 4, 0x70), + FIX_EAST = neighbour(30, 5, 0x80), + }, + } +end + +for _, spec in ipairs({ + { version = "crystal", L = Gen2Layout.crystal, O = Gen2MapContext.OFFSETS.crystal }, + { version = "gold", L = Gen2Layout.goldSilver, O = Gen2MapContext.OFFSETS.goldSilver }, +}) do + local L, O = spec.L, spec.O + local tpl = template(L, O, 21, 14) + local bytes, err = Gen2Save.encode(save(30, 1, 0, 0), spec.version, tpl, + connectedData()) + T.check(bytes, spec.version .. " moved onto a connected map: " .. tostring(err)) + if bytes then + local northWest = { + 0x00, 0x00, 0x59, 0x5A, 0x5B, 0x5C, + 0x00, 0x00, 0x5D, 0x5E, 0x5F, 0x60, + 0x73, 0x74, 0x11, 0x12, 0x13, 0x14, + 0x77, 0x78, 0x21, 0x22, 0x23, 0x24, + 0x7B, 0x7C, 0x31, 0x32, 0x33, 0x34, + } + for i, want in ipairs(northWest) do + T.eq(u8(bytes, O.screenSave + i - 1), want, + ("%s: connected screen block %d"):format(spec.version, i)) + end + end + + bytes, err = Gen2Save.encode(save(30, 1, 7, 7), spec.version, tpl, connectedData()) + T.check(bytes, spec.version .. " moved to the far corner: " .. tostring(err)) + if bytes then + local southEast = { + 0x22, 0x23, 0x24, 0x85, 0x86, 0x87, + 0x32, 0x33, 0x34, 0x89, 0x8A, 0x8B, + 0x42, 0x43, 0x44, 0x8D, 0x8E, 0x8F, + 0x62, 0x63, 0x64, 0x00, 0x00, 0x00, + 0x66, 0x67, 0x68, 0x00, 0x00, 0x00, + } + for i, want in ipairs(southEast) do + T.eq(u8(bytes, O.screenSave + i - 1), want, + ("%s: far-corner screen block %d"):format(spec.version, i)) + end + end + + -- engine/menus/intro_menu.asm:148 + bytes = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, fixtureData(21, 15)) + T.check(bytes, spec.version .. ": an export with no box names at all") + if bytes then + local box1 = { 0x81, 0x8E, 0x97, 0xF7, 0x50 } + for i, want in ipairs(box1) do + T.eq(u8(bytes, L.wBoxNames + i - 1), want, + ("%s: default box 1 name byte %d"):format(spec.version, i)) + end + T.eq(u8(bytes, L.wBoxNames + 13 * 9 + 4), 0xFA, + spec.version .. ": and box 14 ends in a 4") + end end T.finish("gen2 save export map objects") diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua index 762df8ad..abc68742 100644 --- a/tests/engine/gen2_save_import.lua +++ b/tests/engine/gen2_save_import.lua @@ -457,12 +457,29 @@ do "and a save from before the dex was given still hides it") end --- A save with no cartridge image behind it is refused, not invented. +-- A save with no cartridge image behind it gets one synthesized, but only do local out, err = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {}) - eq(out, nil, "encode refuses a save with no lineage") - check(type(err) == "string" and err:find("no cartridge image", 1, true) ~= nil, - "and says why -- " .. tostring(err)) + eq(out, nil, "encode refuses a save with no lineage AND no position") + check(type(err) == "string" and err:find("does not name one", 1, true) ~= nil, + "and says which half is missing -- " .. tostring(err)) + + local data = { + maps = { A_HOUSE = { + group = 21, map = 14, objectEventsAddr = 0x5A17, + width = 2, height = 2, blocks = { 1, 2, 3, 4 }, objects = {}, + } }, + } + local built, why = Gen2Save.encode({ + player = { name = "A" }, + position = { mapGroup = 21, mapNumber = 14, x = 1, y = 1 }, + }, "gold", nil, data) + check(built ~= nil, "a positioned save builds its own image -- " .. tostring(why)) + eq(#built, Gen2Save.SAVE_SIZE, "and it is a full battery image") + eq(Gen2Save.checksumValid(built, Gen2Save.layoutFor("gold")), true, + "sealed with the check values and sum the cartridge verifies") + local back = Gen2Save.decode(built, "gold") + check(back ~= nil and back.player.name == "A", "and decodes back to itself") end -- ------------------------------------------------------------------ @@ -524,11 +541,13 @@ do check(type(save.phoneContacts) == "table", "and the phone book") eq(save.mapScenes.ELMS_LAB, 2, "and the cart's map scenes survive the merge") end - -- Export needs the cartridge image the save came from. Without one it is - -- refused rather than built from nothing. - local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold") - check(type(expErr) == "string" and expErr:find("no cartridge image", 1, true) ~= nil, - "a save with no cartridge behind it is refused -- got: " .. tostring(expErr)) + local out, expErr = SaveConvert.exportSav({ meta = {} }, "gold") + eq(out, nil, "a save that names no map is still refused") + check(type(expErr) == "string" and expErr:find("does not name one", 1, true) ~= nil, + "and the reason is the missing map, not a missing cartridge -- got: " + .. tostring(expErr)) + check(type(expErr) == "string" and expErr:find("no cartridge image to write", 1, true) == nil, + "the old refusal is gone -- got: " .. tostring(expErr)) end -- ------------------------------------------------------------------ diff --git a/tests/engine/gen2_save_import_message.lua b/tests/engine/gen2_save_import_message.lua index 3e8c4a0e..8f2cf041 100644 --- a/tests/engine/gen2_save_import_message.lua +++ b/tests/engine/gen2_save_import_message.lua @@ -110,18 +110,20 @@ do end -- ------------------------------------------------------------------ --- Export needs the cartridge image behind the save --- ------------------------------------------------------------------ +-- Export needs the map the save stands on, not a cartridge behind it --- Export goes through the codec now, but only for a save that came from a --- cartridge: the regions it does not model are the ones the real game trusts --- on CONTINUE. +-- A save begun in this port has no cartridge image, and one is synthesized +-- for it. What export cannot invent is the map window the real game trusts +-- on CONTINUE, so a save that names no map is refused, in a line that says so. for _, version in ipairs({ "gold", "silver", "crystal" }) do eq(SaveConvert.exportSupported(version), true, version .. ": export is supported") local out, why = SaveConvert.exportSav({ meta = {}, player = { name = "A" } }, version) - eq(out, nil, version .. ": a save with no cartridge behind it is refused") - check(type(why) == "string" and why:find("no cartridge image", 1, true) ~= nil, - version .. ": and the reason is the missing image -- " .. tostring(why)) + eq(out, nil, version .. ": a save that names no map is refused") + check(type(why) == "string" and why:find("does not name one", 1, true) ~= nil, + version .. ": and the reason is the missing map -- " .. tostring(why)) + check(type(why) == "string" + and why:find("no cartridge image to write", 1, true) == nil, + version .. ": the lineage refusal is gone -- " .. tostring(why)) end T.finish() diff --git a/tests/engine/launcher_update_all.lua b/tests/engine/launcher_update_all.lua index 338e7a6f..f72de42e 100644 --- a/tests/engine/launcher_update_all.lua +++ b/tests/engine/launcher_update_all.lua @@ -25,6 +25,16 @@ local FEED_CART = { url = "https://example.invalid/wild_green.g1rcart" } }, } +local function feedAt(version) + local entry = {} + for k, v in pairs(FEED_CART) do entry[k] = v end + entry.version = version + entry.latest = { version = version, tag = "v" .. version, + zip = { name = "wild_green-" .. version .. ".g1rcart", + url = "https://example.invalid/wild_green.g1rcart" } } + return entry +end + local oldBeginFetch = ModUpdate.beginFetchReleases local oldBeginZip = ModUpdate.beginDownloadZip local oldPumpZip = ModUpdate.pumpDownloadZip @@ -105,6 +115,9 @@ local function run(ri, frames) for _ = 1, frames or 40 do if not ri._updateAll then break end ri:_pumpUpdateAll() + if ri._updateAll and ri._updateAll.stage == "confirm" then + ri:_confirmUpdateAll() + end ri:_pumpModInstall() ri:_pumpCartInstall() end @@ -138,8 +151,10 @@ do "a cart of the same id from another repo is never overwritten") cartRows = { { id = "wild_green", title = "Wild Green", version = "0.2.0" } } - eq(#launcher(nil, { FEED_CART }):_updateAllCartRows(), 0, - "nor one whose manifest names no repo at all") + local noRepo = launcher(nil, { FEED_CART }):_updateAllCartRows() + eq(#noRepo, 1, + "a manifest that names no repo matches by id, as the Find tab does") + eq((noRepo[1] or {}).to, "0.29.1", "and is offered the listed version") cartRows = installedCart("0.29.1", "ren/wild-green") eq(#launcher(nil, { FEED_CART }):_updateAllCartRows(), 0, @@ -152,6 +167,131 @@ do end +do + installs, cartInstalls = {}, {} + cartRows = installedCart("0.2.0", "someoneelse/wild-green") + local ri = launcher(nil, { FEED_CART }) + ri.modUpdateInfo = {} + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + run(ri) + eq(#cartInstalls, 0, "a cart from another repo is still never overwritten") + check(ri.modNotice.text ~= "Everything is up to date.", + "but the sweep no longer reports the skip as up to date") + eq(#(ri.modNotice.failures or {}), 1, "it names the cart it passed over") + check(tostring((ri.modNotice.failures or {})[1]):find( + "ren/wild-green", 1, true) ~= nil, "with the repo the feed lists it under") + cartRows = {} +end + + +do + installs, cartInstalls = {}, {} + cartRows = installedCart("0.29.1", "ren/wild-green") + local ri = launcher(nil, { FEED_CART }) + ri.modUpdateInfo = {} + local refreshes = {} + ri._refreshFindSources = function(self) + self.findSources = { { feed = "https://example.invalid/index.json" } } + end + ri._refreshFind = function(self, force, opts) + refreshes[#refreshes + 1] = { force = force, quiet = opts and opts.quiet } + self._findFetch = {} + end + check(ri:pressUpdateAllMods(), "the press starts the queue") + ri._modInfoFetch = nil + ri:_pumpUpdateAll() + eq(#refreshes, 1, "the sweep fetches the cart feed itself") + eq((refreshes[1] or {}).force, true, + "forced, not served from the day-old cache") + eq((refreshes[1] or {}).quiet, true, + "and quiet, since the sweep owns the overlay") + eq((ri._updateAll or {}).stage, "check", "the queue waits for that fetch") + eq(#cartInstalls, 0, "installing nothing against the cached listing") + + ri._findFetch = nil + ri.findIndex = { mods = {}, carts = { feedAt("0.30.0") } } + run(ri) + eq(#refreshes, 1, "one forced fetch per sweep, never one per row") + eq(#cartInstalls, 1, "the release the fresh feed lists is installed") + eq(ri.findNotice, nil, "with no FIND notice left behind") + eq(ri.modNotice.text, "Updated 1 items.", "and the run is reported") + cartRows = {} +end + + +do + cartInstalls = {} + cartRows = { { id = "wild_green", title = "Wild Green", version = "0.2.0", + cart = { id = "wild_green", version = "0.2.0" } } } + local function sweep() + local ri = launcher(nil, { feedAt("0.2.0") }) + ri.modUpdateInfo = {} + ri._refreshFindSources = function(self) + self.findSources = { { feed = "https://example.invalid/index.json" } } + end + ri._refreshFind = function(self) self._findFetch = {} end + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + ri:_pumpUpdateAll() + ri._findFetch = nil + ri.findIndex = { mods = {}, carts = { feedAt("0.29.1") } } + for _ = 1, 40 do + if not ri._updateAll or ri._updateAll.stage == "confirm" then break end + ri:_pumpUpdateAll(); ri:_pumpModInstall(); ri:_pumpCartInstall() + end + return ri + end + + local ri = sweep() + eq((ri._updateAll or {}).stage, "confirm", + "a launcher-authored cart the fresh feed lists ahead is asked about first") + eq(#cartInstalls, 0, "and nothing is installed before the answer") + eq((ri._modConfirm or {}).kind, "updateAllRun", "the dialog is on screen") + eq(((ri._modConfirm or {}).lines or {})[1], "Update 1 items?", + "built from the fresh feed, not the cached one") + eq(((ri._modConfirm or {}).lines or {})[2], "Wild Green", + "naming the cart that would be replaced") + eq(ri._busy, nil, "with the overlay down") + ri._modConfirm = nil + run(ri) + eq(ri._updateAll, nil, "declining ends the sweep") + eq(#cartInstalls, 0, "with the cart left alone") + eq(ri.modNotice, nil, "and no notice for a run that never started") + + ri = sweep() + check(ri:_confirmUpdateAll(), "accepting the dialog") + run(ri) + eq(#cartInstalls, 1, "installs the listed release") + eq(ri.modNotice.text, "Updated 1 items.", "and reports it") + cartRows = {} +end + + +do + cartInstalls = {} + cartRows = installedCart("0.2.0", "ren/wild-green") + local ri = launcher(nil, { FEED_CART }) + ri.modUpdateInfo = {} + ri._refreshFindSources = function(self) + self.findSources = { { feed = "https://example.invalid/index.json" } } + end + ri._refreshFind = function(self) self._findFetch = {} end + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + ri:_pumpUpdateAll() + check(ri._findFetch ~= nil, "the feed fetch is in flight") + check(type(ri._busy.cancel) == "function", "behind a cancellable overlay") + ri:_cancelUpdateAll() + eq(ri._updateAll, nil, "cancelling there ends the sweep on the spot") + eq(#cartInstalls, 0, "with nothing installed") + check(ri._findFetch ~= nil, "and the fetch left to finish on its own") + check(ri.modNotice.text:find("Stopped after updating", 1, true) ~= nil, + "and says how far it got") + cartRows = {} +end + + do installs, cartInstalls = {}, {} cartRows = installedCart("0.2.0", "ren/wild-green") @@ -229,8 +369,18 @@ do ri._modInfoFetch = nil ri:_pumpUpdateAll() eq(ri._updateAll.total, 2, "then queues every outdated mod") - + eq(ri._updateAll.stage, "confirm", "and stops to ask before installing") + eq(ri._busy, nil, "with the overlay down so the dialog can be answered") + eq((ri._modConfirm or {}).kind, "updateAllRun", "the dialog is up") + eq(((ri._modConfirm or {}).lines or {})[1], "Update 2 items?", + "counting the rows built after the release checks") + eq(((ri._modConfirm or {}).lines or {})[2], "One", "and naming them") ri:_pumpUpdateAll() + eq(ri._updateAll.index, 0, "no row starts while the dialog waits") + + check(ri:_confirmUpdateAll(), "accepting the dialog") + eq(ri._modConfirm, nil, "takes it down") + check(ri._busy ~= nil, "and puts the overlay back") eq(ri._updateAll.index, 1, "one row is started") ri:_pumpUpdateAll() eq(ri._updateAll.index, 1, "and the next waits for it, not for the frame") @@ -277,8 +427,11 @@ end do Platform.canFetchRemote = function() return false end local ri = launcher("available") + local refreshed = false + ri._refreshFind = function() refreshed = true end check(ri:pressUpdateAllMods() == false, "no remote fetch, no queue") eq(ri._updateAll, nil, "nothing is started") + eq(refreshed, false, "and the cart feed is not fetched either") check(ri.modNotice and not ri.modNotice.ok, "and the refusal is on screen") Platform.canFetchRemote = function() return true end end @@ -290,7 +443,7 @@ do ri:pressUpdateAllMods() ri._modInfoFetch = nil ri:_pumpUpdateAll() - ri:_pumpUpdateAll() + ri:_confirmUpdateAll() ri:_pumpModInstall() eq(#installs, 1, "one row is through") ri:_cancelUpdateAll() @@ -308,7 +461,7 @@ do ri:pressUpdateAllMods() ri._modInfoFetch = nil ri:_pumpUpdateAll() - ri:_pumpUpdateAll() + ri:_confirmUpdateAll() check(ri._modInstall ~= nil, "a row is downloading") ri:_cancelUpdateAll() check(ri._busy ~= nil, diff --git a/tests/engine/menu_keep_open_2281.lua b/tests/engine/menu_keep_open_2281.lua new file mode 100644 index 00000000..051b3ede --- /dev/null +++ b/tests/engine/menu_keep_open_2281.lua @@ -0,0 +1,92 @@ +-- engine/events/cinnabar_lab.asm:29, home/text_script.asm:105 +-- luajit tests/engine/menu_keep_open_2281.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") + +package.loaded["src.core.Sound"] = { play = function() end } + +local Menu = require("src.ui.Menu") + +local function newGame() + local game = { data = {} } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + return game +end + +local function press(state, btn) + state.game.input.queue = { [btn] = true } + state:update(1 / 60) + state.game.input.queue = {} +end + +local function fossilMenu(opts) + local game = newGame() + local fired = {} + local items = { + { label = "DOME FOSSIL", keepOpen = true, + onSelect = function() fired[#fired + 1] = "DOME FOSSIL" end }, + { label = "OLD AMBER", keepOpen = true, + onSelect = function() fired[#fired + 1] = "OLD AMBER" end }, + } + opts = opts or {} + opts.tx, opts.ty, opts.tw = 0, 0, 15 + local menu = Menu.new(game, items, opts) + game.stack:push(menu) + return menu, game, fired +end + +local menu, game, fired = fossilMenu() +press(menu, "a") +eq(#game.stack.states, 1, "a keepOpen row leaves the menu on the stack") +check(game.stack:top() == menu, "and the menu is still the state that was pushed") +eq(fired[1], "DOME FOSSIL", "while onSelect still ran for the picked row") + +menu.frozen = true +press(menu, "down") +eq(menu.index, 1, "a frozen menu ignores the d-pad") +press(menu, "a") +eq(#fired, 1, "and swallows A rather than re-running onSelect") +eq(#game.stack.states, 1, "with nothing popped") +press(menu, "b") +eq(#game.stack.states, 1, "B on a frozen menu pops nothing") + +local cancelled = 0 +local keep, keepGame = fossilMenu({ keepOnCancel = true, + onCancel = function() cancelled = cancelled + 1 end }) +press(keep, "b") +eq(cancelled, 1, "keepOnCancel still fires onCancel on B") +eq(#keepGame.stack.states, 1, "but leaves the menu box on screen") + +local plainGame = newGame() +local plainFired, plainCancel = 0, 0 +local plain = Menu.new(plainGame, + { { label = "POKéDEX", onSelect = function() plainFired = plainFired + 1 end } }) +plainGame.stack:push(plain) +press(plain, "a") +eq(#plainGame.stack.states, 0, "an ordinary menu still pops itself on A") +eq(plainFired, 1, "and still runs onSelect") + +plainGame.stack:push(plain) +plain.onCancel = function() plainCancel = plainCancel + 1 end +press(plain, "b") +eq(#plainGame.stack.states, 0, "and still pops itself on B") +eq(plainCancel, 1, "with onCancel after the pop") + +eq(plain.frozen, nil, "frozen is unset for every caller that does not ask") +eq(plain.keepOnCancel, false, "and keepOnCancel defaults off") + +T.finish("menu_keep_open_2281") diff --git a/tests/engine/pad_speed_binding_bug1922.lua b/tests/engine/pad_speed_binding_bug1922.lua index 106512b1..6781c385 100644 --- a/tests/engine/pad_speed_binding_bug1922.lua +++ b/tests/engine/pad_speed_binding_bug1922.lua @@ -38,8 +38,10 @@ local ROW_A, ROW_SPEED_DOWN, ROW_SPEED_UP = 5, 9, 10 Input:init() eq(Input:padAction("rightshoulder"), "speedUp", "R1 speeds up by default") eq(Input:padAction("leftshoulder"), "speedDown", "L1 slows down by default") -eq(Input:padAction("righttrigger"), "speedUp", "R2 alongside it") -eq(Input:padAction("lefttrigger"), "speedDown", "L2 alongside it") +eq(Input:padAction("triggerright"), "speedUp", "R2 alongside it") +eq(Input:padAction("triggerleft"), "speedDown", "L2 alongside it") +eq(Input:padAction("righttrigger"), nil, + "under the axis spelling LOVE actually emits, not the reversed one") eq(Input:padAction("a"), nil, "a face button is not a pad action") check(Input.padBindings["rightshoulder"] == nil, "a pad action never enters the Game Boy button map") @@ -55,7 +57,7 @@ eq(held, 0, "pressing R1 presses no Game Boy button") Input:applyBindings({ speedUp = { pad = "y" } }) eq(Input:padAction("y"), "speedUp", "the overlay moves SPEED + to Y") eq(Input:padAction("rightshoulder"), nil, "and leaves R1 inert") -eq(Input:padAction("righttrigger"), nil, "including its trigger alias") +eq(Input:padAction("triggerright"), nil, "including its trigger alias") eq(Input:padAction("leftshoulder"), "speedDown", "SPEED - keeps its default") check(Input.padBindings["y"] == nil, "the moved action does not press a Game Boy button either") @@ -64,7 +66,7 @@ check(Input.padBindings["y"] == nil, Input:applyBindings({ speedUp = false, speedDown = false, a = { pad = "y" } }) eq(Input:padAction("rightshoulder"), nil, "unbound: R1 does nothing") eq(Input:padAction("leftshoulder"), nil, "unbound: L1 does nothing") -eq(Input:padAction("righttrigger"), nil, "unbound: R2 does nothing") +eq(Input:padAction("triggerright"), nil, "unbound: R2 does nothing") eq(Input.padBindings["y"], "a", "a GB rebind in the same overlay still lands") Input:init() @@ -134,6 +136,47 @@ Input:applyBindings(nil) eq(Input:padAction("rightshoulder"), "speedUp", "and the live map with it") +local Game = require("src.core.Game") +Input:init() +local tgame = newGame() +local tbm = BindingsMenu.new(tgame) +tgame.stack:push(tbm) + +tbm.index = ROW_SPEED_UP +press(tbm, "a") +eq(tbm.capture, tbm.items[ROW_SPEED_UP], "A arms the SPEED + row") +Game.gamepadaxis(tgame, nil, "triggerright", 1.0) +check(tbm.capture ~= nil, "the trigger press alone does not commit") +Game.gamepadaxis(tgame, nil, "triggerright", 0.0) +eq(tgame.save.options.bindings.speedUp.pad, "triggerright", + "the release stores R2 in the same free-form pad slot joyN uses") +eq(tbm.items[ROW_SPEED_UP].right, "R2", "and the row reads R2") + +tbm.index = ROW_A +press(tbm, "a") +Game.gamepadaxis(tgame, nil, "triggerleft", 1.0) +Game.gamepadaxis(tgame, nil, "triggerleft", 0.0) +eq(tgame.save.options.bindings.a.pad, "triggerleft", "GB A can take L2 too") +tbm:commitBindings() +eq(Input.padBindings["triggerleft"], "a", "applied: L2 is Game Boy A") +eq(Input:padAction("triggerright"), "speedUp", "and R2 is SPEED +") +Input:init() + + +Input:applyBindings({ speedUp = { pad = "joy12" } }) +eq(Input:joyAction(12), "speedUp", "joy12 resolves to the action") +eq(Input:joyAction(1), nil, "and the truncated index does not") +local rawGame = newGame() +rawGame.save.options.bindings = + { speedUp = { pad = "joy12" }, speedDown = { pad = "joy104" } } +local rawBm = BindingsMenu.new(rawGame) +eq(rawBm.items[ROW_SPEED_UP].right, "JOY12", + "the CONTROLS row tells JOY12 apart from JOY1") +eq(rawBm.items[ROW_SPEED_DOWN].right, "J104", + "and a three-digit index no longer truncates into another button's name") +Input:init() + + local SaveData = require("src.core.SaveData") local files = {} local fs = { diff --git a/tests/engine/shaderfx_no_bridge_ui.lua b/tests/engine/shaderfx_no_bridge_ui.lua index cafc4ace..b8b9f9c8 100644 --- a/tests/engine/shaderfx_no_bridge_ui.lua +++ b/tests/engine/shaderfx_no_bridge_ui.lua @@ -27,10 +27,13 @@ package.loaded["src.core.Sound"] = { play = function() end } local converts = {} local activated = {} +local recorded = {} +local trace = {} local fake fake = { OPTION_KEY = { main = "shaderfx", secondary = "shaderfx2" }, - canConvert = function() return fake.can end, + canConvert = function() trace[#trace + 1] = "canConvert"; return fake.can end, + recordError = function(name, err) recorded[#recorded + 1] = tostring(name) .. ": " .. tostring(err) end, bridgeError = function() return fake.can and nil or "librashader bridge not found" end, list = function() return { @@ -45,7 +48,7 @@ fake = { end, activate = function(_, entry) activated[#activated + 1] = entry.name; return true end, deactivate = function() end, - clearBridgeQuarantine = function() end, + clearBridgeQuarantine = function() trace[#trace + 1] = "clear" end, downloadPresets = function() return {} end, downloadStatus = function() return { status = "pending" } end, installDownloaded = function() return 0 end, @@ -64,12 +67,14 @@ local function newGame() end local function open() - converts, activated = {}, {} + converts, activated, recorded, trace = {}, {}, {}, {} return ShaderFXScreen.new(newGame(), "main") end fake.can, fake.convertOk = false, false local screen = open() +eq(trace[1], "clear", "a stale bridge quarantine is cleared before the picker asks canConvert") +eq(trace[2], "canConvert", "and canConvert is what follows the clear") eq(screen.items[3].right, "UPDATE", "an unconvertible preset says what would fix it") check(#screen.items[3].right <= #"CONVERT", "the no-bridge label leaves at least as much room for the name as CONVERT") @@ -97,6 +102,8 @@ fake.convertOk = false screen = open() screen.onChoose(screen.items[3]) eq(screen.items[3].right, "FAILED", "a real convert failure still reads FAILED") +eq(recorded[1], "zfast-lcd.slangp: no bridge", + "and the real error string reaches shaderfx-error.log, not just log.txt") do fake.can, fake.convertOk = true, true diff --git a/tests/engine/shaderfx_portable_paths_2227.lua b/tests/engine/shaderfx_portable_paths_2227.lua new file mode 100644 index 00000000..a5327aff --- /dev/null +++ b/tests/engine/shaderfx_portable_paths_2227.lua @@ -0,0 +1,90 @@ +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") + +package.loaded["src.core.Logger"] = { + info = function() end, + error = function() end, + warn = function() end, +} + +local ROOT = "/tmp/pokeport-shaderfx-2227" +local SAVE = ROOT .. "/save" +local PORTABLE = ROOT .. "/portable" +local REL = "shaders/handheld/probe.slangp" + +os.execute("rm -rf " .. ROOT) +os.execute("mkdir -p " .. SAVE .. "/shaders/handheld " .. PORTABLE) +local seeded = io.open(SAVE .. "/" .. REL, "wb") +check(seeded ~= nil, "the fixture preset is seeded in the save root") +seeded:write("#reference \"x\"\n") +seeded:close() + +local fs = love.filesystem +fs.write(REL, "#reference \"x\"\n") +fs.getSaveDirectory = function() return SAVE end +fs.getRealDirectory = function(rel) + if rel:sub(1, 7) == "shaders" then return SAVE end + return nil +end + +local SaveData = require("src.core.SaveData") +SaveData.portableBaseDir = function() return PORTABLE end + +package.loaded["src.import.CacheFs"] = package.loaded["src.import.CacheFs"] or {} +local CacheFs = package.loaded["src.import.CacheFs"] +CacheFs.prefix = CacheFs.prefix or "" +CacheFs.root = CacheFs.root or function() return nil end +local writes = {} +local prefixDuringWrite +CacheFs.write = function(rel, data) + prefixDuringWrite = CacheFs.prefix + writes[rel] = data + return true +end + +local ShaderFX = require("src.render.ShaderFX") + +eq(ShaderFX.presetDir(), PORTABLE .. package.config:sub(1, 1) .. "shaders", + "presetDir still answers the portable folder") + +local list = ShaderFX.list() +eq(#list, 1, "the seeded preset is enumerated") +local entry = list[1] +check(entry.fullPath:find(SAVE, 1, true) == 1, + "fullPath names the root PhysFS actually found it in (got " .. tostring(entry.fullPath) .. ")") +check(entry.fullPath:find(PORTABLE, 1, true) == nil, + "and not the portable folder nothing ever wrote to") + +local opened = io.open(entry.fullPath, "rb") +check(opened ~= nil, "io.open(entry.fullPath) succeeds, which is what the bridge does") +if opened then opened:close() end + +eq(entry.fullPath:match("^(.*)/[^/]+$"), SAVE .. "/shaders/handheld", + "the path still splits on / so ShaderSourcePatches.dirname keeps working") + +eq(ShaderFX.artifactPath(entry), SAVE .. "/" .. (REL:gsub("%.slangp$", ".lua")), + "the .lua artifact is written beside the preset that exists") + +fs.getRealDirectory = nil +local fallback = ShaderFX.list() +eq(fallback[1].fullPath, PORTABLE .. package.config:sub(1, 1) .. "shaders/handheld/probe.slangp", + "without getRealDirectory the pre-fix presetDir path is still what comes back") +fs.getRealDirectory = function() return SAVE end + +CacheFs.prefix = "blue/" +fs.write("shaderfx_buildbot.zip", "PK") +fs.write("shaderfx_buildbot_mount/handheld/bevel.slangp", "shader0 = stub.slang\n") +local copied, installErr = ShaderFX.installDownloaded(false) +eq(installErr, nil, "installDownloaded reports no error") +eq(copied, 1, "the kept preset is copied") +eq(prefixDuringWrite, "", "installDownloaded pins CacheFs.prefix to the root for the copy") +eq(CacheFs.prefix, "blue/", "and hands the caller's prefix back afterwards") +check(writes["shaders/handheld/bevel.slangp"] ~= nil, + "the extracted tree goes through CacheFs.write, not love.filesystem.write") + +os.execute("rm -rf " .. ROOT) + +T.finish("shaderfx portable preset paths (#2227/#2220)") diff --git a/tests/engine/speed_shoulders_triggers_test.lua b/tests/engine/speed_shoulders_triggers_test.lua index 0a0669a9..53b0f63c 100644 --- a/tests/engine/speed_shoulders_triggers_test.lua +++ b/tests/engine/speed_shoulders_triggers_test.lua @@ -38,11 +38,27 @@ eq(last(), -1, "L1 slows down") -- ---- analog triggers ---------------------------------------------------- dirs = {} -Game.gamepadpressed(game, nil, "righttrigger") -eq(last(), 1, "R2 speeds up") -Game.gamepadpressed(game, nil, "lefttrigger") +Input:reset() +Game.gamepadaxis(game, nil, "triggerright", 0.3) +Game.gamepadaxis(game, nil, "triggerright", 0.35) +eq(#dirs, 0, "resting-trigger drift under the ON threshold is not a press") +Game.gamepadaxis(game, nil, "triggerright", 1.0) +eq(last(), 1, "R2 past the threshold speeds up") +eq(#dirs, 1, "exactly once") +Game.gamepadaxis(game, nil, "triggerright", 1.0) +Game.gamepadaxis(game, nil, "triggerright", 0.45) +Game.gamepadaxis(game, nil, "triggerright", 0.3) +eq(#dirs, 1, "a held trigger is an edge, not a level, and jitter is neither") +Game.gamepadaxis(game, nil, "triggerright", 0.0) +eq(#dirs, 1, "the release cycles nothing by itself") +Game.gamepadaxis(game, nil, "triggerright", 1.0) +eq(#dirs, 2, "but the next crossing is a fresh press") +eq(last(), 1, "still faster") +Game.gamepadaxis(game, nil, "triggerleft", 1.0) eq(last(), -1, "L2 slows down") +eq(Input.stickDir, nil, "and no trigger sample ever moved the walk direction") +Input:reset() local routed = nil game.stack.top = function() return { onGamepadPressed = function(_, b) routed = b end } @@ -53,9 +69,10 @@ routed = nil Game.gamepadpressed(game, nil, "rightshoulder") eq(routed, "rightshoulder", "a screen owning pad input sees L1/R1 too, so CONTROLS can capture them") -Game.gamepadpressed(game, nil, "lefttrigger") -eq(routed, "lefttrigger", "and the trigger names alongside them") -check(#dirs == 2, "a captured shoulder never touches the speed ladder") +Game.gamepadaxis(game, nil, "triggerleft", 1.0) +eq(routed, "triggerleft", + "and a trigger arrives there under its axis name, so CONTROLS can bind L2") +check(#dirs == 3, "a captured shoulder never touches the speed ladder") game.stack.top = function() return nil end local forwarded = nil @@ -63,9 +80,38 @@ local origPad = Input.gamepadpressed function Input:gamepadpressed(joystick, button) forwarded = button end Game.gamepadpressed(game, nil, "rightshoulder") check(forwarded == nil, "the speed buttons never reach the GB button map") -eq(#dirs, 3, "and they still cycle the ladder") +eq(#dirs, 4, "and they still cycle the ladder") Game.gamepadpressed(game, nil, "a") eq(forwarded, "a", "everything else does reach it") +Input:reset() +Game.gamepadaxis(game, nil, "triggerleft", 1.0) +eq(#dirs, 5, "a trigger speed action does not reach it either") Input.gamepadpressed = origPad +Input:init() +Input:applyBindings({ a = { pad = "triggerright" }, speedUp = false }) +game.stack.top = function() return nil end +Game.gamepadaxis(game, nil, "triggerright", 1.0) +Input:step() +check(Input:isDown("a"), "a held L2/R2 holds the GB button it is bound to") +Game.gamepadaxis(game, nil, "triggerright", 0.3) +Input:step() +check(Input:isDown("a"), "jitter above OFF does not drop the hold") +Game.gamepadaxis(game, nil, "triggerright", 0.0) +Input:step() +check(not Input:isDown("a"), "and the release lets go") +Input:init() + +dirs = {} +Input:applyBindings({ speedUp = { pad = "joy12" } }) +eq(Input:joyAction(12), "speedUp", "the joyN decode reaches the action map") +Game.joystickpressed(game, nil, 12) +eq(last(), 1, "and the raw press cycles the ladder") +eq(#dirs, 1, "once") +Input:step() +local held = 0 +for _ in pairs(Input.state) do held = held + 1 end +eq(held, 0, "without also pressing a Game Boy button") +Input:init() + T.finish("speed_shoulders_triggers") diff --git a/tests/engine/trade_anim_parity_2278.lua b/tests/engine/trade_anim_parity_2278.lua new file mode 100644 index 00000000..4be1cd6d --- /dev/null +++ b/tests/engine/trade_anim_parity_2278.lua @@ -0,0 +1,129 @@ +-- engine/movie/trade.asm +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() +local ids = T.fixtures.ids + +local S = require("tests.harness").suite("trade anim parity 2278") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local Sound = require("src.core.Sound") +local PaletteFX = require("src.render.PaletteFX") +local TradeAnim = require("src.ui.TradeAnim") + +Data.text._TradeWentToText = "{RAM:wStringBuffer} went to\n{RAM:wLinkEnemyTrainerName}." +Data.text._TradeForText = "For {RAM:wStringBuffer}," +Data.text._TradeSendsText = "{RAM:wLinkEnemyTrainerName} sends\n{RAM:wNameBuffer}." +Data.text._TradeWavesFarewellText = "{RAM:wLinkEnemyTrainerName} waves." +Data.text._TradeTransferredText = "{RAM:wNameBuffer} is\ntransferred." +Data.text._TradeTakeCareText = "Take good care of\n{RAM:wNameBuffer}." + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +local sent = Pokemon.new(Data, ids.species[1], 10) +local recv = Pokemon.new(Data, ids.species[2], 10) +recv.nickname = "DUX" +recv.ot = "TRAINER" +recv.otId = 8193 +local recvName = Data.pokemon[recv.species].name + +local sounds = {} +local realPlay, realCry = Sound.play, Sound.playCry +Sound.play = function(_, name) sounds[#sounds + 1] = { name = name } end +Sound.playCry = function() end + +local done = false +local anim = TradeAnim.new(Game, { + sent = sent, received = recv, enemyName = "TRAINER", + onDone = function() done = true end, +}) +Game.stack:push(anim) +if anim.enter then anim:enter() end +eq(#sounds, 0, "enter() plays no SFX") + +local texts, subs = {}, {} +local monVisibleInPoof, boxInRest, ballInRest = false, true, true +local lastSub = nil +local guard = 0 +while not done and guard < 20000 do + guard = guard + 1 + for i = #sounds, 1, -1 do sounds[i].phase = sounds[i].phase or anim.phase end + StateStack:update(1 / 60) + if anim.dialogText then texts[anim.dialogText] = true end + if anim.phase == "show_enemy" and anim.sub and anim.sub ~= lastSub then + subs[#subs + 1] = anim.sub + lastSub = anim.sub + end + if anim.phase == "show_enemy" and anim.sub == "poof" then + monVisibleInPoof = monVisibleInPoof or anim.monVisible + end + if anim.phase == "show_enemy" and anim.sub == "ball_rest" then + boxInRest = boxInRest and anim.boxVisible == true + ballInRest = ballInRest and anim.activeBallBlock ~= nil + monVisibleInPoof = monVisibleInPoof or anim.monVisible + end +end +Sound.play, Sound.playCry = realPlay, realCry +check(done, "TradeAnim reaches onDone") + +-- data/moves/animations.asm:1198, data/battle_anims/special_effects.asm:24 +local firstMachine +for _, s in ipairs(sounds) do + check(s.name ~= "Ball_Poof", "no Ball_Poof SFX (" .. tostring(s.name) .. ")") + if s.name == "Trade_Machine" and not firstMachine then firstMachine = s.phase end +end +eq(firstMachine, "ball_enter", "Trade_Machine only once the ball is sucked in") + +-- engine/movie/trade.asm:186 +local sawSends, sawTransferred, sawTakeCare = false, false, false +for text in pairs(texts) do + check(not text:find("DUX", 1, true), "no nickname in dialog: " .. text) + if text:find("sends", 1, true) then sawSends = true end + if text:find("transferred", 1, true) then sawTransferred = true end + if text:find("Take good care", 1, true) then sawTakeCare = true end + if text:find("sends", 1, true) or text:find("transferred", 1, true) + or text:find("Take good care", 1, true) then + check(text:find(recvName, 1, true) ~= nil, + "species name in dialog: " .. text) + end +end +check(sawSends and sawTransferred and sawTakeCare, "all three name lines printed") + +-- engine/movie/trade.asm:354 +local order = table.concat(subs, ",") +eq(order:sub(1, #"ball_bounce,ball_rest,poof,cry"), + "ball_bounce,ball_rest,poof,cry", "ball rests on the box before the poof") +check(boxInRest, "info box is up while the ball rests") +check(ballInRest, "ball is drawn while it rests") +check(not monVisibleInPoof, "mon stays hidden until the poof ends") + +-- engine/movie/trade.asm:602 +PaletteFX.setMode("ogred") +local flash = TradeAnim.new(Game, { sent = sent, received = recv }) +flash.phase = "transfer_lr" +flash.cableFlash = false +local base = flash:sgbPalettes(Game) +flash.cableFlash = true +local swapped = flash:sgbPalettes(Game) +check(base and base[1] and base[1].colors, "cable zone exists") +eq(swapped[1].colors[2], base[1].colors[3], "shade 1 shows shade 2 on flash") +eq(swapped[1].colors[3], base[1].colors[2], "shade 2 shows shade 1 on flash") +eq(swapped[1].colors[1], base[1].colors[1], "shade 0 unchanged") +eq(swapped[1].colors[4], base[1].colors[4], "shade 3 unchanged") + +-- engine/link/cable_club.asm:145 +local linkSrc = assert(io.open("src/link/LinkState.lua", "rb")):read("*a") +check(not linkSrc:find("Trade_Machine", 1, true), + "LinkState plays no Trade_Machine before the cinematic") + +S.finish() diff --git a/tests/gen2_save_convert_cli_test.lua b/tests/gen2_save_convert_cli_test.lua index 5cea280c..b124e3f4 100644 --- a/tests/gen2_save_convert_cli_test.lua +++ b/tests/gen2_save_convert_cli_test.lua @@ -62,14 +62,17 @@ write(goldPath, SaveSerializer.encode({ local out = run(("luajit tools/save_convert/convert.lua export %q %q") :format(goldPath, outPath)) --- Gen 2 exports through Gen2Save now, but only for a save that carries the --- cartridge image it came from. A slot built in the launcher has none. -check(out:find("no cartridge image", 1, true) ~= nil, - "exporting a Gold slot with no cartridge behind it is refused, and says why: " +-- Gen 2 no longer needs the cartridge image the save came from (#2283): one +-- is synthesized. What it does need is the map the save stands on, which this +check(out:find("does not name one", 1, true) ~= nil, + "exporting a Gold slot that names no map is refused, and says why: " .. (out:gsub("%s+$", ""))) +check(out:find("no cartridge image to write", 1, true) == nil, + "and not with the old lineage refusal: " .. (out:gsub("%s+$", ""))) check(not exists(outPath), "and no 32768-byte file that looks like a Red battery is written") + -- The way IN is no longer a gate: Gen 2 imports through -- src/save_convert/Gen2Save.lua now. What this pins is that the bytes reach -- that codec and are judged by ITS rules -- an all-zero image has neither of diff --git a/tests/gen2_save_export_test.lua b/tests/gen2_save_export_test.lua index 65ec1103..0867a7c6 100644 --- a/tests/gen2_save_export_test.lua +++ b/tests/gen2_save_export_test.lua @@ -65,9 +65,12 @@ check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil, "the slot file the launcher lists is on disk") local ok, res = SaveFileIO.exportActiveSlot("gold") -eq(ok, false, "Export on a Gold slot is refused, not crashed") -check(type(res) == "string" and res:find("no cartridge image", 1, true), - "the refusal names the missing cartridge image: " .. tostring(res)) +eq(ok, false, "Export on a cacheless Gold slot is refused, not crashed") +check(type(res) == "string" and res:find("does not name one", 1, true), + "the refusal names the unresolved map: " .. tostring(res)) +check(type(res) == "string" + and not res:find("no cartridge image to write", 1, true), + "and not the old lineage refusal: " .. tostring(res)) check(not tostring(res):find("GenSave", 1, true) and not tostring(res):find("attempt to index", 1, true), "no codec traceback leaks into the notice line") @@ -75,7 +78,36 @@ local exported = false for path in pairs(files) do if path:find("^exports/") then exported = true end end -eq(exported, false, "no export file is written for a Gold slot") +eq(exported, false, "no export file is written when the map cannot be resolved") + +do + local Gen2Save = require("src.save_convert.Gen2Save") + local placed = GoldSave.newGame({ playerName = "BLAKE", rivalName = "SILVER" }) + placed.position = { mapGroup = 24, mapNumber = 7, x = 3, y = 3 } + local data = { maps = { HOME = { + group = 24, map = 7, objectEventsAddr = 0x5CF0, + width = 4, height = 3, + blocks = { 4, 1, 3, 2, 5, 6, 5, 5, 5, 5, 7, 5 }, + objects = {}, + } } } + local bytes, why = Gen2Save.encode(placed, "gold", nil, data) + check(bytes ~= nil, "a fresh Gold slot exports -- " .. tostring(why)) + if bytes then + eq(#bytes, 32768, "a full battery image") + eq(Gen2Save.checksumValid(bytes, Gen2Save.layoutFor("gold")), true, + "sealed the way the cartridge verifies it") + local L = Gen2Save.layoutFor("gold") + -- data/default_options.asm, engine/overworld/player_object.asm:411 + eq(bytes:byte(L.sOptions + 1), 0x03, "sOptions carries DefaultOptions") + eq(bytes:byte(L.wPlayerStruct + 1), 0x01, "the player struct has a sprite") + eq(bytes:byte(L.wPlayerStruct + 4), 0x0B, "and SPRITEMOVEDATA_PLAYER") + eq(bytes:byte(L.wScreenSave + 1 + 7), 4, + "and the restored screen window holds the map's own blocks") + local back = Gen2Save.decode(bytes, "gold") + check(back ~= nil and back.player.name == "BLAKE", + "and the bytes decode back to the save they came from") + end +end -- The import direction no longer matches the export one. Gold imports through -- Gen2Save now, so a 32 KB image aimed at Gold is decoded rather than turned diff --git a/tests/parity_E.lua b/tests/parity_E.lua index 56571ab5..242c0a56 100644 --- a/tests/parity_E.lua +++ b/tests/parity_E.lua @@ -36,12 +36,9 @@ local realSoundOpts = require("src.render.TextBox").soundOpts package.loaded["src.render.TextBox"] = { new = function(game, text, onDone, opts) table.insert(shownTexts, text) - if opts and opts.choice then - opts.choice(choiceAnswer) - elseif onDone then - onDone() - end - return { text = text } + return { text = text, box = true, onDone = onDone, + stay = opts and opts.stay, + choice = opts and opts.choice } end, soundOpts = realSoundOpts, } @@ -81,16 +78,12 @@ end local realMenu = package.loaded["src.ui.Menu"] local menuPick = 1 local menuLabels = nil +local menuUnder, seesUnderMenu = nil, nil package.loaded["src.ui.Menu"] = { new = function(game, items, opts) menuLabels = {} for i, it in ipairs(items) do menuLabels[i] = it.label end - if menuPick == "cancel" then - if opts and opts.onCancel then opts.onCancel() end - else - items[menuPick].onSelect() - end - return {} + return { menu = true, items = items, opts = opts or {} } end, } @@ -107,14 +100,49 @@ check(story5.CINNABAR_ISLAND ~= nil and story5.CINNABAR_ISLAND.onEnter ~= nil, local talkScientist1 = story2.CINNABAR_LAB_FOSSIL_ROOM.talk.TEXT_CINNABARLABFOSSILROOM_SCIENTIST1 +local function newStack() + local stack = { states = {} } + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + function stack:push(state) + table.insert(self.states, state) + if state.menu then + menuUnder = self.states[#self.states - 1] + local pick, opts = menuPick, state.opts + if pick == "cancel" then + if not opts.keepOnCancel then self:pop() end + if opts.onCancel then opts.onCancel() end + else + local item = state.items[pick] + if not item.keepOpen then self:pop() end + if item.onSelect then item.onSelect() end + end + elseif state.box then + if state.stay then + if state.stay.onShown then state.stay.onShown() end + elseif state.choice then + local below = self.states[#self.states - 1] + seesUnderMenu = (below and below.menu) or false + self:pop() + state.choice(choiceAnswer) + else + self:pop() + if state.onDone then state.onDone() end + end + end + end + return stack +end + local function newGame() local save = SaveData.newGame() - local game = { data = Data, save = save, stack = { push = function() end } } + local game = { data = Data, save = save, stack = newStack() } return game end local function talk(game) shownTexts = {} + game.stack.states = {} local doneCalled = false local ow = {} ow.runner = fakeRunner(game, ow) @@ -144,6 +172,14 @@ do check(talk(game), "deposit talk completes") eq(menuLabels and #menuLabels, 1, "fossil menu lists the one carried fossil") eq(menuLabels and menuLabels[1], "OLD AMBER", "fossil menu shows the item name") + -- under the TextBoxBorder 0,0 menu (engine/events/cinnabar_lab.asm:22-24) + check(menuUnder ~= nil and menuUnder.box == true, + "the intro dialogue box is still on the stack under the fossil menu") + eq(menuUnder and menuUnder.text, shownTexts[1], + "and it is the intro text, not a box pushed after it") + -- SeesFossilText prints below it (engine/events/cinnabar_lab.asm:55-67) + eq(seesUnderMenu, true, "the menu box is still up when SeesFossilText prints") + eq(#game.stack.states, 0, "and both boxes are popped exactly once when it ends") local sees for _, s in ipairs(shownTexts) do if s:find("Resurrection") then sees = s end @@ -220,6 +256,8 @@ do menuPick = "cancel" check(talk(game), "menu-cancel talk completes") eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!{DONE}", "menu B-out shows ComeAgainText") + -- (engine/events/cinnabar_lab.asm:70-73) + eq(#game.stack.states, 0, "the B-out path pops the menu and the intro box too") eq(game.save.inventory.DOME_FOSSIL, 1, "fossil kept after menu cancel") check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "menu cancel sets no quest flags") eq(game.save.labFossilMon, nil, "menu cancel leaves no pending species") @@ -232,6 +270,7 @@ do menuPick, choiceAnswer = 1, false check(talk(game), "confirm-NO talk completes") eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!{DONE}", "NO on the confirm shows ComeAgainText") + eq(#game.stack.states, 0, "the NO path pops the menu and the intro box too") eq(game.save.inventory.DOME_FOSSIL, 1, "fossil kept after NO") check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "NO sets no quest flags") eq(game.save.labFossilMon, nil, "NO leaves no pending species") diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index 0f427eea..133bfe47 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -150,9 +150,10 @@ check(guard > expected, "credits pages were not skippable by holding A") -- the autosave (SaveGameData while THE END is up) check(wrote, "autosave ran during THE END") -local savedRaw = love.filesystem.read("save.lua") -local saved = savedRaw and SaveData.decode(savedRaw) or nil -check(saved ~= nil, "save.lua written and decodable") +-- Read it back the way it was written: once a slot is registered, SaveData.save +-- targets saves//.lua, not the pre-slots flat file. +local saved = SaveData.load(game2.save.version) +check(saved ~= nil, "the autosave is written and decodable") eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN", "wLastBlackoutMap := PALLET_TOWN before the save") -- #103 parked CONTINUE in the upstairs bedroom; #253 corrected that to the diff --git a/tests/parity_lift_key_yellow_bug552.lua b/tests/parity_lift_key_yellow_bug552.lua index 1fd59398..5576b441 100644 --- a/tests/parity_lift_key_yellow_bug552.lua +++ b/tests/parity_lift_key_yellow_bug552.lua @@ -135,6 +135,66 @@ do eq(save.objectToggles[MAP][BALL], true, "the ball is not re-toggled") end +-- home/trainers.asm:341 +do + local GameVersion = require("src.core.GameVersion") + local prior = GameVersion.current + local game, ow, npc, save = newWorld(false) + local view = mapScripts.get(MAP) + check(type(view and view.onVictory) == "function", + "ROCKET_HIDEOUT_B4F declares an onVictory hook (#2277)") + + save.defeatedTrainers[npc.id] = true + save.flags.EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 = true + + GameVersion.set("yellow") + if type(view and view.onVictory) == "function" then view.onVictory(game, ow) end + GameVersion.set(prior) + + check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true, + "a sight battle sets EVENT_ROCKET_DROPPED_LIFT_KEY too") + eq(save.objectToggles[MAP] and save.objectToggles[MAP][BALL], true, + "and ShowObject reveals the ball with no conversation") + check(OverworldState.objectVisible(save, MAP, ballObj), + "the LIFT KEY ball is on the floor after the sight battle") +end + +-- pokered/scripts/RocketHideoutB4F.asm:190 +do + local GameVersion = require("src.core.GameVersion") + local prior = GameVersion.current + local game, ow, npc, save = newWorld(false) + save.defeatedTrainers[npc.id] = true + save.flags.EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 = true + local onVictory = mapScripts.get(MAP).onVictory + GameVersion.set("red") + if type(onVictory) == "function" then onVictory(game, ow) end + GameVersion.set(prior) + check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY, + "Red/Blue do not gain the ball a conversation early") + check(not OverworldState.objectVisible(save, MAP, ballObj), + "the Red/Blue ball stays hidden until Rocket3's after-battle text") +end + +-- pokered/scripts/RocketHideoutB4F.asm:193 +do + local game, ow, npc, save, pushed = newWorld(true) + save.flags.EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 = true + local done = false + check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY, + "the stuck save has the win but not the drop") + script(game, ow, npc, function() done = true end) + eq(ow.engagements, 0, "a beaten grunt does not re-engage") + eq(#pushed, 1, "he still says his after-battle line") + eq(pushed[1] and getmetatable(pushed[1]), TextBox, "in a text box") + if pushed[1] and pushed[1].onDone then pushed[1].onDone() end + check(done, "closing that box hands input back to the overworld") + check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true, + "and that talk sets EVENT_ROCKET_DROPPED_LIFT_KEY (#2277)") + check(OverworldState.objectVisible(save, MAP, ballObj), + "the ball a sight win swallowed is on the floor again") +end + -- ------------------------------------------ the ball itself, #105's half -- talkTo's item-ball branch is what turns the revealed object into a bag -- entry; without it the fix above only puts a sprite on the floor. Same diff --git a/tests/parity_status_move_failure.lua b/tests/parity_status_move_failure.lua new file mode 100644 index 00000000..2df6f549 --- /dev/null +++ b/tests/parity_status_move_failure.lua @@ -0,0 +1,135 @@ +-- (engine/battle/core.asm:3718) for these +-- straight to JumpMoveEffect (core.asm:3125, :3213) and a 0-BP move bails +-- out of damage at core.asm:3146 before MoveHitTest +-- prints its own: PrintDidntAffectText for SleepEffect (effects.asm:57,67), +-- PoisonEffect (:113,159) and ParalyzeEffect_ (move_effects/paralyze.asm:33,40); +-- ConditionalPrintButItFailed for ConfusionEffect (effects.asm:1158), +-- for LeechSeedEffect_ (move_effects/leech_seed.asm:28-32) +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.moves and Data.moves.HYPNOSIS) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end + +local Game = require("src.core.Game") +Game.data = Data +Game.save = require("src.core.SaveData").newGame() + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local strip = require("src.render.TextBox").strip +local S = require("tests.harness").suite("parity status move failure") +local check, eq = S.check, S.eq + +local function freshBattle(species, rng) + Game.save.options.animations = true + Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) } + local tb = BattleState.newWild(Game, species or "PIDGEY", 10) + tb.queue, tb.nextInsert = {}, 0 + tb.rng = rng or function(a, b) return b end + return tb +end + +local function texts(tb) + local out = {} + for _, row in ipairs(tb.queue) do + if row.text then out[#out + 1] = strip(row.text):gsub("%s+$", "") end + end + return out +end + +local function say(moveId, opts) + opts = opts or {} + local tb = freshBattle(opts.species, opts.rng) + local user = opts.enemy and tb.enemy or tb.player + local target = opts.enemy and tb.player or tb.enemy + if opts.before then opts.before(tb, user, target) end + tb:performMove(user, target, { id = moveId, pp = 10 }, false) + local lines = texts(tb) + return lines[#lines] or "", tb +end + +local function has(line, needle) + return line:find(needle, 1, true) ~= nil +end + +do + for _, id in ipairs({ "HYPNOSIS", "POISONPOWDER", "THUNDER_WAVE" }) do + local line = say(id) + check(has(line, "didn't affect"), + id .. " miss prints DidntAffectText, got: " .. line) + check(not has(line, "attack missed"), + id .. " miss must not print AttackMissedText (#2282), got: " .. line) + end + eq(Data.moves.HYPNOSIS.effect, "SLEEP_EFFECT", "HYPNOSIS is SLEEP_EFFECT") + eq(Data.moves.POISONPOWDER.effect, "POISON_EFFECT", "POISONPOWDER is POISON_EFFECT") + eq(Data.moves.THUNDER_WAVE.effect, "PARALYZE_EFFECT", "THUNDER WAVE is PARALYZE_EFFECT") +end + +do + for _, id in ipairs({ "CONFUSE_RAY", "DISABLE", "GROWL", "SAND_ATTACK" }) do + local line = say(id) + check(has(line, "But, it failed!"), + id .. " miss prints ButItFailedText, got: " .. line) + check(not has(line, "attack missed"), + id .. " miss must not print AttackMissedText, got: " .. line) + end +end + +-- the enemy-only 25% stat-down whiff -- engine/battle/effects.asm:552-555 +do + local line = say("GROWL", { enemy = true, rng = function() return 0 end }) + check(has(line, "But, it failed!"), + "the foe's 25% stat-down whiff prints ButItFailedText, got: " .. line) +end + +do + local line = say("LEECH_SEED") + check(has(line, "evaded attack"), + "a missed LEECH SEED prints EvadedAttackText, got: " .. line) + + -- leech_seed.asm:14-20 + local grass = say("LEECH_SEED", { species = "BULBASAUR", + rng = function(a) return a end }) + check(has(grass, "evaded attack"), + "LEECH SEED on a Grass-type prints EvadedAttackText, got: " .. grass) + + -- leech_seed.asm:21-24 + local again = say("LEECH_SEED", { rng = function(a) return a end, + before = function(_, _, target) + target.leechSeeded = true + end }) + check(has(again, "evaded attack"), + "re-seeding prints EvadedAttackText, got: " .. again) +end + +do + local line = say("TACKLE") + check(has(line, "attack missed"), + "a real accuracy miss on a damaging move still prints AttackMissedText, got: " .. line) +end + +do + local prev = Data.move_effects + local records = {} + for id, record in pairs(require("src.battle.MoveEffects").RECORDS) do + records[id] = record + end + records.MODKIT_TEST_EFFECT = { kind = "primary", accuracyChecked = true, + run = function() return {} end } + Data.move_effects = records + Data.moves.MODKIT_TEST_MOVE = { id = "MODKIT_TEST_MOVE", name = "MODTEST", + type = "NORMAL_TYPE", power = 0, pp = 10, + accuracy = 60, effect = "MODKIT_TEST_EFFECT" } + local ok, line = pcall(say, "MODKIT_TEST_MOVE") + Data.move_effects = prev + Data.moves.MODKIT_TEST_MOVE = nil + check(ok, "a mod-registered status effect resolves: " .. tostring(line)) + check(has(line, "attack missed"), + "an unmapped record keeps AttackMissedText, got: " .. tostring(line)) +end diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index 68529399..fc2f962f 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -412,6 +412,17 @@ tC:confirm(true) tC:handle({ type = "confirm", ok = false }) eq(tC.stage, "cancelled", "declined trade cancels") +-- TradeCenter_PrintPartyListNames (engine/link/cable_club.asm:657) reads +local LinkStateMod = require("src.link.LinkState") +local labelState = setmetatable({ game = { data = Data } }, LinkStateMod) +local nicked = Pokemon.new(Data, "SANDSHREW", 5) +nicked.nickname = "FLUFFY" +eq(labelState:listLabel(nicked), "SANDSHREW", "the list shows the species, not the nickname") +eq(#labelState:listLabel(nicked), 9, "a 9-char species name is not truncated") +local pickStub = { canPick = function() return false end } +eq(labelState:listLabel(nicked, pickStub, 1), "SANDSHREWX", + "an unpickable mon keeps its X mark") + -- ---------------------------------------------------------------- link battle (lockstep) -- Both sides run the full engine locally on a shared seed; this drives -- two simulations over a loopback and checks they agree. diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py index 78ff3258..c953db06 100644 --- a/tools/gen2_sram_offsets.py +++ b/tools/gen2_sram_offsets.py @@ -33,9 +33,19 @@ FIELDS = [ "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", "wStatusFlags", "wStatusFlags2", "wPokegearFlags", "wVisitedSpawns", "wVariableSprites", "wGameTimeHours", "wGameTimeMinutes", + # engine/menus/intro_menu.asm:28 _ResetWRAM, engine/overworld/player_object.asm:19 + "wRedsName", "wGreensName", "wSavedAtLeastOnce", "wSpawnAfterChampion", + "wCenteredObject", "wPlayerStruct", "wMapObjects", "wNumPCItems", + "wMomItemTriggerBalance", "wRoamMon1MapGroup", "wRoamMon2MapGroup", + "wRoamMon3MapGroup", "wBestMagikarpLengthFeet", "wBestMagikarpLengthInches", + "wMagikarpRecordHoldersName", "wDecoBed", "wDecoPoster", "wScreenSave", ] GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] +# ram/sram.asm -- bank 0 labels, outside the copied WRAM block: file offset is +# bank * 0x2000 + (addr - $A000). +RAW_SRAM = ["sOptions", "sMysteryGiftUnlocked"] + # ram/sram.asm:138-144 SRAM_FIELDS = [("wPlayerGender", "sCrystalData", "wCrystalData")] @@ -84,7 +94,7 @@ def backup_table(sym, rows, label): "sChecksum": off("sBackupChecksum"), "sGameData": off("sBackupGameData"), "sGameDataEnd": off("sBackupGameDataEnd")} - absolute = {n for n, _, _ in SRAM_FIELDS} + absolute = {n for n, _, _ in SRAM_FIELDS} | set(RAW_SRAM) out = [] for name, value in rows: if name in guards: @@ -128,6 +138,11 @@ def table(sym, label): if name in sym and sbase in sym and wbase in sym: at = sym[sbase][0] * 0x2000 + (sym[sbase][1] - 0xA000) rows.append((name, at + (sym[name][1] - sym[wbase][1]))) + for name in RAW_SRAM: + s = sym.get(name) + if not s: + sys.exit(f"{label}: {name} is missing from the symbol file") + rows.append((name, s[0] * 0x2000 + (s[1] - 0xA000))) boxes = [] for i in range(1, BOX_COUNT + 1): b = sym.get("sBox%d" % i)