Compare commits

..

27 Commits

Author SHA1 Message Date
bryanthaboi 08121faa3d Merge pull request #1806 from bryanthaboi/dev
bugs and shader fix
2026-08-25 09:33:02 -04:00
github-actions bc7b96c963 chore(ios): update app-repo.json [skip ci] 2026-08-24 13:18:39 -04:00
bryanthaboi aea38240b2 Merge pull request #1780 from bryanthaboi/dev
gender name string
2026-08-24 13:08:31 -04:00
github-actions e7d898625d chore(ios): update app-repo.json [skip ci] 2026-08-24 12:48:34 -04:00
bryanthaboi 5ee4a3cd35 Merge pull request #1778 from bryanthaboi/dev
some fixes
2026-08-24 12:38:35 -04:00
github-actions 72ce1fe33e chore(ios): update app-repo.json [skip ci] 2026-08-24 11:46:51 -04:00
bryanthaboi 493fca7297 Merge pull request #1776 from bryanthaboi/dev
chris1.1 and bonus additions
2026-08-24 11:36:23 -04:00
github-actions cec264ad81 chore(ios): update app-repo.json [skip ci] 2026-08-24 10:23:37 -04:00
bryanthaboi 92fd4221a0 Merge pull request #1772 from bryanthaboi/dev
SPIDER CAR 2 delivery
2026-08-24 10:12:06 -04:00
github-actions 7ae469603c chore(ios): update app-repo.json [skip ci] 2026-08-22 19:22:00 -04:00
bryanthaboi 77e189bc2e Merge pull request #1726 from bryanthaboi/dev 2026-08-22 19:13:03 -04:00
github-actions 6e7b9c0848 chore(ios): update app-repo.json [skip ci] 2026-08-21 22:57:25 -04:00
bryanthaboi 116a6ba450 Merge pull request #1679 from bryanthaboi/dev 2026-08-21 22:48:58 -04:00
github-actions ea2cd63395 chore(ios): update app-repo.json [skip ci] 2026-08-21 17:19:30 -04:00
bryanthaboi 70d7b6383e Merge pull request #1659 from bryanthaboi/dev
skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again
2026-08-21 17:10:00 -04:00
github-actions 63b31d234c chore(ios): update app-repo.json [skip ci] 2026-08-21 15:11:23 -04:00
bryanthaboi 44f4680b24 Merge pull request #1652 from bryanthaboi/dev
skin studio fix
2026-08-21 15:01:51 -04:00
github-actions 464fb47756 chore(ios): update app-repo.json [skip ci] 2026-08-21 14:21:21 -04:00
bryanthaboi fe3746fd1a Merge pull request #1650 from bryanthaboi/dev
big freaking junk off
2026-08-21 14:12:38 -04:00
github-actions 738f7317d7 chore(ios): update app-repo.json [skip ci] 2026-08-20 20:51:52 -04:00
bryanthaboi 5e514335c1 Merge pull request #1632 from bryanthaboi/dev
saves and saves
2026-08-20 20:40:07 -04:00
github-actions f63707c45b chore(ios): update app-repo.json [skip ci] 2026-08-20 18:46:51 -04:00
bryanthaboi 7756fdc3cb Merge pull request #1622 from bryanthaboi/dev
fix android because goia broke it
2026-08-20 18:37:33 -04:00
github-actions 5cbde96177 chore(ios): update app-repo.json [skip ci] 2026-08-20 17:44:02 -04:00
bryanthaboi 03c5a1eddf Merge pull request #1617 from bryanthaboi/dev
some major buggies and some minor huggies
2026-08-20 17:34:16 -04:00
github-actions 1e6613e2de chore(ios): update app-repo.json [skip ci] 2026-08-20 13:16:04 -04:00
bryanthaboi debfaf28e6 Merge pull request #1607 from bryanthaboi/dev
sometimes adrian be reading these PR titles
2026-08-20 13:05:45 -04:00
63 changed files with 537 additions and 2975 deletions
+1 -1
View File
@@ -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
[![Watch the latest update video](https://img.youtube.com/vi/0hgtLDNGpdo/maxresdefault.jpg)](https://youtu.be/0hgtLDNGpdo) [![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
This project does not include a ROM, emulate the Game Boy, transpile assembly, 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, or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
-270
View File
@@ -1,270 +0,0 @@
-- 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 },
},
},
},
}
-36
View File
@@ -505,42 +505,6 @@ Menu choices and moves use the same engine methods as the native controls;
their mutable logic. Tutorial, link, forced, stale, and covered battle states their mutable logic. Tutorial, link, forced, stale, and covered battle states
refuse core intents. Use `mod.input` for ordinary text advance. 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 = <BattleState>, name = <display name>, game = <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 ## Party-full custody at a catch
When a capture lands on a full party, the cart deposits the mon in storage When a capture lands on a full party, the cart deposits the mon in storage
-110
View File
@@ -1,110 +0,0 @@
# 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 = <BattleState>, name = <display name>, game = <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.
+9 -19
View File
@@ -384,14 +384,12 @@ function bootGame(version, cartId)
Game = require("src.core.Game2").new() Game = require("src.core.Game2").new()
Game:load() Game:load()
else else
-- Gen1 Game is a module singleton. Always re-require after in-process -- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
-- EXIT GAME so a prior session cannot leave a table whose rawget(load) -- place; if a prior teardown left load missing, rebuild from source.
-- 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") local gameMod = require("src.core.Game")
if type(rawget(gameMod, "load")) ~= "function" then if type(gameMod.load) ~= "function" then
error("src.core.Game missing load after reload") package.loaded["src.core.Game"] = nil
gameMod = require("src.core.Game")
end end
Game = gameMod Game = gameMod
Game:load() Game:load()
@@ -1127,23 +1125,15 @@ function love.quit()
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- 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 -- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below. -- 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() local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted return Game and not Importer and not quitToLauncher and not scripted
and (inProcessReturn or not launchedIntoGame) and (isAndroid or not launchedIntoGame)
end) end)
if wouldReturnToLauncher then if wouldReturnToLauncher then
if inProcessReturn then if isAndroid then
returnToLauncher() returnToLauncher()
return true -- abort this quit; stay in the same LOVE run return true -- abort this quit; the restart lands back in the launcher
end end
quitToLauncher = true quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- Tell the fresh boot to ignore any boot-straight-into-a-game option this
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
#endif #endif
} }
bool System::pickFile(const char *kind, const char *destination) const bool System::pickFile(const char *kind) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
const char *dest = "picked_rom.gb"; const char *dest = "picked_rom.gb";
@@ -193,8 +193,7 @@ bool System::pickFile(const char *kind, const char *destination) const
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav"; dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0) else if (strcmp(kind, "required_import") == 0)
dest = (destination != nullptr && destination[0] != '\0') dest = "picked_required_import.bin";
? destination : "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0) else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb"; dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them // Unknown kinds used to fall through to the ROM destination. Refuse them
@@ -116,7 +116,7 @@ public:
* "required_import" -> picked_required_import.bin. * "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const; virtual bool pickFile(const char *kind = nullptr) const;
virtual const char *pickFileKinds() const; virtual const char *pickFileKinds() const;
/** /**
@@ -101,8 +101,7 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
const char *kind = luaL_optstring(L, 1, nullptr); const char *kind = luaL_optstring(L, 1, nullptr);
const char *destination = luaL_optstring(L, 2, nullptr); luax_pushboolean(L, instance()->pickFile(kind));
luax_pushboolean(L, instance()->pickFile(kind, destination));
return 1; return 1;
} }
@@ -39,7 +39,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.security.MessageDigest;
import android.Manifest; import android.Manifest;
import android.app.AlarmManager; import android.app.AlarmManager;
@@ -112,8 +111,6 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher // basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442). // instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag"; 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 // Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS // consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER // GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
@@ -579,21 +576,6 @@ public class GameActivity extends SDLActivity {
* picked_rom.gb, picked_mod.zip, picked_save.sav, or * picked_rom.gb, picked_mod.zip, picked_save.sav, or
* picked_required_import.bin) * 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. */ /** Legacy single-argument entry; resolves the save dir itself. */
@Keep @Keep
public static boolean showFilePicker(String destFilename) { public static boolean showFilePicker(String destFilename) {
@@ -611,30 +593,14 @@ public class GameActivity extends SDLActivity {
// onActivityResult copies the pick there, not into a recomputed // onActivityResult copies the pick there, not into a recomputed
// (possibly different-volume) root (#604, #839). // (possibly different-volume) root (#604, #839).
self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
// Basename destinations keep the historical ROM/mod/save staging path. // Reject path separators so a hostile JNI caller cannot escape the
// A nested destination is accepted only for an engine-generated mod // save identity directory.
// baseroms path, then canonicalized beneath LOVE's mounted save root. if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
String normalizedDest = destFilename.replace('\\', '/'); Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
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; return false;
} }
self.pendingPickFilename = normalizedDest; self.pendingPickFilename = destFilename;
if (android.os.Build.VERSION.SDK_INT >= 21) { if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
@@ -1311,7 +1277,13 @@ public class GameActivity extends SDLActivity {
/** Drops a small flag file in the save identity for Lua to consume on focus. */ /** Drops a small flag file in the save identity for Lua to consume on focus. */
private void writeSaveDirFlag(String name, String body) { private void writeSaveDirFlag(String name, String body) {
writeFlagFile(saveIdentityDir(), name, 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());
}
} }
/** /**
@@ -1534,26 +1506,9 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "could not create " + destDir); Log.d("GameActivity", "could not create " + destDir);
return; return;
} }
final String destName = pendingPickFilename != null String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME; ? pendingPickFilename : PICKED_ROM_FILENAME;
final boolean directRequired = isDirectRequiredDestination(destName); File destFile = new File(destDir, 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 // ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
// some OEM shells (ColorOS) offer third-party file managers in a // some OEM shells (ColorOS) offer third-party file managers in a
@@ -1579,110 +1534,15 @@ public class GameActivity extends SDLActivity {
writeSaveDirFlag(PICK_ERROR_FILENAME, destName); writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return; return;
} }
final InputStream pickedSource = source; if (!copyAssetFile(source, destFile.getPath())) {
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); 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(); destFile.delete();
writeSaveDirFlag(PICK_ERROR_FILENAME, destName); 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. * Copies a given file from the assets folder to the destination.
* *
@@ -1705,12 +1565,13 @@ public class GameActivity extends SDLActivity {
assert (source != null && destination != null); assert (source != null && destination != null);
try { try {
byte[] buf = new byte[1024 * 1024]; byte[] buf = new byte[1024];
while ((chunk_read = source.read(buf)) != -1) { chunk_read = source.read(buf);
if (chunk_read == 0) continue; do {
destination.write(buf, 0, chunk_read); destination.write(buf, 0, chunk_read);
bytes_written += chunk_read; bytes_written += chunk_read;
} chunk_read = source.read(buf);
} while (chunk_read != -1);
} catch (IOException e) { } catch (IOException e) {
Log.d("GameActivity", "Copying failed:" + e.getMessage()); Log.d("GameActivity", "Copying failed:" + e.getMessage());
} }
+91
View File
@@ -12,6 +12,97 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.24",
"date": "2026-08-24",
"size": 14060528,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.23",
"date": "2026-08-24",
"size": 14060437,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.22",
"date": "2026-08-24",
"size": 14060365,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
},
{
"version": "0.2.21",
"date": "2026-08-24",
"size": 14058979,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
},
{
"version": "0.2.20",
"date": "2026-08-22",
"size": 13812226,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
},
{
"version": "0.2.19",
"date": "2026-08-22",
"size": 13778435,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
},
{
"version": "0.2.18",
"date": "2026-08-21",
"size": 13772120,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.17",
"date": "2026-08-21",
"size": 13771903,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.16",
"date": "2026-08-21",
"size": 13769300,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
},
{
"version": "0.2.15",
"date": "2026-08-21",
"size": 13753007,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.2.14",
"date": "2026-08-20",
"size": 13749372,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.13",
"date": "2026-08-20",
"size": 13749377,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.12",
"date": "2026-08-20",
"size": 13737259,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Cant sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
},
{ {
"version": "0.2.11", "version": "0.2.11",
"date": "2026-08-20", "date": "2026-08-20",
+4 -101
View File
@@ -13,7 +13,6 @@
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import CryptoKit
@objc(GRPickerBridge) @objc(GRPickerBridge)
public final class GRPickerBridge: NSObject { public final class GRPickerBridge: NSObject {
@@ -191,17 +190,9 @@ public final class GRPickerBridge: NSObject {
@objc(presentPickerWithKind:saveDir:) @objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?, public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool { saveDir: UnsafePointer<CChar>?) -> Bool {
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
}
@objc(presentPickerWithKind:saveDir:destination:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?) -> Bool {
let kindStr = kind.map { String(cString: $0) } ?? "rom" let kindStr = kind.map { String(cString: $0) } ?? "rom"
guard let dir = resolvedSaveDir(saveDir) else { return false } guard let dir = resolvedSaveDir(saveDir) else { return false }
let requestedDestination = destination.map { String(cString: $0) }
let destName: String let destName: String
var types: [UTType] = [] var types: [UTType] = []
switch kindStr { switch kindStr {
@@ -211,13 +202,7 @@ public final class GRPickerBridge: NSObject {
case "sav": case "sav":
destName = "picked_save.sav" destName = "picked_save.sav"
case "required_import": case "required_import":
if let requestedDestination, destName = "picked_required_import.bin"
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 -- // A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this // 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 // was added for. Its own filename on purpose: an N64 ROM landing on
@@ -249,18 +234,12 @@ public final class GRPickerBridge: NSObject {
types.append(.data) types.append(.data)
if !types.contains(.item) { types.append(.item) } if !types.contains(.item) { types.append(.item) }
let directRequired = kindStr == "required_import"
&& destName != "picked_required_import.bin"
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: !directRequired) asCopy: true)
picker.allowsMultipleSelection = false picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in let delegate = PickerDelegate { urls in
guard let src = urls.first else { return } guard let src = urls.first else { return }
if directRequired { copyItem(at: src, into: dir, named: destName)
copyRequiredItemAsync(at: src, into: dir, relative: destName)
} else {
copyItem(at: src, into: dir, named: destName)
}
} }
return present(picker, with: delegate) return present(picker, with: delegate)
} }
@@ -431,82 +410,6 @@ public final class GRPickerBridge: NSObject {
try? fm.moveItem(at: item, to: target) 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) { private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource() let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } } defer { if scoped { src.stopAccessingSecurityScopedResource() } }
@@ -523,7 +426,7 @@ public final class GRPickerBridge: NSObject {
let report = "Could not copy \(src.lastPathComponent): " + let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription error.localizedDescription
try? report.data(using: .utf8)? try? report.data(using: .utf8)?
.write(to: dir.appendingPathComponent("pick_error.flag")) .write(to: dir.appendingPathComponent("pick_error.txt"))
} }
} }
+1 -14
View File
@@ -93,20 +93,7 @@ static int gr_callBridge(lua_State *L, const char *className,
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
const char *kind = luaL_optstring(L, 1, "rom"); const char *kind = luaL_optstring(L, 1, "rom");
const char *destination = luaL_optstring(L, 2, nullptr); return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
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 // love.system.pickFileKinds() -> the comma-separated kinds supported by the
-7
View File
@@ -106,13 +106,6 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
APPLICATION_ID="$TEST_APPLICATION_ID" APPLICATION_ID="$TEST_APPLICATION_ID"
APP_NAME="$APP_NAME (test)" APP_NAME="$APP_NAME (test)"
fi 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 # --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
-1
View File
@@ -114,7 +114,6 @@ fi
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py 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 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 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 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 run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
+7 -53
View File
@@ -4531,7 +4531,8 @@ function BattleState:enemyMonFainted()
-- remaining HP); SET / single-mon / fainted active skip the prompt. -- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex] local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = self:battleStyle() local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self:playerPartyView() local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
@@ -5099,57 +5100,6 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!") return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
end 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, -- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party -- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172). -- AddPartyMon and SendNewMonToBox (#172).
@@ -5230,7 +5180,11 @@ function BattleState:storeCaughtMon()
end) end)
end end
local function askCaughtNickname() local function askCaughtNickname()
self:offerNickname(self.enemy.mon, self.enemy.name) local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
return self:askNicknameUI(caught, enemyName)
end)
end end
if Party.add(game.save.party, self.enemy.mon) then if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname() askCaughtNickname()
-23
View File
@@ -170,10 +170,6 @@ function Data:seedDefaults(version)
-- writes no header for him -- seed one so he engages on sight and has -- 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). -- his defeat / re-talk lines (same idea as the Cinnabar seed above).
Data.seedFightingDojoKarateMaster(self) 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) -- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps) require("src.world.SsAnneLayout").apply(self.maps)
end end
@@ -205,25 +201,6 @@ function Data:seedFightingDojoKarateMaster()
} }
end 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() function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end if not headers or headers.CinnabarGym then return end
+1 -17
View File
@@ -158,15 +158,7 @@ end
-- and the app dies. There we relaunch through the GameActivity.restartApp -- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent -- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run. -- and kills the process so no native state can leak into the fresh run.
-- iOS is the same class of problem with a sharper edge: love.cpp under -- On every other platform the in-process restart works, so keep it.
-- 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() function HostShell.restart()
if not (love and love.event and love.event.quit) then return end if not (love and love.event and love.event.quit) then return end
@@ -182,14 +174,6 @@ function HostShell.restart()
love.event.quit() love.event.quit()
return return
end 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") local appimage = os.getenv("APPIMAGE")
if not appimage then if not appimage then
+1 -5
View File
@@ -227,11 +227,7 @@ local function metadata(options, context)
end end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion() local version = appVersion()
-- Bug reports always want the stamped engine when we have one; window add("App", version ~= "" and Version.title() or "gen1recomp")
-- 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()) add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end if safeMode then add("Safe mode", "on") end
return { return {
+6 -15
View File
@@ -30,8 +30,6 @@ function SessionLifecycle.endMountedSession(version)
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
if Runtime.reset then Runtime.reset() end if Runtime.reset then Runtime.reset() end
if Assets.installLoader then Assets.installLoader(nil) 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") local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
end end
@@ -66,18 +64,11 @@ end
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the -- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- live game instance so map/GPU holders are gone before endMountedSession. -- 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) function SessionLifecycle.endGameSession(game)
pcall(function() require("src.core.Music").stop() end) pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end) pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].stopMusic) pcall(package.loaded["src.core.ChipAudio"].shutdown)
end end
if package.loaded["src.core.DiscordPresence"] then if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown) pcall(package.loaded["src.core.DiscordPresence"].shutdown)
@@ -96,11 +87,11 @@ function SessionLifecycle.endGameSession(game)
if game and game.reset then if game and game.reset then
pcall(function() game:reset() end) pcall(function() game:reset() end)
end end
-- Gen1 Game is the module singleton. Always drop the cached module after -- Gen1 Game is the module singleton. If teardown left it without load,
-- a session that owned it so the next bootGame require rebuilds a clean -- drop the cached module so the next require rebuilds a clean table
-- table. A type(game.load) check is not enough: a wrong table parked in -- (bootGame also guards this; doing it here keeps Play-again reliable).
-- package.loaded (e.g. with __index) can still look like it has load. if game and type(game.load) ~= "function"
if game and package.loaded["src.core.Game"] == game then and package.loaded["src.core.Game"] == game then
package.loaded["src.core.Game"] = nil package.loaded["src.core.Game"] = nil
end end
+3 -15
View File
@@ -22,22 +22,10 @@ local Version = {
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
} }
-- True for the working-tree placeholder and any stamped "-dev" pre-release. -- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
-- 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) function Version.title(base)
base = base or "gen1recomp" return (base or "gen1recomp")
if Version.isDev() then .. " v" .. Version.engine
return base .. " v" .. Version.engine
end
return base
end end
return Version return Version
-22
View File
@@ -7,28 +7,6 @@ local SHADES = {
{ 0, 0, 0, 1 }, { 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) local function assertDimensions(raw, width, height, bits)
assert(width % 8 == 0 and height % 8 == 0, assert(width % 8 == 0 and height % 8 == 0,
("%dbpp dimensions must be tile-aligned: %dx%d") ("%dbpp dimensions must be tile-aligned: %dx%d")
+2 -10
View File
@@ -1535,14 +1535,6 @@ function RomExtractor:extractYellowTitleArt()
local bg = sheetTiles("TitlePikachuBGGraphics", 64) local bg = sheetTiles("TitlePikachuBGGraphics", 64)
local ob = sheetTiles("TitlePikachuOBGraphics", 12) local ob = sheetTiles("TitlePikachuOBGraphics", 12)
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true) 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) local function tileFor(id)
if id < 0x80 then return logo[id + 1] end if id < 0x80 then return logo[id + 1] end
if id < 0xF0 then return bg[id - 0x80 + 1] end if id < 0xF0 then return bg[id - 0x80 + 1] end
@@ -1631,13 +1623,13 @@ function RomExtractor:extractYellowTitleArt()
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0) local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16) ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
for _, e in ipairs(EYE_LAYOUT) do for _, e in ipairs(EYE_LAYOUT) do
blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4]) blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
end end
overlays[suffix] = overlay overlays[suffix] = overlay
end end
-- open eyes bake into pikachu.png AFTER the blank-face crops -- open eyes bake into pikachu.png AFTER the blank-face crops
for _, e in ipairs(EYE_LAYOUT) do for _, e in ipairs(EYE_LAYOUT) do
blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4]) blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
end end
self:save(pikachu, "title/pikachu.png") self:save(pikachu, "title/pikachu.png")
for suffix, overlay in pairs(overlays) do for suffix, overlay in pairs(overlays) do
+2 -150
View File
@@ -9,9 +9,6 @@ local SafeArea = require("src.core.SafeArea")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = 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 -- 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 -- 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 -- mobile/ios/patch_love_src.py). A build without it must fall back to the
@@ -1585,11 +1582,8 @@ function RomImporter:focus(f)
.. love.filesystem.getSaveDirectory() .. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import" and self.pickerPendingKind == "required_import"
if self.pickerPendingKind == "required_import" if pickError:find("picked_required_import", 1, true)
or pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 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 or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text } self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil self.pickerPendingKind = nil
@@ -1612,22 +1606,6 @@ function RomImporter:focus(f)
end end
return return
end end
-- Current mobile bridge: the native picker has already streamed a raw
-- dependency into mods/<id>/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) local requiredName = findPendingRequiredImport(self)
if requiredName then if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
@@ -2081,122 +2059,6 @@ local function requiredImportNotice(self, modId, importId, text)
} }
end 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<relative destination>\n<md5>\n<byte count>\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) function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId) local manifest = requiredManifest(self, modId)
if not manifest then if not manifest then
@@ -2356,15 +2218,7 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingModId = modId self.pickerPendingModId = modId
self.pickerPendingImportId = importId self.pickerPendingImportId = importId
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
-- Raw imports can go straight to their final private destination on current if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
-- 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.pickerPendingKind = nil
self.pickerPendingModId = nil self.pickerPendingModId = nil
self.pickerPendingImportId = nil self.pickerPendingImportId = nil
@@ -2695,8 +2549,6 @@ function RomImporter:_pollPickedFiles(dt)
return return
end end
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil 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 if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower() local n = name:lower()
+16 -29
View File
@@ -184,14 +184,6 @@ local function adjacentSleepingSnorlax(save, ow)
return nil return nil
end 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 -- Use an item on a target party mon (target may be nil for targetless
-- items). data = generated data tables; battle = BattleState when used -- items). data = generated data tables; battle = BattleState when used
-- mid-battle; ow = the overworld (OverworldState), needed only to check -- mid-battle; ow = the overworld (OverworldState), needed only to check
@@ -288,7 +280,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"All sleeping\nPOKéMON woke up!") } "All sleeping\nPOKéMON woke up!") }
end end
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635) -- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then or itemId == "POKE_DOLL" then
if not battle then if not battle then
@@ -301,23 +293,21 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
require("src.world.PikachuFollower") require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDXITEM", b and b.mon) .modifyHappiness(save, "USEDXITEM", b and b.mon)
end end
local used = itemUseLine(data, save, name)
if itemId == "X_ACCURACY" then if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage); vanilla prints only the used line -- (not an accuracy stage)
b.xAccuracy = true b.xAccuracy = true
return "consumed", { used }, USE_JINGLE return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
end end
if X_ITEMS[itemId] then if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId] local stat = X_ITEMS[itemId]
local cur = b.stages[stat] or 0 local cur = b.stages[stat] or 0
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect -- ItemUseXStat removes the item BEFORE running the stat-up
-- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!"
if cur >= 6 then if cur >= 6 then
return "consumed", { used }, { return "consumed", { romText(data, "_NothingHappenedText",
useJingle = true, "Nothing happened!") }
afterMessages = { romText(data, "_NothingHappenedText",
"Nothing happened!") },
}
end end
b.stages[stat] = cur + 1 b.stages[stat] = cur + 1
b.hazeStatReset = nil b.hazeStatReset = nil
@@ -325,29 +315,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
and battle.kind ~= "link" then and battle.kind ~= "link" then
require("src.battle.Damage").reapplyBadgeBoosts(b, stat) require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
end end
return "consumed", { used }, { return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
useJingle = true,
afterMessages = { Strings("%s's\n%s rose!", b.name,
Strings(STAT_LABEL[stat])) },
}
end end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active; vanilla prints only used -- the item, even when it is already active
if itemId == "DIRE_HIT" then if itemId == "DIRE_HIT" then
b.focusEnergy = true b.focusEnergy = true
return "consumed", { used }, USE_JINGLE return "consumed", { romText(data, "_GettingPumpedText",
"%s's\ngetting pumped!", b.name) }
end end
if itemId == "GUARD_SPEC" then if itemId == "GUARD_SPEC" then
b.mist = true b.mist = true
return "consumed", { used }, USE_JINGLE return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
end end
if itemId == "POKE_DOLL" then if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { notTime(data, save) } return "failed", { notTime(data, save) }
end end
-- PrintItemUseTextAndRemoveItem then escape return "consumed_escape", { romText(data, "_WildRanText",
return "consumed_escape", { used }, USE_JINGLE "The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
end end
end end
@@ -626,7 +613,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if REPELS[itemId] then if REPELS[itemId] then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps save.repelSteps = steps
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
end end
return "failed", { notTime(data, save) } return "failed", { notTime(data, save) }
+2 -14
View File
@@ -147,19 +147,12 @@ local SUPPORTED_REQUIRES = {
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "") local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "") :gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
local ENGINE_CHUNKS = {
["main.lua"] = true,
["conf.lua"] = true,
}
local function callerIsMod(level) local function callerIsMod(level)
if ENGINE_PREFIX == "" then return false end if ENGINE_PREFIX == "" then return false end
local info = debug.getinfo(level, "S") local info = debug.getinfo(level, "S")
local source = info and info.source local source = info and info.source
if not source or source:sub(1, 1) ~= "@" then return false end if not source or source:sub(1, 1) ~= "@" then return false end
local path = source:sub(2) return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX
if ENGINE_CHUNKS[path] then return false end
return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX
end end
local function scanRequire(name) local function scanRequire(name)
@@ -214,11 +207,6 @@ local function engineRequire(name)
return module return module
end end
function Loader.endSession()
devShim.generation = nil
devShim.errors = nil
end
function Loader:_installDevShim() function Loader:_installDevShim()
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet devShim.permissions[id] = mod.manifest.permissionSet
@@ -246,7 +234,7 @@ function Loader:_installDevShim()
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. -- 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 -- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it. -- requires src.core.Game on both generations and means it.
if devShim.generation == 2 and Gen2Compat.serves(name) if devShim.generation ~= 1 and Gen2Compat.serves(name)
and (owner or callerIsMod(3)) then and (owner or callerIsMod(3)) then
local adapter = Gen2Compat.resolve(name, Runtime.currentMod) local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then if adapter then
+13 -49
View File
@@ -20,8 +20,6 @@ local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support) local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet local gbcPack -- false = missing; nil = not loaded yet
local yellowPack -- 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 -- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy, -- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
@@ -302,38 +300,6 @@ function PaletteFX.yellowPack()
return yellowPack or nil return yellowPack or nil
end 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) function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode mode = mode or PaletteFX.mode
return mode == "redpp" return mode == "redpp"
@@ -487,10 +453,7 @@ function PaletteFX.pal(data, name)
if fromRom then return fromRom end if fromRom then return fromRom end
end end
if GameVersion.isYellow() then if GameVersion.isYellow() then
-- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes if PaletteFX.usesYellowCgb() then
-- 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) local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end if fromCgb then return fromCgb end
end end
@@ -548,8 +511,7 @@ function PaletteFX.monPal(data, species, transformed)
if pal then return pal end if pal then return pal end
end end
local name = p.pokemon[species] or "MEWMON" local name = p.pokemon[species] or "MEWMON"
if PaletteFX.usesYellowCgb() if PaletteFX.usesYellowCgb() then
or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then
local yc = PaletteFX.pal(data, name) local yc = PaletteFX.pal(data, name)
if yc then return yc end if yc then return yc end
end end
@@ -656,14 +618,16 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel
-- (false for a mod tileset with no pokered-gbc counterpart, or when the -- (false for a mod tileset with no pokered-gbc counterpart, or when the
-- pack failed to load at all) -- pack failed to load at all)
function PaletteFX.hasWorldTileset(tileset) function PaletteFX.hasWorldTileset(tileset)
local w = PaletteFX.worldPack() local pack = PaletteFX.gbcPack()
local w = pack and pack.world
return (w and w.tileGroups[tileset]) ~= nil return (w and w.tileGroups[tileset]) ~= nil
end end
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset, -- 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 -- with the current map's tile-id exceptions (if any) applied first
function PaletteFX.worldGroupAt(tileset, mapId, tileId) function PaletteFX.worldGroupAt(tileset, mapId, tileId)
local w = PaletteFX.worldPack() local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local groups = w and w.tileGroups[tileset] local groups = w and w.tileGroups[tileset]
if not groups then return nil end if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId] local exc = TILE_GROUP_EXCEPTIONS[mapId]
@@ -678,7 +642,8 @@ end
-- Saffron's roof colors while the player stands in its top 2 cell rows, -- 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) -- like pokered's wYCoord check -- data is Game.data, for the map lookup)
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local w = PaletteFX.worldPack() local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local base = w and w.groupColors[tileset] local base = w and w.groupColors[tileset]
if not base then return nil end if not base then return nil end
if not w.roofGroup[tileset] then return darkGroups(base) end if not w.roofGroup[tileset] then return darkGroups(base) end
@@ -713,18 +678,17 @@ end
-- here): a stable hash instead, so the same NPC instance always shows the -- here): a stable hash instead, so the same NPC instance always shows the
-- same one of the 4 SPR_PAL_* colors. -- same one of the 4 SPR_PAL_* colors.
function PaletteFX.spriteObp(spriteDef, seed) function PaletteFX.spriteObp(spriteDef, seed)
local w = PaletteFX.worldPack() local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]")) local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite and SurfingPikachuSprite load outside -- RedBikeSprite and SurfingPikachuSprite load outside
-- SpriteSheetPointerTable, so their source has no bracketed index; -- SpriteSheetPointerTable, so their source has no bracketed index;
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue. -- they wear the player's OBP palette (spriteAssignment[0]).
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639). if not idx and (src:find("RedBikeSprite", 1, true)
if not idx and src:find("RedBikeSprite", 1, true) then or src:find("SurfingPikachuSprite", 1, true)) then
idx = 0 idx = 0
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
idx = GameVersion.isYellow() and 60 or 0
end end
local group = idx and w.spriteAssignment[idx] local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end if group == nil then return nil end
+2 -5
View File
@@ -338,7 +338,6 @@ function Renderer:beginFrame(transparent)
-- warp-fade overlay from Transition (issue #121); cleared each frame so -- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil -- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil self.worldFadeAlpha = nil
self.worldFadeColor = nil
-- battle-transition wipe, drawn over the whole surface (BattleTransition) -- battle-transition wipe, drawn over the whole surface (BattleTransition)
self.battleWipe = nil self.battleWipe = nil
-- whole-surface veil in screen space (battle-transition flash, the -- whole-surface veil in screen space (battle-transition flash, the
@@ -1046,8 +1045,7 @@ function Renderer:endFrame(zones, worldZones)
-- the screen-space overlays the flat path draws over its composite -- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
if fade and fade > 0 then if fade and fade > 0 then
local c = self.worldFadeColor or { 0, 0, 0 } love.graphics.setColor(0, 0, 0, fade)
love.graphics.setColor(c[1], c[2], c[3], fade)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -1134,8 +1132,7 @@ function Renderer:endFrame(zones, worldZones)
-- composite normally if one is ever stacked that way. -- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
if fade and fade > 0 then if fade and fade > 0 then
local c = self.worldFadeColor or { 0, 0, 0 } love.graphics.setColor(0, 0, 0, fade)
love.graphics.setColor(c[1], c[2], c[3], fade)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+4 -14
View File
@@ -65,24 +65,16 @@ end
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call -- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the -- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4). -- 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) local self = setmetatable({}, Transition)
self.game = game self.game = game
self.onMidpoint = onMidpoint self.onMidpoint = onMidpoint
self.onDone = onDone self.onDone = onDone
self.t = 0 self.t = 0
self.phase = "out" self.phase = "out"
self.color = opts.color or { 0, 0, 0 }
local style = styleOf(game, "warp_fade") local style = styleOf(game, "warp_fade")
self.frames = opts.frames or style.frames or FRAMES self.frames = style.frames or FRAMES
if opts.framesIn ~= nil then if warp then
self.framesIn = opts.framesIn
elseif warp then
-- a style may still ask for a fade in (mods, and the record is -- a style may still ask for a fade in (mods, and the record is
-- data-driven); the built-in warp is 0, matching hardware -- data-driven); the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN self.framesIn = style.framesIn or FRAMES_IN
@@ -133,7 +125,6 @@ end
function Transition:draw() function Transition:draw()
local alpha = self:alpha() local alpha = self:alpha()
local c = self.color or { 0, 0, 0 }
-- Survey zoom draws the overworld into a window-filling world canvas -- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the -- 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 -- UI canvas only darkens that center box (issue #121); when the world
@@ -142,10 +133,9 @@ function Transition:draw()
local r = self.game and self.game.renderer local r = self.game and self.game.renderer
if r and r.worldActive then if r and r.worldActive then
r.worldFadeAlpha = alpha r.worldFadeAlpha = alpha
r.worldFadeColor = c
return return
end end
love.graphics.setColor(c[1], c[2], c[3], alpha) love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+4 -19
View File
@@ -54,21 +54,6 @@ local function showMessages(game, msgs, onDone, opts)
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts)) game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end 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 -- 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 -- 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 -- the stack, so every exit that prints has to close it afterwards. For
@@ -130,7 +115,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
if result == "consumed_escape" then -- Poké Doll if result == "consumed_escape" then -- Poké Doll
consume(game, id, list) consume(game, id, list)
list:close() list:close()
showUseMessages(game, payload, function() showMessages(game, payload, function()
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches -- ItemUsePokeDoll sets wEscapedFromBattle and never touches
-- wBattleResult, so a script that reads the result afterwards sees -- wBattleResult, so a script that reads the result afterwards sees
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that -- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
@@ -140,7 +125,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
battle.result = "run" battle.result = "run"
battle.afterQueue = "finish" battle.afterQueue = "finish"
battle.phase = "messages" battle.phase = "messages"
end, extra) end)
return return
end end
@@ -418,9 +403,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end end
if battle then if battle then
list:close() list:close()
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra) showMessages(game, payload, function() battle:itemUsed({}) end)
else else
showUseMessages(game, payload, closePicker, extra) showMessages(game, payload, closePicker)
end end
return return
end end
+1 -10
View File
@@ -4818,15 +4818,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
require("src.core.Sound").play(Game.data, require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside") outdoor and "Go_Outside" or "Go_Inside")
end 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() Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts) self:setMap(mapId, x, y, facing or "down", opts)
-- the departure-side hide from flyAnim/teleportOut ends here, on the new -- the departure-side hide from flyAnim/teleportOut ends here, on the new
@@ -4893,7 +4884,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
end, function() end, function()
self.transitioning = false self.transitioning = false
if onDone then onDone() end if onDone then onDone() end
end, not specialWarp, fadeOpts)) end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write)
end end
-- Re-read a map record after its data changed (WorldAPI:invalidateMap, -- Re-read a map record after its data changed (WorldAPI:invalidateMap,
+1 -27
View File
@@ -109,35 +109,9 @@ do
-- Mimic a shared net module parked on the singleton (handle-style release). -- Mimic a shared net module parked on the singleton (handle-style release).
Game.net = { release = function(id) end } Game.net = { release = function(id) end }
SessionLifecycle.endGameSession(Game) SessionLifecycle.endGameSession(Game)
check(package.loaded["src.core.Game"] == nil,
"endGameSession drops the Gen1 Game module cache")
local again = require("src.core.Game") local again = require("src.core.Game")
check(type(rawget(again, "load")) == "function", check(type(again.load) == "function",
"Play-again can call Game:load after endGameSession") "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 end
T.finish("android_exit_to_launcher_test") T.finish("android_exit_to_launcher_test")
+1 -6
View File
@@ -118,13 +118,8 @@ local systemFile = assert(io.open(systemPath, "rb"))
local system = systemFile:read("*a") local system = systemFile:read("*a")
systemFile:close() systemFile:close()
check(system:find('strcmp(kind, "required_import")', 1, true) check(system:find('strcmp(kind, "required_import")', 1, true)
and system:find('destination != nullptr', 1, true) and system:find('dest = "picked_required_import.bin"', 1, true)
and system:find('"picked_required_import.bin"', 1, true)
and system:find('return "rom,mod,sav,required_import"', 1, true), and system:find('return "rom,mod,sav,required_import"', 1, true),
"native Android bridge advertises and routes required imports") "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") print("android_host_extension_test: ok")
-90
View File
@@ -1,90 +0,0 @@
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()
+3 -12
View File
@@ -4,10 +4,7 @@
-- The fix prefers the love.system.restartApp JNI bridge (which kills the -- 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 -- 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 -- whose liblove lacks the bridge, falls back to a CLEAN quit with no
-- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART -- argument. Desktop keeps the in-process quit("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 -- luajit tests/engine/host_restart_android_bug575.lua
package.path = "./?.lua;./?/init.lua;" .. package.path package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -51,18 +48,12 @@ HostShell.restart()
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing") eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
eq(quits[2].n, 0, "again with no restart argument") 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 -- desktop (no AppImage in a test environment) keeps the in-process restart
if not os.getenv("APPIMAGE") then if not os.getenv("APPIMAGE") then
osName = "OS X" osName = "OS X"
HostShell.restart() HostShell.restart()
eq(quits[4] and quits[4].arg, "restart", eq(quits[3] and quits[3].arg, "restart",
"non-mobile still restarts in-process") "non-Android still restarts in-process")
end end
T.finish("host_restart_android_bug575") T.finish("host_restart_android_bug575")
-72
View File
@@ -1,72 +0,0 @@
-- #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()
@@ -64,17 +64,12 @@ do
Game.linkFetch = { Game.linkFetch = {
release = function(id) jobs[id] = nil end, release = function(id) jobs[id] = nil end,
} }
local before = Game
SessionLifecycle.endGameSession(Game) SessionLifecycle.endGameSession(Game)
check(type(before.load) == "function", check(type(Game.load) == "function",
"endGameSession reset leaves methods on the old table") "endGameSession leaves Game.load intact for the next bootGame")
check(package.loaded["src.core.Game"] == nil, check(package.loaded["src.core.Game"] == Game
"endGameSession drops Gen1 Game from package.loaded") or type((package.loaded["src.core.Game"] or {}).load) == "function",
local again = require("src.core.Game") "Gen1 Game module remains require-able after endGameSession")
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 end
-- ---- Game2:reset releases world GPU and present canvases ------------------ -- ---- Game2:reset releases world GPU and present canvases ------------------
@@ -1,32 +0,0 @@
-- #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()
-10
View File
@@ -197,16 +197,6 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
"Fetch registers its shutdown hook at load") "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, -- 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 -- 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 -- love.quit() veto test, or the editor's abort-quit path would die on a quit
@@ -1,32 +0,0 @@
-- #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()
@@ -31,19 +31,16 @@ local save = SaveData.newGame()
local player = { name = "FIXMON", stages = {} } local player = { name = "FIXMON", stages = {} }
local xBattle = { player = player, kind = "wild" } local xBattle = { player = player, kind = "wild" }
local _, baseline, baselineExtra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
local baselineRose = baselineExtra and baselineExtra.afterMessages T.check(baseline[1]:find("ATTACK", 1, true) ~= nil,
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") "X ATTACK's rose! message names the stat in English with no catalog")
withCatalog({ ATTACK = "ATTAQUE" }, function() withCatalog({ ATTACK = "ATTAQUE" }, function()
player.stages.attack = nil player.stages.attack = nil
local _, msgs, extra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
local rose = extra and extra.afterMessages and extra.afterMessages[1] or msgs[1] T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil,
T.check(rose:find("ATTAQUE", 1, true) ~= nil,
"a catalog translating ATTACK reaches the X ATTACK rose! message") "a catalog translating ATTACK reaches the X ATTACK rose! message")
T.check(rose:find("ATTACK", 1, true) == nil, T.check(msgs[1]:find("ATTACK", 1, true) == nil,
"...and the untranslated English stat name is gone") "...and the untranslated English stat name is gone")
end) end)
-19
View File
@@ -1,19 +0,0 @@
-- 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()
+3 -10
View File
@@ -52,10 +52,7 @@ Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = Game.stack local stack = Game.stack
-- The draw guard both entity passes use: the player sprite is skipped while -- The draw guard both entity passes use: the player sprite is skipped while
-- any of flyAnim / flyArrive / playerHidden is set. Dig/Teleport spinDrop -- any of flyAnim / flyArrive / playerHidden is set.
-- 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) local function playerHidden(ow)
return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true
end end
@@ -118,12 +115,8 @@ check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins")
check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)") check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)")
eq(st.gapFrames, 0, eq(st.gapFrames, 0,
"no dig fade frame leaves the player standing bare (#916)") "no dig fade frame leaves the player standing bare (#916)")
-- Dig uses white fade-out + fade-in (#1644). Draw-skip hide covers the check(st.fadeFramesHidden >= st.fadeFrames - 1,
-- fade-out; the fade-in deliberately shows spinDrop under the veil, so "dig fade hidden on every frame but the arrival-arming midpoint ("
-- 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 .. ")") .. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
check(st.arrivalFrame ~= nil, "dig arrival spin-down arms") check(st.arrivalFrame ~= nil, "dig arrival spin-down arms")
check(ow.playerHidden == false, "dig hide cleared on the new map") check(ow.playerHidden == false, "dig hide cleared on the new map")
@@ -1,79 +0,0 @@
-- #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()
+1 -17
View File
@@ -145,24 +145,8 @@ check(type(Version.engine) == "string"
and Semver.parse(Version.engine) ~= nil, and Semver.parse(Version.engine) ~= nil,
"engine version parses as a semver (triple, optionally with a pre-release)") "engine version parses as a semver (triple, optionally with a pre-release)")
check(Version.modApi == 2, "mod api version is 2") 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, check(Version.title("X") == "X v" .. Version.engine,
"dev window title carries the engine version") "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 -- null-object runtime: emit/call are safe with no loader installed
local nullEvents, nullHooks = Runtime.events, Runtime.hooks local nullEvents, nullHooks = Runtime.events, Runtime.hooks
-129
View File
@@ -1,129 +0,0 @@
-- 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")
+4 -58
View File
@@ -17,11 +17,10 @@ local saved = {
pickFileKinds = love.system.pickFileKinds, pickFileKinds = love.system.pickFileKinds,
} }
local pickCalls, pickDestinations = {}, {} local pickCalls = {}
love.system.getOS = function() return "Android" end love.system.getOS = function() return "Android" end
love.system.pickFile = function(kind, destination) love.system.pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom" pickCalls[#pickCalls + 1] = kind or "rom"
pickDestinations[#pickCalls] = destination
return true return true
end end
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
@@ -104,15 +103,13 @@ ri.nativePicker = true
ri.mobileFileBridge = true ri.mobileFileBridge = true
ri.mods = { { ri.mods = { {
id = "needs_source", id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source", manifest = { id = "needs_source", name = "Needs Source",
required_imports = { { id = "source", name = "Source", file = "source.bin", required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } }, format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} } } }
ri:chooseRequiredImport("needs_source", "source") ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "required_import", eq(pickCalls[1], "required_import",
"required file asks for the dedicated picker kind") "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.pickerPendingModId, "needs_source", "pending mod is remembered")
eq(ri.pickerPendingImportId, "source", "pending import is remembered") eq(ri.pickerPendingImportId, "source", "pending import is remembered")
@@ -169,52 +166,6 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara
check(love.filesystem.getInfo("picked_required_import.bin") == nil, check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick") "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 -- Android releases with the updated launcher but the older native bridge do
-- not advertise required_import. They still support the established ROM SAF -- not advertise required_import. They still support the established ROM SAF
-- picker, whose result must be quarantined to the pending dependency request. -- picker, whose result must be quarantined to the pending dependency request.
@@ -225,14 +176,12 @@ ri.nativePicker = true
ri.mobileFileBridge = true ri.mobileFileBridge = true
ri.mods = { { ri.mods = { {
id = "needs_source", id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source", manifest = { id = "needs_source", name = "Needs Source",
required_imports = { { id = "source", name = "Source", file = "source.bin", required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } }, format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} } } }
ri:chooseRequiredImport("needs_source", "source") ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker") 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, check(ri.requiredImportLegacyRomPick,
"legacy Android ROM picker result is marked as a required import") "legacy Android ROM picker result is marked as a required import")
ri._importRequiredSource = function(self, modId, importId, source) ri._importRequiredSource = function(self, modId, importId, source)
@@ -267,8 +216,5 @@ love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin") love.filesystem.remove("picked_required_import.bin")
love.filesystem.remove("picked_rom.gb") 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() S.finish()
-39
View File
@@ -102,44 +102,5 @@ class ApplyKnownNonreproducibleOverridesTest(TestCase):
texts, field_data) 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__": if __name__ == "__main__":
main() main()
+3 -276
View File
@@ -927,113 +927,6 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
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 do
-- The inspector's nickname field is commit-on-Enter: the draft lives in -- The inspector's nickname field is commit-on-Enter: the draft lives in
-- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, -- S.nicknameDraft while typing, Enter commits it through Ops.setNickname,
@@ -1234,13 +1127,10 @@ do
-- 720x1280 / 1280x720 are the #715 report's shapes (Android, both -- 720x1280 / 1280x720 are the #715 report's shapes (Android, both
-- orientations): the Map tab used to lay its viewport out at a negative -- orientations): the Map tab used to lay its viewport out at a negative
-- width in portrait and crash on the scissor. RGxxx / Switch shapes pin -- width in portrait and crash on the scissor. The desktop sizes pin that
-- short-landscape handhelds (RG34XXSP 720x480, RG35XX 640x480, NX 1280x720) -- the responsive reflow does not disturb the layouts that already worked.
-- and a tiny 360x640 phone. Desktop sizes keep the layouts that already
-- worked.
for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 }, for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
{ 1280, 800 }, { 720, 1280 }, { 1280, 720 }, { 1280, 800 }, { 720, 1280 }, { 1280, 720 },
{ 720, 480 }, { 640, 480 }, { 480, 320 },
{ 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do { 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do
love.graphics.getDimensions = function() return size[1], size[2] end love.graphics.getDimensions = function() return size[1], size[2] end
App.load(tmpPath, { version = "red" }) App.load(tmpPath, { version = "red" })
@@ -1263,12 +1153,6 @@ do
ok, err = pcall(App.draw) ok, err = pcall(App.draw)
check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err))) check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err)))
Ops.closeSpeciesPicker(S, Kit) 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 end
love.graphics.getDimensions = oldDimensions love.graphics.getDimensions = oldDimensions
@@ -1349,12 +1233,8 @@ do
f:close() f:close()
local oldDimensions = love.graphics.getDimensions 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 }, local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 },
{ 720, 480 }, { 1024, 768 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 } }
{ 900, 700 }, { 1920, 1080 } }
for _, size in ipairs(sizes) do for _, size in ipairs(sizes) do
local W, H = size[1], size[2] local W, H = size[1], size[2]
love.graphics.getDimensions = function() return W, H end love.graphics.getDimensions = function() return W, H end
@@ -1386,14 +1266,6 @@ do
if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end
Kit.audit = nil Kit.audit = nil
Ops.closeSpeciesPicker(S, Kit) 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 end
love.graphics.getDimensions = oldDimensions love.graphics.getDimensions = oldDimensions
@@ -1477,150 +1349,5 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
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)) print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end if failed > 0 then os.exit(1) end
-34
View File
@@ -1,34 +0,0 @@
#!/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()
+1 -4
View File
@@ -22,12 +22,9 @@ eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222")
PaletteFX.setMode("redpp") PaletteFX.setMode("redpp")
local mewmonAdv = PaletteFX.pal(nil, "MEWMON") local mewmonAdv = PaletteFX.pal(nil, "MEWMON")
check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow") 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 -- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette!
-- 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[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][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)") check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)")
-- Test 3: OG YELLOW mode (ogred on Yellow) -- Test 3: OG YELLOW mode (ogred on Yellow)
+1 -42
View File
@@ -77,26 +77,6 @@ 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): def _symbol(symbols, name):
try: try:
return symbols[name] return symbols[name]
@@ -1900,33 +1880,12 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
(3, 24, 24, True), (2, 32, 24, True), (3, 24, 24, True), (2, 32, 24, True),
(0, 56, 16, False), (1, 64, 16, False), (0, 56, 16, False), (1, 64, 16, False),
(2, 56, 24, False), (3, 64, 24, False)): (2, 56, 24, False), (3, 64, 24, False)):
eye = ob_clear[ob_index].copy() eye = ob_clear[ob_index]
_apply_title_obp0(eye)
if flip: if flip:
eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT) eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
pikachu.paste(eye, (px, py), eye) pikachu.paste(eye, (px, py), eye)
_save_png(pikachu, os.path.join(assets_dir, "title/pikachu.png")) _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( falling_star = raw_2bpp(
"FallingStar", 8, 8, "intro/falling_star.png", "FallingStar", 8, 8, "intro/falling_star.png",
transparent=True) transparent=True)
-37
View File
@@ -86,43 +86,6 @@ def parse_super_rod(pokered):
return out 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): def parse_trades(pokered):
"""data/events/trades.asm: npctrade give, get, dialogset, nickname.""" """data/events/trades.asm: npctrade give, get, dialogset, nickname."""
# TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes # TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes
-1
View File
@@ -453,7 +453,6 @@ def derive(red, pokeyellow, symbols_path):
print(f"warning: parse_credits failed ({exc}); keeping Red credits") print(f"warning: parse_credits failed ({exc}); keeping Red credits")
# TODO: hand-author a Yellow credits banner if pret layout drifts. # TODO: hand-author a Yellow credits banner if pret layout drifts.
yellow["field"]["trades"] = field.parse_trades(pokeyellow) yellow["field"]["trades"] = field.parse_trades(pokeyellow)
yellow["field"]["superRod"] = field.parse_super_rod_yellow(pokeyellow)
finally: finally:
util.ASM_DEFINES = saved util.ASM_DEFINES = saved
+260 -316
View File
@@ -8360,74 +8360,94 @@
"superRod": { "superRod": {
"CELADON_CITY": [ "CELADON_CITY": [
{ {
"level": 5, "level": 23,
"species": "GOLDEEN" "species": "POLIWHIRL"
}, },
{ {
"level": 10, "level": 15,
"species": "GOLDEEN" "species": "SLOWPOKE"
}
],
"CERULEAN_CAVE_1F": [
{
"level": 23,
"species": "SLOWBRO"
},
{
"level": 23,
"species": "SEAKING"
},
{
"level": 23,
"species": "KINGLER"
},
{
"level": 23,
"species": "SEADRA"
}
],
"CERULEAN_CAVE_2F": [
{
"level": 23,
"species": "SLOWBRO"
},
{
"level": 23,
"species": "SEAKING"
},
{
"level": 23,
"species": "KINGLER"
},
{
"level": 23,
"species": "SEADRA"
}
],
"CERULEAN_CAVE_B1F": [
{
"level": 23,
"species": "SLOWBRO"
},
{
"level": 23,
"species": "SEAKING"
},
{
"level": 23,
"species": "KINGLER"
},
{
"level": 23,
"species": "SEADRA"
}
],
"CERULEAN_CITY": [
{
"level": 15,
"species": "PSYDUCK"
}, },
{ {
"level": 15, "level": 15,
"species": "GOLDEEN" "species": "GOLDEEN"
}, },
{ {
"level": 20, "level": 15,
"species": "GOLDEEN" "species": "KRABBY"
} }
], ],
"CERULEAN_CAVE_1F": [ "CERULEAN_GYM": [
{ {
"level": 25, "level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "GOLDEEN" "species": "GOLDEEN"
}, },
{ {
"level": 35, "level": 15,
"species": "SEAKING" "species": "KRABBY"
},
{
"level": 45,
"species": "SEAKING"
},
{
"level": 55,
"species": "SEAKING"
}
],
"CERULEAN_CAVE_B1F": [
{
"level": 30,
"species": "GOLDEEN"
},
{
"level": 40,
"species": "SEAKING"
},
{
"level": 50,
"species": "SEAKING"
},
{
"level": 60,
"species": "SEAKING"
}
],
"CERULEAN_CITY": [
{
"level": 25,
"species": "GOLDEEN"
},
{
"level": 30,
"species": "GOLDEEN"
},
{
"level": 30,
"species": "SEAKING"
},
{
"level": 40,
"species": "SEAKING"
} }
], ],
"CINNABAR_ISLAND": [ "CINNABAR_ISLAND": [
@@ -8437,123 +8457,99 @@
}, },
{ {
"level": 15, "level": 15,
"species": "TENTACOOL" "species": "HORSEA"
}, },
{ {
"level": 10, "level": 15,
"species": "STARYU" "species": "SHELLDER"
}, },
{ {
"level": 30, "level": 15,
"species": "TENTACOOL" "species": "GOLDEEN"
} }
], ],
"FUCHSIA_CITY": [ "FUCHSIA_CITY": [
{ {
"level": 5, "level": 23,
"species": "MAGIKARP" "species": "SEAKING"
}, },
{ {
"level": 10, "level": 15,
"species": "MAGIKARP" "species": "KRABBY"
},
{
"level": 15,
"species": "GOLDEEN"
}, },
{ {
"level": 15, "level": 15,
"species": "MAGIKARP" "species": "MAGIKARP"
},
{
"level": 15,
"species": "GYARADOS"
} }
], ],
"PALLET_TOWN": [ "PALLET_TOWN": [
{ {
"level": 10, "level": 15,
"species": "STARYU"
},
{
"level": 10,
"species": "TENTACOOL" "species": "TENTACOOL"
}, },
{ {
"level": 5, "level": 15,
"species": "STARYU" "species": "POLIWAG"
},
{
"level": 20,
"species": "TENTACOOL"
} }
], ],
"ROUTE_10": [ "ROUTE_10": [
{
"level": 23,
"species": "POLIWHIRL"
},
{ {
"level": 15, "level": 15,
"species": "KRABBY" "species": "SLOWPOKE"
},
{
"level": 20,
"species": "KRABBY"
},
{
"level": 10,
"species": "HORSEA"
},
{
"level": 25,
"species": "KINGLER"
} }
], ],
"ROUTE_11": [ "ROUTE_11": [
{ {
"level": 15, "level": 15,
"species": "TENTACOOL" "species": "KRABBY"
}, },
{ {
"level": 20, "level": 15,
"species": "TENTACOOL" "species": "SHELLDER"
},
{
"level": 10,
"species": "TENTACOOL"
},
{
"level": 5,
"species": "HORSEA"
} }
], ],
"ROUTE_12": [ "ROUTE_12": [
{ {
"level": 20, "level": 5,
"species": "HORSEA" "species": "TENTACOOL"
}, },
{ {
"level": 25, "level": 15,
"species": "HORSEA" "species": "KRABBY"
}, },
{ {
"level": 25, "level": 15,
"species": "SEADRA" "species": "GOLDEEN"
}, },
{ {
"level": 35, "level": 15,
"species": "SEADRA" "species": "MAGIKARP"
} }
], ],
"ROUTE_13": [ "ROUTE_13": [
{ {
"level": 15, "level": 5,
"species": "HORSEA"
},
{
"level": 20,
"species": "HORSEA"
},
{
"level": 10,
"species": "TENTACOOL" "species": "TENTACOOL"
}, },
{ {
"level": 20, "level": 15,
"species": "SEADRA" "species": "KRABBY"
},
{
"level": 15,
"species": "GOLDEEN"
},
{
"level": 15,
"species": "MAGIKARP"
} }
], ],
"ROUTE_17": [ "ROUTE_17": [
@@ -8563,357 +8559,305 @@
}, },
{ {
"level": 15, "level": 15,
"species": "TENTACOOL" "species": "KRABBY"
}, },
{ {
"level": 25, "level": 15,
"species": "SHELLDER" "species": "GOLDEEN"
}, },
{ {
"level": 35, "level": 15,
"species": "SHELLDER" "species": "MAGIKARP"
} }
], ],
"ROUTE_18": [ "ROUTE_18": [
{ {
"level": 15, "level": 5,
"species": "TENTACOOL" "species": "TENTACOOL"
}, },
{ {
"level": 20, "level": 15,
"species": "SHELLDER" "species": "KRABBY"
}, },
{ {
"level": 30, "level": 15,
"species": "SHELLDER" "species": "GOLDEEN"
}, },
{ {
"level": 40, "level": 15,
"species": "SHELLDER" "species": "MAGIKARP"
} }
], ],
"ROUTE_19": [ "ROUTE_19": [
{ {
"level": 15, "level": 15,
"species": "TENTACOOL"
},
{
"level": 20,
"species": "STARYU" "species": "STARYU"
}, },
{ {
"level": 30, "level": 15,
"species": "TENTACOOL" "species": "HORSEA"
}, },
{ {
"level": 30, "level": 15,
"species": "TENTACRUEL" "species": "SHELLDER"
},
{
"level": 15,
"species": "GOLDEEN"
} }
], ],
"ROUTE_20": [ "ROUTE_20": [
{ {
"level": 20, "level": 15,
"species": "TENTACOOL"
},
{
"level": 20,
"species": "TENTACRUEL"
},
{
"level": 30,
"species": "STARYU" "species": "STARYU"
}, },
{ {
"level": 40, "level": 15,
"species": "TENTACRUEL" "species": "HORSEA"
},
{
"level": 15,
"species": "SHELLDER"
},
{
"level": 15,
"species": "GOLDEEN"
} }
], ],
"ROUTE_21": [ "ROUTE_21": [
{ {
"level": 15, "level": 15,
"species": "TENTACOOL"
},
{
"level": 20,
"species": "STARYU" "species": "STARYU"
}, },
{ {
"level": 30, "level": 15,
"species": "TENTACOOL" "species": "HORSEA"
}, },
{ {
"level": 30, "level": 15,
"species": "TENTACRUEL" "species": "SHELLDER"
},
{
"level": 15,
"species": "GOLDEEN"
} }
], ],
"ROUTE_22": [ "ROUTE_22": [
{ {
"level": 5, "level": 15,
"species": "POLIWAG" "species": "GOLDEEN"
},
{
"level": 10,
"species": "POLIWAG"
}, },
{ {
"level": 15, "level": 15,
"species": "POLIWAG" "species": "POLIWAG"
},
{
"level": 15,
"species": "POLIWHIRL"
} }
], ],
"ROUTE_23": [ "ROUTE_23": [
{ {
"level": 25, "level": 23,
"species": "POLIWAG" "species": "SLOWBRO"
}, },
{ {
"level": 30, "level": 23,
"species": "POLIWAG" "species": "SEAKING"
}, },
{ {
"level": 30, "level": 23,
"species": "POLIWHIRL" "species": "KINGLER"
}, },
{ {
"level": 40, "level": 23,
"species": "POLIWHIRL" "species": "SEADRA"
} }
], ],
"ROUTE_24": [ "ROUTE_24": [
{ {
"level": 20, "level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "GOLDEEN" "species": "GOLDEEN"
}, },
{ {
"level": 25, "level": 15,
"species": "GOLDEEN" "species": "KRABBY"
},
{
"level": 30,
"species": "GOLDEEN"
},
{
"level": 30,
"species": "SEAKING"
} }
], ],
"ROUTE_25": [ "ROUTE_25": [
{ {
"level": 10, "level": 15,
"species": "KRABBY" "species": "PSYDUCK"
},
{
"level": 15,
"species": "GOLDEEN"
}, },
{ {
"level": 15, "level": 15,
"species": "KRABBY" "species": "KRABBY"
},
{
"level": 15,
"species": "KINGLER"
},
{
"level": 25,
"species": "KINGLER"
} }
], ],
"ROUTE_4": [ "ROUTE_4": [
{ {
"level": 20, "level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "GOLDEEN" "species": "GOLDEEN"
}, },
{ {
"level": 25, "level": 15,
"species": "GOLDEEN" "species": "KRABBY"
},
{
"level": 30,
"species": "GOLDEEN"
},
{
"level": 30,
"species": "SEAKING"
} }
], ],
"ROUTE_6": [ "ROUTE_6": [
{ {
"level": 5, "level": 15,
"species": "GOLDEEN" "species": "KRABBY"
},
{
"level": 10,
"species": "GOLDEEN"
}, },
{ {
"level": 15, "level": 15,
"species": "GOLDEEN" "species": "SHELLDER"
},
{
"level": 20,
"species": "GOLDEEN"
} }
], ],
"SAFARI_ZONE_CENTER": [ "SAFARI_ZONE_CENTER": [
{ {
"level": 5, "level": 15,
"species": "MAGIKARP"
},
{
"level": 10,
"species": "MAGIKARP"
},
{
"level": 10,
"species": "DRATINI" "species": "DRATINI"
}, },
{ {
"level": 15, "level": 15,
"species": "DRAGONAIR" "species": "KRABBY"
},
{
"level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "SLOWPOKE"
} }
], ],
"SAFARI_ZONE_EAST": [ "SAFARI_ZONE_EAST": [
{
"level": 5,
"species": "MAGIKARP"
},
{
"level": 10,
"species": "MAGIKARP"
},
{
"level": 15,
"species": "MAGIKARP"
},
{ {
"level": 15, "level": 15,
"species": "DRATINI" "species": "DRATINI"
},
{
"level": 15,
"species": "KRABBY"
},
{
"level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "SLOWPOKE"
} }
], ],
"SAFARI_ZONE_NORTH": [ "SAFARI_ZONE_NORTH": [
{
"level": 5,
"species": "MAGIKARP"
},
{
"level": 10,
"species": "MAGIKARP"
},
{
"level": 15,
"species": "MAGIKARP"
},
{ {
"level": 15, "level": 15,
"species": "DRATINI" "species": "DRATINI"
},
{
"level": 15,
"species": "KRABBY"
},
{
"level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "SLOWPOKE"
} }
], ],
"SAFARI_ZONE_WEST": [ "SAFARI_ZONE_WEST": [
{
"level": 5,
"species": "MAGIKARP"
},
{
"level": 10,
"species": "MAGIKARP"
},
{
"level": 15,
"species": "MAGIKARP"
},
{ {
"level": 15, "level": 15,
"species": "DRATINI" "species": "DRATINI"
},
{
"level": 15,
"species": "KRABBY"
},
{
"level": 15,
"species": "PSYDUCK"
},
{
"level": 15,
"species": "SLOWPOKE"
} }
], ],
"SEAFOAM_ISLANDS_B3F": [ "SEAFOAM_ISLANDS_B3F": [
{ {
"level": 25, "level": 15,
"species": "KRABBY"
},
{
"level": 20,
"species": "STARYU" "species": "STARYU"
}, },
{ {
"level": 35, "level": 15,
"species": "KINGLER" "species": "HORSEA"
}, },
{ {
"level": 40, "level": 15,
"species": "STARYU" "species": "SHELLDER"
},
{
"level": 15,
"species": "GOLDEEN"
} }
], ],
"SEAFOAM_ISLANDS_B4F": [ "SEAFOAM_ISLANDS_B4F": [
{ {
"level": 25, "level": 15,
"species": "KRABBY"
},
{
"level": 20,
"species": "STARYU" "species": "STARYU"
}, },
{ {
"level": 35, "level": 15,
"species": "KINGLER" "species": "HORSEA"
}, },
{ {
"level": 40, "level": 15,
"species": "STARYU" "species": "SHELLDER"
},
{
"level": 15,
"species": "GOLDEEN"
} }
], ],
"VERMILION_CITY": [ "VERMILION_CITY": [
{ {
"level": 15, "level": 15,
"species": "TENTACOOL" "species": "KRABBY"
}, },
{ {
"level": 20, "level": 15,
"species": "TENTACOOL" "species": "SHELLDER"
},
{
"level": 10,
"species": "TENTACOOL"
},
{
"level": 5,
"species": "HORSEA"
} }
], ],
"VERMILION_DOCK": [ "VERMILION_DOCK": [
{ {
"level": 10, "level": 15,
"species": "TENTACOOL" "species": "KRABBY"
}, },
{ {
"level": 15, "level": 15,
"species": "TENTACOOL"
},
{
"level": 15,
"species": "STARYU"
},
{
"level": 10,
"species": "SHELLDER" "species": "SHELLDER"
} }
], ],
"VIRIDIAN_CITY": [ "VIRIDIAN_CITY": [
{ {
"level": 5, "level": 15,
"species": "POLIWAG" "species": "TENTACOOL"
},
{
"level": 10,
"species": "POLIWAG"
}, },
{ {
"level": 15, "level": 15,
"species": "POLIWAG" "species": "POLIWAG"
},
{
"level": 10,
"species": "POLIWAG"
} }
] ]
}, },
+11 -40
View File
@@ -9,11 +9,7 @@
-- * Edit on a launcher save row (main.lua, embedded = true), Close returns -- * Edit on a launcher save row (main.lua, embedded = true), Close returns
-- to the launcher with the slot list refreshed -- to the launcher with the slot list refreshed
-- --
-- Vertical rhythm inside the platform safe area (scaled by Kit's height/768 -- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes):
-- 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 -- 0 6px tri-colour version rail, identical to the launcher's
-- 6 64px title bar identity, file chip, Save / Reload / Open / Close -- 6 64px title bar identity, file chip, Save / Reload / Open / Close
-- (104px when the bar reflows to two rows, #715) -- (104px when the bar reflows to two rows, #715)
@@ -22,7 +18,6 @@
-- -38 38px status bar the last Ops message + the keyboard map -- -38 38px status bar the last Ops message + the keyboard map
local Data = require("src.core.Data") local Data = require("src.core.Data")
local SafeArea = require("src.core.SafeArea")
local TileRenderer = require("src.render.TileRenderer") local TileRenderer = require("src.render.TileRenderer")
local SaveIO = require("SaveIO") local SaveIO = require("SaveIO")
local Catalog = require("Catalog") local Catalog = require("Catalog")
@@ -42,7 +37,6 @@ local MapBrowser = require("MapBrowser")
local Dex = require("Dex") local Dex = require("Dex")
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541) -- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
local SpeciesPicker = require("SpeciesPicker") local SpeciesPicker = require("SpeciesPicker")
local MovePicker = require("MovePicker")
local ItemPicker = require("ItemPicker") local ItemPicker = require("ItemPicker")
local App = {} local App = {}
@@ -778,17 +772,7 @@ function App.draw()
-- tolerates that rather than indexing a torn-down state. -- tolerates that rather than indexing a torn-down state.
if not S then return end if not S then return end
local width, height = love.graphics.getDimensions() local width, height = love.graphics.getDimensions()
width = math.max(1, tonumber(width) or 1) Kit.layout(width, height)
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 s = Kit.scale
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
@@ -807,7 +791,6 @@ function App.draw()
-- shield goes up before anything dispatches and comes down only for the -- shield goes up before anything dispatches and comes down only for the
-- picker's own layer at the bottom of this function (#541). -- picker's own layer at the bottom of this function (#541).
Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil) Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil)
or (S.movePicker ~= nil)
Theme.field(width, height) Theme.field(width, height)
@@ -816,29 +799,26 @@ function App.draw()
-- the window is too narrow for both on one, instead of the buttons and the -- 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 -- identity painting through each other (#715). The taller bar simply
-- costs the content column height, which scrolls. -- costs the content column height, which scrolls.
local titleTwoRow = titleNeedsTwoRows(sw) local titleTwoRow = titleNeedsTwoRows(width)
local titleH = (titleTwoRow and 104 or 64) * s local titleH = (titleTwoRow and 104 or 64) * s
local tabH = 66 * s local tabH = 66 * s
local statusH = 38 * s local statusH = 38 * s
Theme.versionRail(ox, oy, sw, railH) Theme.versionRail(0, 0, width, railH)
drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow) drawTitleBar(0, railH, width, titleH, titleTwoRow)
drawTabRail(ox, oy + railH + titleH, sw, tabH) drawTabRail(0, railH + titleH, width, tabH)
local contentY = oy + railH + titleH + tabH local contentY = railH + titleH + tabH
local contentH = sh - railH - titleH - tabH - statusH local contentH = height - contentY - statusH
local panel = PANELS[S.tab] local panel = PANELS[S.tab]
if panel then if panel then
panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s, panel.draw(S, Kit, 22 * s, contentY + 20 * s,
sw - 44 * s, contentH - 38 * s) width - 44 * s, contentH - 38 * s)
end end
drawStatusBar(ox, oy + sh - statusH, sw, statusH) drawStatusBar(0, height - statusH, width, statusH)
Kit.blockClicks = false 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) SpeciesPicker.draw(S, Kit, width, height)
MovePicker.draw(S, Kit, width, height)
ItemPicker.draw(S, Kit, width, height) ItemPicker.draw(S, Kit, width, height)
Kit.endFrame() Kit.endFrame()
PadInput.draw() PadInput.draw()
@@ -861,15 +841,6 @@ function App.keypressed(key)
return return
end end
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 S.speciesPicker then
if key == "return" or key == "kpenter" then if key == "return" or key == "kpenter" then
SpeciesPicker.commitFirst(S, Kit) SpeciesPicker.commitFirst(S, Kit)
+1 -6
View File
@@ -11,8 +11,7 @@
-- Hit testing is a plain rect with no z-order, so panels must draw -- 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 -- overlapping controls in dispatch order and every target is >= 26px tall
-- (rule 6 of the design spec) -- that sizing is the whole accessibility story -- (rule 6 of the design spec) -- that sizing is the whole accessibility story
-- here. Kit.tapMin() is the px floor after scale (a short handheld sits on -- here.
-- the 0.9 scale floor, so bare `26 * s` would slip under 26).
local Theme = require("Theme") local Theme = require("Theme")
local PAL = Theme.PAL local PAL = Theme.PAL
@@ -26,10 +25,6 @@ Kit.time = 0
Kit.fonts = {} Kit.fonts = {}
Kit.scale = 1 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 G = love and love.graphics or nil
local edits = {} -- queued textinput / backspace since the last frame local edits = {} -- queued textinput / backspace since the last frame
local kbField = nil -- id of the field the OS soft keyboard is raised for local kbField = nil -- id of the field the OS soft keyboard is raised for
+3 -133
View File
@@ -329,31 +329,6 @@ function Ops.speciesSearch(S, query)
for _, id in ipairs(S.cat.species) do for _, id in ipairs(S.cat.species) do
if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end
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 return out
end end
@@ -454,8 +429,6 @@ 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)) return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
end 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) 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 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 local moves = S.cat.moves
@@ -468,117 +441,14 @@ function Ops.cycleMove(S, mon, slot)
end end
for step = 1, #moves do for step = 1, #moves do
local nextId = moves[((idx + step - 1) % #moves) + 1] local nextId = moves[((idx + step - 1) % #moves) + 1]
if Ops.moveUsable(S, nextId) then if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then
return Ops.setMove(S, mon, slot, nextId) MonOps.setMove(S.data, mon, slot, nextId)
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
end end
end end
return false return false
end 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) function Ops.clearMove(S, mon, slot)
if not (mon and mon.moves and mon.moves[slot]) then if not (mon and mon.moves and mon.moves[slot]) then
return Ops.say(S, ("Move slot %d is already empty"):format(slot)) return Ops.say(S, ("Move slot %d is already empty"):format(slot))
-83
View File
@@ -1,83 +0,0 @@
-- 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
-5
View File
@@ -53,11 +53,6 @@ function State.new()
-- without a z-order (#541). -- without a z-order (#541).
speciesPicker = nil, 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 -- item picker overlay: nil when closed, otherwise
-- { query, offset, dest = "bag"|"pc" }. Same modal contract as -- { query, offset, dest = "bag"|"pc" }. Same modal contract as
-- speciesPicker above -- adding an item is now a full-screen picker -- speciesPicker above -- adding an item is now a full-screen picker
+16 -15
View File
@@ -13,7 +13,6 @@
local Theme = require("Theme") local Theme = require("Theme")
local Ops = require("Ops") local Ops = require("Ops")
local PickerChrome = require("PickerChrome")
local PAL = Theme.PAL local PAL = Theme.PAL
local Picker = {} local Picker = {}
@@ -58,39 +57,38 @@ function Picker.draw(S, Kit, width, height)
Kit.blockClicks = true Kit.blockClicks = true
end end
-- the scrim doubles as the "tap outside to cancel" target; it covers the -- the scrim doubles as the "tap outside to cancel" target
-- full window so unsafe bands (notch / home indicator) stay dimmed too
Theme.col(PAL.bg, 0.82) Theme.col(PAL.bg, 0.82)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, width, height)
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a local w = math.min(width - 32 * s, 520 * s)
-- usable list (#917 / #715). local h = math.min(height - 32 * s, 560 * s)
local x, y, w, h, pad = PickerChrome.card(Kit, width, height) local x = (width - w) / 2
local y = (height - h) / 2
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
Ops.closeItemPicker(S, Kit) Ops.closeItemPicker(S, Kit)
return return
end end
Kit.card(x, y, w, h) Kit.card(x, y, w, h)
local pad = 18 * s
local cx, cy = x + pad, y + pad local cx, cy = x + pad, y + pad
local inner = w - 2 * pad local inner = w - 2 * pad
local closeW = PickerChrome.closeSize(Kit) Kit.caption(cx, cy, "ADD AN ITEM")
local captionH = Kit.textHeight("caption") local closeW = 30 * s
local headH = math.max(captionH, closeW) if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
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 { font = "small" }) then
Ops.closeItemPicker(S, Kit) Ops.closeItemPicker(S, Kit)
return return
end end
cy = cy + headH + 10 * s cy = cy + Kit.textHeight("caption") + 10 * s
-- Destination toggle. Which list an item lands in is the only real choice -- 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 -- 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". -- bottom that each mean "commit, and also pick a destination".
local half = (inner - 8 * s) / 2 local half = (inner - 8 * s) / 2
local destH = math.max(PickerChrome.tapMin(Kit), math.floor(30 * s)) local destH = 30 * s
if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then
p.dest = "bag" p.dest = "bag"
end end
@@ -100,13 +98,16 @@ function Picker.draw(S, Kit, width, height)
end end
cy = cy + destH + 10 * s cy = cy + destH + 10 * s
local fieldH = PickerChrome.fieldH(Kit) local fieldH = 34 * s
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
"type an item id") "type an item id")
cy = cy + fieldH + 10 * s cy = cy + fieldH + 10 * s
local hits = Picker.results(S) local hits = Picker.results(S)
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local rowH = 36 * s
local rowGap = 6 * s
local pagerH = 30 * s
local listH = (y + h - pad - pagerH - 10 * s) - cy
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) 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)) 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 -- wheel / touch drag scroll the modal list too; the shield is already
+7 -7
View File
@@ -213,7 +213,7 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap)
local ry = rowY + (slot - 1) * (rowH + rowGap) local ry = rowY + (slot - 1) * (rowH + rowGap)
Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6) Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6)
local mv = mon.moves and mon.moves[slot] local mv = mon.moves and mon.moves[slot]
local clear = Kit.tapMin() local clear = 24 * s
local clearX = rightX + colW - 10 * s - clear local clearX = rightX + colW - 10 * s - clear
local ppText = mv and ("PP %d"):format(mv.pp or 0) or "" local ppText = mv and ("PP %d"):format(mv.pp or 0) or ""
local ppW = Kit.textWidth("tiny", ppText) 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) mv and PAL.text or PAL.faint)
Kit.textRight("tiny", ppText, clearX - 10 * s, Kit.textRight("tiny", ppText, clearX - 10 * s,
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption) ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption)
-- the row body opens the searchable picker; the x empties the slot -- the row body cycles, the x empties: two targets, no modal picker
if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then
Ops.openMovePicker(S, Kit, slot) Ops.cycleMove(S, mon, slot)
end end
if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x", if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x",
{ kind = "danger", font = "tiny", radius = 6 * s }) then { kind = "danger", font = "tiny", radius = 6 * s }) then
@@ -264,10 +264,10 @@ function MonEditor.draw(S, Kit, x, y, w, h)
-- text keeps room. -- text keeps room.
local narrow = inner < 470 * s local narrow = inner < 470 * s
local sprite = (narrow and 64 or 96) * s local sprite = (narrow and 64 or 96) * s
local rowH = math.max(Kit.tapMin(), 30 * s) local rowH = 30 * s
local rowGap = 8 * s local rowGap = 8 * s
local cellH = 52 * s local cellH = 52 * s
local actH = math.max(Kit.tapMin(), 34 * s) local actH = 34 * s
local hw = inner - sprite - 18 * s local hw = inner - sprite - 18 * s
local levelInHeader = hw >= levelRowWidth(Kit, mon) 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 local movesY = rowY + colRowsH + 14 * s
Kit.caption(cx, movesY, "MOVES") Kit.caption(cx, movesY, "MOVES")
Kit.textRight("tiny", "click a slot to search", cx + inner, movesY, PAL.caption) Kit.textRight("tiny", "click a slot to cycle", cx + inner, movesY, PAL.caption)
local mRowY = movesY + capH + 10 * s local mRowY = movesY + capH + 10 * s
drawMoveRows(S, Kit, mon, cx, mRowY, inner, rowH, rowGap) 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), Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0),
cx + colW, colY, PAL.caption) cx + colW, colY, PAL.caption)
Kit.caption(rightX, colY, "MOVES") Kit.caption(rightX, colY, "MOVES")
Kit.textRight("tiny", "click a slot to search", rightX + colW, colY, PAL.caption) Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption)
local rowY = colY + capH + 10 * s local rowY = colY + capH + 10 * s
drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
-136
View File
@@ -1,136 +0,0 @@
-- 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
+15 -14
View File
@@ -12,7 +12,6 @@
local Theme = require("Theme") local Theme = require("Theme")
local Ops = require("Ops") local Ops = require("Ops")
local MonEditor = require("MonEditor") local MonEditor = require("MonEditor")
local PickerChrome = require("PickerChrome")
local PAL = Theme.PAL local PAL = Theme.PAL
local Picker = {} local Picker = {}
@@ -60,42 +59,44 @@ function Picker.draw(S, Kit, width, height)
Kit.blockClicks = true Kit.blockClicks = true
end end
-- the scrim doubles as the "tap outside to cancel" target; it covers the -- the scrim doubles as the "tap outside to cancel" target
-- full window so unsafe bands (notch / home indicator) stay dimmed too
Theme.col(PAL.bgBot, 0.72) Theme.col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, width, height)
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a local w = math.min(width - 32 * s, 520 * s)
-- usable list (#917 / #715). local h = math.min(height - 32 * s, 560 * s)
local x, y, w, h, pad = PickerChrome.card(Kit, width, height) local x = (width - w) / 2
local y = (height - h) / 2
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
Ops.closeSpeciesPicker(S, Kit) Ops.closeSpeciesPicker(S, Kit)
return return
end end
Kit.card(x, y, w, h) Kit.card(x, y, w, h)
local pad = 18 * s
local cx, cy = x + pad, y + pad local cx, cy = x + pad, y + pad
local inner = w - 2 * pad local inner = w - 2 * pad
local closeW = PickerChrome.closeSize(Kit) Kit.caption(cx, cy, p.mode == "box-add"
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") and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES")
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x", local closeW = 30 * s
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
{ font = "small", radius = 7 * s }) then { font = "small", radius = 7 * s }) then
Ops.closeSpeciesPicker(S, Kit) Ops.closeSpeciesPicker(S, Kit)
return return
end end
cy = cy + headH + 10 * s cy = cy + Kit.textHeight("caption") + 10 * s
local fieldH = PickerChrome.fieldH(Kit) local fieldH = 34 * s
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query, p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
"type a name, an id, or a dex number") "type a name, an id, or a dex number")
cy = cy + fieldH + 10 * s cy = cy + fieldH + 10 * s
local hits = Picker.results(S) local hits = Picker.results(S)
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local rowH = 40 * s
local rowGap = 6 * s
local pagerH = 30 * s
local listH = (y + h - pad - pagerH - 10 * s) - cy
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) 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)) 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 -- wheel / touch drag scroll the modal list too; the shield is already