mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cfd91a503 | |||
| c0ad6d0328 | |||
| 84de8c9cb1 |
@@ -79,9 +79,3 @@ mobile/ios/bundle_id.local
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
# User-owned ROMs imported for individual mods. Manifests declare the
|
||||
# destinations, but source checkouts and packaged mods never ship the files.
|
||||
/mods/*/baseroms/
|
||||
/imports/baseroms/
|
||||
/imports/baseroms-recovery/
|
||||
|
||||
+11
-35
@@ -246,37 +246,17 @@ real Gold boot.
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself.
|
||||
`options_schema`, and anything you `load()` yourself. What is absent:
|
||||
|
||||
The globals the sandbox took away are still *reachable*, as compat
|
||||
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
|
||||
underneath. A mod written before the sandbox keeps working; it logs one
|
||||
warning per call it should migrate, and the mod manager lists them. What
|
||||
each stand-in actually does:
|
||||
|
||||
| Pre-sandbox call | What it does now | Migrate to |
|
||||
| --- | --- | --- |
|
||||
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
|
||||
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
|
||||
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
|
||||
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
|
||||
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
|
||||
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
|
||||
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
|
||||
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
|
||||
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
|
||||
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
|
||||
|
||||
What has no stand-in, because there is nothing honest to reroute it to:
|
||||
|
||||
| Still refused | Why |
|
||||
| Absent | Use instead |
|
||||
| --- | --- |
|
||||
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach |
|
||||
| `require("ffi")` | arbitrary C |
|
||||
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
|
||||
| `io.popen`, `os.execute` | spawning a process |
|
||||
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
|
||||
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
|
||||
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
|
||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
@@ -303,12 +283,8 @@ permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the rerouted globals, the fix is almost always
|
||||
`mod.storage`. The overlay is a compatibility floor, not a second storage
|
||||
system: it is not scoped per playthrough, it does not migrate, and it is
|
||||
the first thing that will be dropped once the mods on the index have
|
||||
moved off it. Open an issue if you have a case `mod.storage` does not
|
||||
cover.
|
||||
If your mod used one of the absent globals, the fix is almost always
|
||||
`mod.storage`. Open an issue if you have a case it does not cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
|
||||
@@ -486,11 +486,9 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
|
||||
**by name, before the first row runs**, so a mod never gets a half-run queue.
|
||||
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
|
||||
byte for it) and returns `nil, reason` rather than approximating one.
|
||||
`availableFieldActions` and `useFieldAction` expose the same contextual field
|
||||
item and move records in both games. Gold extends the shared ids with its own
|
||||
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
|
||||
actions. Each engine keeps ownership of its inventory, badges, terrain,
|
||||
surfing, bike, fishing, and field-move rules.
|
||||
`availableFieldActions` and `useFieldAction` expose the same contextual
|
||||
bicycle and fishing records in both games. Each engine keeps ownership of its
|
||||
inventory, terrain, surfing, bike, and fishing rules.
|
||||
|
||||
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
|
||||
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||
|
||||
+7
-102
@@ -44,25 +44,6 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"required_imports": [
|
||||
{
|
||||
"id": "stadium2",
|
||||
"name": "Pokemon Stadium 2 ROM",
|
||||
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
|
||||
"file": "stadium2.z64",
|
||||
"format": "n64",
|
||||
"size": 67108864,
|
||||
"md5": ["00000000000000000000000000000000"]
|
||||
}
|
||||
],
|
||||
"optional_imports": [
|
||||
{
|
||||
"id": "bonus_source",
|
||||
"name": "Optional bonus source",
|
||||
"file": "bonus.bin",
|
||||
"md5": "00000000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
@@ -86,8 +67,6 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
@@ -112,42 +91,6 @@ Dependencies in `dependencies` and `optional_dependencies` can be declared in se
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
### Required user-supplied files
|
||||
|
||||
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
|
||||
out of mod archives while giving every platform the same installation flow.
|
||||
Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
(a filename, never a path), and one MD5 digest or an array of accepted MD5
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
must not include anything beneath `baseroms/`. The engine records a validation
|
||||
receipt keyed by file size and modification time so launcher refreshes and
|
||||
later boots do not repeatedly hash an unchanged imported ROM.
|
||||
|
||||
New mobile code should call `love.system.pickFile("required_import")`. The
|
||||
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -204,23 +147,19 @@ Companion UIs and alternate party screens can call
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Contextual field actions
|
||||
## Contextual field items
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
`mod.world:availableFieldActions()` returns the field items that can start at
|
||||
the player's current position. Red and Gold currently expose `bicycle` and
|
||||
`fish`; fishing rows include the owned rods that are valid choices. The list
|
||||
is empty while the world is busy, while riding states or terrain forbid an
|
||||
action, or when the required item is not owned.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
not need generation-specific bike, collision, or fishing logic.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
@@ -802,37 +741,3 @@ the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
## Pre-sandbox globals (compat)
|
||||
|
||||
A mod written before the sandbox landed does not have to be updated to
|
||||
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
|
||||
`love.system` and `love.event` are all present again as compat stand-ins
|
||||
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
|
||||
(`love.mousemoved = fn`) installs on the real table the way it always did.
|
||||
Every stand-in call logs one warning naming its replacement, and
|
||||
`loader:legacyReport(modId)` returns the same list with call counts, which
|
||||
is what a "needs updating" badge should read.
|
||||
|
||||
The stand-ins are not the old globals. Paths are classified rather than
|
||||
passed through:
|
||||
|
||||
- A path inside your own mod directory reads the file you shipped.
|
||||
- Anything else, including an absolute path, resolves into a private
|
||||
per-mod overlay at `mod_compat/<your id>/` under the save directory.
|
||||
Two mods naming the same path never see each other's bytes, and nothing
|
||||
is written outside the game tree.
|
||||
- A read misses through the overlay to your shipped file, then to
|
||||
`mod.storage`, so a half-migrated mod sees both.
|
||||
- A write over a path you shipped shadows it; the packaged file is never
|
||||
modified, and `mod:read` still returns the packaged bytes.
|
||||
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
|
||||
a virtual root, so a legacy mod that joins its own paths lands back in
|
||||
the same overlay.
|
||||
|
||||
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
|
||||
with the full standard library, which the sandbox in this state cannot
|
||||
reach, so a stand-in would be a hole rather than a reroute. The same goes
|
||||
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
|
||||
`love.errorhandler`. A mod that needs real background work needs an
|
||||
engine-owned facility, not a compat shim.
|
||||
|
||||
@@ -40,4 +40,3 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
|
||||
* **On-screen touch pad** and controller SELECT for registered items
|
||||
|
||||
|
||||
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
|
||||
|
||||
@@ -76,6 +76,15 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
|
||||
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
|
||||
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
|
||||
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
|
||||
-- then has to quarantine on the next boot. Refuse by name, the same way the
|
||||
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
|
||||
-- Red/Blue/Yellow slots are untouched.
|
||||
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -128,6 +137,11 @@ local function openEditor(version, slotId)
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] = { ok = false, text = text }
|
||||
end
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
return
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
@@ -306,6 +320,14 @@ function love.load(args)
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
-- No launcher behind this run to carry a notice, so say it and stop
|
||||
-- rather than open a Gen 2 slot on Gen 1 panels.
|
||||
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
|
||||
@@ -192,14 +192,8 @@ bool System::pickFile(const char *kind) const
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
@@ -208,15 +202,6 @@ bool System::pickFile(const char *kind) const
|
||||
#endif
|
||||
}
|
||||
|
||||
const char *System::pickFileKinds() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return "rom,mod,sav,required_import";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -112,12 +112,10 @@ public:
|
||||
* 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,
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
|
||||
@@ -102,12 +102,6 @@ int w_pickFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
luax_pushstring(L, instance()->pickFileKinds());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
@@ -228,7 +222,6 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.87",
|
||||
"date": "2026-08-14",
|
||||
"size": 11310910,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.1.86",
|
||||
"date": "2026-08-14",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
@@ -83,8 +83,6 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
@@ -137,7 +135,7 @@ public final class GRPickerBridge: NSObject {
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium,required_import" as NSString
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
|
||||
@@ -89,8 +89,7 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
// Swift bridge (including required_import), or nil off iOS.
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
|
||||
@@ -51,12 +51,6 @@ rm -f "$OUTPUT"
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||
fi
|
||||
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
|
||||
fi
|
||||
|
||||
if [ -n "$BUILD_INFO" ]; then
|
||||
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
|
||||
|
||||
@@ -135,7 +135,6 @@ if [ -f data/generated/maps.lua ]; then
|
||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
|
||||
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
||||
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
|
||||
@@ -98,69 +98,10 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
}
|
||||
end
|
||||
|
||||
-- The species string is the source of truth. `mon.name` is a copy of that
|
||||
-- species' display name (GetPokemonName), kept so menus can print without a
|
||||
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
|
||||
-- and prints this copy. Changing species without rewriting it leaves the
|
||||
-- previous species' name on the party list and the SUMMARY's top line.
|
||||
function Mon.syncIdentity(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return mon end
|
||||
mon.name = def.name or mon.species
|
||||
if def.types then mon.types = def.types end
|
||||
if mon.dvs then
|
||||
mon.gender = Mon.gender(def, mon.dvs,
|
||||
{ species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs,
|
||||
{ species = mon.species, def = def, level = mon.level })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
else
|
||||
mon.unownLetter = nil
|
||||
end
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Every screen that prints a mon without a Data lookup should go through here:
|
||||
-- nickname if the player set one, otherwise the species display copy `name`.
|
||||
-- Skipping `name` and jumping to `species` is how a swapped mon can still
|
||||
-- read as ABRA on one menu and RAYQUAZA on another.
|
||||
function Mon.displayName(mon)
|
||||
if type(mon) ~= "table" then return "?" end
|
||||
return mon.nickname or mon.name or mon.species or "?"
|
||||
end
|
||||
|
||||
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
|
||||
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
|
||||
-- `name` is the ABRA bug in a second closet.
|
||||
function Mon.eachSaveMon(save, fn)
|
||||
if type(save) ~= "table" or type(fn) ~= "function" then return end
|
||||
for _, mon in ipairs(save.party or {}) do fn(mon) end
|
||||
for _, box in pairs(save.boxes or {}) do
|
||||
if type(box) == "table" then
|
||||
for _, mon in ipairs(box) do fn(mon) end
|
||||
end
|
||||
end
|
||||
local dc = save.dayCare
|
||||
if type(dc) == "table" then
|
||||
if dc.man and dc.man.mon then fn(dc.man.mon) end
|
||||
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
|
||||
if dc.egg then fn(dc.egg) end
|
||||
end
|
||||
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
|
||||
end
|
||||
|
||||
function Mon.syncSaveIdentity(save, data)
|
||||
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
|
||||
end
|
||||
|
||||
function Mon.refreshStats(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not (def and def.baseStats) then return mon end
|
||||
Mon.syncIdentity(mon, data)
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||
mon.stats = stats
|
||||
|
||||
+2
-5
@@ -283,9 +283,6 @@ function Game2:continueGame(save)
|
||||
local modsDiff = SaveData.modsDiff(save, activeMods)
|
||||
self.save = save
|
||||
self:adoptSave(save)
|
||||
-- Editor species swaps used to leave mon.name on the previous species.
|
||||
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
|
||||
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
|
||||
-- options.lua wins over anything a save file carries: options are a display
|
||||
-- preference that survives New Game and is edited from the launcher, so a
|
||||
-- save written before they moved out must not drag old values back in.
|
||||
@@ -591,13 +588,13 @@ function Game2:useFieldItem(itemId)
|
||||
end
|
||||
if not allowed then
|
||||
self:say(("%s can't learn %s!"):format(
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
return
|
||||
end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then
|
||||
self:say(("%s already knows %s!"):format(
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
+2
-49
@@ -832,41 +832,9 @@ local function tryMigrateLegacy(version, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
|
||||
-- has no registered slots for this version (e.g. options.lua was reset or lost).
|
||||
local function scanDiskSlots(version, fs)
|
||||
if not fs then return nil end
|
||||
local dir = "saves/" .. version
|
||||
local slots = {}
|
||||
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
|
||||
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
|
||||
local numbers = {}
|
||||
for _, item in ipairs(items) do
|
||||
local slotId = item:match("^(slot%d+)%.lua$")
|
||||
if slotId then
|
||||
local n = tonumber(slotId:match("%d+"))
|
||||
table.insert(numbers, { id = slotId, num = n or 0 })
|
||||
end
|
||||
end
|
||||
table.sort(numbers, function(a, b) return a.num < b.num end)
|
||||
for _, item in ipairs(numbers) do
|
||||
table.insert(slots, item.id)
|
||||
end
|
||||
else
|
||||
for i = 1, 30 do
|
||||
local slotId = "slot" .. i
|
||||
local path = dir .. "/" .. slotId .. ".lua"
|
||||
if fs.getInfo and fs.getInfo(path) then
|
||||
table.insert(slots, slotId)
|
||||
end
|
||||
end
|
||||
end
|
||||
return #slots > 0 and slots or nil
|
||||
end
|
||||
|
||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
|
||||
-- slot1; otherwise false, meaning the flat legacy path.
|
||||
local function ensureVersionSlots(version, fs)
|
||||
if slotsChecked[version] then return end
|
||||
slotsChecked[version] = true
|
||||
@@ -880,22 +848,7 @@ local function ensureVersionSlots(version, fs)
|
||||
activeSlotCache[version] = reg.active or reg.list[1]
|
||||
return
|
||||
end
|
||||
local migrated = tryMigrateLegacy(version, fs)
|
||||
if migrated then
|
||||
activeSlotCache[version] = migrated
|
||||
return
|
||||
end
|
||||
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
|
||||
local recovered = scanDiskSlots(version, fs)
|
||||
if recovered and #recovered > 0 then
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
activeSlotCache[version] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = false
|
||||
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
|
||||
end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+68
-328
@@ -299,11 +299,6 @@ end
|
||||
|
||||
local CART_DRAG_SLOP = 8
|
||||
local TAU = math.pi * 2
|
||||
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
|
||||
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
|
||||
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
|
||||
local CART_MESH_PAD = 0.07
|
||||
local CART_MESH_PAD_MIN = 8
|
||||
|
||||
local function cartridgeState(imp, version)
|
||||
imp._cartridge = imp._cartridge or {}
|
||||
@@ -548,11 +543,8 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
Theme.A.focus, 2, Theme.cardRadius() + 2)
|
||||
end
|
||||
|
||||
local meshPad = math.max(CART_MESH_PAD_MIN,
|
||||
math.floor(math.min(w, h) * CART_MESH_PAD))
|
||||
local halfW = math.max(1, w / 2 - meshPad)
|
||||
local halfH = math.max(1, h / 2 - meshPad)
|
||||
local depth = math.max(8, (halfW * 2) * 0.14)
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
@@ -638,7 +630,6 @@ end
|
||||
|
||||
local function modStatusColor(status)
|
||||
if status == "ok" then return Strings("Ready"), PAL.green end
|
||||
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
|
||||
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
||||
-- not a fault: the mod is intact, this is simply not a game it is for
|
||||
-- (src/mods/ModTargets.lua)
|
||||
@@ -647,8 +638,14 @@ local function modStatusColor(status)
|
||||
end
|
||||
|
||||
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
|
||||
local function modScopeOptions(imp)
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = { { id = nil, label = Strings("All games") } }
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if imp.ready and imp.ready[version] then
|
||||
@@ -656,33 +653,6 @@ local function modScopeOptions(imp)
|
||||
{ id = version, label = GameVersion.info(version).label }
|
||||
end
|
||||
end
|
||||
return options
|
||||
end
|
||||
|
||||
local function modScopeCurrentLabel(imp, options)
|
||||
for _, opt in ipairs(options) do
|
||||
if imp.modScope == opt.id then return opt.label end
|
||||
end
|
||||
return options[1] and options[1].label or Strings("All games")
|
||||
end
|
||||
|
||||
local function modScopeChipsWidth(options, gap, m)
|
||||
local need = 0
|
||||
for i, opt in ipairs(options) do
|
||||
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if i < #options then need = need + gap end
|
||||
end
|
||||
return need
|
||||
end
|
||||
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = modScopeOptions(imp)
|
||||
|
||||
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
|
||||
local _, activeProf = LauncherMods.getProfiles()
|
||||
@@ -720,12 +690,9 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
})
|
||||
|
||||
if #options >= 2 then
|
||||
local avail = profX - gap - cx
|
||||
-- Chips stay when they all fit; otherwise they used to be skipped and
|
||||
-- vanish off the portrait edge. Collapse to one menu in that case only.
|
||||
if modScopeChipsWidth(options, gap, m) <= avail then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if cx + cw <= profX - gap then
|
||||
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
|
||||
"mod-scope-" .. tostring(opt.id or "all")) then
|
||||
local want = opt.id
|
||||
@@ -734,15 +701,6 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
elseif avail > 0 then
|
||||
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
|
||||
math.max(0, avail - math.floor(18 * m.s)))
|
||||
local cw = math.min(avail,
|
||||
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
|
||||
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
|
||||
queueAction(imp, "mod-scope-menu",
|
||||
function() imp._modScopePopup = true end)
|
||||
end
|
||||
end
|
||||
end
|
||||
return h + math.floor(8 * m.s)
|
||||
@@ -782,8 +740,8 @@ local function setPage(imp, key, v)
|
||||
imp._pages[key] = v
|
||||
end
|
||||
|
||||
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
||||
-- and the launcher ships no icon asset for them.
|
||||
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
||||
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
@@ -794,18 +752,6 @@ local function drawCross(x, y, size, color)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- header
|
||||
-- Rail, logo row (settings and quit on the right), tab bar.
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
@@ -1370,32 +1316,20 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
or tostring(version)
|
||||
local ready = (not locked) and imp.ready[version] or false
|
||||
|
||||
-- title + status tag. Ready is a check chip (the font has no tick glyph);
|
||||
-- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action.
|
||||
-- title + status tag
|
||||
local titleH = Kit.textHeight("title")
|
||||
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
|
||||
local tagText, tagCol
|
||||
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
|
||||
elseif imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
||||
+ math.floor(12 * m.s)
|
||||
local tagY = y + (titleH - tagH) / 2
|
||||
local tagW, tagCol
|
||||
if ready then
|
||||
tagCol = PAL.green
|
||||
tagW = tagH
|
||||
if love.graphics then
|
||||
Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1)
|
||||
local ck = math.floor(tagH * 0.55)
|
||||
drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol)
|
||||
end
|
||||
else
|
||||
local tagText
|
||||
if imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol)
|
||||
end
|
||||
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
||||
if ready then
|
||||
local hint = Strings("(PRESS THE CART TO PLAY)")
|
||||
local hintX = tagX + tagW + math.floor(10 * m.s)
|
||||
@@ -1403,11 +1337,8 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
|
||||
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
|
||||
end
|
||||
-- Extra gap under the title when the cart is showing: 12px left the 3D
|
||||
-- shell sitting on the hairline. Scaled, and still small on a phone.
|
||||
local afterTitle = math.floor((ready and 22 or 12) * m.s)
|
||||
local cy = y + titleH + afterTitle
|
||||
local remaining = availH - (titleH + afterTitle)
|
||||
local cy = y + titleH + math.floor(12 * m.s)
|
||||
local remaining = availH - (titleH + math.floor(12 * m.s))
|
||||
|
||||
local gap = m.gap
|
||||
local lx, lw, rx2, rw
|
||||
@@ -1515,6 +1446,20 @@ local function currentSort(imp)
|
||||
return sortKey
|
||||
end
|
||||
|
||||
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
|
||||
-- a tofu box on the "you already have this" signal would be worse than none.
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
@@ -2033,47 +1978,21 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
|
||||
.. "it might have been tampered with. go to the discord to verify "
|
||||
.. COMMUNITY_URL .. " (or click the logo above)"
|
||||
|
||||
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
|
||||
-- height so they do not read as bigger than the logo; the row can still
|
||||
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
|
||||
-- second row rather than overflowing the mark.
|
||||
local function footerLayout(imp, m, markW)
|
||||
local markH = math.floor(22 * m.s)
|
||||
local rowH = math.max(markH, Kit.tapMin())
|
||||
local notesLabel = Strings("Patch notes")
|
||||
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
|
||||
local chipPad = math.floor(24 * m.s)
|
||||
local nw = Kit.textWidth("micro", notesLabel) + chipPad
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
|
||||
local gap = math.floor(10 * m.s)
|
||||
local inner = m.w - 2 * m.pad
|
||||
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
|
||||
return {
|
||||
rowH = rowH, chipH = markH, gap = gap,
|
||||
notesLabel = notesLabel, nw = nw,
|
||||
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
|
||||
uw = uw, wrap = topW > inner,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pinned to the bottom of the window; returns the y it starts at, so the
|
||||
-- panels above know how much room they have.
|
||||
-- Deliberately compact: at a large UI scale the footer is pure overhead
|
||||
-- competing with the panel for a short window's height, so the mark and the
|
||||
-- link share one line and the trust warning is capped at a single line.
|
||||
local function footerHeight(imp, m)
|
||||
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
|
||||
-- wrapped trust message + bottom pad. The message wraps to as many lines
|
||||
-- as it needs: truncating a trust warning defeats its purpose, and the
|
||||
-- bottom pad is not optional either (without it the last line sits flush
|
||||
-- on the window edge and its lower half clips off). The row is tapMin
|
||||
-- tall because the small update button rides beside the mark.
|
||||
local f = footerLayout(imp, m, math.floor(130 * m.s))
|
||||
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
|
||||
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
|
||||
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
-- Top pad + mark/update row + gap + the FULL wrapped trust message +
|
||||
-- bottom pad. The message wraps to as many lines as it needs: truncating
|
||||
-- a trust warning defeats its purpose, and the bottom pad is not optional
|
||||
-- either (without it the last line sits flush on the window edge and its
|
||||
-- lower half clips off). The row is tapMin tall because the small update
|
||||
-- button rides beside the mark.
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
|
||||
+ Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
+ math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
@@ -2090,17 +2009,19 @@ local function buildFooter(imp, m, y)
|
||||
local bw, bh = imp.bcg:getDimensions()
|
||||
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
|
||||
local dw, dh = bw * scale, bh * scale
|
||||
local f = footerLayout(imp, m, dw)
|
||||
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
|
||||
-- The mark, the small self-update control, and Patch notes share the row,
|
||||
-- centred as a group. The updater moved down here from the header, where
|
||||
-- it overlapped the wordmark on a phone; small on purpose, its glow still
|
||||
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
|
||||
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
|
||||
if not f.wrap then topW = topW + gap + f.nw end
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
-- The mark and the small self-update control share the row, centred as a
|
||||
-- group. The updater moved down here from the header, where it overlapped
|
||||
-- the wordmark on a phone; small on purpose, its glow still carries the
|
||||
-- "act on me" signal.
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
-- Kit.button insets its label 16*scale per side, so the width must budget
|
||||
-- more than that or the label ellipsizes ("Check for updat...").
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
|
||||
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
|
||||
local bx = m.x + math.floor((m.w - groupW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 2)
|
||||
local chipY = cy + math.floor((rowH - chipH) / 2)
|
||||
local hot = Kit.hover(bx, my, dw, dh)
|
||||
love.graphics.setShader(imp.invertShader)
|
||||
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
|
||||
@@ -2110,30 +2031,14 @@ local function buildFooter(imp, m, y)
|
||||
if Kit.press(bx, my, dw, dh) then
|
||||
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
||||
end
|
||||
local cx = bx + dw
|
||||
if f.upStatus then
|
||||
cx = cx + gap
|
||||
btn(imp, cx, chipY, f.uw, chipH, "updater",
|
||||
f.upLabel, {
|
||||
kind = f.upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = f.upGlow, action = f.upAction,
|
||||
if upStatus then
|
||||
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
|
||||
upLabel, {
|
||||
kind = upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = upGlow, action = upAction,
|
||||
})
|
||||
cx = cx + f.uw
|
||||
end
|
||||
local function notesBtn(x, y)
|
||||
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
|
||||
kind = "ghost", font = "micro",
|
||||
action = function() imp._appPatchNotes = true end,
|
||||
})
|
||||
end
|
||||
if f.wrap then
|
||||
cy = cy + rowH + gap
|
||||
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
|
||||
cy = cy + chipH + math.floor(6 * m.s)
|
||||
else
|
||||
notesBtn(cx + gap, chipY)
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
end
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
-- The trust message wraps in full, each line centred under the mark, and
|
||||
-- the URL inside it IS the link -- no separate link floating elsewhere.
|
||||
-- font:getWrap never splits an unspaced word, so the URL stays whole on
|
||||
@@ -2638,34 +2543,6 @@ local function buildSortModal(imp, m)
|
||||
action = function() imp._sortPopup = nil end })
|
||||
end
|
||||
|
||||
-- Game-scope chooser used when the Show-for chips cannot all fit on the
|
||||
-- mods toolbar (portrait phones). Same options as the chip row.
|
||||
local function buildModScopeModal(imp, m)
|
||||
local options = modScopeOptions(imp)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #options * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
for _, opt in ipairs(options) do
|
||||
local key = tostring(opt.id or "all")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, {
|
||||
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
|
||||
action = function()
|
||||
imp:_setModScope(opt.id)
|
||||
imp._modScopePopup = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._modScopePopup = nil end })
|
||||
end
|
||||
|
||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||
-- enough categories to overflow a single stacked column on a short window.
|
||||
local function buildFilterModal(imp, m)
|
||||
@@ -2768,14 +2645,11 @@ local function buildModActionsModal(imp, m)
|
||||
local hasGit = mod.github and mod.github ~= ""
|
||||
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
|
||||
local hasDeps = depSpecs and #depSpecs > 0
|
||||
local imports = mod.imports or mod.requiredImports
|
||||
local hasImports = imports and #imports > 0
|
||||
local info = hasGit and imp:_modUpdateInfo(mod.id)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(440 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
|
||||
+ (hasImports and 1 or 0) + 2
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ nBtns * (m.btnH + gap) - gap + pad
|
||||
@@ -2825,19 +2699,6 @@ local function buildModActionsModal(imp, m)
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
if hasImports then
|
||||
local missing = tonumber(mod.missingRequiredImports) or 0
|
||||
local label = missing > 0
|
||||
and Strings("Imported files (%d required)", missing)
|
||||
or Strings("Imported files")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-imports",
|
||||
label, { kind = missing > 0 and "warn" or "accent", font = "small",
|
||||
action = function()
|
||||
imp._modImports = id
|
||||
imp._modActions = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
local armed = deleteArmed(imp, "mod", id, nil)
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
|
||||
DELETE_LABEL(armed), {
|
||||
@@ -2854,109 +2715,6 @@ local function buildModActionsModal(imp, m)
|
||||
action = function() imp._modActions = nil end })
|
||||
end
|
||||
|
||||
-- Imported files declared by one installed mod. The engine picks, validates,
|
||||
-- canonicalizes and copies; this surface never exposes a host path to mod code.
|
||||
local function buildRequiredImportsModal(imp, m)
|
||||
local mod
|
||||
for _, candidate in ipairs(imp.mods or {}) do
|
||||
if candidate.id == imp._modImports then mod = candidate break end
|
||||
end
|
||||
if not mod then imp._modImports = nil return end
|
||||
local imports = mod.imports or mod.requiredImports or {}
|
||||
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
|
||||
local w = math.floor(540 * m.s)
|
||||
local notice = imp.requiredImportNotice
|
||||
if not notice or notice.modId ~= mod.id then notice = nil end
|
||||
local noticeText
|
||||
if notice then
|
||||
local importName = notice.importId
|
||||
for _, row in ipairs(imports) do
|
||||
if row.id == notice.importId then importName = row.name break end
|
||||
end
|
||||
noticeText = Strings("%s rejected: %s", importName, notice.text)
|
||||
end
|
||||
local noticeW = w - 2 * pad
|
||||
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
|
||||
local rowH = math.max(math.floor(70 * m.s), m.btnH)
|
||||
local perPage = math.min(4, math.max(1, #imports))
|
||||
local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ noticeH + (noticeH > 0 and gap or 0)
|
||||
+ perPage * rowH + math.max(0, perPage - 1) * gap
|
||||
+ (pagerH > 0 and (gap + pagerH) or 0) + gap + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
|
||||
px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."),
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
if noticeText then
|
||||
cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy,
|
||||
pw - 2 * pad, PAL.red, 2) + gap
|
||||
end
|
||||
|
||||
local pageKey = "required-imports-" .. mod.id
|
||||
local cur = page(imp, pageKey)
|
||||
local first, last, bounded = Kit.pageBounds(cur, #imports, perPage)
|
||||
setPage(imp, pageKey, bounded)
|
||||
for i = first, last do
|
||||
local row = imports[i]
|
||||
local importId = row.id
|
||||
Kit.card(px + pad, cy, pw - 2 * pad, rowH, row.present and "muted" or false)
|
||||
local innerX = px + pad + math.floor(12 * m.s)
|
||||
local actionW = math.floor(108 * m.s)
|
||||
local removeW = row.present and math.floor(86 * m.s) or 0
|
||||
local actionX = px + pw - pad - math.floor(10 * m.s) - actionW
|
||||
if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end
|
||||
local textW = actionX - innerX - math.floor(8 * m.s)
|
||||
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
|
||||
cy + math.floor(8 * m.s), PAL.heading)
|
||||
local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small")
|
||||
+ math.floor(3 * m.s)
|
||||
if row.description and row.description ~= "" then
|
||||
Kit.text("micro", Kit.ellipsize("micro", row.description, textW),
|
||||
innerX, stateY, PAL.muted)
|
||||
stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s)
|
||||
end
|
||||
local state = row.present and Strings("Ready - %s", row.file)
|
||||
or (row.error and Strings("Invalid file - choose again")
|
||||
or (row.required and Strings("Required - %s", row.file)
|
||||
or Strings("Optional - %s", row.file)))
|
||||
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
|
||||
row.present and PAL.green or (row.required and PAL.yellow or PAL.muted))
|
||||
btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH,
|
||||
"req-pick-" .. mod.id .. "-" .. importId,
|
||||
row.present and Strings("Replace") or Strings("Choose file"), {
|
||||
kind = row.present and "ghost" or "accent", font = "small",
|
||||
action = function() imp:chooseRequiredImport(mod.id, importId) end })
|
||||
if row.present then
|
||||
local deleteId = mod.id .. ":" .. importId
|
||||
local armed = deleteArmed(imp, "required-import", deleteId, nil)
|
||||
btn(imp, actionX + actionW + math.floor(6 * m.s),
|
||||
cy + (rowH - m.btnH) / 2, removeW, m.btnH,
|
||||
"req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), {
|
||||
kind = "danger", font = "small", keepArm = true,
|
||||
action = function()
|
||||
imp:pressDelete("required-import", deleteId, nil, function()
|
||||
imp:_removeRequiredImport(mod.id, importId)
|
||||
end)
|
||||
end })
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
end
|
||||
if pagerH > 0 then
|
||||
local newPage = Kit.pager(px + pad, cy, pw - 2 * pad, bounded,
|
||||
#imports, perPage, pageKey)
|
||||
setPage(imp, pageKey, newPage)
|
||||
cy = cy + pagerH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), {
|
||||
font = "small", action = function() imp._modImports = nil end })
|
||||
end
|
||||
|
||||
-- Per-mod popup for FIND MODS: the row is a plain click, and Install /
|
||||
-- Details / Source live here instead of crowding every row.
|
||||
local function buildFindEntryModal(imp, m)
|
||||
@@ -3495,10 +3253,8 @@ end
|
||||
local function modalUp(imp)
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||
or imp._filterPopup or imp._modScopePopup or imp._indexManage
|
||||
or imp._modActions or imp._modImports
|
||||
or imp._filterPopup or imp._indexManage or imp._modActions
|
||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||
end
|
||||
@@ -3595,20 +3351,6 @@ local function buildModals(imp, m)
|
||||
return true
|
||||
end
|
||||
if imp._modConfirm then buildConfirmModal(imp, m) return true end
|
||||
if imp._appPatchNotes then
|
||||
local PatchNotes = require("src.update.PatchNotes")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local raw, ver = PatchNotes.body(imp.Check)
|
||||
local body = ModUpdate.cleanBody(raw or "", 0)
|
||||
if body == "" then body = Strings("(No patch notes.)") end
|
||||
local title = Strings("Patch notes")
|
||||
if ver and ver ~= "" then
|
||||
title = title .. " v" .. tostring(ver)
|
||||
end
|
||||
buildTextModal(imp, m, "patch-notes-modal", title, body,
|
||||
function() imp._appPatchNotes = nil end)
|
||||
return true
|
||||
end
|
||||
if imp._modReleaseNotes then
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local n = imp._modReleaseNotes
|
||||
@@ -3630,7 +3372,6 @@ local function buildModals(imp, m)
|
||||
end
|
||||
if imp._modVersions then buildVersionsModal(imp, m) return true end
|
||||
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
|
||||
if imp._modImports then buildRequiredImportsModal(imp, m) return true end
|
||||
-- The lighter popups come after the deep ones on purpose: opening
|
||||
-- Versions or Details from inside an actions popup draws the deeper modal
|
||||
-- while the popup's own state stays set, so closing the deep one drops
|
||||
@@ -3639,7 +3380,6 @@ local function buildModals(imp, m)
|
||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||
if imp._modActions then buildModActionsModal(imp, m) return true end
|
||||
|
||||
+6
-245
@@ -344,14 +344,6 @@ local function readExternalPath(path)
|
||||
return data
|
||||
end
|
||||
|
||||
local function externalFileSize(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local size = file:seek("end")
|
||||
file:close()
|
||||
return size
|
||||
end
|
||||
|
||||
local function readDroppedFile(file)
|
||||
local ok, openError = file:open("r")
|
||||
if not ok then return nil, openError end
|
||||
@@ -989,25 +981,6 @@ local function findPendingSav(preferAny, skip)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pickerHasKind(kind)
|
||||
local fn = love.system.pickFileKinds
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, kinds = pcall(fn)
|
||||
if not ok or type(kinds) ~= "string" then return false end
|
||||
for token in kinds:gmatch("[^,%s]+") do
|
||||
if token == kind then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function findPendingRequiredImport()
|
||||
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
||||
for _, name in ipairs(names) do
|
||||
if love.filesystem.getInfo(name, "file") then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
@@ -1139,39 +1112,6 @@ local function chooseSav()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Generic user-supplied dependency picker. Keep the native prompt entirely
|
||||
-- engine-owned: manifest labels are untrusted and must never enter shell
|
||||
-- command templates. The LÖVE modal already shows the specific import name.
|
||||
local function chooseRequiredFile()
|
||||
local prompt = shellSafe(Strings("Choose required mod file"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The self-updater only surfaces on the real distributed build: a fused,
|
||||
-- interactive launcher with no scripted-run override. A dev / source checkout
|
||||
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
|
||||
@@ -1290,9 +1230,7 @@ function RomImporter.new(onComplete, opts)
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -1482,13 +1420,7 @@ function RomImporter:focus(f)
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
elseif pickError:find("picked_mod", 1, true) then
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
@@ -1499,20 +1431,6 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
local requiredName = findPendingRequiredImport()
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
local imported = modId and importId
|
||||
and self:_importRequiredSource(modId, importId, requiredName)
|
||||
consumePick(self, requiredName, requiredName, imported)
|
||||
if not modId or not importId then
|
||||
self.modNotice = { ok = false,
|
||||
text = "A picked dependency file had no pending mod request and was discarded." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
@@ -1820,152 +1738,6 @@ function RomImporter:chooseMod()
|
||||
if path then self:_installMod(path) end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == modId then return row.manifest, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredSpec(manifest, importId)
|
||||
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
|
||||
if candidate.id == importId then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredImportNotice(self, modId, importId, text)
|
||||
self.requiredImportNotice = {
|
||||
modId = modId,
|
||||
importId = importId,
|
||||
text = tostring(text),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
|
||||
return nil
|
||||
end
|
||||
local ok, result = require("src.mods.RequiredImports")
|
||||
.importData(manifest, importId, data)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
-- Keep validation feedback on the imported-files page. A general Mods-page
|
||||
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
|
||||
requiredImportNotice(self, modId, importId, result)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
local spec = manifest and requiredSpec(manifest, importId)
|
||||
if not spec then
|
||||
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
return self:_importRequiredData(modId, importId, data)
|
||||
end
|
||||
|
||||
function RomImporter:_removeRequiredImport(modId, importId)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
|
||||
self:_refreshMods()
|
||||
else
|
||||
requiredImportNotice(self, modId, importId, err)
|
||||
self.modNotice = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Select and validate one manifest-declared file. NX has no host picker, so
|
||||
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
|
||||
-- over MTP; every other native/mobile picker lands on the same validation path.
|
||||
function RomImporter:chooseRequiredImport(modId, importId)
|
||||
if self.workState == "working" then return end
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local spec = requiredSpec(manifest, importId)
|
||||
if not spec then return end
|
||||
|
||||
if self.isNX then
|
||||
local inbox = "imports/baseroms"
|
||||
love.filesystem.createDirectory(inbox)
|
||||
local lastError
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = inbox .. "/" .. name
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
local sizeErr = info and require("src.mods.RequiredImports")
|
||||
.sizeError(spec, info.size, false)
|
||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||
if sizeErr then lastError = sizeErr
|
||||
elseif self.requiredImportNotice
|
||||
and self.requiredImportNotice.modId == modId
|
||||
and self.requiredImportNotice.importId == importId then
|
||||
lastError = self.requiredImportNotice.text
|
||||
end
|
||||
end
|
||||
end
|
||||
requiredImportNotice(self, modId, importId, lastError
|
||||
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
if self.nativePicker then
|
||||
if self.mobileFileBridge and not pickerHasKind("required_import") then
|
||||
requiredImportNotice(self, modId, importId,
|
||||
"This app build cannot pick required mod files yet. Update the app and try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
self.pickerPendingKind = "required_import"
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
if not pickFile("required_import") then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
||||
self.modNotice = nil
|
||||
elseif self.android then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local path = chooseRequiredFile()
|
||||
if path then self:_importRequiredSource(modId, importId, path) end
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
@@ -2273,8 +2045,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
|
||||
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
@@ -2403,12 +2174,7 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "required_import" then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
if modId and importId then self:_importRequiredSource(modId, importId, path) end
|
||||
if Platform.isUWP() then os.remove(path) end
|
||||
elseif kind == "mod" then
|
||||
if kind == "mod" then
|
||||
self:_installMod(path)
|
||||
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
||||
os.remove(path)
|
||||
@@ -2430,10 +2196,7 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion or self:_savedropTarget()
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "required_import" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
elseif kind == "mod" then
|
||||
if kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
@@ -3057,7 +2820,7 @@ function RomImporter:keypressed(key)
|
||||
return
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails or self._appPatchNotes then
|
||||
or self._findDetails then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
@@ -3070,8 +2833,6 @@ function RomImporter:keypressed(key)
|
||||
self._findDetails = nil
|
||||
elseif self._modReleaseNotes then
|
||||
self._modReleaseNotes = nil
|
||||
elseif self._appPatchNotes then
|
||||
self._appPatchNotes = nil
|
||||
else
|
||||
self._modConfirm = nil
|
||||
self._modVersions = nil
|
||||
|
||||
@@ -38,7 +38,6 @@ local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
@@ -460,38 +459,13 @@ function LauncherMods.list(version)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
-- Imports are private player grants. Never scan or copy another mod's
|
||||
-- baseroms: a matching public digest is not permission to share the file.
|
||||
local importState = {}
|
||||
for _, manifest in ipairs(manifests) do
|
||||
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
|
||||
importState[manifest.id] = { rows = rows, missing = missing,
|
||||
missingOptional = missingOptional }
|
||||
end
|
||||
-- The first build containing game-specific switches turns the old shared
|
||||
-- state into one explicit answer per installed mod and game. Saving here
|
||||
-- means users who only visit the launcher still receive the migration.
|
||||
if SaveData.migrateModEnablement(options, manifests) then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
local rows = LauncherMods.deriveList(manifests, options, version)
|
||||
for _, row in ipairs(rows) do
|
||||
local state = importState[row.id]
|
||||
or { rows = {}, missing = 0, missingOptional = 0 }
|
||||
local imports, missing = state.rows, state.missing
|
||||
row.requiredImports, row.missingRequiredImports = imports, missing
|
||||
row.imports = imports
|
||||
row.missingOptionalImports = state.missingOptional or 0
|
||||
if missing > 0 and row.status == "ok" then
|
||||
row.status = "needs_import"
|
||||
local first
|
||||
for _, import in ipairs(imports) do
|
||||
if not import.present then first = import break end
|
||||
end
|
||||
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
|
||||
end
|
||||
end
|
||||
return rows
|
||||
return LauncherMods.deriveList(manifests, options, version)
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
@@ -703,26 +677,6 @@ local function copyTree(src, dst)
|
||||
return true
|
||||
end
|
||||
|
||||
-- User-supplied baseroms are install state, not package content. Snapshot
|
||||
-- them before replacing a mod tree so an update cannot make the player select
|
||||
-- the same cartridge again (or destroy their only reusable copy if the new
|
||||
-- archive later fails to copy).
|
||||
local function snapshotTree(path, into, relative)
|
||||
local fs = love.filesystem
|
||||
into, relative = into or {}, relative or ""
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return into end
|
||||
if info.type == "directory" then
|
||||
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local rel = relative == "" and name or (relative .. "/" .. name)
|
||||
snapshotTree(path .. "/" .. name, into, rel)
|
||||
end
|
||||
elseif relative ~= "" and into[relative] == nil then
|
||||
into[relative] = fs.read(path)
|
||||
end
|
||||
return into
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
-- portable game folder is on its read path), but the deletes go through
|
||||
-- CacheFs so a portable install's real files actually go away instead of
|
||||
@@ -965,28 +919,14 @@ function LauncherMods._installZipInner(source, opts)
|
||||
return nil, ("zip is for '%s', expected '%s'")
|
||||
:format(manifest.id, opts.expectId)
|
||||
end
|
||||
local packagedBaseroms = root .. "/baseroms"
|
||||
if fs.getInfo(packagedBaseroms, "directory")
|
||||
and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then
|
||||
cleanup()
|
||||
return nil, "mod archives must not include user-supplied baseroms/ files"
|
||||
end
|
||||
|
||||
local dest = "mods/" .. manifest.id
|
||||
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
|
||||
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
|
||||
if installedSomewhere and not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
local preservedBaseroms = {}
|
||||
-- A previous failed update may have staged the user's files outside mods/ so
|
||||
-- discovery cannot mistake recovery debris for an installed mod.
|
||||
snapshotTree(baseromRecovery, preservedBaseroms)
|
||||
if #existing > 0 then
|
||||
for _, path in ipairs(existing) do
|
||||
snapshotTree(path .. "/baseroms", preservedBaseroms)
|
||||
end
|
||||
-- drop every old tree before copy -- mods/<id> and any same-id folder
|
||||
-- under another name, or the survivor keeps winning discover()'s
|
||||
-- first-id-wins race after the "successful" update (#801). A tree with
|
||||
@@ -1010,28 +950,6 @@ function LauncherMods._installZipInner(source, opts)
|
||||
CacheFs.prefix = ""
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
local preserveErr
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then
|
||||
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
|
||||
if not restored and not preserveErr then
|
||||
preserveErr = "could not preserve baseroms/" .. rel .. ": "
|
||||
.. tostring(restoreErr)
|
||||
end
|
||||
end
|
||||
end
|
||||
if preserveErr then
|
||||
-- Do not report a successful update that discarded user-owned input, and
|
||||
-- do not leave a manifest-less baseroms tree that resembles an install.
|
||||
removeTree(dest)
|
||||
removeTree(baseromRecovery)
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
|
||||
end
|
||||
copied, copyErr = nil, preserveErr
|
||||
elseif copied then
|
||||
removeTree(baseromRecovery)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
cleanup()
|
||||
|
||||
@@ -1,943 +0,0 @@
|
||||
local Logger = require("src.core.Logger")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local okSave, SaveData = pcall(require, "src.core.SaveData")
|
||||
if not okSave then SaveData = nil end
|
||||
local okStorage, Storage = pcall(require, "src.mods.Storage")
|
||||
if not okStorage then Storage = nil end
|
||||
|
||||
local LegacyCompat = {}
|
||||
|
||||
local ROOT = "mod_compat"
|
||||
local OWN = "_own"
|
||||
local MAX_SEGMENTS = 24
|
||||
|
||||
LegacyCompat.reports = {}
|
||||
|
||||
function LegacyCompat.reset()
|
||||
LegacyCompat.reports = {}
|
||||
end
|
||||
|
||||
function LegacyCompat.report(modId)
|
||||
if modId then
|
||||
local entry = LegacyCompat.reports[modId]
|
||||
return entry and entry.order or {}
|
||||
end
|
||||
local out = {}
|
||||
for id, entry in pairs(LegacyCompat.reports) do
|
||||
out[id] = entry.order
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function note(ctx, call, advice)
|
||||
local entry = LegacyCompat.reports[ctx.modId]
|
||||
if not entry then
|
||||
entry = { calls = {}, order = {} }
|
||||
LegacyCompat.reports[ctx.modId] = entry
|
||||
end
|
||||
local row = entry.calls[call]
|
||||
if row then
|
||||
row.count = row.count + 1
|
||||
return
|
||||
end
|
||||
row = { call = call, advice = advice, count = 1 }
|
||||
entry.calls[call] = row
|
||||
entry.order[#entry.order + 1] = row
|
||||
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
|
||||
advice)
|
||||
end
|
||||
|
||||
local function refuse(ctx, call, advice)
|
||||
note(ctx, call, advice)
|
||||
return nil, ("%s is not available to mods; %s"):format(call, advice)
|
||||
end
|
||||
|
||||
-- ------- path routing
|
||||
|
||||
local function normalize(path)
|
||||
if type(path) ~= "string" or path == "" then return nil end
|
||||
path = path:gsub("\\", "/")
|
||||
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
|
||||
if path ~= "/" then path = (path:gsub("/$", "")) end
|
||||
return path
|
||||
end
|
||||
|
||||
local function flatten(path)
|
||||
local parts = {}
|
||||
for segment in path:gmatch("[^/]+") do
|
||||
if segment ~= "." and segment ~= ".." then
|
||||
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function storageKey(key)
|
||||
local parts = {}
|
||||
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
|
||||
if #parts == 0 then return nil end
|
||||
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
|
||||
for i = 1, #parts do
|
||||
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
|
||||
if cleaned == "" then return nil end
|
||||
parts[i] = cleaned
|
||||
end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function ownRelative(ctx, path)
|
||||
if not ctx.modPath then return nil end
|
||||
if path == ctx.modPath then return "" end
|
||||
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
|
||||
return path:sub(#ctx.modPath + 2)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function ownFull(ctx, rel)
|
||||
if rel == "" then return ctx.modPath end
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then return nil end
|
||||
return ctx.modPath .. "/" .. safe
|
||||
end
|
||||
|
||||
local function ownExists(ctx, rel)
|
||||
local full = ownFull(ctx, rel)
|
||||
local fs = ctx.fs
|
||||
if not (full and fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(full)
|
||||
end
|
||||
|
||||
local function classify(ctx, path)
|
||||
path = normalize(path)
|
||||
if not path then return nil end
|
||||
|
||||
local root = ctx.virtualRoot
|
||||
if path == root then return { key = "", dir = true } end
|
||||
if path:sub(1, #root + 1) == root .. "/" then
|
||||
return { key = flatten(path:sub(#root + 2)) }
|
||||
end
|
||||
|
||||
local rel = ownRelative(ctx, path)
|
||||
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
|
||||
and ownExists(ctx, path) then
|
||||
rel = path
|
||||
end
|
||||
if rel then
|
||||
if rel == "" then return { key = OWN, rel = "", dir = true } end
|
||||
local flat = flatten(rel)
|
||||
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
|
||||
end
|
||||
|
||||
return { key = flatten(path) }
|
||||
end
|
||||
|
||||
-- ------- the overlay
|
||||
|
||||
local function persistFs(ctx)
|
||||
if SaveData and SaveData.persistenceFs then
|
||||
return SaveData.persistenceFs(ctx.fs)
|
||||
end
|
||||
return ctx.fs
|
||||
end
|
||||
|
||||
local function overlayPath(ctx, key)
|
||||
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
|
||||
return ROOT .. "/" .. ctx.modId .. "/" .. key
|
||||
end
|
||||
|
||||
local function ensureParent(fs, path)
|
||||
if not fs.createDirectory then return end
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if not dir then return end
|
||||
local built = nil
|
||||
for segment in dir:gmatch("[^/]+") do
|
||||
built = built and (built .. "/" .. segment) or segment
|
||||
fs.createDirectory(built)
|
||||
end
|
||||
end
|
||||
|
||||
local function overlayRead(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.read and fs.getInfo) then return nil end
|
||||
local path = overlayPath(ctx, key)
|
||||
local info = fs.getInfo(path)
|
||||
if not info or info.type == "directory" then return nil end
|
||||
local body = fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function overlayWrite(ctx, key, data)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.write) then
|
||||
return false, "no writable filesystem is available"
|
||||
end
|
||||
local path = overlayPath(ctx, key)
|
||||
ensureParent(fs, path)
|
||||
local ok, err = fs.write(path, data)
|
||||
if ok == false then return false, err or "write failed" end
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayRemove(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.remove and fs.getInfo) then return false end
|
||||
local path = overlayPath(ctx, key)
|
||||
if not fs.getInfo(path) then return false end
|
||||
fs.remove(path)
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayInfo(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(overlayPath(ctx, key))
|
||||
end
|
||||
|
||||
local function storageRead(ctx, key)
|
||||
if not (Storage and key and key ~= "") then return nil end
|
||||
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
|
||||
local game = ctx.game and ctx.game()
|
||||
if not game then return nil end
|
||||
local sk = storageKey(key)
|
||||
if not sk then return nil end
|
||||
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
|
||||
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function readPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil, "invalid path" end
|
||||
local body = at.key and overlayRead(ctx, at.key)
|
||||
if body then return body end
|
||||
if at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.read then
|
||||
local packaged = fs.read(full)
|
||||
if type(packaged) == "string" then return packaged end
|
||||
end
|
||||
end
|
||||
body = at.key and storageRead(ctx, at.key)
|
||||
if body then return body end
|
||||
return nil, "could not open " .. tostring(path)
|
||||
end
|
||||
|
||||
local function writePath(ctx, path, data)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false, "invalid path" end
|
||||
return overlayWrite(ctx, at.key, data)
|
||||
end
|
||||
|
||||
local function infoPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil end
|
||||
if at.key then
|
||||
local info = overlayInfo(ctx, at.key)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
if at.rel then
|
||||
local info = ownExists(ctx, at.rel)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
local stored = at.key and storageRead(ctx, at.key)
|
||||
if stored then return { type = "file", size = #stored } end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function listPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
local seen, out = {}, {}
|
||||
local function add(name)
|
||||
if name and name ~= "" and not seen[name] then
|
||||
seen[name] = true
|
||||
out[#out + 1] = name
|
||||
end
|
||||
end
|
||||
if at and at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
|
||||
end
|
||||
end
|
||||
if at and at.key then
|
||||
local fs = persistFs(ctx)
|
||||
if fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
|
||||
add(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- buffered file handles
|
||||
|
||||
local function openBuffer(ctx, path, mode)
|
||||
mode = tostring(mode or "r"):gsub("b", "")
|
||||
local writable = mode:find("[wa+]") ~= nil
|
||||
local body
|
||||
if mode:sub(1, 1) == "w" then
|
||||
body = ""
|
||||
else
|
||||
body = readPath(ctx, path)
|
||||
if not body then
|
||||
if not writable then return nil, "could not open " .. tostring(path) end
|
||||
body = ""
|
||||
end
|
||||
end
|
||||
local state = {
|
||||
ctx = ctx, path = path, buf = body, pos = 1,
|
||||
writable = writable, closed = false, dirty = false,
|
||||
}
|
||||
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
|
||||
return state
|
||||
end
|
||||
|
||||
local function bufferFlush(state)
|
||||
if not (state.writable and state.dirty) then return true end
|
||||
local ok, err = writePath(state.ctx, state.path, state.buf)
|
||||
if ok then state.dirty = false end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function bufferWrite(state, text)
|
||||
if not state.writable then return false, "file is not open for writing" end
|
||||
local head = state.buf:sub(1, state.pos - 1)
|
||||
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
|
||||
local tail = state.buf:sub(state.pos + #text)
|
||||
state.buf = head .. text .. tail
|
||||
state.pos = state.pos + #text
|
||||
state.dirty = true
|
||||
return true
|
||||
end
|
||||
|
||||
local function bufferRead(state, fmt)
|
||||
if fmt == nil then fmt = "*l" end
|
||||
if type(fmt) == "number" then
|
||||
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
|
||||
if state.pos > #state.buf then return nil end
|
||||
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
|
||||
state.pos = state.pos + #chunk
|
||||
return chunk
|
||||
end
|
||||
fmt = tostring(fmt):gsub("^%*", "")
|
||||
if fmt == "a" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
return rest
|
||||
end
|
||||
if fmt == "l" or fmt == "L" then
|
||||
if state.pos > #state.buf then return nil end
|
||||
local nl = state.buf:find("\n", state.pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
|
||||
state.pos = nl + 1
|
||||
else
|
||||
line = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
if fmt == "n" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
|
||||
if not text then return nil end
|
||||
state.pos = state.pos + after - 1
|
||||
return tonumber(text)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bufferSeek(state, whence, offset)
|
||||
whence = whence or "cur"
|
||||
offset = offset or 0
|
||||
if whence == "set" then state.pos = offset + 1
|
||||
elseif whence == "cur" then state.pos = state.pos + offset
|
||||
elseif whence == "end" then state.pos = #state.buf + offset + 1
|
||||
else return nil, "bad seek base" end
|
||||
if state.pos < 1 then state.pos = 1 end
|
||||
return state.pos - 1
|
||||
end
|
||||
|
||||
local function bufferClose(state)
|
||||
if state.closed then return true end
|
||||
local ok, err = bufferFlush(state)
|
||||
state.closed = true
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function ioFile(ctx, path, mode)
|
||||
local state, err = openBuffer(ctx, path, mode)
|
||||
if not state then return nil, err end
|
||||
local file = {}
|
||||
function file:read(...)
|
||||
local count = select("#", ...)
|
||||
if count == 0 then return bufferRead(state, "*l") end
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
|
||||
return unpack(out, 1, count)
|
||||
end
|
||||
function file:write(...)
|
||||
for i = 1, select("#", ...) do
|
||||
local value = select(i, ...)
|
||||
local ok, writeErr = bufferWrite(state, tostring(value))
|
||||
if not ok then return nil, writeErr end
|
||||
end
|
||||
return file
|
||||
end
|
||||
function file:lines(fmt)
|
||||
return function() return bufferRead(state, fmt or "*l") end
|
||||
end
|
||||
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
|
||||
function file:flush() bufferFlush(state) return file end
|
||||
function file:close() return bufferClose(state) end
|
||||
function file:setvbuf() return true end
|
||||
return file
|
||||
end
|
||||
|
||||
local function loveFile(ctx, path, mode)
|
||||
local state = nil
|
||||
local file = {}
|
||||
function file:open(openMode)
|
||||
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
|
||||
if not opened then return false, err end
|
||||
state = opened
|
||||
return true
|
||||
end
|
||||
function file:isOpen() return state ~= nil and not state.closed end
|
||||
function file:read(bytes)
|
||||
if not state and not file:open("r") then return nil, 0 end
|
||||
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
|
||||
if not body then return nil, 0 end
|
||||
return body, #body
|
||||
end
|
||||
function file:write(data, size)
|
||||
if not state and not file:open(mode or "w") then return false end
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = bufferWrite(state, data)
|
||||
if not ok then return false, err end
|
||||
bufferFlush(state)
|
||||
return true
|
||||
end
|
||||
function file:lines()
|
||||
if not state then file:open("r") end
|
||||
return function() return state and bufferRead(state, "*l") or nil end
|
||||
end
|
||||
function file:seek(offset)
|
||||
if not state then return false end
|
||||
bufferSeek(state, "set", offset or 0)
|
||||
return true
|
||||
end
|
||||
function file:tell() return state and (state.pos - 1) or 0 end
|
||||
function file:getSize()
|
||||
if state then return #state.buf end
|
||||
local body = readPath(ctx, path)
|
||||
return body and #body or 0
|
||||
end
|
||||
function file:flush() if state then bufferFlush(state) end return true end
|
||||
function file:close()
|
||||
if state then bufferClose(state) end
|
||||
state = nil
|
||||
return true
|
||||
end
|
||||
function file:getFilename() return path end
|
||||
function file:getMode() return mode or "c" end
|
||||
function file:setBuffer() return true end
|
||||
if mode and mode ~= "c" then file:open(mode) end
|
||||
return file
|
||||
end
|
||||
|
||||
-- ------- the love.filesystem stand-in
|
||||
|
||||
local function realFilesystem()
|
||||
return _G.love and _G.love.filesystem or nil
|
||||
end
|
||||
|
||||
local function filesystemShim(ctx)
|
||||
local fsShim = {}
|
||||
|
||||
local function readAdvice() return "reads now come from mod:read and mod.storage" end
|
||||
|
||||
function fsShim.read(a, b, c)
|
||||
local path, size = a, b
|
||||
if (a == "string" or a == "data") and type(b) == "string" then
|
||||
path, size = b, c
|
||||
end
|
||||
note(ctx, "love.filesystem.read", readAdvice())
|
||||
local body, err = readPath(ctx, path)
|
||||
if not body then return nil, err end
|
||||
if size and size >= 0 then body = body:sub(1, size) end
|
||||
return body, #body
|
||||
end
|
||||
|
||||
function fsShim.write(path, data, size)
|
||||
note(ctx, "love.filesystem.write",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = writePath(ctx, path, data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.append(path, data, size)
|
||||
note(ctx, "love.filesystem.append",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local existing = readPath(ctx, path) or ""
|
||||
local ok, err = writePath(ctx, path, existing .. data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.lines(path)
|
||||
note(ctx, "love.filesystem.lines", readAdvice())
|
||||
local body = readPath(ctx, path)
|
||||
if not body then error("could not open " .. tostring(path), 2) end
|
||||
local pos = 1
|
||||
return function()
|
||||
if pos > #body then return nil end
|
||||
local nl = body:find("\n", pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = body:sub(pos, nl - 1)
|
||||
pos = nl + 1
|
||||
else
|
||||
line = body:sub(pos)
|
||||
pos = #body + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
end
|
||||
|
||||
function fsShim.load(path)
|
||||
note(ctx, "love.filesystem.load",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end
|
||||
|
||||
function fsShim.getInfo(path, a, b)
|
||||
local info = infoPath(ctx, path)
|
||||
local filter = type(a) == "string" and a or nil
|
||||
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
|
||||
if not info then return nil end
|
||||
if filter and info.type ~= filter then return nil end
|
||||
if into then
|
||||
into.type, into.size, into.modtime = info.type, info.size, info.modtime
|
||||
return into
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
function fsShim.getDirectoryItems(path)
|
||||
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
|
||||
return listPath(ctx, path)
|
||||
end
|
||||
|
||||
function fsShim.createDirectory(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.createDirectory) then return false end
|
||||
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
|
||||
fs.createDirectory(overlayPath(ctx, at.key))
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.remove(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
return overlayRemove(ctx, at.key)
|
||||
end
|
||||
|
||||
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
|
||||
|
||||
function fsShim.isFile(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "file"
|
||||
end
|
||||
|
||||
function fsShim.isDirectory(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "directory"
|
||||
end
|
||||
|
||||
function fsShim.getSize(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.size or 0
|
||||
end
|
||||
|
||||
function fsShim.getLastModified(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.modtime or 0
|
||||
end
|
||||
|
||||
local function virtual(call)
|
||||
note(ctx, call, "paths are virtual now; everything under the returned root "
|
||||
.. "lands in this mod's private compat storage")
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
|
||||
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
|
||||
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
|
||||
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
|
||||
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
|
||||
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
|
||||
function fsShim.getRealDirectory() return ctx.virtualRoot end
|
||||
|
||||
function fsShim.getIdentity() return ctx.modId end
|
||||
|
||||
function fsShim.setIdentity()
|
||||
note(ctx, "love.filesystem.setIdentity",
|
||||
"a mod cannot repoint the save directory; the call does nothing")
|
||||
return false
|
||||
end
|
||||
|
||||
function fsShim.getRequirePath() return "" end
|
||||
function fsShim.setRequirePath() return false end
|
||||
function fsShim.getCRequirePath() return "" end
|
||||
function fsShim.setCRequirePath() return false end
|
||||
|
||||
function fsShim.mount()
|
||||
note(ctx, "love.filesystem.mount",
|
||||
"mounting is refused; ship the files inside your mod and use mod:read")
|
||||
return false
|
||||
end
|
||||
fsShim.unmount = fsShim.mount
|
||||
|
||||
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
|
||||
|
||||
function fsShim.newFileData(a, b)
|
||||
local real = realFilesystem()
|
||||
if type(a) == "string" and b == nil then
|
||||
note(ctx, "love.filesystem.newFileData", readAdvice())
|
||||
local body = readPath(ctx, a)
|
||||
if not body then return nil, "could not open " .. tostring(a) end
|
||||
if real and real.newFileData then return real.newFileData(body, a) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
if real and real.newFileData then return real.newFileData(a, b) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
|
||||
function fsShim.isFused()
|
||||
local real = realFilesystem()
|
||||
return real and real.isFused and real.isFused() or false
|
||||
end
|
||||
|
||||
function fsShim.areSymlinksEnabled() return false end
|
||||
function fsShim.setSymlinksEnabled() return false end
|
||||
function fsShim.init() return false end
|
||||
function fsShim.setSource() return false end
|
||||
|
||||
return setmetatable(fsShim, { __index = function(_, key)
|
||||
note(ctx, "love.filesystem." .. tostring(key),
|
||||
"there is no compat stand-in for it; use mod.storage or mod:read")
|
||||
return nil
|
||||
end })
|
||||
end
|
||||
|
||||
-- ------- the rest of the removed surface
|
||||
|
||||
local function systemShim(ctx)
|
||||
local function real() return _G.love and _G.love.system or nil end
|
||||
return {
|
||||
getOS = function()
|
||||
local sys = real()
|
||||
return sys and sys.getOS and sys.getOS() or "Unknown"
|
||||
end,
|
||||
getPowerInfo = function()
|
||||
local sys = real()
|
||||
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
|
||||
return sys.getPowerInfo()
|
||||
end,
|
||||
getProcessorCount = function()
|
||||
local sys = real()
|
||||
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
|
||||
end,
|
||||
getClipboardText = function()
|
||||
note(ctx, "love.system.getClipboardText",
|
||||
"clipboard access stays sandboxed; the call returns an empty string")
|
||||
return ""
|
||||
end,
|
||||
setClipboardText = function()
|
||||
note(ctx, "love.system.setClipboardText",
|
||||
"clipboard access stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
openURL = function()
|
||||
note(ctx, "love.system.openURL",
|
||||
"launching a URL stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
vibrate = function(...)
|
||||
local sys = real()
|
||||
if sys and sys.vibrate then return sys.vibrate(...) end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function eventShim(ctx)
|
||||
local function real() return _G.love and _G.love.event or nil end
|
||||
local shim = {
|
||||
quit = function()
|
||||
note(ctx, "love.event.quit",
|
||||
"a mod cannot close the game out from under the player; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
}
|
||||
shim.push = function(name, ...)
|
||||
if name == "quit" then return shim.quit() end
|
||||
local ev = real()
|
||||
if ev and ev.push then return ev.push(name, ...) end
|
||||
return false
|
||||
end
|
||||
return setmetatable(shim, { __index = function(_, key)
|
||||
local ev = real()
|
||||
return ev and ev[key] or nil
|
||||
end })
|
||||
end
|
||||
|
||||
local function ioShim(ctx)
|
||||
local stream = {
|
||||
write = function(self, ...)
|
||||
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
|
||||
return self
|
||||
end,
|
||||
flush = function(self) return self end,
|
||||
close = function() return true end,
|
||||
read = function() return nil end,
|
||||
lines = function() return function() return nil end end,
|
||||
seek = function() return 0 end,
|
||||
setvbuf = function() return true end,
|
||||
}
|
||||
local out = setmetatable({}, { __index = stream })
|
||||
local shim = {
|
||||
open = function(path, mode)
|
||||
note(ctx, "io.open",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, mode)
|
||||
if not file then return nil, err, 2 end
|
||||
return file
|
||||
end,
|
||||
lines = function(path, fmt)
|
||||
if path == nil then return function() return nil end end
|
||||
note(ctx, "io.lines",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, "r")
|
||||
if not file then error(err, 2) end
|
||||
return file:lines(fmt)
|
||||
end,
|
||||
close = function(file) if file and file.close then return file:close() end return true end,
|
||||
type = function(file)
|
||||
if type(file) == "table" and file.read and file.close then return "file" end
|
||||
return nil
|
||||
end,
|
||||
read = function() return nil end,
|
||||
write = function(...) return out:write(...) end,
|
||||
input = function() return out end,
|
||||
output = function() return out end,
|
||||
stdout = out,
|
||||
stderr = out,
|
||||
stdin = setmetatable({}, { __index = stream }),
|
||||
popen = function()
|
||||
return refuse(ctx, "io.popen", "spawning a process is refused")
|
||||
end,
|
||||
tmpfile = function()
|
||||
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
|
||||
end,
|
||||
}
|
||||
return shim
|
||||
end
|
||||
|
||||
local function osShim(ctx)
|
||||
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
|
||||
USERPROFILE = true, XDG_DATA_HOME = true,
|
||||
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
|
||||
return {
|
||||
getenv = function(name)
|
||||
note(ctx, "os.getenv",
|
||||
"the environment is hidden; home-like names answer with this mod's virtual root")
|
||||
if type(name) == "string" and HOME[name:upper()] then
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
remove = function(path)
|
||||
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key and overlayRemove(ctx, at.key)) then
|
||||
return nil, tostring(path) .. ": no such file"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
rename = function(from, to)
|
||||
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
|
||||
local body = readPath(ctx, from)
|
||||
if not body then return nil, tostring(from) .. ": no such file" end
|
||||
local ok, err = writePath(ctx, to, body)
|
||||
if not ok then return nil, err end
|
||||
local at = classify(ctx, from)
|
||||
if at and at.key then overlayRemove(ctx, at.key) end
|
||||
return true
|
||||
end,
|
||||
tmpname = function()
|
||||
return ctx.virtualRoot .. "/tmp/os_tmpname"
|
||||
end,
|
||||
execute = function()
|
||||
note(ctx, "os.execute", "running a command is refused")
|
||||
return false
|
||||
end,
|
||||
exit = function()
|
||||
note(ctx, "os.exit",
|
||||
"a mod cannot end the process; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
setlocale = function() return "C" end,
|
||||
}
|
||||
end
|
||||
|
||||
local function packageShim(ctx)
|
||||
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
note(ctx, "package." .. tostring(key),
|
||||
"the module loader is sandboxed; use require for the supported engine modules")
|
||||
return nil
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ------- love callbacks
|
||||
|
||||
local CALLBACKS = {
|
||||
load = true, update = true, draw = true, quit = true, lowmemory = true,
|
||||
threaderror = true, keypressed = true, keyreleased = true,
|
||||
textinput = true, textedited = true, mousemoved = true,
|
||||
mousepressed = true, mousereleased = true, wheelmoved = true,
|
||||
mousefocus = true, touchpressed = true, touchreleased = true,
|
||||
touchmoved = true, joystickadded = true, joystickremoved = true,
|
||||
joystickpressed = true, joystickreleased = true, joystickaxis = true,
|
||||
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
|
||||
gamepadaxis = true, focus = true, visible = true, resize = true,
|
||||
filedropped = true, directorydropped = true, displayrotated = true,
|
||||
audiodevicechanged = true, localechanged = true,
|
||||
}
|
||||
|
||||
local REFUSED = {
|
||||
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
|
||||
errorhandler = "love.errorhandler is how a crash reaches the player; "
|
||||
.. "use mod.events",
|
||||
}
|
||||
|
||||
-- ------- assembly
|
||||
|
||||
function LegacyCompat.new(opts)
|
||||
opts = opts or {}
|
||||
local ctx = {
|
||||
modId = opts.modId or "mod",
|
||||
modPath = opts.modPath,
|
||||
fs = opts.fs,
|
||||
game = opts.game,
|
||||
compile = nil,
|
||||
}
|
||||
ctx.virtualRoot = "/pokeport/" .. ctx.modId
|
||||
|
||||
local filesystem = filesystemShim(ctx)
|
||||
local io_ = ioShim(ctx)
|
||||
local system = systemShim(ctx)
|
||||
local event = eventShim(ctx)
|
||||
|
||||
local compat = {
|
||||
ctx = ctx,
|
||||
love = { filesystem = filesystem, system = system, event = event },
|
||||
os = osShim(ctx),
|
||||
globals = {
|
||||
io = io_,
|
||||
package = packageShim(ctx),
|
||||
loadfile = function(path)
|
||||
note(ctx, "loadfile",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end,
|
||||
},
|
||||
modules = {
|
||||
io = io_,
|
||||
["love.filesystem"] = filesystem,
|
||||
["love.system"] = system,
|
||||
["love.event"] = event,
|
||||
},
|
||||
}
|
||||
|
||||
compat.globals.dofile = function(path)
|
||||
local chunk, err = compat.globals.loadfile(path)
|
||||
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
|
||||
return chunk()
|
||||
end
|
||||
|
||||
function compat.module(name)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local substitute = compat.modules[name]
|
||||
if substitute then
|
||||
note(ctx, ('require("%s")'):format(name),
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return substitute
|
||||
end
|
||||
if name == "os" then
|
||||
note(ctx, 'require("os")',
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return compat.osTable
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- true when the assignment is a callback chain, which is what a mod
|
||||
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
|
||||
-- the two names the engine owns, false alone for a module table.
|
||||
function compat.assign(key, value)
|
||||
if REFUSED[key] then
|
||||
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
|
||||
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
|
||||
end
|
||||
if not CALLBACKS[key] then return false end
|
||||
if value ~= nil and type(value) ~= "function" then return false end
|
||||
note(ctx, ("love.%s assignment"):format(key),
|
||||
"the callback lands on the real love table the way it did before the "
|
||||
.. "sandbox; prefer mod.hooks and mod.events")
|
||||
return true
|
||||
end
|
||||
|
||||
function compat.bind(env, compile)
|
||||
ctx.compile = compile
|
||||
compat.osTable = env and env.os or nil
|
||||
end
|
||||
|
||||
return compat
|
||||
end
|
||||
|
||||
return LegacyCompat
|
||||
+2
-33
@@ -4,7 +4,6 @@ local SaveData = require("src.core.SaveData")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
@@ -20,7 +19,6 @@ local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
|
||||
@@ -567,24 +565,7 @@ function Loader:_validate()
|
||||
elseif manifest.assets_transforms
|
||||
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
|
||||
reason = "assets_transforms file missing: " .. manifest.assets_transforms
|
||||
end
|
||||
if not reason and #(manifest.required_imports or {}) > 0 then
|
||||
for _, import in ipairs(manifest.required_imports) do
|
||||
local path = mod.path .. "/baseroms/" .. import.file
|
||||
if not self:_exists(path) then
|
||||
reason = "required import missing: " .. import.name
|
||||
break
|
||||
end
|
||||
local valid, importErr = RequiredImports.validateStored(
|
||||
manifest, import, self.fs)
|
||||
if not valid then
|
||||
reason = "required import invalid: " .. import.name
|
||||
.. " (" .. tostring(importErr) .. ")"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not reason and manifest.game_version and not devEngine() then
|
||||
elseif manifest.game_version and not devEngine() then
|
||||
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
|
||||
if not ok then
|
||||
reason = ("needs game version %s, engine is %s")
|
||||
@@ -1289,24 +1270,12 @@ function Loader:_modEnv(mod)
|
||||
local id = mod.manifest.id
|
||||
local env = self.modEnv[id]
|
||||
if not env then
|
||||
local loader = self
|
||||
local compat = LegacyCompat.new({
|
||||
modId = id, modPath = mod.path, fs = self.fs,
|
||||
game = function() return loader:_game() end,
|
||||
})
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet,
|
||||
compat = compat })
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
-- Which pre-sandbox calls each loaded mod actually took, for the manager's
|
||||
-- "needs updating" badge; nil id answers for every mod.
|
||||
function Loader:legacyReport(modId)
|
||||
return LegacyCompat.report(modId)
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
|
||||
+1
-100
@@ -132,97 +132,13 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
local scrubUtf8 -- shared by required-import labels and top-level strings
|
||||
|
||||
-- User-supplied files a mod needs beside its own source. The launcher owns
|
||||
-- the picker and the copy; the mod only reads the resulting
|
||||
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
|
||||
-- deliberately the manifest vocabulary here: ROM preservation databases and
|
||||
-- the mods consuming these files commonly identify dumps by MD5, and the
|
||||
-- digest is an identity check rather than a security boundary.
|
||||
local function parseImports(value, field, required)
|
||||
field = field or "required_imports"
|
||||
local out, ids, files = {}, {}, {}
|
||||
for _, entry in ipairs(array(value)) do
|
||||
assert(type(entry) == "table", field .. " entries must be objects")
|
||||
local id = entry.id
|
||||
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
|
||||
field .. " id must contain only letters, numbers, _ or -")
|
||||
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
|
||||
ids[id] = true
|
||||
|
||||
local file = entry.file
|
||||
assert(type(file) == "string" and file ~= "",
|
||||
field .. " file is required")
|
||||
file = SafePath.require(file, field .. " file")
|
||||
assert(not file:find("/", 1, true),
|
||||
field .. " file must be a filename inside baseroms")
|
||||
assert(file:sub(1, 1) ~= ".",
|
||||
field .. " file must not use a hidden metadata filename")
|
||||
assert(not files[file], "duplicate " .. field .. " file: " .. file)
|
||||
files[file] = true
|
||||
|
||||
local hashes = entry.md5
|
||||
if type(hashes) == "string" then hashes = { hashes } end
|
||||
assert(type(hashes) == "table" and #hashes > 0,
|
||||
field .. " md5 must be a hash or non-empty array")
|
||||
local accepted, seen = {}, {}
|
||||
for _, digest in ipairs(hashes) do
|
||||
assert(type(digest) == "string" and digest:match("^[%x]+$")
|
||||
and #digest == 32, field .. " md5 values must be 32 hex characters")
|
||||
digest = digest:lower()
|
||||
if not seen[digest] then
|
||||
seen[digest] = true
|
||||
accepted[#accepted + 1] = digest
|
||||
end
|
||||
end
|
||||
|
||||
local format = entry.format or "raw"
|
||||
assert(format == "raw" or format == "n64",
|
||||
field .. " format must be raw or n64")
|
||||
local name = entry.name or id
|
||||
assert(type(name) == "string" and name ~= "",
|
||||
field .. " name must be a non-empty string")
|
||||
local description = entry.description or entry.hint
|
||||
if description ~= nil then
|
||||
assert(type(description) == "string" and description ~= "",
|
||||
field .. " description must be a non-empty string")
|
||||
end
|
||||
local size = entry.size
|
||||
local maxSize = entry.max_size
|
||||
local function validateSize(value, label)
|
||||
if value == nil then return end
|
||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||
field .. " " .. label .. " must be a positive integer")
|
||||
assert(value <= 128 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
||||
end
|
||||
validateSize(size, "size")
|
||||
validateSize(maxSize, "max_size")
|
||||
assert(not (size and maxSize) or size <= maxSize,
|
||||
field .. " size must not exceed max_size")
|
||||
out[#out + 1] = {
|
||||
id = id,
|
||||
name = scrubUtf8(name),
|
||||
description = scrubUtf8(description),
|
||||
file = file,
|
||||
md5 = accepted,
|
||||
format = format,
|
||||
size = size,
|
||||
max_size = maxSize,
|
||||
required = required ~= false,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
scrubUtf8 = function(s)
|
||||
local function scrubUtf8(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
@@ -374,19 +290,6 @@ function Manifest.validate(raw, path)
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
local requiredImports = parseImports(raw.required_imports,
|
||||
"required_imports", true)
|
||||
local optionalImports = parseImports(raw.optional_imports,
|
||||
"optional_imports", false)
|
||||
local importIds, importFiles = {}, {}
|
||||
for _, list in ipairs({ requiredImports, optionalImports }) do
|
||||
for _, import in ipairs(list) do
|
||||
assert(not importIds[import.id], "duplicate import id: " .. import.id)
|
||||
assert(not importFiles[import.file], "duplicate import file: " .. import.file)
|
||||
importIds[import.id], importFiles[import.file] = true, true
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
@@ -415,8 +318,6 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
required_imports = requiredImports,
|
||||
optional_imports = optionalImports,
|
||||
-- an env var name, not a path, so it keeps the plain string check
|
||||
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
|
||||
path = path,
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
-- Engine-owned import handling for files declared by a mod's required or
|
||||
-- optional import arrays. Mods never receive host paths or broader
|
||||
-- filesystem access: accepted bytes are copied into their own
|
||||
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local RequiredImports = {}
|
||||
|
||||
local function allSpecs(manifest)
|
||||
local out = {}
|
||||
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
|
||||
out[#out + 1] = spec
|
||||
end
|
||||
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
|
||||
out[#out + 1] = spec
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function isRequired(spec)
|
||||
return spec.required ~= false
|
||||
end
|
||||
|
||||
RequiredImports.specs = allSpecs
|
||||
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
|
||||
|
||||
local function sizeLabel(bytes)
|
||||
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
||||
end
|
||||
|
||||
-- Check size before a caller reads an external or stored file into one large
|
||||
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
||||
-- files are always canonical and therefore must match the declared size.
|
||||
function RequiredImports.sizeError(spec, size, stored)
|
||||
if type(size) ~= "number" then return nil end
|
||||
if size > RequiredImports.MAX_BYTES then
|
||||
return ("file is too large (%s; hard limit is %s)")
|
||||
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
|
||||
end
|
||||
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
|
||||
if spec and spec.size then
|
||||
if size ~= spec.size and size ~= spec.size + headerAllowance then
|
||||
return ("wrong file size (expected %d bytes%s, got %d)")
|
||||
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
|
||||
end
|
||||
end
|
||||
if spec and spec.max_size and size > spec.max_size + headerAllowance then
|
||||
return ("file is too large for this import (maximum %d bytes, got %d)")
|
||||
:format(spec.max_size, size)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local N64_MAGIC = {
|
||||
["\128\55\18\64"] = "z64", -- big endian / canonical
|
||||
["\55\128\64\18"] = "v64", -- byte-swapped
|
||||
["\64\18\55\128"] = "n64", -- little endian words
|
||||
}
|
||||
|
||||
local function n64KindAt(data, offset)
|
||||
return N64_MAGIC[data:sub(offset, offset + 3)]
|
||||
end
|
||||
|
||||
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
|
||||
-- recognized only when valid N64 magic follows it, so arbitrary data is never
|
||||
-- shortened just because its size happens to line up.
|
||||
function RequiredImports.normalizeN64(data)
|
||||
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
||||
local offset, kind = 1, n64KindAt(data, 1)
|
||||
if not kind then
|
||||
kind = n64KindAt(data, 513)
|
||||
if kind then offset = 513 end
|
||||
end
|
||||
if not kind then
|
||||
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
|
||||
end
|
||||
data = data:sub(offset)
|
||||
if kind == "z64" then return data end
|
||||
|
||||
if kind == "v64" then
|
||||
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
|
||||
return (data:gsub("(.)(.)", "%2%1"))
|
||||
else
|
||||
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
|
||||
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
|
||||
end
|
||||
end
|
||||
|
||||
function RequiredImports.normalize(spec, data)
|
||||
if spec and spec.format == "n64" then
|
||||
return RequiredImports.normalizeN64(data)
|
||||
end
|
||||
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
||||
return data
|
||||
end
|
||||
|
||||
local function hexDigest(data, hashFn)
|
||||
if hashFn then return hashFn(data):lower() end
|
||||
if not (love and love.data and love.data.hash and love.data.encode) then
|
||||
return nil, "MD5 support is unavailable in this build"
|
||||
end
|
||||
local digest = love.data.hash("md5", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
digest = digest:getString()
|
||||
end
|
||||
return love.data.encode("string", "hex", digest):lower()
|
||||
end
|
||||
|
||||
local function accepts(spec, digest)
|
||||
for _, wanted in ipairs((spec and spec.md5) or {}) do
|
||||
if wanted == digest then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function RequiredImports.path(manifest, spec)
|
||||
return manifest.path .. "/baseroms/" .. spec.file
|
||||
end
|
||||
|
||||
local function removedMarker(manifest, spec)
|
||||
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
|
||||
end
|
||||
|
||||
local function receiptPath(manifest, spec)
|
||||
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
|
||||
end
|
||||
|
||||
RequiredImports.receiptPath = receiptPath
|
||||
|
||||
local function parseReceipt(raw)
|
||||
if type(raw) ~= "string" then return nil end
|
||||
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
|
||||
if not digest then return nil end
|
||||
return digest:lower(), tonumber(size), tonumber(modtime)
|
||||
end
|
||||
|
||||
local function cachedDigest(manifest, spec, fs, info)
|
||||
-- A size alone cannot detect a same-length replacement. Require modtime as
|
||||
-- well; filesystems that do not expose it simply take the safe hash path.
|
||||
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
|
||||
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
|
||||
if digest and size == info.size and modtime == info.modtime
|
||||
and accepts(spec, digest) then
|
||||
return digest
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function writeReceipt(manifest, spec, digest, info, fs)
|
||||
if not (digest and info and info.size and info.modtime) then return end
|
||||
local path = receiptPath(manifest, spec)
|
||||
local body = ("v1\n%s\n%d\n%s\n")
|
||||
:format(digest, info.size, tostring(info.modtime))
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.write(path, body)
|
||||
CacheFs.prefix = savedPrefix
|
||||
elseif fs and fs.write then
|
||||
fs.write(path, body)
|
||||
end
|
||||
end
|
||||
|
||||
local function removeReceipt(manifest, spec, fs)
|
||||
local path = receiptPath(manifest, spec)
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(path)
|
||||
CacheFs.prefix = savedPrefix
|
||||
elseif fs and fs.remove then
|
||||
fs.remove(path)
|
||||
end
|
||||
end
|
||||
|
||||
-- Validate bytes against a declaration. The returned data is canonicalized
|
||||
-- (notably for N64 byte order/header variants) and is what must be stored.
|
||||
function RequiredImports.validateData(spec, data, hashFn)
|
||||
local sourceSizeErr = type(data) == "string"
|
||||
and RequiredImports.sizeError(spec, #data, false)
|
||||
if sourceSizeErr then return nil, sourceSizeErr end
|
||||
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
|
||||
if not normalized then return nil, normalizeErr end
|
||||
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
|
||||
if storedSizeErr then return nil, storedSizeErr end
|
||||
local digest, hashErr = hexDigest(normalized, hashFn)
|
||||
if not digest then return nil, hashErr end
|
||||
if not accepts(spec, digest) then
|
||||
return nil, ("MD5 mismatch (got %s)"):format(digest)
|
||||
end
|
||||
return normalized, digest
|
||||
end
|
||||
|
||||
-- Validate one installed import without reading it when the engine-authored
|
||||
-- receipt still matches the file's size and modification time.
|
||||
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
fs = fs or (love and love.filesystem)
|
||||
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local info = fs.getInfo(path, "file")
|
||||
if not info then
|
||||
removeReceipt(manifest, spec, fs)
|
||||
return nil, "file is missing"
|
||||
end
|
||||
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||
if sizeErr then
|
||||
removeReceipt(manifest, spec, fs)
|
||||
return nil, sizeErr
|
||||
end
|
||||
local cached = cachedDigest(manifest, spec, fs, info)
|
||||
if cached then return true, cached, true end
|
||||
removeReceipt(manifest, spec, fs)
|
||||
if not fs.read then return nil, "file could not be read" end
|
||||
local data = fs.read(path)
|
||||
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
if not normalized then return nil, detail end
|
||||
info = fs.getInfo(path, "file") or info
|
||||
info.size = info.size or #data
|
||||
writeReceipt(manifest, spec, detail, info, fs)
|
||||
return true, detail, false
|
||||
end
|
||||
|
||||
function RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
|
||||
if not normalized then return nil, detail end
|
||||
if normalized ~= data then
|
||||
return nil, "stored N64 ROM is not canonical; choose the source file again"
|
||||
end
|
||||
return normalized, detail
|
||||
end
|
||||
|
||||
function RequiredImports.inspect(manifest, fs, hashFn)
|
||||
fs = fs or (love and love.filesystem)
|
||||
local rows, missing, missingOptional = {}, 0, 0
|
||||
for _, spec in ipairs(allSpecs(manifest)) do
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local suppressed = fs and fs.getInfo
|
||||
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
|
||||
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
|
||||
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
local row = { id = spec.id, name = spec.name, file = spec.file,
|
||||
description = spec.description, format = spec.format, path = path,
|
||||
present = valid == true, digest = valid and detail or nil,
|
||||
error = exists and not valid and detail or nil,
|
||||
suppressed = suppressed, required = isRequired(spec), spec = spec }
|
||||
if not row.present then
|
||||
if row.required then missing = missing + 1
|
||||
else missingOptional = missingOptional + 1 end
|
||||
end
|
||||
rows[#rows + 1] = row
|
||||
end
|
||||
return rows, missing, missingOptional
|
||||
end
|
||||
|
||||
function RequiredImports.importData(manifest, importId, data, opts)
|
||||
opts = opts or {}
|
||||
local spec
|
||||
for _, candidate in ipairs(allSpecs(manifest)) do
|
||||
if candidate.id == importId then spec = candidate break end
|
||||
end
|
||||
if not spec then return nil, "unknown required import: " .. tostring(importId) end
|
||||
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
|
||||
if not normalized then return nil, digest end
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(receiptPath(manifest, spec))
|
||||
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
|
||||
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
|
||||
if ok and love and love.filesystem and love.filesystem.getInfo then
|
||||
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
|
||||
writeReceipt(manifest, spec, digest, info, love.filesystem)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then return nil, "could not copy import: " .. tostring(err) end
|
||||
return true, digest
|
||||
end
|
||||
|
||||
function RequiredImports.remove(manifest, importId)
|
||||
for _, spec in ipairs(allSpecs(manifest)) do
|
||||
if spec.id == importId then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(RequiredImports.path(manifest, spec))
|
||||
CacheFs.remove(receiptPath(manifest, spec))
|
||||
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
|
||||
return true
|
||||
end
|
||||
end
|
||||
return nil, "unknown required import: " .. tostring(importId)
|
||||
end
|
||||
|
||||
return RequiredImports
|
||||
+9
-34
@@ -73,15 +73,11 @@ local BLOCKED_LOVE = {
|
||||
.. "the step bridge", event = true,
|
||||
}
|
||||
|
||||
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||
-- by that mod's own overlay and must not be shared.
|
||||
local function loveFacade(compat)
|
||||
if not _G.love then return nil end
|
||||
local overrides = compat and compat.love
|
||||
return setmetatable({}, {
|
||||
local loveProxy
|
||||
local function loveFacade()
|
||||
if loveProxy or not _G.love then return loveProxy end
|
||||
loveProxy = setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local override = overrides and overrides[key]
|
||||
if override ~= nil then return override end
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
@@ -89,20 +85,11 @@ local function loveFacade(compat)
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
-- a callback chain lands on the real table (compat.assign decides which
|
||||
-- names qualify); a module table never does
|
||||
__newindex = function(_, key, value)
|
||||
if compat then
|
||||
local allowed, reason = compat.assign(key, value)
|
||||
if allowed then
|
||||
_G.love[key] = value
|
||||
return
|
||||
end
|
||||
if reason then error(reason, 2) end
|
||||
end
|
||||
__newindex = function(_, key)
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
return loveProxy
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
@@ -190,12 +177,8 @@ end
|
||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
||||
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||
-- lazily from an event handler).
|
||||
local function sandboxedRequire(modId, permissionSet, compat)
|
||||
local function sandboxedRequire(modId, permissionSet)
|
||||
return function(name, ...)
|
||||
-- the compat stand-in answers first, so a legacy require("io") gets the
|
||||
-- rerouted table instead of the denial below (src/mods/LegacyCompat.lua)
|
||||
local substitute = compat and compat.module(name)
|
||||
if substitute ~= nil then return substitute end
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
@@ -209,23 +192,15 @@ end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local compat = opts.compat
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade(compat)
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||
env.love = loveFacade()
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
env.loadstring = loader
|
||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||
-- neither can reach the engine's
|
||||
env._G = env
|
||||
if compat then
|
||||
for key, value in pairs(compat.globals) do env[key] = value end
|
||||
for key, value in pairs(compat.os) do env.os[key] = value end
|
||||
compat.bind(env, function(source, chunkname)
|
||||
return Sandbox.compile(source, chunkname, env)
|
||||
end)
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
|
||||
+52
-12
@@ -454,7 +454,30 @@ function PartyMenu:update(dt)
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
ow:useFlashFieldMove(function() self:close() end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
-- .flash prints on any map, but the light it records is this map's:
|
||||
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
|
||||
-- a FLASH used in daylight must not carry into Rock Tunnel
|
||||
local wasDark = ow and ow.dark
|
||||
if wasDark then self.game.save.flashLit = true end
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self:close()
|
||||
-- setDark, not a bare field write: ADVANCED carries the darkness
|
||||
-- in a baked atlas, so lighting the cave drops every resident map
|
||||
-- and rebakes this one (#383). It runs HERE, before the blink,
|
||||
-- because start_sub_menus.asm .flash clears wMapPalOffset before
|
||||
-- PrintText and blinks last of all: the cave is already lit by the
|
||||
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
|
||||
-- blink's completion instead left that rebuild's whole cost --
|
||||
-- seconds of per-pixel atlas baking on a phone -- on screen as a
|
||||
-- solid white frame with nothing under it, which reads as a
|
||||
-- lockup (#610).
|
||||
if wasDark then ow:setDark(false) end
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||
@@ -482,7 +505,12 @@ function PartyMenu:update(dt)
|
||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||
-- steps the player forward onto land (or across a connection
|
||||
-- strip when the shore is the next map's edge)
|
||||
ow:stopSurfing(function() self.game.stack:pop() end)
|
||||
self.game.stack:pop()
|
||||
ow.player.surfing = false
|
||||
require("src.core.Music").setSurfing(self.game.data, false)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
ow:stepForwardOrCrossEdge(ow.player.facing)
|
||||
end))
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -539,18 +567,30 @@ function PartyMenu:update(dt)
|
||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||
-- before CloseTextDisplay returns to the map.
|
||||
local ow = self.game.overworld
|
||||
if ow and ow.useStrengthFieldMove then
|
||||
if not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||
return
|
||||
elseif ow and ow.useFieldMove then
|
||||
ow:useFieldMove("STRENGTH", mon)
|
||||
self:close()
|
||||
if ow and not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320, #385): both texts print with the party menu
|
||||
-- still on screen, and the blink IS the menu closing afterwards,
|
||||
-- not a flashbang on the empty map
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
return
|
||||
elseif action == "softboiled" then
|
||||
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
||||
|
||||
@@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def)
|
||||
put(out, genderGlyph(mon.gender), 18, 13)
|
||||
-- (8,14) is a bare '/', so the nickname starts at (9,14).
|
||||
put(out, "/", 8, 14)
|
||||
put(out, mon.nickname or mon.name or mon.species, 9, 14)
|
||||
put(out, mon.nickname or mon.species, 9, 14)
|
||||
put(out, levelText(mon.level), 1, 16)
|
||||
end
|
||||
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16).
|
||||
|
||||
@@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row)
|
||||
if not allowed then
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row)
|
||||
if move.id == moveId then
|
||||
if game.say then
|
||||
game:say(("%s already knows %s!"):format(
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+3
-12
@@ -71,9 +71,6 @@ function Check.parseRelease(jsonText, Json)
|
||||
payloadName = payloadName,
|
||||
payload = Check.pickAsset(doc.assets, payloadName),
|
||||
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
||||
-- GitHub release body: already fetched with the update check, shown by
|
||||
-- the launcher's Patch notes footer button.
|
||||
notes = type(doc.body) == "string" and doc.body or "",
|
||||
}
|
||||
end
|
||||
|
||||
@@ -138,10 +135,6 @@ local function drain()
|
||||
if stateCh then
|
||||
local msg = stateCh:pop()
|
||||
while msg do
|
||||
if type(msg) == "table" and type(msg.notes) ~= "string"
|
||||
and type(cache.notes) == "string" then
|
||||
msg.notes = cache.notes
|
||||
end
|
||||
cache = msg
|
||||
msg = stateCh:pop()
|
||||
end
|
||||
@@ -165,11 +158,11 @@ function Check.start()
|
||||
return
|
||||
end
|
||||
requested = true
|
||||
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
|
||||
cache = { status = "checking" }
|
||||
cmdCh:push({ cmd = "check" })
|
||||
end
|
||||
|
||||
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
|
||||
-- Current snapshot: { status, latest, progress, error }. status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
||||
function Check.state()
|
||||
drain()
|
||||
@@ -178,7 +171,6 @@ function Check.state()
|
||||
latest = cache.latest,
|
||||
progress = cache.progress,
|
||||
error = cache.error,
|
||||
notes = cache.notes,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -188,8 +180,7 @@ function Check.download()
|
||||
drain()
|
||||
if not cmdCh then return end
|
||||
if cache.status ~= "available" then return end
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0,
|
||||
notes = cache.notes }
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0 }
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
-- Resolve the game's patch notes for the launcher footer modal.
|
||||
--
|
||||
-- Sources, first hit wins:
|
||||
-- 1. The GitHub body the self-updater already fetched (Check.parseRelease)
|
||||
-- 2. PATCH_NOTES.md, if a build packed one
|
||||
-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into
|
||||
-- versions[].localizedDescription, so a checkout already has them
|
||||
-- on disk even when the updater has not run
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local PatchNotes = {}
|
||||
|
||||
PatchNotes.FILES = {
|
||||
"PATCH_NOTES.md",
|
||||
"assets/PATCH_NOTES.md",
|
||||
}
|
||||
|
||||
PatchNotes.REPO_FILES = {
|
||||
"mobile/ios/app-repo.json",
|
||||
}
|
||||
|
||||
local function nonempty(s)
|
||||
return type(s) == "string" and s:find("%S")
|
||||
end
|
||||
|
||||
function PatchNotes.fromCheck(Check)
|
||||
if not (Check and Check.state) then return nil, nil end
|
||||
local ok, st = pcall(Check.state)
|
||||
st = (ok and type(st) == "table") and st or nil
|
||||
if not st then return nil, nil end
|
||||
if nonempty(st.notes) then
|
||||
return st.notes, st.latest
|
||||
end
|
||||
return nil, st.latest
|
||||
end
|
||||
|
||||
local function readPath(path)
|
||||
local fs = love and love.filesystem
|
||||
if fs and fs.read then
|
||||
local ok, text = pcall(fs.read, path)
|
||||
if ok and nonempty(text) then return text end
|
||||
end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local text = f:read("*a")
|
||||
f:close()
|
||||
return nonempty(text) and text or nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromFile()
|
||||
for _, path in ipairs(PatchNotes.FILES) do
|
||||
local text = readPath(path)
|
||||
if text then return text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Newest-first list of { version, notes } from the iOS sidecar.
|
||||
function PatchNotes.parseRepo(jsonText)
|
||||
local doc = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end
|
||||
local out = {}
|
||||
for _, app in ipairs(doc.apps) do
|
||||
if type(app) == "table" and type(app.versions) == "table" then
|
||||
for _, row in ipairs(app.versions) do
|
||||
if type(row) == "table" and nonempty(row.localizedDescription) then
|
||||
out[#out + 1] = {
|
||||
version = row.version,
|
||||
notes = row.localizedDescription,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function PatchNotes.fromRepo(engine)
|
||||
for _, path in ipairs(PatchNotes.REPO_FILES) do
|
||||
local text = readPath(path)
|
||||
if text then
|
||||
local list = PatchNotes.parseRepo(text)
|
||||
if #list == 0 then return nil, nil end
|
||||
if engine and engine ~= "0.0.0-dev" then
|
||||
for _, row in ipairs(list) do
|
||||
if row.version == engine then
|
||||
return row.notes, row.version
|
||||
end
|
||||
end
|
||||
end
|
||||
return list[1].notes, list[1].version
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
function PatchNotes.body(Check)
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes then return notes, ver end
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, ver end
|
||||
local Version = require("src.core.Version")
|
||||
local engine = (Version and Version.engine) or "?"
|
||||
notes, ver = PatchNotes.fromRepo(engine)
|
||||
if notes then return notes, ver end
|
||||
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
|
||||
.. "They appear here after the launcher checks GitHub for the latest "
|
||||
.. "release.", engine
|
||||
end
|
||||
|
||||
return PatchNotes
|
||||
@@ -45,12 +45,7 @@ local Boot = loadModule("src/update/Boot.lua")
|
||||
local cmdCh = love.thread.getChannel("update_check_cmd")
|
||||
local stateCh = love.thread.getChannel("update_check_state")
|
||||
|
||||
local function post(t)
|
||||
if pending and type(t) == "table" and t.notes == nil then
|
||||
t.notes = pending.notes
|
||||
end
|
||||
stateCh:push(t)
|
||||
end
|
||||
local function post(t) stateCh:push(t) end
|
||||
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
local isWindows = osName == "Windows"
|
||||
|
||||
@@ -784,49 +784,6 @@ function OverworldState:useBicycle()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Field-move entry points keep presentation and state transitions in the
|
||||
-- overworld. The party menu and supported mod facade both call these, so a
|
||||
-- shortcut cannot drift from the game's own move flow.
|
||||
function OverworldState:useFlashFieldMove(onClose)
|
||||
-- A FLASH used in daylight must not carry into the next dark map. Lighting
|
||||
-- also happens before the blink: setDark may rebake an ADVANCED atlas, and
|
||||
-- putting that work in WhiteFlash:onDone caused the frozen white frame in
|
||||
-- #610.
|
||||
local wasDark = self.dark
|
||||
if wasDark then Game.save.flashLit = true end
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
if onClose then onClose() end
|
||||
if wasDark then self:setDark(false) end
|
||||
Game.stack:push(Transition.whiteFlash(Game))
|
||||
end))
|
||||
return true
|
||||
end
|
||||
|
||||
function OverworldState:useStrengthFieldMove(mon, onClose)
|
||||
mon = mon or self:partyKnows("STRENGTH")
|
||||
if not mon then return false end
|
||||
local def = Game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
self.strengthActive = true
|
||||
local first = (Game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH."))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
local second = (Game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders."))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
Game.stack:push(TextBox.new(Game, first, function()
|
||||
Game.stack:push(TextBox.new(Game, second, function()
|
||||
if onClose then onClose() end
|
||||
Game.stack:push(Transition.whiteFlash(Game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(Game.data, mon.species)
|
||||
end } }))
|
||||
return true
|
||||
end
|
||||
|
||||
-- The battle transition's dungeon wipe uses the explicit map lists in
|
||||
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
||||
-- inclusive map-id ranges -- faithful to the original's omissions
|
||||
@@ -2543,15 +2500,6 @@ function OverworldState:trySurf(fx, fy, onClose)
|
||||
end))
|
||||
end
|
||||
|
||||
function OverworldState:stopSurfing(onClose)
|
||||
if onClose then onClose() end
|
||||
self.player.surfing = false
|
||||
require("src.core.Music").setSurfing(Game.data, false)
|
||||
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
||||
self:stepForwardOrCrossEdge(self.player.facing)
|
||||
end))
|
||||
end
|
||||
|
||||
function OverworldState:tryCut(fx, fy)
|
||||
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
|
||||
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
local Party = require("src.pokemon.Party")
|
||||
@@ -17,8 +15,6 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
FACILITY = true, INTERIOR = true }
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
@@ -119,30 +115,6 @@ function WorldAPI:availableFieldActions()
|
||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||
end
|
||||
end
|
||||
|
||||
if ow:useCutFieldMove() == "ok" then
|
||||
out[#out + 1] = { id = "cut", label = "CUT" }
|
||||
end
|
||||
local surf = ow:useSurfFieldMove()
|
||||
if surf == "ok" or surf == "dismount" then
|
||||
out[#out + 1] = { id = "surf",
|
||||
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
|
||||
end
|
||||
|
||||
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
|
||||
out[#out + 1] = { id = "strength", label = "STRENGTH" }
|
||||
end
|
||||
if ow.dark and ow:partyKnows("FLASH") then
|
||||
out[#out + 1] = { id = "flash", label = "FLASH" }
|
||||
end
|
||||
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
|
||||
and ow:partyKnows("DIG") then
|
||||
out[#out + 1] = { id = "dig", label = "DIG" }
|
||||
end
|
||||
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(game.data, "outsideTilesets")) then
|
||||
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -158,19 +130,6 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
|
||||
if id == "bicycle" then
|
||||
if ow:useBicycle() then return true end
|
||||
elseif id == "cut" then
|
||||
local x, y = ow.player:facingCell()
|
||||
if ow:tryCut(x, y) then return true end
|
||||
elseif id == "surf" then
|
||||
local mode = ow:useSurfFieldMove()
|
||||
if mode == "dismount" then
|
||||
ow:stopSurfing()
|
||||
return true
|
||||
elseif mode == "ok" then
|
||||
local x, y = ow.player:facingCell()
|
||||
ow:trySurf(x, y)
|
||||
return true
|
||||
end
|
||||
elseif id == "fish" then
|
||||
local rod = opts and opts.rod
|
||||
if not rod and #found.rods == 1 then rod = found.rods[1].id end
|
||||
@@ -178,13 +137,6 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
if choice.id == rod and ow:useFishingRod(rod) then return true end
|
||||
end
|
||||
return nil, "fishing rod unavailable"
|
||||
elseif id == "strength" then
|
||||
if ow:useStrengthFieldMove() then return true end
|
||||
elseif id == "flash" then
|
||||
if ow:useFlashFieldMove() then return true end
|
||||
elseif id == "dig" or id == "teleport" then
|
||||
ow:beginTeleportOut()
|
||||
return true
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
-- Bake a Gen 2 map canvas without a World instance. The save editor's map
|
||||
-- tab uses this so Gold rooms draw the same tiles the overworld would,
|
||||
-- instead of the checkerboard fallback Map2 has no renderer for.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
|
||||
local MapPreview = {}
|
||||
|
||||
local ROOF_TILESETS = {
|
||||
TILESET_JOHTO = true,
|
||||
TILESET_JOHTO_MODERN = true,
|
||||
TILESET_KANTO = true,
|
||||
}
|
||||
|
||||
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
|
||||
local atlasData = love.image.newImageData(Assets.resolve(atlasPath))
|
||||
local roofData = love.image.newImageData(Assets.resolve(roofPath))
|
||||
for t = 0, 8 do
|
||||
local destId = 0x0a + t
|
||||
local dx = (destId % tilesPerRow) * 8
|
||||
local dy = math.floor(destId / tilesPerRow) * 8
|
||||
local sx = t * 8
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = roofData:getPixel(sx + x, y)
|
||||
atlasData:setPixel(dx + x, dy + y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
local image = love.graphics.newImage(atlasData)
|
||||
image:setFilter("nearest", "nearest")
|
||||
return image
|
||||
end
|
||||
|
||||
local function optionalTable(data, gen2Key, plainKey, generated)
|
||||
if data[gen2Key] then return data[gen2Key] end
|
||||
if data[plainKey] then return data[plainKey] end
|
||||
local ok, mod = pcall(require, "data.generated." .. generated)
|
||||
if ok and type(mod) == "table" then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
function MapPreview.baker(data)
|
||||
data = data or {}
|
||||
return {
|
||||
tilesets = data.gen2Tilesets or data.tilesets or {},
|
||||
-- Data:load does not pull roofs.lua; the Gold cache still has it.
|
||||
roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"),
|
||||
palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"),
|
||||
atlasCache = {},
|
||||
mapImages = {},
|
||||
}
|
||||
end
|
||||
|
||||
function MapPreview.atlasFor(baker, mapDef)
|
||||
if not (baker and mapDef) then return nil, nil end
|
||||
local tileset = baker.tilesets and baker.tilesets[mapDef.tileset]
|
||||
if not tileset then return nil, nil end
|
||||
local cacheKey = mapDef.tileset
|
||||
local roofName = nil
|
||||
local roofs = baker.roofs
|
||||
if ROOF_TILESETS[mapDef.tileset] then
|
||||
roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group]
|
||||
end
|
||||
if roofName then cacheKey = cacheKey .. "|" .. roofName end
|
||||
local cached = baker.atlasCache[cacheKey]
|
||||
if cached then return cached, tileset end
|
||||
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local atlas
|
||||
local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName]
|
||||
if roofSpec and roofSpec.image and love.image and love.image.newImageData then
|
||||
local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow)
|
||||
if ok then atlas = img end
|
||||
end
|
||||
if not atlas then
|
||||
if not tileset.image then return nil, tileset end
|
||||
local ok, img = pcall(Assets.image, tileset.image)
|
||||
if not ok then return nil, tileset end
|
||||
atlas = img
|
||||
if atlas.setFilter then atlas:setFilter("nearest", "nearest") end
|
||||
end
|
||||
baker.atlasCache[cacheKey] = atlas
|
||||
return atlas, tileset
|
||||
end
|
||||
|
||||
-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the
|
||||
-- World fields the overworld keeps for anim overlays and cave flicker.
|
||||
function MapPreview.bake(baker, map, daytime)
|
||||
local atlas, tileset = MapPreview.atlasFor(baker, map.def)
|
||||
if not atlas or not tileset then return nil end
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local blocks = tileset.blocks
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local pw, ph = map.width * 32, map.height * 32
|
||||
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
|
||||
if not okCanvas or not canvas then return nil end
|
||||
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
|
||||
local quads = {}
|
||||
local function quadFor(tile)
|
||||
local q = quads[tile]
|
||||
if q then return q end
|
||||
local sx = (tile % tilesPerRow) * 8
|
||||
local sy = math.floor(tile / tilesPerRow) * 8
|
||||
q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions())
|
||||
quads[tile] = q
|
||||
return q
|
||||
end
|
||||
|
||||
local tilePalettes = tileset.tilePalettes
|
||||
local bgSet = baker.palettes and daytime
|
||||
and Palettes.bgSet(baker.palettes, map.def, daytime) or nil
|
||||
local colored = bgSet and tilePalettes and GbcPalette.available()
|
||||
local clearColor = { 0.15, 0.55, 0.25 }
|
||||
if bgSet and bgSet[1] and bgSet[1][1] then
|
||||
local c = GbcPalette.color(bgSet[1], 1)
|
||||
clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 }
|
||||
end
|
||||
|
||||
local function drawTiles(slot)
|
||||
for by = 0, map.height - 1 do
|
||||
for bx = 0, map.width - 1 do
|
||||
local blockId = BorderFill.blockFor(
|
||||
map.blocks[by * map.width + bx + 1], map.borderBlock)
|
||||
local block = blocks and blocks[(blockId or 0) + 1]
|
||||
if block then
|
||||
for i = 0, 15 do
|
||||
local tile = block[i + 1] or 0
|
||||
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
|
||||
if not slot or tileSlot == slot then
|
||||
local tx = bx * 32 + (i % 4) * 8
|
||||
local ty = by * 32 + math.floor(i / 4) * 8
|
||||
love.graphics.draw(atlas, quadFor(tile), tx, ty)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function paint()
|
||||
love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.push()
|
||||
love.graphics.origin()
|
||||
if colored then
|
||||
for slot = 1, 8 do
|
||||
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
|
||||
end
|
||||
else
|
||||
drawTiles(nil)
|
||||
end
|
||||
love.graphics.pop()
|
||||
end
|
||||
if canvas.renderTo then
|
||||
canvas:renderTo(paint)
|
||||
else
|
||||
paint()
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
function MapPreview.imageFor(baker, map)
|
||||
if not (baker and map and map.id) then return nil end
|
||||
local cached = baker.mapImages[map.id]
|
||||
if cached then return cached end
|
||||
local img = MapPreview.bake(baker, map, "DAY")
|
||||
baker.mapImages[map.id] = img or false
|
||||
return img
|
||||
end
|
||||
|
||||
function MapPreview.renderer(baker, map)
|
||||
local img = MapPreview.imageFor(baker, map)
|
||||
if not img then return nil end
|
||||
return {
|
||||
draw = function(_, camX, camY)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0))
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
return MapPreview
|
||||
@@ -36,18 +36,6 @@ WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
local FIELD_ACTIONS = {
|
||||
{ id = "cut", move = "CUT" },
|
||||
{ id = "surf", move = "SURF" },
|
||||
{ id = "strength", move = "STRENGTH" },
|
||||
{ id = "flash", move = "FLASH" },
|
||||
{ id = "headbutt", move = "HEADBUTT" },
|
||||
{ id = "whirlpool", move = "WHIRLPOOL" },
|
||||
{ id = "waterfall", move = "WATERFALL" },
|
||||
{ id = "sweet_scent", move = "SWEET_SCENT" },
|
||||
{ id = "dig", move = "DIG" },
|
||||
{ id = "teleport", move = "TELEPORT" },
|
||||
}
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
@@ -108,26 +96,6 @@ function WorldAPI:availableFieldActions()
|
||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||
end
|
||||
end
|
||||
|
||||
for _, row in ipairs(FIELD_ACTIONS) do
|
||||
if not (row.move == "STRENGTH" and world.strengthActive) then
|
||||
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
|
||||
if mon then
|
||||
context.mon = mon
|
||||
local result = FieldMoves.fromMenu(row.move, context)
|
||||
if result.ok then
|
||||
out[#out + 1] = { id = row.id,
|
||||
label = row.move:gsub("_", " ") }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if (inventory.SQUIRTBOTTLE or 0) > 0
|
||||
and world:squirtbottleTreeScript() then
|
||||
out[#out + 1] = { id = "squirtbottle",
|
||||
label = itemLabel(game, "SQUIRTBOTTLE") }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -155,18 +123,6 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
end
|
||||
end
|
||||
return nil, "fishing rod unavailable"
|
||||
elseif id == "squirtbottle" then
|
||||
local outcome = world:useFieldItem("SQUIRTBOTTLE")
|
||||
if outcome and outcome ~= "nowhere" then return true end
|
||||
end
|
||||
for _, row in ipairs(FIELD_ACTIONS) do
|
||||
if row.id == id then
|
||||
local context = world:fieldContext()
|
||||
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
|
||||
local result = mon and world:useFieldMove(row.move, mon)
|
||||
if result and result.ok then return true end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
-- Mods "Show for" chips collapse to a dropdown when they cannot all fit.
|
||||
-- Landscape / desktop keep the individual game chips. No pokered cite: the
|
||||
-- launcher is port-only chrome.
|
||||
-- luajit tests/engine/launcher_mod_scope_dropdown.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
local function navIds()
|
||||
local ids = {}
|
||||
for i = 1, (Kit._navPrevN or 0) do
|
||||
local slot = Kit._nav[i]
|
||||
if slot and slot.id then ids[slot.id] = true end
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
local function drawMods(W, H)
|
||||
window(W, H)
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.tab = "mods"
|
||||
imp.ready = { red = true, blue = true, yellow = true, gold = true }
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err)))
|
||||
return navIds()
|
||||
end
|
||||
|
||||
local portrait = drawMods(360, 780)
|
||||
check(portrait["mod-scope-menu"] == true,
|
||||
"portrait: Show-for collapses to a dropdown when the chips cannot all fit")
|
||||
check(portrait["mod-scope-gold"] == nil
|
||||
and portrait["mod-scope-red"] == nil
|
||||
and portrait["mod-scope-blue"] == nil
|
||||
and portrait["mod-scope-yellow"] == nil,
|
||||
"portrait: individual version chips are not drawn beside the dropdown")
|
||||
|
||||
local desktop = drawMods(1280, 720)
|
||||
check(desktop["mod-scope-menu"] == nil,
|
||||
"desktop: chips fit, so there is no dropdown")
|
||||
check(desktop["mod-scope-all"] == true
|
||||
and desktop["mod-scope-red"] == true
|
||||
and desktop["mod-scope-blue"] == true
|
||||
and desktop["mod-scope-yellow"] == true
|
||||
and desktop["mod-scope-gold"] == true,
|
||||
"desktop: every Show-for chip stays reachable")
|
||||
|
||||
print("ok launcher mod scope dropdown")
|
||||
@@ -23,7 +23,7 @@ local function importer(field)
|
||||
end
|
||||
|
||||
local modalFields = {
|
||||
"_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails",
|
||||
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
|
||||
}
|
||||
|
||||
for _, field in ipairs(modalFields) do
|
||||
@@ -77,12 +77,6 @@ do
|
||||
imp:keypressed("escape")
|
||||
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_appPatchNotes")
|
||||
imp:keypressed("escape")
|
||||
eq(imp._appPatchNotes, nil, "Escape closes patch notes")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modVersions")
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
-- Launcher footer Patch notes control. The GitHub release body is already
|
||||
-- fetched by the updater; this is the in-app viewer for it.
|
||||
-- luajit tests/engine/launcher_patch_notes.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
local PatchNotes = require("src.update.PatchNotes")
|
||||
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
local function freshLauncher()
|
||||
return RomImporter.new(function() end, { launcher = true })
|
||||
end
|
||||
|
||||
local realPrint = love.graphics.print
|
||||
local function drawAndCapture(imp)
|
||||
local seen = {}
|
||||
love.graphics.print = function(str, ...)
|
||||
seen[#seen + 1] = tostring(str)
|
||||
return realPrint(str, ...)
|
||||
end
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
love.graphics.print = realPrint
|
||||
check(ok, "the frame draws: " .. tostring(err))
|
||||
return table.concat(seen, "\n")
|
||||
end
|
||||
|
||||
window(1280, 720)
|
||||
local imp = freshLauncher()
|
||||
local text = drawAndCapture(imp)
|
||||
check(text:find("Patch notes", 1, true) ~= nil,
|
||||
"desktop footer prints Patch notes")
|
||||
|
||||
window(360, 780)
|
||||
local phone = freshLauncher()
|
||||
check(drawAndCapture(phone):find("Patch notes", 1, true) ~= nil,
|
||||
"portrait footer still prints Patch notes")
|
||||
|
||||
do
|
||||
local body, ver = PatchNotes.body({
|
||||
state = function()
|
||||
return { notes = "## Issues closed\n\n- #12 cart padding", latest = "1.4.2" }
|
||||
end,
|
||||
})
|
||||
eq(body, "## Issues closed\n\n- #12 cart padding",
|
||||
"PatchNotes prefers the updater's GitHub body")
|
||||
eq(ver, "1.4.2", "PatchNotes carries the release version")
|
||||
end
|
||||
|
||||
do
|
||||
local body, ver = PatchNotes.body(nil)
|
||||
check(type(body) == "string" and (body:find("Download", 1, true) or body:find("Issues", 1, true)),
|
||||
"without a check result PatchNotes uses the stashed iOS app-repo notes")
|
||||
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
|
||||
"stashed notes name a release version")
|
||||
end
|
||||
|
||||
do
|
||||
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
|
||||
local list = PatchNotes.parseRepo(f:read("*a"))
|
||||
f:close()
|
||||
check(#list >= 2, "app-repo.json stashes more than one release")
|
||||
local notes, ver = PatchNotes.fromRepo(list[2].version)
|
||||
eq(ver, list[2].version, "fromRepo can pick a specific stashed version")
|
||||
eq(notes, list[2].notes, "fromRepo returns that version's notes")
|
||||
end
|
||||
|
||||
imp._appPatchNotes = true
|
||||
local modal = drawAndCapture(imp)
|
||||
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
|
||||
check(modal:find("Close", 1, true) ~= nil, "the modal can be closed")
|
||||
|
||||
imp:keypressed("escape")
|
||||
eq(imp._appPatchNotes, nil, "Escape dismisses patch notes")
|
||||
|
||||
T.finish("launcher patch notes")
|
||||
@@ -32,15 +32,6 @@ eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version"
|
||||
eq(rel.payload.url, "http://x/love", "payload asset url picked")
|
||||
eq(rel.payload.size, 12345, "payload asset size picked")
|
||||
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
|
||||
eq(rel.notes, "", "missing release body becomes empty notes")
|
||||
|
||||
local withNotes = Check.parseRelease(Json.encode({
|
||||
tag_name = "v1.4.2",
|
||||
body = "## Issues closed\n\n- #1 cart padding",
|
||||
assets = {},
|
||||
}))
|
||||
eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding",
|
||||
"parseRelease keeps the GitHub release body")
|
||||
|
||||
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
|
||||
-- so the worker will route to needs_full rather than an in-place update
|
||||
|
||||
@@ -287,7 +287,6 @@ eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
|
||||
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
|
||||
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
|
||||
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
|
||||
eq(rel.notes, "", "parseRelease treats a missing body as empty notes")
|
||||
|
||||
-- a release with no .love yet still parses; payload/sums are nil so the worker
|
||||
-- routes to a full reinstall rather than an in-place update
|
||||
|
||||
@@ -138,15 +138,6 @@ function FsIo.new(rootDir)
|
||||
return loadfile(abs(path))
|
||||
end
|
||||
|
||||
function fs.createDirectory(path)
|
||||
os.execute(("mkdir -p %q"):format(abs(path)))
|
||||
return true
|
||||
end
|
||||
|
||||
function fs.remove(path)
|
||||
return os.remove(abs(path)) ~= nil
|
||||
end
|
||||
|
||||
function fs.getDirectoryItems(path)
|
||||
return FsIo.listDir(abs(path))
|
||||
end
|
||||
|
||||
@@ -17,7 +17,6 @@ local ARCHIVE = {
|
||||
local files, dirs, arch = {}, {}, {}
|
||||
local fileDataMounts, pathMounts, stagedTemps = 0, 0, {}
|
||||
local stagedEver = false
|
||||
local failWriteOnce
|
||||
|
||||
local function resetFs()
|
||||
for k in pairs(files) do files[k] = nil end
|
||||
@@ -26,7 +25,6 @@ local function resetFs()
|
||||
fileDataMounts, pathMounts = 0, 0
|
||||
stagedTemps = {}
|
||||
stagedEver = false
|
||||
failWriteOnce = nil
|
||||
end
|
||||
|
||||
local function dirChild(key, name)
|
||||
@@ -47,10 +45,6 @@ end
|
||||
local vfs = {}
|
||||
|
||||
function vfs.write(name, data)
|
||||
if failWriteOnce == name then
|
||||
failWriteOnce = nil
|
||||
return nil, "simulated write failure"
|
||||
end
|
||||
files[name] = data
|
||||
if name:match("^mod_import_") then
|
||||
stagedTemps[name] = true
|
||||
@@ -177,16 +171,6 @@ eq(staged, 0, "FileData path leaves no staged temp zip")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"install wrote manifest into mods/")
|
||||
|
||||
-- A third-party archive cannot bypass modkit's no-baseroms packaging gate.
|
||||
resetFs()
|
||||
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = "packaged rom"
|
||||
files["imports/mods/packaged-rom.zip"] = "PK\3\4packaged-rom"
|
||||
ok, err = LauncherMods.installZip("imports/mods/packaged-rom.zip")
|
||||
check(not ok, "an archive containing baseroms is rejected")
|
||||
check(tostring(err):find("must not include", 1, true),
|
||||
"baseroms archive rejection explains the policy")
|
||||
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = nil
|
||||
|
||||
-- Fallback: no newFileData → stage temp + path mount
|
||||
resetFs()
|
||||
vfs.newFileData = nil
|
||||
@@ -221,50 +205,6 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"replace still lands in mods/<id>")
|
||||
|
||||
-- User-selected baseroms belong to the installation, not the downloaded mod
|
||||
-- archive, and survive the same replacement path.
|
||||
resetFs()
|
||||
files["mods/OldFolder/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files["mods/OldFolder/main.lua"] = "return function() end\n"
|
||||
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
|
||||
files["imports/mods/update-with-rom.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/update-with-rom.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace with a baserom succeeds (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"replace preserves user-owned baseroms under the canonical mod folder")
|
||||
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
|
||||
"the shadow mod tree is still removed after preservation")
|
||||
|
||||
-- A preservation write failure keeps recovery bytes outside mods/, where
|
||||
-- discovery cannot mistake a baseroms-only directory for an installed mod.
|
||||
resetFs()
|
||||
files["mods/OldFolder/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files["mods/OldFolder/main.lua"] = "return function() end\n"
|
||||
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
|
||||
files["imports/mods/preserve-fail.zip"] = "PK\3\4update"
|
||||
failWriteOnce = "mods/" .. MOD_ID .. "/baseroms/stadium2.z64"
|
||||
ok, err = LauncherMods.installZip("imports/mods/preserve-fail.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(not ok, "preservation failure rejects the update")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
|
||||
"preservation failure leaves no manifest-less tree under mods")
|
||||
eq(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"],
|
||||
"user-owned-rom", "preservation failure stages recovery outside mods")
|
||||
|
||||
files["imports/mods/preserve-retry.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/preserve-retry.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "retry restores staged baseroms (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"retry restores the recovered baserom into the installed mod")
|
||||
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil,
|
||||
"successful retry clears baserom recovery debris")
|
||||
|
||||
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
|
||||
-- block a plain re-import as "already installed"
|
||||
resetFs()
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local S = require("tests.harness").suite("required mod imports")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local DIGEST = "0123456789abcdef0123456789abcdef"
|
||||
local function fakeHash(data)
|
||||
return data:sub(1, 4) == "\128\55\18\64" and DIGEST
|
||||
or "ffffffffffffffffffffffffffffffff"
|
||||
end
|
||||
|
||||
local manifest = Manifest.validate({
|
||||
id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "stadium2", name = "Stadium 2", file = "stadium2.z64",
|
||||
description = "USA dump", format = "n64", size = 8,
|
||||
md5 = { DIGEST, DIGEST:upper() } },
|
||||
},
|
||||
}, "mods/stadium_fx")
|
||||
|
||||
eq(#manifest.required_imports, 1, "required import parses")
|
||||
eq(#manifest.required_imports[1].md5, 1, "accepted MD5 values normalize and dedupe")
|
||||
eq(manifest.required_imports[1].md5[1], DIGEST, "MD5 is lowercase")
|
||||
eq(manifest.required_imports[1].description, "USA dump",
|
||||
"import description is preserved for the picker UI")
|
||||
eq(manifest.required_imports[1].size, 8, "exact import size parses")
|
||||
|
||||
local optionalManifest = Manifest.validate({
|
||||
id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua",
|
||||
optional_imports = {
|
||||
{ id = "bonus", name = "Bonus ROM", file = "bonus.z64",
|
||||
format = "n64", md5 = DIGEST },
|
||||
},
|
||||
}, "mods/optional_fx")
|
||||
eq(#optionalManifest.optional_imports, 1, "optional import parses")
|
||||
eq(optionalManifest.optional_imports[1].required, false,
|
||||
"optional import is marked non-blocking")
|
||||
local optionalRows, optionalMissing, missingOptional =
|
||||
RequiredImports.inspect(optionalManifest, love.filesystem, fakeHash)
|
||||
eq(optionalMissing, 0, "missing optional import is not required")
|
||||
eq(missingOptional, 1, "missing optional import is reported separately")
|
||||
check(not optionalRows[1].required, "optional row is labeled optional")
|
||||
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = "../outside.z64", md5 = DIGEST } },
|
||||
}), "required import cannot escape baseroms")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } },
|
||||
}), "malformed MD5 is refused")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = ".rom.removed", md5 = DIGEST } },
|
||||
}), "hidden import filenames cannot collide with engine metadata")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = "rom.bin", md5 = DIGEST,
|
||||
max_size = RequiredImports.MAX_BYTES + 1 } },
|
||||
}), "manifest import sizes cannot exceed the hard limit")
|
||||
|
||||
local canonical = "\128\55\18\64ABCD"
|
||||
local v64 = "\55\128\64\18BADC"
|
||||
local n64 = "\64\18\55\128DCBA"
|
||||
check(RequiredImports.importData(optionalManifest, "bonus", canonical,
|
||||
{ hash = fakeHash }), "optional import uses the normal validation path")
|
||||
eq(RequiredImports.normalizeN64(canonical), canonical, "z64 stays canonical")
|
||||
eq(RequiredImports.normalizeN64(v64), canonical, "v64 pair swap canonicalizes")
|
||||
eq(RequiredImports.normalizeN64(n64), canonical, "n64 word swap canonicalizes")
|
||||
eq(RequiredImports.normalizeN64(string.rep("H", 512) .. v64), canonical,
|
||||
"recognized 512-byte copier header is stripped")
|
||||
check(RequiredImports.normalizeN64(string.rep("H", 520)) == nil,
|
||||
"an arbitrary 512-byte prefix is not treated as a copier header")
|
||||
|
||||
local ok, digest = RequiredImports.importData(manifest, "stadium2", v64,
|
||||
{ hash = fakeHash })
|
||||
check(ok, "validated bytes import")
|
||||
eq(digest, DIGEST, "import reports canonical digest")
|
||||
eq(love.filesystem.read("mods/stadium_fx/baseroms/stadium2.z64"), canonical,
|
||||
"import writes canonical bytes inside the mod")
|
||||
local rows, missing = RequiredImports.inspect(manifest, love.filesystem, fakeHash)
|
||||
eq(missing, 0, "written import satisfies its declaration")
|
||||
check(rows[1].present, "inspection reports ready")
|
||||
|
||||
local target = Manifest.validate({
|
||||
id = "other_fx", name = "Other FX", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST },
|
||||
},
|
||||
}, "mods/other_fx")
|
||||
local targetRows, targetMissing = RequiredImports.inspect(target,
|
||||
love.filesystem, fakeHash)
|
||||
eq(targetMissing, 1, "matching hashes do not silently share another mod's import")
|
||||
check(not targetRows[1].present,
|
||||
"a mod needs its own explicit player-selected file")
|
||||
check(RequiredImports.remove(target, "same_rom"), "a required import can be removed")
|
||||
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil,
|
||||
"remove deletes this mod's private copy")
|
||||
check(RequiredImports.importData(target, "same_rom", canonical, { hash = fakeHash }),
|
||||
"choosing the file again clears the removal decision")
|
||||
|
||||
local legacy = Manifest.validate({
|
||||
id = "legacy", name = "Legacy", version = "1.0.0", entry = "main.lua",
|
||||
}, "mods/legacy")
|
||||
local legacyTarget = Manifest.validate({
|
||||
id = "legacy_user", name = "Legacy User", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "rom", file = "legacy-source.z64", format = "n64", md5 = DIGEST },
|
||||
},
|
||||
}, "mods/legacy_user")
|
||||
love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64)
|
||||
local legacyRows, legacyMissing = RequiredImports.inspect(legacyTarget,
|
||||
love.filesystem, fakeHash)
|
||||
eq(legacyMissing, 1, "undeclared files in another mod are never indexed")
|
||||
check(not legacyRows[1].present, "legacy baseroms remain private to their mod")
|
||||
|
||||
local capped = Manifest.validate({
|
||||
id = "capped", name = "Capped", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "small", file = "small.bin", md5 = DIGEST, max_size = 4 },
|
||||
},
|
||||
}, "mods/capped")
|
||||
local tooLarge, sizeWhy = RequiredImports.validateData(
|
||||
capped.required_imports[1], "12345", fakeHash)
|
||||
eq(tooLarge, nil, "per-import size cap rejects before hashing")
|
||||
check(tostring(sizeWhy):find("too large", 1, true) ~= nil,
|
||||
"size rejection explains the limit")
|
||||
|
||||
-- A successful validation writes an engine receipt. Matching size + modtime
|
||||
-- lets later launcher refreshes avoid reading and hashing the ROM again.
|
||||
local cacheFiles = {
|
||||
["mods/cache/baseroms/source.z64"] = canonical,
|
||||
}
|
||||
local dataReads = 0
|
||||
local cacheModtime = 123
|
||||
local cacheFs = {
|
||||
getInfo = function(path, kind)
|
||||
local data = cacheFiles[path]
|
||||
if data then return { type = "file", size = #data, modtime = cacheModtime } end
|
||||
return nil
|
||||
end,
|
||||
read = function(path)
|
||||
if path == "mods/cache/baseroms/source.z64" then dataReads = dataReads + 1 end
|
||||
return cacheFiles[path]
|
||||
end,
|
||||
write = function(path, data) cacheFiles[path] = data return true end,
|
||||
remove = function(path) cacheFiles[path] = nil return true end,
|
||||
}
|
||||
local cacheManifest = Manifest.validate({
|
||||
id = "cache", name = "Cache", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "source", file = "source.z64", format = "n64", size = 8,
|
||||
md5 = DIGEST },
|
||||
},
|
||||
}, "mods/cache")
|
||||
local cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
|
||||
check(cacheRows[1].present, "initial cached import validation succeeds")
|
||||
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, function()
|
||||
error("unchanged cached import should not be hashed again")
|
||||
end)
|
||||
check(cacheRows[1].present, "validation receipt satisfies the next refresh")
|
||||
eq(dataReads, 1, "unchanged imported ROM is read only once")
|
||||
cacheFiles["mods/cache/baseroms/source.z64"] = "BADBYTES"
|
||||
cacheModtime = 124
|
||||
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
|
||||
check(not cacheRows[1].present, "changed imported ROM bypasses a stale receipt")
|
||||
eq(dataReads, 2, "changed imported ROM is read again")
|
||||
eq(cacheFiles[RequiredImports.receiptPath(cacheManifest, cacheManifest.required_imports[1])],
|
||||
nil, "stale validation receipt is removed")
|
||||
|
||||
local rejected, why = RequiredImports.importData(target, "same_rom", "wrong",
|
||||
{ hash = fakeHash })
|
||||
eq(rejected, nil, "mismatched selection is rejected")
|
||||
check(tostring(why):find("N64 ROM (.z64/.v64/.n64)", 1, true) ~= nil,
|
||||
"normalization failure explains the selected format")
|
||||
|
||||
love.filesystem.write("mods/launcher_needs/manifest.json", ([[{
|
||||
"id":"launcher_needs","name":"Launcher Needs","version":"1.0.0",
|
||||
"entry":"main.lua","required_imports":[{"id":"source","name":"Source ROM",
|
||||
"file":"source.bin","md5":"%s"}]
|
||||
}]]):format(DIGEST))
|
||||
love.filesystem.write("mods/launcher_needs/main.lua", "return function(mod) end")
|
||||
local launcherRows = require("src.mods.LauncherMods").list()
|
||||
eq(#launcherRows, 1, "launcher keeps a mod with a missing required import visible")
|
||||
eq(launcherRows[1].missingRequiredImports, 1,
|
||||
"launcher row carries the missing import count")
|
||||
eq(launcherRows[1].status, "needs_import",
|
||||
"missing import changes Ready to Import required")
|
||||
check(launcherRows[1].statusDetail:find("Source ROM", 1, true) ~= nil,
|
||||
"launcher warning names the missing file")
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
if path == "mods" then
|
||||
local out, seen = {}, {}
|
||||
for key in pairs(files) do
|
||||
local id = key:match("^mods/([^/]+)/manifest%.json$")
|
||||
if id and not seen[id] then seen[id] = true; out[#out + 1] = id end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
return {}
|
||||
end,
|
||||
load = function(path)
|
||||
local source = files[path]
|
||||
if not source then return nil, "missing" end
|
||||
return load(source, path)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local manifestJson = ([[{
|
||||
"id":"needs_rom","name":"Needs ROM","version":"1.0.0","entry":"main.lua",
|
||||
"required_imports":[{"id":"rom","name":"Source ROM","file":"source.bin",
|
||||
"md5":"%s"}]
|
||||
}]]):format(DIGEST)
|
||||
local loader = Loader.new({ fs = memfs({
|
||||
["mods/needs_rom/manifest.json"] = manifestJson,
|
||||
["mods/needs_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
|
||||
}) })
|
||||
check(loader:load({}) == false, "missing required import blocks the enabled mod")
|
||||
local status = loader:status().available[1]
|
||||
eq(status.state, "invalid", "blocked mod reports invalid")
|
||||
check(status.error:find("Source ROM", 1, true) ~= nil,
|
||||
"loader failure names the required import")
|
||||
check(not (loader.exports.needs_rom and loader.exports.needs_rom.ran),
|
||||
"blocked mod entry never executes")
|
||||
|
||||
local optionalJson = ([[{
|
||||
"id":"optional_rom","name":"Optional ROM","version":"1.0.0","entry":"main.lua",
|
||||
"optional_imports":[{"id":"rom","name":"Bonus ROM","file":"bonus.bin",
|
||||
"md5":"%s"}]
|
||||
}]]):format(DIGEST)
|
||||
local optionalLoader = Loader.new({ fs = memfs({
|
||||
["mods/optional_rom/manifest.json"] = optionalJson,
|
||||
["mods/optional_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
|
||||
}) })
|
||||
check(optionalLoader:load({}), "missing optional import does not block the mod")
|
||||
check(optionalLoader.exports.optional_rom.ran,
|
||||
"mod entry executes without its optional import")
|
||||
|
||||
S.finish()
|
||||
+55
-204
@@ -1,8 +1,7 @@
|
||||
-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it
|
||||
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path
|
||||
-- outside their own directory: the pre-sandbox globals are back as stand-ins
|
||||
-- whose reads come from the mod's own files and whose writes land in a private
|
||||
-- per-mod overlay. Every case here is an escape a mod would actually try.
|
||||
-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
|
||||
-- an environment with no io, no os beyond the clock, and no way to name a path
|
||||
-- outside its own directory, so a mod cannot reach the player's filesystem.
|
||||
-- Every case here is an escape a mod would actually try.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
@@ -10,7 +9,6 @@ local T = require("tests.modkit")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||
@@ -22,14 +20,16 @@ end
|
||||
local PROBE = [[
|
||||
local mod = ...
|
||||
local out = mod.exports
|
||||
out.io = type(io)
|
||||
out.package = type(package)
|
||||
out.dofile = type(dofile)
|
||||
out.loadfile = type(loadfile)
|
||||
out.io = io
|
||||
out.package = package
|
||||
out.dofile = dofile
|
||||
out.loadfile = loadfile
|
||||
out.setfenv = setfenv
|
||||
out.getfenv = getfenv
|
||||
out.debug = debug
|
||||
out.osGetenv = type(os.getenv)
|
||||
out.osGetenv = os.getenv
|
||||
out.osExecute = os.execute
|
||||
out.osRemove = os.remove
|
||||
out.osTime = type(os.time)
|
||||
out.stringOk = ("a"):rep(3)
|
||||
|
||||
@@ -38,86 +38,30 @@ local PROBE = [[
|
||||
if ok then return false end
|
||||
return tostring(err)
|
||||
end
|
||||
out.requireIoIsShim = select(2, pcall(require, "io")) == io
|
||||
out.requireLoveFsIsShim =
|
||||
select(2, pcall(require, "love.filesystem")) == love.filesystem
|
||||
out.requireIo = attempt(require, "io")
|
||||
out.requireOs = attempt(require, "os")
|
||||
out.requireDebug = attempt(require, "debug")
|
||||
out.requirePackage = attempt(require, "package")
|
||||
out.requireFfi = attempt(require, "ffi")
|
||||
out.requireLoveFs = attempt(require, "love.filesystem")
|
||||
out.requireSocket = attempt(require, "socket")
|
||||
-- called from a nested Lua frame rather than straight off pcall, which is
|
||||
-- the shape a stack-walking gate reads differently
|
||||
out.requireIoNested = attempt(function() return require("io") end)
|
||||
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
|
||||
|
||||
out.loveFilesystem = attempt(function() return love.filesystem end)
|
||||
out.loveThread = attempt(function() return love.thread end)
|
||||
out.loveSystem = attempt(function() return love.system end)
|
||||
out.loveGraphics = type(love.graphics)
|
||||
out.loveAssign = attempt(function() love.filesystem = {} end)
|
||||
-- the callback chain a mod wrapping the mouse writes: it has to reach the
|
||||
-- real love table or the wrap silently never fires
|
||||
out.chainedInner = false
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(...)
|
||||
out.chainedInner = true
|
||||
if inner then return inner(...) end
|
||||
end
|
||||
out.assignRun = attempt(function() love.run = function() end end)
|
||||
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
|
||||
out.powerInfo = type(love.system.getPowerInfo)
|
||||
out.openUrl = love.system.openURL("https://example.com")
|
||||
out.eventQuit = love.event.quit()
|
||||
out.popen = select(1, io.popen("ls"))
|
||||
|
||||
-- the reroute: a write anywhere a mod used to name must land in the mod's
|
||||
-- own overlay, and reading it back must see the write and nothing else
|
||||
local escape = io.open("/etc/hosts", "w")
|
||||
out.escapeOpened = escape ~= nil
|
||||
if escape then
|
||||
escape:write("pwned")
|
||||
escape:close()
|
||||
end
|
||||
local reread = io.open("/etc/hosts", "r")
|
||||
out.escapeReadBack = reread and reread:read("*a") or nil
|
||||
if reread then reread:close() end
|
||||
|
||||
out.homeEnv = os.getenv("HOME")
|
||||
out.saveDir = love.filesystem.getSaveDirectory()
|
||||
|
||||
love.filesystem.write("cfg/settings.txt", "x=1\ny=2\n")
|
||||
out.roundTrip = love.filesystem.read("cfg/settings.txt")
|
||||
out.roundTripInfo = love.filesystem.getInfo("cfg/settings.txt")
|
||||
local lines = {}
|
||||
for line in love.filesystem.lines("cfg/settings.txt") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
out.roundTripLines = lines
|
||||
love.filesystem.append("cfg/settings.txt", "z=3\n")
|
||||
out.appended = love.filesystem.read("cfg/settings.txt")
|
||||
|
||||
-- an absolute path built off the reported save directory comes back to the
|
||||
-- same overlay, which is what a legacy mod's own path joining does
|
||||
love.filesystem.write(out.saveDir .. "/cfg/settings.txt", "rooted")
|
||||
out.rootedRead = love.filesystem.read("cfg/settings.txt")
|
||||
|
||||
-- the mod's own packaged files still read through the old call
|
||||
out.ownThroughLove = love.filesystem.read("mods/fix_sandbox/data/note.txt")
|
||||
out.ownThroughIo = (function()
|
||||
local f = io.open("data/note.txt", "r")
|
||||
if not f then return nil end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
return body
|
||||
end)()
|
||||
|
||||
-- copy-on-write: writing over a packaged path shadows it, it does not
|
||||
-- rewrite the shipped file
|
||||
love.filesystem.write("mods/fix_sandbox/data/note.txt", "shadowed")
|
||||
out.shadowed = love.filesystem.read("mods/fix_sandbox/data/note.txt")
|
||||
out.shadowedOwn = mod:read("data/note.txt")
|
||||
|
||||
-- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
|
||||
-- mod's own source must inherit the sandbox, not the real globals
|
||||
local child = load("return io, os.getenv, _G")
|
||||
local childIo, childGetenv, childG = child()
|
||||
out.childIoIsShim = childIo == io
|
||||
out.childGetenvIsShim = childGetenv == os.getenv
|
||||
out.childIo = childIo
|
||||
out.childGetenv = childGetenv
|
||||
out.childSharesEnv = childG == _G
|
||||
|
||||
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
|
||||
@@ -151,121 +95,58 @@ local FILES = {
|
||||
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
|
||||
}
|
||||
|
||||
LegacyCompat.reset()
|
||||
local savedMouseMoved = love.mousemoved
|
||||
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
||||
local installedMouseMoved = love.mousemoved
|
||||
love.mousemoved = savedMouseMoved
|
||||
T.eq(#run.errors, 0,
|
||||
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local out = run.loader.exports.fix_sandbox or {}
|
||||
|
||||
-- ------- the pre-sandbox globals are stand-ins, not the real thing
|
||||
-- ------- the standard library a mod does not get
|
||||
|
||||
T.eq(out.io, "table", "io is present again, as the compat stand-in")
|
||||
T.eq(out.package, "table", "so is package, as an inert stub")
|
||||
T.eq(out.dofile, "function", "dofile routes through the stand-in")
|
||||
T.eq(out.loadfile, "function", "so does loadfile")
|
||||
T.eq(out.osGetenv, "function", "os.getenv answers rather than crashing the mod")
|
||||
T.eq(out.osTime, "function", "os.time still works: the clock was never the hole")
|
||||
T.eq(out.io, nil, "io is absent from the mod environment")
|
||||
T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
|
||||
T.eq(out.dofile, nil, "dofile is absent")
|
||||
T.eq(out.loadfile, nil, "loadfile is absent")
|
||||
T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
|
||||
T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out")
|
||||
T.eq(out.debug, nil, "the debug library is absent")
|
||||
T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit "
|
||||
.. "found the user's home directory")
|
||||
T.eq(out.osExecute, nil, "os.execute is absent")
|
||||
T.eq(out.osRemove, nil, "os.remove is absent")
|
||||
T.eq(out.osTime, "function", "os.time still works: the clock is not the hole")
|
||||
T.eq(out.stringOk, "aaa", "the safe standard library is intact")
|
||||
|
||||
-- what stays gone: there is no rerouted stand-in for these, so faking one
|
||||
-- would be the hole rather than a compat shim
|
||||
T.eq(out.setfenv, nil, "setfenv is still absent, so a mod cannot swap its own env")
|
||||
T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _G out")
|
||||
T.eq(out.debug, nil, "the debug library is still absent")
|
||||
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
|
||||
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
|
||||
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
|
||||
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
|
||||
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
|
||||
T.eq(out.openUrl, false, "love.system.openURL does nothing")
|
||||
T.eq(out.eventQuit, false, "love.event.quit cannot close the game on the player")
|
||||
-- ------- require, the one call that would undo all of the above
|
||||
|
||||
-- ------- require answers with the same stand-ins
|
||||
|
||||
T.check(out.requireIoIsShim, "require(\"io\") hands back the same compat table")
|
||||
T.check(out.requireLoveFsIsShim,
|
||||
"require(\"love.filesystem\") hands back the same compat table")
|
||||
T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true),
|
||||
"require(\"io\") is refused: " .. tostring(out.requireIo))
|
||||
T.check(out.requireOs ~= false, "require(\"os\") is refused")
|
||||
T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
|
||||
T.check(out.requirePackage ~= false, "require(\"package\") is refused")
|
||||
T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C")
|
||||
T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused")
|
||||
T.check(out.requireIoNested ~= false,
|
||||
"require(\"io\") from a nested frame is refused the same way")
|
||||
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
|
||||
"a network module still names the permission it needs: " .. tostring(out.requireSocket))
|
||||
"a network module names the permission it needs: " .. tostring(out.requireSocket))
|
||||
T.eq(type(out.requireSemver), "table",
|
||||
"the supported engine requires still resolve")
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true),
|
||||
"love.filesystem is refused and names the replacement")
|
||||
T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state")
|
||||
T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true),
|
||||
"love.system is refused and names the scoped power replacement")
|
||||
T.eq(out.loveGraphics, "table", "the rest of love passes through")
|
||||
T.check(out.loveAssign ~= false,
|
||||
"a mod cannot replace a love module table: " .. tostring(out.loveAssign))
|
||||
T.eq(out.powerInfo, "function",
|
||||
"love.system reads through to the same information mod.device exposes")
|
||||
|
||||
-- a wrapped callback has to land on the real table or the wrap never fires
|
||||
T.eq(type(installedMouseMoved), "function",
|
||||
"love.mousemoved assigned by a mod reaches the real love table")
|
||||
T.check(installedMouseMoved ~= savedMouseMoved,
|
||||
"and it is the mod's wrapper, not the one that was there")
|
||||
T.check(out.assignRun and out.assignRun:find("fixed-step loop", 1, true),
|
||||
"love.run stays refused: it is the engine's own loop ("
|
||||
.. tostring(out.assignRun) .. ")")
|
||||
T.check(out.assignGarbage ~= false,
|
||||
"and a callback slot only takes a function")
|
||||
|
||||
-- ------- containment: every rerouted write lands in the mod's own overlay
|
||||
|
||||
T.check(out.escapeOpened, "io.open on an absolute path outside the tree opens")
|
||||
T.eq(out.escapeReadBack, "pwned", "and reads its own write back")
|
||||
T.eq(FILES["/etc/hosts"], nil,
|
||||
"but nothing was written outside the game tree")
|
||||
T.eq(FILES["mod_compat/fix_sandbox/etc/hosts"], "pwned",
|
||||
"the bytes went to this mod's private overlay instead")
|
||||
T.eq(out.homeEnv, "/pokeport/fix_sandbox",
|
||||
"os.getenv(\"HOME\") answers with the mod's virtual root, not the real one")
|
||||
T.eq(out.saveDir, "/pokeport/fix_sandbox",
|
||||
"and so does the reported save directory")
|
||||
|
||||
T.eq(out.roundTrip, "x=1\ny=2\n", "a love.filesystem write reads back")
|
||||
T.eq(out.roundTripInfo and out.roundTripInfo.type, "file",
|
||||
"and getInfo sees it")
|
||||
T.same(out.roundTripLines, { "x=1", "y=2" }, "lines() walks it")
|
||||
T.eq(out.appended, "x=1\ny=2\nz=3\n", "append extends it")
|
||||
T.eq(out.rootedRead, "rooted",
|
||||
"a path joined to the reported save directory routes to the same key")
|
||||
T.eq(FILES["mod_compat/fix_sandbox/cfg/settings.txt"], "rooted",
|
||||
"one key in the overlay, under the mod's own id, however it was named")
|
||||
|
||||
-- ------- reads still see the mod's packaged files
|
||||
|
||||
T.eq(out.ownThroughLove, "own file",
|
||||
"love.filesystem.read of a path inside the mod reads the shipped file")
|
||||
T.eq(out.ownThroughIo, "own file", "and so does io.open on a relative path")
|
||||
T.eq(out.shadowed, "shadowed", "a write over a packaged path shadows it")
|
||||
T.eq(FILES["mods/fix_sandbox/data/note.txt"], "own file",
|
||||
"without rewriting what the mod shipped")
|
||||
T.eq(out.shadowedOwn, "own file",
|
||||
"and mod:read still reports the packaged bytes")
|
||||
|
||||
-- ------- the reroute is reported, not silent
|
||||
|
||||
do
|
||||
local report = run.loader:legacyReport("fix_sandbox")
|
||||
local calls = {}
|
||||
for _, row in ipairs(report) do calls[row.call] = row end
|
||||
T.check(calls["io.open"], "io.open is recorded against the mod")
|
||||
T.check(calls["love.filesystem.write"], "so is love.filesystem.write")
|
||||
T.check(calls["os.getenv"], "and os.getenv")
|
||||
T.check(calls["love.filesystem.write"].count >= 2,
|
||||
"with a count, so a manager can rank the worst offenders")
|
||||
T.check(calls["io.open"].advice and #calls["io.open"].advice > 0,
|
||||
"each row carries the advice the warning printed")
|
||||
end
|
||||
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
|
||||
|
||||
-- ------- env propagation and isolation
|
||||
|
||||
T.check(out.childIoIsShim,
|
||||
T.eq(out.childIo, nil,
|
||||
"a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)")
|
||||
T.check(out.childGetenvIsShim, "the child chunk gets the same rerouted os")
|
||||
T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os")
|
||||
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table")
|
||||
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
|
||||
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
|
||||
@@ -306,36 +187,6 @@ T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true),
|
||||
"mod:info cannot climb either")
|
||||
run.release()
|
||||
|
||||
-- ------- two mods never share an overlay
|
||||
|
||||
do
|
||||
local files = {
|
||||
["mods/one/manifest.json"] = manifest("one"),
|
||||
["mods/one/main.lua"] = [[
|
||||
local mod = ...
|
||||
love.filesystem.write("shared.txt", "from one")
|
||||
mod.exports.mine = love.filesystem.read("shared.txt")
|
||||
]],
|
||||
["mods/two/manifest.json"] = manifest("two"),
|
||||
["mods/two/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.peek = love.filesystem.read("shared.txt")
|
||||
mod.exports.climb = love.filesystem.read("../one/shared.txt")
|
||||
]],
|
||||
}
|
||||
local pair = T.sdk.loadMods({ "mods/one", "mods/two" },
|
||||
{ fs = T.sdk.memfs(files) })
|
||||
T.eq(#pair.errors, 0, "both mods load (" .. tostring(pair.errors[1]) .. ")")
|
||||
T.eq(pair.loader.exports.one.mine, "from one", "the first mod sees its write")
|
||||
T.eq(pair.loader.exports.two.peek, nil,
|
||||
"the second mod, naming the same path, sees nothing")
|
||||
T.eq(files["mod_compat/one/shared.txt"], "from one",
|
||||
"because the overlay is keyed by mod id")
|
||||
T.eq(pair.loader.exports.two.climb, nil,
|
||||
"and a climb out of the overlay resolves inside it, not into the neighbour")
|
||||
pair.release()
|
||||
end
|
||||
|
||||
-- ------- the grammar itself
|
||||
|
||||
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
|
||||
@@ -375,11 +226,11 @@ do
|
||||
bytecodeRun.release()
|
||||
end
|
||||
|
||||
-- ------- the sandbox with no compat layer is still closed
|
||||
-- ------- the sandbox is not opt-in
|
||||
|
||||
do
|
||||
local env = Sandbox.envFor({ modId = "probe" })
|
||||
T.eq(env.io, nil, "a bare Sandbox.envFor has no io")
|
||||
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
|
||||
T.eq(env._G, env, "_G points at the sandbox, not the real globals")
|
||||
T.check(not pcall(env.require, "io"), "and its require refuses io")
|
||||
end
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
-- Contextual field actions share one public contract in both generations
|
||||
-- while each engine keeps ownership of its own field-item and move paths.
|
||||
-- Contextual bicycle and fishing actions share one public contract in both
|
||||
-- generations while each engine keeps ownership of its own field-item path.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness").suite("mod world field actions")
|
||||
local T = require("tests.harness").suite("mod world field items")
|
||||
|
||||
local facingWater = false
|
||||
local redCut, redSurf = false, "no_water"
|
||||
local redMoves = {}
|
||||
local redWorld = {
|
||||
isOverworld = true,
|
||||
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
|
||||
@@ -16,15 +14,11 @@ local redWorld = {
|
||||
scriptMoves = {},
|
||||
bikeAllowed = function() return true end,
|
||||
facingIsShoreOrWater = function() return facingWater end,
|
||||
useCutFieldMove = function() return redCut and "ok" or "nothing" end,
|
||||
useSurfFieldMove = function() return redSurf end,
|
||||
partyKnows = function(_, move) return redMoves[move] end,
|
||||
useBicycle = function(self) self.bikeUsed = true return true end,
|
||||
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
|
||||
}
|
||||
local redGame = {
|
||||
data = { field = { outsideTilesets = { "OVERWORLD" } },
|
||||
items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
save = { player = { name = "RED" }, party = {},
|
||||
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
stack = { states = { redWorld } },
|
||||
@@ -36,11 +30,8 @@ local RedAPI = require("src.world.WorldAPI")
|
||||
local red = RedAPI.new(redGame, "fixture")
|
||||
local RedWorld = require("src.world.OverworldController")
|
||||
T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
and type(RedWorld.useFlashFieldMove) == "function"
|
||||
and type(RedWorld.useStrengthFieldMove) == "function"
|
||||
and type(RedWorld.stopSurfing) == "function",
|
||||
"Red keeps field-action execution in its world")
|
||||
and type(RedWorld.useFishingRod) == "function",
|
||||
"Red keeps field-item execution in its world")
|
||||
local actions = red:availableFieldActions()
|
||||
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
|
||||
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
|
||||
@@ -64,98 +55,26 @@ ok, err = red:useFieldAction("bicycle")
|
||||
T.check(not ok and err == "world is busy",
|
||||
"Red refuses a stale action while busy")
|
||||
|
||||
redWorld.player.moving = false
|
||||
facingWater, redCut, redSurf = false, true, "ok"
|
||||
redWorld.dark = true
|
||||
for _, move in ipairs({ "STRENGTH", "FLASH", "TELEPORT" }) do
|
||||
redMoves[move] = { species = "MEW", moves = { { id = move } } }
|
||||
end
|
||||
redWorld.player.facingCell = function() return 4, 5 end
|
||||
redWorld.tryCut = function(self) self.cutUsed = true return true end
|
||||
redWorld.trySurf = function(self) self.surfUsed = true end
|
||||
redWorld.useStrengthFieldMove = function(self) self.strengthUsed = true return true end
|
||||
redWorld.useFlashFieldMove = function(self) self.flashUsed = true return true end
|
||||
redWorld.beginTeleportOut = function(self) self.teleportUsed = true end
|
||||
actions = red:availableFieldActions()
|
||||
local byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
T.check(byId.cut and byId.surf and byId.strength and byId.flash
|
||||
and byId.teleport, "Red lists field moves that can start now")
|
||||
for _, id in ipairs({ "cut", "surf", "strength", "flash", "teleport" }) do
|
||||
T.check(red:useFieldAction(id), "Red accepts listed " .. id)
|
||||
end
|
||||
T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
|
||||
and redWorld.flashUsed and redWorld.teleportUsed,
|
||||
"Red delegates every move to its overworld path")
|
||||
|
||||
redSurf = "dismount"
|
||||
redWorld.player.surfing = true
|
||||
redWorld.stopSurfing = function(self) self.dismounted = true end
|
||||
T.check(red:useFieldAction("surf") and redWorld.dismounted,
|
||||
"Red delegates the contextual SURF dismount")
|
||||
redWorld.player.surfing, redSurf = false, "ok"
|
||||
|
||||
redCut = false
|
||||
ok, err = red:useFieldAction("cut")
|
||||
T.check(not ok and err == "field action unavailable",
|
||||
"Red revalidates a stale field move")
|
||||
|
||||
redWorld.map.id = "ROCK_TUNNEL_1F"
|
||||
redWorld.map.def.tileset = "CAVERN"
|
||||
redMoves.DIG = { species = "MEW", moves = { { id = "DIG" } } }
|
||||
redWorld.beginTeleportOut = function(self) self.digUsed = true end
|
||||
byId = {}
|
||||
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
|
||||
T.check(byId.dig and not byId.teleport,
|
||||
"Red distinguishes dungeon DIG from outdoor TELEPORT")
|
||||
T.check(red:useFieldAction("dig") and redWorld.digUsed,
|
||||
"Red delegates DIG to its escape path")
|
||||
|
||||
local goldWorld = {
|
||||
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
|
||||
player = {}, playerState = "normal",
|
||||
acceptsMenuInput = function() return true end,
|
||||
playerCollision = function() return 0x00 end,
|
||||
alwaysOnBike = function() return false end,
|
||||
fieldContext = function() return { facingColl = 0x20 } end,
|
||||
useFieldItem = function(self, item) self.itemUsed = item return "used" end,
|
||||
squirtbottleTreeScript = function() return { { op = "end" } } end,
|
||||
useFieldMove = function(self, move)
|
||||
self.moveUsed = move
|
||||
return { ok = true }
|
||||
end,
|
||||
}
|
||||
local goldGame = {
|
||||
data = { items = { OLD_ROD = { name = "OLD ROD" },
|
||||
SQUIRTBOTTLE = { name = "SQUIRTBOTTLE" } } },
|
||||
save = { inventory = { BICYCLE = 1, OLD_ROD = 1, SQUIRTBOTTLE = 1 },
|
||||
player = { badges = { FOG = true } },
|
||||
party = { { moves = { { id = "SURF" }, { id = "SWEET_SCENT" },
|
||||
{ id = "TELEPORT" } } } } },
|
||||
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
world = goldWorld,
|
||||
}
|
||||
goldWorld.fieldContext = function(_, mon) return {
|
||||
save = goldGame.save, party = goldGame.save.party, mon = mon,
|
||||
facing = "right", facingColl = 0x29, playerColl = 0,
|
||||
environment = "ROUTE", playerState = "normal", alwaysOnBike = false,
|
||||
dark = false, canEscapeRope = false,
|
||||
} end
|
||||
|
||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||
local gold = GoldAPI.new(goldGame, "fixture")
|
||||
actions = gold:availableFieldActions()
|
||||
byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id")
|
||||
T.eq(actions[2].id, "fish", "Gold preserves the original action order")
|
||||
T.check(byId.surf and byId.sweet_scent and byId.teleport,
|
||||
"Gold lists field moves through its generic dispatcher")
|
||||
T.eq(byId.fish.rods[1].id, "OLD_ROD", "Gold shares the rod shape")
|
||||
T.check(byId.squirtbottle,
|
||||
"Gold lists the SquirtBottle only at its matching tree")
|
||||
T.check(gold:useFieldAction("sweet_scent"),
|
||||
"Gold accepts a listed field move")
|
||||
T.eq(goldWorld.moveUsed, "SWEET_SCENT",
|
||||
"Gold delegates moves to its own field-move path")
|
||||
T.eq(actions[2].rods[1].id, "OLD_ROD", "Gold shares the rod shape")
|
||||
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
|
||||
"Gold accepts the same fishing request")
|
||||
T.eq(goldWorld.itemUsed, "OLD_ROD",
|
||||
@@ -165,9 +84,5 @@ ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
|
||||
T.check(not ok and err == "fishing rod unavailable",
|
||||
"Gold rejects an unowned rod")
|
||||
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
|
||||
T.check(gold:useFieldAction("squirtbottle"),
|
||||
"Gold accepts the contextual SquirtBottle")
|
||||
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
||||
"Gold delegates the SquirtBottle to its field-item path")
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -463,8 +463,6 @@ return function(mod)
|
||||
end
|
||||
]])
|
||||
write(bad .. "/hack.gb", "GBDATA")
|
||||
os.execute((mkdir .. " %q"):format(bad .. "/baseroms"))
|
||||
write(bad .. "/baseroms/stadium2.z64", "USER ROM")
|
||||
write(bad .. "/cachepath.lua",
|
||||
'return { pic = "assets/generated/battle/front/mew.png" }')
|
||||
|
||||
@@ -476,8 +474,6 @@ check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101")
|
||||
check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field")
|
||||
check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301")
|
||||
check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303")
|
||||
check(out:find("MK307", 1, true) ~= nil,
|
||||
"a user-supplied baseroms file is refused explicitly")
|
||||
|
||||
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, bad, root .. "/bad.modpkg"))
|
||||
|
||||
@@ -14,7 +14,6 @@ love.system = love.system or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
pickFileKinds = love.system.pickFileKinds,
|
||||
}
|
||||
|
||||
local pickCalls = {}
|
||||
@@ -23,7 +22,6 @@ love.system.pickFile = function(kind)
|
||||
pickCalls[#pickCalls + 1] = kind or "rom"
|
||||
return true
|
||||
end
|
||||
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
|
||||
|
||||
local function freshImporter(ready)
|
||||
return setmetatable({
|
||||
@@ -96,83 +94,11 @@ 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")
|
||||
|
||||
-- Required mod files use their own safe picker kind and pending filename.
|
||||
pickCalls = {}
|
||||
ri = freshImporter({ red = true, blue = true })
|
||||
ri.nativePicker = true
|
||||
ri.mobileFileBridge = true
|
||||
ri.mods = { {
|
||||
id = "needs_source",
|
||||
manifest = { id = "needs_source", name = "Needs Source",
|
||||
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
||||
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
||||
} }
|
||||
ri:chooseRequiredImport("needs_source", "source")
|
||||
eq(pickCalls[1], "required_import",
|
||||
"required file asks for the dedicated picker kind")
|
||||
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
|
||||
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
|
||||
|
||||
-- A rejected selection stays on the imported-files page, where the player can
|
||||
-- see it before choosing another file, instead of behind the modal.
|
||||
ri.nativePicker = false
|
||||
ri._importRequiredData = RomImporter._importRequiredData
|
||||
local savedData = love.data
|
||||
love.data = {
|
||||
hash = function() return "not accepted" end,
|
||||
encode = function() return "ffffffffffffffffffffffffffffffff" end,
|
||||
}
|
||||
ri:_importRequiredData("needs_source", "source", "wrong source bytes")
|
||||
love.data = savedData
|
||||
check(ri.requiredImportNotice ~= nil,
|
||||
"required import rejection creates an in-modal notice")
|
||||
eq(ri.requiredImportNotice.modId, "needs_source",
|
||||
"required import notice identifies its mod")
|
||||
eq(ri.requiredImportNotice.importId, "source",
|
||||
"required import notice identifies its file")
|
||||
check(ri.requiredImportNotice.text:find("MD5 mismatch", 1, true) ~= nil,
|
||||
"required import notice includes the MD5 failure")
|
||||
check(ri.modNotice == nil,
|
||||
"required import rejection is not hidden in the general Mods notice")
|
||||
|
||||
-- Reported size is checked before the selected file is read into Lua.
|
||||
local savedGetInfo = love.filesystem.getInfo
|
||||
love.filesystem.getInfo = function(name, kind)
|
||||
if name == "oversized_required.bin" then
|
||||
return { type = "file", size = 10 }
|
||||
end
|
||||
return savedGetInfo(name, kind)
|
||||
end
|
||||
ri.mods[1].manifest.required_imports[1].max_size = 4
|
||||
ri._importRequiredSource = RomImporter._importRequiredSource
|
||||
ri._importRequiredData = function(self) self._oversizedWasRead = true end
|
||||
ri:_importRequiredSource("needs_source", "source", "oversized_required.bin")
|
||||
check(not ri._oversizedWasRead, "oversized required file is rejected before import")
|
||||
check(ri.requiredImportNotice.text:find("too large", 1, true) ~= nil,
|
||||
"oversized required file reports its size error in the modal")
|
||||
ri.mods[1].manifest.required_imports[1].max_size = nil
|
||||
love.filesystem.getInfo = savedGetInfo
|
||||
|
||||
ri.nativePicker = true
|
||||
ri._importRequiredSource = function(self, modId, importId, source)
|
||||
self._requiredImported = { modId = modId, importId = importId, source = source }
|
||||
return true
|
||||
end
|
||||
love.filesystem.write("picked_required_import.bin", "source bytes")
|
||||
ri:focus(true)
|
||||
check(ri._requiredImported ~= nil, "focus consumes a required-file SAF pick")
|
||||
eq(ri._requiredImported.modId, "needs_source", "focus routes to the pending mod")
|
||||
eq(ri._requiredImported.importId, "source", "focus routes to the pending declaration")
|
||||
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
|
||||
"focus removes the staged required-file pick")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.pickFileKinds = saved.pickFileKinds
|
||||
-- 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")
|
||||
love.filesystem.remove("picked_required_import.bin")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
-- tests/save_editor_task7_tests.lua
|
||||
-- tests/save_editor_task8_tests.lua
|
||||
-- tests/save_editor_mod_tests.lua
|
||||
-- tests/save_editor_gen2_tests.lua
|
||||
-- See tools/save-editor/README.md for the full list.
|
||||
--
|
||||
-- All of them drive tools/save-editor/Ops.lua rather than clicking pixel
|
||||
|
||||
@@ -3475,8 +3475,6 @@ do
|
||||
local lua = (arg and arg[-1]) or "luajit"
|
||||
local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua))
|
||||
check(status == 0 or status == true, "save_editor_mod_tests suite")
|
||||
status = os.execute(("%q tests/save_editor_gen2_tests.lua"):format(lua))
|
||||
check(status == 0 or status == true, "save_editor_gen2_tests suite")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- input hold regressions
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
-- Headless Gold save-editor rules. Run from repo root:
|
||||
-- luajit tests/save_editor_gen2_tests.lua
|
||||
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
|
||||
.. ";./tools/save-editor/panels/?.lua"
|
||||
|
||||
local love_stub = require("tests.love_stub")
|
||||
love = love_stub
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL: " .. msg)
|
||||
end
|
||||
end
|
||||
|
||||
local function eq(a, b, msg)
|
||||
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
|
||||
end
|
||||
|
||||
print("== save editor gen2 tests ==")
|
||||
|
||||
local Gen = require("Gen")
|
||||
local Catalog = require("Catalog")
|
||||
local MonOps = require("MonOps")
|
||||
local Ops = require("Ops")
|
||||
local State = require("State")
|
||||
local Save2 = require("src.core.gen2.Save")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local data = {
|
||||
pokemon = {
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
|
||||
types = { "FIRE" },
|
||||
baseStats = {
|
||||
hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
specialAttack = 60, specialDefense = 50,
|
||||
},
|
||||
catchRate = 45, baseExp = 65,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
levelMoves = { { level = 1, move = "TACKLE" } },
|
||||
genderRatio = 31,
|
||||
},
|
||||
TOTODILE = {
|
||||
id = "TOTODILE", name = "TOTODILE", dex = 158,
|
||||
types = { "WATER" },
|
||||
baseStats = {
|
||||
hp = 50, attack = 65, defense = 64, speed = 43,
|
||||
specialAttack = 44, specialDefense = 48,
|
||||
},
|
||||
catchRate = 45, baseExp = 66,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
levelMoves = { { level = 1, move = "SCRATCH" } },
|
||||
genderRatio = 31,
|
||||
},
|
||||
},
|
||||
moves = {
|
||||
TACKLE = { pp = 35 },
|
||||
SCRATCH = { pp = 35 },
|
||||
},
|
||||
items = {
|
||||
POTION = { pocket = "ITEM" },
|
||||
MASTER_BALL = { pocket = "BALL" },
|
||||
FLOWER_MAIL = { pocket = "ITEM" },
|
||||
},
|
||||
maps = {},
|
||||
}
|
||||
|
||||
local function newState()
|
||||
local S = State.new()
|
||||
S.data = data
|
||||
S.cat = Catalog.build(data)
|
||||
S.save = Save2.newGame()
|
||||
S.version = "gold"
|
||||
Gen.ensureBoxes(S.save)
|
||||
return S
|
||||
end
|
||||
|
||||
do
|
||||
GameVersion.set("gold")
|
||||
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
|
||||
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
|
||||
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
|
||||
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
|
||||
local S1 = State.new()
|
||||
S1.data = {
|
||||
pokemon = {
|
||||
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
|
||||
BROKEN = { baseStats = { hp = 1 } },
|
||||
},
|
||||
}
|
||||
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
|
||||
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
eq(#S.save.party, 1, "partyAdd on gold")
|
||||
local mon = S.save.party[1]
|
||||
eq(mon.species, "CYNDAQUIL", "first catalog species")
|
||||
check(mon.experience ~= nil, "gold mon has experience")
|
||||
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
|
||||
check(mon.stats.specialAttack and mon.stats.specialDefense,
|
||||
"gold stats have spa/spd")
|
||||
check(mon.happiness ~= nil, "gold mon has happiness")
|
||||
eq(mon.ot, S.save.player.name, "stampOT copies player name")
|
||||
|
||||
Ops.setLevel(S, mon, 20)
|
||||
eq(mon.level, 20, "setLevel 20")
|
||||
check(mon.experience > 0, "experience resynced")
|
||||
|
||||
Ops.setHappiness(S, mon, 200)
|
||||
eq(mon.happiness, 200, "happiness 200")
|
||||
Ops.setPokerus(S, mon, 15)
|
||||
eq(mon.pokerus, 15, "pokerus byte")
|
||||
Ops.setHeldItem(S, mon, "POTION")
|
||||
eq(mon.item, "POTION", "held item")
|
||||
|
||||
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
|
||||
Ops.setSpecies(S, mon, "TOTODILE")
|
||||
eq(mon.species, "TOTODILE", "setSpecies id")
|
||||
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
|
||||
check(mon.nickname == nil, "setSpecies does not invent a nickname")
|
||||
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
Ops.partyAdd(S)
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
|
||||
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
|
||||
S.selectedParty = 1
|
||||
Ops.partyMove(S, 1)
|
||||
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
|
||||
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
|
||||
S.selectedParty = 1
|
||||
check(Ops.partyRemove(S) == false, "partyRemove arms")
|
||||
check(Ops.partyRemove(S) == true, "partyRemove commits")
|
||||
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
|
||||
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
local mon = S.save.party[1]
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
|
||||
eq(mon.item, "FLOWER_MAIL", "held mail item")
|
||||
local letter = Mail.state(S.save).party[1]
|
||||
check(letter ~= nil, "giving mail writes sPartyMail")
|
||||
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
|
||||
Ops.setSpecies(S, mon, "TOTODILE")
|
||||
eq(Mail.state(S.save).party[1].species, "TOTODILE",
|
||||
"setSpecies updates the letter's species copy")
|
||||
Ops.setHeldItem(S, mon, "POTION")
|
||||
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
|
||||
end
|
||||
|
||||
do
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local S = newState()
|
||||
local stale = Mon.new(data, "CYNDAQUIL", 5)
|
||||
stale.species = "TOTODILE"
|
||||
stale.name = "CYNDAQUIL"
|
||||
stale.types = { "FIRE" }
|
||||
S.save.dayCare = { man = { mon = stale }, lady = {} }
|
||||
Mon.syncSaveIdentity(S.save, data)
|
||||
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
|
||||
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
|
||||
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
|
||||
"ABRA", "displayName prefers the species copy over the id")
|
||||
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
|
||||
"BOB", "displayName prefers nickname")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
eq(Ops.boxCount(S), 14, "14 gold boxes")
|
||||
eq(Ops.boxCapacity(S), 20, "20 per box")
|
||||
Ops.boxAdd(S)
|
||||
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
S.save.party[1].hp = 0
|
||||
Ops.partyAdd(S)
|
||||
S.selectedParty = 1
|
||||
S.selectedBox = 1
|
||||
local ok = Ops.deposit(S)
|
||||
check(ok, "deposit fainted mon while a healthy remains")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
Ops.partyAdd(S)
|
||||
S.selectedParty = 1
|
||||
S.selectedBox = 1
|
||||
local ok = Ops.deposit(S)
|
||||
check(ok, "deposit one of two healthy mons")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
S.selectedParty = 1
|
||||
S.selectedBox = 1
|
||||
local ok = Ops.deposit(S)
|
||||
check(not ok, "refuse depositing last healthy mon")
|
||||
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
|
||||
or S.status:find("POKEMON") or S.status:find("last"),
|
||||
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
|
||||
eq(#S.save.party, 1, "party still has the mon")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
eq(Gen.money(S.save), 3000, "gold start money on player")
|
||||
Ops.addMoney(S, 1000)
|
||||
eq(S.save.player.money, 4000, "money writes player.money")
|
||||
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
|
||||
Ops.maxMoney(S)
|
||||
eq(S.save.player.money, 999999, "money cap")
|
||||
Ops.addCoins(S, 250)
|
||||
eq(S.save.player.coins, 250, "coins write player.coins")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
|
||||
Ops.toggleBadge(S, "ZEPHYR")
|
||||
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
|
||||
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
|
||||
Ops.toggleBadge(S, "BOULDER")
|
||||
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.dexOwned(S, "CYNDAQUIL", true)
|
||||
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
|
||||
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
|
||||
"does not write owned on gold")
|
||||
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
|
||||
local _, owned = Ops.dexCounts(S)
|
||||
eq(owned, 1, "dexCounts reads caught")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
local name = "EVENT_BEAT_FALKNER"
|
||||
Ops.setFlag(S, name, true)
|
||||
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
|
||||
check(S.save.flags[name] == nil, "numeric flags are not string keys")
|
||||
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
|
||||
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
S.mapId = "NEW_BARK_TOWN"
|
||||
S.mapClickCell = { cx = 4, cy = 5 }
|
||||
Ops.setPlayerHere(S)
|
||||
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
|
||||
eq(S.save.position.x, 4, "position.x")
|
||||
eq(S.save.position.y, 5, "position.y")
|
||||
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
|
||||
"does not write player.map on gold")
|
||||
end
|
||||
|
||||
do
|
||||
local encoded = SaveData.encode(Save2.newGame())
|
||||
local back = SaveData.decode(encoded)
|
||||
eq(back.generation, 2, "round-trip keeps generation 2")
|
||||
end
|
||||
|
||||
do
|
||||
local names = Catalog.goldEventList()
|
||||
local hasFalkner = false
|
||||
for _, n in ipairs(names) do
|
||||
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
|
||||
end
|
||||
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
|
||||
end
|
||||
|
||||
do
|
||||
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
|
||||
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
|
||||
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
|
||||
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
|
||||
"Gen.maps falls back to maps")
|
||||
local mansion = Gen.maps({
|
||||
maps = {
|
||||
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
|
||||
},
|
||||
gen2Maps = {
|
||||
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
|
||||
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
|
||||
},
|
||||
})
|
||||
eq(mansion.CELADON_MANSION_2F.width, 4,
|
||||
"Gen.maps keeps extractor width under a gen2Maps objects patch")
|
||||
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
|
||||
"Gen.maps still applies the gen2Maps patch fields")
|
||||
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
|
||||
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
|
||||
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
|
||||
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
|
||||
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
|
||||
"Gen.tilesets prefers gen2Tilesets")
|
||||
end
|
||||
|
||||
do
|
||||
local Map2 = require("src.world.gen2.Map")
|
||||
local MapPreview = require("src.world.gen2.MapPreview")
|
||||
local def = {
|
||||
id = "AZALEA_GYM", tileset = "TILESET_GYM",
|
||||
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
|
||||
warps = {}, environment = "INDOOR",
|
||||
}
|
||||
local tileset = {
|
||||
id = "TILESET_GYM",
|
||||
image = "assets/generated/tilesets/gym.png",
|
||||
tilesPerRow = 16,
|
||||
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
|
||||
}
|
||||
local map = Map2.new(def, tileset)
|
||||
check(map.renderer == nil, "Map2 does not ship a renderer")
|
||||
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
|
||||
local renderer = MapPreview.renderer(baker, map)
|
||||
check(renderer ~= nil and renderer.draw ~= nil,
|
||||
"MapPreview attaches a draw for a Gold map")
|
||||
end
|
||||
|
||||
do
|
||||
local memfs = {
|
||||
files = {
|
||||
["saves/gold/slot1.lua"] = 'return { version = "gold", generation = 2, player = { name = "GOLD" } }',
|
||||
["options.lua"] = 'return { textSpeed = 3 }',
|
||||
},
|
||||
getInfo = function(self, path)
|
||||
return self.files[path] and { type = "file" } or nil
|
||||
end,
|
||||
read = function(self, path)
|
||||
return self.files[path]
|
||||
end,
|
||||
write = function(self, path, data)
|
||||
self.files[path] = data
|
||||
return true
|
||||
end,
|
||||
remove = function(self, path)
|
||||
self.files[path] = nil
|
||||
return true
|
||||
end,
|
||||
}
|
||||
local main, _, _ = SaveData.saveFilename("gold")
|
||||
check(main ~= nil, "saveFilename resolves for gold")
|
||||
end
|
||||
|
||||
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
@@ -142,41 +142,6 @@ local ok, err = pcall(function()
|
||||
local report = SaveData.validate(probe, Data)
|
||||
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
|
||||
"validate keeps the modded mon while the mod is enabled")
|
||||
|
||||
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
|
||||
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Ops = require("Ops")
|
||||
check(not ModTargets.supports({}, "gold"),
|
||||
"legacy gen1-only fixture does not support gold")
|
||||
check(not ModTargets.supports({ games = { "red" } }, "gold"),
|
||||
"explicit gen1 games list does not support gold")
|
||||
check(ModTargets.supports({ games = { "gold" } }, "gold"),
|
||||
"gold-targeted manifest supports gold")
|
||||
check(ModTargets.supports({ gen2compat = true }, "gold"),
|
||||
"gen2compat legacy still supports gold")
|
||||
|
||||
local goldS = {
|
||||
data = {
|
||||
pokemon = {
|
||||
EDITMON = {
|
||||
baseStats = {
|
||||
hp = 50, attack = 50, defense = 50, speed = 50,
|
||||
specialAttack = 50, specialDefense = 50,
|
||||
},
|
||||
},
|
||||
G1ONLY = {
|
||||
baseStats = {
|
||||
hp = 50, attack = 50, defense = 50, speed = 50, special = 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
check(Ops.speciesUsable(goldS, "EDITMON"),
|
||||
"spa/spd EDITMON is usable on gold")
|
||||
check(Ops.speciesUsable(goldS, "G1ONLY"),
|
||||
"gen1 special record remains usable (dual-key gate)")
|
||||
end)
|
||||
|
||||
os.remove(MOD_ROOT .. "/main.lua")
|
||||
@@ -189,7 +154,7 @@ os.remove(tmpPath)
|
||||
love.filesystem = savedFS
|
||||
-- leave shared singletons the way we found them (the fixture merged one
|
||||
-- record into Data.pokemon)
|
||||
if Data.pokemon then Data.pokemon.EDITMON = nil end
|
||||
Data.pokemon.EDITMON = nil
|
||||
Assets.loader = savedBridge
|
||||
Assets.invalidate()
|
||||
Runtime.install(savedEvents, savedHooks, savedErrors)
|
||||
|
||||
@@ -12,7 +12,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
local Flags = dofile("tools/goldwalk/flags.lua")
|
||||
|
||||
local ROOT = arg[1] or "../pokegold"
|
||||
local OUT = "src/core/gen2/FlagNames.lua"
|
||||
local OUT = "tests/drivers/gold/flag_names.lua"
|
||||
|
||||
local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm")
|
||||
local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm")
|
||||
|
||||
@@ -1045,14 +1045,6 @@ def lint_dir(repo, mod_dir, manifest):
|
||||
for rel in mod_files(mod_dir):
|
||||
path = os.path.join(mod_dir, rel)
|
||||
ext = os.path.splitext(rel)[1].lower()
|
||||
# MK307: required_imports are user-owned installation state. Keeping
|
||||
# baseroms in the walk without an explicit gate would let `pack`
|
||||
# silently bundle exactly the ROM this feature exists not to ship.
|
||||
if rel.startswith("baseroms/"):
|
||||
findings.append(Finding(
|
||||
"MK307", "error",
|
||||
"user-supplied baseroms must not be distributed", rel))
|
||||
continue
|
||||
# MK301: nothing may live in (or point into) the generated trees
|
||||
if rel.startswith(("data/generated/", "assets/generated/")):
|
||||
findings.append(Finding(
|
||||
|
||||
+42
-49
@@ -25,7 +25,6 @@ local State = require("State")
|
||||
local Kit = require("Kit")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PadInput = require("PadInput")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
@@ -88,43 +87,56 @@ local function applyLoaded(path, statusVerb)
|
||||
if save then
|
||||
S.save = save
|
||||
S.status = statusVerb .. " " .. path
|
||||
S.mapId = save.player.map
|
||||
S.loadError = false
|
||||
S.allowSave = true
|
||||
elseif existed then
|
||||
S.save = Gen.newGame(S.version)
|
||||
-- File is present but SaveIO.load couldn't decode it: treat it as a
|
||||
-- real (corrupt) save, not a missing one. Editing a stub here is fine,
|
||||
-- but Save must stay disabled so we never clobber the corrupt file
|
||||
-- until the user fixes it and Reload succeeds.
|
||||
S.save = require("src.core.SaveData").newGame()
|
||||
S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
|
||||
"), Save disabled, use Reload after fixing the file"
|
||||
S.mapId = S.save.player.map
|
||||
S.loadError = true
|
||||
S.allowSave = false
|
||||
else
|
||||
S.save = Gen.newGame(S.version)
|
||||
S.save = require("src.core.SaveData").newGame()
|
||||
S.status = "No save at " .. path .. " (" .. tostring(err) ..
|
||||
"), editing new game stub"
|
||||
S.mapId = S.save.player.map
|
||||
S.loadError = false
|
||||
S.allowSave = true
|
||||
end
|
||||
local mapId = Gen.playerMap(S.save)
|
||||
S.mapId = mapId
|
||||
S.dirty = false
|
||||
S._quitArmed = false
|
||||
S._openArmed = false
|
||||
S.editingMon = nil
|
||||
Ops.disarm(S)
|
||||
Gen.ensureBoxes(S.save)
|
||||
Gen.hydrateSave(Data, S.save)
|
||||
local boxes = require("src.pokemon.Boxes").ensure(S.save)
|
||||
-- Imported .sav box mons have no stat block (box_struct stops before
|
||||
-- MON_STATS). The game derives them in SaveData.validate; the editor
|
||||
-- only validates a copy, so hydrate here for Boxes/Party/MonEditor.
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local function ensureStats(mon)
|
||||
Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon)
|
||||
end
|
||||
for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end
|
||||
for _, box in ipairs(boxes) do
|
||||
for _, mon in ipairs(box) do ensureStats(mon) end
|
||||
end
|
||||
if S.save.daycare and S.save.daycare.mon then
|
||||
ensureStats(S.save.daycare.mon)
|
||||
end
|
||||
-- what the running game would quarantine, computed on a copy so the
|
||||
-- editor never mutates the file behind the user's back
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local probe = require("src.mods.Merge").deepCopy(S.save)
|
||||
S.validation = Gen.validate(probe, Data)
|
||||
if not Gen.emptyReport(S.save, S.validation) then
|
||||
if Gen.of(S.save, S.version) == 2 then
|
||||
S.status = S.status .. string.format(
|
||||
", game would quarantine: %d script bytes, %d mail, %d events",
|
||||
#(S.validation.lostScriptMem or {}),
|
||||
#(S.validation.lostMail or {}),
|
||||
#(S.validation.lostEvents or {}))
|
||||
else
|
||||
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
|
||||
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
|
||||
end
|
||||
S.validation = SaveData.validate(probe, Data)
|
||||
if not SaveData.emptyReport(S.validation) then
|
||||
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
|
||||
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -140,9 +152,6 @@ function App.load(pathOverride, opts)
|
||||
S.slotId = opts.slotId
|
||||
S.embedded = opts.embedded or false
|
||||
S.onClose = opts.onClose
|
||||
if opts.version then
|
||||
require("src.core.GameVersion").set(opts.version)
|
||||
end
|
||||
-- the same mod set the game loads, merged into Data before the catalogs
|
||||
-- build, so modded species/items/moves are editable and MonOps stops
|
||||
-- asserting on them
|
||||
@@ -154,10 +163,6 @@ function App.load(pathOverride, opts)
|
||||
-- loaded at least once, so it doubles as the "needs evicting" marker.
|
||||
if Data._pristineKeys then Data:unloadGenerated() end
|
||||
Data:load()
|
||||
if Gen.of(nil, opts.version) == 2
|
||||
or require("src.core.GameVersion").generation() == 2 then
|
||||
Gen.bindGoldData(Data)
|
||||
end
|
||||
local ModLoader = require("src.mods.Loader")
|
||||
mods = ModLoader.new()
|
||||
mods:load(Data)
|
||||
@@ -169,16 +174,9 @@ function App.load(pathOverride, opts)
|
||||
for _, mod in ipairs(S.mods:status().loaded) do
|
||||
modRoots[#modRoots + 1] = mod.path
|
||||
end
|
||||
if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
|
||||
S.events = Catalog.goldEventList(modRoots)
|
||||
else
|
||||
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
|
||||
nil, modRoots)
|
||||
end
|
||||
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
|
||||
nil, modRoots)
|
||||
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
|
||||
if Gen.of(S.save, S.version) == 2 then
|
||||
S.events = Catalog.goldEventList(modRoots)
|
||||
end
|
||||
end
|
||||
|
||||
-- Switch to another save file (Open button, drag-drop, or --save arg).
|
||||
@@ -584,9 +582,11 @@ local function tabCount(id)
|
||||
return tostring(n)
|
||||
elseif id == "items" then
|
||||
local Bag = require("src.inventory.Bag")
|
||||
return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data))
|
||||
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
|
||||
elseif id == "events" then
|
||||
return tostring(Gen.flagCount(S.save))
|
||||
local n = 0
|
||||
for _ in pairs(S.save.flags or {}) do n = n + 1 end
|
||||
return tostring(n)
|
||||
elseif id == "map" then
|
||||
-- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
|
||||
return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
|
||||
@@ -602,28 +602,21 @@ end
|
||||
-- something. Returns what to draw plus the tab that owns the first problem,
|
||||
-- so the rail can reserve the pill's width before laying out the tiles.
|
||||
local function validationPill()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local report = S.validation
|
||||
if not report or Gen.emptyReport(S.save, report) then
|
||||
if not report or SaveData.emptyReport(report) then
|
||||
return "Save validates clean", PAL.green, nil, true
|
||||
end
|
||||
local parts = {}
|
||||
local target
|
||||
local function add(n, singular, plural, tab)
|
||||
n = n or 0
|
||||
if n <= 0 then return end
|
||||
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
|
||||
target = target or tab
|
||||
end
|
||||
if Gen.of(S.save, S.version) == 2 then
|
||||
add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events")
|
||||
add(#(report.lostMail or {}), "mail", "mail", "party")
|
||||
add(#(report.lostEvents or {}), "event", "events", "events")
|
||||
add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map")
|
||||
else
|
||||
add(#(report.lostMons or {}), "mon", "mons", "party")
|
||||
add(#(report.lostItems or {}), "item", "items", "items")
|
||||
add(#(report.remappedMaps or {}), "map", "maps", "map")
|
||||
end
|
||||
add(#report.lostMons, "mon", "mons", "party")
|
||||
add(#report.lostItems, "item", "items", "items")
|
||||
add(#report.remappedMaps, "map", "maps", "map")
|
||||
return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
|
||||
end
|
||||
|
||||
|
||||
@@ -91,8 +91,7 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
end
|
||||
end
|
||||
|
||||
local dirs = {}
|
||||
if scriptDir then dirs[#dirs + 1] = scriptDir end
|
||||
local dirs = { scriptDir }
|
||||
for _, dir in ipairs(extraDirs or {}) do
|
||||
dirs[#dirs + 1] = dir
|
||||
end
|
||||
@@ -111,25 +110,4 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
return sortedKeys(found)
|
||||
end
|
||||
|
||||
function Catalog.goldEventList(extraDirs)
|
||||
local names = {}
|
||||
local ok, flags = pcall(require, "src.core.gen2.FlagNames")
|
||||
if ok and flags and flags.events then
|
||||
for name in pairs(flags.events) do
|
||||
names[#names + 1] = name
|
||||
end
|
||||
end
|
||||
table.sort(names)
|
||||
local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs)
|
||||
local seen = {}
|
||||
for _, name in ipairs(names) do seen[name] = true end
|
||||
for _, name in ipairs(modFlags) do
|
||||
if not seen[name] then
|
||||
names[#names + 1] = name
|
||||
seen[name] = true
|
||||
end
|
||||
end
|
||||
return names
|
||||
end
|
||||
|
||||
return Catalog
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
-- Generation adapter for the save editor. Panels stay generation-blind;
|
||||
-- Ops and App read Gold vs RBY through this module so a Gold write never
|
||||
-- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...).
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local Gen = {}
|
||||
|
||||
local function versionGeneration(version)
|
||||
if type(version) ~= "string" then return nil end
|
||||
local info = GameVersion.info(version)
|
||||
if info then return info.generation or 1 end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Gen.of(save, version)
|
||||
if type(save) == "table" then
|
||||
if save.generation == 2 then return 2 end
|
||||
local fromVersion = versionGeneration(save.version)
|
||||
if fromVersion then return fromVersion end
|
||||
end
|
||||
local fromArg = versionGeneration(version)
|
||||
if fromArg then return fromArg end
|
||||
return GameVersion.generation()
|
||||
end
|
||||
|
||||
function Gen.ofState(S)
|
||||
if not S then return GameVersion.generation() end
|
||||
return Gen.of(S.save, S.version)
|
||||
end
|
||||
|
||||
function Gen.is2(save, version)
|
||||
return Gen.of(save, version) == 2
|
||||
end
|
||||
|
||||
-- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod
|
||||
-- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded
|
||||
-- cache so a mod patch that landed on an empty gen2Maps (objects only, no
|
||||
-- width) does not hide the extractor's record, and a new map like BERRY_FARM
|
||||
-- still appears.
|
||||
local function overlayRecords(base, overlay)
|
||||
if not overlay then return base or {} end
|
||||
if not base or base == overlay then return overlay end
|
||||
local out = {}
|
||||
for id, def in pairs(base) do out[id] = def end
|
||||
for id, def in pairs(overlay) do
|
||||
local prior = out[id]
|
||||
if type(def) == "table" and type(prior) == "table" then
|
||||
local merged = {}
|
||||
for k, v in pairs(prior) do merged[k] = v end
|
||||
for k, v in pairs(def) do merged[k] = v end
|
||||
out[id] = merged
|
||||
else
|
||||
out[id] = def
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Gen.maps(data)
|
||||
if type(data) ~= "table" then return {} end
|
||||
return overlayRecords(data.maps, data.gen2Maps)
|
||||
end
|
||||
|
||||
function Gen.tilesets(data)
|
||||
if type(data) ~= "table" then return {} end
|
||||
return overlayRecords(data.tilesets, data.gen2Tilesets)
|
||||
end
|
||||
|
||||
-- Point the Gen 2 Data keys at the tables Data:load already filled, before
|
||||
-- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold.
|
||||
function Gen.bindGoldData(data)
|
||||
if type(data) ~= "table" then return data end
|
||||
if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end
|
||||
if data.tilesets and data.gen2Tilesets == nil then
|
||||
data.gen2Tilesets = data.tilesets
|
||||
end
|
||||
if data.palettes and data.gen2Palettes == nil then
|
||||
data.gen2Palettes = data.palettes
|
||||
end
|
||||
if data.gen2Roofs == nil and data.roofs == nil then
|
||||
local ok, roofs = pcall(require, "data.generated.roofs")
|
||||
if ok and type(roofs) == "table" then
|
||||
data.roofs = roofs
|
||||
data.gen2Roofs = roofs
|
||||
end
|
||||
elseif data.roofs and data.gen2Roofs == nil then
|
||||
data.gen2Roofs = data.roofs
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
function Gen.newGame(version)
|
||||
if (versionGeneration(version) or GameVersion.generation(version)) == 2 then
|
||||
return require("src.core.gen2.Save").newGame()
|
||||
end
|
||||
return require("src.core.SaveData").newGame()
|
||||
end
|
||||
|
||||
function Gen.validate(save, data)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Save").validate(save)
|
||||
end
|
||||
return require("src.core.SaveData").validate(save, data)
|
||||
end
|
||||
|
||||
function Gen.emptyReport(save, report)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Save").emptyReport(report)
|
||||
end
|
||||
return require("src.core.SaveData").emptyReport(report)
|
||||
end
|
||||
|
||||
function Gen.hydrateMon(data, mon)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
local gen2 = (mon.stats and mon.stats.specialAttack)
|
||||
or (def and def.baseStats and def.baseStats.specialAttack)
|
||||
or mon.experience ~= nil
|
||||
if gen2 then
|
||||
require("src.battle.gen2.Mon").refreshStats(mon, data)
|
||||
else
|
||||
require("src.pokemon.Stats").ensure(def, mon)
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare.
|
||||
function Gen.hydrateSave(data, save)
|
||||
if type(save) ~= "table" then return save end
|
||||
if Gen.of(save) == 2 then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end)
|
||||
return save
|
||||
end
|
||||
for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end
|
||||
for _, box in ipairs(save.boxes or {}) do
|
||||
if type(box) == "table" then
|
||||
for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end
|
||||
end
|
||||
end
|
||||
if save.daycare and save.daycare.mon then
|
||||
Gen.hydrateMon(data, save.daycare.mon)
|
||||
end
|
||||
return save
|
||||
end
|
||||
|
||||
function Gen.ensureBoxes(save)
|
||||
if Gen.of(save) == 2 then
|
||||
local Boxes2 = require("src.core.gen2.Boxes")
|
||||
save.boxes = save.boxes or {}
|
||||
for i = 1, Boxes2.NUM_BOXES do
|
||||
save.boxes[i] = save.boxes[i] or {}
|
||||
end
|
||||
save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
|
||||
return save.boxes
|
||||
end
|
||||
return require("src.pokemon.Boxes").ensure(save)
|
||||
end
|
||||
|
||||
function Gen.boxCount(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Boxes").NUM_BOXES
|
||||
end
|
||||
return require("src.pokemon.Boxes").COUNT
|
||||
end
|
||||
|
||||
function Gen.boxCapacity(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Boxes").MONS_PER_BOX
|
||||
end
|
||||
return require("src.pokemon.Boxes").CAPACITY
|
||||
end
|
||||
|
||||
function Gen.money(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return (save.player and save.player.money) or 0
|
||||
end
|
||||
return save.money or 0
|
||||
end
|
||||
|
||||
function Gen.setMoney(save, amount)
|
||||
if Gen.of(save) == 2 then
|
||||
save.player = save.player or {}
|
||||
save.player.money = amount
|
||||
else
|
||||
save.money = amount
|
||||
end
|
||||
end
|
||||
|
||||
function Gen.coins(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return (save.player and save.player.coins) or 0
|
||||
end
|
||||
return save.coins or 0
|
||||
end
|
||||
|
||||
function Gen.setCoins(save, amount)
|
||||
if Gen.of(save) == 2 then
|
||||
save.player = save.player or {}
|
||||
save.player.coins = amount
|
||||
else
|
||||
save.coins = amount
|
||||
end
|
||||
end
|
||||
|
||||
function Gen.dexOwnedKey(save)
|
||||
if Gen.of(save) == 2 then return "caught" end
|
||||
return "owned"
|
||||
end
|
||||
|
||||
function Gen.playerMap(save)
|
||||
if Gen.of(save) == 2 then
|
||||
local p = save.position
|
||||
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end
|
||||
return save.spawn, 0, 0
|
||||
end
|
||||
local p = save.player or {}
|
||||
return p.map, p.x or 0, p.y or 0
|
||||
end
|
||||
|
||||
function Gen.setPlayerHere(save, mapId, x, y, facing)
|
||||
if Gen.of(save) == 2 then
|
||||
local prev = save.position or {}
|
||||
save.position = {
|
||||
map = mapId,
|
||||
x = x,
|
||||
y = y,
|
||||
facing = facing or prev.facing or "down",
|
||||
}
|
||||
return
|
||||
end
|
||||
save.player = save.player or {}
|
||||
save.player.map = mapId
|
||||
save.player.x = x
|
||||
save.player.y = y
|
||||
end
|
||||
|
||||
local JOHTO = {
|
||||
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
|
||||
}
|
||||
local KANTO = {
|
||||
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
|
||||
"SOUL", "MARSH", "VOLCANO", "EARTH",
|
||||
}
|
||||
|
||||
function Gen.badgeIds(save, cat)
|
||||
if Gen.of(save) == 2 then
|
||||
local ids = {}
|
||||
for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end
|
||||
for _, name in ipairs(KANTO) do ids[#ids + 1] = name end
|
||||
return ids
|
||||
end
|
||||
local ids = {}
|
||||
for _, id in ipairs((cat and cat.items) or {}) do
|
||||
if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
local KANTO_SET = {}
|
||||
for _, name in ipairs(KANTO) do KANTO_SET[name] = true end
|
||||
|
||||
function Gen.hasBadge(save, id)
|
||||
if Gen.of(save) == 2 then
|
||||
local p = save.player or {}
|
||||
local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {})
|
||||
if store[id] then return true end
|
||||
local list = KANTO_SET[id] and KANTO or JOHTO
|
||||
for index, name in ipairs(list) do
|
||||
if name == id then return store[index] == true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
return save.inventory and save.inventory[id] and true or false
|
||||
end
|
||||
|
||||
function Gen.toggleBadge(save, id)
|
||||
if Gen.of(save) == 2 then
|
||||
save.player = save.player or {}
|
||||
local storeName = KANTO_SET[id] and "kantoBadges" or "badges"
|
||||
save.player[storeName] = save.player[storeName] or {}
|
||||
local store = save.player[storeName]
|
||||
local on = Gen.hasBadge(save, id)
|
||||
store[id] = (not on) and true or nil
|
||||
for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do
|
||||
if name == id then store[index] = nil end
|
||||
end
|
||||
return not on
|
||||
end
|
||||
local on = save.inventory[id] and true or false
|
||||
save.inventory[id] = (not on) and 1 or nil
|
||||
return not on
|
||||
end
|
||||
|
||||
local function goldFlagId(name)
|
||||
local flags = require("src.core.gen2.FlagNames")
|
||||
return flags.events and flags.events[name]
|
||||
end
|
||||
|
||||
function Gen.getFlag(save, name)
|
||||
if Gen.of(save) == 2 then
|
||||
local id = goldFlagId(name)
|
||||
if id then
|
||||
local Events2 = require("src.world.gen2.Events")
|
||||
local ev = Events2.new()
|
||||
ev:restore(save.events)
|
||||
return ev:get(id)
|
||||
end
|
||||
return save.flags and save.flags[name] == true
|
||||
end
|
||||
return save.flags and save.flags[name] == true
|
||||
end
|
||||
|
||||
function Gen.setFlag(save, name, on)
|
||||
if Gen.of(save) == 2 then
|
||||
local id = goldFlagId(name)
|
||||
if id then
|
||||
local Events2 = require("src.world.gen2.Events")
|
||||
local ev = Events2.new()
|
||||
ev:restore(save.events)
|
||||
ev:set(id, on and true or false)
|
||||
save.events = ev:serialize()
|
||||
return
|
||||
end
|
||||
save.flags = save.flags or {}
|
||||
save.flags[name] = on and true or nil
|
||||
return
|
||||
end
|
||||
save.flags = save.flags or {}
|
||||
save.flags[name] = on and true or nil
|
||||
end
|
||||
|
||||
function Gen.flagCount(save)
|
||||
if Gen.of(save) == 2 then
|
||||
local n = 0
|
||||
for _ in pairs(save.events or {}) do n = n + 1 end
|
||||
for _ in pairs(save.flags or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
local n = 0
|
||||
for _ in pairs(save.flags or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
function Gen.exp(mon)
|
||||
return mon.experience or mon.exp or 0
|
||||
end
|
||||
|
||||
return Gen
|
||||
@@ -4,40 +4,23 @@ local Growth = require("src.pokemon.Growth")
|
||||
|
||||
local MonOps = {}
|
||||
|
||||
function MonOps.create(data, species, level, gen)
|
||||
if gen == 2 then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local mon = Mon.new(data, species, level)
|
||||
assert(mon, "unknown species " .. tostring(species))
|
||||
return mon
|
||||
end
|
||||
function MonOps.create(data, species, level)
|
||||
return Pokemon.new(data, species, level)
|
||||
end
|
||||
|
||||
function MonOps.recalc(data, mon, gen)
|
||||
if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then
|
||||
require("src.battle.gen2.Mon").refreshStats(mon, data)
|
||||
return
|
||||
end
|
||||
function MonOps.recalc(data, mon)
|
||||
local def = data.pokemon[mon.species]
|
||||
assert(def, "unknown species")
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
|
||||
end
|
||||
|
||||
function MonOps.setLevel(data, mon, level, gen)
|
||||
function MonOps.setLevel(data, mon, level)
|
||||
level = math.max(1, math.min(100, math.floor(level)))
|
||||
local def = data.pokemon[mon.species]
|
||||
mon.level = level
|
||||
if gen == 2 or mon.experience ~= nil then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
mon.experience = Mon.experienceForLevel(growth, level)
|
||||
Mon.refreshStats(mon, data)
|
||||
return
|
||||
end
|
||||
mon.exp = Growth.expForLevel(def.growthRate, level)
|
||||
MonOps.recalc(data, mon, gen)
|
||||
MonOps.recalc(data, mon)
|
||||
end
|
||||
|
||||
function MonOps.setMove(data, mon, slot, moveId)
|
||||
@@ -49,7 +32,6 @@ function MonOps.setMove(data, mon, slot, moveId)
|
||||
id = moveId,
|
||||
pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5),
|
||||
ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil,
|
||||
maxPp = mdef.pp,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -60,38 +42,19 @@ function MonOps.syncHpDv(dvs)
|
||||
return dvs
|
||||
end
|
||||
|
||||
function MonOps.setDv(data, mon, key, value, gen)
|
||||
function MonOps.setDv(data, mon, key, value)
|
||||
mon.dvs[key] = math.max(0, math.min(15, math.floor(value)))
|
||||
if key ~= "hp" then
|
||||
MonOps.syncHpDv(mon.dvs)
|
||||
end
|
||||
if gen == 2 or (mon.stats and mon.stats.specialAttack) then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
mon.dvs.hp = Mon.hpDV(mon.dvs)
|
||||
local def = data.pokemon[mon.species]
|
||||
if def then
|
||||
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
end
|
||||
end
|
||||
end
|
||||
MonOps.recalc(data, mon, gen)
|
||||
MonOps.recalc(data, mon)
|
||||
end
|
||||
|
||||
-- Keep level; resync exp to the species growth curve (species changes).
|
||||
-- Gold also copies def.name onto mon.name: the party list and SUMMARY print
|
||||
-- that field when nickname is nil, so leaving the previous species' name
|
||||
-- made a swapped mon still read as ABRA (or whoever was added first).
|
||||
function MonOps.setSpecies(data, mon, species, gen)
|
||||
function MonOps.setSpecies(data, mon, species)
|
||||
assert(data.pokemon[species], "unknown species")
|
||||
mon.species = species
|
||||
MonOps.setLevel(data, mon, mon.level, gen)
|
||||
if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then
|
||||
require("src.battle.gen2.Mon").syncIdentity(mon, data)
|
||||
end
|
||||
MonOps.setLevel(data, mon, mon.level)
|
||||
end
|
||||
|
||||
return MonOps
|
||||
|
||||
+70
-246
@@ -17,7 +17,6 @@ local BoxesMod = require("src.pokemon.Boxes")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local MonOps = require("MonOps")
|
||||
local Charmap = require("src.save_convert.data.charmap")
|
||||
local Gen = require("Gen")
|
||||
|
||||
local Ops = {}
|
||||
|
||||
@@ -36,62 +35,6 @@ local function clamp(n, lo, hi)
|
||||
end
|
||||
Ops.clamp = clamp
|
||||
|
||||
local function stampNewMon(S, mon)
|
||||
if Gen.ofState(S) == 2 then
|
||||
require("src.battle.gen2.Mon").stampOT(S.save, mon)
|
||||
else
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
local function createMon(S, species, level)
|
||||
local mon = MonOps.create(S.data, species, level, Gen.ofState(S))
|
||||
return stampNewMon(S, mon)
|
||||
end
|
||||
|
||||
local function partySlot(S, mon)
|
||||
for i, member in ipairs(S.save.party or {}) do
|
||||
if member == mon then return i end
|
||||
end
|
||||
end
|
||||
|
||||
-- Portrait mail (and CheckPokeMail) store species on the letter, not the mon.
|
||||
local function partyMailEntry(S, slot)
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
return Mail.state(S.save).party[slot]
|
||||
end
|
||||
|
||||
local function syncPartyMailSpecies(S, mon)
|
||||
if Gen.ofState(S) ~= 2 then return end
|
||||
local slot = partySlot(S, mon)
|
||||
if not slot then return end
|
||||
local entry = partyMailEntry(S, slot)
|
||||
if entry then entry.species = mon.species end
|
||||
end
|
||||
|
||||
local function syncPartyMailHeldItem(S, mon, prevItem, newItem)
|
||||
if Gen.ofState(S) ~= 2 then return end
|
||||
local slot = partySlot(S, mon)
|
||||
if not slot then return end
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
if Mail.isMail(newItem) then
|
||||
local prev = partyMailEntry(S, slot)
|
||||
local player = S.save.player or {}
|
||||
Mail.set(S.save, slot, Mail.entry(
|
||||
newItem,
|
||||
prev and prev.message or "",
|
||||
tostring(mon.otName or mon.ot or player.name or ""):sub(1, Mail.AUTHOR_LENGTH),
|
||||
mon.otId or player.id or 0,
|
||||
mon.species))
|
||||
return
|
||||
end
|
||||
if Mail.isMail(prevItem) or partyMailEntry(S, slot) then
|
||||
Mail.clear(S.save, slot)
|
||||
end
|
||||
end
|
||||
|
||||
local function now()
|
||||
if love and love.timer and love.timer.getTime then
|
||||
return love.timer.getTime()
|
||||
@@ -168,7 +111,9 @@ function Ops.partyAdd(S)
|
||||
return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX))
|
||||
end
|
||||
local species = S.cat.species[1]
|
||||
local mon = createMon(S, species, 5)
|
||||
local mon = MonOps.create(S.data, species, 5)
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
table.insert(S.save.party, mon)
|
||||
S.selectedParty = #S.save.party
|
||||
S.editingMon = mon
|
||||
@@ -184,11 +129,6 @@ function Ops.partyRemove(S)
|
||||
return false
|
||||
end
|
||||
table.remove(S.save.party, index)
|
||||
-- sPartyMail is keyed by party slot, not by mon: dropping a member without
|
||||
-- shifting letters hands the next mon someone else's mail.
|
||||
if Gen.ofState(S) == 2 then
|
||||
require("src.core.gen2.Mail").removeSlot(S.save, index)
|
||||
end
|
||||
if S.editingMon == mon then S.editingMon = nil end
|
||||
S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1))
|
||||
S.editingMon = S.save.party[S.selectedParty]
|
||||
@@ -204,9 +144,6 @@ function Ops.partyMove(S, delta)
|
||||
return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon")
|
||||
end
|
||||
party[i], party[j] = party[j], party[i]
|
||||
if Gen.ofState(S) == 2 then
|
||||
require("src.core.gen2.Mail").swapSlots(S.save, i, j)
|
||||
end
|
||||
S.selectedParty = j
|
||||
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
|
||||
end
|
||||
@@ -220,7 +157,7 @@ function Ops.setLevel(S, mon, level)
|
||||
if want == mon.level then
|
||||
return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100")
|
||||
end
|
||||
MonOps.setLevel(S.data, mon, want, Gen.ofState(S))
|
||||
MonOps.setLevel(S.data, mon, want)
|
||||
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
|
||||
end
|
||||
|
||||
@@ -235,23 +172,15 @@ end
|
||||
-- instead of trusting the list: without this, picking such a species walked
|
||||
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
|
||||
-- down (#541).
|
||||
local BASE_STAT_KEYS_G1 = { "hp", "attack", "defense", "speed", "special" }
|
||||
local BASE_STAT_KEYS_G2 = {
|
||||
"hp", "attack", "defense", "speed", "specialAttack", "specialDefense",
|
||||
}
|
||||
|
||||
local function baseStatsComplete(bs, keys)
|
||||
for _, key in ipairs(keys) do
|
||||
if type(bs[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
||||
|
||||
function Ops.speciesUsable(S, id)
|
||||
local def = id and S.data.pokemon[id]
|
||||
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
|
||||
return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1)
|
||||
or baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G2)
|
||||
for _, key in ipairs(BASE_STAT_KEYS) do
|
||||
if type(def.baseStats[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- The one funnel every species change goes through (the picker, the stepper,
|
||||
@@ -270,19 +199,14 @@ function Ops.setSpecies(S, mon, id)
|
||||
end
|
||||
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
|
||||
-- it in place, so holding the old reference is a real rollback.
|
||||
local wasSpecies, wasLevel, wasExp, wasExperience = mon.species, mon.level, mon.exp, mon.experience
|
||||
local wasStats, wasHp, wasName = mon.stats, mon.hp, mon.name
|
||||
local wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp =
|
||||
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp
|
||||
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id, Gen.ofState(S))
|
||||
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
|
||||
local wasStats, wasHp = mon.stats, mon.hp
|
||||
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
|
||||
if not ok then
|
||||
mon.species, mon.level, mon.exp, mon.experience = wasSpecies, wasLevel, wasExp, wasExperience
|
||||
mon.stats, mon.hp, mon.name = wasStats, wasHp, wasName
|
||||
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp =
|
||||
wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp
|
||||
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
|
||||
mon.stats, mon.hp = wasStats, wasHp
|
||||
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
|
||||
end
|
||||
syncPartyMailSpecies(S, mon)
|
||||
return Ops.mark(S, ("Species set to %s"):format(id))
|
||||
end
|
||||
|
||||
@@ -389,9 +313,9 @@ end
|
||||
-- the target is the box, not a mon.
|
||||
function Ops.openBoxAddPicker(S, Kit)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
if #box >= Ops.boxCapacity(S) then
|
||||
if #box >= BoxesMod.CAPACITY then
|
||||
return Ops.say(S, ("Box %d is full (%d/%d)")
|
||||
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
|
||||
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
||||
end
|
||||
S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" }
|
||||
if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529)
|
||||
@@ -403,15 +327,17 @@ end
|
||||
-- box mon and a party mon born in the editor are indistinguishable.
|
||||
function Ops.boxAddSpecies(S, id)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
if #box >= Ops.boxCapacity(S) then
|
||||
if #box >= BoxesMod.CAPACITY then
|
||||
return Ops.say(S, ("Box %d is full (%d/%d)")
|
||||
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
|
||||
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
||||
end
|
||||
if not Ops.speciesUsable(S, id) then
|
||||
return Ops.say(S, ("%s has no usable base stats, cannot add it")
|
||||
:format(tostring(id)))
|
||||
end
|
||||
local mon = createMon(S, id, 5)
|
||||
local mon = MonOps.create(S.data, id, 5)
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
table.insert(box, mon)
|
||||
S.selectedBoxSlot = #box
|
||||
S.editingMon = mon
|
||||
@@ -425,7 +351,7 @@ function Ops.setDv(S, mon, key, value)
|
||||
if want == mon.dvs[key] then
|
||||
return Ops.say(S, ("%s DV is already %d"):format(key, want))
|
||||
end
|
||||
MonOps.setDv(S.data, mon, key, want, Gen.ofState(S))
|
||||
MonOps.setDv(S.data, mon, key, want)
|
||||
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
|
||||
end
|
||||
|
||||
@@ -456,17 +382,7 @@ end
|
||||
function Ops.resetMoves(S, mon)
|
||||
if not mon then return false end
|
||||
local def = S.data.pokemon[mon.species]
|
||||
local gen = Gen.ofState(S)
|
||||
local learned
|
||||
if gen == 2 then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
learned = {}
|
||||
for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do
|
||||
learned[#learned + 1] = mv.id
|
||||
end
|
||||
else
|
||||
learned = Pokemon.movesAtLevel(def, mon.level)
|
||||
end
|
||||
local learned = Pokemon.movesAtLevel(def, mon.level)
|
||||
mon.moves = {}
|
||||
for slot, id in ipairs(learned) do
|
||||
MonOps.setMove(S.data, mon, slot, id)
|
||||
@@ -481,7 +397,6 @@ function Ops.healMon(S, mon)
|
||||
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
|
||||
end
|
||||
mon.hp = mon.stats.hp
|
||||
if mon.maxHp then mon.maxHp = mon.stats.hp end
|
||||
mon.status = nil
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
local def = S.data.moves[mv.id]
|
||||
@@ -639,35 +554,27 @@ function Ops.clearNickname(S, mon)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ boxes
|
||||
function Ops.boxCount(S)
|
||||
return Gen.boxCount(S.save)
|
||||
end
|
||||
|
||||
function Ops.boxCapacity(S)
|
||||
return Gen.boxCapacity(S.save)
|
||||
end
|
||||
|
||||
function Ops.boxes(S)
|
||||
return Gen.ensureBoxes(S.save)
|
||||
return BoxesMod.ensure(S.save)
|
||||
end
|
||||
|
||||
function Ops.selectBox(S, index)
|
||||
S.selectedBox = clamp(index, 1, Ops.boxCount(S))
|
||||
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
|
||||
S.selectedBoxSlot = 1
|
||||
S.save.currentBox = S.selectedBox
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S))
|
||||
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY)
|
||||
return true
|
||||
end
|
||||
|
||||
function Ops.stepBox(S, delta)
|
||||
local n = Ops.boxCount(S)
|
||||
local n = BoxesMod.COUNT
|
||||
return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1)
|
||||
end
|
||||
|
||||
function Ops.selectBoxSlot(S, index)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S))
|
||||
S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY)
|
||||
local mon = box[S.selectedBoxSlot]
|
||||
S.editingMon = mon
|
||||
S.status = mon
|
||||
@@ -682,12 +589,14 @@ end
|
||||
-- chooses what lands in the box instead of always getting catalog entry #1.
|
||||
function Ops.boxAdd(S)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
if #box >= Ops.boxCapacity(S) then
|
||||
if #box >= BoxesMod.CAPACITY then
|
||||
return Ops.say(S, ("Box %d is full (%d/%d)")
|
||||
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
|
||||
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
||||
end
|
||||
local species = S.cat.species[1]
|
||||
local mon = createMon(S, species, 5)
|
||||
local mon = MonOps.create(S.data, species, 5)
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
table.insert(box, mon)
|
||||
S.selectedBoxSlot = #box
|
||||
S.editingMon = mon
|
||||
@@ -703,16 +612,9 @@ function Ops.withdraw(S)
|
||||
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
|
||||
:format(#S.save.party, PartyMod.MAX))
|
||||
end
|
||||
if Gen.ofState(S) == 2 then
|
||||
local Boxes2 = require("src.core.gen2.Boxes")
|
||||
local ok, reason = Boxes2.canWithdraw(S.save, S.selectedBox, S.selectedBoxSlot)
|
||||
if not ok then return Ops.say(S, reason) end
|
||||
Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot)
|
||||
else
|
||||
table.remove(box, S.selectedBoxSlot)
|
||||
table.insert(S.save.party, mon)
|
||||
end
|
||||
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1))
|
||||
table.remove(box, S.selectedBoxSlot)
|
||||
table.insert(S.save.party, mon)
|
||||
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
|
||||
S.selectedParty = #S.save.party
|
||||
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
|
||||
end
|
||||
@@ -737,17 +639,6 @@ function Ops.deposit(S)
|
||||
local i = S.selectedParty
|
||||
local mon = S.save.party[i]
|
||||
if not mon then return Ops.say(S, "No party slot selected") end
|
||||
if Gen.ofState(S) == 2 then
|
||||
local Boxes2 = require("src.core.gen2.Boxes")
|
||||
local boxIndex = S.selectedBox or S.save.currentBox or 1
|
||||
local ok, reason = Boxes2.canDeposit(S.save, i, boxIndex)
|
||||
if not ok then return Ops.say(S, reason) end
|
||||
Boxes2.deposit(S.save, i, boxIndex)
|
||||
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
|
||||
S.selectedBox = boxIndex
|
||||
if S.editingMon == mon then S.editingMon = nil end
|
||||
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex))
|
||||
end
|
||||
local boxNum = BoxesMod.deposit(S.save, mon)
|
||||
if not boxNum then
|
||||
return Ops.say(S, "Every box is full, release something first")
|
||||
@@ -761,13 +652,12 @@ end
|
||||
|
||||
-- ------------------------------------------------------------------ items
|
||||
function Ops.addMoney(S, delta)
|
||||
local have = Gen.money(S.save)
|
||||
local want = clamp(have + delta, 0, Ops.MONEY_MAX)
|
||||
if want == have then
|
||||
local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX)
|
||||
if want == S.save.money then
|
||||
return Ops.say(S, delta < 0 and "Money is already $0"
|
||||
or ("Money is already capped at $%d"):format(Ops.MONEY_MAX))
|
||||
end
|
||||
Gen.setMoney(S.save, want)
|
||||
S.save.money = want
|
||||
return Ops.mark(S, ("Money set to $%d"):format(want))
|
||||
end
|
||||
|
||||
@@ -775,29 +665,15 @@ function Ops.maxMoney(S)
|
||||
return Ops.addMoney(S, Ops.MONEY_MAX)
|
||||
end
|
||||
|
||||
Ops.COIN_MAX = 9999
|
||||
|
||||
function Ops.addCoins(S, delta)
|
||||
local have = Gen.coins(S.save)
|
||||
local want = clamp(have + delta, 0, Ops.COIN_MAX)
|
||||
if want == have then
|
||||
return Ops.say(S, delta < 0 and "Coins are already 0"
|
||||
or ("Coins are already capped at %d"):format(Ops.COIN_MAX))
|
||||
end
|
||||
Gen.setCoins(S.save, want)
|
||||
return Ops.mark(S, ("Coins set to %d"):format(want))
|
||||
end
|
||||
|
||||
function Ops.addToBag(S, id)
|
||||
if not id then return Ops.say(S, "Pick an item first") end
|
||||
local pocket = Bag.pocketOf(id, S.data)
|
||||
local capacity = Bag.capacity(S.data, pocket)
|
||||
local capacity = Bag.capacity(S.data)
|
||||
if Bag.add(S.save, id, 1, S.data) then
|
||||
return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)")
|
||||
:format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket))
|
||||
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
|
||||
:format(id, Bag.slots(S.save), capacity))
|
||||
end
|
||||
return Ops.say(S, ("Bag is full (%d/%d %s slots)")
|
||||
:format(Bag.slots(S.save, S.data, pocket), capacity, pocket))
|
||||
return Ops.say(S, ("Bag is full (%d/%d slots)")
|
||||
:format(Bag.slots(S.save), capacity))
|
||||
end
|
||||
|
||||
function Ops.bagAdjust(S, id, delta)
|
||||
@@ -839,12 +715,6 @@ end
|
||||
function Ops.addToPc(S, id)
|
||||
if not id then return Ops.say(S, "Pick an item first") end
|
||||
local pc = Ops.pcItems(S)
|
||||
local n = 0
|
||||
for _ in pairs(pc) do n = n + 1 end
|
||||
if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then
|
||||
return Ops.say(S, "PC item storage is full (50 stacks)")
|
||||
end
|
||||
local pc = Ops.pcItems(S)
|
||||
pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1)
|
||||
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
|
||||
end
|
||||
@@ -879,17 +749,27 @@ function Ops.isBadgeId(id)
|
||||
end
|
||||
|
||||
function Ops.badgeIds(S)
|
||||
return Gen.badgeIds(S.save, S.cat)
|
||||
local ids = {}
|
||||
for _, id in ipairs(S.cat.items) do
|
||||
if Ops.isBadgeId(id) then ids[#ids + 1] = id end
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
function Ops.toggleBadge(S, id)
|
||||
local nowOn = Gen.toggleBadge(S.save, id)
|
||||
return Ops.mark(S, ("%s %s"):format(id, nowOn and "earned" or "removed"))
|
||||
-- #515: badges are truthy inventory entries written as 1 by the in-game
|
||||
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
|
||||
-- GenSave's .sav import; read and write that same shape here, or a badge
|
||||
-- earned in game reads as unowned and an editor-written boolean blows up
|
||||
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
|
||||
local on = S.save.inventory[id] and true or false
|
||||
S.save.inventory[id] = (not on) and 1 or nil
|
||||
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- events
|
||||
function Ops.setFlag(S, name, on)
|
||||
Gen.setFlag(S.save, name, on)
|
||||
S.save.flags[name] = on and true or nil
|
||||
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
|
||||
end
|
||||
|
||||
@@ -921,19 +801,17 @@ end
|
||||
|
||||
-- -------------------------------------------------------------------- dex
|
||||
function Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} }
|
||||
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
|
||||
S.save.pokedex.seen = S.save.pokedex.seen or {}
|
||||
S.save.pokedex[key] = S.save.pokedex[key] or {}
|
||||
S.save.pokedex.owned = S.save.pokedex.owned or {}
|
||||
return S.save.pokedex
|
||||
end
|
||||
|
||||
function Ops.dexCounts(S)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
local seen, owned = 0, 0
|
||||
for _ in pairs(dex.seen) do seen = seen + 1 end
|
||||
for _ in pairs(dex[key] or {}) do owned = owned + 1 end
|
||||
for _ in pairs(dex.owned) do owned = owned + 1 end
|
||||
return seen, owned, #S.cat.species
|
||||
end
|
||||
|
||||
@@ -941,28 +819,25 @@ end
|
||||
-- the game's own rule, enforced here so a hand-edited dex stays legal.
|
||||
function Ops.dexSeen(S, species, on)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
dex.seen[species] = on and true or nil
|
||||
if not on then dex[key][species] = nil end
|
||||
if not on then dex.owned[species] = nil end
|
||||
return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared"))
|
||||
end
|
||||
|
||||
function Ops.dexOwned(S, species, on)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
dex[key][species] = on and true or nil
|
||||
dex.owned[species] = on and true or nil
|
||||
if on then dex.seen[species] = true end
|
||||
return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned"))
|
||||
end
|
||||
|
||||
function Ops.dexStamp(S)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
local n = 0
|
||||
local function stamp(mon)
|
||||
if not dex[key][mon.species] then n = n + 1 end
|
||||
if not dex.owned[mon.species] then n = n + 1 end
|
||||
dex.seen[mon.species] = true
|
||||
dex[key][mon.species] = true
|
||||
dex.owned[mon.species] = true
|
||||
end
|
||||
for _, m in ipairs(S.save.party) do stamp(m) end
|
||||
for _, box in ipairs(S.save.boxes or {}) do
|
||||
@@ -980,10 +855,9 @@ end
|
||||
|
||||
function Ops.dexOwnAll(S)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
for _, species in ipairs(S.cat.species) do
|
||||
dex.seen[species] = true
|
||||
dex[key][species] = true
|
||||
dex.owned[species] = true
|
||||
end
|
||||
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
|
||||
end
|
||||
@@ -992,8 +866,7 @@ function Ops.dexClear(S)
|
||||
if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then
|
||||
return false
|
||||
end
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
S.save.pokedex = { seen = {}, [key] = {} }
|
||||
S.save.pokedex = { seen = {}, owned = {} }
|
||||
return Ops.mark(S, "Pokedex wiped")
|
||||
end
|
||||
|
||||
@@ -1054,11 +927,6 @@ end
|
||||
-- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save
|
||||
-- has already visited.
|
||||
function Ops.isOutdoor(S, map)
|
||||
if not map or not map.def then return false end
|
||||
local Map2 = require("src.world.gen2.Map")
|
||||
if Gen.ofState(S) == 2 and Map2.isOutdoor then
|
||||
return Map2.isOutdoor(map.def) and true or false
|
||||
end
|
||||
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
|
||||
return true
|
||||
end
|
||||
@@ -1069,17 +937,15 @@ end
|
||||
function Ops.setPlayerHere(S)
|
||||
local cell = S.mapClickCell
|
||||
if not cell then return Ops.say(S, "Click a cell first") end
|
||||
Gen.setPlayerHere(S.save, S.mapId, cell.cx, cell.cy)
|
||||
S.save.player.map = S.mapId
|
||||
S.save.player.x = cell.cx
|
||||
S.save.player.y = cell.cy
|
||||
return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
||||
end
|
||||
|
||||
function Ops.setLastOutdoor(S, map)
|
||||
local cell = S.mapClickCell
|
||||
if not cell then return Ops.say(S, "Click a cell first") end
|
||||
if Gen.ofState(S) == 2 then
|
||||
S.save.spawn = S.mapId
|
||||
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
|
||||
end
|
||||
if not Ops.isOutdoor(S, map) then
|
||||
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
|
||||
end
|
||||
@@ -1090,50 +956,8 @@ end
|
||||
function Ops.setLastHeal(S)
|
||||
local cell = S.mapClickCell
|
||||
if not cell then return Ops.say(S, "Click a cell first") end
|
||||
if Gen.ofState(S) == 2 then
|
||||
S.save.spawn = S.mapId
|
||||
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
|
||||
end
|
||||
S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy }
|
||||
return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
||||
end
|
||||
|
||||
function Ops.setHeldItem(S, mon, id)
|
||||
if not mon then return false end
|
||||
if id == "" or id == nil then
|
||||
if not mon.item then return Ops.say(S, "No held item to clear") end
|
||||
local was = mon.item
|
||||
mon.item = nil
|
||||
syncPartyMailHeldItem(S, mon, was, nil)
|
||||
return Ops.mark(S, ("Cleared held item (%s)"):format(was))
|
||||
end
|
||||
if not S.data.items[id] then
|
||||
return Ops.say(S, ("%s is not an item"):format(tostring(id)))
|
||||
end
|
||||
local was = mon.item
|
||||
mon.item = id
|
||||
syncPartyMailHeldItem(S, mon, was, id)
|
||||
return Ops.mark(S, ("%s now holds %s"):format(mon.species, id))
|
||||
end
|
||||
|
||||
function Ops.setHappiness(S, mon, value)
|
||||
if not mon then return false end
|
||||
local want = clamp(math.floor(value), 0, 255)
|
||||
if want == (mon.happiness or 0) then
|
||||
return Ops.say(S, ("Happiness is already %d"):format(want))
|
||||
end
|
||||
mon.happiness = want
|
||||
return Ops.mark(S, ("%s happiness %d"):format(mon.species, want))
|
||||
end
|
||||
|
||||
function Ops.setPokerus(S, mon, value)
|
||||
if not mon then return false end
|
||||
local want = clamp(math.floor(value), 0, 255)
|
||||
if want == (mon.pokerus or 0) then
|
||||
return Ops.say(S, ("Pokerus is already %d"):format(want))
|
||||
end
|
||||
mon.pokerus = want
|
||||
return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want))
|
||||
end
|
||||
|
||||
return Ops
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
-- in the selected box as a Lv5 mon built by the same MonOps path partyAdd
|
||||
-- uses, so its stats, exp and moves are consistent.
|
||||
|
||||
local BoxesMod = require("src.pokemon.Boxes")
|
||||
local PartyMod = require("src.pokemon.Party")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
@@ -32,12 +33,12 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
|
||||
local s = Kit.scale
|
||||
local pad = 16 * s
|
||||
Kit.card(x, y, stripW, h)
|
||||
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(Ops.boxCount(S)))
|
||||
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT))
|
||||
local stripTop = y + pad + Kit.textHeight("caption") + 10 * s
|
||||
local stripInner = stripW - 2 * pad
|
||||
local bRowH = math.min(30 * s, math.max(22 * s,
|
||||
(h - (stripTop - y) - pad - (Ops.boxCount(S) - 1) * 6 * s) / Ops.boxCount(S)))
|
||||
for i = 1, Ops.boxCount(S) do
|
||||
(h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT))
|
||||
for i = 1, BoxesMod.COUNT do
|
||||
local ry = stripTop + (i - 1) * (bRowH + 6 * s)
|
||||
if ry + bRowH > y + h - pad then break end
|
||||
if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then
|
||||
@@ -51,7 +52,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
|
||||
ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
|
||||
local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s
|
||||
Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s,
|
||||
fill / Ops.boxCapacity(S) * 100, fill >= Ops.boxCapacity(S) and PAL.yellow or PAL.blue)
|
||||
fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,7 +66,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
Kit.text("tab", ("Box %d"):format(S.selectedBox), gx,
|
||||
y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading)
|
||||
local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox))
|
||||
Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)),
|
||||
Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY),
|
||||
gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption)
|
||||
local navW = 34 * s
|
||||
if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<",
|
||||
@@ -101,7 +102,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
end
|
||||
if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel,
|
||||
{ font = "small", radius = 9 * s,
|
||||
enabled = #box < Ops.boxCapacity(S) }) then
|
||||
enabled = #box < BoxesMod.CAPACITY }) then
|
||||
Ops.openBoxAddPicker(S, Kit)
|
||||
end
|
||||
if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel,
|
||||
@@ -118,7 +119,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
-- of five slivers.
|
||||
local cols = math.max(2, math.min(COLS,
|
||||
math.floor((ginner + cellGap) / (86 * s + cellGap))))
|
||||
local rows = math.ceil(Ops.boxCapacity(S) / cols)
|
||||
local rows = math.ceil(BoxesMod.CAPACITY / cols)
|
||||
local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols)
|
||||
-- floor at Kit's 26px tap target so a short window shrinks the cells but
|
||||
-- never inverts them (#715); overflow clips inside the grid body rather
|
||||
@@ -127,7 +128,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s))
|
||||
|
||||
Kit.pushClip(gx, gridTop, ginner, gridH)
|
||||
for i = 1, Ops.boxCapacity(S) do
|
||||
for i = 1, BoxesMod.CAPACITY do
|
||||
local cc = (i - 1) % cols
|
||||
local cr = math.floor((i - 1) / cols)
|
||||
local bx = gx + cc * (cellW + cellGap)
|
||||
@@ -219,7 +220,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
local s = Kit.scale
|
||||
local gap = 20 * s
|
||||
|
||||
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, Ops.boxCount(S))
|
||||
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT)
|
||||
S.save.currentBox = S.selectedBox
|
||||
local boxes = Ops.boxes(S)
|
||||
local box = boxes[S.selectedBox]
|
||||
|
||||
@@ -145,8 +145,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
local rx = cx + ci * (colW + colGap)
|
||||
local ry = gridTop + ri * (rowH + rowGap)
|
||||
local isSeen = dex.seen[id] == true
|
||||
local ownedKey = require("Gen").dexOwnedKey(S.save)
|
||||
local isOwned = dex[ownedKey] and dex[ownedKey][id] == true
|
||||
local isOwned = dex.owned[id] == true
|
||||
|
||||
Theme.row(rx, ry, colW, rowH, 9 * s, 0.6)
|
||||
local def = S.data.pokemon[id]
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local M = {}
|
||||
@@ -51,7 +50,7 @@ local function buildRows(S)
|
||||
if contains(name, filter) then
|
||||
rows[#rows + 1] = {
|
||||
label = name,
|
||||
checked = Gen.getFlag(S.save, name),
|
||||
checked = S.save.flags[name] == true,
|
||||
set = function(on) Ops.setFlag(S, name, on) end,
|
||||
}
|
||||
end
|
||||
@@ -108,12 +107,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
-- past the card edge.
|
||||
local pillH = 32 * s
|
||||
local px, py = cx, y + pad
|
||||
local pills = SUB_TABS
|
||||
if Gen.of(S.save) == 2 then
|
||||
pills = { SUB_TABS[1] }
|
||||
if S.eventsTab ~= "flags" then S.eventsTab = "flags" end
|
||||
end
|
||||
for _, t in ipairs(pills) do
|
||||
for _, t in ipairs(SUB_TABS) do
|
||||
local pw = Kit.textWidth("small", t.label) + 32 * s
|
||||
if px > cx and px + pw > cx + inner then
|
||||
px = cx
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local M = {}
|
||||
@@ -47,11 +46,7 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu
|
||||
local qtyW = Kit.textWidth("monoRow", qtyText)
|
||||
Kit.textRight("monoRow", qtyText, bx - 10 * s,
|
||||
y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading)
|
||||
local label = id
|
||||
if Gen.of(S.save) == 2 then
|
||||
label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id
|
||||
end
|
||||
Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)),
|
||||
Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)),
|
||||
x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text)
|
||||
return clicked
|
||||
end
|
||||
@@ -60,13 +55,9 @@ end
|
||||
-- Each card is a function of its own rect so the wide (three column) and the
|
||||
-- stacked (#715) layouts are the same drawing code with different geometry.
|
||||
|
||||
local function moneyHeight(Kit, s, pad, S)
|
||||
local h = pad * 2 + Kit.textHeight("caption") + 8 * s
|
||||
local function moneyHeight(Kit, s, pad)
|
||||
return pad * 2 + Kit.textHeight("caption") + 8 * s
|
||||
+ Kit.textHeight("headline") + 10 * s + 30 * s
|
||||
if S and Gen.of(S.save) == 2 then
|
||||
h = h + 28 * s
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
local function drawMoney(S, Kit, x, y, w, h)
|
||||
@@ -77,19 +68,11 @@ local function drawMoney(S, Kit, x, y, w, h)
|
||||
local maxW = 74 * s
|
||||
if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out",
|
||||
{ kind = "accent", font = "tiny", radius = 7 * s,
|
||||
enabled = Gen.money(S.save) < Ops.MONEY_MAX }) then
|
||||
enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then
|
||||
Ops.maxMoney(S)
|
||||
end
|
||||
Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad,
|
||||
Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad,
|
||||
y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow)
|
||||
if Gen.of(S.save) == 2 then
|
||||
Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s,
|
||||
y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted)
|
||||
if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins",
|
||||
{ kind = "ghost", font = "tiny", radius = 6 * s }) then
|
||||
Ops.addCoins(S, 100)
|
||||
end
|
||||
end
|
||||
local mbY = y + h - pad - 30 * s
|
||||
local mbW = (w - 2 * pad - 3 * 8 * s) / 4
|
||||
for i, delta in ipairs(MONEY_STEPS) do
|
||||
@@ -116,7 +99,10 @@ local function drawBadges(S, Kit, x, y, w, h)
|
||||
Kit.card(x, y, w, h)
|
||||
local earned = 0
|
||||
for _, id in ipairs(badgeIds) do
|
||||
if Gen.hasBadge(S.save, id) then earned = earned + 1 end
|
||||
-- #515: truthy check, not `== true` -- the in-game grant path stores a
|
||||
-- number (see OverworldController.lua checkVictoryRewards), matching
|
||||
-- src/inventory/Badges.lua's own truthy read.
|
||||
if S.save.inventory[id] then earned = earned + 1 end
|
||||
end
|
||||
Kit.caption(x + pad, y + pad, "BADGES")
|
||||
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad,
|
||||
@@ -126,7 +112,7 @@ local function drawBadges(S, Kit, x, y, w, h)
|
||||
for i, id in ipairs(badgeIds) do
|
||||
local bc = (i - 1) % BADGE_COLS
|
||||
local br = math.floor((i - 1) / BADGE_COLS)
|
||||
local on = Gen.hasBadge(S.save, id)
|
||||
local on = S.save.inventory[id]
|
||||
local short = id:gsub("BADGE$", "")
|
||||
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
|
||||
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
|
||||
@@ -267,7 +253,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
-- drag over their own bodies first.
|
||||
local off = Theme.clamp(S.itemsScroll or 0, 0,
|
||||
math.max(0, (S._itemsContentH or 0) - h))
|
||||
local moneyH = moneyHeight(Kit, s, pad, S)
|
||||
local moneyH = moneyHeight(Kit, s, pad)
|
||||
local badgeH = badgeHeight(S, Kit, s, pad)
|
||||
local pickH = 280 * s
|
||||
local listH = 300 * s
|
||||
@@ -292,7 +278,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
-- Money and badges are fixed-height so the picker gets every pixel left
|
||||
-- over: cycling through ~250 item ids in a two-row list was the thing that
|
||||
-- made the old panel unusable.
|
||||
local moneyH = moneyHeight(Kit, s, pad, S)
|
||||
local moneyH = moneyHeight(Kit, s, pad)
|
||||
local badgeH = badgeHeight(S, Kit, s, pad)
|
||||
drawMoney(S, Kit, x, y, leftW, moneyH)
|
||||
drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap)
|
||||
|
||||
@@ -14,18 +14,12 @@ local MapLoader = require("src.world.MapLoader")
|
||||
local Warp = require("src.world.Warp")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local MapBrowser = {}
|
||||
|
||||
local CELL = 16 -- the walk grid; a cell is 16px of map art
|
||||
|
||||
local function playerPos(S)
|
||||
local map, x, y = Gen.playerMap(S.save)
|
||||
return map, x or 0, y or 0
|
||||
end
|
||||
|
||||
local function clampZoom(z)
|
||||
if z < 1 then return 1 end
|
||||
if z > 4 then return 4 end
|
||||
@@ -44,7 +38,7 @@ MapBrowser.centerOn = centerOn
|
||||
|
||||
local function sortedMapIds(data)
|
||||
local ids = {}
|
||||
for id in pairs(Gen.maps(data)) do ids[#ids + 1] = id end
|
||||
for id in pairs(data.maps) do ids[#ids + 1] = id end
|
||||
table.sort(ids)
|
||||
return ids
|
||||
end
|
||||
@@ -58,19 +52,7 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
|
||||
|
||||
local function goToWarp(S, warp)
|
||||
local def = warp.def
|
||||
if Gen.of(S.save) == 2 then
|
||||
local dest = def.destMap or def.map
|
||||
if dest then
|
||||
S.mapId = dest
|
||||
S.mapClickCell = nil
|
||||
S._mapCenteredFor = dest
|
||||
S.status = "Followed warp to " .. tostring(dest)
|
||||
else
|
||||
S.status = "Warp has no destination map"
|
||||
end
|
||||
return
|
||||
end
|
||||
local fromMap = Gen.maps(S.data)[S.mapId]
|
||||
local fromMap = S.data.maps[S.mapId]
|
||||
if fromMap and OUTSIDE_TILESETS[fromMap.tileset]
|
||||
and def.destMap ~= "LAST_MAP" and def.destMap ~= S.mapId then
|
||||
S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y }
|
||||
@@ -143,25 +125,22 @@ local function drawOverlays(S, map)
|
||||
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
|
||||
end
|
||||
love.graphics.setColor(0.27, 0.59, 1, 0.55)
|
||||
for _, wdef in ipairs(map.def.warps or {}) do
|
||||
for _, wdef in ipairs(map.def.warps) do
|
||||
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
|
||||
end
|
||||
local playerMap, px, py = playerPos(S)
|
||||
if playerMap == S.mapId then
|
||||
if S.save.player.map == S.mapId then
|
||||
love.graphics.setColor(1, 0.36, 0.4, 0.9)
|
||||
love.graphics.rectangle("fill", cellRect(px, py))
|
||||
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
|
||||
end
|
||||
if Gen.of(S.save) ~= 2 then
|
||||
local heal = S.save.lastHeal
|
||||
if heal and heal.map == S.mapId then
|
||||
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
|
||||
end
|
||||
local out = S.save.lastOutdoor
|
||||
if out and out.id == S.mapId then
|
||||
love.graphics.setColor(1, 0.8, 0.02, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(out.x, out.y))
|
||||
end
|
||||
local heal = S.save.lastHeal
|
||||
if heal and heal.map == S.mapId then
|
||||
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
|
||||
end
|
||||
local out = S.save.lastOutdoor
|
||||
if out and out.id == S.mapId then
|
||||
love.graphics.setColor(1, 0.8, 0.02, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(out.x, out.y))
|
||||
end
|
||||
if S.mapClickCell then
|
||||
love.graphics.setColor(1, 1, 0.35, 0.95)
|
||||
@@ -260,13 +239,9 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
#ids, perPage)
|
||||
if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location",
|
||||
{ font = "small", radius = 9 * s }) then
|
||||
local pmap, px, py = playerPos(S)
|
||||
if pmap then
|
||||
MapBrowser.select(S, pmap)
|
||||
Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py))
|
||||
else
|
||||
Ops.say(S, "No player location on this save")
|
||||
end
|
||||
MapBrowser.select(S, S.save.player.map)
|
||||
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
|
||||
S.save.player.x, S.save.player.y))
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- the viewport
|
||||
@@ -278,32 +253,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
Kit.text("monoBig", tostring(S.mapId), vx0,
|
||||
vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
|
||||
|
||||
local ok, map
|
||||
if Gen.of(S.save) == 2 then
|
||||
local def = Gen.maps(S.data)[S.mapId]
|
||||
if def then
|
||||
local Map2 = require("src.world.gen2.Map")
|
||||
if type(def.width) ~= "number" or type(def.height) ~= "number" then
|
||||
ok, map = false, "incomplete map record (missing width/height)"
|
||||
else
|
||||
local tileset = Gen.tilesets(S.data)[def.tileset]
|
||||
ok, map = pcall(Map2.new, def, tileset or {})
|
||||
if ok and map and not map.renderer then
|
||||
local MapPreview = require("src.world.gen2.MapPreview")
|
||||
S._g2MapBaker = S._g2MapBaker or MapPreview.baker({
|
||||
tilesets = Gen.tilesets(S.data),
|
||||
gen2Roofs = S.data.gen2Roofs, roofs = S.data.roofs,
|
||||
gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes,
|
||||
})
|
||||
map.renderer = MapPreview.renderer(S._g2MapBaker, map)
|
||||
end
|
||||
end
|
||||
else
|
||||
ok, map = false, "unknown map"
|
||||
end
|
||||
else
|
||||
ok, map = pcall(MapLoader.load, S.data, S.mapId)
|
||||
end
|
||||
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
|
||||
if not ok then
|
||||
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
|
||||
vr.y + vpad + headH + 20 * s, PAL.red)
|
||||
@@ -329,13 +279,12 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
local zBtn = 32 * s
|
||||
local rightEdge = vx0 + vinner
|
||||
local zoomW = 2 * zBtn + 56 * s + 12 * s
|
||||
local pmap, px, py = playerPos(S)
|
||||
local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s
|
||||
if showCenter then
|
||||
if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player",
|
||||
{ kind = "accent", font = "small", radius = 7 * s }) then
|
||||
if pmap == S.mapId then
|
||||
centerOn(S, px, py)
|
||||
if S.save.player.map == S.mapId then
|
||||
centerOn(S, S.save.player.x, S.save.player.y)
|
||||
Ops.say(S, "Centred on the player")
|
||||
else
|
||||
Ops.say(S, "Player isn't on this map")
|
||||
@@ -363,11 +312,10 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
-- the panel has laid itself out.
|
||||
if S._mapCenteredFor ~= S.mapId then
|
||||
S._mapCenteredFor = S.mapId
|
||||
if pmap == S.mapId then
|
||||
centerOn(S, px, py)
|
||||
if S.save.player.map == S.mapId then
|
||||
centerOn(S, S.save.player.x, S.save.player.y)
|
||||
else
|
||||
centerOn(S, (map.widthCells or map.width or 10) / 2,
|
||||
(map.heightCells or map.height or 10) / 2)
|
||||
centerOn(S, map.widthCells / 2, map.heightCells / 2)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -386,24 +334,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
love.graphics.push()
|
||||
love.graphics.translate(vx0, vy0)
|
||||
love.graphics.scale(S.mapZoom, S.mapZoom)
|
||||
if map.renderer and map.renderer.draw then
|
||||
map.renderer:draw(S.mapCamX, S.mapCamY)
|
||||
else
|
||||
local wc = map.widthCells or ((map.width or 8) * 2)
|
||||
local hc = map.heightCells or ((map.height or 8) * 2)
|
||||
for cy = 0, hc - 1 do
|
||||
for cx = 0, wc - 1 do
|
||||
if (cx + cy) % 2 == 0 then
|
||||
love.graphics.setColor(0.18, 0.22, 0.32, 1)
|
||||
else
|
||||
love.graphics.setColor(0.14, 0.17, 0.26, 1)
|
||||
end
|
||||
love.graphics.rectangle("fill",
|
||||
cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
map.renderer:draw(S.mapCamX, S.mapCamY)
|
||||
drawOverlays(S, map)
|
||||
love.graphics.pop()
|
||||
love.graphics.setScissor()
|
||||
@@ -471,31 +402,20 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS")
|
||||
local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s
|
||||
local sInner = sr.w - 2 * pad
|
||||
local pmap2, px2, py2 = playerPos(S)
|
||||
local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset"
|
||||
local spawns
|
||||
if Gen.of(S.save) == 2 then
|
||||
spawns = {
|
||||
{ key = "PLAYER", color = PAL.red, value = playerValue,
|
||||
set = function() Ops.setPlayerHere(S) end },
|
||||
{ key = "SPAWN", color = PAL.green,
|
||||
value = tostring(S.save.spawn or "SPAWN_HOME"),
|
||||
set = function() Ops.setLastHeal(S) end },
|
||||
}
|
||||
else
|
||||
local out = S.save.lastOutdoor
|
||||
local heal = S.save.lastHeal
|
||||
spawns = {
|
||||
{ key = "PLAYER", color = PAL.red, value = playerValue,
|
||||
set = function() Ops.setPlayerHere(S) end },
|
||||
{ key = "LAST HEAL", color = PAL.green,
|
||||
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
|
||||
set = function() Ops.setLastHeal(S) end },
|
||||
{ key = "LAST OUTDOOR", color = PAL.yellow,
|
||||
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
|
||||
set = function() Ops.setLastOutdoor(S, map) end },
|
||||
}
|
||||
end
|
||||
local player = S.save.player
|
||||
local out = S.save.lastOutdoor
|
||||
local heal = S.save.lastHeal
|
||||
local spawns = {
|
||||
{ key = "PLAYER", color = PAL.red,
|
||||
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
|
||||
set = function() Ops.setPlayerHere(S) end },
|
||||
{ key = "LAST HEAL", color = PAL.green,
|
||||
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
|
||||
set = function() Ops.setLastHeal(S) end },
|
||||
{ key = "LAST OUTDOOR", color = PAL.yellow,
|
||||
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
|
||||
set = function() Ops.setLastOutdoor(S, map) end },
|
||||
}
|
||||
local spawnH = 62 * s
|
||||
for i, sp in ipairs(spawns) do
|
||||
local ry = sTop + (i - 1) * (spawnH + 8 * s)
|
||||
@@ -511,7 +431,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted)
|
||||
end
|
||||
|
||||
local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s
|
||||
local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s
|
||||
Kit.textCenter("tiny",
|
||||
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
|
||||
"Arrow keys / WASD pan, the wheel zooms.",
|
||||
|
||||
@@ -19,26 +19,17 @@
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local PAL = Theme.PAL
|
||||
local Gen = require("Gen")
|
||||
|
||||
local MonEditor = {}
|
||||
|
||||
local DV_KEYS = { "attack", "defense", "speed", "special" }
|
||||
local STAT_KEYS_G1 = {
|
||||
local STAT_KEYS = {
|
||||
{ key = "HP", field = "hp" },
|
||||
{ key = "ATK", field = "attack" },
|
||||
{ key = "DEF", field = "defense" },
|
||||
{ key = "SPD", field = "speed" },
|
||||
{ key = "SPC", field = "special" },
|
||||
}
|
||||
local STAT_KEYS_G2 = {
|
||||
{ key = "HP", field = "hp" },
|
||||
{ key = "ATK", field = "attack" },
|
||||
{ key = "DEF", field = "defense" },
|
||||
{ key = "SPA", field = "specialAttack" },
|
||||
{ key = "SPD", field = "specialDefense" },
|
||||
{ key = "SPE", field = "speed" },
|
||||
}
|
||||
|
||||
-- Front sprites are read straight off the generated cache. One image per
|
||||
-- species, cached for the process: the old panel called newImage every frame,
|
||||
@@ -105,7 +96,7 @@ local function drawLevelRow(S, Kit, mon, lx0, ly)
|
||||
end
|
||||
lx = lx + bw + 8 * s
|
||||
end
|
||||
Kit.text("mono", ("EXP %d"):format(Gen.exp(mon)), lx + 6 * s,
|
||||
Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s,
|
||||
ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted)
|
||||
return lh
|
||||
end
|
||||
@@ -115,7 +106,7 @@ end
|
||||
local function levelRowWidth(Kit, mon)
|
||||
local s = Kit.scale
|
||||
return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s)
|
||||
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(Gen.exp(mon)))
|
||||
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0))
|
||||
end
|
||||
|
||||
local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
|
||||
@@ -188,7 +179,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
local tw = math.min(w - 40 * s, 340 * s)
|
||||
Kit.textCenter("button",
|
||||
"Pick a slot on the left to inspect it. Every change here re-runs the " ..
|
||||
"stat formulas, so HP and stats stay legal.",
|
||||
"Gen1 stat formulas, so HP and stats stay legal.",
|
||||
x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted)
|
||||
return
|
||||
end
|
||||
@@ -229,13 +220,10 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
end
|
||||
-- the nickname section: a caption line (with the Clear button on it) plus
|
||||
-- the field + Set row
|
||||
local extraH = 0
|
||||
if Gen.ofState(S) == 2 then extraH = 88 * s end
|
||||
local nickFieldH = 30 * s
|
||||
local contentH = pad + headerH + 18 * s
|
||||
+ capH + 10 * s + nickFieldH + 18 * s
|
||||
+ capH + 10 * s + cellH + 18 * s
|
||||
+ extraH
|
||||
+ colsH + pad
|
||||
|
||||
-- Called before the widgets so this frame already draws at the updated
|
||||
@@ -323,9 +311,8 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
|
||||
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
|
||||
statsY = statsY + capH + 10 * s
|
||||
local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1
|
||||
local gap = 12 * s
|
||||
local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS
|
||||
local cellW = (inner - gap * 4) / 5
|
||||
for i, st in ipairs(STAT_KEYS) do
|
||||
local bx = cx + (i - 1) * (cellW + gap)
|
||||
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
|
||||
@@ -339,40 +326,6 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
|
||||
-- --------------------------------------------------- DVs | moves split
|
||||
local colY = statsY + cellH + 18 * s
|
||||
if Gen.ofState(S) == 2 then
|
||||
local extraY = colY
|
||||
Kit.caption(cx, extraY, "GOLD")
|
||||
extraY = extraY + capH + 8 * s
|
||||
local row = 28 * s
|
||||
Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text)
|
||||
if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item",
|
||||
{ kind = "danger", font = "tiny", radius = 6 * s }) then
|
||||
Ops.setHeldItem(S, mon, nil)
|
||||
end
|
||||
extraY = extraY + row + 6 * s
|
||||
Kit.text("tiny", ("HAPPINESS %d"):format(mon.happiness or 0), cx, extraY, PAL.text)
|
||||
if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "-", { font = "small" }) then
|
||||
Ops.setHappiness(S, mon, (mon.happiness or 0) - 10)
|
||||
end
|
||||
if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, "+", { font = "small" }) then
|
||||
Ops.setHappiness(S, mon, (mon.happiness or 0) + 10)
|
||||
end
|
||||
Kit.text("tiny", ("PKRS %d"):format(mon.pokerus or 0), cx + 220 * s, extraY, PAL.text)
|
||||
if Kit.stepper(cx + 300 * s, extraY, 28 * s, row, "-", { font = "small" }) then
|
||||
Ops.setPokerus(S, mon, (mon.pokerus or 0) - 1)
|
||||
end
|
||||
if Kit.stepper(cx + 334 * s, extraY, 28 * s, row, "+", { font = "small" }) then
|
||||
Ops.setPokerus(S, mon, (mon.pokerus or 0) + 1)
|
||||
end
|
||||
extraY = extraY + row + 4 * s
|
||||
local bits = {}
|
||||
if mon.gender then bits[#bits + 1] = mon.gender end
|
||||
if mon.shiny then bits[#bits + 1] = "shiny" end
|
||||
if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end
|
||||
Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ")
|
||||
or "gender/shiny follow DVs", cx, extraY, PAL.caption)
|
||||
colY = extraY + 22 * s
|
||||
end
|
||||
if narrow then
|
||||
-- stacked: DVs first, then moves, then the two actions side by side at
|
||||
-- full width (#715)
|
||||
|
||||
Reference in New Issue
Block a user