diff --git a/README.md b/README.md index 141f35d4..8591c88b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. * ### Watch the latest update video -[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM) +[![Watch the latest update video](https://img.youtube.com/vi/0hgtLDNGpdo/maxresdefault.jpg)](https://youtu.be/0hgtLDNGpdo) This project does not include a ROM, emulate the Game Boy, transpile assembly, or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, diff --git a/data/palettes_gbc_yellow.lua b/data/palettes_gbc_yellow.lua new file mode 100644 index 00000000..7b4c341c --- /dev/null +++ b/data/palettes_gbc_yellow.lua @@ -0,0 +1,270 @@ +-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua +-- world tables when GameVersion.isYellow(); Red/Blue never load this file's +-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal +-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON). +-- See #1639. + +return { + world = { + -- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE + -- (closest Advanced indoor profile); colors lean sand / wood / water. + tileGroups = { + BEACH_HOUSE = { + [0] = 5, + [1] = 0, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 0, + [6] = 3, + [7] = 3, + [8] = 2, + [9] = 2, + [10] = 2, + [11] = 2, + [12] = 2, + [13] = 2, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 0, + [22] = 3, + [23] = 3, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 0, + [29] = 0, + [30] = 5, + [31] = 5, + [32] = 0, + [33] = 0, + [34] = 3, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 2, + [43] = 2, + [44] = 5, + [45] = 5, + [46] = 5, + [47] = 5, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 5, + [71] = 5, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 5, + [81] = 5, + [82] = 5, + [83] = 5, + [84] = 5, + [85] = 5, + [86] = 5, + [87] = 5, + [88] = 5, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 5, + [93] = 5, + [94] = 5, + [95] = 5, + }, + }, + groupColors = { + BEACH_HOUSE = { + -- sand / wood floor + { + { 255, 239, 198 }, + { 206, 173, 115 }, + { 156, 123, 74 }, + { 58, 58, 58 }, + }, + -- signs / accents (warm coral) + { + { 255, 239, 198 }, + { 255, 156, 148 }, + { 230, 82, 66 }, + { 58, 58, 58 }, + }, + -- foliage + { + { 255, 239, 198 }, + { 123, 189, 82 }, + { 66, 123, 41 }, + { 58, 58, 58 }, + }, + -- water / windows + { + { 255, 239, 198 }, + { 99, 189, 230 }, + { 41, 115, 189 }, + { 58, 58, 58 }, + }, + -- yellow props + { + { 255, 239, 198 }, + { 255, 255, 82 }, + { 230, 148, 25 }, + { 58, 58, 58 }, + }, + -- wood walls / furniture + { + { 255, 239, 198 }, + { 189, 140, 74 }, + { 140, 99, 41 }, + { 58, 58, 58 }, + }, + -- sky / cool trim + { + { 255, 239, 198 }, + { 148, 189, 255 }, + { 99, 148, 230 }, + { 58, 58, 58 }, + }, + -- text + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + }, + + -- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72. + -- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James); + -- Red's ball/fossil/snorlax/... shift to 70-81. + spriteAssignment = { + [0] = 0, + [1] = 1, + [2] = 3, + [3] = "random", + [4] = 0, + [5] = "random", + [6] = "random", + [7] = "random", + [8] = 0, + [9] = "random", + [10] = "random", + [11] = "random", + [12] = "random", + [13] = "random", + [14] = "random", + [15] = 1, + [16] = 1, + [17] = "random", + [18] = "random", + [19] = "random", + [20] = "random", + [21] = 2, + [22] = 1, + [23] = 3, + [24] = "random", + [25] = "random", + [26] = "random", + [27] = "random", + [28] = "random", + [29] = 0, + [30] = 3, + [31] = 3, + [32] = "random", + [33] = "random", + [34] = "random", + [35] = "random", + [36] = "random", + [37] = "random", + [38] = "random", + [39] = "random", + [40] = 0, + [41] = 2, + [42] = "random", + [43] = "random", + [44] = "random", + [45] = "random", + [46] = "random", + [47] = "random", + [48] = "random", + [49] = "random", + [50] = "random", + [51] = "random", + [52] = "random", + [53] = "random", + [54] = "random", + [55] = 0, + [56] = 1, + [57] = 3, + [58] = 0, + [59] = 0, + [60] = 4, -- SPRITE_PIKACHU + [61] = 1, -- SPRITE_OFFICER_JENNY + [62] = 3, -- SPRITE_SANDSHREW + [63] = 2, -- SPRITE_ODDISH + [64] = 2, -- SPRITE_BULBASAUR + [65] = "random", -- SPRITE_JIGGLYPUFF + [66] = "random", -- SPRITE_CLEFAIRY + [67] = "random", -- SPRITE_CHANSEY + [68] = "random", -- SPRITE_JESSIE + [69] = "random", -- SPRITE_JAMES + [70] = 0, -- SPRITE_POKE_BALL + [71] = 0, -- SPRITE_FOSSIL + [72] = 3, -- SPRITE_BOULDER + [73] = 3, -- SPRITE_PAPER + [74] = 0, -- SPRITE_POKEDEX + [75] = 3, -- SPRITE_CLIPBOARD + [76] = 0, -- SPRITE_SNORLAX + [77] = 3, -- SPRITE_UNUSED_OLD_AMBER + [78] = 3, -- SPRITE_OLD_AMBER + [79] = "random", + [80] = "random", + [81] = 3, -- SPRITE_GAMBLER_ASLEEP + }, + + -- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]). + spritePalettes = { + [4] = { + { 222, 255, 222 }, + { 255, 255, 0 }, + { 230, 115, 0 }, + { 0, 0, 0 }, + }, + }, + }, +} diff --git a/docs/modding.md b/docs/modding.md index 55f8ee82..7c464abe 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -505,6 +505,42 @@ Menu choices and moves use the same engine methods as the native controls; their mutable logic. Tutorial, link, forced, stale, and covered battle states refuse core intents. Use `mod.input` for ordinary text advance. +## Battle rule hooks + +Two decisions the OPTION screen and the cart make for the player, which a game +mode can make instead (RFC 0015). Neither writes the player's saved +preference, so a mode can hold a rule for as long as it is active and hand the +player's own setting back untouched. + +`battle.style` wraps the SHIFT/SET read at the moment the foe's Pokémon faints +and the engine would offer a free switch: + +```lua +mod.hooks:wrap("battle.style", function(next, battle) + if myMode.active then return "set" end -- no "will you change POKéMON?" + return next(battle) -- the OPTION row, as today +end) +``` + +Return `"set"` or `"shift"`; anything else reads as the vanilla answer. + +`catch.nickname` wraps the `AskName` prompt after a capture (party or box), +the same question `pokemon.before_give`'s `gift.nickname` already answers for +script gifts: + +```lua +mod.hooks:wrap("catch.nickname", function(next, mon, ctx) + -- ctx = { battle = , name = , game = } + if myMode.active then return false end -- keep the species name + if myNames then return myNames[mon.species] end -- a string names it, no prompt + return next(mon, ctx) -- true: ask, as today +end) +``` + +`false` keeps the species name with no prompt. A string is the nickname with +no prompt, clipped to the naming grid's ten characters (an empty string names +nothing). Anything else queues the prompt. + ## Party-full custody at a catch When a capture lands on a full party, the cart deposits the mon in storage diff --git a/docs/rfcs/0015-battle-rule-hooks.md b/docs/rfcs/0015-battle-rule-hooks.md new file mode 100644 index 00000000..ca482c92 --- /dev/null +++ b/docs/rfcs/0015-battle-rule-hooks.md @@ -0,0 +1,110 @@ +# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname` + +## Status + +Proposed. + +## Motivation + +Two decisions in a Red/Blue/Yellow battle are made for the player by the +OPTION screen and by the cart, and a game mode has no way to make them +instead: + +**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the +battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon +faints, SET does not. The engine reads `save.options.battleStyle` inline at +that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale +where party-as-health is the whole design — can only get it by writing the +player's saved preference, which leaks into their real playthrough the first +time anything calls `Game:writeOptions` (the speed hotkey does, on every +press). + +**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox` +both run `AskName` after a capture. A mode with a disposable team, a +randomizer that names what it hands out, a speedrun practice mod — all want to +answer that prompt themselves, and the only way today is to drive the yes/no +box from outside. The script-gift path already has this seam: a mod that sets +`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does +not. + +The immediate consumer is a battle-royale mode; neither hook is specific to it. + +## The decision it extends + +This extends the **additive, guarded seam convention** Route B in +`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee +`tests/engine/gate_meta_coverage.lua` enforces. `catch.nickname` mirrors a +contract that already exists for gifts (`pokemon.before_give`'s +`gift.nickname`), so the two ways a Pokémon joins the party answer the same +question the same way. + +There is no in-repo D-number registry to amend. + +## Exact API delta + +### New hook: `battle.style` + +```lua +mod.hooks:wrap("battle.style", function(next, battle) + if myMode.active then return "set" end + return next(battle) -- the OPTION row, as today +end) +``` + +Call site: `BattleState:battleStyle()`, called from the enemy send-out path at +the moment the SHIFT prompt would be offered. The vanilla link reads +`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the +inline read did. A return of `"set"` or `"shift"` is used; anything else reads +as the vanilla answer, so a hook that returns nothing by mistake cannot change +the rule. The player's saved preference is never written. + +### New hook: `catch.nickname` + +```lua +mod.hooks:wrap("catch.nickname", function(next, mon, ctx) + -- ctx = { battle = , name = , game = } + if myMode.active then return false end -- keep the species name + if randomizer then return pickName(mon) end -- a string names it + return next(mon, ctx) -- true: ask, as today +end) +``` + +Call site: `BattleState:offerNickname(mon, displayName)`, called from the +capture path where `AskName` ran, before the prompt is queued. The vanilla +link returns `true`. `false` skips the prompt and keeps the species name; a +string skips the prompt and is the nickname, clipped to the naming grid's ten +characters (an empty string names nothing, like the grid's own empty entry); +anything else queues the prompt as today. The method returns whether a prompt +was queued. + +### No other surface changes + +Both call sites are guarded by `Runtime.wantsHook`, and both vanilla links are +file-local functions, so a build with nothing wrapped runs the branch exactly +as before and allocates nothing it did not allocate before. `askNicknameUI` is +unchanged and still public. + +## Migration + +Nothing changes for existing mods. A mod that was writing +`save.options.battleStyle` to force a style should wrap `battle.style` instead +and stop writing the option. + +## Verification + +- `tests/modkit/cases/battle_rule_hooks.lua` — through the public mod API: the + no-mod answers match the OPTION row and the cart's AskName; a wrapped mod + forces either style without the row being written; `false`, a string, a + too-long string, an empty string, and a fall-through each do what this RFC + says at the catch prompt. +- `tests/engine/gate_hooks.lua` — both names are in the live catalog and pass + the no-mod parity gate (vanilla called exactly once, result unchanged, + nothing allocated). +- `tests/engine/gate_meta_coverage.lua` — both names are covered by the unit + corpus. + +## Backward compatibility + +Additive. No existing hook, event, registry or manifest field changes shape. +The two new methods on `BattleState` are called only from the paths that +previously inlined their logic; the results with no subscriber are identical. diff --git a/main.lua b/main.lua index 52a4a1f2..eda842d2 100644 --- a/main.lua +++ b/main.lua @@ -384,12 +384,14 @@ function bootGame(version, cartId) Game = require("src.core.Game2").new() Game:load() else - -- Gen1 Game is a module singleton. In-process EXIT GAME resets it in - -- place; if a prior teardown left load missing, rebuild from source. + -- Gen1 Game is a module singleton. Always re-require after in-process + -- EXIT GAME so a prior session cannot leave a table whose rawget(load) + -- is nil (release Android: bootGame then dies with load-a-nil-value). + -- rawget: type(mod.load) can lie via __index and skip a rebuild. + package.loaded["src.core.Game"] = nil local gameMod = require("src.core.Game") - if type(gameMod.load) ~= "function" then - package.loaded["src.core.Game"] = nil - gameMod = require("src.core.Game") + if type(rawget(gameMod, "load")) ~= "function" then + error("src.core.Game missing load after reload") end Game = gameMod Game:load() @@ -1125,15 +1127,23 @@ function love.quit() -- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- this Lua launcher via that hook. Vanilla behavior (used when no mod -- claims the hook) is exactly the condition below. - local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android") + -- + -- Android and iOS both tear down LOVE in-process rather than + -- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes + -- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for + -- every quit while warning that leftover threads make that unreliable. + -- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning + -- real -- endProcess joins them, then the native restart still blows up. + local osName = love.system and love.system.getOS and love.system.getOS() + local inProcessReturn = (osName == "Android" or osName == "iOS") local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() return Game and not Importer and not quitToLauncher and not scripted - and (isAndroid or not launchedIntoGame) + and (inProcessReturn or not launchedIntoGame) end) if wouldReturnToLauncher then - if isAndroid then + if inProcessReturn then returnToLauncher() - return true -- abort this quit; the restart lands back in the launcher + return true -- abort this quit; stay in the same LOVE run end quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 54a5be46..68d1cd5a 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -182,7 +182,7 @@ void System::vibrate(double seconds) const #endif } -bool System::pickFile(const char *kind) const +bool System::pickFile(const char *kind, const char *destination) const { #ifdef LOVE_ANDROID const char *dest = "picked_rom.gb"; @@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) dest = "picked_save.sav"; else if (strcmp(kind, "required_import") == 0) - dest = "picked_required_import.bin"; + dest = (destination != nullptr && destination[0] != '\0') + ? destination : "picked_required_import.bin"; else if (strcmp(kind, "rom") == 0) dest = "picked_rom.gb"; // Unknown kinds used to fall through to the ROM destination. Refuse them diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 2fcfad46..a15c3bea 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -116,7 +116,7 @@ public: * "required_import" -> picked_required_import.bin. * @return Whether the picker was shown. **/ - virtual bool pickFile(const char *kind = nullptr) const; + virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const; virtual const char *pickFileKinds() const; /** diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 0acdbf0c..0f65b740 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -101,7 +101,8 @@ int w_vibrate(lua_State *L) int w_pickFile(lua_State *L) { const char *kind = luaL_optstring(L, 1, nullptr); - luax_pushboolean(L, instance()->pickFile(kind)); + const char *destination = luaL_optstring(L, 2, nullptr); + luax_pushboolean(L, instance()->pickFile(kind, destination)); return 1; } 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 2fe59948..8490fa50 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 @@ -39,6 +39,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.security.MessageDigest; import android.Manifest; import android.app.AlarmManager; @@ -111,6 +112,8 @@ public class GameActivity extends SDLActivity { // basename as its body, so RomImporter:focus can say so in the launcher // instead of leaving the player on "No ROM imported" (issue #442). private static final String PICK_ERROR_FILENAME = "pick_error.flag"; + // Written after a direct required-import copy has been fully published. + private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag"; // Step bridge (love.system.syncHealthSteps): pending-steps delivery // consumed by the Pokéwalker mod, same contract as the iOS // GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER @@ -576,6 +579,21 @@ public class GameActivity extends SDLActivity { * picked_rom.gb, picked_mod.zip, picked_save.sav, or * picked_required_import.bin) */ + private static boolean isDirectRequiredDestination(String relative) { + if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false; + String normalized = relative.replace('\\', '/'); + if (!normalized.startsWith("mods/")) return false; + int marker = normalized.indexOf("/baseroms/"); + if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) { + return false; + } + return !normalized.contains("//") + && !normalized.equals("..") + && !normalized.startsWith("../") + && !normalized.contains("/../") + && !normalized.endsWith("/.."); + } + /** Legacy single-argument entry; resolves the save dir itself. */ @Keep public static boolean showFilePicker(String destFilename) { @@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity { // onActivityResult copies the pick there, not into a recomputed // (possibly different-volume) root (#604, #839). self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; - // Reject path separators so a hostile JNI caller cannot escape the - // save identity directory. - if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { - Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename); + // Basename destinations keep the historical ROM/mod/save staging path. + // A nested destination is accepted only for an engine-generated mod + // baseroms path, then canonicalized beneath LOVE's mounted save root. + String normalizedDest = destFilename.replace('\\', '/'); + boolean nested = normalizedDest.indexOf('/') >= 0; + if (nested && !isDirectRequiredDestination(normalizedDest)) { + Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename); + return false; + } + try { + File rootCanonical = self.saveIdentityDir().getCanonicalFile(); + File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile(); + String rootPrefix = rootCanonical.getPath() + File.separator; + if (destCanonical.equals(rootCanonical) + || !destCanonical.getPath().startsWith(rootPrefix)) { + Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename); + return false; + } + } catch (IOException e) { + Log.d("GameActivity", "could not validate picker dest: " + e.getMessage()); return false; } - self.pendingPickFilename = destFilename; + self.pendingPickFilename = normalizedDest; if (android.os.Build.VERSION.SDK_INT >= 21) { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -1277,13 +1311,7 @@ public class GameActivity extends SDLActivity { /** Drops a small flag file in the save identity for Lua to consume on focus. */ private void writeSaveDirFlag(String name, String body) { - try { - FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false); - fos.write(body.getBytes()); - fos.close(); - } catch (IOException e) { - Log.d("GameActivity", "could not write " + name + ": " + e.getMessage()); - } + writeFlagFile(saveIdentityDir(), name, body); } /** @@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "could not create " + destDir); return; } - String destName = pendingPickFilename != null + final String destName = pendingPickFilename != null ? pendingPickFilename : PICKED_ROM_FILENAME; - File destFile = new File(destDir, destName); + final boolean directRequired = isDirectRequiredDestination(destName); + final File destFile; + try { + File rootCanonical = destDir.getCanonicalFile(); + destFile = new File(rootCanonical, destName).getCanonicalFile(); + String rootPrefix = rootCanonical.getPath() + File.separator; + if (destFile.equals(rootCanonical) + || !destFile.getPath().startsWith(rootPrefix) + || (destName.indexOf('/') >= 0 && !directRequired)) { + Log.d("GameActivity", "refusing unsafe result dest: " + destName); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); + return; + } + } catch (IOException e) { + Log.d("GameActivity", "could not validate result dest: " + e.getMessage()); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); + return; + } // ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but // some OEM shells (ColorOS) offer third-party file managers in a @@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity { writeSaveDirFlag(PICK_ERROR_FILENAME, destName); return; } - if (!copyAssetFile(source, destFile.getPath())) { + final InputStream pickedSource = source; + final File pickedRoot = destDir; + if (directRequired) { + // Optical-disc-sized imports must not block Android's UI thread and + // must not create a second picked_required_import.bin copy. + new Thread(new Runnable() { + @Override public void run() { + PickCopyResult result = copyRequiredImport(pickedSource, destFile); + if (!result.ok) { + writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName); + return; + } + String marker = "v1\n" + destName + "\n" + result.md5 + "\n" + + Long.toString(result.bytes) + "\n"; + writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker); + } + }, "gen1recomp-required-import").start(); + return; + } + + if (!copyAssetFile(pickedSource, destFile.getPath())) { Log.d("GameActivity", "could not copy picked file to " + destFile); - // A truncated pick would only fail verification later, so drop it - // and report instead. destFile.delete(); writeSaveDirFlag(PICK_ERROR_FILENAME, destName); } } + private static final class PickCopyResult { + boolean ok = false; + long bytes = 0; + String md5 = ""; + } + + private static String hex(byte[] bytes) { + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff)); + return out.toString(); + } + + private static void writeFlagFile(File dir, String name, String body) { + try { + if (!dir.isDirectory() && !dir.mkdirs()) return; + File tmp = new File(dir, name + ".tmp"); + File dest = new File(dir, name); + FileOutputStream out = new FileOutputStream(tmp, false); + out.write(body.getBytes("UTF-8")); + out.getFD().sync(); + out.close(); + if (dest.exists() && !dest.delete()) { tmp.delete(); return; } + if (!tmp.renameTo(dest)) tmp.delete(); + } catch (Exception e) { + Log.d("GameActivity", "could not write " + name + ": " + e.getMessage()); + } + } + + private static PickCopyResult copyRequiredImport(InputStream source, File destination) { + PickCopyResult result = new PickCopyResult(); + File parent = destination.getParentFile(); + File partial = new File(destination.getPath() + ".part"); + BufferedInputStream in = null; + BufferedOutputStream out = null; + FileOutputStream rawOut = null; + try { + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result; + if (partial.exists() && !partial.delete()) return result; + MessageDigest md5 = MessageDigest.getInstance("MD5"); + in = new BufferedInputStream(source, 1024 * 1024); + rawOut = new FileOutputStream(partial, false); + out = new BufferedOutputStream(rawOut, 1024 * 1024); + byte[] buf = new byte[1024 * 1024]; + int n; + long total = 0; + while ((n = in.read(buf)) != -1) { + if (n == 0) continue; + out.write(buf, 0, n); + md5.update(buf, 0, n); + total += n; + } + out.flush(); + rawOut.getFD().sync(); + out.close(); out = null; rawOut = null; + in.close(); in = null; + + // Publish only a complete same-directory file. Validation still + // happens in Lua against the manifest before a receipt is written. + if (destination.exists() && !destination.delete()) return result; + if (!partial.renameTo(destination)) return result; + result.ok = true; + result.bytes = total; + result.md5 = hex(md5.digest()); + Log.d("GameActivity", "direct required import copied " + total + + " bytes to " + destination); + return result; + } catch (Exception e) { + Log.d("GameActivity", "direct required import failed: " + e.getMessage()); + return result; + } finally { + try { if (in != null) in.close(); } catch (IOException ignored) {} + try { if (out != null) out.close(); } catch (IOException ignored) {} + try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {} + if (!result.ok && partial.exists()) partial.delete(); + } + } + /** * Copies a given file from the assets folder to the destination. * @@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity { assert (source != null && destination != null); try { - byte[] buf = new byte[1024]; - chunk_read = source.read(buf); - do { + byte[] buf = new byte[1024 * 1024]; + while ((chunk_read = source.read(buf)) != -1) { + if (chunk_read == 0) continue; destination.write(buf, 0, chunk_read); bytes_written += chunk_read; - chunk_read = source.read(buf); - } while (chunk_read != -1); + } } catch (IOException e) { Log.d("GameActivity", "Copying failed:" + e.getMessage()); } diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index 06f0db26..26de1c85 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -13,6 +13,7 @@ import UIKit import UniformTypeIdentifiers +import CryptoKit @objc(GRPickerBridge) public final class GRPickerBridge: NSObject { @@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject { @objc(presentPickerWithKind:saveDir:) public static func presentPicker(kind: UnsafePointer?, saveDir: UnsafePointer?) -> Bool { + return presentPicker(kind: kind, saveDir: saveDir, destination: nil) + } + + @objc(presentPickerWithKind:saveDir:destination:) + public static func presentPicker(kind: UnsafePointer?, + saveDir: UnsafePointer?, + destination: UnsafePointer?) -> Bool { let kindStr = kind.map { String(cString: $0) } ?? "rom" guard let dir = resolvedSaveDir(saveDir) else { return false } + let requestedDestination = destination.map { String(cString: $0) } let destName: String var types: [UTType] = [] switch kindStr { @@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject { case "sav": destName = "picked_save.sav" case "required_import": - destName = "picked_required_import.bin" + if let requestedDestination, + isDirectRequiredDestination(requestedDestination), + safeDestination(in: dir, relative: requestedDestination) != nil { + destName = requestedDestination + } else { + destName = "picked_required_import.bin" + } // A Nintendo 64 cartridge, for mods that build assets out of one -- // the voxel mod's Pokemon Stadium battle models are the caller this // was added for. Its own filename on purpose: an N64 ROM landing on @@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject { types.append(.data) if !types.contains(.item) { types.append(.item) } + let directRequired = kindStr == "required_import" + && destName != "picked_required_import.bin" let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, - asCopy: true) + asCopy: !directRequired) picker.allowsMultipleSelection = false let delegate = PickerDelegate { urls in guard let src = urls.first else { return } - copyItem(at: src, into: dir, named: destName) + if directRequired { + copyRequiredItemAsync(at: src, into: dir, relative: destName) + } else { + copyItem(at: src, into: dir, named: destName) + } } return present(picker, with: delegate) } @@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject { try? fm.moveItem(at: item, to: target) } + private static func isDirectRequiredDestination(_ relative: String) -> Bool { + let normalized = relative.replacingOccurrences(of: "\\", with: "/") + guard normalized.hasPrefix("mods/"), + let range = normalized.range(of: "/baseroms/"), + range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5), + range.upperBound < normalized.endIndex else { return false } + return !normalized.hasPrefix("/") + && !normalized.contains("//") + && !normalized.contains("/../") + && !normalized.hasSuffix("/..") + } + + private static func safeDestination(in root: URL, relative: String) -> URL? { + guard isDirectRequiredDestination(relative) else { return nil } + let rootURL = root.standardizedFileURL + let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL + let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/" + guard candidate.path.hasPrefix(rootPath) else { return nil } + return candidate + } + + private static func writeFlag(in dir: URL, name: String, body: String) { + try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name), + options: .atomic) + } + + private static func copyRequiredItemAsync(at src: URL, into dir: URL, + relative: String) { + DispatchQueue.global(qos: .userInitiated).async { + let scoped = src.startAccessingSecurityScopedResource() + defer { if scoped { src.stopAccessingSecurityScopedResource() } } + guard let dest = safeDestination(in: dir, relative: relative) else { + writeFlag(in: dir, name: "pick_error.flag", body: relative) + return + } + let fm = FileManager.default + ensureDirectory(dest.deletingLastPathComponent()) + let partial = URL(fileURLWithPath: dest.path + ".part") + try? fm.removeItem(at: partial) + guard fm.createFile(atPath: partial.path, contents: nil) else { + writeFlag(in: dir, name: "pick_error.flag", body: relative) + return + } + + var hasher = Insecure.MD5() + var total: UInt64 = 0 + do { + let input = try FileHandle(forReadingFrom: src) + let output = try FileHandle(forWritingTo: partial) + defer { + try? input.close() + try? output.close() + } + while true { + let data = input.readData(ofLength: 1024 * 1024) + if data.isEmpty { break } + hasher.update(data: data) + output.write(data) + total += UInt64(data.count) + } + output.synchronizeFile() + try? fm.removeItem(at: dest) + try fm.moveItem(at: partial, to: dest) + let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined() + let marker = "v1\n" + relative + "\n" + digest + "\n" + + String(total) + "\n" + writeFlag(in: dir, name: "pick_complete.flag", body: marker) + NSLog("GRPickerBridge: direct required import delivered %llu bytes", total) + } catch { + try? fm.removeItem(at: partial) + NSLog("GRPickerBridge: direct required import failed: \(error)") + writeFlag(in: dir, name: "pick_error.flag", body: relative) + } + } + } + private static func copyItem(at src: URL, into dir: URL, named name: String) { let scoped = src.startAccessingSecurityScopedResource() defer { if scoped { src.stopAccessingSecurityScopedResource() } } @@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject { let report = "Could not copy \(src.lastPathComponent): " + error.localizedDescription try? report.data(using: .utf8)? - .write(to: dir.appendingPathComponent("pick_error.txt")) + .write(to: dir.appendingPathComponent("pick_error.flag")) } } diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 837210d8..9f268443 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className, int w_pickFile(lua_State *L) { const char *kind = luaL_optstring(L, 1, "rom"); - return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind); + const char *destination = luaL_optstring(L, 2, nullptr); + Class cls = objc_getClass("GRPickerBridge"); + if (cls == nullptr) + { + lua_pushboolean(L, 0); + return 1; + } + typedef signed char (*GRPick)(Class, SEL, const char *, const char *, + const char *); + signed char ok = ((GRPick)objc_msgSend)( + cls, sel_registerName("presentPickerWithKind:saveDir:destination:"), + kind, gr_saveDirectory(), destination); + lua_pushboolean(L, ok != 0); + return 1; } // love.system.pickFileKinds() -> the comma-separated kinds supported by the diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 49917a19..4cbfd153 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then APPLICATION_ID="$TEST_APPLICATION_ID" APP_NAME="$APP_NAME (test)" fi +# Optional overrides for side-by-side test APKs (never used by CI shipping builds). +if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then + APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID" +fi +if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then + APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME" +fi # --------------------------------------------------------------- preconditions if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then diff --git a/scripts/test.sh b/scripts/test.sh index 71760e19..966b6e3e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -114,6 +114,7 @@ fi run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py +run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0130903c..2889573c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4531,8 +4531,7 @@ function BattleState:enemyMonFainted() -- remaining HP); SET / single-mon / fainted active skip the prompt. local nextMon = self.enemyParty[self.enemyIndex] local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name - local style = tostring((self.game.save.options or {}).battleStyle or "shift") - :lower() + local style = self:battleStyle() local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays @@ -5100,6 +5099,57 @@ function BattleState:ballMissMessage(shakes) return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!") end +-- ------- battle rules a mode may own +-- +-- Two decisions the OPTION screen and the cart make for the player that a +-- game mode may want to make instead: whether a faint offers a free switch, +-- and whether a catch asks for a nickname. Each is a hook around the vanilla +-- answer, so a mode can force it without touching the player's saved +-- preference and without the player being able to change it mid-match. +-- +-- The vanilla links are file-locals so an empty chain allocates no closure. + +local function styleFromOptions(battle) + return tostring(((battle.game.save or {}).options or {}).battleStyle or "shift") + :lower() +end + +local function alwaysAsk() return true end + +-- "shift" or "set" for this battle. battle.style wraps the OPTION row: a +-- mod returns "set" or "shift"; anything else reads as the vanilla answer. +function BattleState:battleStyle() + if not Runtime.wantsHook("battle.style") then return styleFromOptions(self) end + local style = Runtime.call("battle.style", styleFromOptions, self) + if style == "set" then return "set" end + if style == "shift" then return "shift" end + return styleFromOptions(self) +end + +-- AskName for a catch (AddPartyMon / SendNewMonToBox). Vanilla queues the +-- yes/no prompt. catch.nickname may answer for the player: false keeps the +-- species name and shows nothing; a string is the nickname, shown nothing; +-- anything else asks as usual. Returns whether a prompt was queued. +-- +-- The same verdict a script gift already takes from pokemon.before_give's +-- gift.nickname, for the other way a Pokemon joins the party. +function BattleState:offerNickname(mon, displayName) + if Runtime.wantsHook("catch.nickname") then + local verdict = Runtime.call("catch.nickname", alwaysAsk, mon, + { battle = self, name = displayName, game = self.game }) + if verdict == false then return false end + if type(verdict) == "string" then + -- the naming grid's own limit, so a mod cannot hand the party a name + -- the summary screen has no room to draw + verdict = verdict:sub(1, 10) + if #verdict > 0 then mon.nickname = verdict end + return false + end + end + self:uiNext(function() return self:askNicknameUI(mon, displayName) end) + return true +end + -- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, -- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party -- AddPartyMon and SendNewMonToBox (#172). @@ -5180,11 +5230,7 @@ function BattleState:storeCaughtMon() end) end local function askCaughtNickname() - local caught = self.enemy.mon - local enemyName = self.enemy.name - self:uiNext(function() - return self:askNicknameUI(caught, enemyName) - end) + self:offerNickname(self.enemy.mon, self.enemy.name) end if Party.add(game.save.party, self.enemy.mon) then askCaughtNickname() diff --git a/src/core/Data.lua b/src/core/Data.lua index 935739a9..5ba18879 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -170,6 +170,10 @@ function Data:seedDefaults(version) -- writes no header for him -- seed one so he engages on sight and has -- his defeat / re-talk lines (same idea as the Cinnabar seed above). Data.seedFightingDojoKarateMaster(self) + -- #1743: Mt. Moon B2F Super Nerd is text_asm (no def_trainers), so Yellow's + -- extractor never writes his header -- seed one so engageTrainer finds + -- battle/won/after text instead of the "I like shorts!" fallback. + Data.seedMtMoonB2FSuperNerd(self) -- #189: 1F cabin door order vs rooms map (survey zoom) require("src.world.SsAnneLayout").apply(self.maps) end @@ -201,6 +205,25 @@ function Data:seedFightingDojoKarateMaster() } end +-- Mt. Moon B2F Super Nerd (object index 1) is text_asm with no def_trainers +-- row, so Yellow never gets a trainerHeaders.MtMoonB2F[1] entry (Red/Blue +-- pin one in make_rom_manifest.py). Without it, engageTrainer falls through +-- to the hard-coded "I like shorts!" fallback (#1743). trainerDefeated still +-- tracks him via defeatedTrainers[npc.id]; the fabricated event name matches +-- the shipped Red/Blue manifest pin for consistency with fossil drivers. +function Data:seedMtMoonB2FSuperNerd() + local headers = self.trainer_headers + if not headers then return end + headers.MtMoonB2F = headers.MtMoonB2F or {} + if headers.MtMoonB2F[1] then return end + headers.MtMoonB2F[1] = { + battle = "_MtMoonB2FSuperNerdTheyreBothMineText", + won = "_MtMoonB2FSuperNerdOkIllShareText", + after = "_MtMoonB2FSuperNerdTheresAPokemonLabText", + event = "EVENT_BEAT_MT_MOON_3_SUPER_NERD", + } +end + function Data:seedCinnabarGymTrainerHeaders() local headers = self.trainer_headers if not headers or headers.CinnabarGym then return end diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index cb6d1082..43d40bc5 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -158,7 +158,15 @@ end -- and the app dies. There we relaunch through the GameActivity.restartApp -- JNI bridge (love.system.restartApp), which schedules our launch intent -- and kills the process so no native state can leak into the fresh run. --- On every other platform the in-process restart works, so keep it. +-- iOS is the same class of problem with a sharper edge: love.cpp under +-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic +-- exit) and comments that leftover threads make that restart unreliable -- +-- which our ChipAudio / Fetch / Check workers are. There is no +-- restartApp bridge on iOS, so callers that want "back to launcher" must +-- use main.lua's in-process returnToLauncher (love.quit aborts the quit); +-- HostShell.restart itself refuses quit("restart") and falls back to a +-- bare quit() so a mod that still calls restart does not pick the worst +-- path on purpose. function HostShell.restart() if not (love and love.event and love.event.quit) then return end @@ -174,6 +182,14 @@ function HostShell.restart() love.event.quit() return end + if osName == "iOS" then + -- No process-kill bridge. A bare quit still becomes DONE_RESTART in + -- love.cpp, but quit("restart") is the path that also runs our + -- endProcess worker joins first and then re-enters runlove -- the + -- combination that crashes EXIT GAME. Prefer the softer quit. + love.event.quit() + return + end local appimage = os.getenv("APPIMAGE") if not appimage then diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua index 4f63ffe2..6b5caaa1 100644 --- a/src/core/IssueReport.lua +++ b/src/core/IssueReport.lua @@ -227,7 +227,11 @@ local function metadata(options, context) end if flags and flags.fullscreen == true then add("Fullscreen", "yes") end local version = appVersion() - add("App", version ~= "" and Version.title() or "gen1recomp") + -- Bug reports always want the stamped engine when we have one; window + -- chrome hides it on release builds (Version.title). Unstamped + -- working-tree builds stay as plain "gen1recomp" so the placeholder + -- never lands in a filed issue. + add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp") add("LÖVE", loveVersion()) if safeMode then add("Safe mode", "on") end return { diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index 815a00d4..c8e73653 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -30,6 +30,8 @@ function SessionLifecycle.endMountedSession(version) local Runtime = require("src.mods.Runtime") if Runtime.reset then Runtime.reset() end if Assets.installLoader then Assets.installLoader(nil) end + local Loader = package.loaded["src.mods.Loader"] + if Loader and Loader.endSession then Loader.endSession() end local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") if okCompat and LegacyCompat.reset then LegacyCompat.reset() end end @@ -64,11 +66,18 @@ end -- EXIT GAME / intent_game before dropping Game. Stops audio and resets the -- live game instance so map/GPU holders are gone before endMountedSession. +-- +-- ChipAudio's worker stays alive across game sessions (see tier comment +-- above). shutdown() joins the thread and is process-exit only -- calling +-- it here on every Android EXIT GAME has been observed to leave the Gen1 +-- Game singleton unbootable (Game.load nil) on the next Play of a version +-- already opened this process, while a debug APK with a different liblove +-- did not reproduce. function SessionLifecycle.endGameSession(game) pcall(function() require("src.core.Music").stop() end) pcall(function() require("src.core.Sound").stop() end) if package.loaded["src.core.ChipAudio"] then - pcall(package.loaded["src.core.ChipAudio"].shutdown) + pcall(package.loaded["src.core.ChipAudio"].stopMusic) end if package.loaded["src.core.DiscordPresence"] then pcall(package.loaded["src.core.DiscordPresence"].shutdown) @@ -87,11 +96,11 @@ function SessionLifecycle.endGameSession(game) if game and game.reset then pcall(function() game:reset() end) end - -- Gen1 Game is the module singleton. If teardown left it without load, - -- drop the cached module so the next require rebuilds a clean table - -- (bootGame also guards this; doing it here keeps Play-again reliable). - if game and type(game.load) ~= "function" - and package.loaded["src.core.Game"] == game then + -- Gen1 Game is the module singleton. Always drop the cached module after + -- a session that owned it so the next bootGame require rebuilds a clean + -- table. A type(game.load) check is not enough: a wrong table parked in + -- package.loaded (e.g. with __index) can still look like it has load. + if game and package.loaded["src.core.Game"] == game then package.loaded["src.core.Game"] = nil end diff --git a/src/core/Version.lua b/src/core/Version.lua index 184d8431..5e632ae6 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -22,10 +22,22 @@ local Version = { cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } --- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds) +-- True for the working-tree placeholder and any stamped "-dev" pre-release. +-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here. +function Version.isDev() + local engine = tostring(Version.engine or "") + return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil +end + +-- Window / chrome title. Dev builds keep the version visible +-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux +-- window chrome and taskbars do not read "gen1recomp 0.1.73". function Version.title(base) - return (base or "gen1recomp") - .. " v" .. Version.engine + base = base or "gen1recomp" + if Version.isDev() then + return base .. " v" .. Version.engine + end + return base end return Version diff --git a/src/import/ImageWriter.lua b/src/import/ImageWriter.lua index 3b57e347..633b41c7 100644 --- a/src/import/ImageWriter.lua +++ b/src/import/ImageWriter.lua @@ -7,6 +7,28 @@ local SHADES = { { 0, 0, 0, 1 }, } +ImageWriter.SHADES = SHADES + +-- Title screen rOBP0 = %11100000 ($E0): OBJ shades 1 and 2 draw as white, +-- shade 3 as black (pokeyellow engine/movie/title.asm after PlacePikachu). +-- Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the +-- shade-1 glints become body yellow under the title palette. +function ImageWriter.applyTitleObp0(image) + local mid, dark = SHADES[2][1], SHADES[3][1] + local w, h = image:getWidth(), image:getHeight() + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, g, b, a = image:getPixel(x, y) + if a ~= 0 then + if math.abs(r - mid) < 0.02 or math.abs(r - dark) < 0.02 then + image:setPixel(x, y, 1, 1, 1, 1) + end + end + end + end + return image +end + local function assertDimensions(raw, width, height, bits) assert(width % 8 == 0 and height % 8 == 0, ("%dbpp dimensions must be tile-aligned: %dx%d") diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index a1b59884..0ab57381 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt() local bg = sheetTiles("TitlePikachuBGGraphics", 64) local ob = sheetTiles("TitlePikachuOBGraphics", 12) local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true) + -- Title rOBP0=$E0: remap eye OAM shades 1/2 → white before baking into the + -- MEWMON BG composition (otherwise glints read as body yellow). + local eyeOb = {} + for i, tile in ipairs(obClear) do + local copy = ImageWriter.blank(8, 8, 0, 0, 0, 0) + ImageWriter.blit(copy, tile, 0, 0) + eyeOb[i] = ImageWriter.applyTitleObp0(copy) + end local function tileFor(id) if id < 0x80 then return logo[id + 1] end if id < 0xF0 then return bg[id - 0x80 + 1] end @@ -1623,13 +1631,13 @@ function RomExtractor:extractYellowTitleArt() local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0) ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16) for _, e in ipairs(EYE_LAYOUT) do - blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4]) + blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4]) end overlays[suffix] = overlay end -- open eyes bake into pikachu.png AFTER the blank-face crops for _, e in ipairs(EYE_LAYOUT) do - blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4]) + blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4]) end self:save(pikachu, "title/pikachu.png") for suffix, overlay in pairs(overlays) do diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index c385786b..364de276 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea") local RomImporter = {} RomImporter.__index = RomImporter +local PICK_COMPLETE_FILENAME = "pick_complete.flag" +local finishDirectRequiredImport + -- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on -- builds that compiled one (Android, and iOS builds patched by -- mobile/ios/patch_love_src.py). A build without it must fall back to the @@ -1582,8 +1585,11 @@ function RomImporter:focus(f) .. love.filesystem.getSaveDirectory() local legacyRequiredPick = self.requiredImportLegacyRomPick and self.pickerPendingKind == "required_import" - if pickError:find("picked_required_import", 1, true) + if self.pickerPendingKind == "required_import" + or pickError:find("picked_required_import", 1, true) or pickError:find("picked_stadium", 1, true) + or pickError:find("/baseroms/", 1, true) + or pickError:find("\\baseroms\\", 1, true) or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then self.modNotice = { ok = false, text = text } self.pickerPendingKind = nil @@ -1606,6 +1612,22 @@ function RomImporter:focus(f) end return end + + -- Current mobile bridge: the native picker has already streamed a raw + -- dependency into mods//baseroms and published its digest/size marker. + local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") + and love.filesystem.read(PICK_COMPLETE_FILENAME) + if completedRequired then + love.filesystem.remove(PICK_COMPLETE_FILENAME) + self.pickPending = nil + finishDirectRequiredImport(self, completedRequired) + self.pickerPendingKind = nil + self.pickerPendingModId = nil + self.pickerPendingImportId = nil + self.requiredImportLegacyRomPick = nil + return + end + local requiredName = findPendingRequiredImport(self) if requiredName then local modId, importId = self.pickerPendingModId, self.pickerPendingImportId @@ -2059,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text) } end +-- Current Android/iOS bridges can stream a raw required import directly from +-- the system document provider into its engine-owned baseroms destination. +-- The bridge hashes/counts the same bytes and then publishes this tiny marker: +-- v1\n\n\n\n +-- Older mobile builds still stage picked_required_import.bin, so this is an +-- additive completion path rather than a replacement for the legacy one. +local function directRequiredImportTarget(self, marker) + local fields = {} + for line in tostring(marker or ""):gmatch("[^\r\n]+") do + fields[#fields + 1] = line + if #fields > 4 then break end + end + local version, path, digest, count = fields[1], fields[2], fields[3], fields[4] + if version ~= "v1" or not path or not digest or not count or #digest ~= 32 + or not digest:match("^%x+$") or not count:match("^%d+$") then + return nil, "The completed dependency marker was malformed." + end + digest = digest:lower() + count = tonumber(count) + if not count then return nil, "The completed dependency size was invalid." end + + if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end + local RequiredImports = require("src.mods.RequiredImports") + + local function matchRow(row, wantedImportId) + local manifest = row and row.manifest + if not manifest then return nil end + for _, spec in ipairs(RequiredImports.specs(manifest)) do + if (not wantedImportId or spec.id == wantedImportId) + and RequiredImports.path(manifest, spec) == path then + return manifest, spec, row.id or manifest.id + end + end + return nil + end + + -- Normal resume: bind the marker to exactly the request that opened picker. + if self.pickerPendingModId and self.pickerPendingImportId then + for _, row in ipairs(self.mods or {}) do + if row.id == self.pickerPendingModId then + local manifest, spec, modId = matchRow(row, self.pickerPendingImportId) + if manifest then return path, digest, count, manifest, spec, modId end + end + end + return nil, "The completed dependency did not match the pending import request." + end + + -- Android may recreate GameActivity while DocumentsUI is open. If Lua was + -- restarted too, recover by the exact engine-owned destination. The path + -- includes the mod id and only a manifest-declared import can match it. + for _, row in ipairs(self.mods or {}) do + local manifest, spec, modId = matchRow(row) + if manifest then return path, digest, count, manifest, spec, modId end + end + return nil, "The completed dependency did not match an installed mod request." +end + +finishDirectRequiredImport = function(self, marker) + local path, digestOrErr, nativeSize, manifest, spec, modId = + directRequiredImportTarget(self, marker) + if not path then + self.modNotice = { ok = false, text = tostring(digestOrErr) } + return nil + end + local digest = digestOrErr + local RequiredImports = require("src.mods.RequiredImports") + local info = love.filesystem.getInfo(path, "file") + if not info or type(info.size) ~= "number" then + requiredImportNotice(self, modId, spec.id, + "The completed dependency file was not found.") + self.modNotice = nil + return nil + end + if info.size ~= nativeSize then + love.filesystem.remove(path) + if RequiredImports.receiptPath then + love.filesystem.remove(RequiredImports.receiptPath(manifest, spec)) + end + requiredImportNotice(self, modId, spec.id, + ("Dependency copy size changed after completion (expected %d bytes, found %d).") + :format(nativeSize, info.size)) + self.modNotice = nil + return nil + end + local sizeErr = RequiredImports.sizeError(spec, info.size, true) + if sizeErr then + love.filesystem.remove(path) + if RequiredImports.receiptPath then + love.filesystem.remove(RequiredImports.receiptPath(manifest, spec)) + end + requiredImportNotice(self, modId, spec.id, sizeErr) + self.modNotice = nil + return nil + end + + -- No second 665 MiB / 1.46 GiB read: native calculated this digest while + -- streaming the selected document into the one final copy. + local ok, detail = RequiredImports.acceptStoredDigest( + manifest, spec.id, digest, love.filesystem) + if not ok then + love.filesystem.remove(path) + if RequiredImports.receiptPath then + love.filesystem.remove(RequiredImports.receiptPath(manifest, spec)) + end + requiredImportNotice(self, modId, spec.id, detail) + self.modNotice = nil + return nil + end + + self.requiredImportNotice = nil + self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id) + .. " for " .. tostring(manifest.name or manifest.id) .. "." } + self:_refreshMods() + return true +end + function RomImporter:_importRequiredData(modId, importId, data) local manifest = requiredManifest(self, modId) if not manifest then @@ -2218,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId) self.pickerPendingModId = modId self.pickerPendingImportId = importId self.requiredImportLegacyRomPick = legacyAndroidPicker or nil - if not pickFile(legacyAndroidPicker and "rom" or "required_import") then + -- Raw imports can go straight to their final private destination on current + -- Android/iOS bridges. N64 stays on staging because the launcher still has + -- to canonicalize byte order/copier headers before storing it. + local directDestination = (self.mobileFileBridge and not legacyAndroidPicker + and spec.format ~= "n64" + and type(manifest.path) == "string" and manifest.path ~= "") + and require("src.mods.RequiredImports").path(manifest, spec) or nil + if not pickFile(legacyAndroidPicker and "rom" or "required_import", + directDestination) then self.pickerPendingKind = nil self.pickerPendingModId = nil self.pickerPendingImportId = nil @@ -2549,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt) return end local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil + or love.filesystem.getInfo("pick_error.flag", "file") ~= nil + or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil if not found then for _, name in ipairs(love.filesystem.getDirectoryItems("")) do local n = name:lower() diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index f0ecadcd..6fa8ab07 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -184,6 +184,14 @@ local function adjacentSleepingSnorlax(save, ow) return nil end +local function itemUseLine(data, save, name) + return romText(data, "_ItemUseText001", "%s used\n%s!", save.player.name, name) +end + +-- PrintItemUseTextAndRemoveItem (item_effects.asm): used-line + SFX_HEAL_AILMENT. +-- BagMenu plays Heal_Ailment via TextBox.soundOpts when extra.useJingle is set. +local USE_JINGLE = { useJingle = true } + -- Use an item on a target party mon (target may be nil for targetless -- items). data = generated data tables; battle = BattleState when used -- mid-battle; ow = the overworld (OverworldState), needed only to check @@ -280,7 +288,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "All sleeping\nPOKéMON woke up!") } end - -- battle-only items + -- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635) if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" or itemId == "POKE_DOLL" then if not battle then @@ -293,21 +301,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) require("src.world.PikachuFollower") .modifyHappiness(save, "USEDXITEM", b and b.mon) end + local used = itemUseLine(data, save, name) if itemId == "X_ACCURACY" then -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss - -- (not an accuracy stage) + -- (not an accuracy stage); vanilla prints only the used line b.xAccuracy = true - return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) } + return "consumed", { used }, USE_JINGLE end if X_ITEMS[itemId] then local stat = X_ITEMS[itemId] local cur = b.stages[stat] or 0 - -- ItemUseXStat removes the item BEFORE running the stat-up - -- effect, so at +6 it is still consumed and StatModifierUpEffect - -- just prints "Nothing happened!" + -- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect if cur >= 6 then - return "consumed", { romText(data, "_NothingHappenedText", - "Nothing happened!") } + return "consumed", { used }, { + useJingle = true, + afterMessages = { romText(data, "_NothingHappenedText", + "Nothing happened!") }, + } end b.stages[stat] = cur + 1 b.hazeStatReset = nil @@ -315,26 +325,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) and battle.kind ~= "link" then require("src.battle.Damage").reapplyBadgeBoosts(b, stat) end - return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } + return "consumed", { used }, { + useJingle = true, + afterMessages = { Strings("%s's\n%s rose!", b.name, + Strings(STAT_LABEL[stat])) }, + } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume - -- the item, even when it is already active + -- the item, even when it is already active; vanilla prints only used if itemId == "DIRE_HIT" then b.focusEnergy = true - return "consumed", { romText(data, "_GettingPumpedText", - "%s's\ngetting pumped!", b.name) } + return "consumed", { used }, USE_JINGLE end if itemId == "GUARD_SPEC" then b.mist = true - return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) } + return "consumed", { used }, USE_JINGLE end if itemId == "POKE_DOLL" then if battle.kind ~= "wild" then -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles return "failed", { notTime(data, save) } end - return "consumed_escape", { romText(data, "_WildRanText", - "The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) } + -- PrintItemUseTextAndRemoveItem then escape + return "consumed_escape", { used }, USE_JINGLE end end @@ -613,7 +626,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if REPELS[itemId] then local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 save.repelSteps = steps - return "consumed", { Strings("%s used\n%s!", save.player.name, name) } + return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE end return "failed", { notTime(data, save) } diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ab10fb2e..86d7b796 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = { local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "") :gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "") +local ENGINE_CHUNKS = { + ["main.lua"] = true, + ["conf.lua"] = true, +} + local function callerIsMod(level) if ENGINE_PREFIX == "" then return false end local info = debug.getinfo(level, "S") local source = info and info.source if not source or source:sub(1, 1) ~= "@" then return false end - return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX + local path = source:sub(2) + if ENGINE_CHUNKS[path] then return false end + return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX end local function scanRequire(name) @@ -207,6 +214,11 @@ local function engineRequire(name) return module end +function Loader.endSession() + devShim.generation = nil + devShim.errors = nil +end + function Loader:_installDevShim() for id, mod in pairs(self.mods) do devShim.permissions[id] = mod.manifest.permissionSet @@ -234,7 +246,7 @@ function Loader:_installDevShim() -- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. -- Engine code keeps the real module: src/render/PaletteFX.lua:776 -- requires src.core.Game on both generations and means it. - if devShim.generation ~= 1 and Gen2Compat.serves(name) + if devShim.generation == 2 and Gen2Compat.serves(name) and (owner or callerIsMod(3)) then local adapter = Gen2Compat.resolve(name, Runtime.currentMod) if adapter then diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index b00d65c3..f94ff2ca 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -20,6 +20,8 @@ local PaletteFX = {} local shader -- false = unavailable (headless / no shader support) local gbcPack -- false = missing; nil = not loaded yet local yellowPack -- false = missing; nil = not loaded yet +local gbcYellowPack -- Yellow Advanced deltas; false = missing; nil = not loaded yet + -- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations -- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy, @@ -300,6 +302,38 @@ function PaletteFX.yellowPack() return yellowPack or nil end +-- Yellow-only Advanced deltas (BEACH_HOUSE, sprite remap, YELLOWMON). Never +-- consulted on Red/Blue (#1639). +function PaletteFX.gbcYellowPack() + if gbcYellowPack == nil then + local ok, pack = pcall(require, "data.palettes_gbc_yellow") + gbcYellowPack = ok and pack or false + end + return gbcYellowPack or nil +end + +-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas +-- via __index so shared Red tilesets stay identical byte-for-byte. +function PaletteFX.worldPack() + local pack = PaletteFX.gbcPack() + local w = pack and pack.world + if not w then return nil end + if not GameVersion.isYellow() then return w end + local y = PaletteFX.gbcYellowPack() + local yw = y and y.world + if not yw then return w end + return { + tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }), + groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }), + roofGroup = w.roofGroup, + roofByMapIndex = w.roofByMapIndex, + spriteAssignment = yw.spriteAssignment or w.spriteAssignment, + spritePalettes = yw.spritePalettes + and setmetatable(yw.spritePalettes, { __index = w.spritePalettes }) + or w.spritePalettes, + } +end + function PaletteFX.usesGbcPack(mode) mode = mode or PaletteFX.mode return mode == "redpp" @@ -453,7 +487,10 @@ function PaletteFX.pal(data, name) if fromRom then return fromRom end end if GameVersion.isYellow() then - if PaletteFX.usesYellowCgb() then + -- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes + -- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World + -- tile bake still comes from the Advanced GBC pack via worldPack (#1639). + if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then local fromCgb = yellowCgbNamedPal(data, name) if fromCgb then return fromCgb end end @@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed) if pal then return pal end end local name = p.pokemon[species] or "MEWMON" - if PaletteFX.usesYellowCgb() then + if PaletteFX.usesYellowCgb() + or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then local yc = PaletteFX.pal(data, name) if yc then return yc end end @@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel -- (false for a mod tileset with no pokered-gbc counterpart, or when the -- pack failed to load at all) function PaletteFX.hasWorldTileset(tileset) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() return (w and w.tileGroups[tileset]) ~= nil end -- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset, -- with the current map's tile-id exceptions (if any) applied first function PaletteFX.worldGroupAt(tileset, mapId, tileId) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() local groups = w and w.tileGroups[tileset] if not groups then return nil end local exc = TILE_GROUP_EXCEPTIONS[mapId] @@ -642,8 +678,7 @@ end -- Saffron's roof colors while the player stands in its top 2 cell rows, -- like pokered's wYCoord check -- data is Game.data, for the map lookup) function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() local base = w and w.groupColors[tileset] if not base then return nil end if not w.roofGroup[tileset] then return darkGroups(base) end @@ -678,17 +713,18 @@ end -- here): a stable hash instead, so the same NPC instance always shows the -- same one of the 4 SPR_PAL_* colors. function PaletteFX.spriteObp(spriteDef, seed) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) if not (w and src) then return nil end local idx = tonumber(src:match("%[(%d+)%]")) -- RedBikeSprite and SurfingPikachuSprite load outside -- SpriteSheetPointerTable, so their source has no bracketed index; - -- they wear the player's OBP palette (spriteAssignment[0]). - if not idx and (src:find("RedBikeSprite", 1, true) - or src:find("SurfingPikachuSprite", 1, true)) then + -- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue. + -- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639). + if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 + elseif not idx and src:find("SurfingPikachuSprite", 1, true) then + idx = GameVersion.isYellow() and 60 or 0 end local group = idx and w.spriteAssignment[idx] if group == nil then return nil end diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 6b956889..4c795875 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -338,6 +338,7 @@ function Renderer:beginFrame(transparent) -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil + self.worldFadeColor = nil -- battle-transition wipe, drawn over the whole surface (BattleTransition) self.battleWipe = nil -- whole-surface veil in screen space (battle-transition flash, the @@ -1045,7 +1046,8 @@ function Renderer:endFrame(zones, worldZones) -- the screen-space overlays the flat path draws over its composite local fade = self.worldFadeAlpha if fade and fade > 0 then - love.graphics.setColor(0, 0, 0, fade) + local c = self.worldFadeColor or { 0, 0, 0 } + love.graphics.setColor(c[1], c[2], c[3], fade) love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.setColor(1, 1, 1, 1) end @@ -1132,7 +1134,8 @@ function Renderer:endFrame(zones, worldZones) -- composite normally if one is ever stacked that way. local fade = self.worldFadeAlpha if fade and fade > 0 then - love.graphics.setColor(0, 0, 0, fade) + local c = self.worldFadeColor or { 0, 0, 0 } + love.graphics.setColor(c[1], c[2], c[3], fade) love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/render/Transition.lua b/src/render/Transition.lua index 4080afd7..7c955a3b 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -65,16 +65,24 @@ end -- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call -- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the -- symmetric 32-frame fade back in (home/fade.asm:21, b = 4). -function Transition.new(game, onMidpoint, onDone, warp) +-- +-- opts.color = {r,g,b} in 0..1 (default black). opts.frames / opts.framesIn +-- override duration. Fly/Teleport/Dig/Escape Rope use white +-- GBFadeOutToWhite / GBFadeInFromWhite (#1644). +function Transition.new(game, onMidpoint, onDone, warp, opts) + opts = opts or {} local self = setmetatable({}, Transition) self.game = game self.onMidpoint = onMidpoint self.onDone = onDone self.t = 0 self.phase = "out" + self.color = opts.color or { 0, 0, 0 } local style = styleOf(game, "warp_fade") - self.frames = style.frames or FRAMES - if warp then + self.frames = opts.frames or style.frames or FRAMES + if opts.framesIn ~= nil then + self.framesIn = opts.framesIn + elseif warp then -- a style may still ask for a fade in (mods, and the record is -- data-driven); the built-in warp is 0, matching hardware self.framesIn = style.framesIn or FRAMES_IN @@ -125,6 +133,7 @@ end function Transition:draw() local alpha = self:alpha() + local c = self.color or { 0, 0, 0 } -- Survey zoom draws the overworld into a window-filling world canvas -- while the UI pass stays the classic 160x144 letterbox. A rect on the -- UI canvas only darkens that center box (issue #121); when the world @@ -133,9 +142,10 @@ function Transition:draw() local r = self.game and self.game.renderer if r and r.worldActive then r.worldFadeAlpha = alpha + r.worldFadeColor = c return end - love.graphics.setColor(0, 0, 0, alpha) + love.graphics.setColor(c[1], c[2], c[3], alpha) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 949dcfcc..8b5a6c1b 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -54,6 +54,21 @@ local function showMessages(game, msgs, onDone, opts) game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts)) end +-- PrintItemUseTextAndRemoveItem: used-line TextBox carries Heal_Ailment, then +-- optional afterMessages (X Stat effect line) before onDone (#1635). +local function showUseMessages(game, msgs, onDone, extra) + local opts = (extra and extra.useJingle) + and TextBox.soundOpts(game, "Heal_Ailment") or nil + local after = extra and extra.afterMessages + if after and #after > 0 then + showMessages(game, msgs, function() + showMessages(game, after, onDone) + end, opts) + else + showMessages(game, msgs, onDone, opts) + end +end + -- run the use-flow for an item on a chosen target. `picker` is the party -- menu when it was opened with keepOpen (HP medicine only): it is still on -- the stack, so every exit that prints has to close it afterwards. For @@ -115,7 +130,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) if result == "consumed_escape" then -- Poké Doll consume(game, id, list) list:close() - showMessages(game, payload, function() + showUseMessages(game, payload, function() -- ItemUsePokeDoll sets wEscapedFromBattle and never touches -- wBattleResult, so a script that reads the result afterwards sees -- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that @@ -125,7 +140,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) battle.result = "run" battle.afterQueue = "finish" battle.phase = "messages" - end) + end, extra) return end @@ -403,9 +418,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if battle then list:close() - showMessages(game, payload, function() battle:itemUsed({}) end) + showUseMessages(game, payload, function() battle:itemUsed({}) end, extra) else - showMessages(game, payload, closePicker) + showUseMessages(game, payload, closePicker, extra) end return end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 4554dc5d..6616c0bb 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -4818,6 +4818,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) require("src.core.Sound").play(Game.data, outdoor and "Go_Outside" or "Go_Inside") end + -- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite + -- (player_animations.asm). Door warps stay black with no fade-in (#1644). + local Timing = require("src.core.Timing") + local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport" + local fadeOpts = specialWarp and { + color = { 1, 1, 1 }, + frames = Timing.FADE_OUT_TO_WHITE, + framesIn = Timing.FADE_IN_FROM_WHITE, + } or nil Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) -- the departure-side hide from flyAnim/teleportOut ends here, on the new @@ -4884,7 +4893,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) end, function() self.transitioning = false if onDone then onDone() end - end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write) + end, not specialWarp, fadeOpts)) end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua index 04e69531..09ff37e0 100644 --- a/tests/engine/android_exit_to_launcher_test.lua +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -109,9 +109,35 @@ do -- Mimic a shared net module parked on the singleton (handle-style release). Game.net = { release = function(id) end } SessionLifecycle.endGameSession(Game) + check(package.loaded["src.core.Game"] == nil, + "endGameSession drops the Gen1 Game module cache") local again = require("src.core.Game") - check(type(again.load) == "function", + check(type(rawget(again, "load")) == "function", "Play-again can call Game:load after endGameSession") + check(again ~= Game, "Play-again gets a fresh Gen1 Game module table") +end + +-- 7. endGameSession must not join the ChipAudio worker (process-tier only). +-- Joining on every EXIT GAME correlates with release-APK Game.load nil +-- when reopening a version already played this process. +do + local SessionLifecycle = require("src.core.SessionLifecycle") + local ChipAudio = require("src.core.ChipAudio") + local shutdownCalls, stopCalls = 0, 0 + local origShutdown, origStop = ChipAudio.shutdown, ChipAudio.stopMusic + ChipAudio.shutdown = function(...) + shutdownCalls = shutdownCalls + 1 + return origShutdown(...) + end + ChipAudio.stopMusic = function(...) + stopCalls = stopCalls + 1 + return origStop(...) + end + local game = { reset = function() end, load = function() end } + SessionLifecycle.endGameSession(game) + ChipAudio.shutdown, ChipAudio.stopMusic = origShutdown, origStop + eq(shutdownCalls, 0, "endGameSession does not ChipAudio.shutdown") + check(stopCalls >= 1, "endGameSession stops chip music (Music.stop and/or stopMusic)") end T.finish("android_exit_to_launcher_test") diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index efe7a839..2f6815e4 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -118,8 +118,13 @@ local systemFile = assert(io.open(systemPath, "rb")) local system = systemFile:read("*a") systemFile:close() check(system:find('strcmp(kind, "required_import")', 1, true) - and system:find('dest = "picked_required_import.bin"', 1, true) + and system:find('destination != nullptr', 1, true) + and system:find('"picked_required_import.bin"', 1, true) and system:find('return "rom,mod,sav,required_import"', 1, true), "native Android bridge advertises and routes required imports") +check(source:find('normalized.startsWith("mods/")', 1, true) + and source:find('/baseroms/', 1, true) + and source:find('PICK_COMPLETE_FILENAME', 1, true), + "direct required imports stay inside mod baseroms and publish completion") print("android_host_extension_test: ok") diff --git a/tests/engine/gen2_shim_engine_require.lua b/tests/engine/gen2_shim_engine_require.lua new file mode 100644 index 00000000..1db767d5 --- /dev/null +++ b/tests/engine/gen2_shim_engine_require.lua @@ -0,0 +1,90 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("gen2 shim engine require") +local check, eq = S.check, S.eq + +local Loader = require("src.mods.Loader") +local Gen2Compat = require("src.mods.Gen2Compat") +local GameVersion = require("src.core.GameVersion") +local SessionLifecycle = require("src.core.SessionLifecycle") + +local NAME = "src.core.Game" +local REAL = { load = function() end } + +local function memfs(files) + return { + files = files, + read = function(path) return files[path] end, + write = function(path, content) files[path] = content return true end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + createDirectory = function() return true end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function requireFrom(source) + local chunk = assert(loadstring( + "local name = ... local mod = require(name) return mod", "@" .. source)) + return chunk(NAME) +end + +local savedVersion = GameVersion.get() +local savedModule = package.loaded[NAME] +package.loaded[NAME] = REAL + +GameVersion.set("gold") +local files = { + ["mods/facade/manifest.json"] = + [[{"id":"facade","name":"facade","version":"1.0.0","entry":"main.lua",]] + .. [["gen2compat":true}]], + ["mods/facade/main.lua"] = "return function(mod) end", +} +local loader = Loader.new({ fs = memfs(files) }) +loader:load({ pokemon = {} }) + +eq(loader.generation, 2, "the fixture loader is a Gen 2 one") +check(Gen2Compat.serves(NAME), "the facade serves " .. NAME) + +local facade = Gen2Compat.resolve(NAME) +eq(requireFrom("mods/facade/main.lua"), facade, + "a mod's require still resolves to the Gen 2 facade") +eq(requireFrom("main.lua"), REAL, + "main.lua's require is the engine's own, never the facade") +eq(requireFrom("conf.lua"), REAL, "conf.lua's require is the engine's own") + +SessionLifecycle.endMountedSession() +eq(requireFrom("mods/facade/main.lua"), REAL, + "after the session ends the facade stops answering for the next boot") + +package.loaded[NAME] = savedModule +GameVersion.set(savedVersion) + +S.finish() diff --git a/tests/engine/host_restart_android_bug575.lua b/tests/engine/host_restart_android_bug575.lua index 82f1dca3..3d5c7dc0 100644 --- a/tests/engine/host_restart_android_bug575.lua +++ b/tests/engine/host_restart_android_bug575.lua @@ -4,7 +4,10 @@ -- The fix prefers the love.system.restartApp JNI bridge (which kills the -- process, so a true return is never observed live) and, on an old APK -- whose liblove lacks the bridge, falls back to a CLEAN quit with no --- argument. Desktop keeps the in-process quit("restart"). +-- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART +-- for every quit; HostShell.restart must still refuse quit("restart") so a +-- leftover caller does not pick the worker-join + native-restart path that +-- crashes EXIT GAME. Desktop keeps the in-process quit("restart"). -- luajit tests/engine/host_restart_android_bug575.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -48,12 +51,18 @@ HostShell.restart() eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing") eq(quits[2].n, 0, "again with no restart argument") +-- iOS: no process-kill bridge; never quit("restart") +osName = "iOS" +HostShell.restart() +eq(#quits, 3, "iOS HostShell.restart still quits once") +eq(quits[3].n, 0, "iOS uses a bare quit(), never quit(\"restart\")") + -- desktop (no AppImage in a test environment) keeps the in-process restart if not os.getenv("APPIMAGE") then osName = "OS X" HostShell.restart() - eq(quits[3] and quits[3].arg, "restart", - "non-Android still restarts in-process") + eq(quits[4] and quits[4].arg, "restart", + "non-mobile still restarts in-process") end T.finish("host_restart_android_bug575") diff --git a/tests/engine/item_use_jingle_bug1635.lua b/tests/engine/item_use_jingle_bug1635.lua new file mode 100644 index 00000000..50490938 --- /dev/null +++ b/tests/engine/item_use_jingle_bug1635.lua @@ -0,0 +1,72 @@ +-- #1635: PrintItemUseTextAndRemoveItem items must request Heal_Ailment +-- via extra.useJingle after the "X used Y!" line. +-- +-- ROM-free: stubs items/text only (CI headless has no data/generated/). +-- +-- luajit tests/engine/item_use_jingle_bug1635.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("item use Heal_Ailment jingle #1635") +local check, eq = S.check, S.eq + +local ItemEffects = require("src.inventory.ItemEffects") +local SaveData = require("src.core.SaveData") + +local ITEM_IDS = { + "REPEL", "SUPER_REPEL", "MAX_REPEL", + "X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL", + "X_ACCURACY", "DIRE_HIT", "GUARD_SPEC", "POKE_DOLL", +} + +local Data = { items = {}, text = {} } +for _, id in ipairs(ITEM_IDS) do + Data.items[id] = { name = id:gsub("_", " ") } +end + +local save = SaveData.newGame() +save.player.name = "RED" + +local function battleStub(playerName) + return { + kind = "wild", + player = { + name = playerName or "PIKACHU", + stages = { attack = 0, defense = 0, speed = 0, special = 0, accuracy = 0 }, + mon = {}, + }, + enemy = { name = "RATTATA" }, + } +end + +local function assertJingle(itemId, battle) + local result, msgs, extra = ItemEffects.use(Data, save, itemId, nil, battle) + check(result == "consumed" or result == "consumed_escape", + itemId .. " succeeds (" .. tostring(result) .. ")") + check(extra and extra.useJingle, itemId .. " sets useJingle") + check(msgs and msgs[1] and msgs[1]:find("used"), + itemId .. " prints used line") + return result, msgs, extra +end + +for _, id in ipairs({ "REPEL", "SUPER_REPEL", "MAX_REPEL" }) do + assertJingle(id, nil) +end + +local b = battleStub() +for _, id in ipairs({ + "X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL", + "X_ACCURACY", "DIRE_HIT", "GUARD_SPEC", +}) do + local _, _, extra = assertJingle(id, b) + if id == "X_ATTACK" then + check(extra.afterMessages and #extra.afterMessages > 0, + "X_ATTACK follows used line with effect text") + end +end + +local _, _, dollExtra = assertJingle("POKE_DOLL", battleStub()) +eq(dollExtra.afterMessages, nil, "Poké Doll is used-line only") + +S.finish() diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index 5e3b4b66..f4348a42 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -64,12 +64,17 @@ do Game.linkFetch = { release = function(id) jobs[id] = nil end, } + local before = Game SessionLifecycle.endGameSession(Game) - check(type(Game.load) == "function", - "endGameSession leaves Game.load intact for the next bootGame") - check(package.loaded["src.core.Game"] == Game - or type((package.loaded["src.core.Game"] or {}).load) == "function", - "Gen1 Game module remains require-able after endGameSession") + check(type(before.load) == "function", + "endGameSession reset leaves methods on the old table") + check(package.loaded["src.core.Game"] == nil, + "endGameSession drops Gen1 Game from package.loaded") + local again = require("src.core.Game") + check(type(rawget(again, "load")) == "function", + "require rebuilds a bootable Gen1 Game after endGameSession") + check(again ~= before, "Play-again uses a fresh Gen1 Game module table") + Game = again end -- ---- Game2:reset releases world GPU and present canvases ------------------ diff --git a/tests/engine/mt_moon_super_nerd_header_bug1743.lua b/tests/engine/mt_moon_super_nerd_header_bug1743.lua new file mode 100644 index 00000000..e19d9284 --- /dev/null +++ b/tests/engine/mt_moon_super_nerd_header_bug1743.lua @@ -0,0 +1,32 @@ +-- #1743: Mt. Moon B2F Super Nerd is text_asm with no def_trainers row, so +-- Yellow never gets trainerHeaders.MtMoonB2F[1]. Without a seed, +-- engageTrainer falls through to the "I like shorts!" fallback. +-- +-- luajit tests/engine/mt_moon_super_nerd_header_bug1743.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("mt moon super nerd header #1743") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") + +local empty = { trainer_headers = {} } +Data.seedMtMoonB2FSuperNerd(empty) +local seeded = empty.trainer_headers.MtMoonB2F[1] +check(seeded ~= nil, "seed fills MtMoonB2F[1] when missing") +eq(seeded.battle, "_MtMoonB2FSuperNerdTheyreBothMineText", + "pre-battle text is They're both mine") +eq(seeded.won, "_MtMoonB2FSuperNerdOkIllShareText", "won text seeded") +eq(seeded.after, "_MtMoonB2FSuperNerdTheresAPokemonLabText", "after text seeded") +eq(seeded.event, "EVENT_BEAT_MT_MOON_3_SUPER_NERD", + "event name matches shipped Red/Blue pin") + +-- idempotent: existing header wins +empty.trainer_headers.MtMoonB2F[1] = { battle = "KEEP" } +Data.seedMtMoonB2FSuperNerd(empty) +eq(empty.trainer_headers.MtMoonB2F[1].battle, "KEEP", + "seed does not overwrite an existing header") + +S.finish() diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index 4f8a3589..9ee27fca 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -197,6 +197,16 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, "Fetch registers its shutdown hook at load") +-- iOS EXIT GAME must share Android's in-process returnToLauncher: love.cpp +-- under LOVE_IOS forces DONE_RESTART for every quit and warns that leftover +-- threads make that restart unreliable (ChipAudio / Fetch / Check). +check(quitHook:find('osName == "Android" or osName == "iOS"', 1, true) ~= nil, + "love.quit treats Android and iOS as in-process return platforms") +check(quitHook:find("inProcessReturn", 1, true) ~= nil, + "love.quit gates returnToLauncher on inProcessReturn") +check(quitHook:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "desktop return-to-launcher still reaches HostShell.restart") + -- The Android half: LOVE keeps the JVM process after the native main returns, -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit diff --git a/tests/engine/special_warp_white_fade_bug1644.lua b/tests/engine/special_warp_white_fade_bug1644.lua new file mode 100644 index 00000000..2f09c69c --- /dev/null +++ b/tests/engine/special_warp_white_fade_bug1644.lua @@ -0,0 +1,32 @@ +-- #1644: Fly/Teleport special warps fade white (GBFadeOutToWhite / +-- GBFadeInFromWhite), not black door-warp shape. +-- +-- luajit tests/engine/special_warp_white_fade_bug1644.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("special warp white fade #1644") +local check, eq = S.check, S.eq + +local Timing = require("src.core.Timing") +local Transition = require("src.render.Transition") + +local white = { 1, 1, 1 } +local t = Transition.new(nil, function() end, nil, false, { + color = white, + frames = Timing.FADE_OUT_TO_WHITE, + framesIn = Timing.FADE_IN_FROM_WHITE, +}) +eq(t.color[1], 1, "special warp veil is white R") +eq(t.color[2], 1, "special warp veil is white G") +eq(t.color[3], 1, "special warp veil is white B") +eq(t.frames, Timing.FADE_OUT_TO_WHITE, "fade out uses FADE_OUT_TO_WHITE") +eq(t.framesIn, Timing.FADE_IN_FROM_WHITE, "fade in uses FADE_IN_FROM_WHITE") + +local door = Transition.new(nil, function() end, nil, true) +eq(door.color[1], 0, "door warp stays black") +eq(door.framesIn, 0, "door warp has no fade in") +check(door.frames == Timing.WARP_FADE_OUT, "door warp uses WARP_FADE_OUT") + +S.finish() diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua index 83b4da0c..943d785e 100644 --- a/tests/engine/stat_rise_message_translation_test.lua +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -31,16 +31,19 @@ local save = SaveData.newGame() local player = { name = "FIXMON", stages = {} } local xBattle = { player = player, kind = "wild" } -local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) -T.check(baseline[1]:find("ATTACK", 1, true) ~= nil, +local _, baseline, baselineExtra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) +local baselineRose = baselineExtra and baselineExtra.afterMessages + and baselineExtra.afterMessages[1] or baseline[1] +T.check(baselineRose:find("ATTACK", 1, true) ~= nil, "X ATTACK's rose! message names the stat in English with no catalog") withCatalog({ ATTACK = "ATTAQUE" }, function() player.stages.attack = nil - local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) - T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil, + local _, msgs, extra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) + local rose = extra and extra.afterMessages and extra.afterMessages[1] or msgs[1] + T.check(rose:find("ATTAQUE", 1, true) ~= nil, "a catalog translating ATTACK reaches the X ATTACK rose! message") - T.check(msgs[1]:find("ATTACK", 1, true) == nil, + T.check(rose:find("ATTACK", 1, true) == nil, "...and the untranslated English stat name is gone") end) diff --git a/tests/engine/title_pikachu_obp_bug.lua b/tests/engine/title_pikachu_obp_bug.lua new file mode 100644 index 00000000..21152dc5 --- /dev/null +++ b/tests/engine/title_pikachu_obp_bug.lua @@ -0,0 +1,19 @@ +-- Title eye OBP remap lives in ImageWriter.applyTitleObp0; pixel-level +-- coverage is tests/title_pikachu_obp_test.py (love_stub ImageData is a +-- no-op). This file only pins the export so the Lua bake path stays wired. +-- +-- luajit tests/engine/title_pikachu_obp_bug.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("title pikachu OBP export") +local check = S.check + +local ImageWriter = require("src.import.ImageWriter") +check(type(ImageWriter.applyTitleObp0) == "function", + "ImageWriter.applyTitleObp0 is exported for RomExtractor") +check(type(ImageWriter.SHADES) == "table" and #ImageWriter.SHADES == 4, + "ImageWriter.SHADES exposes the 4 DMG ramps") + +S.finish() diff --git a/tests/engine/warp_sprite_hidden_bug916.lua b/tests/engine/warp_sprite_hidden_bug916.lua index 5e6cd885..0376350b 100644 --- a/tests/engine/warp_sprite_hidden_bug916.lua +++ b/tests/engine/warp_sprite_hidden_bug916.lua @@ -52,7 +52,10 @@ Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) } local stack = Game.stack -- The draw guard both entity passes use: the player sprite is skipped while --- any of flyAnim / flyArrive / playerHidden is set. +-- any of flyAnim / flyArrive / playerHidden is set. Dig/Teleport spinDrop +-- does NOT skip the sprite -- EnterMapAnim draws the spinning trainer under +-- the white fade-in (#1644) -- so "bare" is only a frame with no hide flag +-- and no arrival (gapFrames below). local function playerHidden(ow) return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true end @@ -115,8 +118,12 @@ check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins") check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)") eq(st.gapFrames, 0, "no dig fade frame leaves the player standing bare (#916)") -check(st.fadeFramesHidden >= st.fadeFrames - 1, - "dig fade hidden on every frame but the arrival-arming midpoint (" +-- Dig uses white fade-out + fade-in (#1644). Draw-skip hide covers the +-- fade-out; the fade-in deliberately shows spinDrop under the veil, so +-- fadeFramesHidden is only the out half (allow one midpoint tick). +local Timing = require("src.core.Timing") +check(st.fadeFramesHidden >= Timing.FADE_OUT_TO_WHITE - 1, + "dig fade-out hidden (" .. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")") check(st.arrivalFrame ~= nil, "dig arrival spin-down arms") check(ow.playerHidden == false, "dig hide cleared on the new map") diff --git a/tests/engine/yellow_advanced_palette_bug1639.lua b/tests/engine/yellow_advanced_palette_bug1639.lua new file mode 100644 index 00000000..0263f772 --- /dev/null +++ b/tests/engine/yellow_advanced_palette_bug1639.lua @@ -0,0 +1,79 @@ +-- #1639: Yellow-only Advanced overlay — BEACH_HOUSE world bake, sprite +-- coverage past Red's 72 entries, saturated YELLOWMON — without changing +-- Red/Blue Advanced. +-- +-- luajit tests/engine/yellow_advanced_palette_bug1639.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("yellow advanced palette #1639") +local check, eq = S.check, S.eq + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") + +local prevVer = GameVersion.get() +local prevMode = PaletteFX.mode + +PaletteFX.setMode("redpp") + +-- Red: no BEACH_HOUSE, shared HOUSE still present +GameVersion.set("red") +check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"), + "Red Advanced has no BEACH_HOUSE tileset") +check(PaletteFX.hasWorldTileset("HOUSE"), "Red Advanced still has HOUSE") +local redHouse = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0) + +-- Blue unchanged for BEACH_HOUSE +GameVersion.set("blue") +check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"), + "Blue Advanced has no BEACH_HOUSE tileset") + +-- Yellow: BEACH_HOUSE present, HOUSE still inherits from Red pack +GameVersion.set("yellow") +check(PaletteFX.hasWorldTileset("BEACH_HOUSE"), + "Yellow Advanced profiles BEACH_HOUSE") +check(PaletteFX.hasWorldTileset("HOUSE"), + "Yellow Advanced still sees shared HOUSE") +local beach = PaletteFX.worldGroupColors(nil, "BEACH_HOUSE", "SUMMER_BEACH_HOUSE", 0) +check(beach ~= nil and #beach == 8, "BEACH_HOUSE has 8 group colors") +eq(beach[1][1][1], 255, "BEACH_HOUSE sand group is warm (R=255)") + +-- Sprite: Pikachu @60 and high indices resolve; Red path for bike stays 0 +local pika = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "pika") +check(pika ~= nil, "Yellow SPRITE_PIKACHU gets an OBP") +eq(pika[2][2], 255, "Pikachu OBP mid shade is saturated yellow G=255") +local boulder = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[72]" }, "b") +check(boulder ~= nil, "Yellow boulder (index 72) gets an OBP") + +GameVersion.set("red") +local redBall = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "ball") +check(redBall ~= nil, "Red poke-ball @60 still resolves") +-- Red group 0 mid is orange skin, not pure yellow +check(redBall[2][2] < 200, "Red @60 is not Yellow's Pikachu ramp") + +-- YELLOWMON / MEWMON / LOGO: Advanced uses CGBBase (saturated), not washed +-- SuperPalettes +GameVersion.set("yellow") +local yellowMon = PaletteFX.pal(nil, "YELLOWMON") +eq(yellowMon[2][2], 255, "Advanced Yellow YELLOWMON mid is saturated yellow G") +eq(yellowMon[2][3], 0, "Advanced Yellow YELLOWMON mid is pure yellow B=0") +local mewmon = PaletteFX.pal(nil, "MEWMON") +eq(mewmon[2][1], 255, "MEWMON stays Yellow title yellow (not Red++ purple)") +eq(mewmon[2][2], 255, "MEWMON body is saturated yellow G=255 (not washed 156)") +eq(mewmon[2][3], 0, "MEWMON body is pure yellow B=0") +check(mewmon[3][3] < 150, "MEWMON cheek stays red, not purple") +local logo2 = PaletteFX.pal(nil, "LOGO2") +eq(logo2[2][2], 255, "LOGO2 fill is saturated yellow under Advanced") +eq(logo2[2][3], 0, "LOGO2 fill is pure yellow B=0") + +-- Red HOUSE colors identical before/after Yellow merge path +GameVersion.set("red") +local redHouse2 = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0) +eq(redHouse2[1][1][1], redHouse[1][1][1], "Red HOUSE colors unchanged") + +PaletteFX.setMode(prevMode) +GameVersion.set(prevVer) + +S.finish() diff --git a/tests/mod_runtime_tests.lua b/tests/mod_runtime_tests.lua index 33d42bae..44f52ad6 100644 --- a/tests/mod_runtime_tests.lua +++ b/tests/mod_runtime_tests.lua @@ -145,8 +145,24 @@ check(type(Version.engine) == "string" and Semver.parse(Version.engine) ~= nil, "engine version parses as a semver (triple, optionally with a pre-release)") check(Version.modApi == 2, "mod api version is 2") +check(Version.isDev() == true, "the working-tree placeholder is a dev build") check(Version.title("X") == "X v" .. Version.engine, - "window title carries the engine version") + "dev window title carries the engine version") +do + local saved = Version.engine + Version.engine = "0.1.73" + check(Version.isDev() == false, "a stamped X.Y.Z is not a dev build") + check(Version.title() == "gen1recomp", + "release window title omits the version number") + check(Version.title("Gen 1 Recompilation Project") + == "Gen 1 Recompilation Project", + "release titles keep the base string alone") + Version.engine = "1.2.3-dev" + check(Version.isDev() == true, "a -dev pre-release still counts as dev") + check(Version.title() == "gen1recomp v1.2.3-dev", + "and still shows the version in the title") + Version.engine = saved +end -- null-object runtime: emit/call are safe with no loader installed local nullEvents, nullHooks = Runtime.events, Runtime.hooks diff --git a/tests/modkit/cases/battle_rule_hooks.lua b/tests/modkit/cases/battle_rule_hooks.lua new file mode 100644 index 00000000..16a91f4d --- /dev/null +++ b/tests/modkit/cases/battle_rule_hooks.lua @@ -0,0 +1,129 @@ +-- A sandboxed mod can decide two battle rules the OPTION screen and the cart +-- otherwise decide for the player -- whether a faint offers a free switch +-- (battle.style) and whether a catch asks for a nickname (catch.nickname) -- +-- using only public mod surfaces, and neither hook touches the player's +-- saved preference. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local TextBox = require("src.render.TextBox") + +local FIXTURE = { + ["mods/rules_probe/manifest.json"] = [[{ + "id": "rules_probe", + "name": "Rules Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/rules_probe/main.lua"] = [[ + local mod = ... + mod.exports.style = "set" + mod.exports.nickname = false + mod.hooks:wrap("battle.style", function(next, battle) + mod.exports.styleCalls = (mod.exports.styleCalls or 0) + 1 + mod.exports.styleBattle = battle + if mod.exports.style ~= nil then return mod.exports.style end + return next(battle) + end) + mod.hooks:wrap("catch.nickname", function(next, mon, ctx) + mod.exports.nameCalls = (mod.exports.nameCalls or 0) + 1 + mod.exports.nameCtx = ctx + if mod.exports.nickname ~= nil then return mod.exports.nickname end + return next(mon, ctx) + end) + ]], +} + +local function fixtureBattle(style) + local data = { pokemon = {}, text = {} } + return setmetatable({ + game = { save = { options = { battleStyle = style } }, + stack = { push = function() end }, data = data }, + data = data, + queue = {}, nextInsert = 0, + }, { __index = BattleState }) +end + +-- does the queued UI, if any, put the yes/no prompt on screen? +local function promptQueued(battle) + for _, item in ipairs(battle.queue) do + if item.ui then + local state = item.ui() + if getmetatable(state) == TextBox and state.choice then return true end + end + end + return false +end + +-- ------- no mod: the OPTION row and the cart's AskName decide + +local vanilla = T.sdk.loadNone({}) +T.eq(fixtureBattle("shift"):battleStyle(), "shift", "no mod: SHIFT row reads shift") +T.eq(fixtureBattle("set"):battleStyle(), "set", "no mod: SET row reads set") +T.eq(fixtureBattle("SET"):battleStyle(), "set", "no mod: the row is case-insensitive") +T.eq(fixtureBattle(nil):battleStyle(), "shift", "no mod: a missing row is the cart default") + +local plain = fixtureBattle("shift") +local mon = { species = "RATTATA" } +T.eq(plain:offerNickname(mon, "RATTATA"), true, "no mod: a catch queues the prompt") +T.eq(promptQueued(plain), true, "no mod: and it is the yes/no box") +T.eq(mon.nickname, nil, "no mod: nothing is named behind the player's back") +vanilla.release() + +-- ------- a mod answers both + +local run = T.sdk.loadMods({ "mods/rules_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "the rules probe loads clean (" .. tostring(run.errors[1]) .. ")") +local probe = run.loader.exports.rules_probe + +local forced = fixtureBattle("shift") +T.eq(forced:battleStyle(), "set", "\"set\" wins over a SHIFT row") +T.eq(forced.game.save.options.battleStyle, "shift", "without writing the row") +T.eq(probe.styleCalls, 1, "the hook ran once") +T.check(probe.styleBattle == forced, "and was handed the battle") +probe.style = "shift" +T.eq(fixtureBattle("set"):battleStyle(), "shift", "\"shift\" wins over a SET row") +probe.style = "banana" +T.eq(fixtureBattle("set"):battleStyle(), "set", + "an answer that is neither reads as the row") +probe.style = nil +T.eq(fixtureBattle("set"):battleStyle(), "set", "falling through reads the row") + +local skipped = fixtureBattle("shift") +local kept = { species = "RATTATA" } +T.eq(skipped:offerNickname(kept, "RATTATA"), false, "false: no prompt is queued") +T.eq(#skipped.queue, 0, "nothing at all is queued") +T.eq(kept.nickname, nil, "and the species name is kept") +T.eq(probe.nameCalls, 1, "the hook ran once") +T.check(probe.nameCtx and probe.nameCtx.battle == skipped, "with the battle in ctx") +T.eq(probe.nameCtx and probe.nameCtx.name, "RATTATA", "and the display name") + +probe.nickname = "SPIKE" +local named = { species = "RATTATA" } +T.eq(fixtureBattle("shift"):offerNickname(named, "RATTATA"), false, + "a string: no prompt either") +T.eq(named.nickname, "SPIKE", "and it is the nickname") + +probe.nickname = "TOOLONGFORTHEGRID" +local clipped = { species = "RATTATA" } +fixtureBattle("shift"):offerNickname(clipped, "RATTATA") +T.eq(clipped.nickname, "TOOLONGFOR", "a long string is clipped to the grid's ten") + +probe.nickname = "" +local blank = { species = "RATTATA" } +T.eq(fixtureBattle("shift"):offerNickname(blank, "RATTATA"), false, + "an empty string still declines the prompt") +T.eq(blank.nickname, nil, "and names nothing, like the grid's own empty entry") + +probe.nickname = nil +local asked = fixtureBattle("shift") +T.eq(asked:offerNickname({ species = "RATTATA" }, "RATTATA"), true, + "falling through asks as usual") +T.eq(promptQueued(asked), true, "with the real prompt") + +run.release() +T.finish("battle rule hooks") diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua index bc88882f..8789067e 100644 --- a/tests/rom_importer_android_mod_pick_test.lua +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -17,10 +17,11 @@ local saved = { pickFileKinds = love.system.pickFileKinds, } -local pickCalls = {} +local pickCalls, pickDestinations = {}, {} love.system.getOS = function() return "Android" end -love.system.pickFile = function(kind) +love.system.pickFile = function(kind, destination) pickCalls[#pickCalls + 1] = kind or "rom" + pickDestinations[#pickCalls] = destination return true end love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end @@ -103,13 +104,15 @@ ri.nativePicker = true ri.mobileFileBridge = true ri.mods = { { id = "needs_source", - manifest = { id = "needs_source", name = "Needs Source", + manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source", required_imports = { { id = "source", name = "Source", file = "source.bin", format = "raw", md5 = { "00000000000000000000000000000000" } } } }, } } ri:chooseRequiredImport("needs_source", "source") eq(pickCalls[1], "required_import", "required file asks for the dedicated picker kind") +eq(pickDestinations[1], "mods/needs_source/baseroms/source.bin", + "raw required file receives its direct private baseroms destination") eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered") eq(ri.pickerPendingImportId, "source", "pending import is remembered") @@ -166,6 +169,52 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara check(love.filesystem.getInfo("picked_required_import.bin") == nil, "focus removes the staged required-file pick") + +-- Current bridge completion: the large source already lives in final baseroms; +-- only the tiny path/digest/size marker crosses the launcher focus path. +local directPath = "mods/needs_source/baseroms/source.bin" +local directSavedGetInfo = love.filesystem.getInfo +love.filesystem.getInfo = function(name, kind) + local info = directSavedGetInfo(name, kind) + if info and name == directPath then + info.size = 12 + info.modtime = 123456 + end + return info +end +love.filesystem.createDirectory("mods/needs_source/baseroms") +love.filesystem.write(directPath, "source bytes") +ri.mods[1].manifest.required_imports[1].md5 = { + "fe1eb7483479c3a4e44fd41ce6f6d6ad" +} +ri.pickerPendingKind = "required_import" +ri.pickerPendingModId = "needs_source" +ri.pickerPendingImportId = "source" +love.filesystem.write("pick_complete.flag", + "v1\n" .. directPath .. "\nfe1eb7483479c3a4e44fd41ce6f6d6ad\n12\n") +ri:focus(true) +check(ri.modNotice ~= nil and ri.modNotice.ok == true, + "focus accepts a direct native required-import completion") +check(love.filesystem.getInfo(directPath, "file") ~= nil, + "direct required import remains in final baseroms") +check(love.filesystem.getInfo("pick_complete.flag") == nil, + "direct completion marker is consumed") + +-- A native digest that does not match the manifest is rejected and the direct +-- copy is removed, so an invalid source cannot masquerade as a validated one. +love.filesystem.write(directPath, "source bytes") +ri.pickerPendingKind = "required_import" +ri.pickerPendingModId = "needs_source" +ri.pickerPendingImportId = "source" +love.filesystem.write("pick_complete.flag", + "v1\n" .. directPath .. "\n00000000000000000000000000000000\n12\n") +ri:focus(true) +check(ri.requiredImportNotice ~= nil, + "bad direct digest reports a dependency validation error") +check(love.filesystem.getInfo(directPath, "file") == nil, + "bad direct digest removes the rejected final copy") +love.filesystem.getInfo = directSavedGetInfo + -- Android releases with the updated launcher but the older native bridge do -- not advertise required_import. They still support the established ROM SAF -- picker, whose result must be quarantined to the pending dependency request. @@ -176,12 +225,14 @@ ri.nativePicker = true ri.mobileFileBridge = true ri.mods = { { id = "needs_source", - manifest = { id = "needs_source", name = "Needs Source", + manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source", required_imports = { { id = "source", name = "Source", file = "source.bin", format = "raw", md5 = { "00000000000000000000000000000000" } } } }, } } ri:chooseRequiredImport("needs_source", "source") eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker") +check(pickDestinations[1] == nil, + "legacy ROM fallback receives no nested dependency destination") check(ri.requiredImportLegacyRomPick, "legacy Android ROM picker result is marked as a required import") ri._importRequiredSource = function(self, modId, importId, source) @@ -216,5 +267,8 @@ love.filesystem.remove("picked_mod.zip") love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_required_import.bin") love.filesystem.remove("picked_rom.gb") +love.filesystem.remove("pick_complete.flag") +love.filesystem.remove("pick_error.flag") +love.filesystem.remove("mods/needs_source/baseroms/source.bin") S.finish() diff --git a/tests/rom_manifest_generator_test.py b/tests/rom_manifest_generator_test.py index 07a88d1c..99dd053e 100644 --- a/tests/rom_manifest_generator_test.py +++ b/tests/rom_manifest_generator_test.py @@ -102,5 +102,44 @@ class ApplyKnownNonreproducibleOverridesTest(TestCase): texts, field_data) +class YellowSuperRodParseTest(TestCase): + """#1074: Yellow's inline Super Rod table (species, level), not Red's.""" + + def test_parse_super_rod_yellow_safari_dragonair(self): + import json + import tempfile + from extract import field + + asm = ( + "SuperRodFishingSlots::\n" + "\tdb SAFARI_ZONE_CENTER, MAGIKARP, 5, MAGIKARP, 10, " + "DRATINI, 10, DRAGONAIR, 15\n" + "\tdb SAFARI_ZONE_EAST, MAGIKARP, 5, MAGIKARP, 10, " + "MAGIKARP, 15, DRATINI, 15\n" + "\tdb -1 ; end\n" + ) + with tempfile.TemporaryDirectory() as td: + wild = Path(td) / "data" / "wild" + wild.mkdir(parents=True) + (wild / "super_rod.asm").write_text(asm) + parsed = field.parse_super_rod_yellow(td) + + self.assertEqual(parsed["SAFARI_ZONE_CENTER"], [ + {"level": 5, "species": "MAGIKARP"}, + {"level": 10, "species": "MAGIKARP"}, + {"level": 10, "species": "DRATINI"}, + {"level": 15, "species": "DRAGONAIR"}, + ]) + self.assertNotIn("DRAGONAIR", + [s["species"] for s in parsed["SAFARI_ZONE_EAST"]]) + + def test_shipped_yellow_manifest_has_safari_dragonair(self): + import json + path = Path(__file__).resolve().parents[1] / "tools" / "rom_manifest_yellow.json" + data = json.loads(path.read_text(encoding="utf-8")) + center = data["field"]["superRod"]["SAFARI_ZONE_CENTER"] + self.assertIn({"level": 15, "species": "DRAGONAIR"}, center) + + if __name__ == "__main__": main() diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index ef452f87..51463776 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -927,6 +927,113 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- Move picker search predicate: same plain-text rules as species (#541), + -- plus type substring and whole-number power / accuracy matches. + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + + check(Ops.moveMatches(S, "THUNDERBOLT", "thunder"), "lowercase query matches an id") + check(Ops.moveMatches(S, "THUNDERBOLT", "BOLT"), "a mid-word substring matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "electric"), "a type substring matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "95"), "a whole power matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "100"), "a whole accuracy matches") + check(Ops.moveMatches(S, "THUNDERBOLT", "9") == false, + "a power/accuracy number does not substring-match") + check(Ops.moveMatches(S, "THUNDERBOLT", ""), "an empty query matches everything") + check(Ops.moveUsable(S, "THUNDERBOLT"), "a real move is usable") + check(Ops.moveUsable(S, "generation") == false, "a provenance scalar is not usable") + + eq(#Ops.moveSearch(S, ""), #S.cat.moves, "an empty search lists the catalog") + eq(#Ops.moveSearch(S, "%a"), 0, "a pattern class is literal") + check(#Ops.moveSearch(S, "zzzznope") == 0, "a miss returns nothing") + local bolt = Ops.moveSearch(S, "thunderbolt") + eq(#bolt, 1, "an exact name search narrows to one") + eq(bolt[1], "THUNDERBOLT", "and it is the right one") + + -- Prefix beats mid-string: "sur" used to list ACUPRESSURE / FISSURE before + -- SURF because the catalog is A-Z. Rank so Enter commits the obvious hit. + local sur = Ops.moveSearch(S, "sur") + check(#sur >= 1, "sur finds at least one move") + eq(sur[1], "SURF", "a prefix match ranks above mid-string hits") + local thunder = Ops.moveSearch(S, "thunder") + check(#thunder >= 1, "thunder finds at least one move") + eq(thunder[1], "THUNDER", "THUNDER prefixes beat THUNDERBOLT / THUNDERSHOCK") +end + +do + -- Move picker end to end through App: open off a move row, type, commit + -- with Enter. Same Enter/Escape-before-Kit rule as the species picker. + local Kit = require("Kit") + local MovePicker = require("MovePicker") + local tmpPath = os.tmpname() .. "-movepicker-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "WARTORTLE", 20) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "party" + + check(Ops.openMovePicker(S, Kit, 1) == false, + "the move picker refuses to open with no slot selected") + check(S.movePicker == nil, "and it stayed closed") + check(S.status:match("Pick a slot") ~= nil, "and it said why") + + Ops.selectParty(S, 1) + check(Ops.openMovePicker(S, Kit, 0) == false, "slot 0 is refused") + check(Ops.openMovePicker(S, Kit, 1) == true, "the move picker opens on slot 1") + check(S.movePicker ~= nil, "the picker is up") + eq(S.movePicker.slot, 1, "for the requested slot") + eq(S.movePicker.query, "", "it opens with an empty query") + eq(Kit.focus, "move-picker", "it opens with the field focused (#529 keyboard)") + + local ok, err = pcall(App.draw) + check(ok, "the move picker draws headlessly: " .. tostring(err)) + + App.textinput("THUNDERBOLT") + ok, err = pcall(App.draw) + check(ok, "the move picker draws while typing: " .. tostring(err)) + eq(S.movePicker.query, "THUNDERBOLT", "typing reaches the picker's field") + eq(#MovePicker.results(S), 1, "the list narrowed to the typed move") + + App.keypressed("return") + eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "Enter commits the top match") + check(S.movePicker == nil, "and closes the picker") + check(S.dirty, "and the save is dirty") + + -- Escape leaves without touching the mon + local before = S.save.party[1].moves[1].id + Ops.openMovePicker(S, Kit, 1) + App.textinput("SURF") + App.draw() + App.keypressed("escape") + check(S.movePicker == nil, "Escape closes the move picker") + eq(S.save.party[1].moves[1].id, before, "Escape did not commit anything") + check(S.editingMon ~= nil, "Escape closed the picker, not the selection") + + -- a query nothing matches cannot commit + Ops.openMovePicker(S, Kit, 2) + App.textinput("zzzznope") + App.draw() + App.keypressed("return") + check(S.movePicker ~= nil, "Enter on an empty result set keeps the picker up") + check(S.status:match("No move matches") ~= nil, "and says so") + Ops.closeMovePicker(S, Kit) + + -- Ops.setMove refuses a scalar / missing id + check(Ops.setMove(S, S.editingMon, 1, "generation") == false, + "setMove refuses a provenance scalar") + eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "and leaves the slot alone") + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + do -- The inspector's nickname field is commit-on-Enter: the draft lives in -- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, @@ -1127,10 +1234,13 @@ do -- 720x1280 / 1280x720 are the #715 report's shapes (Android, both -- orientations): the Map tab used to lay its viewport out at a negative - -- width in portrait and crash on the scissor. The desktop sizes pin that - -- the responsive reflow does not disturb the layouts that already worked. + -- width in portrait and crash on the scissor. RGxxx / Switch shapes pin + -- short-landscape handhelds (RG34XXSP 720x480, RG35XX 640x480, NX 1280x720) + -- and a tiny 360x640 phone. Desktop sizes keep the layouts that already + -- worked. for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 }, { 1280, 800 }, { 720, 1280 }, { 1280, 720 }, + { 720, 480 }, { 640, 480 }, { 480, 320 }, { 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do love.graphics.getDimensions = function() return size[1], size[2] end App.load(tmpPath, { version = "red" }) @@ -1153,6 +1263,12 @@ do ok, err = pcall(App.draw) check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err))) Ops.closeSpeciesPicker(S, Kit) + Ops.openMovePicker(S, Kit, 1) + ok, err = pcall(App.draw) + check(ok, ("the move picker draws at %s: %s"):format(label, tostring(err))) + ok, err = pcall(App.draw) + check(ok, ("the move picker redraws at %s: %s"):format(label, tostring(err))) + Ops.closeMovePicker(S, Kit) end love.graphics.getDimensions = oldDimensions @@ -1233,8 +1349,12 @@ do f:close() local oldDimensions = love.graphics.getDimensions + -- Include RG34XXSP (720x480) and Switch handheld; keep 640x480 out of the + -- overlap audit (Map still needs its own short-landscape pass) but the + -- phone draw suite above already covers it. local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 }, - { 1024, 768 }, { 900, 700 }, { 1920, 1080 } } + { 720, 480 }, { 1024, 768 }, + { 900, 700 }, { 1920, 1080 } } for _, size in ipairs(sizes) do local W, H = size[1], size[2] love.graphics.getDimensions = function() return W, H end @@ -1266,6 +1386,14 @@ do if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end Kit.audit = nil Ops.closeSpeciesPicker(S, Kit) + Ops.openMovePicker(S, Kit, 1) + App.draw() + Kit.audit = {} + ok, err = pcall(App.draw) + check(ok, ("%dx%d move picker draws: %s"):format(W, H, tostring(err))) + if ok then auditFrame(("%dx%d move picker"):format(W, H), W, H) end + Kit.audit = nil + Ops.closeMovePicker(S, Kit) end love.graphics.getDimensions = oldDimensions @@ -1349,5 +1477,150 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- #917: Pixel 9a Save was dead because the title cluster sat in the + -- cutout / status unsafe band. Chrome and modal search fields must live + -- inside love.window.getSafeArea (background may still paint full-bleed). + local Kit = require("Kit") + local tmpPath = os.tmpname() .. "-safe-area-save.lua" + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(SaveData.newGame())) + f:close() + + local W, H = 720, 1560 + local ox, oy, sw, sh = 0, 64, 720, 1456 -- punch-hole + home indicator + local oldDimensions = love.graphics.getDimensions + local oldSafe = love.window.getSafeArea + love.graphics.getDimensions = function() return W, H end + love.window.getSafeArea = function() return ox, oy, sw, sh end + + App.load(tmpPath, { version = "blue" }) + local S = App.getState() + Ops.partyAdd(S) + S.dirty = true + S.allowSave = true + S.tab = "party" + + local function insideSafe(rects, where) + for _, r in ipairs(rects or {}) do + if r.class == "control" then + check(r.x >= ox - 0.5, + where .. " '" .. r.label .. "' is right of the left inset") + check(r.y >= oy - 0.5, + where .. " '" .. r.label .. "' is below the notch (#917)") + check(r.x + r.w <= ox + sw + 0.5, + where .. " '" .. r.label .. "' stays left of the right inset") + check(r.y + r.h <= oy + sh + 0.5, + where .. " '" .. r.label .. "' stays above the home indicator") + end + end + end + + Kit.audit = {} + local ok, err = pcall(App.draw) + check(ok, "editor draws inside an inset safe area: " .. tostring(err)) + insideSafe(Kit.audit, "chrome") + + local saveBtn + for _, r in ipairs(Kit.audit) do + if r.class == "control" and (r.label == "SAVE" or r.label == "SAVED" + or r.label == "SAVE LOCKED") then + saveBtn = r + break + end + end + check(saveBtn ~= nil, "the Save button was audited") + if saveBtn then + check(saveBtn.y >= oy - 0.5, + "Save clears the top safe inset (#917)") + check(saveBtn.y + saveBtn.h <= oy + sh + 0.5, + "Save stays above the bottom safe inset") + end + Kit.audit = nil + + -- Species-picker search field must also clear the notch once chrome is inset. + Ops.openSpeciesPicker(S, Kit) + App.draw() -- opening frame is fully shielded + Kit.audit = {} + ok, err = pcall(App.draw) + check(ok, "species picker draws inside an inset safe area: " .. tostring(err)) + insideSafe(Kit.audit, "species picker") + local search + for _, r in ipairs(Kit.audit) do + if r.class == "control" and r.label == "species-picker" then + search = r + break + end + end + check(search ~= nil, "the species search field was audited") + if search then + check(search.y >= oy - 0.5, + "species search clears the top safe inset (#917)") + end + Kit.audit = nil + Ops.closeSpeciesPicker(S, Kit) + + love.graphics.getDimensions = oldDimensions + love.window.getSafeArea = oldSafe + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + +do + -- PickerChrome: short RGxxx / phone landscapes must nearly fill SafeArea + -- (not sit in a 32px-guttered desk card that leaves no list body), and every + -- interactive metric stays at or above the 26px tap floor. + local Kit = require("Kit") + local PickerChrome = require("PickerChrome") + local oldDimensions = love.graphics.getDimensions + local oldSafe = love.window.getSafeArea + + local function checkDevice(W, H, safe, label) + love.graphics.getDimensions = function() return W, H end + if safe then + love.window.getSafeArea = function() + return safe[1], safe[2], safe[3], safe[4] + end + else + love.window.getSafeArea = function() return 0, 0, W, H end + end + Kit.layout(safe and safe[3] or W, safe and safe[4] or H) + local x, y, w, h, pad = PickerChrome.card(Kit, W, H) + local ox = safe and safe[1] or 0 + local oy = safe and safe[2] or 0 + local sw = safe and safe[3] or W + local sh = safe and safe[4] or H + check(w > 0 and h > 0, label .. ": card has positive size") + check(x >= ox - 0.5 and y >= oy - 0.5, + label .. ": card origin stays inside the safe rect") + check(x + w <= ox + sw + 0.5 and y + h <= oy + sh + 0.5, + label .. ": card fits inside the safe rect") + -- Short landscapes should use almost all of the safe height. + if sh <= 560 * Kit.scale + 40 then + check(h >= sh * 0.85, + label .. ": short landscape card fills most of the safe height") + end + local tap = PickerChrome.tapMin(Kit) + check(tap >= 26, label .. ": tapMin is at least 26px") + check(PickerChrome.fieldH(Kit) >= tap, label .. ": search field meets tapMin") + check(PickerChrome.closeSize(Kit) >= tap, label .. ": close meets tapMin") + local listH, rowH = PickerChrome.listMetrics(Kit, y, h, pad, + y + pad + 80 * Kit.scale) + check(listH >= 0, label .. ": list height is non-negative") + check(rowH >= tap, label .. ": list rows meet tapMin") + end + + checkDevice(720, 480, nil, "RG34XXSP 720x480") + checkDevice(640, 480, nil, "RG35XX 640x480") + checkDevice(1280, 720, nil, "Switch handheld 1280x720") + checkDevice(720, 1280, { 0, 64, 720, 1176 }, "Pixel portrait + notch") + checkDevice(1280, 720, { 48, 0, 1184, 720 }, "landscape + side cutout") + checkDevice(360, 640, nil, "tiny phone 360x640") + checkDevice(1920, 1080, nil, "desktop 1080p") + + love.graphics.getDimensions = oldDimensions + love.window.getSafeArea = oldSafe +end + print(string.format("save editor tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tests/title_pikachu_obp_test.py b/tests/title_pikachu_obp_test.py new file mode 100644 index 00000000..a0d47a25 --- /dev/null +++ b/tests/title_pikachu_obp_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Title rOBP0=$E0 remaps eye OAM shades 1/2 to white (#1639 pupils).""" + +from pathlib import Path +from unittest import TestCase, main +import sys +import tempfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +import build_rom_data as b # noqa: E402 +from PIL import Image # noqa: E402 + + +class TitleObp0Test(TestCase): + def test_mid_and_dark_become_white(self): + img = Image.new("RGBA", (4, 1), (0, 0, 0, 0)) + img.putpixel((0, 0), b.GB_SHADES[1]) + img.putpixel((1, 0), b.GB_SHADES[2]) + img.putpixel((2, 0), b.GB_SHADES[3]) + img.putpixel((3, 0), b.GB_SHADES[0]) + b._apply_title_obp0(img) + self.assertEqual(img.getpixel((0, 0)), b.GB_SHADES[0]) + self.assertEqual(img.getpixel((1, 0)), b.GB_SHADES[0]) + self.assertEqual(img.getpixel((2, 0)), b.GB_SHADES[3]) + self.assertEqual(img.getpixel((3, 0)), b.GB_SHADES[0]) + + def test_transparent_untouched(self): + img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + b._apply_title_obp0(img) + self.assertEqual(img.getpixel((0, 0))[3], 0) + + +if __name__ == "__main__": + main() diff --git a/tests/yellow_title_palette_test.lua b/tests/yellow_title_palette_test.lua index 33c5d0db..5fdcf044 100644 --- a/tests/yellow_title_palette_test.lua +++ b/tests/yellow_title_palette_test.lua @@ -22,9 +22,12 @@ eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222") PaletteFX.setMode("redpp") local mewmonAdv = PaletteFX.pal(nil, "MEWMON") check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow") --- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette! +-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu +-- CGBBase yellow {255,255,0}, not washed SuperPalette cream {255,255,156}. eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)") eq(mewmonAdv[2][1], 255, "ADVANCED: Color 1 is yellow R=255 (Pikachu body)") +eq(mewmonAdv[2][2], 255, "ADVANCED: Color 1 is saturated yellow G=255") +eq(mewmonAdv[2][3], 0, "ADVANCED: Color 1 is pure yellow B=0 (not washed cream)") check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)") -- Test 3: OG YELLOW mode (ogred on Yellow) diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 2e1f876f..86f21b85 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -77,6 +77,26 @@ GB_SHADES = ( ) +def _apply_title_obp0(image): + """Title rOBP0=%11100000 ($E0): OBJ shades 1 and 2 → white, 3 → black. + + Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the + shade-1 glints become body yellow under the title palette + (pokeyellow engine/movie/title.asm after PlacePikachu). + """ + pixels = image.load() + w, h = image.size + mid, dark = GB_SHADES[1][0], GB_SHADES[2][0] + for y in range(h): + for x in range(w): + r, g, b, a = pixels[x, y] + if a == 0: + continue + if abs(r - mid) <= 2 or abs(r - dark) <= 2: + pixels[x, y] = GB_SHADES[0] + return image + + def _symbol(symbols, name): try: return symbols[name] @@ -1880,12 +1900,33 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): (3, 24, 24, True), (2, 32, 24, True), (0, 56, 16, False), (1, 64, 16, False), (2, 56, 24, False), (3, 64, 24, False)): - eye = ob_clear[ob_index] + eye = ob_clear[ob_index].copy() + _apply_title_obp0(eye) if flip: eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT) pikachu.paste(eye, (px, py), eye) _save_png(pikachu, os.path.join(assets_dir, "title/pikachu.png")) + # Blink overlays (half/closed) — same OBP remap as open eyes. + eye_layout = ( + (1, 24, 16, True), (0, 32, 16, True), + (3, 24, 24, True), (2, 32, 24, True), + (0, 56, 16, False), (1, 64, 16, False), + (2, 56, 24, False), (3, 64, 24, False), + ) + # Re-compose blank-face pika for overlays (open eyes already baked). + blank_face = matte_color0(compose(13, 9, pika_cells)) + for suffix, base in (("eyes_half", 4), ("eyes_closed", 8)): + overlay = Image.new("RGBA", (48, 16), (255, 255, 255, 0)) + overlay.paste(blank_face.crop((24, 16, 72, 32)), (0, 0)) + for ob_index, px, py, flip in eye_layout: + eye = ob_clear[base + ob_index].copy() + _apply_title_obp0(eye) + if flip: + eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + overlay.paste(eye, (px - 24, py - 16), eye) + _save_png(overlay, os.path.join(assets_dir, f"title/{suffix}.png")) + falling_star = raw_2bpp( "FallingStar", 8, 8, "intro/falling_star.png", transparent=True) diff --git a/tools/extract/field.py b/tools/extract/field.py index 839be555..0f0224db 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -86,6 +86,43 @@ def parse_super_rod(pokered): return out +def parse_super_rod_yellow(pokeyellow): + """pokeyellow data/wild/super_rod.asm: inline species,level rows. + + Yellow stores four (species, level) pairs per map on one `db` line + (`db MAP, SPECIES, LEVEL, SPECIES, LEVEL, ...`), unlike Red's + `dbw MAP, .Group` + `db level, species` groups. Slot order is kept + so Super Rod weighted rolls and DexNav lists stay faithful (#1074). + """ + out = {} + path = os.path.join(pokeyellow, "data/wild/super_rod.asm") + for lineno, line in read_asm(path): + s = line.strip() + if not s.startswith("db ") or s == "db -1" or s.startswith("db -1 ;"): + continue + # db MAP, SPECIES, LEVEL, SPECIES, LEVEL, SPECIES, LEVEL, SPECIES, LEVEL + parts = [p.strip() for p in s[3:].split(",")] + if len(parts) < 3 or not re.match(r"^[A-Z][A-Z0-9_]*$", parts[0]): + continue + map_id = parts[0] + slots = [] + rest = parts[1:] + i = 0 + while i + 1 < len(rest): + species, level = rest[i], rest[i + 1] + if not re.match(r"^[A-Z][A-Z0-9_]*$", species): + break + try: + level_n = int(level) + except ValueError: + break + slots.append({"level": level_n, "species": species}) + i += 2 + if slots: + out[map_id] = slots + return out + + def parse_trades(pokered): """data/events/trades.asm: npctrade give, get, dialogset, nickname.""" # TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index bcf70752..10988433 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -453,6 +453,7 @@ def derive(red, pokeyellow, symbols_path): print(f"warning: parse_credits failed ({exc}); keeping Red credits") # TODO: hand-author a Yellow credits banner if pret layout drifts. yellow["field"]["trades"] = field.parse_trades(pokeyellow) + yellow["field"]["superRod"] = field.parse_super_rod_yellow(pokeyellow) finally: util.ASM_DEFINES = saved diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index ed4d489c..e6da0bdb 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -8360,94 +8360,74 @@ "superRod": { "CELADON_CITY": [ { - "level": 23, - "species": "POLIWHIRL" + "level": 5, + "species": "GOLDEEN" + }, + { + "level": 10, + "species": "GOLDEEN" }, { "level": 15, - "species": "SLOWPOKE" + "species": "GOLDEEN" + }, + { + "level": 20, + "species": "GOLDEEN" } ], "CERULEAN_CAVE_1F": [ { - "level": 23, - "species": "SLOWBRO" + "level": 25, + "species": "GOLDEEN" }, { - "level": 23, + "level": 35, "species": "SEAKING" }, { - "level": 23, - "species": "KINGLER" - }, - { - "level": 23, - "species": "SEADRA" - } - ], - "CERULEAN_CAVE_2F": [ - { - "level": 23, - "species": "SLOWBRO" - }, - { - "level": 23, + "level": 45, "species": "SEAKING" }, { - "level": 23, - "species": "KINGLER" - }, - { - "level": 23, - "species": "SEADRA" + "level": 55, + "species": "SEAKING" } ], "CERULEAN_CAVE_B1F": [ { - "level": 23, - "species": "SLOWBRO" + "level": 30, + "species": "GOLDEEN" }, { - "level": 23, + "level": 40, "species": "SEAKING" }, { - "level": 23, - "species": "KINGLER" + "level": 50, + "species": "SEAKING" }, { - "level": 23, - "species": "SEADRA" + "level": 60, + "species": "SEAKING" } ], "CERULEAN_CITY": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 25, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" - } - ], - "CERULEAN_GYM": [ - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 30, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" + "level": 30, + "species": "SEAKING" + }, + { + "level": 40, + "species": "SEAKING" } ], "CINNABAR_ISLAND": [ @@ -8457,99 +8437,123 @@ }, { "level": 15, - "species": "HORSEA" + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" + "level": 10, + "species": "STARYU" }, { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "TENTACOOL" } ], "FUCHSIA_CITY": [ { - "level": 23, - "species": "SEAKING" + "level": 5, + "species": "MAGIKARP" }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "GYARADOS" + } + ], + "PALLET_TOWN": [ + { + "level": 10, + "species": "STARYU" + }, + { + "level": 10, + "species": "TENTACOOL" + }, + { + "level": 5, + "species": "STARYU" + }, + { + "level": 20, + "species": "TENTACOOL" + } + ], + "ROUTE_10": [ { "level": 15, "species": "KRABBY" }, { - "level": 15, - "species": "GOLDEEN" + "level": 20, + "species": "KRABBY" }, { - "level": 15, - "species": "MAGIKARP" - } - ], - "PALLET_TOWN": [ - { - "level": 15, - "species": "TENTACOOL" + "level": 10, + "species": "HORSEA" }, { - "level": 15, - "species": "POLIWAG" - } - ], - "ROUTE_10": [ - { - "level": 23, - "species": "POLIWHIRL" - }, - { - "level": 15, - "species": "SLOWPOKE" + "level": 25, + "species": "KINGLER" } ], "ROUTE_11": [ { "level": 15, - "species": "KRABBY" + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" + "level": 20, + "species": "TENTACOOL" + }, + { + "level": 10, + "species": "TENTACOOL" + }, + { + "level": 5, + "species": "HORSEA" } ], "ROUTE_12": [ { - "level": 5, - "species": "TENTACOOL" + "level": 20, + "species": "HORSEA" }, { - "level": 15, - "species": "KRABBY" + "level": 25, + "species": "HORSEA" }, { - "level": 15, - "species": "GOLDEEN" + "level": 25, + "species": "SEADRA" }, { - "level": 15, - "species": "MAGIKARP" + "level": 35, + "species": "SEADRA" } ], "ROUTE_13": [ { - "level": 5, + "level": 15, + "species": "HORSEA" + }, + { + "level": 20, + "species": "HORSEA" + }, + { + "level": 10, "species": "TENTACOOL" }, { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "GOLDEEN" - }, - { - "level": 15, - "species": "MAGIKARP" + "level": 20, + "species": "SEADRA" } ], "ROUTE_17": [ @@ -8559,304 +8563,356 @@ }, { "level": 15, - "species": "KRABBY" + "species": "TENTACOOL" }, { - "level": 15, - "species": "GOLDEEN" + "level": 25, + "species": "SHELLDER" }, { - "level": 15, - "species": "MAGIKARP" + "level": 35, + "species": "SHELLDER" } ], "ROUTE_18": [ { - "level": 5, + "level": 15, "species": "TENTACOOL" }, { - "level": 15, - "species": "KRABBY" + "level": 20, + "species": "SHELLDER" }, { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "SHELLDER" }, { - "level": 15, - "species": "MAGIKARP" + "level": 40, + "species": "SHELLDER" } ], "ROUTE_19": [ { "level": 15, + "species": "TENTACOOL" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 30, + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "TENTACRUEL" } ], "ROUTE_20": [ { - "level": 15, + "level": 20, + "species": "TENTACOOL" + }, + { + "level": 20, + "species": "TENTACRUEL" + }, + { + "level": 30, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" - }, - { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 40, + "species": "TENTACRUEL" } ], "ROUTE_21": [ { "level": 15, + "species": "TENTACOOL" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 30, + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "TENTACRUEL" } ], "ROUTE_22": [ { - "level": 15, - "species": "GOLDEEN" + "level": 5, + "species": "POLIWAG" + }, + { + "level": 10, + "species": "POLIWAG" }, { "level": 15, "species": "POLIWAG" + }, + { + "level": 15, + "species": "POLIWHIRL" } ], "ROUTE_23": [ { - "level": 23, - "species": "SLOWBRO" + "level": 25, + "species": "POLIWAG" }, { - "level": 23, - "species": "SEAKING" + "level": 30, + "species": "POLIWAG" }, { - "level": 23, - "species": "KINGLER" + "level": 30, + "species": "POLIWHIRL" }, { - "level": 23, - "species": "SEADRA" + "level": 40, + "species": "POLIWHIRL" } ], "ROUTE_24": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 20, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" + "level": 25, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "SEAKING" } ], "ROUTE_25": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 10, + "species": "KRABBY" }, { "level": 15, "species": "KRABBY" + }, + { + "level": 15, + "species": "KINGLER" + }, + { + "level": 25, + "species": "KINGLER" } ], "ROUTE_4": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 20, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" + "level": 25, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "SEAKING" } ], "ROUTE_6": [ { - "level": 15, - "species": "KRABBY" + "level": 5, + "species": "GOLDEEN" + }, + { + "level": 10, + "species": "GOLDEEN" }, { "level": 15, - "species": "SHELLDER" + "species": "GOLDEEN" + }, + { + "level": 20, + "species": "GOLDEEN" } ], "SAFARI_ZONE_CENTER": [ { - "level": 15, + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 10, "species": "DRATINI" }, { "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" + "species": "DRAGONAIR" } ], "SAFARI_ZONE_EAST": [ + { + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, { "level": 15, "species": "DRATINI" - }, - { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" } ], "SAFARI_ZONE_NORTH": [ + { + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, { "level": 15, "species": "DRATINI" - }, - { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" } ], "SAFARI_ZONE_WEST": [ + { + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, { "level": 15, "species": "DRATINI" - }, - { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" } ], "SEAFOAM_ISLANDS_B3F": [ { - "level": 15, + "level": 25, + "species": "KRABBY" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 35, + "species": "KINGLER" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 40, + "species": "STARYU" } ], "SEAFOAM_ISLANDS_B4F": [ { - "level": 15, + "level": 25, + "species": "KRABBY" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 35, + "species": "KINGLER" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 40, + "species": "STARYU" } ], "VERMILION_CITY": [ { "level": 15, - "species": "KRABBY" + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" + "level": 20, + "species": "TENTACOOL" + }, + { + "level": 10, + "species": "TENTACOOL" + }, + { + "level": 5, + "species": "HORSEA" } ], "VERMILION_DOCK": [ { - "level": 15, - "species": "KRABBY" + "level": 10, + "species": "TENTACOOL" }, - { - "level": 15, - "species": "SHELLDER" - } - ], - "VIRIDIAN_CITY": [ { "level": 15, "species": "TENTACOOL" }, { "level": 15, + "species": "STARYU" + }, + { + "level": 10, + "species": "SHELLDER" + } + ], + "VIRIDIAN_CITY": [ + { + "level": 5, + "species": "POLIWAG" + }, + { + "level": 10, + "species": "POLIWAG" + }, + { + "level": 15, + "species": "POLIWAG" + }, + { + "level": 10, "species": "POLIWAG" } ] diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index b64816f4..4948c208 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -9,7 +9,11 @@ -- * Edit on a launcher save row (main.lua, embedded = true), Close returns -- to the launcher with the slot list refreshed -- --- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes): +-- Vertical rhythm inside the platform safe area (scaled by Kit's height/768 +-- factor, everything else flexes). Background still fills the full window so +-- the notch / home-indicator bands match the field colour; interactive chrome +-- starts at SafeArea.rect() so Save stays reachable on a punch-hole phone +-- (#917). Offsets below are relative to that safe origin: -- 0 6px tri-colour version rail, identical to the launcher's -- 6 64px title bar identity, file chip, Save / Reload / Open / Close -- (104px when the bar reflows to two rows, #715) @@ -18,6 +22,7 @@ -- -38 38px status bar the last Ops message + the keyboard map local Data = require("src.core.Data") +local SafeArea = require("src.core.SafeArea") local TileRenderer = require("src.render.TileRenderer") local SaveIO = require("SaveIO") local Catalog = require("Catalog") @@ -37,6 +42,7 @@ local MapBrowser = require("MapBrowser") local Dex = require("Dex") -- chrome, not a tab panel, so deliberately kept out of PANELS below (#541) local SpeciesPicker = require("SpeciesPicker") +local MovePicker = require("MovePicker") local ItemPicker = require("ItemPicker") local App = {} @@ -772,7 +778,17 @@ function App.draw() -- tolerates that rather than indexing a torn-down state. if not S then return end local width, height = love.graphics.getDimensions() - Kit.layout(width, height) + width = math.max(1, tonumber(width) or 1) + height = math.max(1, tonumber(height) or 1) + -- Usable chrome rect. Background still fills the window so the notch / + -- home-indicator bands stay the field colour; every button (Save first) + -- lives inside the safe area, matching the launcher and Skin Studio (#917). + local ox, oy, sw, sh = SafeArea.rect() + ox = math.max(0, tonumber(ox) or 0) + oy = math.max(0, tonumber(oy) or 0) + sw = math.max(1, tonumber(sw) or width) + sh = math.max(1, tonumber(sh) or height) + Kit.layout(sw, sh) local s = Kit.scale local mx, my = love.mouse.getPosition() @@ -791,6 +807,7 @@ function App.draw() -- shield goes up before anything dispatches and comes down only for the -- picker's own layer at the bottom of this function (#541). Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil) + or (S.movePicker ~= nil) Theme.field(width, height) @@ -799,26 +816,29 @@ function App.draw() -- the window is too narrow for both on one, instead of the buttons and the -- identity painting through each other (#715). The taller bar simply -- costs the content column height, which scrolls. - local titleTwoRow = titleNeedsTwoRows(width) + local titleTwoRow = titleNeedsTwoRows(sw) local titleH = (titleTwoRow and 104 or 64) * s local tabH = 66 * s local statusH = 38 * s - Theme.versionRail(0, 0, width, railH) - drawTitleBar(0, railH, width, titleH, titleTwoRow) - drawTabRail(0, railH + titleH, width, tabH) + Theme.versionRail(ox, oy, sw, railH) + drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow) + drawTabRail(ox, oy + railH + titleH, sw, tabH) - local contentY = railH + titleH + tabH - local contentH = height - contentY - statusH + local contentY = oy + railH + titleH + tabH + local contentH = sh - railH - titleH - tabH - statusH local panel = PANELS[S.tab] if panel then - panel.draw(S, Kit, 22 * s, contentY + 20 * s, - width - 44 * s, contentH - 38 * s) + panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s, + sw - 44 * s, contentH - 38 * s) end - drawStatusBar(0, height - statusH, width, statusH) + drawStatusBar(ox, oy + sh - statusH, sw, statusH) Kit.blockClicks = false + -- Scrim still covers the full window (including unsafe bands); the card + -- itself is centred in the safe rect so search fields clear the notch. SpeciesPicker.draw(S, Kit, width, height) + MovePicker.draw(S, Kit, width, height) ItemPicker.draw(S, Kit, width, height) Kit.endFrame() PadInput.draw() @@ -841,6 +861,15 @@ function App.keypressed(key) return end end + if S.movePicker then + if key == "return" or key == "kpenter" then + MovePicker.commitFirst(S, Kit) + return + elseif key == "escape" then + Ops.closeMovePicker(S, Kit) + return + end + end if S.speciesPicker then if key == "return" or key == "kpenter" then SpeciesPicker.commitFirst(S, Kit) diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 2197d7a4..7c1fe851 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -11,7 +11,8 @@ -- Hit testing is a plain rect with no z-order, so panels must draw -- overlapping controls in dispatch order and every target is >= 26px tall -- (rule 6 of the design spec) -- that sizing is the whole accessibility story --- here. +-- here. Kit.tapMin() is the px floor after scale (a short handheld sits on +-- the 0.9 scale floor, so bare `26 * s` would slip under 26). local Theme = require("Theme") local PAL = Theme.PAL @@ -25,6 +26,10 @@ Kit.time = 0 Kit.fonts = {} Kit.scale = 1 +function Kit.tapMin() + return math.max(26, math.floor(30 * (Kit.scale or 1))) +end + local G = love and love.graphics or nil local edits = {} -- queued textinput / backspace since the last frame local kbField = nil -- id of the field the OS soft keyboard is raised for diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index c5e2a33d..fe26cec9 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -329,6 +329,31 @@ function Ops.speciesSearch(S, query) for _, id in ipairs(S.cat.species) do if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end end + -- Rank hits so a typed prefix ("pika") puts PIKACHU above mid-string + -- noise; empty query keeps the catalog's A-Z order. + if query and tostring(query) ~= "" then + local q = tostring(query):lower() + local function rank(id) + local idLower = id:lower() + local def = S.data.pokemon[id] + local nameLower = def and def.name and tostring(def.name):lower() or "" + if idLower == q or nameLower == q then return 0 end + if idLower:sub(1, #q) == q + or (nameLower ~= "" and nameLower:sub(1, #q) == q) then + return 1 + end + if idLower:find(q, 1, true) + or (nameLower ~= "" and nameLower:find(q, 1, true)) then + return 2 + end + return 3 -- dex-number hit + end + table.sort(out, function(a, b) + local ra, rb = rank(a), rank(b) + if ra ~= rb then return ra < rb end + return a < b + end) + end return out end @@ -429,6 +454,8 @@ function Ops.setDv(S, mon, key, value) return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp)) end +-- Kept for tests and any keyboard path; the inspector opens the searchable +-- picker instead of walking the catalog one tap at a time. function Ops.cycleMove(S, mon, slot) if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end local moves = S.cat.moves @@ -441,14 +468,117 @@ function Ops.cycleMove(S, mon, slot) end for step = 1, #moves do local nextId = moves[((idx + step - 1) % #moves) + 1] - if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then - MonOps.setMove(S.data, mon, slot, nextId) - return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId)) + if Ops.moveUsable(S, nextId) then + return Ops.setMove(S, mon, slot, nextId) end end return false end +-- A move record is usable when it is a real table with a numeric PP -- the +-- same floor Catalog already uses to keep provenance scalars out of the list. +function Ops.moveUsable(S, id) + local def = id and S.data and S.data.moves and S.data.moves[id] + return type(def) == "table" and type(def.pp) == "number" +end + +-- Search predicate behind the move picker's field: id, display name, and type +-- substring-match case-insensitively; power / accuracy match as whole numbers +-- (same plain-text / no-pattern rule as Ops.speciesMatches). +function Ops.moveMatches(S, id, query) + if not query or query == "" then return true end + local q = tostring(query):lower() + if id:lower():find(q, 1, true) then return true end + local def = S.data.moves[id] + if type(def) ~= "table" then return false end + local name = def.name + if name and tostring(name):lower():find(q, 1, true) then return true end + local typ = def.type + if typ and tostring(typ):lower():find(q, 1, true) then return true end + local power = tonumber(def.power) + if power ~= nil and q == tostring(power) then return true end + local accuracy = tonumber(def.accuracy) + return accuracy ~= nil and q == tostring(accuracy) +end + +function Ops.moveSearch(S, query) + local out = {} + for _, id in ipairs(S.cat.moves or {}) do + if Ops.moveMatches(S, id, query) then out[#out + 1] = id end + end + -- Rank hits so a typed prefix ("sur") puts SURF above mid-string noise + -- like ACUPRESSURE / FISSURE; empty query keeps the catalog's A-Z order. + if query and tostring(query) ~= "" then + local q = tostring(query):lower() + local function rank(id) + local idLower = id:lower() + local def = S.data.moves[id] + local nameLower = (type(def) == "table" and def.name) + and tostring(def.name):lower() or "" + if idLower == q or nameLower == q then return 0 end + if idLower:sub(1, #q) == q + or (nameLower ~= "" and nameLower:sub(1, #q) == q) then + return 1 + end + if idLower:find(q, 1, true) + or (nameLower ~= "" and nameLower:find(q, 1, true)) then + return 2 + end + return 3 -- type / power / accuracy hit + end + table.sort(out, function(a, b) + local ra, rb = rank(a), rank(b) + if ra ~= rb then return ra < rb end + return a < b + end) + end + return out +end + +-- One funnel for assigning a move (picker commit and cycleMove). Refuses +-- unknown / scalar ids before MonOps asserts, and speaks in the status bar. +function Ops.setMove(S, mon, slot, id) + if not mon then return false end + slot = math.floor(tonumber(slot) or 0) + if slot < 1 or slot > 4 then return false end + local current = mon.moves and mon.moves[slot] and mon.moves[slot].id + if id == current then + return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id))) + end + if not Ops.moveUsable(S, id) then + return Ops.say(S, ("%s is not a usable move, cannot assign it") + :format(tostring(id))) + end + local ok, err = pcall(MonOps.setMove, S.data, mon, slot, id) + if not ok then + return Ops.say(S, ("Could not set move %d: %s"):format(slot, tostring(err))) + end + return Ops.mark(S, ("Move %d set to %s"):format(slot, id)) +end + +-- Modal door for the move picker. `slot` is which of the four move rows the +-- inspector opened; the picker writes back through Ops.setMove on commit. +function Ops.openMovePicker(S, Kit, slot) + if not S.editingMon then + return Ops.say(S, "Pick a slot first, then choose a move") + end + slot = math.floor(tonumber(slot) or 0) + if slot < 1 or slot > 4 then + return Ops.say(S, "Move slots are 1 through 4") + end + if not (S.cat and S.cat.moves and #S.cat.moves > 0) then + return Ops.say(S, "No moves in the catalog") + end + S.movePicker = { query = "", offset = 0, opened = true, slot = slot } + if Kit then Kit.focus = "move-picker" end + return true +end + +function Ops.closeMovePicker(S, Kit) + S.movePicker = nil + if Kit and Kit.blur then Kit.blur() end +end + function Ops.clearMove(S, mon, slot) if not (mon and mon.moves and mon.moves[slot]) then return Ops.say(S, ("Move slot %d is already empty"):format(slot)) diff --git a/tools/save-editor/PickerChrome.lua b/tools/save-editor/PickerChrome.lua new file mode 100644 index 00000000..130f3a54 --- /dev/null +++ b/tools/save-editor/PickerChrome.lua @@ -0,0 +1,83 @@ +-- Shared modal-picker frame for Species / Move / Item pickers. +-- +-- Desktop keeps a centred card capped at 520x560 logical px. Phones and +-- RGxxx handhelds (RG34XXSP 720x480, RG35XX 640x480, Switch 1280x720, tall +-- portrait Androids) need the card to nearly fill SafeArea.rect() so the +-- search field, list, and pager stay usable instead of collapsing under a +-- fixed 32px margin into a short landscape window (#917 / #715). + +local SafeArea = require("src.core.SafeArea") + +local PickerChrome = {} + +-- Minimum tap target (design rule 6). Scale can sit on the 0.9 floor on a +-- short handheld, so a bare `26 * s` would dip under 26px -- clamp in px. +function PickerChrome.tapMin(Kit) + local s = (Kit and Kit.scale) or 1 + return math.max(26, math.floor(30 * s)) +end + +-- Usable card rect inside the platform safe area. +-- Returns x, y, w, h, pad. +function PickerChrome.card(Kit, windowW, windowH) + local s = (Kit and Kit.scale) or 1 + windowW = math.max(1, tonumber(windowW) or 1) + windowH = math.max(1, tonumber(windowH) or 1) + local sox, soy, ssw, ssh = SafeArea.rect() + sox = math.max(0, tonumber(sox) or 0) + soy = math.max(0, tonumber(soy) or 0) + ssw = math.max(1, tonumber(ssw) or windowW) + ssh = math.max(1, tonumber(ssh) or windowH) + + -- Shrink the gutter on short / narrow safe areas so the card keeps room + -- for caption + search + at least one list row + pager. + local gutter = math.floor(16 * s) + local minSide = math.min(ssw, ssh) + if minSide < 560 * s then + gutter = math.max(4, math.floor(minSide * 0.03)) + end + + local maxW = math.floor(520 * s) + local maxH = math.floor(560 * s) + local w = math.min(ssw - 2 * gutter, maxW) + local h = math.min(ssh - 2 * gutter, maxH) + -- Short landscapes (720x480 class): fill the safe height instead of + -- leaving letterbox bands inside an already-short rect. + if ssh <= maxH + 2 * gutter then + h = ssh - 2 * gutter + end + if ssw <= maxW + 2 * gutter then + w = ssw - 2 * gutter + end + w = math.max(1, w) + h = math.max(1, h) + + local x = sox + (ssw - w) / 2 + local y = soy + (ssh - h) / 2 + local pad = math.max(8, math.floor(math.min(18 * s, w * 0.04, h * 0.04))) + return x, y, w, h, pad +end + +-- List-body metrics once caption / field / optional extra chrome are placed. +-- `contentTop` is the y just below that chrome; returns listH, rowH, rowGap, +-- pagerH sized so taps stay >= tapMin and listH never goes negative. +function PickerChrome.listMetrics(Kit, cardY, cardH, pad, contentTop) + local s = (Kit and Kit.scale) or 1 + local tap = PickerChrome.tapMin(Kit) + local pagerH = math.max(tap, math.floor(30 * s)) + local rowH = math.max(tap, math.floor(40 * s)) + local rowGap = math.max(4, math.floor(6 * s)) + local listBottom = cardY + cardH - pad - pagerH - math.max(6, math.floor(10 * s)) + local listH = math.max(0, listBottom - contentTop) + return listH, rowH, rowGap, pagerH +end + +function PickerChrome.fieldH(Kit) + return math.max(PickerChrome.tapMin(Kit), math.floor(34 * ((Kit and Kit.scale) or 1))) +end + +function PickerChrome.closeSize(Kit) + return math.max(PickerChrome.tapMin(Kit), math.floor(30 * ((Kit and Kit.scale) or 1))) +end + +return PickerChrome diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 534d5411..de9acaa3 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -53,6 +53,11 @@ function State.new() -- without a z-order (#541). speciesPicker = nil, + -- move picker overlay: nil when closed, otherwise + -- { query, offset, slot = 1..4 }. Same modal contract as speciesPicker; + -- the inspector opens it instead of cycling the catalog one tap at a time. + movePicker = nil, + -- item picker overlay: nil when closed, otherwise -- { query, offset, dest = "bag"|"pc" }. Same modal contract as -- speciesPicker above -- adding an item is now a full-screen picker diff --git a/tools/save-editor/panels/ItemPicker.lua b/tools/save-editor/panels/ItemPicker.lua index 0bb91dc8..e3ec9351 100644 --- a/tools/save-editor/panels/ItemPicker.lua +++ b/tools/save-editor/panels/ItemPicker.lua @@ -13,6 +13,7 @@ local Theme = require("Theme") local Ops = require("Ops") +local PickerChrome = require("PickerChrome") local PAL = Theme.PAL local Picker = {} @@ -57,38 +58,39 @@ function Picker.draw(S, Kit, width, height) Kit.blockClicks = true end - -- the scrim doubles as the "tap outside to cancel" target + -- the scrim doubles as the "tap outside to cancel" target; it covers the + -- full window so unsafe bands (notch / home indicator) stay dimmed too Theme.col(PAL.bg, 0.82) love.graphics.rectangle("fill", 0, 0, width, height) - local w = math.min(width - 32 * s, 520 * s) - local h = math.min(height - 32 * s, 560 * s) - local x = (width - w) / 2 - local y = (height - h) / 2 + -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a + -- usable list (#917 / #715). + local x, y, w, h, pad = PickerChrome.card(Kit, width, height) if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then Ops.closeItemPicker(S, Kit) return end Kit.card(x, y, w, h) - local pad = 18 * s local cx, cy = x + pad, y + pad local inner = w - 2 * pad - Kit.caption(cx, cy, "ADD AN ITEM") - local closeW = 30 * s - if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", + local closeW = PickerChrome.closeSize(Kit) + local captionH = Kit.textHeight("caption") + local headH = math.max(captionH, closeW) + Kit.caption(cx, cy + (headH - captionH) / 2, "ADD AN ITEM") + if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", { font = "small" }) then Ops.closeItemPicker(S, Kit) return end - cy = cy + Kit.textHeight("caption") + 10 * s + cy = cy + headH + 10 * s -- Destination toggle. Which list an item lands in is the only real choice -- here, so it is a pair of chips at the top rather than two buttons at the -- bottom that each mean "commit, and also pick a destination". local half = (inner - 8 * s) / 2 - local destH = 30 * s + local destH = math.max(PickerChrome.tapMin(Kit), math.floor(30 * s)) if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then p.dest = "bag" end @@ -98,16 +100,13 @@ function Picker.draw(S, Kit, width, height) end cy = cy + destH + 10 * s - local fieldH = 34 * s + local fieldH = PickerChrome.fieldH(Kit) p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, "type an item id") cy = cy + fieldH + 10 * s local hits = Picker.results(S) - local rowH = 36 * s - local rowGap = 6 * s - local pagerH = 30 * s - local listH = (y + h - pad - pagerH - 10 * s) - cy + local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 35757324..4b4df13a 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -213,7 +213,7 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) local ry = rowY + (slot - 1) * (rowH + rowGap) Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6) local mv = mon.moves and mon.moves[slot] - local clear = 24 * s + local clear = Kit.tapMin() local clearX = rightX + colW - 10 * s - clear local ppText = mv and ("PP %d"):format(mv.pp or 0) or "" local ppW = Kit.textWidth("tiny", ppText) @@ -226,9 +226,9 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) mv and PAL.text or PAL.faint) Kit.textRight("tiny", ppText, clearX - 10 * s, ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) - -- the row body cycles, the x empties: two targets, no modal picker + -- the row body opens the searchable picker; the x empties the slot if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then - Ops.cycleMove(S, mon, slot) + Ops.openMovePicker(S, Kit, slot) end if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x", { kind = "danger", font = "tiny", radius = 6 * s }) then @@ -264,10 +264,10 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- text keeps room. local narrow = inner < 470 * s local sprite = (narrow and 64 or 96) * s - local rowH = 30 * s + local rowH = math.max(Kit.tapMin(), 30 * s) local rowGap = 8 * s local cellH = 52 * s - local actH = 34 * s + local actH = math.max(Kit.tapMin(), 34 * s) local hw = inner - sprite - 18 * s local levelInHeader = hw >= levelRowWidth(Kit, mon) @@ -449,7 +449,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local movesY = rowY + colRowsH + 14 * s Kit.caption(cx, movesY, "MOVES") - Kit.textRight("tiny", "click a slot to cycle", cx + inner, movesY, PAL.caption) + Kit.textRight("tiny", "click a slot to search", cx + inner, movesY, PAL.caption) local mRowY = movesY + capH + 10 * s drawMoveRows(S, Kit, mon, cx, mRowY, inner, rowH, rowGap) @@ -472,7 +472,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0), cx + colW, colY, PAL.caption) Kit.caption(rightX, colY, "MOVES") - Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption) + Kit.textRight("tiny", "click a slot to search", rightX + colW, colY, PAL.caption) local rowY = colY + capH + 10 * s drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) diff --git a/tools/save-editor/panels/MovePicker.lua b/tools/save-editor/panels/MovePicker.lua new file mode 100644 index 00000000..f229dc54 --- /dev/null +++ b/tools/save-editor/panels/MovePicker.lua @@ -0,0 +1,136 @@ +-- Type-to-search move picker. The inspector used to change moves by cycling +-- the catalog one tap at a time -- 165 taps to walk Gen1 -- which is the same +-- class of friction the species arrows had before #541. This is the +-- replacement: one modal list, filtered as you type, committing through +-- Ops.setMove so an unusable / scalar id refuses instead of asserting. +-- +-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises +-- Kit.blockClicks over the chrome and the panel while this is open and lowers +-- it only for this overlay; nothing underneath can take the same tap. + +local Theme = require("Theme") +local Ops = require("Ops") +local PickerChrome = require("PickerChrome") +local PAL = Theme.PAL + +local Picker = {} + +local FIELD_ID = "move-picker" + +function Picker.results(S) + local p = S.movePicker + return Ops.moveSearch(S, p and p.query or "") +end + +local function commit(S, id) + local p = S.movePicker + if not p then return false end + return Ops.setMove(S, S.editingMon, p.slot, id) +end + +-- Enter commits the top match, which is the whole point of a search field. +function Picker.commitFirst(S, Kit) + local hits = Picker.results(S) + if not hits[1] then return Ops.say(S, "No move matches that") end + local ok = commit(S, hits[1]) + if ok then Ops.closeMovePicker(S, Kit) end + return ok +end + +function Picker.draw(S, Kit, width, height) + local p = S.movePicker + if not p then return end + local s = Kit.scale + + -- The click that opened the picker is still the frame's click: the + -- inspector dispatches earlier in App.draw than this overlay does, so + -- without swallowing it the scrim below would read it as a tap outside and + -- shut the picker in the same frame it went up. App re-raises the shield + -- at the top of the next frame, so leaving it up here is safe. + if p.opened then + p.opened = nil + Kit.blockClicks = true + end + + -- the scrim doubles as the "tap outside to cancel" target; it covers the + -- full window so unsafe bands (notch / home indicator) stay dimmed too + Theme.col(PAL.bgBot, 0.72) + love.graphics.rectangle("fill", 0, 0, width, height) + + -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a + -- usable list (#917 / #715). + local x, y, w, h, pad = PickerChrome.card(Kit, width, height) + if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then + Ops.closeMovePicker(S, Kit) + return + end + + Kit.card(x, y, w, h) + local cx, cy = x + pad, y + pad + local inner = w - 2 * pad + + local closeW = PickerChrome.closeSize(Kit) + local captionH = Kit.textHeight("caption") + local headH = math.max(captionH, closeW) + Kit.caption(cx, cy + (headH - captionH) / 2, ("CHOOSE MOVE %d"):format(p.slot or 1)) + if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", + { font = "small", radius = 7 * s }) then + Ops.closeMovePicker(S, Kit) + return + end + cy = cy + headH + 10 * s + + local fieldH = PickerChrome.fieldH(Kit) + p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, + "type a name, an id, or a type") + cy = cy + fieldH + 10 * s + + local hits = Picker.results(S) + local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) + local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) + p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) + -- wheel / touch drag scroll the modal list too; the shield is already + -- lowered for this layer, so Kit.scroll works here and only here (#715) + p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) + + local mon = S.editingMon + local currentId = mon and mon.moves and mon.moves[p.slot] + and mon.moves[p.slot].id + + if #hits == 0 then + Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") + else + Kit.pushClip(cx, cy, inner, listH) + for i = 1, perPage do + local id = hits[p.offset + i] + if not id then break end + local ry = cy + (i - 1) * (rowH + rowGap) + local def = S.data.moves[id] + local usable = Ops.moveUsable(S, id) + local current = currentId == id + if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then + if commit(S, id) then + Ops.closeMovePicker(S, Kit) + Kit.popClip() + return + end + end + local typ = usable and tostring(def and def.type or "?") or "no data" + local pp = usable and ("PP %d"):format(tonumber(def and def.pp) or 0) or "" + local tail = pp ~= "" and (typ .. " " .. pp) or typ + local tailW = Kit.textWidth("tiny", tail) + Kit.text("monoRow", + Kit.ellipsize("monoRow", id, inner - tailW - 28 * s), + cx + 12 * s, ry + (rowH - Kit.textHeight("monoRow")) / 2, + usable and PAL.text or PAL.faint) + Kit.textRight("tiny", tail, cx + inner - 10 * s, + ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) + end + Kit.popClip() + Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) + end + + p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) +end + +return Picker diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua index e7c2710d..8af5b377 100644 --- a/tools/save-editor/panels/SpeciesPicker.lua +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -12,6 +12,7 @@ local Theme = require("Theme") local Ops = require("Ops") local MonEditor = require("MonEditor") +local PickerChrome = require("PickerChrome") local PAL = Theme.PAL local Picker = {} @@ -59,44 +60,42 @@ function Picker.draw(S, Kit, width, height) Kit.blockClicks = true end - -- the scrim doubles as the "tap outside to cancel" target + -- the scrim doubles as the "tap outside to cancel" target; it covers the + -- full window so unsafe bands (notch / home indicator) stay dimmed too Theme.col(PAL.bgBot, 0.72) love.graphics.rectangle("fill", 0, 0, width, height) - local w = math.min(width - 32 * s, 520 * s) - local h = math.min(height - 32 * s, 560 * s) - local x = (width - w) / 2 - local y = (height - h) / 2 + -- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a + -- usable list (#917 / #715). + local x, y, w, h, pad = PickerChrome.card(Kit, width, height) if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then Ops.closeSpeciesPicker(S, Kit) return end Kit.card(x, y, w, h) - local pad = 18 * s local cx, cy = x + pad, y + pad local inner = w - 2 * pad - Kit.caption(cx, cy, p.mode == "box-add" + local closeW = PickerChrome.closeSize(Kit) + local captionH = Kit.textHeight("caption") + local headH = math.max(captionH, closeW) + Kit.caption(cx, cy + (headH - captionH) / 2, p.mode == "box-add" and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES") - local closeW = 30 * s - if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x", + if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", { font = "small", radius = 7 * s }) then Ops.closeSpeciesPicker(S, Kit) return end - cy = cy + Kit.textHeight("caption") + 10 * s + cy = cy + headH + 10 * s - local fieldH = 34 * s + local fieldH = PickerChrome.fieldH(Kit) p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, "type a name, an id, or a dex number") cy = cy + fieldH + 10 * s local hits = Picker.results(S) - local rowH = 40 * s - local rowGap = 6 * s - local pagerH = 30 * s - local listH = (y + h - pad - pagerH - 10 * s) - cy + local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already