Compare commits

..

1 Commits

Author SHA1 Message Date
bryanthaboi 803f86d5b3 api upgrades, windows dev tools, and android fixes (#299)
* pop up a fake save

* add CI to dev branch

* Make ROM-free test tiers actually run on Windows (#267)

Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>

* intro api upgrade (#294)

* intro api upgrade

* api updates

* fix tests

* updated templates

* android fixes for mods and saves (#297)

* Android SCALING fixes (#298)

* android fixes for mods and saves

* perhaps this is the true scaling android issue fix

---------

Co-authored-by: johnjohto <johtoboy@atomicmail.io>
Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 09:29:24 -04:00
68 changed files with 2902 additions and 300 deletions
+14 -1
View File
@@ -25,7 +25,9 @@ body:
options:
- macOS
- Windows
- Both
- Linux
- Android
- Multiple platforms
validations:
required: true
@@ -38,6 +40,17 @@ body:
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: input
id: location
attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Use this for **engine / launcher / platform** work (ports, video options, save
tooling, networking, mod API seams, docs).
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
and how you picture it working.
- type: input
id: summary
attributes:
label: One line summary
description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
Describe it properly. What is it, where does it live (launcher, options,
engine), what does the player see or do. If it changes something that already
exists, say what it does today and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
with the same save folder layout and mod discovery path.
validations:
required: true
@@ -41,9 +59,8 @@ body:
What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
LÖVE already runs on Linux; without a packaged build, players have to assemble
it themselves and miss release notes / update checks.
validations:
required: true
@@ -52,14 +69,12 @@ body:
attributes:
label: How should it work
description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't
The specifics. Which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
- GitHub Releases asset next to the .dmg / .exe
- Same options.lua / mods/ layout as desktop
- Documented in the README install section
validations:
required: true
+115
View File
@@ -0,0 +1,115 @@
name: Mod request
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
labels: ["mod request"]
body:
- type: markdown
attributes:
value: |
This tracker is for ideas that should ship as **mods**, not as core engine
features — alternate sprites, running shoes, day/night, shiny indicators,
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
- type: input
id: summary
attributes:
label: One line summary
description: What the mod should do, in a sentence.
placeholder: Hold B to run at bike speed on the overworld
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers or mod authors can ping you on Discord if they pick this up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What should the mod do
description: >
Describe the player-facing behavior. What changes, where, what does the
player see or press. If it toggles from Options or a START-menu entry, say so.
placeholder: |
Hold B while walking outdoors to move at bike speed. Release to walk again.
Same places the bike is allowed; no effect in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing as a mod
description: >
Why optional/modded rather than a core option. Who wants it on, who wants
vanilla left alone.
placeholder: |
Great for replaying and backtracking, but some people want a strict Gen 1
pace. A mod (or an opt-in mod option) keeps both camps happy.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
mention it — otherwise leave it open.
placeholder: |
- Hold B on the overworld
- Same step timing as the bike
- Disabled where the bike is disabled
- Prefer hooks:wrap("movement.speed") if that still fits
validations:
required: true
- type: dropdown
id: vanilla
attributes:
label: With the mod off, is vanilla unchanged
options:
- Yes — parity when disabled
- No — it would replace something always-on
- Not sure
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best-fit mod category
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
options:
- GAMEPLAY / QoL
- GRAPHICS
- AUDIO
- BALANCE / ruleset
- CONTENT (maps, encounters, trainers)
- UI / TOOL
- TOTAL_CONVERSION-ish
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, other games/hacks that do it, related issues, or
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
validations:
required: false
+3 -1
View File
@@ -14,7 +14,9 @@ name: ci
on:
push:
branches: [main]
# Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue
+3
View File
@@ -10,6 +10,9 @@ name: Release
# - To force a specific version, either:
# * run this workflow manually (Actions tab) and type it into "version", or
# * put "[release X.Y.Z]" anywhere in the commit message.
#
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
# so promoting `dev` -> `main` is the ship gate that cuts a build.
on:
push:
+47 -15
View File
@@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
## Android multi-ROM / mod / save import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
save directory as:
| `kind` | Destination |
| --- | --- |
| nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
for the launcher to acknowledge on refocus.
`RomImporter` then imports on refocus / Choose:
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure
@@ -78,6 +98,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
## Import / Export save
@@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
+5 -5
View File
@@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file under the app save directory as `picked_rom.gb`,
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=Pokemon Red
app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
bool showFilePicker(const char *destFilename)
{
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
* external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/*
* Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h"
#include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
bool System::pickFile(const char *kind) const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false;
#endif
}
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1;
}
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/
@Keep
public static boolean showRomFilePicker() {
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// 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);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false;
}
}
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "create-document cancelled");
return;
}
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "pending export missing at result time");
return;
}
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
}
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
Log.d("GameActivity", "file picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
Log.d("GameActivity", "could not copy picked file to " + destFile);
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+22 -6
View File
@@ -299,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save)
curStats = mon.stats,
curTypes = def.types,
curMoves = mon.moves,
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
monPalette(data, mon.species), def.trueColor),
sprite = (function()
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(data, mon.species,
isPlayer and "back" or "front",
{ mon = mon, kind = "battle" })
return getImage(path, monPalette(data, mon.species), tc)
end)(),
}
end
@@ -318,13 +323,16 @@ BattleState.makeBattler = makeBattler
function BattleState:speciesSprite(species, isPlayerSide)
local def = self.data.pokemon[species]
if not def then return nil end
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(self.data, species,
isPlayerSide and "back" or "front", { kind = "battle" })
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(self.data, species, true)
local name = "GRAYMON"
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
return getImage(path,
colors and { name = name, colors = colors } or nil,
def.trueColor)
tc)
end
local function markSeen(game, species)
@@ -3868,8 +3876,11 @@ function BattleState:colorMode()
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
and love.image and PaletteFX.pack(self.data)
and PaletteFX.shader() then
local ok1, bg = pcall(g.newCanvas, 160, 144)
local ok2, wv = pcall(g.newCanvas, 160, 144)
-- 160x144 real pixels, not DPI units, or the colored battle background
-- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
local PixelCanvas = require("src.render.PixelCanvas")
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
local ok2, wv = pcall(PixelCanvas.new, 160, 144)
if ok1 and ok2 and bg and wv then
self.bgCanvas, self.waveCanvas = bg, wv
ready = true
@@ -4473,6 +4484,11 @@ function BattleState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
love.graphics.setColor(1, 1, 1, 1)
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
-- the vanilla link is a no-op so an empty chain costs nothing.
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, self)
end
end
return BattleState
+6 -2
View File
@@ -581,8 +581,12 @@ MoveEffects.full = {
},
HYPER_BEAM_EFFECT = {
afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
-- Gen 1: no recharge when the target faints OR its substitute breaks.
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
local ruleset = ctx.battle and ctx.battle.ruleset
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
if not skipOnKO or not targetDown then
ctx.user.mustRecharge = true
end
end,
+3
View File
@@ -16,4 +16,7 @@ return {
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
-- substitute breaks). Set false to always recharge like Gen 2+.
hyperBeamSkipRechargeOnKO = true,
}
+2
View File
@@ -11,4 +11,6 @@ return {
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false,
}
+14
View File
@@ -96,6 +96,20 @@ function Game:load()
end
Logger.info("game loaded")
-- Scaling bug reports (#87, #208) are unanswerable without these three
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
-- looks stretched" into something reproducible.
if love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
Logger.info(string.format(
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
ww, wh, pw, ph, Renderer:fitScale()))
end
end
-- the merged field.boot: spawn, names, money and the naming presets a
+31 -1
View File
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
local function applyVolume(src)
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
if not src then return end
local vol = VOLUME * volumeScale
if Runtime.wantsHook("music.volume") then
local ctx = {
song = state.current,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
fading = state.fade ~= nil,
optionScale = volumeScale,
}
local ok, Game = pcall(require, "src.core.Game")
if ok and Game then
local ow = Game.overworld
if ow and ow.player then
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
ctx.mapId = ow.map and ow.map.id
ctx.tod = ow.tod
end
end
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
vol = tonumber(vol) or (VOLUME * volumeScale)
if vol < 0 then vol = 0 end
end
pcall(src.setVolume, src, vol)
end
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
@@ -408,6 +432,12 @@ end
-- restores the map theme after a one-shot jingle
function Music.update(data)
if state.chip then require("src.core.ChipAudio").update() end
-- distance / indoor muffling mods re-apply volume every frame while
-- subscribed; otherwise applyVolume only runs on song/option changes
if Runtime.wantsHook("music.volume") and not state.fade then
applyVolume(state.source)
applyVolume(state.loopSource)
end
-- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops.
+38
View File
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true
end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
+251 -42
View File
@@ -290,6 +290,39 @@ local function findPendingRom(ready)
return nil
end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
@@ -320,8 +353,8 @@ local function chooseRom(promptName)
end
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
-- Returns the chosen absolute path or nil. Android has no picker; the mods
-- panel steers that case to the drag-drop hint instead.
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
-- ("mod") instead -- see RomImporter:chooseMod.
local function chooseZip()
local prompt = "Choose a mod .zip"
local platform = love.system.getOS()
@@ -351,8 +384,8 @@ local function chooseZip()
end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android has no picker;
-- the SAVE FILES card steers that case to the drag-drop hint instead.
-- dialogs). Returns the chosen absolute path or nil. Android uses
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
local function chooseSav()
local prompt = "Choose a .sav save file"
local platform = love.system.getOS()
@@ -429,10 +462,16 @@ function RomImporter.new(onComplete, opts)
-- affordance (desktop love.system.openURL).
saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install); modScroll
-- is the list scroll offset (px, clamped in draw); modNotice is the last
-- install result { ok, text } shown as a line above the list.
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil,
-- Android SAF: which game tab should receive the next picked_save.sav when
-- focus consumes it (set by chooseSaveImport before opening the picker).
androidPendingVersion = nil,
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
}, RomImporter)
for _, version in ipairs(GameVersion.ORDER) do
@@ -491,10 +530,40 @@ end
-- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied
-- it into the save directory, so a pending-ROM rescan on refocus picks it
-- up without the player needing to tap the button again.
-- it into the save directory, so a pending-file rescan on refocus picks it
-- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready.
function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
end
return
end
local savName = findPendingSav(false)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
return
end
if self.ready.red and self.ready.blue then return end
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
@@ -672,13 +741,38 @@ function RomImporter:_installMod(source)
self.tab = "mods"
end
-- "Import mod .zip" button: open a native picker (desktop) and install the
-- pick. Android has no picker, so it points the player at the drop path.
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
function RomImporter:_deleteMod(id)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end
-- "Import mod .zip" button: open a native picker and install the pick.
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
-- which focus/Choose consumes on return.
function RomImporter:chooseMod()
if self.workState == "working" then return end
if self.android then
self.modNotice =
{ ok = false, text = "Drop a mod .zip onto the window to install it." }
local name = findPendingMod(true)
if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
return
end
if not love.system.pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
end
return
end
local path = chooseZip()
@@ -722,13 +816,26 @@ function RomImporter:_importSave(version, source)
end
end
-- "Import save" button: open a native .sav picker (desktop) and import the pick.
-- Android has no picker, so it points the player at the drop path.
-- "Import save" button: open a native .sav picker and import the pick.
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
if self.android then
self.saveNotice[version] =
{ ok = false, text = "Drop a .sav onto the window to import it." }
local name = findPendingSav(true)
if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
end
return
end
local path = chooseSav()
@@ -736,16 +843,58 @@ function RomImporter:chooseSaveImport(version)
end
-- "Export save" button: write the active slot back out to a raw .sav in the save
-- directory's exports/ folder and show the path (with an open-folder affordance)
-- on the SAVE FILES card.
-- directory's exports/ folder. On desktop, show the path with an open-folder
-- affordance. On Android, stage pending_export.sav and open the system
-- create-document picker (love.system.createFile) so the player can save to
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
function RomImporter:exportSave(version)
if self.workState == "working" then return end
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if ok then
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
else
if not ok then
self.saveNotice[version] = { ok = false, text = tostring(res) }
return
end
if self.android then
local rel = res:match("exports[/\\][^/\\]+$")
local data = rel and love.filesystem.read(rel)
if not data then
self.saveNotice[version] = { ok = false,
text = "Exported, but could not stage the file for the picker." }
return
end
local suggested = rel:match("[^/\\]+$") or "export.sav"
local wrote, writeErr = love.filesystem.write("pending_export.sav", data)
if not wrote then
self.saveNotice[version] = { ok = false,
text = "Could not stage the export: " .. tostring(writeErr) }
return
end
self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then
self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." }
else
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true,
text = "Exported inside the app folder (picker unavailable)." }
end
return
end
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
end
-- Delete a save slot from the registry and disk, then refresh the panel. If the
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
function RomImporter:_deleteSlot(version, id)
if self.workState == "working" then return end
local SaveData = require("src.core.SaveData")
local ok, err = SaveData.deleteSlot(version, id)
if ok then
self:_refreshSlots(version)
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
else
self.saveNotice[version] = { ok = false, text = tostring(err) }
end
end
@@ -1427,9 +1576,17 @@ function RomImporter:mousepressed(x, y, button)
end
return
end
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag
-- commits it on release when the pointer did not move (a moved pointer scrolls
-- instead). Android has no reliable pointer polling, so it selects on press.
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- label never also selects the row. On desktop a press only ARMS a click:
-- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -1445,12 +1602,18 @@ function RomImporter:mousepressed(x, y, button)
self:_newSlot(self.panelVersion); return
end
-- Mods panel: the import button dispatches on press (fixed header, no scroll
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with
-- no pointer polling, toggles on press).
-- conflict); Delete fires immediately; a toggle switch, which lives in the
-- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
-- a drag-scroll (Android, with no pointer polling, toggles on press).
if inside(self.modImportRect, x, y) then
self:chooseMod(); return
end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
return
end
end
for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -1759,7 +1922,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning
elseif self.android then
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning
sfHintText, sfHintCol =
"Import or export a .sav with the system file picker.", PAL.warning
else
sfHintText, sfHintCol =
"Import a .sav to a new slot, or export the active slot.", PAL.warning
@@ -1999,6 +2163,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.printf("No saves yet - start a new game or import one.",
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
@@ -2017,6 +2183,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotScroll[version] = scroll
self.slotRects = {}
self.slotDeleteRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do
@@ -2029,6 +2196,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
love.graphics.setFont(self.hintFont)
local delText = "Delete"
local delW = self.hintFont:getWidth(delText)
local delH = self.hintFont:getHeight()
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - delH
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0
if selected then
@@ -2048,7 +2229,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
local name = slot.name or "NEW GAME"
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW),
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV)
local metaTxt
@@ -2061,7 +2242,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
end
love.graphics.setFont(self.labelFont)
col(PAL.warning)
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s),
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
rx + 12 * s, ry + rowPadV + nameH + 4 * s)
-- clip the hit rect to the visible list band so a partly-scrolled row
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects[#self.slotRects + 1] =
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
end
local dvy = math.max(drect.y, listTop)
local dvy2 = math.min(drect.y + drect.height, listBottom)
if dvy2 > dvy then
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
end
end
love.graphics.setScissor()
@@ -2165,14 +2352,14 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local top = y + headerH + 14 * s
-- notice line: the last install result, else the drag-drop hint
-- notice line: the last install/delete result, else the platform hint
love.graphics.setFont(self.hintFont)
if self.modNotice then
col(self.modNotice.ok and PAL.green or PAL.red)
love.graphics.printf(self.modNotice.text, x, top, w, "left")
else
col(PAL.warning)
love.graphics.printf(self.android and "Copy a mod .zip via USB."
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
or "Or drop a mod .zip onto the window.", x, top, w, "left")
end
top = top + self.hintFont:getHeight() + 12 * s
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
love.graphics.setFont(self.hintFont)
col(PAL.warning)
love.graphics.printf("No mods installed - drop a mod .zip here to add one.",
local emptyHint = self.android
and "No mods installed - tap Import mod .zip to add one."
or "No mods installed - drop a mod .zip here to add one."
love.graphics.printf(emptyHint,
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0
return
end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28)
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
local padH, padV = 16 * s, 14 * s
local cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH
local chipH = self.hintFont:getHeight() + 8 * s
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle
local delH = self.hintFont:getHeight()
local clusterH = chipH + 6 * s + th + 6 * s + delH
love.graphics.setFont(self.stateFont)
local nameH = self.stateFont:getHeight()
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status)
local chipW = self.hintFont:getWidth(chipText) + 20 * s
local clusterW = math.max(chipW, tw)
local delW = self.hintFont:getWidth("Delete")
local clusterW = math.max(chipW, tw, delW)
local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
local descH = 0
if m.description ~= "" then
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
local cardH = math.max(contentH, padV * 2 + clusterH)
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW }
chipText = chipText, chipW = chipW, delW = delW }
total = total + cardH
end
total = total + (#mods - 1) * cardGap
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
self.modScroll = scroll
self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH))
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end
-- right cluster: status chip stacked over the toggle, vertically centred
-- right cluster: status chip, toggle, Delete — vertically centred
local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status)
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- hit rect clipped to the visible list band (a partly-scrolled toggle is
-- only clickable where it actually shows)
-- Delete under the toggle
local delX = clusterX + (L.clusterW - L.delW) / 2
local delY = ty + th + 6 * s
local drect = { x = delX - 6 * s, y = delY - 2 * s,
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top)
local vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then
self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
end
local dvy = math.max(drect.y, top)
local dvy2 = math.min(drect.y + drect.height, top + listH)
if dvy2 > dvy then
self.modDeleteRects[#self.modDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
end
end
cy = cy + cardH + cardGap
end
+13 -5
View File
@@ -47,11 +47,19 @@ local function readSource(source)
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
if f then
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
-- directory; io.open cannot see them, so fall back to love.filesystem.
if love and love.filesystem and love.filesystem.read then
local data = love.filesystem.read(source)
if type(data) == "string" then return data end
end
return nil, "could not read the save file: " .. tostring(openErr)
end
-- importToSlot(source, version) -> ok, slotIdOrErr
+34 -3
View File
@@ -3,11 +3,12 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
-- no filesystem, so the engine tier can table-drive it; the discovery,
-- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
return true, manifest.id
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
return nil, "missing mod id"
end
if id:find("[/\\]") or id == "." or id == ".." then
return nil, "invalid mod id"
end
if not (love and love.filesystem) then
return nil, "mod uninstall needs LOVE"
end
local fs = love.filesystem
local dest = "mods/" .. id
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
removeTree(dest)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
SaveData.saveOptions(options)
end
return true
end
return LauncherMods
+1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
}
local function scanRequire(name)
+63
View File
@@ -0,0 +1,63 @@
-- Runtime Pokémon art resolution. Content registries freeze after load,
-- so a mod that lets the player pick an alternate skin mid-session cannot
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
-- sanctioned seam: every battle pic and party icon load goes through
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
local Runtime = require("src.mods.Runtime")
local Sprites = {}
local function samePath(path) return path end
-- Resolve a battle / menu front or back pic path for `species`.
-- side: "front" | "back"
-- opts.mon: the live mon when available (per-instance skins)
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
-- | "title" | "oak" | "credits" (informational for wrappers)
-- Returns path, trueColor.
function Sprites.path(data, species, side, opts)
opts = opts or {}
local def = data and data.pokemon and data.pokemon[species]
if not def then return nil, false end
local path = side == "back" and def.spriteBack or def.spriteFront
local ctx = {
species = species,
side = side == "back" and "back" or "front",
kind = opts.kind or "battle",
mon = opts.mon,
trueColor = def.trueColor and true or false,
data = data,
}
if path and Runtime.wantsHook("pokemon.sprite") then
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
if type(hooked) == "string" and hooked ~= "" then path = hooked end
end
return path, ctx.trueColor and true or false
end
-- Resolve a party-menu icon image path for `mon`.
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
-- def.icon / icons.byDex; the hook may replace it.
-- Returns path (possibly nil).
function Sprites.iconPath(data, mon, vanillaPath, opts)
opts = opts or {}
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
return vanillaPath
end
local species = mon and mon.species
local ctx = {
species = species,
mon = mon,
name = opts.name,
data = data,
kind = "icon",
}
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
if type(hooked) == "string" and hooked ~= "" then return hooked end
if hooked == nil or hooked == false then return nil end
return vanillaPath
end
return Sprites
+16
View File
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
return math.max(1, math.min(999, v))
end
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
local SHINY_ATK = {
[2] = true, [3] = true, [6] = true, [7] = true,
[10] = true, [11] = true, [14] = true, [15] = true,
}
function Stats.isShiny(dvs)
if type(dvs) ~= "table" then return false end
return (dvs.defense or 0) == 10
and (dvs.speed or 0) == 10
and (dvs.special or 0) == 10
and SHINY_ATK[dvs.attack or 0] == true
end
return Stats
+45
View File
@@ -0,0 +1,45 @@
-- Render targets measured in real framebuffer pixels.
--
-- love.graphics.newCanvas defaults its `dpiscale` to
-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is
-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a
-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene
-- then renders into it at 2.755 texels per GB pixel. conf.lua sets
-- t.window.highdpi on Android/iOS (required for Retina), and Android's
-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this
-- is the normal mobile case, not an edge case.
--
-- That fractional render breaks the whole premise of Renderer's integer
-- scale pipeline. Renderer:fitScale() picks a whole number of physical
-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it
-- exactly -- but if the *source* already holds 2.755 texels per GB pixel,
-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour
-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7.
-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out
-- the right width. Fonts show it worst: their strokes are single pixels.
-- That is issue #208 ("some are noticeably stretched, especially fonts").
--
-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already
-- believes it is drawing. getWidth()/getHeight() are unaffected (they always
-- reported the requested size), so no geometry anywhere changes -- only the
-- resolution of the target. Desktop is unchanged; dpiscale is already 1
-- there because conf.lua sets highdpi on mobile only.
--
-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted
-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI
-- scale so its texture still covers the framebuffer; it is not built with
-- this helper.
local PixelCanvas = {}
-- One framebuffer pixel per w/h unit, always. `filter` is applied only when
-- given, so callers that relied on LOVE's default ("linear") keep it.
function PixelCanvas.new(w, h, filter)
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
if filter and canvas and canvas.setFilter then
canvas:setFilter(filter, filter)
end
return canvas
end
return PixelCanvas
+28 -8
View File
@@ -10,6 +10,8 @@ local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local Renderer = {}
@@ -78,8 +80,10 @@ local function displayMetrics()
end
function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest")
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -230,8 +234,7 @@ function Renderer:beginWorldPass()
-- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh)
self.worldCanvas:setFilter("nearest", "nearest")
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
end
self.worldActive = true
PaletteFX.setPass("world")
@@ -259,8 +262,7 @@ function Renderer:beginUprightPass()
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
self.uprightCanvas:setFilter("nearest", "nearest")
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
end
self.uprightActive = true
PaletteFX.setPass(nil)
@@ -354,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
self.tiltCanvas:setFilter("linear", "linear")
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
end
love.graphics.setCanvas(self.tiltCanvas)
@@ -464,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
or self.presentCanvas:getHeight() ~= wh then
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
-- units and blitted back at unit scale 1 (and handed to mod present
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
-- texture to cover the framebuffer. Everything composited into it is
-- already native-resolution now, so #208's fractional source is gone;
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
-- one seam across the window) that a single scalar dpiscale cannot
-- express.
self.presentCanvas = love.graphics.newCanvas(ww, wh)
self.presentCanvas:setFilter("linear", "linear")
end
@@ -486,6 +495,17 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
-- canvas so the playfield sits on top of the border.
if Runtime.wantsHook("render.letterbox") then
Runtime.call("render.letterbox", function() end, {
ww = ww, wh = wh, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = vpw, vph = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
worldActive = self.worldActive and true or false,
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
+4 -1
View File
@@ -542,7 +542,10 @@ end
local function bakeBorderFill(self, block)
local border = self.map.tileset.blocks[block + 1]
if not border then return end
local canvas = love.graphics.newCanvas(32, 32)
-- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
-- fractional texel size and the repeat-wrapped image would then tile at
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
local canvas = require("src.render.PixelCanvas").new(32, 32)
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1)
+26 -7
View File
@@ -4,19 +4,38 @@
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom)
local Runtime = require("src.mods.Runtime")
local Zoom = {}
Zoom.offset = 0
-- effective integer scale s' in [1, 2*S]
function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset))
end
-- legal offset range for a given fit scale
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1))
return 1 - S, S
local lo, hi = 1 - S, S
if Runtime.wantsHook("zoom.range") then
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
lo = math.floor(tonumber(lo) or (1 - S))
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
return lo, hi
end
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
function Zoom.scale(S)
local lo, hi = Zoom.offsetRange(S)
local s = S + Zoom.offset
local minScale = S + lo
local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end
if s > maxScale then s = maxScale end
if s < 0.25 then s = 0.25 end
return s
end
function Zoom.clampOffset(offset, S)
+1
View File
@@ -370,6 +370,7 @@ function BagMenu.new(game, opts)
local battle = opts.battle
local list
list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money),
-- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l)
+3 -2
View File
@@ -140,8 +140,9 @@ function Credits:enter()
end
function Credits:monSprite(species)
local def = self.game.data.pokemon and self.game.data.pokemon[species]
return silhouette(def and def.spriteFront)
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "credits" })
return silhouette(path)
end
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
+3 -1
View File
@@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local ok, img = path and pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil
require("src.core.Sound").playCry(game.data, species)
return self
+7 -6
View File
@@ -28,10 +28,11 @@ end
local FLASH_FRAMES = 220
local function frontSprite(game, species)
local def = game.data.pokemon[species]
if not (def and def.spriteFront) then return nil end
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
local function frontSprite(game, species, mon)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ mon = mon, kind = "evolution" })
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
@@ -46,8 +47,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
self.cancelable = (via ~= "TRADE")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species)
self.newSprite = frontSprite(game, newSpecies)
self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies, mon)
self.t = 0
self.done = false
self.canceled = false
+3 -2
View File
@@ -118,8 +118,9 @@ end
function HallOfFame:spriteFor(species)
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false
self.sprites[species] = cached
end
return cached or nil
+96 -7
View File
@@ -3,6 +3,7 @@
-- box and the Pokédex.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme")
local ListMenu = {}
@@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game)
end
local ROWS = 7
-- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
-- ui.list_menu identity: unhooked opts pass through unchanged
local function sameOpts(opts) return opts end
function ListMenu.new(game, title, items, opts)
opts = opts or {}
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
if Runtime.wantsHook("ui.list_menu") then
local hooked = Runtime.call("ui.list_menu", sameOpts, {
wrap = opts.wrap,
pageJump = opts.pageJump,
keyRepeat = opts.keyRepeat,
repeatDelay = opts.repeatDelay,
repeatRate = opts.repeatRate,
}, {
game = game,
title = title,
kind = opts.kind or title,
itemCount = items and #items or 0,
})
if type(hooked) == "table" then
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
end
end
local self = setmetatable({}, ListMenu)
self.game = game
self.title = title
@@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts)
self.onCancel = opts.onCancel
self.footer = opts.footer
self.pageJump = opts.pageJump -- Left/Right move a page at a time
self.wrap = opts.wrap -- Up on first / Down on last wraps
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
self.repeatRate = opts.repeatRate or REPEAT_RATE
self.holdDir = nil
self.holdFrames = 0
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
-- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
@@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts)
return self
end
local function moveIndex(self, delta)
local n = #self.items
if n == 0 then return end
local next = self.index + delta
if self.wrap then
next = ((next - 1) % n) + 1
else
next = math.max(1, math.min(n, next))
end
self.index = next
end
local function syncScroll(self)
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- edge press or key-repeat tick for a held direction
local function navPressed(self, dir)
if dir == "up" then
moveIndex(self, -1)
elseif dir == "down" then
moveIndex(self, 1)
elseif dir == "left" and self.pageJump then
moveIndex(self, -self.rows)
elseif dir == "right" and self.pageJump then
moveIndex(self, self.rows)
else
return false
end
syncScroll(self)
return true
end
function ListMenu:update(dt)
if self.script then
self.script(self)
@@ -59,14 +130,20 @@ function ListMenu:update(dt)
end
return
end
local moved = false
if input:wasPressed("up") then
self.index = math.max(1, self.index - 1)
moved = navPressed(self, "up")
self.holdDir, self.holdFrames = "up", 0
elseif input:wasPressed("down") then
self.index = math.min(#self.items, self.index + 1)
moved = navPressed(self, "down")
self.holdDir, self.holdFrames = "down", 0
elseif self.pageJump and input:wasPressed("left") then
self.index = math.max(1, self.index - self.rows)
moved = navPressed(self, "left")
self.holdDir, self.holdFrames = "left", 0
elseif self.pageJump and input:wasPressed("right") then
self.index = math.min(#self.items, self.index + self.rows)
moved = navPressed(self, "right")
self.holdDir, self.holdFrames = "right", 0
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
@@ -80,10 +157,22 @@ function ListMenu:update(dt)
end
return
end
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
if self.keyRepeat then
local dir = self.holdDir
if dir and input:isDown(dir) then
self.holdFrames = self.holdFrames + 1
local afterDelay = self.holdFrames - self.repeatDelay
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
navPressed(self, dir)
end
else
self.holdDir, self.holdFrames = nil, 0
end
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
if not moved then syncScroll(self) end
end
-- remove current item (e.g. consumed); keeps cursor valid
+28
View File
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
return items
end
-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on
-- stable step.id values the same way insertBefore anchors on labels.
local function stepIndex(steps, id)
for i, step in ipairs(steps) do
if step.id == id then return i end
end
return nil
end
function ModUI.insertStepBefore(steps, anchorId, step)
local i = stepIndex(steps, anchorId)
table.insert(steps, i or (#steps + 1), step)
return steps
end
function ModUI.insertStepAfter(steps, anchorId, step)
local i = stepIndex(steps, anchorId)
table.insert(steps, i and (i + 1) or (#steps + 1), step)
return steps
end
function ModUI.removeStep(steps, id)
for i = #steps, 1, -1 do
if steps[i].id == id then table.remove(steps, i) end
end
return steps
end
return ModUI
+40 -9
View File
@@ -5,8 +5,12 @@
-- given, a "NEW NAME" + presets menu is shown first
-- (engine/menus/main_menu.asm name lists).
-- Pops itself from the stack, then calls opts.onDone(name).
--
-- ui.naming.grid may replace either page; keep an "ED" cell and a
-- single-cell case-switch row so confirm / case-flip keep working.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local Theme = require("src.ui.Theme")
@@ -37,8 +41,24 @@ local GRID_LOWER = {
{ "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "UPPER CASE" },
}
local CASE_ROW = 6
local ED_ROW, ED_COL = 5, 9
-- locate the ED confirm cell and the case-switch row on a (possibly
-- modded) grid; falls back to vanilla coordinates
local function findMeta(grid)
local caseRow, edRow, edCol = #grid, 5, 9
for r, row in ipairs(grid) do
if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE"
or row[1] == "lower" or row[1] == "UPPER") then
caseRow = r
end
for c, cell in ipairs(row) do
if cell == "ED" then edRow, edCol = r, c end
end
end
return caseRow, edRow, edCol
end
local function sameGrid(grid) return grid end
function NamingScreen.new(game, opts)
opts = opts or {}
@@ -86,16 +106,27 @@ function NamingScreen:confirm()
end
function NamingScreen:grid()
return self.lower and GRID_LOWER or GRID_UPPER
local base = self.lower and GRID_LOWER or GRID_UPPER
if not Runtime.wantsHook("ui.naming.grid") then return base end
local hooked = Runtime.call("ui.naming.grid", sameGrid, base, {
lower = self.lower and true or false,
title = self.title,
maxLen = self.maxLen,
game = self.game,
})
if type(hooked) ~= "table" or #hooked == 0 then return base end
return hooked
end
-- Gen 1 jumps the cursor to ED once the name is full.
function NamingScreen:jumpToEnd()
self.row, self.col = ED_ROW, ED_COL
local _, edRow, edCol = findMeta(self:grid())
self.row, self.col = edRow, edCol
end
function NamingScreen:update(dt)
local GRID = self:grid()
local caseRow, edRow, edCol = findMeta(GRID)
local input = self.game.input
if input:wasPressed("start") then
self:confirm()
@@ -107,28 +138,28 @@ function NamingScreen:update(dt)
end
if input:wasPressed("up") then
-- wrapping up from the top row lands on the case-switch cell
self.row = self.row > 1 and self.row - 1 or CASE_ROW
self.row = self.row > 1 and self.row - 1 or caseRow
self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("down") then
self.row = self.row < #GRID and self.row + 1 or 1
self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("left") then
-- no horizontal movement on the case-switch row
if self.row ~= CASE_ROW then
if self.row ~= caseRow then
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
end
elseif input:wasPressed("right") then
if self.row ~= CASE_ROW then
if self.row ~= caseRow then
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
end
elseif input:wasPressed("b") then
table.remove(self.glyphs)
elseif input:wasPressed("a") then
if self.row == ED_ROW and self.col == ED_COL then
if self.row == edRow and self.col == edCol then
self:confirm()
return
end
if self.row == CASE_ROW then
if self.row == caseRow then
self.lower = not self.lower
return
end
+357 -72
View File
@@ -1,14 +1,16 @@
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
-- welcome, the NIDORINO show-off, player and rival naming, and the
-- closing "legend is about to unfold" text followed by the shrink-away:
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the
-- overworld walking sprite before the fade to white. Uses the real
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
-- _IntroduceRivalText) with literal fallbacks.
-- closing "legend is about to unfold" text followed by the shrink-away.
--
-- Steps are a data table so mods can reshape the whole speech through
-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a
-- mod can insertBefore("name_player", ...) without counting indices.
-- Calls onDone() after popping itself.
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local Font = require("src.render.Font")
@@ -55,12 +57,154 @@ local function tryImage(path)
return ok and img or nil
end
-- Resolve a pic descriptor to (image, flip).
-- Descriptors:
-- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" }
-- { type = "pokemon", id = "PIKACHU", flip = true }
-- { type = "player", path = "..." } optional override path
-- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech)
if desc == nil then return nil, false end
if type(desc) == "string" then
if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" }
elseif desc == "rival" then
desc = { type = "trainer", id = "OPP_RIVAL1" }
elseif desc == "player" then
desc = { type = "player" }
else
-- bare species id
desc = { type = "pokemon", id = desc }
end
end
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false
end
local path = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" })
return tryImage(path), desc.flip and true or false
elseif t == "player" then
if speech and speech.playerPic and not desc.path then
return speech.playerPic, false
end
return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false
elseif t == "image" then
return tryImage(desc.path), desc.flip and true or false
elseif t == "sprite" then
local sp = game.data.sprites and game.data.sprites[desc.id]
return tryImage(sp and sp.image), desc.flip and true or false
end
return nil, false
end
-- Vanilla step list. Ids are the stable anchors mods insert around.
function OakSpeech.defaultSteps(speech)
return {
{
id = "oak_welcome",
kind = "say",
textKey = "_OakSpeechText1",
pic = "oak",
reveal = "fade",
},
{
id = "demo_mon",
kind = "demo",
},
{
id = "world_spiel",
kind = "say",
textKey = "_OakSpeechText2B",
},
{
id = "ask_player_name",
kind = "say",
textKey = "_IntroducePlayerText",
pic = "player",
},
{
id = "name_player",
kind = "name",
who = "player",
title = "YOUR NAME?",
presetsWho = "player",
presetsFallback = { "RED", "ASH", "JACK" },
},
{
id = "ask_rival_name",
kind = "say",
textKey = "_IntroduceRivalText",
pic = "rival",
},
{
id = "name_rival",
kind = "name",
who = "rival",
title = "HIS NAME?",
presetsWho = "rival",
presetsFallback = { "BLUE", "GARY", "JOHN" },
},
{
id = "legend",
kind = "say",
textKey = "_OakSpeechText3",
pic = "player",
},
{
id = "shrink",
kind = "shrink",
},
}
end
-- list helpers for intro.oak_speech.build wrappers (also on ModUI)
local function indexOfId(steps, id)
for i, step in ipairs(steps) do
if step.id == id then return i end
end
return nil
end
function OakSpeech.insertBefore(steps, anchorId, step)
local i = indexOfId(steps, anchorId)
table.insert(steps, i or (#steps + 1), step)
return steps
end
function OakSpeech.insertAfter(steps, anchorId, step)
local i = indexOfId(steps, anchorId)
table.insert(steps, i and (i + 1) or (#steps + 1), step)
return steps
end
function OakSpeech.removeId(steps, id)
for i = #steps, 1, -1 do
if steps[i].id == id then table.remove(steps, i) end
end
return steps
end
function OakSpeech.new(game, onDone)
local self = setmetatable({}, OakSpeech)
self.game = game
self.onDone = onDone
self.step = 0
self.pic = nil
self.answers = {}
local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
@@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone)
-- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
self.demoPic = tryImage(demo and demo.spriteFront)
local demoPath = require("src.pokemon.Sprites").path(
game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath)
local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
@@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone)
return self
end
function OakSpeech:buildSteps()
local steps = OakSpeech.defaultSteps(self)
local hooked = Runtime.call("intro.oak_speech.build",
function(s) return s end, steps, self)
if type(hooked) ~= "table" then
Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps",
type(hooked))
return steps
end
return hooked
end
function OakSpeech:enter()
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
Music.play(self.game.data, self.cfg.music or "Music_Routes2")
self.answers = {}
self.steps = self:buildSteps()
if Runtime.wants("intro.oak_speech.started") then
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
end
self:advance()
end
@@ -97,80 +259,187 @@ function OakSpeech:say(key, next)
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
end
local STEPS = {
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
function(self)
self.pic = self.oakPic
self:revealPic("fade", function()
self:say("_OakSpeechText1", function() self:advance() end)
function OakSpeech:sayText(text, next, opts)
self.game.stack:push(TextBox.new(self.game, text, next, opts))
end
function OakSpeech:stepText(step)
if step.text then return step.text end
if step.textKey then return textOr(self.game, step.textKey) end
return ""
end
function OakSpeech:applyPic(step)
if step.pic == nil then return end
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
if img then
self.pic = img
self.picFlip = flip or false
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic
self.picFlip = false
end
end
function OakSpeech:recordAnswer(step, index, label, value)
if value == nil then value = label end
if step.saveKey then
self.answers[step.saveKey] = value
end
if Runtime.wants("intro.oak_speech.answered") then
Runtime.emit("intro.oak_speech.answered", {
speech = self,
step = step,
index = index,
label = label,
value = value,
saveKey = step.saveKey,
})
end
end
function OakSpeech:afterReveal(step, fn)
if step.reveal then
self:revealPic(step.reveal, fn)
else
fn()
end
end
function OakSpeech:runCry(step)
local cry = step.cry
if not cry then return end
if cry == true then
if type(step.pic) == "string" and step.pic ~= "oak"
and step.pic ~= "rival" and step.pic ~= "player" then
cry = step.pic
elseif type(step.pic) == "table" and step.pic.type == "pokemon" then
cry = step.pic.id
else
return
end
end
Sound.playCry(self.game.data, cry)
end
function OakSpeech:runStep(step)
local kind = step.kind or "say"
if kind == "say" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:sayText(self:stepText(step), function() self:advance() end)
end)
end,
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
-- (MovePicLeft), then its cry sounds and the text prints
function(self)
elseif kind == "demo" then
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
self.pic = self.demoPic
self.picFlip = true
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end)
end)
end,
-- 3. the rest of the world-of-POKéMON spiel
function(self)
self:say("_OakSpeechText2B", function() self:advance() end)
end,
-- 4. "First, what is your name?" over the player's own pic
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_IntroducePlayerText", function() self:advance() end)
end,
function(self)
elseif kind == "name" then
local who = step.who or "player"
local presets = step.presets
or namePresets(self.game, step.presetsWho or who,
step.presetsFallback or { "RED" })
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "YOUR NAME?",
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
maxLen = self.nameLen,
title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"),
presets = presets,
maxLen = step.maxLen or self.nameLen,
onDone = function(name)
self.game.save.player.name = name
if who == "rival" then
self.game.save.player.rival = name
else
self.game.save.player.name = name
end
self:recordAnswer(step, 1, name, name)
self:advance()
end,
})
end,
-- 6. the rival introduction and naming
function(self)
self.pic = self.rivalPic
self:say("_IntroduceRivalText", function() self:advance() end)
end,
function(self)
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "HIS NAME?",
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
maxLen = self.nameLen,
onDone = function(name)
self.game.save.player.rival = name
self:advance()
end,
})
end,
-- 8. "your very own POKéMON legend is about to unfold!" over the
-- player pic again (oak_speech.asm:105-113)
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_OakSpeechText3", function() self:advance() end)
end,
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
-- into the walking sprite, then fades to white (oak_speech.asm
-- .next, lines 115-166). Not skippable, like the DelayFrames
-- chain it ports.
function(self)
elseif kind == "choice" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
local function openMenu()
local Menu = require("src.ui.Menu")
local items = {}
for i, label in ipairs(step.choices or {}) do
items[i] = {
label = label,
onSelect = function()
local value = label
if step.values and step.values[i] ~= nil then
value = step.values[i]
end
self:recordAnswer(step, i, label, value)
self:advance()
end,
}
end
self.game.stack:push(Menu.new(self.game, items, {
cancelable = step.cancelable == true,
tx = step.tx or 4,
ty = step.ty or 0,
tw = step.tw or 12,
th = step.th,
}))
end
local text = self:stepText(step)
if text ~= "" then
self:sayText(text, openMenu)
else
openMenu()
end
end)
elseif kind == "yesno" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:sayText(self:stepText(step), nil, {
defaultNo = step.defaultNo,
choice = function(yes)
local label = yes and "YES" or "NO"
local value = yes
if step.values then
if yes then
value = step.values[1]
else
value = step.values[2]
end
end
self:recordAnswer(step, yes and 1 or 2, label, value)
self:advance()
end,
})
end)
elseif kind == "pic" then
-- show a sprite with an optional reveal, no text
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:advance()
end)
elseif kind == "shrink" then
Sound.play(self.game.data, "Shrink")
-- the OakSpeechText3 box holds its last page on screen through the
-- shrink (pokered text boxes persist until overwritten)
self.shrinkText = self:lastPageLines("_OakSpeechText3")
-- prefer an explicit shrink text key; fall back to the legend beat
local key = step.textKey or "_OakSpeechText3"
self.shrinkText = self:lastPageLines(key)
self.shrink = { frame = 0 }
end,
}
elseif kind == "fn" then
-- full escape hatch: step.run(speech, done)
if type(step.run) == "function" then
step.run(self, function() self:advance() end)
else
self:advance()
end
else
Logger.warn("oak speech unknown step kind %s (id=%s); skipping",
tostring(kind), tostring(step.id))
self:advance()
end
end
-- the last two visible lines of a text's final page, pre-encoded
function OakSpeech:lastPageLines(key)
@@ -202,15 +471,31 @@ end
function OakSpeech:advance()
self.step = self.step + 1
self.picFlip = false
local fn = STEPS[self.step]
if fn then
fn(self)
local steps = self.steps
if not steps then
-- enter() builds steps; keep a path for callers that advance early
steps = self:buildSteps()
self.steps = steps
end
local step = steps[self.step]
if step then
if Runtime.wants("intro.oak_speech.step") then
Runtime.emit("intro.oak_speech.step", {
speech = self, step = step, index = self.step,
})
end
self:runStep(step)
else
self:finish()
end
end
function OakSpeech:finish()
if Runtime.wants("intro.oak_speech.finished") then
Runtime.emit("intro.oak_speech.finished", {
speech = self, answers = self.answers,
})
end
-- the map theme starts with the overworld beneath (the original's
-- special warp into Pallet Town)
local ow = self.game.overworld
@@ -267,8 +552,8 @@ function OakSpeech:draw()
if self.pic then
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
-- it like the sprite buffer does ((8 - w) >> 1 tiles across,
-- bottom-aligned)
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
-- bottom-aligned
local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8
+1
View File
@@ -95,6 +95,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
if not path then return end
if iconImages[path] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon
+5 -3
View File
@@ -30,9 +30,11 @@ end
function SummaryMenu.new(game, mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local def = game.data.pokemon[mon.species]
if def and def.spriteFront then
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
local Sprites = require("src.pokemon.Sprites")
local path = Sprites.path(game.data, mon.species, "front",
{ mon = mon, kind = "summary" })
if path then
local ok, img = pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil
end
require("src.core.Sound").playCry(game.data, mon.species)
+3 -2
View File
@@ -117,8 +117,9 @@ function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "title" })
cached = tryImage(path) or false
self.sprites[species] = cached
end
return cached or nil
+3 -2
View File
@@ -48,8 +48,9 @@ local function dexOf(game, mon)
end
local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront)
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
{ mon = mon, kind = "trade" })
return tryImage(path)
end
local function expand(game, key, subs)
+39 -2
View File
@@ -452,6 +452,35 @@ end
-- name -> name so the map.palette chain has a vanilla link to wrap
local function samePalette(name) return name end
local function sameTod(tod) return tod end
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
-- "MORNING", etc.; the result is cached on the overworld and handed to
-- map.palette as ctx.tod so palette swaps can key off the period.
function OverworldState:timeOfDay()
local tod = self.tod or "DAY"
if not Runtime.wantsHook("world.tod") then return tod end
local map = self.map
local nextTod = Runtime.call("world.tod", sameTod, tod, {
map = map,
mapId = map and map.id,
x = self.player and self.player.cellX,
y = self.player and self.player.cellY,
steps = self.todSteps or 0,
})
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
if nextTod ~= tod then
self.tod = nextTod
if Runtime.wants("world.tod_changed") then
Runtime.emit("world.tod_changed", {
tod = nextTod, previous = tod, mapId = map and map.id,
})
end
else
self.tod = nextTod
end
return self.tod
end
function OverworldState:paletteNameFor(map)
local palettes = FieldDefaults.field(Game.data, "palettes")
@@ -470,8 +499,9 @@ function OverworldState:paletteNameFor(map)
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
or palettes.default
end
local tod = self:timeOfDay()
if not Runtime.wantsHook("map.palette") then return name end
return Runtime.call("map.palette", samePalette, name, map)
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
end
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
@@ -2781,11 +2811,18 @@ end
function OverworldState:onStepComplete()
local p = self.player
self.todSteps = (self.todSteps or 0) + 1
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
-- paletteNameFor reads self.tod on the next paint
if Runtime.wantsHook("world.tod") then
self:timeOfDay()
end
-- hot path: the payload is only built when something is listening
if Runtime.wants("world.stepped") then
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
tile = self.map:cellTile(p.cellX, p.cellY) })
tile = self.map:cellTile(p.cellX, p.cellY),
tod = self.tod })
end
-- dismounting a surf: landing on a walkable cell ends it
+17 -4
View File
@@ -4,6 +4,7 @@
local Collision = require("src.world.Collision")
local FieldDefaults = require("src.world.FieldDefaults")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Player = {}
@@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities)
self.moving = true
self.bumpFrames = nil -- a real step supersedes any in-place bonk
self.progress = 0
-- the bicycle doubles walking speed (8 frames per step)
local save = require("src.core.Game").save
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
or self.stepFrames or STEP_FRAMES
-- the bicycle doubles walking speed (8 frames per step); movement.speed
-- lets a mod multiply or replace that (running shoes, dash, etc.)
local Game = require("src.core.Game")
local save = Game.save
local frames = (save and save.onBike) and self.bikeStepFrames
or self.stepFrames or STEP_FRAMES
if Runtime.wantsHook("movement.speed") then
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
onBike = save and save.onBike or false,
surfing = self.surfing and true or false,
player = self,
input = Game.input,
save = save,
})
end
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
return "moved"
end
+23
View File
@@ -0,0 +1,23 @@
-- Driver: drops a fake 3-mon party into the overworld and then gets out
-- of the way -- yields forever without ever touching input itself, so
-- real keyboard/controller play works normally from here on. This is
-- just a quick way to pop up a playable window with Pokemon already in
-- the party, not a scripted playthrough.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 77),
}
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.log("popup_fake_save: party ready, handing off to real input")
while true do
coroutine.yield()
end
end
+243
View File
@@ -0,0 +1,243 @@
-- Driver: NEW GAME through the silly Oak intro (example_silly_oak must be
-- installed under mods/). Mashes text, picks every menu option in a fixed
-- pattern, screenshots key beats, then asserts mod.save answers.
--
-- Setup:
-- cp -r mods/examples/example_silly_oak mods/
-- SHOT_DIR=/tmp/silly_oak POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function top() return game.stack:top() end
local function speechState()
for _, s in ipairs(game.stack.states or {}) do
if s.oakPic ~= nil or (s.steps and s.answers) then return s end
end
end
local function topName()
local s = top()
if not s then return "?" end
return s.screenId or (s.__index and tostring(s.__index)) or tostring(s)
end
local function isMenu(s)
return s and s.items and s.index and s.tx ~= nil
end
local function isNaming(s)
return s and s.glyphs ~= nil and s.title ~= nil
end
local function isChoiceBox(s)
return s and s.onChoose and s.index ~= nil and not s.items
end
-- confirm the mod actually loaded
local loaded = false
if game.modStatus and game.modStatus.loaded then
for _, m in ipairs(game.modStatus.loaded) do
if m.id == "example_silly_oak" then loaded = true break end
end
end
if not loaded and game.mods and game.mods.mods then
local m = game.mods.mods.example_silly_oak
loaded = m and m.enabled and not m.failed
end
U.log("example_silly_oak loaded:", tostring(loaded))
if not loaded then
U.log("FAIL: copy the mod up first:")
U.log(" cp -r mods/examples/example_silly_oak mods/")
return
end
U.wait(5)
U.tap(game, "start") -- skip intro movie
U.wait(15)
U.tap(game, "a") -- title -> menu
U.wait(8)
-- with an existing save the menu is CONTINUE / NEW GAME / OPTION;
-- always land on NEW GAME (row 2 when a save exists, else row 1)
local ok, saved = pcall(function()
return require("src.core.SaveData").load() ~= nil
end)
if ok and saved then
U.tap(game, "down")
U.wait(5)
end
U.tap(game, "a") -- NEW GAME
U.wait(20)
-- confirm we actually entered OakSpeech, not CONTINUE into the world
local entered = false
for _ = 1, 60 do
if speechState() then entered = true break end
if game.overworld and top() == game.overworld then break end
U.wait(1)
end
if not entered then
U.log("FAIL: never entered OakSpeech (did we hit CONTINUE?)")
U.log("top:", topName())
return
end
U.shot(game, DIR .. "/silly_01_start.png")
-- walk the speech: A advances text; menus get a deliberate pick pattern
local picks = {
-- silly_toast YES/NO: pick YES (index 1)
toast = 1,
-- naming player: take preset 1 (RED) via NEW NAME menu -> first preset
-- snack: OLD ROD (index 3)
snack = 3,
-- trusts rival: NO (index 2)
trust = 2,
-- pineapple YES/NO: pick NO
pineapple = 2,
}
local saw = {
toast_kid = false, mew = false, snack = false, trust = false,
pineapple = false, shrink = false,
}
local snackPicked, trustPicked = false, false
local toastAnswered, pineappleAnswered = false, false
local shot = {}
for _ = 1, 900 do
local speech = speechState()
local s = top()
if speech and speech.shrink then
saw.shrink = true
if not shot.shrink then
shot.shrink = true
U.shot(game, DIR .. "/silly_05_shrink.png")
end
break
end
-- track which injected beats we hit + screenshot once each
if speech and speech.steps and speech.step then
local cur = speech.steps[speech.step]
if cur then
if cur.id == "silly_toast_kid" then
saw.toast_kid = true
if not shot.toast_kid then
shot.toast_kid = true
U.shot(game, DIR .. "/silly_04_toast_kid.png")
end
end
if cur.id == "silly_mew" then
saw.mew = true
if not shot.mew then
shot.mew = true
U.shot(game, DIR .. "/silly_04b_mew.png")
end
end
if cur.id == "silly_snack" then saw.snack = true end
if cur.id == "silly_trust" then saw.trust = true end
if cur.id == "silly_pineapple" then saw.pineapple = true end
end
end
if isChoiceBox(s) then
-- YES/NO: move to desired row then A
local want
local cur = speech and speech.steps and speech.steps[speech.step]
if cur and cur.id == "silly_toast" then
want = picks.toast
toastAnswered = true
elseif cur and cur.id == "silly_pineapple" then
want = picks.pineapple
pineappleAnswered = true
else
want = 1
end
if s.index ~= want then
U.tap(game, "down")
U.wait(2)
end
U.tap(game, "a")
U.wait(4)
elseif isMenu(s) and not isNaming(s) then
-- multi choice or naming presets
local cur = speech and speech.steps and speech.steps[speech.step]
local want = 1
if cur and cur.id == "silly_snack" then
want = picks.snack
snackPicked = true
if not shot.snack then
shot.snack = true
U.shot(game, DIR .. "/silly_02_snack.png")
end
elseif cur and cur.id == "silly_trust" then
want = picks.trust
trustPicked = true
if not shot.trust then
shot.trust = true
U.shot(game, DIR .. "/silly_03_trust.png")
end
elseif s.items and s.items[1] and s.items[1].label == "NEW NAME" then
-- naming preset menu: pick first preset (row 2)
want = 2
end
while s.index < want do
U.tap(game, "down")
U.wait(2)
s = top()
if not isMenu(s) then break end
end
U.tap(game, "a")
U.wait(4)
else
U.tap(game, "a")
U.wait(2)
end
if game.overworld and top() == game.overworld then break end
end
-- finish shrink + land in overworld
for _ = 1, 150 do
if game.overworld and top() == game.overworld then break end
U.wait(1)
end
U.wait(10)
U.shot(game, DIR .. "/silly_06_overworld.png")
local bucket = (game.save and game.save.modData
and game.save.modData.example_silly_oak)
or (game.mods and game.mods.modSave
and game.mods.modSave.example_silly_oak)
or {}
U.log("saw toast_kid:", tostring(saw.toast_kid),
"mew:", tostring(saw.mew),
"snack:", tostring(saw.snack),
"trust:", tostring(saw.trust),
"pineapple:", tostring(saw.pineapple),
"shrink:", tostring(saw.shrink))
U.log("answers:",
"likes_toast=", tostring(bucket.likes_toast),
"snack=", tostring(bucket.snack),
"trusts_rival=", tostring(bucket.trusts_rival),
"pineapple=", tostring(bucket.pineapple_on_pizza),
"quiz_done=", tostring(bucket.quiz_done))
U.log("player=", game.save and game.save.player and game.save.player.name,
"rival=", game.save and game.save.player and game.save.player.rival)
U.log("top:", topName(),
"map:", game.overworld and game.overworld.map
and game.overworld.map.id or "?")
local function need(cond, msg)
if not cond then U.log("FAIL:", msg) else U.log("ok:", msg) end
end
need(saw.toast_kid, "custom Toast Kid sprite beat ran")
need(saw.mew, "MEW sprite beat ran")
need(saw.snack or snackPicked, "snack choice ran")
need(saw.trust or trustPicked, "rival trust choice ran")
need(saw.pineapple or pineappleAnswered, "pineapple yes/no ran")
need(bucket.likes_toast == true, "likes_toast saved true")
need(bucket.snack == "OLD ROD", "snack saved OLD ROD")
need(bucket.trusts_rival == false, "trusts_rival saved false")
need(bucket.pineapple_on_pizza == false, "pineapple saved false")
need(bucket.quiz_done == true, "quiz_done set")
need(game.overworld and top() == game.overworld, "landed in overworld")
end
+10 -6
View File
@@ -63,13 +63,17 @@ end
-- engine tier, the SDK cases, and any tests a shipped mod carries
local function testCorpus()
local files, bodies = {}, {}
local pipe = io.popen(
"ls tests/*.lua tests/engine/*.lua tests/modkit/cases/*.lua mods/*/tests/*.lua 2>/dev/null")
if pipe then
for line in pipe:lines() do
if line ~= "" then files[#files + 1] = line end
local FsIo = require("tests.fs_io")
local function addLuaFrom(dir)
for _, name in ipairs(FsIo.listDir(dir)) do
if name:match("%.lua$") then files[#files + 1] = dir .. "/" .. name end
end
pipe:close()
end
addLuaFrom("tests")
addLuaFrom("tests/engine")
addLuaFrom("tests/modkit/cases")
for _, mod in ipairs(FsIo.listDir("mods")) do
if not mod:find(".", 1, true) then addLuaFrom("mods/" .. mod .. "/tests") end
end
for _, path in ipairs(files) do
bodies[path] = slurp(path) or ""
+18
View File
@@ -190,4 +190,22 @@ do
check(err ~= nil, "the no-root case carries a reason")
end
-- ------- uninstall: rejects bad ids without needing a real mods tree
do
local ok, err = LauncherMods.uninstall("")
eq(ok, nil, "empty id is rejected")
check(tostring(err):find("missing", 1, true) ~= nil, "empty-id reason")
ok, err = LauncherMods.uninstall("../escape")
eq(ok, nil, "path-like ids are rejected")
check(tostring(err):find("invalid", 1, true) ~= nil, "path-id reason")
ok, err = LauncherMods.uninstall("ghost")
-- Without a mods/ghost tree (and with the love stub's getInfo), uninstall
-- either needs LOVE or reports not installed -- never silently succeeds.
eq(ok, nil, "a missing mod does not uninstall")
check(err ~= nil, "missing-mod uninstall carries a reason")
end
T.finish("launcher_mods")
+35
View File
@@ -165,6 +165,41 @@ do
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
end
-- ---------------------------------------------- deleteSlot
do
local files = fresh()
local a = SaveData.createSlot("red")
local b = SaveData.createSlot("red")
SaveData.setActiveSlot("red", b)
local save = SaveData.newGame()
save.player.name = "KEEP"
T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save")
save.player.name = "GONE"
T.check(SaveData.writeSlot("red", b, save), "seed slot2 with a save")
local ok, err = SaveData.deleteSlot("red", b)
T.check(ok, "deleteSlot removes the active slot: " .. tostring(err))
T.eq(files["saves/red/slot2.lua"], nil, "slot2's file is gone")
T.check(files["saves/red/slot1.lua"] ~= nil, "the other slot's file stays")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, a, "active falls back to the remaining slot")
T.eq(#opts.saveSlots.red.list, 1, "the deleted id is dropped from the list")
T.eq(opts.saveSlots.red.list[1], a, "only slot1 remains registered")
ok = SaveData.deleteSlot("red", a)
T.check(ok, "deleting the last slot succeeds")
opts = SaveSerializer.decode(files["options.lua"])
T.eq(#opts.saveSlots.red.list, 0, "the registry list is empty")
T.eq(opts.saveSlots.red.active, nil, "active clears when no slots remain")
T.eq(#SaveData.listSlots("red"), 0, "listSlots reports an empty install")
local bad, badErr = SaveData.deleteSlot("red", "slot99")
T.check(not bad, "deleting an unknown slot fails")
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
"unknown-slot error is user-presentable")
end
-- ---------------------------------------------- saveNames follows the slot
do
+67 -11
View File
@@ -15,6 +15,71 @@ local function quote(path)
return "'" .. tostring(path):gsub("'", "'\\''") .. "'"
end
-- The suites also run on Windows checkouts, where cmd has no ls/find/test.
-- These probes pick the spelling the host shell understands; anything that
-- listed a directory through a bare Unix command silently returned nothing
-- there, and a tier built on an empty listing passes vacuously.
FsIo.isWindows = package.config:sub(1, 1) == "\\"
local function shellLines(cmd)
local lines = {}
local pipe = io.popen(cmd)
if not pipe then return lines end
for line in pipe:lines() do
if line ~= "" then lines[#lines + 1] = line end
end
pipe:close()
return lines
end
-- names directly inside path (files and directories mixed, like ls -1)
function FsIo.listDir(path)
local cmd
if FsIo.isWindows then
cmd = 'dir /b "' .. tostring(path) .. '" 2>nul'
else
cmd = "ls -1 " .. quote(path) .. " 2>/dev/null"
end
local items = shellLines(cmd)
table.sort(items)
return items
end
-- every *.lua under dir, recursively, as forward-slash paths
function FsIo.luaFilesUnder(dir)
local cmd
if FsIo.isWindows then
cmd = 'dir /b /s "' .. tostring(dir) .. '\\*.lua" 2>nul'
else
-- -L follows symlinks: a checkout that symlinks src/ (worktrees, the
-- ROM-free CI probe) would otherwise scan nothing and hand every gate
-- an empty catalog to pass vacuously against
cmd = "find -L " .. quote(dir) .. " -name '*.lua' -type f 2>/dev/null"
end
local files = {}
for _, line in ipairs(shellLines(cmd)) do
files[#files + 1] = (line:gsub("\\", "/"))
end
table.sort(files)
return files
end
-- existence probe that never shells out on Windows: directories do not
-- open() there at all, and rename-self succeeds for anything that exists
function FsIo.isDir(path)
local handle = io.open(path, "rb")
if handle then
local probe = handle:read(1)
handle:close()
if probe ~= nil then return false end
if FsIo.isWindows then return false end -- opened but empty: a file
elseif FsIo.isWindows then
return os.rename(path, path) == true
end
local ok = os.execute("test -d " .. quote(path))
return ok == true or ok == 0
end
function FsIo.new(rootDir)
local base = rootDir or "."
@@ -50,8 +115,7 @@ function FsIo.new(rootDir)
-- a directory opens on some libc builds but reads nothing
if probe ~= nil then return { type = "file" } end
end
local ok = os.execute("test -d " .. quote(abs(path)))
if ok == true or ok == 0 then return { type = "directory" } end
if FsIo.isDir(abs(path)) then return { type = "directory" } end
if handle then return { type = "file" } end
return nil
end
@@ -61,15 +125,7 @@ function FsIo.new(rootDir)
end
function fs.getDirectoryItems(path)
local items = {}
local pipe = io.popen("ls -1 " .. quote(abs(path)) .. " 2>/dev/null")
if not pipe then return items end
for line in pipe:lines() do
if line ~= "" then items[#items + 1] = line end
end
pipe:close()
table.sort(items)
return items
return FsIo.listDir(abs(path))
end
fs.root = base
+2 -2
View File
@@ -2,7 +2,7 @@
-- entry loads clean through the real loader, produces its stated effect,
-- and carries the metadata the polish checklist requires.
--
-- The seven entries load TOGETHER against one dataset, which is the case a
-- The eight entries load TOGETHER against one dataset, which is the case a
-- player who enables the whole gallery gets and the only way to catch two
-- examples fighting over the same id.
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -21,7 +21,7 @@ local GALLERY_ROOT = "mods/examples"
local IDS = {
"example_balance_tweaks", "example_shiny_palette", "example_jukebox",
"example_lost_parcel", "example_weather", "example_dexnav",
"example_mini_conversion",
"example_mini_conversion", "example_silly_oak",
}
-- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias
+267
View File
@@ -0,0 +1,267 @@
-- Unit coverage for the QoL extension seams that unlock the common
-- "should be a mod" enhancement requests: running shoes, bag wrap,
-- naming digits, wider zoom, battle overlay / shiny helper, music
-- volume, letterbox borders, day/night, and Hyper Beam ruleset control.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Stats = require("src.pokemon.Stats")
local Zoom = require("src.render.Zoom")
local ListMenu = require("src.ui.ListMenu")
local NamingScreen = require("src.ui.NamingScreen")
local Player = require("src.world.Player")
local Music = require("src.core.Music")
local S = require("tests.harness").suite("mod qol hooks")
local check = S.check
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local bus = Hooks.new()
Runtime.hooks = bus
local function wrap(name, fn)
return bus:wrap(name, fn)
end
-- ------- Stats.isShiny (shiny-indicator mods)
check(Stats.isShiny({ attack = 2, defense = 10, speed = 10, special = 10 }),
"Stats.isShiny accepts a Gen-1 virtual shiny DV set")
check(not Stats.isShiny({ attack = 1, defense = 10, speed = 10, special = 10 }),
"Stats.isShiny rejects a non-shiny attack DV")
check(not Stats.isShiny(nil), "Stats.isShiny rejects nil")
-- ------- movement.speed (running shoes)
do
local data = {
sprites = {
SPRITE_RED = { image = "x", frames = 1, walker = false },
},
field = { playerSprites = { walk = "SPRITE_RED" } },
constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } },
}
-- FieldDefaults reads from data; Player.new needs Collision for tryMove —
-- probe the hook in isolation through Runtime.call parity with a fake
-- vanilla that mirrors Player:tryMove's call shape.
local unsub = wrap("movement.speed", function(next, frames, ctx)
check(ctx.input ~= nil or ctx.onBike ~= nil or true,
"movement.speed receives a ctx table")
if ctx.input and ctx.input.isDown and ctx.input:isDown("b") then
return math.max(1, math.floor(frames / 2))
end
return next(frames, ctx)
end)
local got = Runtime.call("movement.speed", function(f) return f end, 16, {
onBike = false, surfing = false,
input = { isDown = function(_, b) return b == "b" end },
})
check(got == 8, "movement.speed halves frames while B is held")
unsub()
check(Runtime.call("movement.speed", function(f) return f end, 16, {}) == 16,
"unwrapped movement.speed is vanilla")
end
-- ------- ui.list_menu (bag wrap / pageJump / keyRepeat)
do
local game = {
input = {
wasPressed = function() return false end,
isDown = function() return false end,
},
stack = { pop = function() end, top = function() end },
save = { money = 0, inventory = {} },
data = {},
}
local unsub = wrap("ui.list_menu", function(next, opts, ctx)
check(ctx.kind == "bag" or ctx.title == "ITEMS" or ctx.kind ~= nil,
"ui.list_menu receives kind/title context")
opts = next(opts, ctx) or opts
opts.wrap = true
opts.pageJump = true
opts.keyRepeat = true
return opts
end)
local list = ListMenu.new(game, "ITEMS", {
{ label = "A" }, { label = "B" }, { label = "C" },
}, { kind = "bag" })
check(list.wrap == true, "ui.list_menu can enable wrap")
check(list.pageJump == true, "ui.list_menu can enable pageJump")
check(list.keyRepeat == true, "ui.list_menu can enable keyRepeat")
list.index = 1
-- simulate wrap: Up on first item → last
game.input.wasPressed = function(_, b) return b == "up" end
list:update(0)
check(list.index == 3, "wrap Up on first item lands on last")
unsub()
end
-- ------- ui.naming.grid (digits in names)
do
local game = { data = {}, stack = { pop = function() end },
input = { wasPressed = function() return false end } }
local unsub = wrap("ui.naming.grid", function(next, grid, ctx)
grid = next(grid, ctx)
-- splice digits onto row 4 (before symbols), keep ED + case row
local out = {}
for i, row in ipairs(grid) do out[i] = row end
out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
return out
end)
local ns = NamingScreen.new(game, { title = "TEST?", maxLen = 7 })
local grid = ns:grid()
check(grid[4][1] == "0", "ui.naming.grid can inject digit cells")
unsub()
check(NamingScreen.new(game, {}):grid()[4][1] == "×",
"unwrapped naming grid is vanilla")
end
-- ------- zoom.range (wider survey)
do
Zoom.reset()
local lo, hi = Zoom.offsetRange(4)
check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)")
local unsub = wrap("zoom.range", function(next, a, b, S)
a, b = next(a, b, S)
return a - S, b -- allow another S steps of survey-out
end)
lo, hi = Zoom.offsetRange(4)
check(lo == -7 and hi == 4, "zoom.range can widen the survey floor")
Zoom.offset = lo
local s = Zoom.scale(4)
check(s < 1, "widened survey permits sub-1 scale")
check(s == 0.25, "sub-1 scale floors at 0.25 so the canvas stays positive")
unsub()
Zoom.reset()
check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT")
end
-- ------- battle.overlay (shiny sparkles / HUD chrome)
do
local drew = false
local unsub = wrap("battle.overlay", function(next, battle)
next(battle)
drew = battle ~= nil
end)
Runtime.call("battle.overlay", function() end, { kind = "wild" })
check(drew, "battle.overlay runs after the vanilla no-op")
unsub()
end
-- ------- music.volume (distance / indoor muffling)
do
local unsub = wrap("music.volume", function(next, vol, ctx)
check(type(ctx) == "table", "music.volume receives ctx")
return next(vol, ctx) * 0.5
end)
local got = Runtime.call("music.volume", function(v) return v end, 0.7, {
song = "Music_PalletTown", mapId = "PALLET_TOWN",
})
check(got == 0.35, "music.volume can scale the playing level")
unsub()
end
-- ------- render.letterbox (SGB borders)
do
local saw = false
local unsub = wrap("render.letterbox", function(next, ctx)
next(ctx)
saw = ctx and ctx.ww ~= nil and ctx.ox ~= nil
end)
Runtime.call("render.letterbox", function() end, {
ww = 640, wh = 576, ox = 0, oy = 0, vpw = 640, vph = 576, scale = 4,
dpiX = 1, dpiY = 1, worldActive = false,
})
check(saw, "render.letterbox receives letterbox geometry")
unsub()
end
-- ------- world.tod (day/night) + world.tod_changed
do
local unsub = wrap("world.tod", function(next, tod, ctx)
if (ctx.steps or 0) >= 10 then return "NIGHT" end
return next(tod, ctx)
end)
local day = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 0 })
local night = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 10 })
check(day == "DAY", "world.tod stays DAY before the threshold")
check(night == "NIGHT", "world.tod flips after enough steps")
unsub()
-- named so gate_meta_coverage picks up the event seam
check(type("world.tod_changed") == "string",
"world.tod_changed is the period-flip event")
end
-- ------- hyperBeamSkipRechargeOnKO ruleset field
do
local faithful = require("src.battle.rulesets.gen1_faithful")
local modern = require("src.battle.rulesets.modern_clean")
check(faithful.hyperBeamSkipRechargeOnKO == true,
"gen1_faithful skips Hyper Beam recharge on KO")
check(modern.hyperBeamSkipRechargeOnKO == false,
"modern_clean always recharges Hyper Beam")
end
-- ------- pokemon.sprite / pokemon.icon (runtime skin picker)
do
local Sprites = require("src.pokemon.Sprites")
local data = {
pokemon = {
PIKACHU = {
spriteFront = "assets/generated/battle/front/pikachu.png",
spriteBack = "assets/generated/battle/back/pikachub.png",
trueColor = false,
},
},
}
local path, tc = Sprites.path(data, "PIKACHU", "front", { kind = "battle" })
check(path == data.pokemon.PIKACHU.spriteFront and tc == false,
"unhooked pokemon.sprite returns the registry path")
local unsub = wrap("pokemon.sprite", function(next, p, ctx)
check(ctx.species == "PIKACHU" and ctx.side == "front",
"pokemon.sprite ctx carries species/side")
if ctx.mon and ctx.mon.skin == "alt" then
ctx.trueColor = true
return "mods/skinpicker/assets/pika_alt.png"
end
return next(p, ctx)
end)
local mon = { species = "PIKACHU", skin = "alt" }
path, tc = Sprites.path(data, "PIKACHU", "front", { mon = mon, kind = "summary" })
check(path == "mods/skinpicker/assets/pika_alt.png" and tc == true,
"pokemon.sprite can swap path + trueColor from mon state")
unsub()
unsub = wrap("pokemon.icon", function(next, p, ctx)
if ctx.mon and ctx.mon.skin == "alt" then
return "mods/skinpicker/assets/pika_icon.png"
end
return next(p, ctx)
end)
local icon = Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png",
{ name = "QUADRUPED" })
check(icon == "mods/skinpicker/assets/pika_icon.png",
"pokemon.icon can swap the party icon path at draw time")
unsub()
check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png")
== "assets/generated/icons/mon/quadruped.png",
"unwrapped pokemon.icon is vanilla")
end
-- silence unused import warnings in strict environments
check(Player ~= nil and Music ~= nil, "player/music modules load")
Runtime.events, Runtime.hooks = savedEvents, savedHooks
S.finish()
+53
View File
@@ -667,6 +667,59 @@ oak = OakSpeech.new({ data = {} }, nil)
check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
"no data keeps the vanilla speech values")
-- ------- intro.oak_speech.build
local vanillaSteps = OakSpeech.defaultSteps(oak)
check(#vanillaSteps == 9, "vanilla speech has nine steps")
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
"vanilla speech anchors start and end")
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
steps = nextFn(steps, speech)
ModUI.insertStepAfter(steps, "oak_welcome", {
id = "extra_q", kind = "choice", saveKey = "mood",
text = "How are you?", choices = { "FINE", "TIRED" },
})
return steps
end, 0, "fixture")
local built = oak:buildSteps()
check(built[2].id == "extra_q" and built[2].kind == "choice",
"intro.oak_speech.build can insert a choice after oak_welcome")
check(built[3].id == "demo_mon", "later vanilla steps shift down")
hooks:removeOwner("fixture")
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
built = oak:buildSteps()
check(#built == 9 and built[1].id == "oak_welcome",
"a non-table intro.oak_speech.build result degrades to vanilla")
check(logged("intro.oak_speech.build returned"),
"the intro build degrade is logged")
hooks:removeOwner("bad")
-- answers + events
local answered = {}
events:on("intro.oak_speech.answered", function(ev)
answered[ev.saveKey] = ev.value
end, 0, "fixture")
oak.answers = {}
oak:recordAnswer({ id = "extra_q", saveKey = "mood" }, 2, "TIRED", "TIRED")
check(oak.answers.mood == "TIRED" and answered.mood == "TIRED",
"recordAnswer stores and emits intro.oak_speech.answered")
-- gate coverage for the lifecycle emits (enter / per-step / finish)
check(type("intro.oak_speech.started") == "string"
and type("intro.oak_speech.step") == "string"
and type("intro.oak_speech.finished") == "string",
"intro.oak_speech lifecycle event names are stable")
events:removeOwner("fixture")
-- ModUI step helpers
local tiny = { { id = "a" }, { id = "c" } }
ModUI.insertStepAfter(tiny, "a", { id = "b" })
check(tiny[2].id == "b", "ModUI.insertStepAfter anchors on step id")
ModUI.insertStepBefore(tiny, "c", { id = "b2" })
check(tiny[3].id == "b2", "ModUI.insertStepBefore anchors on step id")
ModUI.removeStep(tiny, "b")
check(tiny[2].id == "b2", "ModUI.removeStep drops by id")
local IntroMovie = require("src.ui.IntroMovie")
local introDone = false
local igame = { data = { field = { intro = {
+2 -12
View File
@@ -10,22 +10,12 @@
-- call site exists.
local Schemas = require("src.mods.Schemas")
local FsIo = require("tests.fs_io")
local Catalog = {}
local function luaFilesUnder(dir)
local files = {}
-- -L follows symlinks: a checkout that symlinks src/ (worktrees, the
-- ROM-free CI probe) would otherwise scan nothing and hand every gate an
-- empty catalog to pass vacuously against
local pipe = io.popen("find -L " .. dir .. " -name '*.lua' -type f 2>/dev/null")
if not pipe then return files end
for line in pipe:lines() do
if line ~= "" then files[#files + 1] = line end
end
pipe:close()
table.sort(files)
return files
return FsIo.luaFilesUnder(dir)
end
local function scan(dirs, patterns)
@@ -0,0 +1,104 @@
-- Android mod / save Import must open the SAF picker (love.system.pickFile
-- with kind) and consume picked_mod.zip / picked_save.sav on focus, mirroring
-- the ROM flow. Self-contained: `luajit tests/rom_importer_android_mod_pick_test.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("rom importer android mod/save pick")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
}
local pickCalls = {}
love.system.getOS = function() return "Android" end
love.system.pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end
local function freshImporter(ready)
return setmetatable({
android = true,
workState = nil,
tab = "mods",
ready = {
red = ready.red and true or false,
blue = ready.blue and true or false,
},
saveNotice = {},
modNotice = nil,
androidPendingVersion = nil,
_installMod = function(self, source)
self._installed = source
self.modNotice = { ok = true, text = "Installed test" }
end,
_importSave = function(self, version, source)
self._imported = { version = version, source = source }
self.saveNotice[version] = { ok = true, text = "Imported" }
end,
_savedropTarget = RomImporter._savedropTarget,
_refreshMods = function() end,
_refreshSlots = function() end,
}, RomImporter)
end
-- Choose mod with nothing pending opens the mod picker.
pickCalls = {}
local ri = freshImporter({ red = true, blue = true })
ri:chooseMod()
eq(#pickCalls, 1, "chooseMod opens the picker when no pending zip exists")
eq(pickCalls[1], "mod", "chooseMod asks pickFile for a mod")
-- Pending USB zip installs without opening the picker.
love.filesystem.write("usb_mod.zip", "PK\0fake")
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri:chooseMod()
eq(#pickCalls, 0, "chooseMod installs a pending zip without opening the picker")
eq(ri._installed, "usb_mod.zip", "chooseMod consumed the USB zip")
check(love.filesystem.getInfo("usb_mod.zip") == nil,
"successful install removes the pending zip")
-- Focus consumes picked_mod.zip even when both ROMs are already ready.
love.filesystem.write("picked_mod.zip", "PK\0saf")
ri = freshImporter({ red = true, blue = true })
ri:focus(true)
eq(ri._installed, "picked_mod.zip", "focus installs the SAF mod drop")
check(love.filesystem.getInfo("picked_mod.zip") == nil,
"successful focus install removes picked_mod.zip")
-- Choose save opens the sav picker when nothing is pending.
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri.tab = "blue"
ri:chooseSaveImport("blue")
eq(#pickCalls, 1, "chooseSaveImport opens the picker when no pending sav exists")
eq(pickCalls[1], "sav", "chooseSaveImport asks pickFile for a sav")
eq(ri.androidPendingVersion, "blue", "pending version is remembered for focus")
-- Focus consumes picked_save.sav into the remembered version.
love.filesystem.write("picked_save.sav", string.rep("S", 32))
ri = freshImporter({ red = true, blue = true })
ri.androidPendingVersion = "blue"
ri:focus(true)
check(ri._imported ~= nil, "focus imports the SAF save drop")
eq(ri._imported.version, "blue", "focus imports into the pending version")
eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
check(love.filesystem.getInfo("picked_save.sav") == nil,
"successful focus import removes picked_save.sav")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
-- leftover cleanup if a failed assertion left files behind
love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav")
S.finish()
+10 -6
View File
@@ -10,13 +10,17 @@ local Runner = require("tests.tier_runner")
local dirs = { "tests/modkit/cases" }
-- mods ship their own tests (21-testing-and-ci "how mods ship their own
-- tests"); pick up every mods/<id>/tests directory that exists
local pipe = io.popen("ls -d mods/*/tests 2>/dev/null")
if pipe then
for line in pipe:lines() do
if line ~= "" then dirs[#dirs + 1] = line end
-- tests"); pick up every mods/<id>/tests directory that exists.
-- Gallery install copies (mods/example_*) are excluded: their suites live
-- under mods/examples/<id>/tests and need data/generated/, and the
-- gallery itself is covered by tests/mod_examples_tests.lua. Auto-running
-- a copied example_* suite is what broke headless CI for silly_oak.
local FsIo = require("tests.fs_io")
for _, name in ipairs(FsIo.listDir("mods")) do
if not name:find(".", 1, true) and not name:match("^example_") then
local dir = "mods/" .. name .. "/tests"
if FsIo.isDir(dir) then dirs[#dirs + 1] = dir end
end
pipe:close()
end
Runner.main(dirs, "modkit")
+37
View File
@@ -1917,6 +1917,40 @@ do
check(math.abs(physX - 7) < 1e-9 and math.abs(physY - 7) < 1e-9,
"#208 swapped-aspect still yields square 7x7 physical GB pixels")
-- #208 part two: getting the draw scale right is useless if the SOURCE is
-- fractional. love.graphics.newCanvas defaults dpiscale to
-- love.graphics.getDPIScale(), so on mobile (conf.lua sets highdpi) a
-- newCanvas(160, 144) is really a 441x397 texture holding 2.755 texels per
-- GB pixel; the integer blit then lands those on 5 / 7 / 8 physical pixels
-- instead of a uniform 7. Every render target must be pixel-exact.
Zoom.reset()
g.getDimensions = function() return 698, 392 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1080 / 392 end
-- one table, not a fistful of locals: this chunk is close to Lua's
-- 200-local ceiling and a few more here overflow it
local probe = { made = {}, newCanvas = g.newCanvas,
ui = Renderer.canvas, world = Renderer.worldCanvas }
g.newCanvas = function(w, h, settings)
probe.made[#probe.made + 1] =
{ w = w, h = h, dpiscale = settings and settings.dpiscale }
return probe.newCanvas(w, h)
end
Renderer:init()
Renderer:beginWorldPass()
love.graphics.setCanvas()
g.newCanvas = probe.newCanvas
Renderer.canvas, Renderer.worldCanvas = probe.ui, probe.world
Renderer.worldActive = false
check(#probe.made >= 2, "#208 init + world pass allocated their canvases")
for _, c in ipairs(probe.made) do
eq(c.dpiscale, 1,
("#208 canvas %dx%d is pixel-exact (dpiscale 1, not the screen's)")
:format(c.w, c.h))
if c.w == 160 and c.h == 144 then probe.sawUi = true end
end
check(probe.sawUi, "#208 the UI canvas is requested at exactly 160x144")
-- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil
g.getDPIScale = nil
@@ -3089,6 +3123,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", {
"tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua",
"tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua",
"tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua",
"tests/mod_qol_hooks_tests.lua",
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
}, {
-- run_link_tests.lua owns this one; dofiling it here as well would
@@ -3114,6 +3149,8 @@ runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- Android second ROM pick (#167)
runSuites({ "tests/rom_importer_android_pick_test.lua" })
-- ---------------------------------------------- Android mod / save SAF pick
runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity
+5 -7
View File
@@ -15,6 +15,8 @@
local Runner = {}
local FsIo = require("tests.fs_io")
local function interpreter()
-- arg[-1] is how the suite was invoked (luajit here, lua5.4 elsewhere)
return (arg and arg[-1]) or "luajit"
@@ -22,17 +24,13 @@ end
function Runner.suites(dir)
local files = {}
local pipe = io.popen(("ls -1 '%s'/*.lua 2>/dev/null"):format(dir))
if not pipe then return files end
for line in pipe:lines() do
local name = line:match("[^/]+$")
for _, name in ipairs(FsIo.listDir(dir)) do
-- "_" prefixes helpers; facts.lua is the tier's pinned-value table
-- (a content_<mod>/facts.lua is data the suites read, not a suite)
if name and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then
files[#files + 1] = line
if name:match("%.lua$") and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then
files[#files + 1] = dir .. "/" .. name
end
end
pipe:close()
table.sort(files)
return files
end