mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-26 07:21:22 +02:00
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
|
|
||||||
### Watch the latest update video
|
### Watch the latest update video
|
||||||
|
|
||||||
[](https://youtu.be/yi7LkWQPKKM)
|
[](https://youtu.be/0hgtLDNGpdo)
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua
|
||||||
|
-- world tables when GameVersion.isYellow(); Red/Blue never load this file's
|
||||||
|
-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal
|
||||||
|
-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON).
|
||||||
|
-- See #1639.
|
||||||
|
|
||||||
|
return {
|
||||||
|
world = {
|
||||||
|
-- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE
|
||||||
|
-- (closest Advanced indoor profile); colors lean sand / wood / water.
|
||||||
|
tileGroups = {
|
||||||
|
BEACH_HOUSE = {
|
||||||
|
[0] = 5,
|
||||||
|
[1] = 0,
|
||||||
|
[2] = 0,
|
||||||
|
[3] = 0,
|
||||||
|
[4] = 1,
|
||||||
|
[5] = 0,
|
||||||
|
[6] = 3,
|
||||||
|
[7] = 3,
|
||||||
|
[8] = 2,
|
||||||
|
[9] = 2,
|
||||||
|
[10] = 2,
|
||||||
|
[11] = 2,
|
||||||
|
[12] = 2,
|
||||||
|
[13] = 2,
|
||||||
|
[14] = 5,
|
||||||
|
[15] = 5,
|
||||||
|
[16] = 0,
|
||||||
|
[17] = 0,
|
||||||
|
[18] = 0,
|
||||||
|
[19] = 0,
|
||||||
|
[20] = 1,
|
||||||
|
[21] = 0,
|
||||||
|
[22] = 3,
|
||||||
|
[23] = 3,
|
||||||
|
[24] = 5,
|
||||||
|
[25] = 5,
|
||||||
|
[26] = 5,
|
||||||
|
[27] = 5,
|
||||||
|
[28] = 0,
|
||||||
|
[29] = 0,
|
||||||
|
[30] = 5,
|
||||||
|
[31] = 5,
|
||||||
|
[32] = 0,
|
||||||
|
[33] = 0,
|
||||||
|
[34] = 3,
|
||||||
|
[35] = 5,
|
||||||
|
[36] = 5,
|
||||||
|
[37] = 5,
|
||||||
|
[38] = 5,
|
||||||
|
[39] = 5,
|
||||||
|
[40] = 5,
|
||||||
|
[41] = 5,
|
||||||
|
[42] = 2,
|
||||||
|
[43] = 2,
|
||||||
|
[44] = 5,
|
||||||
|
[45] = 5,
|
||||||
|
[46] = 5,
|
||||||
|
[47] = 5,
|
||||||
|
[48] = 5,
|
||||||
|
[49] = 5,
|
||||||
|
[50] = 5,
|
||||||
|
[51] = 5,
|
||||||
|
[52] = 5,
|
||||||
|
[53] = 5,
|
||||||
|
[54] = 5,
|
||||||
|
[55] = 5,
|
||||||
|
[56] = 5,
|
||||||
|
[57] = 5,
|
||||||
|
[58] = 5,
|
||||||
|
[59] = 5,
|
||||||
|
[60] = 5,
|
||||||
|
[61] = 5,
|
||||||
|
[62] = 5,
|
||||||
|
[63] = 5,
|
||||||
|
[64] = 0,
|
||||||
|
[65] = 0,
|
||||||
|
[66] = 0,
|
||||||
|
[67] = 0,
|
||||||
|
[68] = 0,
|
||||||
|
[69] = 0,
|
||||||
|
[70] = 5,
|
||||||
|
[71] = 5,
|
||||||
|
[72] = 5,
|
||||||
|
[73] = 5,
|
||||||
|
[74] = 5,
|
||||||
|
[75] = 5,
|
||||||
|
[76] = 5,
|
||||||
|
[77] = 5,
|
||||||
|
[78] = 5,
|
||||||
|
[79] = 5,
|
||||||
|
[80] = 5,
|
||||||
|
[81] = 5,
|
||||||
|
[82] = 5,
|
||||||
|
[83] = 5,
|
||||||
|
[84] = 5,
|
||||||
|
[85] = 5,
|
||||||
|
[86] = 5,
|
||||||
|
[87] = 5,
|
||||||
|
[88] = 5,
|
||||||
|
[89] = 5,
|
||||||
|
[90] = 5,
|
||||||
|
[91] = 5,
|
||||||
|
[92] = 5,
|
||||||
|
[93] = 5,
|
||||||
|
[94] = 5,
|
||||||
|
[95] = 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupColors = {
|
||||||
|
BEACH_HOUSE = {
|
||||||
|
-- sand / wood floor
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 206, 173, 115 },
|
||||||
|
{ 156, 123, 74 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- signs / accents (warm coral)
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 255, 156, 148 },
|
||||||
|
{ 230, 82, 66 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- foliage
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 123, 189, 82 },
|
||||||
|
{ 66, 123, 41 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- water / windows
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 99, 189, 230 },
|
||||||
|
{ 41, 115, 189 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- yellow props
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 255, 255, 82 },
|
||||||
|
{ 230, 148, 25 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- wood walls / furniture
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 189, 140, 74 },
|
||||||
|
{ 140, 99, 41 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- sky / cool trim
|
||||||
|
{
|
||||||
|
{ 255, 239, 198 },
|
||||||
|
{ 148, 189, 255 },
|
||||||
|
{ 99, 148, 230 },
|
||||||
|
{ 58, 58, 58 },
|
||||||
|
},
|
||||||
|
-- text
|
||||||
|
{
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 0, 0, 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72.
|
||||||
|
-- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James);
|
||||||
|
-- Red's ball/fossil/snorlax/... shift to 70-81.
|
||||||
|
spriteAssignment = {
|
||||||
|
[0] = 0,
|
||||||
|
[1] = 1,
|
||||||
|
[2] = 3,
|
||||||
|
[3] = "random",
|
||||||
|
[4] = 0,
|
||||||
|
[5] = "random",
|
||||||
|
[6] = "random",
|
||||||
|
[7] = "random",
|
||||||
|
[8] = 0,
|
||||||
|
[9] = "random",
|
||||||
|
[10] = "random",
|
||||||
|
[11] = "random",
|
||||||
|
[12] = "random",
|
||||||
|
[13] = "random",
|
||||||
|
[14] = "random",
|
||||||
|
[15] = 1,
|
||||||
|
[16] = 1,
|
||||||
|
[17] = "random",
|
||||||
|
[18] = "random",
|
||||||
|
[19] = "random",
|
||||||
|
[20] = "random",
|
||||||
|
[21] = 2,
|
||||||
|
[22] = 1,
|
||||||
|
[23] = 3,
|
||||||
|
[24] = "random",
|
||||||
|
[25] = "random",
|
||||||
|
[26] = "random",
|
||||||
|
[27] = "random",
|
||||||
|
[28] = "random",
|
||||||
|
[29] = 0,
|
||||||
|
[30] = 3,
|
||||||
|
[31] = 3,
|
||||||
|
[32] = "random",
|
||||||
|
[33] = "random",
|
||||||
|
[34] = "random",
|
||||||
|
[35] = "random",
|
||||||
|
[36] = "random",
|
||||||
|
[37] = "random",
|
||||||
|
[38] = "random",
|
||||||
|
[39] = "random",
|
||||||
|
[40] = 0,
|
||||||
|
[41] = 2,
|
||||||
|
[42] = "random",
|
||||||
|
[43] = "random",
|
||||||
|
[44] = "random",
|
||||||
|
[45] = "random",
|
||||||
|
[46] = "random",
|
||||||
|
[47] = "random",
|
||||||
|
[48] = "random",
|
||||||
|
[49] = "random",
|
||||||
|
[50] = "random",
|
||||||
|
[51] = "random",
|
||||||
|
[52] = "random",
|
||||||
|
[53] = "random",
|
||||||
|
[54] = "random",
|
||||||
|
[55] = 0,
|
||||||
|
[56] = 1,
|
||||||
|
[57] = 3,
|
||||||
|
[58] = 0,
|
||||||
|
[59] = 0,
|
||||||
|
[60] = 4, -- SPRITE_PIKACHU
|
||||||
|
[61] = 1, -- SPRITE_OFFICER_JENNY
|
||||||
|
[62] = 3, -- SPRITE_SANDSHREW
|
||||||
|
[63] = 2, -- SPRITE_ODDISH
|
||||||
|
[64] = 2, -- SPRITE_BULBASAUR
|
||||||
|
[65] = "random", -- SPRITE_JIGGLYPUFF
|
||||||
|
[66] = "random", -- SPRITE_CLEFAIRY
|
||||||
|
[67] = "random", -- SPRITE_CHANSEY
|
||||||
|
[68] = "random", -- SPRITE_JESSIE
|
||||||
|
[69] = "random", -- SPRITE_JAMES
|
||||||
|
[70] = 0, -- SPRITE_POKE_BALL
|
||||||
|
[71] = 0, -- SPRITE_FOSSIL
|
||||||
|
[72] = 3, -- SPRITE_BOULDER
|
||||||
|
[73] = 3, -- SPRITE_PAPER
|
||||||
|
[74] = 0, -- SPRITE_POKEDEX
|
||||||
|
[75] = 3, -- SPRITE_CLIPBOARD
|
||||||
|
[76] = 0, -- SPRITE_SNORLAX
|
||||||
|
[77] = 3, -- SPRITE_UNUSED_OLD_AMBER
|
||||||
|
[78] = 3, -- SPRITE_OLD_AMBER
|
||||||
|
[79] = "random",
|
||||||
|
[80] = "random",
|
||||||
|
[81] = 3, -- SPRITE_GAMBLER_ASLEEP
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]).
|
||||||
|
spritePalettes = {
|
||||||
|
[4] = {
|
||||||
|
{ 222, 255, 222 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 230, 115, 0 },
|
||||||
|
{ 0, 0, 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -505,6 +505,42 @@ Menu choices and moves use the same engine methods as the native controls;
|
|||||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
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
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Two decisions in a Red/Blue/Yellow battle are made for the player by the
|
||||||
|
OPTION screen and by the cart, and a game mode has no way to make them
|
||||||
|
instead:
|
||||||
|
|
||||||
|
**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the
|
||||||
|
battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon
|
||||||
|
faints, SET does not. The engine reads `save.options.battleStyle` inline at
|
||||||
|
that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale
|
||||||
|
where party-as-health is the whole design — can only get it by writing the
|
||||||
|
player's saved preference, which leaks into their real playthrough the first
|
||||||
|
time anything calls `Game:writeOptions` (the speed hotkey does, on every
|
||||||
|
press).
|
||||||
|
|
||||||
|
**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox`
|
||||||
|
both run `AskName` after a capture. A mode with a disposable team, a
|
||||||
|
randomizer that names what it hands out, a speedrun practice mod — all want to
|
||||||
|
answer that prompt themselves, and the only way today is to drive the yes/no
|
||||||
|
box from outside. The script-gift path already has this seam: a mod that sets
|
||||||
|
`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does
|
||||||
|
not.
|
||||||
|
|
||||||
|
The immediate consumer is a battle-royale mode; neither hook is specific to it.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
This extends the **additive, guarded seam convention** Route B in
|
||||||
|
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
||||||
|
`tests/engine/gate_meta_coverage.lua` enforces. `catch.nickname` mirrors a
|
||||||
|
contract that already exists for gifts (`pokemon.before_give`'s
|
||||||
|
`gift.nickname`), so the two ways a Pokémon joins the party answer the same
|
||||||
|
question the same way.
|
||||||
|
|
||||||
|
There is no in-repo D-number registry to amend.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### New hook: `battle.style`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.style", function(next, battle)
|
||||||
|
if myMode.active then return "set" end
|
||||||
|
return next(battle) -- the OPTION row, as today
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `BattleState:battleStyle()`, called from the enemy send-out path at
|
||||||
|
the moment the SHIFT prompt would be offered. The vanilla link reads
|
||||||
|
`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the
|
||||||
|
inline read did. A return of `"set"` or `"shift"` is used; anything else reads
|
||||||
|
as the vanilla answer, so a hook that returns nothing by mistake cannot change
|
||||||
|
the rule. The player's saved preference is never written.
|
||||||
|
|
||||||
|
### New hook: `catch.nickname`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||||
|
-- ctx = { battle = <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.
|
||||||
@@ -384,12 +384,14 @@ 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. In-process EXIT GAME resets it in
|
-- Gen1 Game is a module singleton. Always re-require after in-process
|
||||||
-- place; if a prior teardown left load missing, rebuild from source.
|
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
|
||||||
|
-- is nil (release Android: bootGame then dies with load-a-nil-value).
|
||||||
|
-- rawget: type(mod.load) can lie via __index and skip a rebuild.
|
||||||
|
package.loaded["src.core.Game"] = nil
|
||||||
local gameMod = require("src.core.Game")
|
local gameMod = require("src.core.Game")
|
||||||
if type(gameMod.load) ~= "function" then
|
if type(rawget(gameMod, "load")) ~= "function" then
|
||||||
package.loaded["src.core.Game"] = nil
|
error("src.core.Game missing load after reload")
|
||||||
gameMod = require("src.core.Game")
|
|
||||||
end
|
end
|
||||||
Game = gameMod
|
Game = gameMod
|
||||||
Game:load()
|
Game:load()
|
||||||
@@ -1125,15 +1127,23 @@ 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 (isAndroid or not launchedIntoGame)
|
and (inProcessReturn or not launchedIntoGame)
|
||||||
end)
|
end)
|
||||||
if wouldReturnToLauncher then
|
if wouldReturnToLauncher then
|
||||||
if isAndroid then
|
if inProcessReturn then
|
||||||
returnToLauncher()
|
returnToLauncher()
|
||||||
return true -- abort this quit; the restart lands back in the launcher
|
return true -- abort this quit; stay in the same LOVE run
|
||||||
end
|
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
|
bool System::pickFile(const char *kind, const char *destination) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
const char *dest = "picked_rom.gb";
|
const char *dest = "picked_rom.gb";
|
||||||
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
|
|||||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
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 = "picked_required_import.bin";
|
dest = (destination != nullptr && destination[0] != '\0')
|
||||||
|
? 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;
|
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
|
||||||
virtual const char *pickFileKinds() const;
|
virtual const char *pickFileKinds() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ 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);
|
||||||
luax_pushboolean(L, instance()->pickFile(kind));
|
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||||
|
luax_pushboolean(L, instance()->pickFile(kind, destination));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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;
|
||||||
@@ -111,6 +112,8 @@ 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
|
||||||
@@ -576,6 +579,21 @@ 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) {
|
||||||
@@ -593,14 +611,30 @@ 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 : "";
|
||||||
// Reject path separators so a hostile JNI caller cannot escape the
|
// Basename destinations keep the historical ROM/mod/save staging path.
|
||||||
// save identity directory.
|
// A nested destination is accepted only for an engine-generated mod
|
||||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
// baseroms path, then canonicalized beneath LOVE's mounted save root.
|
||||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
String normalizedDest = destFilename.replace('\\', '/');
|
||||||
|
boolean nested = normalizedDest.indexOf('/') >= 0;
|
||||||
|
if (nested && !isDirectRequiredDestination(normalizedDest)) {
|
||||||
|
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
|
||||||
|
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
|
||||||
|
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||||
|
if (destCanonical.equals(rootCanonical)
|
||||||
|
|| !destCanonical.getPath().startsWith(rootPrefix)) {
|
||||||
|
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pendingPickFilename = destFilename;
|
self.pendingPickFilename = normalizedDest;
|
||||||
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);
|
||||||
@@ -1277,13 +1311,7 @@ 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) {
|
||||||
try {
|
writeFlagFile(saveIdentityDir(), name, body);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "could not create " + destDir);
|
Log.d("GameActivity", "could not create " + destDir);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String destName = pendingPickFilename != null
|
final String destName = pendingPickFilename != null
|
||||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||||
File destFile = new File(destDir, destName);
|
final boolean directRequired = isDirectRequiredDestination(destName);
|
||||||
|
final File destFile;
|
||||||
|
try {
|
||||||
|
File rootCanonical = destDir.getCanonicalFile();
|
||||||
|
destFile = new File(rootCanonical, destName).getCanonicalFile();
|
||||||
|
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||||
|
if (destFile.equals(rootCanonical)
|
||||||
|
|| !destFile.getPath().startsWith(rootPrefix)
|
||||||
|
|| (destName.indexOf('/') >= 0 && !directRequired)) {
|
||||||
|
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
|
||||||
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
|
||||||
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
// 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
|
||||||
@@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity {
|
|||||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!copyAssetFile(source, destFile.getPath())) {
|
final InputStream pickedSource = source;
|
||||||
|
final File pickedRoot = destDir;
|
||||||
|
if (directRequired) {
|
||||||
|
// Optical-disc-sized imports must not block Android's UI thread and
|
||||||
|
// must not create a second picked_required_import.bin copy.
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
|
||||||
|
if (!result.ok) {
|
||||||
|
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
|
||||||
|
+ Long.toString(result.bytes) + "\n";
|
||||||
|
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
|
||||||
|
}
|
||||||
|
}, "gen1recomp-required-import").start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copyAssetFile(pickedSource, destFile.getPath())) {
|
||||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
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.
|
||||||
*
|
*
|
||||||
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
|
|||||||
assert (source != null && destination != null);
|
assert (source != null && destination != null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] buf = new byte[1024];
|
byte[] buf = new byte[1024 * 1024];
|
||||||
chunk_read = source.read(buf);
|
while ((chunk_read = source.read(buf)) != -1) {
|
||||||
do {
|
if (chunk_read == 0) continue;
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
@objc(GRPickerBridge)
|
@objc(GRPickerBridge)
|
||||||
public final class GRPickerBridge: NSObject {
|
public final class GRPickerBridge: NSObject {
|
||||||
@@ -190,9 +191,17 @@ 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 {
|
||||||
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
|
|||||||
case "sav":
|
case "sav":
|
||||||
destName = "picked_save.sav"
|
destName = "picked_save.sav"
|
||||||
case "required_import":
|
case "required_import":
|
||||||
destName = "picked_required_import.bin"
|
if let requestedDestination,
|
||||||
|
isDirectRequiredDestination(requestedDestination),
|
||||||
|
safeDestination(in: dir, relative: requestedDestination) != nil {
|
||||||
|
destName = requestedDestination
|
||||||
|
} else {
|
||||||
|
destName = "picked_required_import.bin"
|
||||||
|
}
|
||||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
// 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
|
||||||
@@ -234,12 +249,18 @@ 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: true)
|
asCopy: !directRequired)
|
||||||
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 }
|
||||||
copyItem(at: src, into: dir, named: destName)
|
if directRequired {
|
||||||
|
copyRequiredItemAsync(at: src, into: dir, relative: destName)
|
||||||
|
} else {
|
||||||
|
copyItem(at: src, into: dir, named: destName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return present(picker, with: delegate)
|
return present(picker, with: delegate)
|
||||||
}
|
}
|
||||||
@@ -410,6 +431,82 @@ 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() } }
|
||||||
@@ -426,7 +523,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.txt"))
|
.write(to: dir.appendingPathComponent("pick_error.flag"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,20 @@ 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");
|
||||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||||
|
Class cls = objc_getClass("GRPickerBridge");
|
||||||
|
if (cls == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushboolean(L, 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
|
||||||
|
const char *);
|
||||||
|
signed char ok = ((GRPick)objc_msgSend)(
|
||||||
|
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
|
||||||
|
kind, gr_saveDirectory(), destination);
|
||||||
|
lua_pushboolean(L, ok != 0);
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ 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
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ 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
|
||||||
|
|||||||
@@ -4531,8 +4531,7 @@ 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 = tostring((self.game.save.options or {}).battleStyle or "shift")
|
local style = self:battleStyle()
|
||||||
: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
|
||||||
@@ -5100,6 +5099,57 @@ 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).
|
||||||
@@ -5180,11 +5230,7 @@ function BattleState:storeCaughtMon()
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
local function askCaughtNickname()
|
local function askCaughtNickname()
|
||||||
local caught = self.enemy.mon
|
self:offerNickname(self.enemy.mon, self.enemy.name)
|
||||||
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()
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ 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
|
||||||
@@ -201,6 +205,25 @@ 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
|
||||||
|
|||||||
+17
-1
@@ -158,7 +158,15 @@ 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.
|
||||||
-- On every other platform the in-process restart works, so keep it.
|
-- iOS is the same class of problem with a sharper edge: love.cpp under
|
||||||
|
-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic
|
||||||
|
-- exit) and comments that leftover threads make that restart unreliable --
|
||||||
|
-- which our ChipAudio / Fetch / Check workers are. There is no
|
||||||
|
-- restartApp bridge on iOS, so callers that want "back to launcher" must
|
||||||
|
-- use main.lua's in-process returnToLauncher (love.quit aborts the quit);
|
||||||
|
-- HostShell.restart itself refuses quit("restart") and falls back to a
|
||||||
|
-- bare quit() so a mod that still calls restart does not pick the worst
|
||||||
|
-- path on purpose.
|
||||||
function HostShell.restart()
|
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
|
||||||
|
|
||||||
@@ -174,6 +182,14 @@ 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
|
||||||
|
|||||||
@@ -227,7 +227,11 @@ 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()
|
||||||
add("App", version ~= "" and Version.title() or "gen1recomp")
|
-- Bug reports always want the stamped engine when we have one; window
|
||||||
|
-- chrome hides it on release builds (Version.title). Unstamped
|
||||||
|
-- working-tree builds stay as plain "gen1recomp" so the placeholder
|
||||||
|
-- never lands in a filed issue.
|
||||||
|
add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp")
|
||||||
add("LÖVE", loveVersion())
|
add("LÖVE", loveVersion())
|
||||||
if safeMode then add("Safe mode", "on") end
|
if safeMode then add("Safe mode", "on") end
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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
|
||||||
@@ -64,11 +66,18 @@ 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"].shutdown)
|
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
|
||||||
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)
|
||||||
@@ -87,11 +96,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. If teardown left it without load,
|
-- Gen1 Game is the module singleton. Always drop the cached module after
|
||||||
-- drop the cached module so the next require rebuilds a clean table
|
-- a session that owned it so the next bootGame require rebuilds a clean
|
||||||
-- (bootGame also guards this; doing it here keeps Play-again reliable).
|
-- table. A type(game.load) check is not enough: a wrong table parked in
|
||||||
if game and type(game.load) ~= "function"
|
-- package.loaded (e.g. with __index) can still look like it has load.
|
||||||
and package.loaded["src.core.Game"] == game then
|
if game and package.loaded["src.core.Game"] == game then
|
||||||
package.loaded["src.core.Game"] = nil
|
package.loaded["src.core.Game"] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -22,10 +22,22 @@ local Version = {
|
|||||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
|
-- True for the working-tree placeholder and any stamped "-dev" pre-release.
|
||||||
|
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
|
||||||
|
function Version.isDev()
|
||||||
|
local engine = tostring(Version.engine or "")
|
||||||
|
return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Window / chrome title. Dev builds keep the version visible
|
||||||
|
-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux
|
||||||
|
-- window chrome and taskbars do not read "gen1recomp 0.1.73".
|
||||||
function Version.title(base)
|
function Version.title(base)
|
||||||
return (base or "gen1recomp")
|
base = base or "gen1recomp"
|
||||||
.. " v" .. Version.engine
|
if Version.isDev() then
|
||||||
|
return base .. " v" .. Version.engine
|
||||||
|
end
|
||||||
|
return base
|
||||||
end
|
end
|
||||||
|
|
||||||
return Version
|
return Version
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ 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")
|
||||||
|
|||||||
@@ -1535,6 +1535,14 @@ 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
|
||||||
@@ -1623,13 +1631,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, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||||
end
|
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, obClear[e[1]], e[2], e[3], e[4])
|
blitSprite(pikachu, eyeOb[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
|
||||||
|
|||||||
+150
-2
@@ -9,6 +9,9 @@ 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
|
||||||
@@ -1582,8 +1585,11 @@ 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 pickError:find("picked_required_import", 1, true)
|
if self.pickerPendingKind == "required_import"
|
||||||
|
or pickError:find("picked_required_import", 1, true)
|
||||||
or pickError:find("picked_stadium", 1, true)
|
or pickError:find("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
|
||||||
@@ -1606,6 +1612,22 @@ 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
|
||||||
@@ -2059,6 +2081,122 @@ 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
|
||||||
@@ -2218,7 +2356,15 @@ 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
|
||||||
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
|
-- Raw imports can go straight to their final private destination on current
|
||||||
|
-- Android/iOS bridges. N64 stays on staging because the launcher still has
|
||||||
|
-- to canonicalize byte order/copier headers before storing it.
|
||||||
|
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
|
||||||
|
and spec.format ~= "n64"
|
||||||
|
and type(manifest.path) == "string" and manifest.path ~= "")
|
||||||
|
and require("src.mods.RequiredImports").path(manifest, spec) or nil
|
||||||
|
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
|
||||||
|
directDestination) then
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
self.pickerPendingModId = nil
|
self.pickerPendingModId = nil
|
||||||
self.pickerPendingImportId = nil
|
self.pickerPendingImportId = nil
|
||||||
@@ -2549,6 +2695,8 @@ 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()
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ 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
|
||||||
@@ -280,7 +288,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
|
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
|
||||||
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
|
||||||
@@ -293,21 +301,23 @@ 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)
|
-- (not an accuracy stage); vanilla prints only the used line
|
||||||
b.xAccuracy = true
|
b.xAccuracy = true
|
||||||
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
return "consumed", { used }, USE_JINGLE
|
||||||
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 removes the item BEFORE running the stat-up
|
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
|
||||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
|
||||||
-- just prints "Nothing happened!"
|
|
||||||
if cur >= 6 then
|
if cur >= 6 then
|
||||||
return "consumed", { romText(data, "_NothingHappenedText",
|
return "consumed", { used }, {
|
||||||
"Nothing happened!") }
|
useJingle = true,
|
||||||
|
afterMessages = { romText(data, "_NothingHappenedText",
|
||||||
|
"Nothing happened!") },
|
||||||
|
}
|
||||||
end
|
end
|
||||||
b.stages[stat] = cur + 1
|
b.stages[stat] = cur + 1
|
||||||
b.hazeStatReset = nil
|
b.hazeStatReset = nil
|
||||||
@@ -315,26 +325,29 @@ 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", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
|
return "consumed", { used }, {
|
||||||
|
useJingle = true,
|
||||||
|
afterMessages = { Strings("%s's\n%s rose!", b.name,
|
||||||
|
Strings(STAT_LABEL[stat])) },
|
||||||
|
}
|
||||||
end
|
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
|
-- the item, even when it is already active; vanilla prints only used
|
||||||
if itemId == "DIRE_HIT" then
|
if itemId == "DIRE_HIT" then
|
||||||
b.focusEnergy = true
|
b.focusEnergy = true
|
||||||
return "consumed", { romText(data, "_GettingPumpedText",
|
return "consumed", { used }, USE_JINGLE
|
||||||
"%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", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
return "consumed", { used }, USE_JINGLE
|
||||||
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
|
||||||
return "consumed_escape", { romText(data, "_WildRanText",
|
-- PrintItemUseTextAndRemoveItem then escape
|
||||||
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
return "consumed_escape", { used }, USE_JINGLE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -613,7 +626,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", { Strings("%s used\n%s!", save.player.name, name) }
|
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
|
||||||
end
|
end
|
||||||
|
|
||||||
return "failed", { notTime(data, save) }
|
return "failed", { notTime(data, save) }
|
||||||
|
|||||||
+14
-2
@@ -147,12 +147,19 @@ 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
|
||||||
return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
local path = source:sub(2)
|
||||||
|
if ENGINE_CHUNKS[path] then return false end
|
||||||
|
return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scanRequire(name)
|
local function scanRequire(name)
|
||||||
@@ -207,6 +214,11 @@ 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
|
||||||
@@ -234,7 +246,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 ~= 1 and Gen2Compat.serves(name)
|
if devShim.generation == 2 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
|
||||||
|
|||||||
+49
-13
@@ -20,6 +20,8 @@ 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,
|
||||||
@@ -300,6 +302,38 @@ 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"
|
||||||
@@ -453,7 +487,10 @@ 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
|
||||||
if PaletteFX.usesYellowCgb() then
|
-- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes
|
||||||
|
-- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World
|
||||||
|
-- tile bake still comes from the Advanced GBC pack via worldPack (#1639).
|
||||||
|
if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then
|
||||||
local fromCgb = yellowCgbNamedPal(data, name)
|
local fromCgb = yellowCgbNamedPal(data, name)
|
||||||
if fromCgb then return fromCgb end
|
if fromCgb then return fromCgb end
|
||||||
end
|
end
|
||||||
@@ -511,7 +548,8 @@ 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() then
|
if PaletteFX.usesYellowCgb()
|
||||||
|
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
|
||||||
@@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel
|
|||||||
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
|
-- (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 pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
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 pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
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]
|
||||||
@@ -642,8 +678,7 @@ 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 pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
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
|
||||||
@@ -678,17 +713,18 @@ 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 pack = PaletteFX.gbcPack()
|
local w = PaletteFX.worldPack()
|
||||||
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]).
|
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
|
||||||
if not idx and (src:find("RedBikeSprite", 1, true)
|
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
|
||||||
or src:find("SurfingPikachuSprite", 1, true)) then
|
if not idx and src:find("RedBikeSprite", 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
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ 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
|
||||||
@@ -1045,7 +1046,8 @@ 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
|
||||||
love.graphics.setColor(0, 0, 0, fade)
|
local c = self.worldFadeColor or { 0, 0, 0 }
|
||||||
|
love.graphics.setColor(c[1], c[2], c[3], fade)
|
||||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
@@ -1132,7 +1134,8 @@ 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
|
||||||
love.graphics.setColor(0, 0, 0, fade)
|
local c = self.worldFadeColor or { 0, 0, 0 }
|
||||||
|
love.graphics.setColor(c[1], c[2], c[3], fade)
|
||||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -65,16 +65,24 @@ 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 = style.frames or FRAMES
|
self.frames = opts.frames or style.frames or FRAMES
|
||||||
if warp then
|
if opts.framesIn ~= nil then
|
||||||
|
self.framesIn = opts.framesIn
|
||||||
|
elseif warp then
|
||||||
-- a style may still ask for a fade in (mods, and the record is
|
-- 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
|
||||||
@@ -125,6 +133,7 @@ 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
|
||||||
@@ -133,9 +142,10 @@ 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(0, 0, 0, alpha)
|
love.graphics.setColor(c[1], c[2], c[3], 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
|
||||||
|
|||||||
+19
-4
@@ -54,6 +54,21 @@ 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
|
||||||
@@ -115,7 +130,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()
|
||||||
showMessages(game, payload, function()
|
showUseMessages(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
|
||||||
@@ -125,7 +140,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)
|
end, extra)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -403,9 +418,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
end
|
end
|
||||||
if battle then
|
if battle then
|
||||||
list:close()
|
list:close()
|
||||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra)
|
||||||
else
|
else
|
||||||
showMessages(game, payload, closePicker)
|
showUseMessages(game, payload, closePicker, extra)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4818,6 +4818,15 @@ 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
|
||||||
@@ -4884,7 +4893,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, true)) -- warp shape: no fade back in (LoadGBPal restores in one write)
|
end, not specialWarp, fadeOpts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
|
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
|
||||||
|
|||||||
@@ -109,9 +109,35 @@ 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(again.load) == "function",
|
check(type(rawget(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")
|
||||||
|
|||||||
@@ -118,8 +118,13 @@ 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('dest = "picked_required_import.bin"', 1, true)
|
and system:find('destination != nullptr', 1, true)
|
||||||
|
and system:find('"picked_required_import.bin"', 1, true)
|
||||||
and system:find('return "rom,mod,sav,required_import"', 1, true),
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("gen2 shim engine require")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local Loader = require("src.mods.Loader")
|
||||||
|
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||||
|
|
||||||
|
local NAME = "src.core.Game"
|
||||||
|
local REAL = { load = function() end }
|
||||||
|
|
||||||
|
local function memfs(files)
|
||||||
|
return {
|
||||||
|
files = files,
|
||||||
|
read = function(path) return files[path] end,
|
||||||
|
write = function(path, content) files[path] = content return true end,
|
||||||
|
remove = function(path) files[path] = nil return true end,
|
||||||
|
getInfo = function(path)
|
||||||
|
if files[path] then return { type = "file" } end
|
||||||
|
local prefix = path .. "/"
|
||||||
|
for key in pairs(files) do
|
||||||
|
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
load = function(path)
|
||||||
|
if not files[path] then return nil, "no file: " .. path end
|
||||||
|
return load(files[path], path)
|
||||||
|
end,
|
||||||
|
createDirectory = function() return true end,
|
||||||
|
getDirectoryItems = function(path)
|
||||||
|
local seen, items = {}, {}
|
||||||
|
local prefix = path .. "/"
|
||||||
|
for key in pairs(files) do
|
||||||
|
if key:sub(1, #prefix) == prefix then
|
||||||
|
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||||
|
if child and not seen[child] then
|
||||||
|
seen[child] = true
|
||||||
|
items[#items + 1] = child
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(items)
|
||||||
|
return items
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requireFrom(source)
|
||||||
|
local chunk = assert(loadstring(
|
||||||
|
"local name = ... local mod = require(name) return mod", "@" .. source))
|
||||||
|
return chunk(NAME)
|
||||||
|
end
|
||||||
|
|
||||||
|
local savedVersion = GameVersion.get()
|
||||||
|
local savedModule = package.loaded[NAME]
|
||||||
|
package.loaded[NAME] = REAL
|
||||||
|
|
||||||
|
GameVersion.set("gold")
|
||||||
|
local files = {
|
||||||
|
["mods/facade/manifest.json"] =
|
||||||
|
[[{"id":"facade","name":"facade","version":"1.0.0","entry":"main.lua",]]
|
||||||
|
.. [["gen2compat":true}]],
|
||||||
|
["mods/facade/main.lua"] = "return function(mod) end",
|
||||||
|
}
|
||||||
|
local loader = Loader.new({ fs = memfs(files) })
|
||||||
|
loader:load({ pokemon = {} })
|
||||||
|
|
||||||
|
eq(loader.generation, 2, "the fixture loader is a Gen 2 one")
|
||||||
|
check(Gen2Compat.serves(NAME), "the facade serves " .. NAME)
|
||||||
|
|
||||||
|
local facade = Gen2Compat.resolve(NAME)
|
||||||
|
eq(requireFrom("mods/facade/main.lua"), facade,
|
||||||
|
"a mod's require still resolves to the Gen 2 facade")
|
||||||
|
eq(requireFrom("main.lua"), REAL,
|
||||||
|
"main.lua's require is the engine's own, never the facade")
|
||||||
|
eq(requireFrom("conf.lua"), REAL, "conf.lua's require is the engine's own")
|
||||||
|
|
||||||
|
SessionLifecycle.endMountedSession()
|
||||||
|
eq(requireFrom("mods/facade/main.lua"), REAL,
|
||||||
|
"after the session ends the facade stops answering for the next boot")
|
||||||
|
|
||||||
|
package.loaded[NAME] = savedModule
|
||||||
|
GameVersion.set(savedVersion)
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
-- 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. Desktop keeps the in-process quit("restart").
|
-- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART
|
||||||
|
-- for every quit; HostShell.restart must still refuse quit("restart") so a
|
||||||
|
-- leftover caller does not pick the worker-join + native-restart path that
|
||||||
|
-- crashes EXIT GAME. Desktop keeps the in-process quit("restart").
|
||||||
-- luajit tests/engine/host_restart_android_bug575.lua
|
-- luajit tests/engine/host_restart_android_bug575.lua
|
||||||
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
@@ -48,12 +51,18 @@ HostShell.restart()
|
|||||||
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
|
eq(#quits, 2, "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[3] and quits[3].arg, "restart",
|
eq(quits[4] and quits[4].arg, "restart",
|
||||||
"non-Android still restarts in-process")
|
"non-mobile still restarts in-process")
|
||||||
end
|
end
|
||||||
|
|
||||||
T.finish("host_restart_android_bug575")
|
T.finish("host_restart_android_bug575")
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- #1635: PrintItemUseTextAndRemoveItem items must request Heal_Ailment
|
||||||
|
-- via extra.useJingle after the "X used Y!" line.
|
||||||
|
--
|
||||||
|
-- ROM-free: stubs items/text only (CI headless has no data/generated/).
|
||||||
|
--
|
||||||
|
-- luajit tests/engine/item_use_jingle_bug1635.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("item use Heal_Ailment jingle #1635")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local ItemEffects = require("src.inventory.ItemEffects")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local ITEM_IDS = {
|
||||||
|
"REPEL", "SUPER_REPEL", "MAX_REPEL",
|
||||||
|
"X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL",
|
||||||
|
"X_ACCURACY", "DIRE_HIT", "GUARD_SPEC", "POKE_DOLL",
|
||||||
|
}
|
||||||
|
|
||||||
|
local Data = { items = {}, text = {} }
|
||||||
|
for _, id in ipairs(ITEM_IDS) do
|
||||||
|
Data.items[id] = { name = id:gsub("_", " ") }
|
||||||
|
end
|
||||||
|
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.name = "RED"
|
||||||
|
|
||||||
|
local function battleStub(playerName)
|
||||||
|
return {
|
||||||
|
kind = "wild",
|
||||||
|
player = {
|
||||||
|
name = playerName or "PIKACHU",
|
||||||
|
stages = { attack = 0, defense = 0, speed = 0, special = 0, accuracy = 0 },
|
||||||
|
mon = {},
|
||||||
|
},
|
||||||
|
enemy = { name = "RATTATA" },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function assertJingle(itemId, battle)
|
||||||
|
local result, msgs, extra = ItemEffects.use(Data, save, itemId, nil, battle)
|
||||||
|
check(result == "consumed" or result == "consumed_escape",
|
||||||
|
itemId .. " succeeds (" .. tostring(result) .. ")")
|
||||||
|
check(extra and extra.useJingle, itemId .. " sets useJingle")
|
||||||
|
check(msgs and msgs[1] and msgs[1]:find("used"),
|
||||||
|
itemId .. " prints used line")
|
||||||
|
return result, msgs, extra
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, id in ipairs({ "REPEL", "SUPER_REPEL", "MAX_REPEL" }) do
|
||||||
|
assertJingle(id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
local b = battleStub()
|
||||||
|
for _, id in ipairs({
|
||||||
|
"X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL",
|
||||||
|
"X_ACCURACY", "DIRE_HIT", "GUARD_SPEC",
|
||||||
|
}) do
|
||||||
|
local _, _, extra = assertJingle(id, b)
|
||||||
|
if id == "X_ATTACK" then
|
||||||
|
check(extra.afterMessages and #extra.afterMessages > 0,
|
||||||
|
"X_ATTACK follows used line with effect text")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local _, _, dollExtra = assertJingle("POKE_DOLL", battleStub())
|
||||||
|
eq(dollExtra.afterMessages, nil, "Poké Doll is used-line only")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -64,12 +64,17 @@ 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(Game.load) == "function",
|
check(type(before.load) == "function",
|
||||||
"endGameSession leaves Game.load intact for the next bootGame")
|
"endGameSession reset leaves methods on the old table")
|
||||||
check(package.loaded["src.core.Game"] == Game
|
check(package.loaded["src.core.Game"] == nil,
|
||||||
or type((package.loaded["src.core.Game"] or {}).load) == "function",
|
"endGameSession drops Gen1 Game from package.loaded")
|
||||||
"Gen1 Game module remains require-able after endGameSession")
|
local again = require("src.core.Game")
|
||||||
|
check(type(rawget(again, "load")) == "function",
|
||||||
|
"require rebuilds a bootable Gen1 Game after endGameSession")
|
||||||
|
check(again ~= before, "Play-again uses a fresh Gen1 Game module table")
|
||||||
|
Game = again
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- #1743: Mt. Moon B2F Super Nerd is text_asm with no def_trainers row, so
|
||||||
|
-- Yellow never gets trainerHeaders.MtMoonB2F[1]. Without a seed,
|
||||||
|
-- engageTrainer falls through to the "I like shorts!" fallback.
|
||||||
|
--
|
||||||
|
-- luajit tests/engine/mt_moon_super_nerd_header_bug1743.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("mt moon super nerd header #1743")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
|
||||||
|
local empty = { trainer_headers = {} }
|
||||||
|
Data.seedMtMoonB2FSuperNerd(empty)
|
||||||
|
local seeded = empty.trainer_headers.MtMoonB2F[1]
|
||||||
|
check(seeded ~= nil, "seed fills MtMoonB2F[1] when missing")
|
||||||
|
eq(seeded.battle, "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||||
|
"pre-battle text is They're both mine")
|
||||||
|
eq(seeded.won, "_MtMoonB2FSuperNerdOkIllShareText", "won text seeded")
|
||||||
|
eq(seeded.after, "_MtMoonB2FSuperNerdTheresAPokemonLabText", "after text seeded")
|
||||||
|
eq(seeded.event, "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||||
|
"event name matches shipped Red/Blue pin")
|
||||||
|
|
||||||
|
-- idempotent: existing header wins
|
||||||
|
empty.trainer_headers.MtMoonB2F[1] = { battle = "KEEP" }
|
||||||
|
Data.seedMtMoonB2FSuperNerd(empty)
|
||||||
|
eq(empty.trainer_headers.MtMoonB2F[1].battle, "KEEP",
|
||||||
|
"seed does not overwrite an existing header")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -197,6 +197,16 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow
|
|||||||
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
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
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- #1644: Fly/Teleport special warps fade white (GBFadeOutToWhite /
|
||||||
|
-- GBFadeInFromWhite), not black door-warp shape.
|
||||||
|
--
|
||||||
|
-- luajit tests/engine/special_warp_white_fade_bug1644.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("special warp white fade #1644")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
local Transition = require("src.render.Transition")
|
||||||
|
|
||||||
|
local white = { 1, 1, 1 }
|
||||||
|
local t = Transition.new(nil, function() end, nil, false, {
|
||||||
|
color = white,
|
||||||
|
frames = Timing.FADE_OUT_TO_WHITE,
|
||||||
|
framesIn = Timing.FADE_IN_FROM_WHITE,
|
||||||
|
})
|
||||||
|
eq(t.color[1], 1, "special warp veil is white R")
|
||||||
|
eq(t.color[2], 1, "special warp veil is white G")
|
||||||
|
eq(t.color[3], 1, "special warp veil is white B")
|
||||||
|
eq(t.frames, Timing.FADE_OUT_TO_WHITE, "fade out uses FADE_OUT_TO_WHITE")
|
||||||
|
eq(t.framesIn, Timing.FADE_IN_FROM_WHITE, "fade in uses FADE_IN_FROM_WHITE")
|
||||||
|
|
||||||
|
local door = Transition.new(nil, function() end, nil, true)
|
||||||
|
eq(door.color[1], 0, "door warp stays black")
|
||||||
|
eq(door.framesIn, 0, "door warp has no fade in")
|
||||||
|
check(door.frames == Timing.WARP_FADE_OUT, "door warp uses WARP_FADE_OUT")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -31,16 +31,19 @@ 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 = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
local _, baseline, baselineExtra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||||
T.check(baseline[1]:find("ATTACK", 1, true) ~= nil,
|
local baselineRose = baselineExtra and baselineExtra.afterMessages
|
||||||
|
and baselineExtra.afterMessages[1] or baseline[1]
|
||||||
|
T.check(baselineRose:find("ATTACK", 1, true) ~= nil,
|
||||||
"X ATTACK's rose! message names the stat in English with no catalog")
|
"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 = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
local _, msgs, extra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||||
T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil,
|
local rose = extra and extra.afterMessages and extra.afterMessages[1] or msgs[1]
|
||||||
|
T.check(rose:find("ATTAQUE", 1, true) ~= nil,
|
||||||
"a catalog translating ATTACK reaches the X ATTACK rose! message")
|
"a catalog translating ATTACK reaches the X ATTACK rose! message")
|
||||||
T.check(msgs[1]:find("ATTACK", 1, true) == nil,
|
T.check(rose:find("ATTACK", 1, true) == nil,
|
||||||
"...and the untranslated English stat name is gone")
|
"...and the untranslated English stat name is gone")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Title eye OBP remap lives in ImageWriter.applyTitleObp0; pixel-level
|
||||||
|
-- coverage is tests/title_pikachu_obp_test.py (love_stub ImageData is a
|
||||||
|
-- no-op). This file only pins the export so the Lua bake path stays wired.
|
||||||
|
--
|
||||||
|
-- luajit tests/engine/title_pikachu_obp_bug.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("title pikachu OBP export")
|
||||||
|
local check = S.check
|
||||||
|
|
||||||
|
local ImageWriter = require("src.import.ImageWriter")
|
||||||
|
check(type(ImageWriter.applyTitleObp0) == "function",
|
||||||
|
"ImageWriter.applyTitleObp0 is exported for RomExtractor")
|
||||||
|
check(type(ImageWriter.SHADES) == "table" and #ImageWriter.SHADES == 4,
|
||||||
|
"ImageWriter.SHADES exposes the 4 DMG ramps")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -52,7 +52,10 @@ 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.
|
-- any of flyAnim / flyArrive / playerHidden is set. Dig/Teleport spinDrop
|
||||||
|
-- does NOT skip the sprite -- EnterMapAnim draws the spinning trainer under
|
||||||
|
-- the white fade-in (#1644) -- so "bare" is only a frame with no hide flag
|
||||||
|
-- and no arrival (gapFrames below).
|
||||||
local function playerHidden(ow)
|
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
|
||||||
@@ -115,8 +118,12 @@ check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins")
|
|||||||
check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)")
|
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)")
|
||||||
check(st.fadeFramesHidden >= st.fadeFrames - 1,
|
-- Dig uses white fade-out + fade-in (#1644). Draw-skip hide covers the
|
||||||
"dig fade hidden on every frame but the arrival-arming midpoint ("
|
-- fade-out; the fade-in deliberately shows spinDrop under the veil, so
|
||||||
|
-- fadeFramesHidden is only the out half (allow one midpoint tick).
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
check(st.fadeFramesHidden >= Timing.FADE_OUT_TO_WHITE - 1,
|
||||||
|
"dig fade-out hidden ("
|
||||||
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
|
.. 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")
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- #1639: Yellow-only Advanced overlay — BEACH_HOUSE world bake, sprite
|
||||||
|
-- coverage past Red's 72 entries, saturated YELLOWMON — without changing
|
||||||
|
-- Red/Blue Advanced.
|
||||||
|
--
|
||||||
|
-- luajit tests/engine/yellow_advanced_palette_bug1639.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("yellow advanced palette #1639")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
|
||||||
|
local prevVer = GameVersion.get()
|
||||||
|
local prevMode = PaletteFX.mode
|
||||||
|
|
||||||
|
PaletteFX.setMode("redpp")
|
||||||
|
|
||||||
|
-- Red: no BEACH_HOUSE, shared HOUSE still present
|
||||||
|
GameVersion.set("red")
|
||||||
|
check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"),
|
||||||
|
"Red Advanced has no BEACH_HOUSE tileset")
|
||||||
|
check(PaletteFX.hasWorldTileset("HOUSE"), "Red Advanced still has HOUSE")
|
||||||
|
local redHouse = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0)
|
||||||
|
|
||||||
|
-- Blue unchanged for BEACH_HOUSE
|
||||||
|
GameVersion.set("blue")
|
||||||
|
check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"),
|
||||||
|
"Blue Advanced has no BEACH_HOUSE tileset")
|
||||||
|
|
||||||
|
-- Yellow: BEACH_HOUSE present, HOUSE still inherits from Red pack
|
||||||
|
GameVersion.set("yellow")
|
||||||
|
check(PaletteFX.hasWorldTileset("BEACH_HOUSE"),
|
||||||
|
"Yellow Advanced profiles BEACH_HOUSE")
|
||||||
|
check(PaletteFX.hasWorldTileset("HOUSE"),
|
||||||
|
"Yellow Advanced still sees shared HOUSE")
|
||||||
|
local beach = PaletteFX.worldGroupColors(nil, "BEACH_HOUSE", "SUMMER_BEACH_HOUSE", 0)
|
||||||
|
check(beach ~= nil and #beach == 8, "BEACH_HOUSE has 8 group colors")
|
||||||
|
eq(beach[1][1][1], 255, "BEACH_HOUSE sand group is warm (R=255)")
|
||||||
|
|
||||||
|
-- Sprite: Pikachu @60 and high indices resolve; Red path for bike stays 0
|
||||||
|
local pika = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "pika")
|
||||||
|
check(pika ~= nil, "Yellow SPRITE_PIKACHU gets an OBP")
|
||||||
|
eq(pika[2][2], 255, "Pikachu OBP mid shade is saturated yellow G=255")
|
||||||
|
local boulder = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[72]" }, "b")
|
||||||
|
check(boulder ~= nil, "Yellow boulder (index 72) gets an OBP")
|
||||||
|
|
||||||
|
GameVersion.set("red")
|
||||||
|
local redBall = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "ball")
|
||||||
|
check(redBall ~= nil, "Red poke-ball @60 still resolves")
|
||||||
|
-- Red group 0 mid is orange skin, not pure yellow
|
||||||
|
check(redBall[2][2] < 200, "Red @60 is not Yellow's Pikachu ramp")
|
||||||
|
|
||||||
|
-- YELLOWMON / MEWMON / LOGO: Advanced uses CGBBase (saturated), not washed
|
||||||
|
-- SuperPalettes
|
||||||
|
GameVersion.set("yellow")
|
||||||
|
local yellowMon = PaletteFX.pal(nil, "YELLOWMON")
|
||||||
|
eq(yellowMon[2][2], 255, "Advanced Yellow YELLOWMON mid is saturated yellow G")
|
||||||
|
eq(yellowMon[2][3], 0, "Advanced Yellow YELLOWMON mid is pure yellow B=0")
|
||||||
|
local mewmon = PaletteFX.pal(nil, "MEWMON")
|
||||||
|
eq(mewmon[2][1], 255, "MEWMON stays Yellow title yellow (not Red++ purple)")
|
||||||
|
eq(mewmon[2][2], 255, "MEWMON body is saturated yellow G=255 (not washed 156)")
|
||||||
|
eq(mewmon[2][3], 0, "MEWMON body is pure yellow B=0")
|
||||||
|
check(mewmon[3][3] < 150, "MEWMON cheek stays red, not purple")
|
||||||
|
local logo2 = PaletteFX.pal(nil, "LOGO2")
|
||||||
|
eq(logo2[2][2], 255, "LOGO2 fill is saturated yellow under Advanced")
|
||||||
|
eq(logo2[2][3], 0, "LOGO2 fill is pure yellow B=0")
|
||||||
|
|
||||||
|
-- Red HOUSE colors identical before/after Yellow merge path
|
||||||
|
GameVersion.set("red")
|
||||||
|
local redHouse2 = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0)
|
||||||
|
eq(redHouse2[1][1][1], redHouse[1][1][1], "Red HOUSE colors unchanged")
|
||||||
|
|
||||||
|
PaletteFX.setMode(prevMode)
|
||||||
|
GameVersion.set(prevVer)
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -145,8 +145,24 @@ 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,
|
||||||
"window title carries the engine version")
|
"dev window title carries the engine version")
|
||||||
|
do
|
||||||
|
local saved = Version.engine
|
||||||
|
Version.engine = "0.1.73"
|
||||||
|
check(Version.isDev() == false, "a stamped X.Y.Z is not a dev build")
|
||||||
|
check(Version.title() == "gen1recomp",
|
||||||
|
"release window title omits the version number")
|
||||||
|
check(Version.title("Gen 1 Recompilation Project")
|
||||||
|
== "Gen 1 Recompilation Project",
|
||||||
|
"release titles keep the base string alone")
|
||||||
|
Version.engine = "1.2.3-dev"
|
||||||
|
check(Version.isDev() == true, "a -dev pre-release still counts as dev")
|
||||||
|
check(Version.title() == "gen1recomp v1.2.3-dev",
|
||||||
|
"and still shows the version in the title")
|
||||||
|
Version.engine = saved
|
||||||
|
end
|
||||||
|
|
||||||
-- null-object runtime: emit/call are safe with no loader installed
|
-- 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
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
-- A sandboxed mod can decide two battle rules the OPTION screen and the cart
|
||||||
|
-- otherwise decide for the player -- whether a faint offers a free switch
|
||||||
|
-- (battle.style) and whether a catch asks for a nickname (catch.nickname) --
|
||||||
|
-- using only public mod surfaces, and neither hook touches the player's
|
||||||
|
-- saved preference.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
local FIXTURE = {
|
||||||
|
["mods/rules_probe/manifest.json"] = [[{
|
||||||
|
"id": "rules_probe",
|
||||||
|
"name": "Rules Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2
|
||||||
|
}]],
|
||||||
|
["mods/rules_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.exports.style = "set"
|
||||||
|
mod.exports.nickname = false
|
||||||
|
mod.hooks:wrap("battle.style", function(next, battle)
|
||||||
|
mod.exports.styleCalls = (mod.exports.styleCalls or 0) + 1
|
||||||
|
mod.exports.styleBattle = battle
|
||||||
|
if mod.exports.style ~= nil then return mod.exports.style end
|
||||||
|
return next(battle)
|
||||||
|
end)
|
||||||
|
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||||
|
mod.exports.nameCalls = (mod.exports.nameCalls or 0) + 1
|
||||||
|
mod.exports.nameCtx = ctx
|
||||||
|
if mod.exports.nickname ~= nil then return mod.exports.nickname end
|
||||||
|
return next(mon, ctx)
|
||||||
|
end)
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
local function fixtureBattle(style)
|
||||||
|
local data = { pokemon = {}, text = {} }
|
||||||
|
return setmetatable({
|
||||||
|
game = { save = { options = { battleStyle = style } },
|
||||||
|
stack = { push = function() end }, data = data },
|
||||||
|
data = data,
|
||||||
|
queue = {}, nextInsert = 0,
|
||||||
|
}, { __index = BattleState })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- does the queued UI, if any, put the yes/no prompt on screen?
|
||||||
|
local function promptQueued(battle)
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.ui then
|
||||||
|
local state = item.ui()
|
||||||
|
if getmetatable(state) == TextBox and state.choice then return true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- no mod: the OPTION row and the cart's AskName decide
|
||||||
|
|
||||||
|
local vanilla = T.sdk.loadNone({})
|
||||||
|
T.eq(fixtureBattle("shift"):battleStyle(), "shift", "no mod: SHIFT row reads shift")
|
||||||
|
T.eq(fixtureBattle("set"):battleStyle(), "set", "no mod: SET row reads set")
|
||||||
|
T.eq(fixtureBattle("SET"):battleStyle(), "set", "no mod: the row is case-insensitive")
|
||||||
|
T.eq(fixtureBattle(nil):battleStyle(), "shift", "no mod: a missing row is the cart default")
|
||||||
|
|
||||||
|
local plain = fixtureBattle("shift")
|
||||||
|
local mon = { species = "RATTATA" }
|
||||||
|
T.eq(plain:offerNickname(mon, "RATTATA"), true, "no mod: a catch queues the prompt")
|
||||||
|
T.eq(promptQueued(plain), true, "no mod: and it is the yes/no box")
|
||||||
|
T.eq(mon.nickname, nil, "no mod: nothing is named behind the player's back")
|
||||||
|
vanilla.release()
|
||||||
|
|
||||||
|
-- ------- a mod answers both
|
||||||
|
|
||||||
|
local run = T.sdk.loadMods({ "mods/rules_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||||
|
T.eq(#run.errors, 0, "the rules probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
local probe = run.loader.exports.rules_probe
|
||||||
|
|
||||||
|
local forced = fixtureBattle("shift")
|
||||||
|
T.eq(forced:battleStyle(), "set", "\"set\" wins over a SHIFT row")
|
||||||
|
T.eq(forced.game.save.options.battleStyle, "shift", "without writing the row")
|
||||||
|
T.eq(probe.styleCalls, 1, "the hook ran once")
|
||||||
|
T.check(probe.styleBattle == forced, "and was handed the battle")
|
||||||
|
probe.style = "shift"
|
||||||
|
T.eq(fixtureBattle("set"):battleStyle(), "shift", "\"shift\" wins over a SET row")
|
||||||
|
probe.style = "banana"
|
||||||
|
T.eq(fixtureBattle("set"):battleStyle(), "set",
|
||||||
|
"an answer that is neither reads as the row")
|
||||||
|
probe.style = nil
|
||||||
|
T.eq(fixtureBattle("set"):battleStyle(), "set", "falling through reads the row")
|
||||||
|
|
||||||
|
local skipped = fixtureBattle("shift")
|
||||||
|
local kept = { species = "RATTATA" }
|
||||||
|
T.eq(skipped:offerNickname(kept, "RATTATA"), false, "false: no prompt is queued")
|
||||||
|
T.eq(#skipped.queue, 0, "nothing at all is queued")
|
||||||
|
T.eq(kept.nickname, nil, "and the species name is kept")
|
||||||
|
T.eq(probe.nameCalls, 1, "the hook ran once")
|
||||||
|
T.check(probe.nameCtx and probe.nameCtx.battle == skipped, "with the battle in ctx")
|
||||||
|
T.eq(probe.nameCtx and probe.nameCtx.name, "RATTATA", "and the display name")
|
||||||
|
|
||||||
|
probe.nickname = "SPIKE"
|
||||||
|
local named = { species = "RATTATA" }
|
||||||
|
T.eq(fixtureBattle("shift"):offerNickname(named, "RATTATA"), false,
|
||||||
|
"a string: no prompt either")
|
||||||
|
T.eq(named.nickname, "SPIKE", "and it is the nickname")
|
||||||
|
|
||||||
|
probe.nickname = "TOOLONGFORTHEGRID"
|
||||||
|
local clipped = { species = "RATTATA" }
|
||||||
|
fixtureBattle("shift"):offerNickname(clipped, "RATTATA")
|
||||||
|
T.eq(clipped.nickname, "TOOLONGFOR", "a long string is clipped to the grid's ten")
|
||||||
|
|
||||||
|
probe.nickname = ""
|
||||||
|
local blank = { species = "RATTATA" }
|
||||||
|
T.eq(fixtureBattle("shift"):offerNickname(blank, "RATTATA"), false,
|
||||||
|
"an empty string still declines the prompt")
|
||||||
|
T.eq(blank.nickname, nil, "and names nothing, like the grid's own empty entry")
|
||||||
|
|
||||||
|
probe.nickname = nil
|
||||||
|
local asked = fixtureBattle("shift")
|
||||||
|
T.eq(asked:offerNickname({ species = "RATTATA" }, "RATTATA"), true,
|
||||||
|
"falling through asks as usual")
|
||||||
|
T.eq(promptQueued(asked), true, "with the real prompt")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
T.finish("battle rule hooks")
|
||||||
@@ -17,10 +17,11 @@ local saved = {
|
|||||||
pickFileKinds = love.system.pickFileKinds,
|
pickFileKinds = love.system.pickFileKinds,
|
||||||
}
|
}
|
||||||
|
|
||||||
local pickCalls = {}
|
local pickCalls, pickDestinations = {}, {}
|
||||||
love.system.getOS = function() return "Android" end
|
love.system.getOS = function() return "Android" end
|
||||||
love.system.pickFile = function(kind)
|
love.system.pickFile = function(kind, destination)
|
||||||
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
|
||||||
@@ -103,13 +104,15 @@ 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",
|
manifest = { id = "needs_source", name = "Needs Source", path = "mods/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")
|
||||||
|
|
||||||
@@ -166,6 +169,52 @@ 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.
|
||||||
@@ -176,12 +225,14 @@ 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",
|
manifest = { id = "needs_source", name = "Needs Source", path = "mods/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)
|
||||||
@@ -216,5 +267,8 @@ 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()
|
||||||
|
|||||||
@@ -102,5 +102,44 @@ 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()
|
||||||
|
|||||||
@@ -927,6 +927,113 @@ 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,
|
||||||
@@ -1127,10 +1234,13 @@ 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. The desktop sizes pin that
|
-- width in portrait and crash on the scissor. RGxxx / Switch shapes pin
|
||||||
-- the responsive reflow does not disturb the layouts that already worked.
|
-- short-landscape handhelds (RG34XXSP 720x480, RG35XX 640x480, NX 1280x720)
|
||||||
|
-- and a tiny 360x640 phone. Desktop sizes keep the layouts that already
|
||||||
|
-- worked.
|
||||||
for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
|
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" })
|
||||||
@@ -1153,6 +1263,12 @@ 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
|
||||||
@@ -1233,8 +1349,12 @@ 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 },
|
||||||
{ 1024, 768 }, { 900, 700 }, { 1920, 1080 } }
|
{ 720, 480 }, { 1024, 768 },
|
||||||
|
{ 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
|
||||||
@@ -1266,6 +1386,14 @@ 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
|
||||||
|
|
||||||
@@ -1349,5 +1477,150 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Title rOBP0=$E0 remaps eye OAM shades 1/2 to white (#1639 pupils)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import TestCase, main
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||||
|
import build_rom_data as b # noqa: E402
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TitleObp0Test(TestCase):
|
||||||
|
def test_mid_and_dark_become_white(self):
|
||||||
|
img = Image.new("RGBA", (4, 1), (0, 0, 0, 0))
|
||||||
|
img.putpixel((0, 0), b.GB_SHADES[1])
|
||||||
|
img.putpixel((1, 0), b.GB_SHADES[2])
|
||||||
|
img.putpixel((2, 0), b.GB_SHADES[3])
|
||||||
|
img.putpixel((3, 0), b.GB_SHADES[0])
|
||||||
|
b._apply_title_obp0(img)
|
||||||
|
self.assertEqual(img.getpixel((0, 0)), b.GB_SHADES[0])
|
||||||
|
self.assertEqual(img.getpixel((1, 0)), b.GB_SHADES[0])
|
||||||
|
self.assertEqual(img.getpixel((2, 0)), b.GB_SHADES[3])
|
||||||
|
self.assertEqual(img.getpixel((3, 0)), b.GB_SHADES[0])
|
||||||
|
|
||||||
|
def test_transparent_untouched(self):
|
||||||
|
img = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||||
|
b._apply_title_obp0(img)
|
||||||
|
self.assertEqual(img.getpixel((0, 0))[3], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -22,9 +22,12 @@ 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 palette!
|
-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu
|
||||||
|
-- CGBBase yellow {255,255,0}, not washed SuperPalette cream {255,255,156}.
|
||||||
eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)")
|
eq(mewmonAdv[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)
|
||||||
|
|||||||
+42
-1
@@ -77,6 +77,26 @@ GB_SHADES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_title_obp0(image):
|
||||||
|
"""Title rOBP0=%11100000 ($E0): OBJ shades 1 and 2 → white, 3 → black.
|
||||||
|
|
||||||
|
Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
|
||||||
|
shade-1 glints become body yellow under the title palette
|
||||||
|
(pokeyellow engine/movie/title.asm after PlacePikachu).
|
||||||
|
"""
|
||||||
|
pixels = image.load()
|
||||||
|
w, h = image.size
|
||||||
|
mid, dark = GB_SHADES[1][0], GB_SHADES[2][0]
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
r, g, b, a = pixels[x, y]
|
||||||
|
if a == 0:
|
||||||
|
continue
|
||||||
|
if abs(r - mid) <= 2 or abs(r - dark) <= 2:
|
||||||
|
pixels[x, y] = GB_SHADES[0]
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
def _symbol(symbols, name):
|
def _symbol(symbols, name):
|
||||||
try:
|
try:
|
||||||
return symbols[name]
|
return symbols[name]
|
||||||
@@ -1880,12 +1900,33 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
|
|||||||
(3, 24, 24, True), (2, 32, 24, True),
|
(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]
|
eye = ob_clear[ob_index].copy()
|
||||||
|
_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)
|
||||||
|
|||||||
@@ -86,6 +86,43 @@ 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
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
+306
-250
@@ -8360,94 +8360,74 @@
|
|||||||
"superRod": {
|
"superRod": {
|
||||||
"CELADON_CITY": [
|
"CELADON_CITY": [
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 5,
|
||||||
"species": "POLIWHIRL"
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "SLOWPOKE"
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
|
"species": "GOLDEEN"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"CERULEAN_CAVE_1F": [
|
"CERULEAN_CAVE_1F": [
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 25,
|
||||||
"species": "SLOWBRO"
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 35,
|
||||||
"species": "SEAKING"
|
"species": "SEAKING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 45,
|
||||||
"species": "KINGLER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 23,
|
|
||||||
"species": "SEADRA"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"CERULEAN_CAVE_2F": [
|
|
||||||
{
|
|
||||||
"level": 23,
|
|
||||||
"species": "SLOWBRO"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 23,
|
|
||||||
"species": "SEAKING"
|
"species": "SEAKING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 55,
|
||||||
"species": "KINGLER"
|
"species": "SEAKING"
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 23,
|
|
||||||
"species": "SEADRA"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"CERULEAN_CAVE_B1F": [
|
"CERULEAN_CAVE_B1F": [
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 30,
|
||||||
"species": "SLOWBRO"
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 40,
|
||||||
"species": "SEAKING"
|
"species": "SEAKING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 50,
|
||||||
"species": "KINGLER"
|
"species": "SEAKING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 60,
|
||||||
"species": "SEADRA"
|
"species": "SEAKING"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"CERULEAN_CITY": [
|
"CERULEAN_CITY": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "PSYDUCK"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "KRABBY"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"CERULEAN_GYM": [
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "PSYDUCK"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "KRABBY"
|
"species": "SEAKING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 40,
|
||||||
|
"species": "SEAKING"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"CINNABAR_ISLAND": [
|
"CINNABAR_ISLAND": [
|
||||||
@@ -8457,99 +8437,123 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "HORSEA"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 10,
|
||||||
"species": "SHELLDER"
|
"species": "STARYU"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "GOLDEEN"
|
"species": "TENTACOOL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"FUCHSIA_CITY": [
|
"FUCHSIA_CITY": [
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 5,
|
||||||
"species": "SEAKING"
|
"species": "MAGIKARP"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "MAGIKARP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 15,
|
||||||
|
"species": "MAGIKARP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 15,
|
||||||
|
"species": "GYARADOS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PALLET_TOWN": [
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "STARYU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 5,
|
||||||
|
"species": "STARYU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
|
"species": "TENTACOOL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ROUTE_10": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "KRABBY"
|
"species": "KRABBY"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
"species": "GOLDEEN"
|
"species": "KRABBY"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 10,
|
||||||
"species": "MAGIKARP"
|
"species": "HORSEA"
|
||||||
}
|
|
||||||
],
|
|
||||||
"PALLET_TOWN": [
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "TENTACOOL"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "POLIWAG"
|
"species": "KINGLER"
|
||||||
}
|
|
||||||
],
|
|
||||||
"ROUTE_10": [
|
|
||||||
{
|
|
||||||
"level": 23,
|
|
||||||
"species": "POLIWHIRL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "SLOWPOKE"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_11": [
|
"ROUTE_11": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "KRABBY"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
"species": "SHELLDER"
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 5,
|
||||||
|
"species": "HORSEA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_12": [
|
"ROUTE_12": [
|
||||||
{
|
{
|
||||||
"level": 5,
|
"level": 20,
|
||||||
"species": "TENTACOOL"
|
"species": "HORSEA"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "KRABBY"
|
"species": "HORSEA"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "GOLDEEN"
|
"species": "SEADRA"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 35,
|
||||||
"species": "MAGIKARP"
|
"species": "SEADRA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_13": [
|
"ROUTE_13": [
|
||||||
{
|
{
|
||||||
"level": 5,
|
"level": 15,
|
||||||
|
"species": "HORSEA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
|
"species": "HORSEA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
"species": "TENTACOOL"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
"species": "KRABBY"
|
"species": "SEADRA"
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "MAGIKARP"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_17": [
|
"ROUTE_17": [
|
||||||
@@ -8559,304 +8563,356 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "KRABBY"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "GOLDEEN"
|
"species": "SHELLDER"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 35,
|
||||||
"species": "MAGIKARP"
|
"species": "SHELLDER"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_18": [
|
"ROUTE_18": [
|
||||||
{
|
{
|
||||||
"level": 5,
|
"level": 15,
|
||||||
"species": "TENTACOOL"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
"species": "KRABBY"
|
"species": "SHELLDER"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "GOLDEEN"
|
"species": "SHELLDER"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 40,
|
||||||
"species": "MAGIKARP"
|
"species": "SHELLDER"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_19": [
|
"ROUTE_19": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
"species": "STARYU"
|
"species": "STARYU"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "HORSEA"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "SHELLDER"
|
"species": "TENTACRUEL"
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_20": [
|
"ROUTE_20": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
|
"species": "TENTACRUEL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 30,
|
||||||
"species": "STARYU"
|
"species": "STARYU"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 40,
|
||||||
"species": "HORSEA"
|
"species": "TENTACRUEL"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": 15,
|
"level": 30,
|
||||||
"species": "HORSEA"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 30,
|
||||||
"species": "SHELLDER"
|
"species": "TENTACRUEL"
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_22": [
|
"ROUTE_22": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 5,
|
||||||
"species": "GOLDEEN"
|
"species": "POLIWAG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "POLIWAG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "POLIWAG"
|
"species": "POLIWAG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 15,
|
||||||
|
"species": "POLIWHIRL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_23": [
|
"ROUTE_23": [
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 25,
|
||||||
"species": "SLOWBRO"
|
"species": "POLIWAG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 30,
|
||||||
"species": "SEAKING"
|
"species": "POLIWAG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 30,
|
||||||
"species": "KINGLER"
|
"species": "POLIWHIRL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 23,
|
"level": 40,
|
||||||
"species": "SEADRA"
|
"species": "POLIWHIRL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_24": [
|
"ROUTE_24": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
"species": "PSYDUCK"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "KRABBY"
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 30,
|
||||||
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 30,
|
||||||
|
"species": "SEAKING"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_25": [
|
"ROUTE_25": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 10,
|
||||||
"species": "PSYDUCK"
|
"species": "KRABBY"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": 15,
|
"level": 20,
|
||||||
"species": "PSYDUCK"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
"species": "KRABBY"
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 30,
|
||||||
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 30,
|
||||||
|
"species": "SEAKING"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ROUTE_6": [
|
"ROUTE_6": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 5,
|
||||||
"species": "KRABBY"
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "GOLDEEN"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "SHELLDER"
|
"species": "GOLDEEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
|
"species": "GOLDEEN"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SAFARI_ZONE_CENTER": [
|
"SAFARI_ZONE_CENTER": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 5,
|
||||||
|
"species": "MAGIKARP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "MAGIKARP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
"species": "DRATINI"
|
"species": "DRATINI"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "KRABBY"
|
"species": "DRAGONAIR"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": 15,
|
"level": 25,
|
||||||
|
"species": "KRABBY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
"species": "STARYU"
|
"species": "STARYU"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 35,
|
||||||
"species": "HORSEA"
|
"species": "KINGLER"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 40,
|
||||||
"species": "SHELLDER"
|
"species": "STARYU"
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SEAFOAM_ISLANDS_B4F": [
|
"SEAFOAM_ISLANDS_B4F": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 25,
|
||||||
|
"species": "KRABBY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 20,
|
||||||
"species": "STARYU"
|
"species": "STARYU"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 35,
|
||||||
"species": "HORSEA"
|
"species": "KINGLER"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 40,
|
||||||
"species": "SHELLDER"
|
"species": "STARYU"
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "GOLDEEN"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"VERMILION_CITY": [
|
"VERMILION_CITY": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "KRABBY"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 20,
|
||||||
"species": "SHELLDER"
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "TENTACOOL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 5,
|
||||||
|
"species": "HORSEA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"VERMILION_DOCK": [
|
"VERMILION_DOCK": [
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 10,
|
||||||
"species": "KRABBY"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"level": 15,
|
|
||||||
"species": "SHELLDER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"VIRIDIAN_CITY": [
|
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
"species": "TENTACOOL"
|
"species": "TENTACOOL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": 15,
|
"level": 15,
|
||||||
|
"species": "STARYU"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "SHELLDER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"VIRIDIAN_CITY": [
|
||||||
|
{
|
||||||
|
"level": 5,
|
||||||
|
"species": "POLIWAG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
|
"species": "POLIWAG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 15,
|
||||||
|
"species": "POLIWAG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 10,
|
||||||
"species": "POLIWAG"
|
"species": "POLIWAG"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+40
-11
@@ -9,7 +9,11 @@
|
|||||||
-- * 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 (scaled by Kit's height/768 factor, everything else flexes):
|
-- Vertical rhythm inside the platform safe area (scaled by Kit's height/768
|
||||||
|
-- factor, everything else flexes). Background still fills the full window so
|
||||||
|
-- the notch / home-indicator bands match the field colour; interactive chrome
|
||||||
|
-- starts at SafeArea.rect() so Save stays reachable on a punch-hole phone
|
||||||
|
-- (#917). Offsets below are relative to that safe origin:
|
||||||
-- 0 6px tri-colour version rail, identical to the launcher's
|
-- 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)
|
||||||
@@ -18,6 +22,7 @@
|
|||||||
-- -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")
|
||||||
@@ -37,6 +42,7 @@ 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 = {}
|
||||||
@@ -772,7 +778,17 @@ 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()
|
||||||
Kit.layout(width, height)
|
width = math.max(1, tonumber(width) or 1)
|
||||||
|
height = math.max(1, tonumber(height) or 1)
|
||||||
|
-- Usable chrome rect. Background still fills the window so the notch /
|
||||||
|
-- home-indicator bands stay the field colour; every button (Save first)
|
||||||
|
-- lives inside the safe area, matching the launcher and Skin Studio (#917).
|
||||||
|
local ox, oy, sw, sh = SafeArea.rect()
|
||||||
|
ox = math.max(0, tonumber(ox) or 0)
|
||||||
|
oy = math.max(0, tonumber(oy) or 0)
|
||||||
|
sw = math.max(1, tonumber(sw) or width)
|
||||||
|
sh = math.max(1, tonumber(sh) or height)
|
||||||
|
Kit.layout(sw, sh)
|
||||||
local s = Kit.scale
|
local s = Kit.scale
|
||||||
|
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
@@ -791,6 +807,7 @@ function App.draw()
|
|||||||
-- shield goes up before anything dispatches and comes down only for the
|
-- 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)
|
||||||
|
|
||||||
@@ -799,26 +816,29 @@ 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(width)
|
local titleTwoRow = titleNeedsTwoRows(sw)
|
||||||
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(0, 0, width, railH)
|
Theme.versionRail(ox, oy, sw, railH)
|
||||||
drawTitleBar(0, railH, width, titleH, titleTwoRow)
|
drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow)
|
||||||
drawTabRail(0, railH + titleH, width, tabH)
|
drawTabRail(ox, oy + railH + titleH, sw, tabH)
|
||||||
|
|
||||||
local contentY = railH + titleH + tabH
|
local contentY = oy + railH + titleH + tabH
|
||||||
local contentH = height - contentY - statusH
|
local contentH = sh - railH - titleH - tabH - statusH
|
||||||
local panel = PANELS[S.tab]
|
local panel = PANELS[S.tab]
|
||||||
if panel then
|
if panel then
|
||||||
panel.draw(S, Kit, 22 * s, contentY + 20 * s,
|
panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s,
|
||||||
width - 44 * s, contentH - 38 * s)
|
sw - 44 * s, contentH - 38 * s)
|
||||||
end
|
end
|
||||||
|
|
||||||
drawStatusBar(0, height - statusH, width, statusH)
|
drawStatusBar(ox, oy + sh - statusH, sw, 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()
|
||||||
@@ -841,6 +861,15 @@ 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)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
-- 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.
|
-- here. Kit.tapMin() is the px floor after scale (a short handheld sits on
|
||||||
|
-- the 0.9 scale floor, so bare `26 * s` would slip under 26).
|
||||||
|
|
||||||
local Theme = require("Theme")
|
local Theme = require("Theme")
|
||||||
local PAL = Theme.PAL
|
local PAL = Theme.PAL
|
||||||
@@ -25,6 +26,10 @@ 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
|
||||||
|
|||||||
+133
-3
@@ -329,6 +329,31 @@ 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
|
||||||
|
|
||||||
@@ -429,6 +454,8 @@ function Ops.setDv(S, mon, key, value)
|
|||||||
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
|
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
|
||||||
@@ -441,14 +468,117 @@ 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 S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then
|
if Ops.moveUsable(S, nextId) then
|
||||||
MonOps.setMove(S.data, mon, slot, nextId)
|
return Ops.setMove(S, 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))
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
-- Shared modal-picker frame for Species / Move / Item pickers.
|
||||||
|
--
|
||||||
|
-- Desktop keeps a centred card capped at 520x560 logical px. Phones and
|
||||||
|
-- RGxxx handhelds (RG34XXSP 720x480, RG35XX 640x480, Switch 1280x720, tall
|
||||||
|
-- portrait Androids) need the card to nearly fill SafeArea.rect() so the
|
||||||
|
-- search field, list, and pager stay usable instead of collapsing under a
|
||||||
|
-- fixed 32px margin into a short landscape window (#917 / #715).
|
||||||
|
|
||||||
|
local SafeArea = require("src.core.SafeArea")
|
||||||
|
|
||||||
|
local PickerChrome = {}
|
||||||
|
|
||||||
|
-- Minimum tap target (design rule 6). Scale can sit on the 0.9 floor on a
|
||||||
|
-- short handheld, so a bare `26 * s` would dip under 26px -- clamp in px.
|
||||||
|
function PickerChrome.tapMin(Kit)
|
||||||
|
local s = (Kit and Kit.scale) or 1
|
||||||
|
return math.max(26, math.floor(30 * s))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Usable card rect inside the platform safe area.
|
||||||
|
-- Returns x, y, w, h, pad.
|
||||||
|
function PickerChrome.card(Kit, windowW, windowH)
|
||||||
|
local s = (Kit and Kit.scale) or 1
|
||||||
|
windowW = math.max(1, tonumber(windowW) or 1)
|
||||||
|
windowH = math.max(1, tonumber(windowH) or 1)
|
||||||
|
local sox, soy, ssw, ssh = SafeArea.rect()
|
||||||
|
sox = math.max(0, tonumber(sox) or 0)
|
||||||
|
soy = math.max(0, tonumber(soy) or 0)
|
||||||
|
ssw = math.max(1, tonumber(ssw) or windowW)
|
||||||
|
ssh = math.max(1, tonumber(ssh) or windowH)
|
||||||
|
|
||||||
|
-- Shrink the gutter on short / narrow safe areas so the card keeps room
|
||||||
|
-- for caption + search + at least one list row + pager.
|
||||||
|
local gutter = math.floor(16 * s)
|
||||||
|
local minSide = math.min(ssw, ssh)
|
||||||
|
if minSide < 560 * s then
|
||||||
|
gutter = math.max(4, math.floor(minSide * 0.03))
|
||||||
|
end
|
||||||
|
|
||||||
|
local maxW = math.floor(520 * s)
|
||||||
|
local maxH = math.floor(560 * s)
|
||||||
|
local w = math.min(ssw - 2 * gutter, maxW)
|
||||||
|
local h = math.min(ssh - 2 * gutter, maxH)
|
||||||
|
-- Short landscapes (720x480 class): fill the safe height instead of
|
||||||
|
-- leaving letterbox bands inside an already-short rect.
|
||||||
|
if ssh <= maxH + 2 * gutter then
|
||||||
|
h = ssh - 2 * gutter
|
||||||
|
end
|
||||||
|
if ssw <= maxW + 2 * gutter then
|
||||||
|
w = ssw - 2 * gutter
|
||||||
|
end
|
||||||
|
w = math.max(1, w)
|
||||||
|
h = math.max(1, h)
|
||||||
|
|
||||||
|
local x = sox + (ssw - w) / 2
|
||||||
|
local y = soy + (ssh - h) / 2
|
||||||
|
local pad = math.max(8, math.floor(math.min(18 * s, w * 0.04, h * 0.04)))
|
||||||
|
return x, y, w, h, pad
|
||||||
|
end
|
||||||
|
|
||||||
|
-- List-body metrics once caption / field / optional extra chrome are placed.
|
||||||
|
-- `contentTop` is the y just below that chrome; returns listH, rowH, rowGap,
|
||||||
|
-- pagerH sized so taps stay >= tapMin and listH never goes negative.
|
||||||
|
function PickerChrome.listMetrics(Kit, cardY, cardH, pad, contentTop)
|
||||||
|
local s = (Kit and Kit.scale) or 1
|
||||||
|
local tap = PickerChrome.tapMin(Kit)
|
||||||
|
local pagerH = math.max(tap, math.floor(30 * s))
|
||||||
|
local rowH = math.max(tap, math.floor(40 * s))
|
||||||
|
local rowGap = math.max(4, math.floor(6 * s))
|
||||||
|
local listBottom = cardY + cardH - pad - pagerH - math.max(6, math.floor(10 * s))
|
||||||
|
local listH = math.max(0, listBottom - contentTop)
|
||||||
|
return listH, rowH, rowGap, pagerH
|
||||||
|
end
|
||||||
|
|
||||||
|
function PickerChrome.fieldH(Kit)
|
||||||
|
return math.max(PickerChrome.tapMin(Kit), math.floor(34 * ((Kit and Kit.scale) or 1)))
|
||||||
|
end
|
||||||
|
|
||||||
|
function PickerChrome.closeSize(Kit)
|
||||||
|
return math.max(PickerChrome.tapMin(Kit), math.floor(30 * ((Kit and Kit.scale) or 1)))
|
||||||
|
end
|
||||||
|
|
||||||
|
return PickerChrome
|
||||||
@@ -53,6 +53,11 @@ 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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
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 = {}
|
||||||
@@ -57,38 +58,39 @@ 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
|
-- the scrim doubles as the "tap outside to cancel" target; it covers the
|
||||||
|
-- full window so unsafe bands (notch / home indicator) stay dimmed too
|
||||||
Theme.col(PAL.bg, 0.82)
|
Theme.col(PAL.bg, 0.82)
|
||||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||||
|
|
||||||
local w = math.min(width - 32 * s, 520 * s)
|
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
|
||||||
local h = math.min(height - 32 * s, 560 * s)
|
-- usable list (#917 / #715).
|
||||||
local x = (width - w) / 2
|
local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
|
||||||
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
|
||||||
|
|
||||||
Kit.caption(cx, cy, "ADD AN ITEM")
|
local closeW = PickerChrome.closeSize(Kit)
|
||||||
local closeW = 30 * s
|
local captionH = Kit.textHeight("caption")
|
||||||
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
|
local headH = math.max(captionH, closeW)
|
||||||
|
Kit.caption(cx, cy + (headH - captionH) / 2, "ADD AN ITEM")
|
||||||
|
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x",
|
||||||
{ font = "small" }) then
|
{ font = "small" }) then
|
||||||
Ops.closeItemPicker(S, Kit)
|
Ops.closeItemPicker(S, Kit)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
cy = cy + Kit.textHeight("caption") + 10 * s
|
cy = cy + headH + 10 * s
|
||||||
|
|
||||||
-- Destination toggle. Which list an item lands in is the only real choice
|
-- 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 = 30 * s
|
local destH = math.max(PickerChrome.tapMin(Kit), math.floor(30 * s))
|
||||||
if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then
|
if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then
|
||||||
p.dest = "bag"
|
p.dest = "bag"
|
||||||
end
|
end
|
||||||
@@ -98,16 +100,13 @@ function Picker.draw(S, Kit, width, height)
|
|||||||
end
|
end
|
||||||
cy = cy + destH + 10 * s
|
cy = cy + destH + 10 * s
|
||||||
|
|
||||||
local fieldH = 34 * s
|
local fieldH = PickerChrome.fieldH(Kit)
|
||||||
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
|
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 rowH = 36 * s
|
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
|
||||||
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
|
||||||
|
|||||||
@@ -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 = 24 * s
|
local clear = Kit.tapMin()
|
||||||
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 cycles, the x empties: two targets, no modal picker
|
-- the row body opens the searchable picker; the x empties the slot
|
||||||
if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then
|
if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then
|
||||||
Ops.cycleMove(S, mon, slot)
|
Ops.openMovePicker(S, Kit, 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 = 30 * s
|
local rowH = math.max(Kit.tapMin(), 30 * s)
|
||||||
local rowGap = 8 * s
|
local rowGap = 8 * s
|
||||||
local cellH = 52 * s
|
local cellH = 52 * s
|
||||||
local actH = 34 * s
|
local actH = math.max(Kit.tapMin(), 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 cycle", cx + inner, movesY, PAL.caption)
|
Kit.textRight("tiny", "click a slot to search", cx + inner, movesY, PAL.caption)
|
||||||
local mRowY = movesY + capH + 10 * s
|
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 cycle", rightX + colW, colY, PAL.caption)
|
Kit.textRight("tiny", "click a slot to search", rightX + colW, colY, PAL.caption)
|
||||||
|
|
||||||
local rowY = colY + capH + 10 * s
|
local rowY = colY + capH + 10 * s
|
||||||
drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
|
drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
-- Type-to-search move picker. The inspector used to change moves by cycling
|
||||||
|
-- the catalog one tap at a time -- 165 taps to walk Gen1 -- which is the same
|
||||||
|
-- class of friction the species arrows had before #541. This is the
|
||||||
|
-- replacement: one modal list, filtered as you type, committing through
|
||||||
|
-- Ops.setMove so an unusable / scalar id refuses instead of asserting.
|
||||||
|
--
|
||||||
|
-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises
|
||||||
|
-- Kit.blockClicks over the chrome and the panel while this is open and lowers
|
||||||
|
-- it only for this overlay; nothing underneath can take the same tap.
|
||||||
|
|
||||||
|
local Theme = require("Theme")
|
||||||
|
local Ops = require("Ops")
|
||||||
|
local PickerChrome = require("PickerChrome")
|
||||||
|
local PAL = Theme.PAL
|
||||||
|
|
||||||
|
local Picker = {}
|
||||||
|
|
||||||
|
local FIELD_ID = "move-picker"
|
||||||
|
|
||||||
|
function Picker.results(S)
|
||||||
|
local p = S.movePicker
|
||||||
|
return Ops.moveSearch(S, p and p.query or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commit(S, id)
|
||||||
|
local p = S.movePicker
|
||||||
|
if not p then return false end
|
||||||
|
return Ops.setMove(S, S.editingMon, p.slot, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Enter commits the top match, which is the whole point of a search field.
|
||||||
|
function Picker.commitFirst(S, Kit)
|
||||||
|
local hits = Picker.results(S)
|
||||||
|
if not hits[1] then return Ops.say(S, "No move matches that") end
|
||||||
|
local ok = commit(S, hits[1])
|
||||||
|
if ok then Ops.closeMovePicker(S, Kit) end
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
function Picker.draw(S, Kit, width, height)
|
||||||
|
local p = S.movePicker
|
||||||
|
if not p then return end
|
||||||
|
local s = Kit.scale
|
||||||
|
|
||||||
|
-- The click that opened the picker is still the frame's click: the
|
||||||
|
-- inspector dispatches earlier in App.draw than this overlay does, so
|
||||||
|
-- without swallowing it the scrim below would read it as a tap outside and
|
||||||
|
-- shut the picker in the same frame it went up. App re-raises the shield
|
||||||
|
-- at the top of the next frame, so leaving it up here is safe.
|
||||||
|
if p.opened then
|
||||||
|
p.opened = nil
|
||||||
|
Kit.blockClicks = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the scrim doubles as the "tap outside to cancel" target; it covers the
|
||||||
|
-- full window so unsafe bands (notch / home indicator) stay dimmed too
|
||||||
|
Theme.col(PAL.bgBot, 0.72)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||||
|
|
||||||
|
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
|
||||||
|
-- usable list (#917 / #715).
|
||||||
|
local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
|
||||||
|
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
|
||||||
|
Ops.closeMovePicker(S, Kit)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Kit.card(x, y, w, h)
|
||||||
|
local cx, cy = x + pad, y + pad
|
||||||
|
local inner = w - 2 * pad
|
||||||
|
|
||||||
|
local closeW = PickerChrome.closeSize(Kit)
|
||||||
|
local captionH = Kit.textHeight("caption")
|
||||||
|
local headH = math.max(captionH, closeW)
|
||||||
|
Kit.caption(cx, cy + (headH - captionH) / 2, ("CHOOSE MOVE %d"):format(p.slot or 1))
|
||||||
|
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x",
|
||||||
|
{ font = "small", radius = 7 * s }) then
|
||||||
|
Ops.closeMovePicker(S, Kit)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
cy = cy + headH + 10 * s
|
||||||
|
|
||||||
|
local fieldH = PickerChrome.fieldH(Kit)
|
||||||
|
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
|
||||||
|
"type a name, an id, or a type")
|
||||||
|
cy = cy + fieldH + 10 * s
|
||||||
|
|
||||||
|
local hits = Picker.results(S)
|
||||||
|
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
|
||||||
|
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap)))
|
||||||
|
p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage))
|
||||||
|
-- wheel / touch drag scroll the modal list too; the shield is already
|
||||||
|
-- lowered for this layer, so Kit.scroll works here and only here (#715)
|
||||||
|
p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage)
|
||||||
|
|
||||||
|
local mon = S.editingMon
|
||||||
|
local currentId = mon and mon.moves and mon.moves[p.slot]
|
||||||
|
and mon.moves[p.slot].id
|
||||||
|
|
||||||
|
if #hits == 0 then
|
||||||
|
Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.")
|
||||||
|
else
|
||||||
|
Kit.pushClip(cx, cy, inner, listH)
|
||||||
|
for i = 1, perPage do
|
||||||
|
local id = hits[p.offset + i]
|
||||||
|
if not id then break end
|
||||||
|
local ry = cy + (i - 1) * (rowH + rowGap)
|
||||||
|
local def = S.data.moves[id]
|
||||||
|
local usable = Ops.moveUsable(S, id)
|
||||||
|
local current = currentId == id
|
||||||
|
if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then
|
||||||
|
if commit(S, id) then
|
||||||
|
Ops.closeMovePicker(S, Kit)
|
||||||
|
Kit.popClip()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local typ = usable and tostring(def and def.type or "?") or "no data"
|
||||||
|
local pp = usable and ("PP %d"):format(tonumber(def and def.pp) or 0) or ""
|
||||||
|
local tail = pp ~= "" and (typ .. " " .. pp) or typ
|
||||||
|
local tailW = Kit.textWidth("tiny", tail)
|
||||||
|
Kit.text("monoRow",
|
||||||
|
Kit.ellipsize("monoRow", id, inner - tailW - 28 * s),
|
||||||
|
cx + 12 * s, ry + (rowH - Kit.textHeight("monoRow")) / 2,
|
||||||
|
usable and PAL.text or PAL.faint)
|
||||||
|
Kit.textRight("tiny", tail, cx + inner - 10 * s,
|
||||||
|
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption)
|
||||||
|
end
|
||||||
|
Kit.popClip()
|
||||||
|
Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage)
|
||||||
|
end
|
||||||
|
|
||||||
|
p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Picker
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
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 = {}
|
||||||
@@ -59,44 +60,42 @@ 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
|
-- 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)
|
Theme.col(PAL.bgBot, 0.72)
|
||||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||||
|
|
||||||
local w = math.min(width - 32 * s, 520 * s)
|
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
|
||||||
local h = math.min(height - 32 * s, 560 * s)
|
-- usable list (#917 / #715).
|
||||||
local x = (width - w) / 2
|
local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
|
||||||
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
|
||||||
|
|
||||||
Kit.caption(cx, cy, p.mode == "box-add"
|
local closeW = PickerChrome.closeSize(Kit)
|
||||||
|
local captionH = Kit.textHeight("caption")
|
||||||
|
local headH = math.max(captionH, closeW)
|
||||||
|
Kit.caption(cx, cy + (headH - captionH) / 2, p.mode == "box-add"
|
||||||
and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES")
|
and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES")
|
||||||
local closeW = 30 * s
|
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x",
|
||||||
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 + Kit.textHeight("caption") + 10 * s
|
cy = cy + headH + 10 * s
|
||||||
|
|
||||||
local fieldH = 34 * s
|
local fieldH = PickerChrome.fieldH(Kit)
|
||||||
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
|
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 rowH = 40 * s
|
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user