Merge pull request #1816 from bryanthaboi/dev

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