mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73fbaaa250 | |||
| 9469e39926 | |||
| 179048a58e | |||
| e1f5c2b217 | |||
| 1d8ac1e692 | |||
| 39df5bdfa6 | |||
| 4b7a4daf2c | |||
| e1d233d026 | |||
| c22888a7fd | |||
| 0e4fc3c54a | |||
| 82b91e36ca | |||
| 3588a5f3fe | |||
| 992dc80aa7 | |||
| 6e7073d32b | |||
| 000e691966 | |||
| fdbc51c5d1 | |||
| 3e3566d1b4 | |||
| fcb5d1d348 | |||
| b45d783dee | |||
| d99072b44e | |||
| 0ac55b0f9e | |||
| ab48572852 | |||
| 5d2c13ed2b | |||
| d87f6b8ad1 | |||
| 24cf367758 | |||
| 829d398a94 | |||
| 3ee50a27c5 | |||
| 871087a16b | |||
| 180ce6b2e7 | |||
| cf335f67de | |||
| 7e0d81a431 | |||
| 10314bcdcf | |||
| b8ec4fe6b5 | |||
| 099a4266a8 | |||
| cec1f196be | |||
| e24410f0fb | |||
| b29b6fd7bd | |||
| f06c4d4584 | |||
| 3a997e8a62 | |||
| e6ccdd57eb | |||
| 927507f8f7 | |||
| 9bf15c33fd | |||
| 52efdabf61 | |||
| ef208035ec | |||
| 18d61779eb | |||
| d573878a2f |
@@ -262,7 +262,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
|
||||
@@ -16,6 +16,7 @@ save directory as:
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
| `"required_import"` | `picked_required_import.bin` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
|
||||
@@ -46,7 +46,11 @@ when the runtime schema is absent. The supported row types are `toggle`,
|
||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||
established by the existing in-game option UI: choices are `[label, value]`
|
||||
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||
provide `maxLen`.
|
||||
provide `maxLen`. A row may also use
|
||||
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
|
||||
`not_equals`. This only hides the in-game menu row; the schema and stored value
|
||||
remain available, and consumers that do not implement conditions may ignore
|
||||
the field.
|
||||
|
||||
Only mods that are enabled and successfully loaded in the current boot are
|
||||
included. A disabled or failed mod must not contribute rows. If an older
|
||||
@@ -69,9 +73,10 @@ it may ignore an unknown row type or optional field.
|
||||
For compatibility with files produced by the original unversioned prototype,
|
||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||
with a newer version rather than guessing at their shape. Producers must bump
|
||||
the version whenever they change the document shape or the meaning of an
|
||||
existing field. Version 1 is therefore the legacy unversioned format as well
|
||||
as the explicitly versioned format shown above.
|
||||
the version whenever they change the document envelope or the meaning of an
|
||||
existing field. New optional row fields that older consumers can safely ignore
|
||||
do not require a bump. Version 1 is therefore the legacy unversioned format as
|
||||
well as the explicitly versioned format shown above.
|
||||
|
||||
## Migration note
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
| `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"]`). |
|
||||
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
@@ -148,6 +149,18 @@ 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.
|
||||
|
||||
### Platform import flow
|
||||
|
||||
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
|
||||
applies on every supported platform. Windows, macOS, and Linux use the
|
||||
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
|
||||
the Files document picker; both stage the choice as `picked_required_import.bin`
|
||||
before validation. Xbox/UWP uses its native picker and hands the launcher a
|
||||
temporary path. Switch/NX has no host picker, so the player copies a file to
|
||||
`imports/baseroms/` over MTP and chooses the import again. No platform grants
|
||||
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
|
||||
checks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -213,6 +226,8 @@ 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.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
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" }`
|
||||
@@ -222,6 +237,52 @@ 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.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
|
||||
same core fields:
|
||||
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
|
||||
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
|
||||
the result are detached records; changing them cannot change the battle.
|
||||
`revision` stays stable while the visible battle context is unchanged and
|
||||
advances when it changes, so a UI can skip rebuilding an identical view.
|
||||
|
||||
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
|
||||
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
|
||||
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
|
||||
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
|
||||
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
|
||||
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
|
||||
an optional stock `catchChance` percentage.
|
||||
|
||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
|
||||
Gold currently returns an empty `items` list rather than guessing at its
|
||||
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
|
||||
optional ones.
|
||||
|
||||
## Battle menu intents
|
||||
|
||||
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
|
||||
just read. Every intent needs a mod-owned, strictly increasing positive
|
||||
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
|
||||
invalid choices return `nil` plus a reason without changing the battle.
|
||||
|
||||
The shared Red, Blue, Yellow, and Gold intents are:
|
||||
|
||||
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
|
||||
other accepted choices)
|
||||
- `{ kind = "move", slot = 1..4 }`
|
||||
- `{ kind = "back" }` while the move menu is active
|
||||
|
||||
Menu choices and moves use the same engine methods as the native controls;
|
||||
`party` and `item` open the native screens rather than exposing or duplicating
|
||||
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
|
||||
states refuse these core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -852,6 +913,39 @@ with a full standard library that the sandbox cannot reach, so handing one
|
||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||
code, so a mod gets asynchrony without gaining any new reach.
|
||||
|
||||
## Log reporting
|
||||
|
||||
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
|
||||
decides where it talks. It reports a debug/crash log to the https URL the
|
||||
manifest declares in `log_url`, and it is the only API that may not be
|
||||
pointed at a caller-chosen address:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/receive"
|
||||
}
|
||||
```
|
||||
|
||||
The URL is validated at load: it must be `https://`, and declaring it
|
||||
without the `network` permission is a load violation for api 2 mods. The
|
||||
destination is reviewed when the mod ships, not chosen per call, so a mod
|
||||
cannot aim this at arbitrary hosts or read back anything a server replies.
|
||||
|
||||
```lua
|
||||
-- fire and forget; poll() never blocks, same shape as mod.fetch
|
||||
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
|
||||
```
|
||||
|
||||
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
|
||||
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
|
||||
is a closed list with one switch: `format`, either `"text"` (the default)
|
||||
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
|
||||
body }` so a server can attribute and sort reports; any other key or value
|
||||
is refused before a job is submitted. The body is capped at 64 KB, the
|
||||
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
|
||||
response body is never returned to the mod.
|
||||
|
||||
## Background jobs
|
||||
|
||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||
|
||||
+13
-5
@@ -126,11 +126,19 @@ bundled game, in that case.
|
||||
already driving the frame. A payload that must change `love.run` itself
|
||||
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||
than running with half its intended behavior.
|
||||
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||
shells out to curl for both the release check and the download; curl is
|
||||
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||
launcher UI hides on that status) and the player is directed to the
|
||||
releases page via `Check.releaseUrl()` instead.
|
||||
- **Android and iOS use the native download bridge, not curl.** Neither
|
||||
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
|
||||
always landed on `error` and the launcher chip's "Check for updates" tap
|
||||
was a no-op. The worker now talks through `HostShell`, the same transport
|
||||
as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
|
||||
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
|
||||
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
|
||||
APK or IPA can therefore check GitHub and fetch the `.love` payload
|
||||
in-app. If neither transport exists, the worker reports `needs_full` and
|
||||
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
|
||||
@@ -148,9 +148,44 @@ local function openEditor(version, slotId)
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end })
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").")
|
||||
return
|
||||
end
|
||||
EditorApp = appOrErr
|
||||
local okLoad, loadErr = pcall(EditorApp.load, path, {
|
||||
version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end,
|
||||
})
|
||||
if not okLoad then
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(loadErr) .. ").")
|
||||
end
|
||||
end
|
||||
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
@@ -166,6 +201,11 @@ function closeEditor()
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -263,6 +303,11 @@ function love.load(args)
|
||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||
require("src.core.HostShell").hideHostConsole()
|
||||
|
||||
-- Hang gen1tls on love.system before mods boot. Android already has tls*
|
||||
-- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws://
|
||||
-- rooms still work, wss:// just won't.
|
||||
pcall(function() require("src.net.Gen1Tls").install() end)
|
||||
|
||||
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
|
||||
-- the love loaders once so every generated-asset read falls back to the
|
||||
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
||||
|
||||
@@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
||||
return module->newImageData(r.w, r.h, dataformat);
|
||||
}
|
||||
|
||||
bool Canvas::requestImageData()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
love::image::ImageData *Canvas::pollImageData(love::image::Image *)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
|
||||
{
|
||||
if (gfx->isCanvasActive(this))
|
||||
@@ -232,4 +242,3 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
|
||||
|
||||
} // graphics
|
||||
} // love
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ public:
|
||||
int getRequestedMSAA() const;
|
||||
|
||||
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect);
|
||||
virtual bool requestImageData();
|
||||
virtual love::image::ImageData *pollImageData(love::image::Image *module);
|
||||
virtual void generateMipmaps() = 0;
|
||||
|
||||
virtual int getMSAA() const = 0;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "Graphics.h"
|
||||
|
||||
#include <algorithm> // For min/max
|
||||
#include <cstring>
|
||||
|
||||
namespace love
|
||||
{
|
||||
@@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings)
|
||||
, texture(0)
|
||||
, renderbuffer(0)
|
||||
, actualSamples(0)
|
||||
, readbackBuffer(0)
|
||||
, readbackFence(nullptr)
|
||||
, readbackSize(0)
|
||||
{
|
||||
format = getSizedFormat(format);
|
||||
|
||||
@@ -314,6 +318,14 @@ bool Canvas::loadVolatile()
|
||||
|
||||
void Canvas::unloadVolatile()
|
||||
{
|
||||
if (readbackFence != nullptr)
|
||||
glDeleteSync(readbackFence);
|
||||
if (readbackBuffer != 0)
|
||||
glDeleteBuffers(1, &readbackBuffer);
|
||||
readbackFence = nullptr;
|
||||
readbackBuffer = 0;
|
||||
readbackSize = 0;
|
||||
|
||||
if (fbo != 0 || renderbuffer != 0 || texture != 0)
|
||||
{
|
||||
// This is a bit ugly, but we need some way to destroy the cached FBO
|
||||
@@ -480,6 +492,68 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
||||
return data;
|
||||
}
|
||||
|
||||
bool Canvas::requestImageData()
|
||||
{
|
||||
if (readbackFence != nullptr || !isReadable()
|
||||
|| !(GLAD_ES_VERSION_3_0 || GLAD_VERSION_3_2)
|
||||
|| texType != TEXTURE_2D
|
||||
|| (format != PIXELFORMAT_RGBA8 && format != PIXELFORMAT_sRGBA8)
|
||||
|| actualSamples > 0)
|
||||
return false;
|
||||
|
||||
auto gfx = Module::getInstance<Graphics>(Module::M_GRAPHICS);
|
||||
if (gfx != nullptr && gfx->isCanvasActive(this))
|
||||
throw love::Exception("Canvas:requestImageData cannot be called while that Canvas is active.");
|
||||
|
||||
const size_t size = (size_t) pixelWidth * (size_t) pixelHeight * 4;
|
||||
if (readbackBuffer == 0)
|
||||
glGenBuffers(1, &readbackBuffer);
|
||||
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
|
||||
if (readbackSize != size)
|
||||
{
|
||||
glBufferData(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ);
|
||||
readbackSize = size;
|
||||
}
|
||||
|
||||
GLuint currentfbo = gl.getFramebuffer(OpenGL::FRAMEBUFFER_ALL);
|
||||
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, getFBO());
|
||||
glReadPixels(0, 0, pixelWidth, pixelHeight, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
readbackFence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, currentfbo);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
return readbackFence != nullptr;
|
||||
}
|
||||
|
||||
love::image::ImageData *Canvas::pollImageData(love::image::Image *module)
|
||||
{
|
||||
if (readbackFence == nullptr)
|
||||
return nullptr;
|
||||
|
||||
GLenum status = glClientWaitSync(readbackFence, 0, 0);
|
||||
if (status == GL_TIMEOUT_EXPIRED)
|
||||
return nullptr;
|
||||
|
||||
glDeleteSync(readbackFence);
|
||||
readbackFence = nullptr;
|
||||
if (status == GL_WAIT_FAILED)
|
||||
return nullptr;
|
||||
|
||||
love::image::ImageData *data = module->newImageData(pixelWidth, pixelHeight, PIXELFORMAT_RGBA8);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
|
||||
void *pixels = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, readbackSize, GL_MAP_READ_BIT);
|
||||
if (pixels == nullptr)
|
||||
{
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
data->release();
|
||||
throw love::Exception("Could not map asynchronous Canvas readback.");
|
||||
}
|
||||
memcpy(data->getData(), pixels, readbackSize);
|
||||
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
return data;
|
||||
}
|
||||
|
||||
void Canvas::generateMipmaps()
|
||||
{
|
||||
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
|
||||
|
||||
@@ -54,6 +54,8 @@ public:
|
||||
ptrdiff_t getHandle() const override;
|
||||
|
||||
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) override;
|
||||
bool requestImageData() override;
|
||||
love::image::ImageData *pollImageData(love::image::Image *module) override;
|
||||
void generateMipmaps() override;
|
||||
|
||||
int getMSAA() const override
|
||||
@@ -107,6 +109,9 @@ private:
|
||||
GLenum status;
|
||||
|
||||
int actualSamples;
|
||||
GLuint readbackBuffer;
|
||||
GLsync readbackFence;
|
||||
size_t readbackSize;
|
||||
|
||||
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
|
||||
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
|
||||
|
||||
@@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_requestImageData(lua_State *L)
|
||||
{
|
||||
Canvas *canvas = luax_checkcanvas(L, 1);
|
||||
bool requested = false;
|
||||
luax_catchexcept(L, [&](){ requested = canvas->requestImageData(); });
|
||||
luax_pushboolean(L, requested);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_pollImageData(lua_State *L)
|
||||
{
|
||||
Canvas *canvas = luax_checkcanvas(L, 1);
|
||||
love::image::Image *image = luax_getmodule<love::image::Image>(L, love::image::Image::type);
|
||||
love::image::ImageData *data = nullptr;
|
||||
luax_catchexcept(L, [&](){ data = canvas->pollImageData(image); });
|
||||
if (data == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
luax_pushtype(L, data);
|
||||
data->release();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_generateMipmaps(lua_State *L)
|
||||
{
|
||||
Canvas *c = luax_checkcanvas(L, 1);
|
||||
@@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] =
|
||||
{ "getMSAA", w_Canvas_getMSAA },
|
||||
{ "renderTo", w_Canvas_renderTo },
|
||||
{ "newImageData", w_Canvas_newImageData },
|
||||
{ "requestImageData", w_Canvas_requestImageData },
|
||||
{ "pollImageData", w_Canvas_pollImageData },
|
||||
{ "generateMipmaps", w_Canvas_generateMipmaps },
|
||||
{ "getMipmapMode", w_Canvas_getMipmapMode },
|
||||
{ 0, 0 }
|
||||
|
||||
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
// Kept separate from the game-ROM destination so a dependency pick can
|
||||
// never be mistaken for a game import when the picker returns on Android.
|
||||
private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
@@ -363,6 +366,7 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
onHostDestroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -373,6 +377,7 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
onHostPause();
|
||||
super.onPause();
|
||||
@@ -382,6 +387,7 @@ public class GameActivity extends SDLActivity {
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
onHostResume();
|
||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
@@ -504,7 +510,8 @@ public class GameActivity extends SDLActivity {
|
||||
* picker-agnostic and unchanged.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
@@ -535,6 +542,11 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
// The Storage Access Framework grants the returned content URI
|
||||
// directly to this activity. Request the read grant explicitly as
|
||||
// well: Android 13's scoped storage deliberately does not expose
|
||||
// arbitrary paths or require broad media/storage permissions.
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
@@ -547,6 +559,7 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(
|
||||
Intent.createChooser(intent, "Choose a file"),
|
||||
@@ -576,6 +589,12 @@ public class GameActivity extends SDLActivity {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/** Required-mod-file wrapper used by love.system.pickFile("required_import"). */
|
||||
@Keep
|
||||
public static boolean showRequiredImportFilePicker() {
|
||||
return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunches the whole app for love.system.restartApp, used by
|
||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||
@@ -1389,6 +1408,7 @@ public class GameActivity extends SDLActivity {
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
@@ -1400,11 +1420,44 @@ public class GameActivity extends SDLActivity {
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||
if (on) {
|
||||
self.registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
} else {
|
||||
self.unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void registerSecondaryDisplayListener() {
|
||||
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
}
|
||||
|
||||
private void unregisterSecondaryDisplayListener() {
|
||||
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
||||
secondaryDisplayMonitor = null;
|
||||
if (monitor != null) monitor.unregister();
|
||||
}
|
||||
|
||||
private static void refreshSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled) return;
|
||||
SecondaryPresentation current = secondaryPresentation;
|
||||
Display display = current == null ? null : current.getDisplay();
|
||||
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
||||
if (current == null) {
|
||||
setupSecondaryDisplay();
|
||||
} else if (display == null || monitor == null
|
||||
|| !monitor.hasDisplay(display.getDisplayId())) {
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
@@ -1450,6 +1503,35 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.TargetApi(17)
|
||||
private static class SecondaryDisplayMonitor
|
||||
implements android.hardware.display.DisplayManager.DisplayListener {
|
||||
private final android.hardware.display.DisplayManager manager;
|
||||
|
||||
SecondaryDisplayMonitor(GameActivity activity) {
|
||||
manager = (android.hardware.display.DisplayManager)
|
||||
activity.getSystemService(Context.DISPLAY_SERVICE);
|
||||
}
|
||||
|
||||
boolean register() {
|
||||
if (manager == null) return false;
|
||||
manager.registerDisplayListener(this, new Handler(Looper.getMainLooper()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
manager.unregisterDisplayListener(this);
|
||||
}
|
||||
|
||||
boolean hasDisplay(int displayId) {
|
||||
return manager.getDisplay(displayId) != null;
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
|
||||
@@ -12,6 +12,34 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.95",
|
||||
"date": "2026-08-16",
|
||||
"size": 11373796,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.95/gen1recomp++-0.1.95-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.94",
|
||||
"date": "2026-08-15",
|
||||
"size": 11369457,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.94/gen1recomp++-0.1.94-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #694 Missing sound effect when falling from boulder holes\n\n## Contributors\n\n- @anxiousintrovert\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.93",
|
||||
"date": "2026-08-15",
|
||||
"size": 11366991,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.93/gen1recomp++-0.1.93-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1314 Launcher is a bit buggy\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @bryanthaboi\n- @TheRealSolidusSnake"
|
||||
},
|
||||
{
|
||||
"version": "0.1.92",
|
||||
"date": "2026-08-15",
|
||||
"size": 11364274,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.91",
|
||||
"date": "2026-08-15",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
@@ -0,0 +1,257 @@
|
||||
-- Read-only Gen 1 battle state for companion UIs and accessibility mods.
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local BattleAPI = {}
|
||||
BattleAPI.__index = BattleAPI
|
||||
|
||||
function BattleAPI.new(game)
|
||||
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
|
||||
end
|
||||
|
||||
local function activeBattle(game)
|
||||
local states = game and game.stack and game.stack.states or {}
|
||||
for i = #states, 1, -1 do
|
||||
if states[i].isBattleState then return states[i], states[#states] end
|
||||
end
|
||||
end
|
||||
|
||||
local function monCopy(data, mon, active)
|
||||
if not mon then return nil end
|
||||
local def = data.pokemon[mon.species]
|
||||
return {
|
||||
species = mon.species,
|
||||
name = mon.nickname or (def and def.name) or mon.species,
|
||||
level = mon.level, hp = mon.hp,
|
||||
maxHp = mon.stats and mon.stats.hp or mon.hp,
|
||||
status = mon.status, active = active and true or false,
|
||||
}
|
||||
end
|
||||
|
||||
local function visibleMessage(battle, top)
|
||||
local source = top and top.isTextBox and top or top == battle and battle
|
||||
local lines = source and source.visibleText and source:visibleText()
|
||||
if not lines then return nil end
|
||||
local copy = {}
|
||||
for i, line in ipairs(lines) do copy[i] = tostring(line) end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function signature(game, battle, top)
|
||||
if not battle then return "none" end
|
||||
local parts = { tostring(battle), tostring(top), battle.phase or "",
|
||||
tostring(battle.turnCount or 0), tostring(#(battle.queue or {})),
|
||||
tostring(battle.current), tostring(battle.msgWaiting),
|
||||
tostring(battle.msgPrompt), tostring(battle.menuIndex),
|
||||
tostring(battle.mimicIndex),
|
||||
tostring(battle.safari and battle.safari.balls),
|
||||
tostring(battle.ghost), tostring(battle.noCatch),
|
||||
table.concat(visibleMessage(battle, top) or {}, "\n") }
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
local mon = battler and battler.mon
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon and mon.hp)
|
||||
parts[#parts + 1] = tostring(mon and mon.status)
|
||||
parts[#parts + 1] = table.concat(battler and battler.curTypes or {}, ",")
|
||||
end
|
||||
for _, mon in ipairs(game.save.party or {}) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon.hp)
|
||||
parts[#parts + 1] = tostring(mon.status)
|
||||
end
|
||||
local inventory = {}
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id) then
|
||||
inventory[#inventory + 1] = id .. "=" .. tostring(count)
|
||||
end
|
||||
end
|
||||
table.sort(inventory)
|
||||
for _, item in ipairs(inventory) do parts[#parts + 1] = item end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function BattleAPI:_revision(battle, top)
|
||||
local nextSignature = signature(self.game, battle, top)
|
||||
if nextSignature ~= self.signature then
|
||||
self.signature = nextSignature
|
||||
self.revision = self.revision + 1
|
||||
end
|
||||
return self.revision
|
||||
end
|
||||
|
||||
local IMMUNITY_ONLY = { SPECIAL_DAMAGE_EFFECT = true,
|
||||
SUPER_FANG_EFFECT = true, OHKO_EFFECT = true }
|
||||
|
||||
local function movePreview(battle, move, def)
|
||||
local record = battle.effectRecord and battle:effectRecord(def.effect)
|
||||
local power, typeMult = def.power or 0
|
||||
if power > 0 and move.id ~= "COUNTER" then
|
||||
local raw = TypeChart.effectiveness(def.type, battle.enemy.curTypes or {})
|
||||
if IMMUNITY_ONLY[def.effect] then
|
||||
typeMult = raw == 0 and 0 or 10
|
||||
elseif not (record and record.chooseDamage) then
|
||||
typeMult = raw
|
||||
end
|
||||
end
|
||||
local hitChance
|
||||
if record and record.neverMiss then
|
||||
hitChance = 100
|
||||
elseif not (record and record.gate)
|
||||
and (power > 0 or (record and record.accuracyChecked)) then
|
||||
hitChance = battle.enemy.invulnerable and 0
|
||||
or Damage.accuracyChance(battle.ruleset, def,
|
||||
battle.player, battle.enemy)
|
||||
end
|
||||
local displayPower = power > 0 and move.id ~= "COUNTER"
|
||||
and not IMMUNITY_ONLY[def.effect]
|
||||
and not (record and record.chooseDamage) and power or nil
|
||||
return typeMult, hitChance, displayPower
|
||||
end
|
||||
|
||||
local function moveCopies(game, battle)
|
||||
local out = {}
|
||||
for slot, move in ipairs((battle.player and battle.player.curMoves) or {}) do
|
||||
local def = game.data.moves[move.id] or {}
|
||||
local mult, hitChance, displayPower = movePreview(battle, move, def)
|
||||
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
|
||||
pp = move.pp,
|
||||
maxPp = (def.pp or move.pp or 0)
|
||||
+ (move.ppUps or 0) * math.floor((def.pp or 0) / 5),
|
||||
type = def.type, power = def.power, accuracy = def.accuracy,
|
||||
displayPower = displayPower, hitChance = hitChance,
|
||||
effectiveness = mult,
|
||||
disabled = battle.player.disabledSlot == slot }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function itemCopies(game, battle, catchable)
|
||||
local out = {}
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if count > 0
|
||||
and (ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id)) then
|
||||
local def = game.data.items[id] or {}
|
||||
local ball = ItemEffects.isBall(id)
|
||||
out[#out + 1] = { id = id, name = def.name or id, count = count,
|
||||
ball = ball, needsTarget = not ball,
|
||||
catchChance = ball and catchable and battle.catchChance
|
||||
and battle:catchChance(id) or nil }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b) return a.name < b.name end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function mimicCopies(game, battle)
|
||||
local out = {}
|
||||
for i, move in ipairs(battle.mimicMoves or {}) do
|
||||
local def = game.data.moves[move.id] or {}
|
||||
out[i] = { index = i, slot = move.slot, id = move.id,
|
||||
name = def.name or move.id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleAPI:snapshot()
|
||||
local game = self.game
|
||||
local battle, top = activeBattle(game)
|
||||
if not battle then return nil end
|
||||
local kind = battle:battleKind()
|
||||
local supported = kind ~= "oldman" and kind ~= "link"
|
||||
local catchable = kind == "wild" and not battle.ghost and not battle.noCatch
|
||||
local forcedParty = top and top.isPartyMenu and top.battle == battle
|
||||
and top.forceSwitch
|
||||
local canAdvance = supported and ((top == battle
|
||||
and battle.phase == "messages" and battle.current
|
||||
and (battle.msgWaiting or battle.msgPrompt))
|
||||
or (top and top.isTextBox and not top.choice
|
||||
and (top.waiting or top.done)))
|
||||
local prompt = "locked"
|
||||
if canAdvance then prompt = "advance"
|
||||
elseif supported and forcedParty then prompt = "party"
|
||||
elseif supported and top == battle and kind == "safari"
|
||||
and battle.phase == "menu" then prompt = "safari"
|
||||
elseif supported and top == battle and battle.phase == "mimicSelect" then
|
||||
prompt = "mimic"
|
||||
elseif supported and top == battle and battle.phase == "menu" then
|
||||
prompt = "menu"
|
||||
elseif supported and top == battle and battle.phase == "moveSelect" then
|
||||
prompt = "moves"
|
||||
end
|
||||
local party = {}
|
||||
for i, mon in ipairs(game.save.party or {}) do
|
||||
party[i] = monCopy(game.data, mon,
|
||||
battle.player and battle.player.mon == mon)
|
||||
party[i].slot = i
|
||||
end
|
||||
return { revision = self:_revision(battle, top), kind = kind,
|
||||
catchable = catchable, prompt = prompt,
|
||||
message = visibleMessage(battle, top), turn = battle.turnCount or 0,
|
||||
player = monCopy(game.data, battle.player and battle.player.mon, true),
|
||||
enemy = monCopy(game.data, battle.enemy and battle.enemy.mon, true),
|
||||
party = party, moves = moveCopies(game, battle),
|
||||
items = itemCopies(game, battle, catchable),
|
||||
safariBalls = battle.safari and battle.safari.balls or nil,
|
||||
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
|
||||
end
|
||||
|
||||
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
|
||||
|
||||
local function validSlot(slot)
|
||||
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
|
||||
end
|
||||
|
||||
function BattleAPI:submit(intent)
|
||||
if type(intent) ~= "table" then return nil, "intent must be a table" end
|
||||
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
|
||||
return nil, "intent id must be a positive integer"
|
||||
end
|
||||
if self.lastIntentId and intent.id <= self.lastIntentId then
|
||||
return nil, "replayed intent"
|
||||
end
|
||||
|
||||
local battle, top = activeBattle(self.game)
|
||||
if not battle then return nil, "no battle" end
|
||||
if intent.revision ~= self:_revision(battle, top) then
|
||||
return nil, "stale battle context"
|
||||
end
|
||||
local kind = battle:battleKind()
|
||||
if kind == "oldman" or kind == "link" or kind == "safari" then
|
||||
return nil, "battle kind is not controllable"
|
||||
end
|
||||
if top ~= battle then return nil, "battle menu is covered" end
|
||||
|
||||
local ok, err
|
||||
if intent.kind == "menu" then
|
||||
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not MENU_CHOICES[intent.choice] then
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
ok, err = battle:chooseMenu(intent.choice)
|
||||
elseif intent.kind == "move" then
|
||||
if battle.phase ~= "moveSelect" then
|
||||
return nil, "move menu is not active"
|
||||
end
|
||||
if battle.moveSwapIndex then return nil, "move reorder is active" end
|
||||
local move = validSlot(intent.slot) and battle.player
|
||||
and battle.player.curMoves[intent.slot]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
|
||||
if battle.player.disabledSlot == intent.slot then
|
||||
return nil, "move is disabled"
|
||||
end
|
||||
ok, err = battle:chooseMove(intent.slot)
|
||||
elseif intent.kind == "back" then
|
||||
ok, err = battle:cancelMove()
|
||||
else
|
||||
return nil, "unknown battle intent"
|
||||
end
|
||||
if not ok then return nil, err end
|
||||
self.lastIntentId = intent.id
|
||||
self.signature = nil
|
||||
return true
|
||||
end
|
||||
|
||||
return BattleAPI
|
||||
+95
-52
@@ -38,6 +38,7 @@ local romText = RomText
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
BattleState.isBattleState = true
|
||||
|
||||
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
||||
-- style OverworldController.isOverworld already uses. Every battle --
|
||||
@@ -156,6 +157,13 @@ function BattleState:caughtMarkerVisible()
|
||||
function() return false end, self) == true
|
||||
end
|
||||
|
||||
function BattleState:catchChance(ball, rateOverride)
|
||||
if Runtime.wantsHook("catch.rate") then return nil end
|
||||
return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride,
|
||||
{ ballDef = self:ballDef(ball), statuses = self.data.statuses,
|
||||
battle = self })
|
||||
end
|
||||
|
||||
function BattleState:moveGridNavigation()
|
||||
if self:wideLayout() then return true end
|
||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||
@@ -1166,7 +1174,7 @@ function BattleState:startMessage(item)
|
||||
local npos = text:find("[\n\v]", pos)
|
||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||
local codes = Font.encode(chunk)
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
|
||||
self.total = self.total + #codes
|
||||
if not npos then break end
|
||||
cont = text:sub(npos, npos) == "\v"
|
||||
@@ -1199,6 +1207,18 @@ function BattleState:beginMsgLine()
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
function BattleState:visibleText()
|
||||
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
|
||||
return nil
|
||||
end
|
||||
local out, count = {}, #(self.shown or {})
|
||||
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
|
||||
local line = self.lines and self.lines[i]
|
||||
if line then out[#out + 1] = line.text or "" end
|
||||
end
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
if self.waitingUI then
|
||||
if self.game.stack:top() ~= self then return true end
|
||||
@@ -1931,6 +1951,77 @@ function BattleState:playerHasPP()
|
||||
return false
|
||||
end
|
||||
|
||||
-- One semantic path for the native command menu and mod.battle intents.
|
||||
function BattleState:chooseMenu(choice)
|
||||
if self.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not self.player or not self.player.mon or self.player.mon.hp <= 0
|
||||
or self:menuLockedAction(self.player) then
|
||||
return nil, "battle menu is not ready"
|
||||
end
|
||||
self:clearTurnFlinches()
|
||||
if choice == "fight" and self.ghost then
|
||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
-- A scared turn still ticks the player's residual effects.
|
||||
self:queueResidual(self.player, self.enemy)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- Trapping, Bide, and similar locks skip the move list.
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
elseif not self:playerHasPP() then
|
||||
-- No usable PP goes straight to Struggle.
|
||||
self:say(Strings("%s has no\nmoves left!", self.player.name))
|
||||
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
|
||||
else
|
||||
self.phase = "moveSelect"
|
||||
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
||||
self.moveSwapIndex = nil
|
||||
end
|
||||
elseif choice == "run" then
|
||||
self:tryRun()
|
||||
elseif choice == "item" then
|
||||
self:openItems()
|
||||
elseif choice == "party" then
|
||||
self:openParty()
|
||||
else
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:chooseMove(index)
|
||||
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
|
||||
local move = self.player.curMoves[index]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
self.moveIndex = index
|
||||
if self.player.disabledSlot == index then
|
||||
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
elseif move.pp <= 0 then
|
||||
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = index
|
||||
self:resolveTurn(move)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:cancelMove()
|
||||
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:swapMoves(i, j)
|
||||
if i == j then return end
|
||||
local moves = self.player.curMoves
|
||||
@@ -2100,42 +2191,7 @@ function BattleState:update(dt)
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
||||
if choice == "fight" and self.ghost then
|
||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
-- the scared turn still ticks the player's residual (PrintGhostText
|
||||
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
|
||||
self:queueResidual(self.player, self.enemy)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- After the menu: own trapping/Bide or foe Wrap skips the move
|
||||
-- list and forces the locked action (core.asm:320-329)
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
return
|
||||
end
|
||||
if not self:playerHasPP() then
|
||||
-- _NoMovesLeftText, then Struggle engages
|
||||
self:say(Strings("%s has no\nmoves left!", self.player.name))
|
||||
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
|
||||
return
|
||||
end
|
||||
self.phase = "moveSelect"
|
||||
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
||||
self.moveSwapIndex = nil
|
||||
elseif choice == "run" then
|
||||
self:tryRun()
|
||||
elseif choice == "item" then
|
||||
self:openItems()
|
||||
else
|
||||
self:openParty()
|
||||
end
|
||||
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2164,8 +2220,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
self:cancelMove()
|
||||
elseif input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
if self.moveSwapIndex then
|
||||
@@ -2173,19 +2228,7 @@ function BattleState:update(dt)
|
||||
self.moveSwapIndex = nil
|
||||
return
|
||||
end
|
||||
local mv = moves[self.moveIndex]
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
elseif mv.pp <= 0 then
|
||||
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = self.moveIndex
|
||||
self:resolveTurn(mv)
|
||||
end
|
||||
self:chooseMove(self.moveIndex)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+29
-14
@@ -27,6 +27,17 @@ Catching.BALLS = BALLS
|
||||
-- divisor, which is what the old per-field `or` defaults resolved to
|
||||
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
|
||||
|
||||
local function stockFactors(def, targetMon, targetDef, rateOverride, statuses)
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
local record = Status.recordFor(statuses, targetMon.status)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
local f = math.min(255, math.floor(math.floor(
|
||||
targetMon.stats.hp * 255 / factor) / hpQuarter))
|
||||
return rate, statusBonus, f, record
|
||||
end
|
||||
|
||||
function Catching.registerInto(registry, _, owner)
|
||||
for id, record in pairs(BALLS) do
|
||||
registry:register(id, record, owner)
|
||||
@@ -41,21 +52,9 @@ end
|
||||
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
||||
if def.autoCatch then return true, 3 end
|
||||
local randMax = def.randMax
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
|
||||
-- the status subtraction and the wobble bonus come off the merged
|
||||
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
|
||||
local s = targetMon.status
|
||||
local record = Status.recordFor(statuses, s)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
|
||||
-- HP factor (X)
|
||||
local maxhp = targetMon.stats.hp
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
|
||||
-- the intermediate in 16 bits); capping early collapses the value
|
||||
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
|
||||
local rate, statusBonus, f, record = stockFactors(
|
||||
def, targetMon, targetDef, rateOverride, statuses)
|
||||
|
||||
local function shakes()
|
||||
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
|
||||
@@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
|
||||
return false, shakes()
|
||||
end
|
||||
|
||||
-- Exact stock catch probability for read-only previews. A custom attempt
|
||||
-- function may do anything, so nil is safer than presenting a plausible lie.
|
||||
function Catching.chance(ball, targetMon, targetDef, rateOverride, opts)
|
||||
opts = opts or {}
|
||||
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
|
||||
if def.attempt then return nil end
|
||||
if def.autoCatch then return 100 end
|
||||
local rate, statusBonus, f = stockFactors(
|
||||
def, targetMon, targetDef, rateOverride, opts.statuses)
|
||||
local outcomes = def.randMax + 1
|
||||
local automatic = math.min(outcomes, math.max(0, statusBonus))
|
||||
local passed = math.min(outcomes, math.max(0, rate + statusBonus + 1))
|
||||
return (automatic + (passed - automatic) * (f + 1) / 256)
|
||||
* 100 / outcomes
|
||||
end
|
||||
|
||||
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||
-- opts (all optional): ballDef = the merged ball record, statuses = the
|
||||
|
||||
+22
-12
@@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
|
||||
return rng(0, 255) < b
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
|
||||
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
|
||||
-- misses on 255.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
|
||||
-- threshold public lets read-only UIs preview the same rules the roll uses.
|
||||
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
||||
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
|
||||
-- (MoveHitTest returns before any accuracy math, 1/256 included)
|
||||
if attacker.xAccuracy then return true end
|
||||
if attacker.xAccuracy then return 256 end
|
||||
local acc = math.floor(move.accuracy * 255 / 100)
|
||||
local accuracyStage = attacker.stages and attacker.stages.accuracy or 0
|
||||
local evasionStage = defender.stages and defender.stages.evasion or 0
|
||||
-- CalcHitChance scales by the accuracy stage and the evasion stage as
|
||||
-- two separate ratio multiplications, clamping each result
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
attacker.stages and attacker.stages.accuracy or 0))
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
-(defender.stages and defender.stages.evasion or 0)))
|
||||
acc = math.min(255, Stats.applyStage(acc, accuracyStage))
|
||||
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
|
||||
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
||||
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
|
||||
return true
|
||||
and accuracyStage >= evasionStage then
|
||||
return 256
|
||||
end
|
||||
return acc
|
||||
end
|
||||
|
||||
function Damage.accuracyChance(ruleset, move, attacker, defender)
|
||||
return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
||||
if acc == 256 then return true end
|
||||
return rng(0, 255) < acc
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Read-only Gen 2 battle state with the same shape as mod.battle on Gen 1.
|
||||
|
||||
local BattleAPI = {}
|
||||
BattleAPI.__index = BattleAPI
|
||||
|
||||
function BattleAPI.new(game)
|
||||
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
|
||||
end
|
||||
|
||||
local function activeBattle(game)
|
||||
local states = game and game.stack and game.stack.states or {}
|
||||
local battle
|
||||
for i = #states, 1, -1 do
|
||||
local state = states[i]
|
||||
if state.screenId == "Gen2BattleState" or state.isGen2BattleState then
|
||||
battle = state
|
||||
break
|
||||
end
|
||||
end
|
||||
return battle, states[#states]
|
||||
end
|
||||
|
||||
local function monCopy(data, mon, active)
|
||||
if not mon then return nil end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
return { species = mon.species,
|
||||
name = mon.nickname or (def and def.name) or mon.species,
|
||||
level = mon.level, hp = mon.hp,
|
||||
maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp,
|
||||
status = mon.status, active = active and true or false }
|
||||
end
|
||||
|
||||
local function messageCopy(screen)
|
||||
if not screen.message then return nil end
|
||||
local lines = {}
|
||||
for line in tostring(screen.message):gmatch("[^\n]+") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
return #lines > 0 and lines or nil
|
||||
end
|
||||
|
||||
local function signature(game, screen, top)
|
||||
if not screen then return "none" end
|
||||
local battle = screen.battle or {}
|
||||
local parts = { tostring(screen), tostring(top), tostring(screen.phase),
|
||||
tostring(screen.message), tostring(screen.messageTimer),
|
||||
tostring(screen.menuIndex), tostring(screen.moveIndex),
|
||||
tostring(battle.turn), tostring(battle.over), tostring(battle.outcome) }
|
||||
for _, mon in ipairs({ battle.player, battle.enemy }) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon and mon.hp)
|
||||
parts[#parts + 1] = tostring(mon and mon.status)
|
||||
end
|
||||
for _, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon.hp)
|
||||
parts[#parts + 1] = tostring(mon.status)
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function BattleAPI:_revision(screen, top)
|
||||
local nextSignature = signature(self.game, screen, top)
|
||||
if nextSignature ~= self.signature then
|
||||
self.signature = nextSignature
|
||||
self.revision = self.revision + 1
|
||||
end
|
||||
return self.revision
|
||||
end
|
||||
|
||||
local function moveCopies(game, battle)
|
||||
local out = {}
|
||||
for slot, move in ipairs((battle.player and battle.player.moves) or {}) do
|
||||
local def = (game.data.moves or {})[move.id] or {}
|
||||
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
|
||||
pp = move.pp, maxPp = move.maxPp or def.pp or move.pp,
|
||||
type = def.type, power = def.power, accuracy = def.accuracy,
|
||||
disabled = battle:moveDisabled(battle.player, move.id) }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function battleKind(screen)
|
||||
if screen.tutorial then return "oldman" end
|
||||
return screen.battle and screen.battle.wild and "wild" or "trainer"
|
||||
end
|
||||
|
||||
function BattleAPI:snapshot()
|
||||
local game = self.game
|
||||
local screen, top = activeBattle(game)
|
||||
if not screen or not screen.battle then return nil end
|
||||
local battle = screen.battle
|
||||
local prompt = "locked"
|
||||
if top == screen and screen.phase == "menu" then
|
||||
prompt = "menu"
|
||||
elseif top == screen and screen.phase == "moves" then
|
||||
prompt = "moves"
|
||||
elseif top == screen and screen.message then
|
||||
prompt = "advance"
|
||||
elseif top and top.screenId == "Gen2PartyMenu" then
|
||||
prompt = "party"
|
||||
end
|
||||
local party = {}
|
||||
for i, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
|
||||
party[i] = monCopy(game.data, mon, mon == battle.player)
|
||||
party[i].slot = i
|
||||
end
|
||||
return { revision = self:_revision(screen, top), kind = battleKind(screen),
|
||||
catchable = battle.wild and not screen.tutorial, prompt = prompt,
|
||||
message = messageCopy(screen), turn = battle.turn or 0,
|
||||
player = monCopy(game.data, battle.player, true),
|
||||
enemy = monCopy(game.data, battle.enemy, true),
|
||||
party = party, moves = moveCopies(game, battle),
|
||||
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
|
||||
-- until the engine can expose the same semantic item records as Gen 1.
|
||||
items = {} }
|
||||
end
|
||||
|
||||
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
|
||||
|
||||
local function validSlot(slot)
|
||||
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
|
||||
end
|
||||
|
||||
function BattleAPI:submit(intent)
|
||||
if type(intent) ~= "table" then return nil, "intent must be a table" end
|
||||
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
|
||||
return nil, "intent id must be a positive integer"
|
||||
end
|
||||
if self.lastIntentId and intent.id <= self.lastIntentId then
|
||||
return nil, "replayed intent"
|
||||
end
|
||||
|
||||
local screen, top = activeBattle(self.game)
|
||||
if not screen or not screen.battle then return nil, "no battle" end
|
||||
if intent.revision ~= self:_revision(screen, top) then
|
||||
return nil, "stale battle context"
|
||||
end
|
||||
if screen.tutorial then return nil, "battle kind is not controllable" end
|
||||
if top ~= screen then return nil, "battle menu is covered" end
|
||||
|
||||
local battle = screen.battle
|
||||
local ok, err
|
||||
if intent.kind == "menu" then
|
||||
if screen.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not MENU_CHOICES[intent.choice] then
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
ok, err = screen:chooseMenu(intent.choice)
|
||||
elseif intent.kind == "move" then
|
||||
if screen.phase ~= "moves" then return nil, "move menu is not active" end
|
||||
if screen.moveSwapIndex then return nil, "move reorder is active" end
|
||||
local move = validSlot(intent.slot) and battle.player
|
||||
and battle.player.moves and battle.player.moves[intent.slot]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
|
||||
if battle:moveDisabled(battle.player, move.id) then
|
||||
return nil, "move is disabled"
|
||||
end
|
||||
ok, err = screen:chooseMove(intent.slot)
|
||||
elseif intent.kind == "back" then
|
||||
ok, err = screen:cancelMove()
|
||||
else
|
||||
return nil, "unknown battle intent"
|
||||
end
|
||||
if not ok then return nil, err end
|
||||
self.lastIntentId = intent.id
|
||||
self.signature = nil
|
||||
return true
|
||||
end
|
||||
|
||||
return BattleAPI
|
||||
+39
-15
@@ -14,6 +14,14 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has
|
||||
-- maps/text/pokemon/items, not text_pointers / trainer_headers / field).
|
||||
-- Desktop can still `require` Red's copies from the source tree, so Gold
|
||||
-- Edit appeared to work there; an Android APK has only the per-version
|
||||
-- cache, so Data:load used to throw on the first Gold Edit and take the
|
||||
-- activity down. Empty tables are enough for seedDefaults / the editor.
|
||||
local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true }
|
||||
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
@@ -99,7 +107,11 @@ end
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
local constants = self.constants
|
||||
local constants = self.constants or {}
|
||||
self.constants = constants
|
||||
self.field = self.field or {}
|
||||
self.maps = self.maps or {}
|
||||
self.pokemon = self.pokemon or {}
|
||||
for key, value in pairs(CONSTANT_DEFAULTS) do
|
||||
if constants[key] == nil then constants[key] = copy(value) end
|
||||
end
|
||||
@@ -108,7 +120,11 @@ function Data:seedDefaults()
|
||||
if constants.dexSize == nil then
|
||||
local highest = 0
|
||||
for _, def in pairs(self.pokemon) do
|
||||
if def.dex and def.dex > highest then highest = def.dex end
|
||||
-- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation
|
||||
-- scalars beside species rows.
|
||||
if type(def) == "table" and def.dex and def.dex > highest then
|
||||
highest = def.dex
|
||||
end
|
||||
end
|
||||
constants.dexSize = highest
|
||||
end
|
||||
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
|
||||
if not chunk then return false, err end
|
||||
return pcall(chunk)
|
||||
end
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
|
||||
-- cache explicitly when require cannot see the mounted tree.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local path = "data/generated/" .. name .. ".lua"
|
||||
local bytes = CacheFs.readActive(path)
|
||||
if type(bytes) == "string" then
|
||||
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if not chunk then return false, err or mod end
|
||||
return pcall(chunk)
|
||||
local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if chunk then
|
||||
local ok, res = pcall(chunk)
|
||||
if ok then return true, res end
|
||||
end
|
||||
end
|
||||
return false, mod
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
return false, nil
|
||||
end
|
||||
|
||||
function Data:load()
|
||||
local dir = os.getenv("POKEPORT_DATA_DIR")
|
||||
local gen2 = require("src.core.GameVersion").generation() == 2
|
||||
for _, name in ipairs(MODULES) do
|
||||
local ok, mod = loadModule(dir, name)
|
||||
if not ok then
|
||||
if dir then
|
||||
if gen2 and GEN2_OPTIONAL[name] then
|
||||
self[name] = {}
|
||||
elseif dir then
|
||||
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
|
||||
:format(dir, name, mod))
|
||||
else
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
end
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
else
|
||||
self[name] = mod
|
||||
end
|
||||
self[name] = mod
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
local ok, mod = loadModule(dir, name)
|
||||
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
|
||||
if not pristine[key] then self[key] = nil end
|
||||
end
|
||||
end
|
||||
self._pristineKeys = nil
|
||||
for _, name in ipairs(MODULES) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
self[name] = nil
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
self[name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -417,4 +417,88 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
return body
|
||||
end
|
||||
|
||||
-- POST returning success/failure. Strictly one-way: the response body is
|
||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||
-- large body never lands in the command line; the Android bridge has no POST
|
||||
-- transport, and httpPost reports that instead of half-working through
|
||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(body) ~= "string" then return nil, "missing body" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
|
||||
-- "rw". Stage the request body so the response can stay on a read
|
||||
-- pipe. The staging directory comes from the OS temp contract, never
|
||||
-- tmpnam(): the CRT's tmpnam() can return a name relative to the process
|
||||
-- working directory, and a game installed under Program Files has no
|
||||
-- writable CWD -- io.open would fail before curl ever runs and postLog
|
||||
-- would silently drop the send. TEMP/TMP are per-user writable on
|
||||
-- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem:
|
||||
-- the sandbox-era transport stays on plain io/os.
|
||||
local function stagingPath()
|
||||
local dir = os.getenv("TEMP") or os.getenv("TMP")
|
||||
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
|
||||
local sep = dir:find("\\") and "\\" or "/"
|
||||
return dir .. sep .. ("gen1recomp-post-%d.tmp"):format(
|
||||
(os.time() % 1000000) * 100 + math.random(0, 99))
|
||||
end
|
||||
local bodyPath = stagingPath()
|
||||
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
|
||||
if not bodyFile then
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not create request body: " .. tostring(bodyOpenErr)
|
||||
end
|
||||
local bodyOk, bodyErr = pcall(function()
|
||||
assert(bodyFile:write(body))
|
||||
assert(bodyFile:close())
|
||||
end)
|
||||
if not bodyOk then
|
||||
pcall(function() bodyFile:close() end)
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not write body: " .. tostring(bodyErr)
|
||||
end
|
||||
|
||||
-- --data-binary @<file> keeps the payload out of argv (command-line length
|
||||
-- limits on Windows) and preserves every byte including trailing
|
||||
-- newlines. The body is staged above because io.popen cannot be opened
|
||||
-- for both writing and reading. No -f, matching httpGet: the response
|
||||
-- body is discarded anyway, and curl's stderr carries the diagnosis.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
.. "-X POST "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if contentType then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
|
||||
end
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
|
||||
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not run curl"
|
||||
end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
pcall(os.remove, bodyPath)
|
||||
if not readOk then
|
||||
return nil, fetchError(url, nil, tostring(out))
|
||||
end
|
||||
local _, status, noise = splitCurlOutput(out)
|
||||
if not status then return nil, fetchError(url, nil, noise) end
|
||||
if status < 200 or status >= 300 then
|
||||
return nil, fetchError(url, status, "log post rejected")
|
||||
end
|
||||
return true
|
||||
end
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
return HostShell
|
||||
|
||||
@@ -83,6 +83,12 @@ function ItemEffects.healsHP(id)
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- Does this item need a party-member target?
|
||||
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
||||
function ItemEffects.needsTarget(id, itemDef, data)
|
||||
|
||||
@@ -215,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
local function conflictApplies(spec, other)
|
||||
return not spec.range or (other and other.version
|
||||
and Semver.satisfies(other.version, spec.range))
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
if installedOther and isEnabled(conflictId)
|
||||
and conflictApplies(spec, installedOther)
|
||||
and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
@@ -238,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
local installedTarget = installedMap[manifest.id] or manifest
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
if spec.id == manifest.id and conflictApplies(spec, installedTarget) then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
|
||||
@@ -662,7 +662,13 @@ end
|
||||
|
||||
local function systemShim(ctx)
|
||||
local function real() return _G.love and _G.love.system or nil end
|
||||
return {
|
||||
-- tls* comes from the engine (Android JNI, or desktop gen1tls hung on
|
||||
-- love.system at boot). Forward those; keep clipboard / openURL stubbed.
|
||||
local TLS = {
|
||||
tlsOpen = true, tlsStatus = true, tlsSend = true,
|
||||
tlsReceive = true, tlsError = true, tlsClose = true,
|
||||
}
|
||||
local shim = {
|
||||
getOS = function()
|
||||
local sys = real()
|
||||
return sys and sys.getOS and sys.getOS() or "Unknown"
|
||||
@@ -697,6 +703,13 @@ local function systemShim(ctx)
|
||||
return false
|
||||
end,
|
||||
}
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
if not TLS[key] then return nil end
|
||||
local sys = real()
|
||||
return sys and sys[key]
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function eventShim(ctx)
|
||||
|
||||
+27
-2
@@ -1094,6 +1094,23 @@ function Loader:_api(mod)
|
||||
return { available = function() return false end,
|
||||
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- One-way crash-log reporting to the https URL the manifest declares in
|
||||
-- log_url. The destination is reviewed at load, not chosen per call, so
|
||||
-- a mod cannot aim this at arbitrary hosts; the response body is never
|
||||
-- returned, and the worker pool bounds the transfer. Same handle/poll/
|
||||
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
|
||||
postLog = (function()
|
||||
if mod.manifest.permissionSet.network and mod.manifest.log_url then
|
||||
return function(_, body, opts)
|
||||
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
|
||||
end
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.postLog needs the "network" permission and a '
|
||||
.. "log_url in manifest.json"):format(modId), 2)
|
||||
end
|
||||
return refuse
|
||||
end)(),
|
||||
-- Background compute, behind the "background" permission. The worker
|
||||
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
||||
-- one thing love.thread is not: off the main thread without a Lua state
|
||||
@@ -1291,7 +1308,7 @@ function Loader:_api(mod)
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
local world
|
||||
local world, battle
|
||||
setmetatable(api, { __index = function(_, key)
|
||||
-- mod.game is the live service owner, resolved per generation the way
|
||||
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
|
||||
@@ -1300,9 +1317,17 @@ function Loader:_api(mod)
|
||||
-- entry chunk runs. This is what a mod should hold instead of requiring
|
||||
-- src.core.Game, which under Gold hands back a table nothing instantiated.
|
||||
if key == "game" then return loader:_game() end
|
||||
local game = loader:_game()
|
||||
if key == "battle" then
|
||||
if battle then return battle end
|
||||
local module = game and engineRequire(loader.generation == 2
|
||||
and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI")
|
||||
if not module then return nil end
|
||||
battle = module.new(game)
|
||||
return battle
|
||||
end
|
||||
if key ~= "world" then return nil end
|
||||
if world then return world end
|
||||
local game = loader:_game()
|
||||
-- one facade name, one arm per generation: Gold's world is not a stack
|
||||
-- state and its flags are a bitfield, so the resolution differs even
|
||||
-- where the method set does not (src/world/gen2/WorldAPI.lua)
|
||||
|
||||
@@ -986,12 +986,45 @@ end
|
||||
function ManagerState:buildOptionRows(m, schema)
|
||||
local rows = {}
|
||||
local modId = m.id
|
||||
local byKey, visibilityKeys = {}, {}
|
||||
for _, row in ipairs(schema) do
|
||||
if type(row) == "table" and type(row.key) == "string" then
|
||||
byKey[row.key] = row
|
||||
local condition = row.visible_if
|
||||
if type(condition) == "table" and type(condition.key) == "string" then
|
||||
visibilityKeys[condition.key] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
local function visible(row)
|
||||
local condition = row.visible_if
|
||||
if condition == nil then return true end
|
||||
if type(condition) ~= "table" or type(condition.key) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local dependency = byKey[condition.key] or { key = condition.key }
|
||||
local value = self:optionValue(modId, dependency)
|
||||
if condition.equals ~= nil then return value == condition.equals end
|
||||
if condition.not_equals ~= nil then return value ~= condition.not_equals end
|
||||
return false
|
||||
end
|
||||
local function refresh(key)
|
||||
if not visibilityKeys[key] then return end
|
||||
local preferred = rows[self.cursor] and rows[self.cursor].id
|
||||
self.optionRows = self:buildOptionRows(m, schema)
|
||||
for index, candidate in ipairs(self.optionRows) do
|
||||
if candidate.id == preferred then self.cursor = index break end
|
||||
end
|
||||
self.cursor = clampIndex(self.cursor, #self.optionRows)
|
||||
end
|
||||
for _, row in ipairs(schema) do
|
||||
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
|
||||
or not OPTION_TYPES[row.type] then
|
||||
-- malformed rows are skipped, reported where the errors screen reads
|
||||
Runtime.reportError(modId, "options row skipped: "
|
||||
.. tostring(type(row) == "table" and (row.key or row.type) or row))
|
||||
elseif not visible(row) then
|
||||
-- Keep the row in the schema and stored options, only hide its menu row.
|
||||
elseif row.type == "toggle" then
|
||||
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
|
||||
value = function()
|
||||
@@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end,
|
||||
step = function()
|
||||
self:setOption(modId, row.key, not self:optionValue(modId, row))
|
||||
refresh(row.key)
|
||||
return true
|
||||
end }
|
||||
elseif row.type == "choice" then
|
||||
@@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end
|
||||
index = clampIndex(index + dir, #choices)
|
||||
self:setOption(modId, row.key, choices[index][2])
|
||||
refresh(row.key)
|
||||
return true
|
||||
end }
|
||||
elseif row.type == "number" then
|
||||
@@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
step = function(_, dir)
|
||||
local cur = tonumber(self:optionValue(modId, row)) or 0
|
||||
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
|
||||
refresh(row.key)
|
||||
return true
|
||||
end,
|
||||
activate = function()
|
||||
@@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
max = row.max or 99,
|
||||
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
|
||||
onDone = function(qty)
|
||||
if qty then self:setOption(modId, row.key, clamp(qty)) end
|
||||
if qty then
|
||||
self:setOption(modId, row.key, clamp(qty))
|
||||
refresh(row.key)
|
||||
end
|
||||
end,
|
||||
}))
|
||||
end }
|
||||
@@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
default = self:optionValue(modId, row),
|
||||
onDone = function(name)
|
||||
self:setOption(modId, row.key, name)
|
||||
refresh(row.key)
|
||||
end,
|
||||
}))
|
||||
end }
|
||||
@@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
self:setOption(modId, row.key, row.default)
|
||||
end
|
||||
end
|
||||
self.optionRows = self:buildOptionRows(m, schema)
|
||||
self.cursor = clampIndex(self.cursor, #self.optionRows)
|
||||
self:notify("DEFAULTS RESTORED")
|
||||
end }
|
||||
return rows
|
||||
|
||||
@@ -308,6 +308,24 @@ function Manifest.validate(raw, path)
|
||||
|
||||
local github = Manifest.parseGithub(raw.github)
|
||||
|
||||
-- log_url: the mod's one-way crash-log reporting destination. https-only,
|
||||
-- declared in the manifest so the engine reviews the target at load instead
|
||||
-- of trusting per-call URLs from gameplay code, and gated on the `network`
|
||||
-- permission the mod must also declare. api 1 mods never carry it: it is a
|
||||
-- load violation, not a warning, because a postLog-capable mod that does not
|
||||
-- opt in to networking is a bug in the manifest itself.
|
||||
local logUrl = nil
|
||||
if raw.log_url ~= nil then
|
||||
if strict and not permissionSet.network then
|
||||
violation(strict, raw.id, "log_url requires the network permission")
|
||||
elseif strict and (type(raw.log_url) ~= "string"
|
||||
or not raw.log_url:match("^https://")) then
|
||||
violation(strict, raw.id, "log_url must be an https:// URL")
|
||||
elseif strict then
|
||||
logUrl = raw.log_url
|
||||
end
|
||||
end
|
||||
|
||||
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||
"experimental must be a boolean")
|
||||
local experimental = raw.experimental == true
|
||||
@@ -414,6 +432,7 @@ function Manifest.validate(raw, path)
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
permissionSet = permissionSet,
|
||||
log_url = logUrl,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
required_imports = requiredImports,
|
||||
|
||||
@@ -32,6 +32,14 @@ local Net = {}
|
||||
Net.MAX_INFLIGHT = 4
|
||||
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
||||
Net.MAX_SECONDS = 30
|
||||
-- A log body ceiling. A diagnostic ring (boot evidence + recent lines +
|
||||
-- status) routinely exceeds 64 KiB on a long session, so the ceiling is
|
||||
-- 512 KiB: generous for real support logs, still far under the 5 MiB the
|
||||
-- reference loghook endpoint accepts, and small enough that a misbehaving
|
||||
-- mod cannot upload arbitrary megabytes. The body is staged to a file and
|
||||
-- streamed by the transport, so the ceiling is a budget, not a memory
|
||||
-- spike; callers that stay under it never notice it.
|
||||
Net.MAX_BODY = 512 * 1024
|
||||
|
||||
local function fetch()
|
||||
return require("src.net.Fetch")
|
||||
@@ -99,6 +107,62 @@ function Net.get(loader, modId, url, opts)
|
||||
return handle
|
||||
end
|
||||
|
||||
-- The closed list of postLog format switches. Anything outside it is a
|
||||
-- caller bug, rejected before a job is submitted, so the surface stays
|
||||
-- exactly two shapes on the wire.
|
||||
local POST_FORMATS = { text = true, json = true }
|
||||
|
||||
-- A one-way log POST to the mod's manifest-declared log_url (https only,
|
||||
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
|
||||
-- in-flight ceiling, user agent naming the mod. The response body is never
|
||||
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
|
||||
-- reason to hand a mod a server's reply.
|
||||
function Net.postLog(loader, modId, logUrl, body, opts)
|
||||
if type(body) ~= "string" or body == "" then
|
||||
return nil, "log body must be a non-empty string"
|
||||
end
|
||||
if #body > Net.MAX_BODY then
|
||||
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
|
||||
end
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
for key in pairs(opts) do
|
||||
if key ~= "format" then
|
||||
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
|
||||
end
|
||||
end
|
||||
local format = opts.format or "text"
|
||||
if not POST_FORMATS[format] then
|
||||
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
|
||||
end
|
||||
local denial = Net.urlDenial(logUrl)
|
||||
if denial then return nil, denial end
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Net.MAX_INFLIGHT then
|
||||
return nil, ("too many requests in flight (limit %d); poll and release "
|
||||
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
||||
end
|
||||
local payload = body
|
||||
local contentType = "text/plain"
|
||||
if format == "json" then
|
||||
local Json = require("src.link.Json")
|
||||
payload = Json.encode({
|
||||
ts = os.time(),
|
||||
mod = modId,
|
||||
format = "json",
|
||||
body = body,
|
||||
})
|
||||
contentType = "application/json"
|
||||
end
|
||||
local id = fetch().post(logUrl, payload, {
|
||||
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
||||
contentType = contentType,
|
||||
maxSeconds = Net.MAX_SECONDS,
|
||||
})
|
||||
local handle = {}
|
||||
b[handle] = id
|
||||
return handle
|
||||
end
|
||||
|
||||
-- A copy of the job's state, never the engine's own table. An unknown or
|
||||
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
||||
-- one cannot spin waiting on it forever.
|
||||
|
||||
@@ -132,6 +132,17 @@ function Fetch.get(url, opts)
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- POST a body to a URL, one-way. The result carries no body: postLog
|
||||
-- reporting never trusts a server's reply, so the worker surfaces only
|
||||
-- ok/error and the transport's complaint.
|
||||
-- opts: { userAgent, contentType, maxSeconds }
|
||||
function Fetch.post(url, body, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "post", url = url, body = body,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Load gen1tls from next to the exe and put its poll API on love.system --
|
||||
-- same tlsOpen / tlsSend / ... shape Android already exposes through JNI.
|
||||
--
|
||||
-- Mods can't require("ffi") under the sandbox, so they can't load the DLL
|
||||
-- themselves even when it's sitting right there. We do it here, before any
|
||||
-- mod runs. LegacyCompat's love.system shim forwards the tls* keys through
|
||||
-- (clipboard / openURL stay stubbed).
|
||||
--
|
||||
-- true = tlsOpen is ready. Already present (Android), no FFI, or no DLL:
|
||||
-- just return false and move on; plain ws:// rooms don't care.
|
||||
|
||||
local Gen1Tls = {}
|
||||
|
||||
local function exeDir()
|
||||
if love and love.filesystem and love.filesystem.getSourceBaseDirectory then
|
||||
local base = love.filesystem.getSourceBaseDirectory()
|
||||
if type(base) == "string" and base ~= "" then return base end
|
||||
end
|
||||
if type(arg) == "table" and type(arg[0]) == "string" then
|
||||
local dir = arg[0]:match("^(.*)[/\\]")
|
||||
if dir and dir ~= "" then return dir end
|
||||
end
|
||||
return "."
|
||||
end
|
||||
|
||||
local function fileReadable(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local function libNames()
|
||||
local osName = (love and love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Windows" then return { "gen1tls.dll" } end
|
||||
if osName == "OS X" then return { "libgen1tls.dylib", "gen1tls.dylib" } end
|
||||
if osName == "Linux" then return { "libgen1tls.so", "gen1tls.so" } end
|
||||
return { "gen1tls.dll", "libgen1tls.so", "libgen1tls.dylib" }
|
||||
end
|
||||
|
||||
function Gen1Tls.install()
|
||||
if not (love and love.system) then return false end
|
||||
if type(love.system.tlsOpen) == "function" then return true end
|
||||
|
||||
local okFfi, ffi = pcall(require, "ffi")
|
||||
if not okFfi or type(ffi) ~= "table" then return false end
|
||||
|
||||
ffi.cdef[[
|
||||
int gen1tls_open(const char *host, int port);
|
||||
int gen1tls_status(int handle);
|
||||
int gen1tls_send(int handle, const char *data, int length);
|
||||
int gen1tls_receive(int handle, char *buf, int max);
|
||||
int gen1tls_error(int handle, char *buf, int max);
|
||||
void gen1tls_close(int handle);
|
||||
]]
|
||||
|
||||
local dir = exeDir()
|
||||
local lib
|
||||
for _, name in ipairs(libNames()) do
|
||||
local path = dir .. "/" .. name
|
||||
if fileReadable(path) then
|
||||
local ok, loaded = pcall(ffi.load, path)
|
||||
if ok then lib = loaded; break end
|
||||
end
|
||||
local ok, loaded = pcall(ffi.load, name)
|
||||
if ok then lib = loaded; break end
|
||||
end
|
||||
if not lib then return false end
|
||||
|
||||
local errBuf = ffi.new("char[512]")
|
||||
local recvBuf = ffi.new("char[65536]")
|
||||
|
||||
love.system.tlsOpen = function(host, port)
|
||||
return lib.gen1tls_open(host, tonumber(port) or 0)
|
||||
end
|
||||
love.system.tlsStatus = function(handle)
|
||||
return lib.gen1tls_status(handle)
|
||||
end
|
||||
love.system.tlsSend = function(handle, data)
|
||||
data = data or ""
|
||||
return lib.gen1tls_send(handle, data, #data)
|
||||
end
|
||||
love.system.tlsReceive = function(handle, max)
|
||||
max = math.min(tonumber(max) or 8192, 65536)
|
||||
if max <= 0 then return "" end
|
||||
local n = lib.gen1tls_receive(handle, recvBuf, max)
|
||||
if n <= 0 then return "" end
|
||||
return ffi.string(recvBuf, n)
|
||||
end
|
||||
love.system.tlsError = function(handle)
|
||||
if lib.gen1tls_error(handle, errBuf, 512) == 0 then return nil end
|
||||
return ffi.string(errBuf)
|
||||
end
|
||||
love.system.tlsClose = function(handle)
|
||||
lib.gen1tls_close(handle)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return Gen1Tls
|
||||
@@ -99,6 +99,20 @@ local function doDownload(job)
|
||||
post({ id = job.id, ok = true, path = rel, done = true })
|
||||
end
|
||||
|
||||
local function doPost(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
return
|
||||
end
|
||||
local ok, err = HostShell.httpPost(job.url, job.body, job.contentType,
|
||||
job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS)
|
||||
if not ok then
|
||||
post({ id = job.id, ok = false, err = err or "post failed" })
|
||||
return
|
||||
end
|
||||
post({ id = job.id, ok = true, done = true })
|
||||
end
|
||||
|
||||
while true do
|
||||
local job = cmdCh:demand()
|
||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||
@@ -114,6 +128,9 @@ while true do
|
||||
elseif job.kind == "get" then
|
||||
local ok, err = pcall(doGet, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "post" then
|
||||
local ok, err = pcall(doPost, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "download" then
|
||||
local ok, err = pcall(doDownload, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
|
||||
@@ -253,6 +253,16 @@ function TextBox:beginLine()
|
||||
table.insert(self.shown, {})
|
||||
end
|
||||
|
||||
function TextBox:visibleText()
|
||||
local page = self.pages[self.pageIndex]
|
||||
if not page then return nil end
|
||||
local out, count = {}, #(self.shown or {})
|
||||
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
|
||||
if page[i] ~= nil then out[#out + 1] = page[i] end
|
||||
end
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
|
||||
+63
-43
@@ -121,6 +121,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
|
||||
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
|
||||
-- $e1/$e2), which is what makes it fit a six-tile column.
|
||||
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
|
||||
local MENU_ACTION = { FIGHT = "fight", ["<PK><MN>"] = "party",
|
||||
PACK = "item", RUN = "run" }
|
||||
local MENU_BOX_X = 8
|
||||
local MENU_COL_SPACING = 6
|
||||
|
||||
@@ -1660,6 +1662,64 @@ function BattleState:playerMoves()
|
||||
return (self.battle and self.battle.player and self.battle.player.moves) or {}
|
||||
end
|
||||
|
||||
-- One semantic path for the native command menu and mod.battle intents.
|
||||
function BattleState:chooseMenu(choice)
|
||||
if self.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if choice == "fight" then
|
||||
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and #self:playerMoves() > 0
|
||||
and not self.battle:hasUsableMoves(fighter) then
|
||||
self:submit({ kind = "move", move = Battle.STRUGGLE })
|
||||
else
|
||||
self.phase = "moves"
|
||||
-- MoveSelectionScreen reopens on the last used move, clamped if the
|
||||
-- moveset shrank since then.
|
||||
local moves = self:playerMoves()
|
||||
self.moveIndex = math.max(1,
|
||||
math.min(self.moveIndex or 1, math.max(1, #moves)))
|
||||
end
|
||||
elseif choice == "run" then
|
||||
self:submit({ kind = "run" })
|
||||
elseif choice == "item" then
|
||||
if self.tutorial then
|
||||
self:openTutorialPack()
|
||||
elseif self.contest then
|
||||
self:throwParkBall()
|
||||
else
|
||||
self:openPack()
|
||||
end
|
||||
elseif choice == "party" then
|
||||
self:openParty()
|
||||
else
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:chooseMove(index)
|
||||
if self.phase ~= "moves" then return nil, "move menu is not active" end
|
||||
local move = self:playerMoves()[index]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
self.moveIndex = index
|
||||
self.moveSwapIndex = nil
|
||||
if (move.pp or 0) <= 0 then
|
||||
self:refuseMove(TEXT_NO_PP_LEFT)
|
||||
elseif self.battle:moveDisabled(self.battle.player, move.id) then
|
||||
self:refuseMove(TEXT_MOVE_DISABLED)
|
||||
else
|
||||
self:submit({ kind = "move", move = move.id })
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:cancelMove()
|
||||
if self.phase ~= "moves" then return nil, "move menu is not active" end
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
return true
|
||||
end
|
||||
|
||||
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
|
||||
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
|
||||
-- the cursor, and A or B clears the mark without swapping (the A arm opens
|
||||
@@ -1833,37 +1893,7 @@ function BattleState:update(_dt)
|
||||
or self.menuIndex - 2
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
local choice = MENU[self.menuIndex]
|
||||
if choice == "FIGHT" then
|
||||
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
|
||||
-- :5058-5059): a mon with nothing to spend never sees the list.
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and #self:playerMoves() > 0
|
||||
and not self.battle:hasUsableMoves(fighter) then
|
||||
return self:submit({ kind = "move", move = Battle.STRUGGLE })
|
||||
end
|
||||
self.phase = "moves"
|
||||
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
|
||||
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
|
||||
-- back, so the list reopens on the move used last turn; only
|
||||
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
|
||||
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
|
||||
local moves = self:playerMoves()
|
||||
self.moveIndex = math.max(1,
|
||||
math.min(self.moveIndex or 1, math.max(1, #moves)))
|
||||
elseif choice == "RUN" then
|
||||
self:submit({ kind = "run" })
|
||||
elseif choice == "PACK" then
|
||||
if self.tutorial then
|
||||
self:openTutorialPack()
|
||||
elseif self.contest then
|
||||
self:throwParkBall()
|
||||
else
|
||||
self:openPack()
|
||||
end
|
||||
else
|
||||
self:openParty()
|
||||
end
|
||||
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1884,22 +1914,12 @@ function BattleState:update(_dt)
|
||||
elseif input:wasPressed("b") then
|
||||
-- B leaves the list, and a mark never survives it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
self:cancelMove()
|
||||
elseif input:wasPressed("a") then
|
||||
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
|
||||
-- cancels a pending swap rather than performing it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
local move = moves[self.moveIndex]
|
||||
if not move then return end
|
||||
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
|
||||
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
|
||||
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
|
||||
if self.battle:moveDisabled(self.battle.player, move.id) then
|
||||
return self:refuseMove(TEXT_MOVE_DISABLED)
|
||||
end
|
||||
self:submit({ kind = "move", move = move.id })
|
||||
self:chooseMove(self.moveIndex)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
-- "update_check_state" worker -> main: { status, latest, progress, error }
|
||||
--
|
||||
-- Nothing here ever blocks or throws into the game loop: when love.thread is
|
||||
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
|
||||
-- state() simply reports "error" and the UI hides itself. See the shared
|
||||
-- contract in the task brief for the status vocabulary and the file layout.
|
||||
-- absent (the headless test stub) or the worker cannot run, state() reports
|
||||
-- "error" (or the worker reports "needs_full" when there is no transport).
|
||||
-- See the shared contract in the task brief for the status vocabulary.
|
||||
--
|
||||
-- The release-JSON extraction and the sums parsing are exported as pure
|
||||
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
|
||||
|
||||
+65
-62
@@ -5,11 +5,10 @@
|
||||
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
|
||||
-- "update_check_state" out: { status, latest, progress, error }
|
||||
--
|
||||
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
|
||||
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
|
||||
-- or a hung download degrades to a "error"/"needs_full" state rather than
|
||||
-- blocking or crashing the game. On Android curl is absent and the check
|
||||
-- soft-fails to "error", which the UI hides.
|
||||
-- Transport is HostShell: curl via io.popen on desktop, the JNI
|
||||
-- love.system.httpDownload bridge on Android (same path the mod catalog
|
||||
-- already uses). A missing transport, an HTTP error, or a hung download
|
||||
-- degrades to "error"/"needs_full" rather than blocking or crashing the game.
|
||||
--
|
||||
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
|
||||
-- modules are pulled in with love.filesystem.load exactly like
|
||||
@@ -63,9 +62,12 @@ local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/la
|
||||
local pending = nil
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- shell / curl
|
||||
-- shell / fetch
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local UA = "gen1recomp-updater"
|
||||
local GH_ACCEPT = "application/vnd.github+json"
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if isWindows then
|
||||
@@ -74,31 +76,17 @@ local function shq(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- run curl and return its response body (text), or nil on any failure. Used
|
||||
-- for the small text resources (release JSON, sums file); -f makes curl exit
|
||||
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
|
||||
local function curlCapture(url)
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
|
||||
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
||||
.. shq(url)
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then return nil end
|
||||
local out = pipe:read("*a")
|
||||
-- HostShell.pclose, not pipe:close(): a close outside the spawn lock can
|
||||
-- free a FILE while another thread's popen walks the stream list, which
|
||||
-- deadlocks that thread permanently (see HostShell's popen notes).
|
||||
HostShell.pclose(pipe)
|
||||
if not out or out == "" then return nil end
|
||||
return out
|
||||
-- Small text resources (release JSON, sums file) through HostShell so Android
|
||||
-- hits the JNI bridge instead of a curl binary that is never on the device.
|
||||
local function fetchText(url, accept)
|
||||
if not HostShell then return nil end
|
||||
local body = HostShell.httpGet(url, UA, accept)
|
||||
if type(body) ~= "string" or body == "" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function haveCurl()
|
||||
local pipe = HostShell.popen("curl --version")
|
||||
if not pipe then return false end
|
||||
local out = pipe:read("*a")
|
||||
HostShell.pclose(pipe)
|
||||
return out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
local function canFetch()
|
||||
return HostShell and HostShell.canFetch()
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -170,12 +158,14 @@ end
|
||||
local function doCheck()
|
||||
post({ status = "checking" })
|
||||
|
||||
if not haveCurl() then
|
||||
post({ status = "error", error = "curl not available" })
|
||||
if not canFetch() then
|
||||
-- No curl and no JNI bridge: the chip becomes "Open releases" so a tap
|
||||
-- still does something instead of retrying a check that cannot succeed.
|
||||
post({ status = "needs_full" })
|
||||
return
|
||||
end
|
||||
|
||||
local body = curlCapture(API_URL)
|
||||
local body = fetchText(API_URL, GH_ACCEPT)
|
||||
if not body then
|
||||
post({ status = "error", error = "release check failed" })
|
||||
return
|
||||
@@ -212,7 +202,7 @@ local function doCheck()
|
||||
-- pulling the bytes again.
|
||||
local finalRel = "updates/" .. rel.payloadName
|
||||
if love.filesystem.getInfo(finalRel) then
|
||||
local sums = curlCapture(rel.sums.url)
|
||||
local sums = fetchText(rel.sums.url)
|
||||
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
|
||||
if gatePasses(finalRel) == false then
|
||||
love.filesystem.remove(finalRel)
|
||||
@@ -279,39 +269,52 @@ local function doDownload()
|
||||
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
|
||||
local size = rel.payload.size or 0
|
||||
|
||||
launchDownload(rel.payload.url, partAbs, doneAbs)
|
||||
if HostShell and HostShell.haveCurl() then
|
||||
launchDownload(rel.payload.url, partAbs, doneAbs)
|
||||
|
||||
-- poll the .part size for progress until curl drops the done-marker; a
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
-- A queued quit means the window already closed. Bail so the join in
|
||||
-- Check.shutdown does not hold the dead window's process (and, on
|
||||
-- Windows, its folder) open for up to the whole transfer (#727). The
|
||||
-- quit stays on the channel for the command loop; the detached curl
|
||||
-- times out on its own and the next launch's doCheck verifies and
|
||||
-- re-offers whatever landed.
|
||||
local peeked = cmdCh:peek()
|
||||
if type(peeked) == "table" and peeked.cmd == "quit" then return end
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
if size > 0 then
|
||||
local p = cur / size
|
||||
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
|
||||
post({ status = "downloading", latest = rel.version, progress = p })
|
||||
else
|
||||
post({ status = "downloading", latest = rel.version })
|
||||
-- poll the .part size for progress until curl drops the done-marker; a
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
-- A queued quit means the window already closed. Bail so the join in
|
||||
-- Check.shutdown does not hold the dead window's process (and, on
|
||||
-- Windows, its folder) open for up to the whole transfer (#727). The
|
||||
-- quit stays on the channel for the command loop; the detached curl
|
||||
-- times out on its own and the next launch's doCheck verifies and
|
||||
-- re-offers whatever landed.
|
||||
local peeked = cmdCh:peek()
|
||||
if type(peeked) == "table" and peeked.cmd == "quit" then return end
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
if size > 0 then
|
||||
local p = cur / size
|
||||
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
|
||||
post({ status = "downloading", latest = rel.version, progress = p })
|
||||
else
|
||||
post({ status = "downloading", latest = rel.version })
|
||||
end
|
||||
if cur ~= lastSize then lastSize, lastChange = cur, waited end
|
||||
if waited - lastChange > 60 then break end -- 60s with no growth: give up
|
||||
if waited > 960 then break end -- absolute ceiling
|
||||
love.timer.sleep(0.25)
|
||||
waited = waited + 0.25
|
||||
end
|
||||
if cur ~= lastSize then lastSize, lastChange = cur, waited end
|
||||
if waited - lastChange > 60 then break end -- 60s with no growth: give up
|
||||
if waited > 960 then break end -- absolute ceiling
|
||||
love.timer.sleep(0.25)
|
||||
waited = waited + 0.25
|
||||
love.filesystem.remove(doneRel)
|
||||
else
|
||||
-- Android JNI bridge: blocking write, same as fetch_worker. Progress
|
||||
-- cannot be sampled from inside httpDownload.
|
||||
local ok = HostShell and HostShell.httpDownload(
|
||||
rel.payload.url, partAbs, UA, nil, 900)
|
||||
if not ok then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = "download failed" })
|
||||
return
|
||||
end
|
||||
post({ status = "downloading", latest = rel.version, progress = 0.999 })
|
||||
end
|
||||
love.filesystem.remove(doneRel)
|
||||
|
||||
local sums = curlCapture(rel.sums and rel.sums.url or "")
|
||||
local sums = fetchText(rel.sums and rel.sums.url or "")
|
||||
if not sums then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = "checksum fetch failed" })
|
||||
|
||||
@@ -95,8 +95,10 @@ end
|
||||
-- are listed; callers receive copied labels and never inspect world internals.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local game, ow, out = self.game, self:overworld(), {}
|
||||
if not (game and game.save and ow and ow.map and ow.player)
|
||||
or not acceptsMenuInput(game, ow) then return out end
|
||||
if not (game and game.save and ow and ow.map and ow.player) then
|
||||
return out, NO_OVERWORLD
|
||||
end
|
||||
if not acceptsMenuInput(game, ow) then return out, "world is busy" end
|
||||
local save, inventory = game.save, game.save.inventory or {}
|
||||
local items = game.data and game.data.items or {}
|
||||
|
||||
|
||||
@@ -78,8 +78,10 @@ end
|
||||
-- collision and fishing rules.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local world, game, out = self:overworld(), self.game, {}
|
||||
if not (world and game and game.save and world.map and world.player)
|
||||
or not world:acceptsMenuInput() then return out end
|
||||
if not (world and game and game.save and world.map and world.player) then
|
||||
return out, NO_OVERWORLD
|
||||
end
|
||||
if not world:acceptsMenuInput() then return out, "world is busy" end
|
||||
local inventory = game.save.inventory or {}
|
||||
|
||||
if (inventory.BICYCLE or 0) > 0 then
|
||||
|
||||
@@ -49,10 +49,40 @@ check(position("onHostPause();") < position("super.onPause();"),
|
||||
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
||||
"destroy hook runs before SDL destruction")
|
||||
|
||||
check(source:find("DisplayManager.DisplayListener", 1, true)
|
||||
and source:find("registerDisplayListener", 1, true)
|
||||
and source:find("unregisterDisplayListener", 1, true),
|
||||
"secondary displays are monitored while the activity is active")
|
||||
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
|
||||
position("setupSecondaryDisplay();"),
|
||||
"secondary display monitoring starts before initial discovery")
|
||||
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
||||
"a disconnected active display is rebound without replacing a live one")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
check(not source:find("QuestActivity", 1, true) and
|
||||
not source:find("QuestBridge", 1, true),
|
||||
"generic Android activity must not require Quest classes")
|
||||
|
||||
-- Required mod files use Android's Storage Access Framework, which works with
|
||||
-- Android 13 scoped storage without broad media/storage permissions. Keep the
|
||||
-- native destination distinct so it cannot be consumed as a game ROM.
|
||||
check(source:find('PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"',
|
||||
1, true), "required imports use their own Android picker destination")
|
||||
check(source:find("showRequiredImportFilePicker", 1, true),
|
||||
"Android exposes a required-import picker entry point")
|
||||
check(source:find("Intent.ACTION_OPEN_DOCUMENT", 1, true)
|
||||
and source:find("Intent.FLAG_GRANT_READ_URI_PERMISSION", 1, true),
|
||||
"Android 13 uses SAF with an explicit read grant")
|
||||
|
||||
local systemPath = "mobile/android/love/src/jni/love/src/modules/system/System.cpp"
|
||||
local systemFile = assert(io.open(systemPath, "rb"))
|
||||
local system = systemFile:read("*a")
|
||||
systemFile:close()
|
||||
check(system:find('strcmp(kind, "required_import")', 1, true)
|
||||
and system:find('dest = "picked_required_import.bin"', 1, true)
|
||||
and system:find('return "rom,mod,sav,required_import"', 1, true),
|
||||
"native Android bridge advertises and routes required imports")
|
||||
|
||||
print("android_host_extension_test: ok")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
|
||||
--
|
||||
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
|
||||
-- and a response status, so the body is staged in a temporary file and curl
|
||||
-- is opened read-only for its response.
|
||||
-- luajit tests/engine/host_shell_postlog.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local HostShell = require("src.core.HostShell")
|
||||
|
||||
local MARK = "\n__gen1recomp_http__"
|
||||
local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
|
||||
local URL = "https://logs.example.com/logs"
|
||||
local BODY = "debug log body\n"
|
||||
|
||||
local realOpen = io.open
|
||||
local realPopen = io.popen
|
||||
local realGetenv = os.getenv
|
||||
local realRemove = os.remove
|
||||
local realHaveCurl = HostShell.haveCurl
|
||||
|
||||
local openedPath, openedMode, writtenBody
|
||||
local popenCommand, popenMode, removedPath
|
||||
|
||||
HostShell.haveCurl = function() return true end
|
||||
os.getenv = function(name)
|
||||
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
|
||||
return STAGE_DIR
|
||||
end
|
||||
return realGetenv(name)
|
||||
end
|
||||
os.remove = function(path)
|
||||
removedPath = path
|
||||
return true
|
||||
end
|
||||
|
||||
io.open = function(path, mode)
|
||||
openedPath, openedMode = path, mode
|
||||
return {
|
||||
write = function(_, value)
|
||||
writtenBody = value
|
||||
return true
|
||||
end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
io.popen = function(command, mode)
|
||||
popenCommand, popenMode = command, mode
|
||||
return {
|
||||
read = function() return MARK .. "200" end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
|
||||
|
||||
io.open = realOpen
|
||||
io.popen = realPopen
|
||||
os.getenv = realGetenv
|
||||
os.remove = realRemove
|
||||
HostShell.haveCurl = realHaveCurl
|
||||
|
||||
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
|
||||
check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
|
||||
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
|
||||
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
|
||||
eq(writtenBody, BODY, "the complete log body is staged")
|
||||
eq(popenMode, "r", "curl is opened in the supported read-only mode")
|
||||
check(popenCommand:find("--data-binary", 1, true) ~= nil,
|
||||
"curl reads the staged body with --data-binary")
|
||||
check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
|
||||
"curl receives the temporary body path")
|
||||
check(popenCommand:find(BODY, 1, true) == nil,
|
||||
"the log body is not placed directly in the command line")
|
||||
eq(removedPath, openedPath, "the staged request body is removed")
|
||||
|
||||
T.finish("host shell postlog")
|
||||
@@ -0,0 +1,32 @@
|
||||
-- iOS required imports travel through the same document-picker contract as
|
||||
-- Android. Keep the Swift bridge and liblove patch aligned: a build that has
|
||||
-- only one side would show the import button but fail on device.
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local data = file:read("*a")
|
||||
file:close()
|
||||
return data
|
||||
end
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local bridge = read("mobile/ios/native/GRPickerBridge.swift")
|
||||
check(bridge:find('case "required_import":', 1, true)
|
||||
and bridge:find('destName = "picked_required_import.bin"', 1, true),
|
||||
"iOS routes required imports to their own staged filename")
|
||||
check(bridge:find("types.append(.data)", 1, true)
|
||||
and bridge:find("types.append(.item)", 1, true),
|
||||
"iOS required imports accept user-owned binary ROM files")
|
||||
check(bridge:find('"rom,mod,sav,stadium,required_import"', 1, true),
|
||||
"iOS advertises required_import to Lua before opening the picker")
|
||||
|
||||
local patch = read("mobile/ios/patch_love_src.py")
|
||||
check(patch:find("int w_pickFileKinds", 1, true)
|
||||
and patch:find('{ "pickFileKinds", w_pickFileKinds }', 1, true),
|
||||
"iOS liblove patch exposes the picker capability query")
|
||||
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
|
||||
"iOS build patch compiles the required-import picker bridge")
|
||||
|
||||
print("ios_required_import_picker_test: ok")
|
||||
@@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]],
|
||||
check(removedPath == [[C:\LocalState\picked_mod.zip]],
|
||||
"removes the temporary copy after installation")
|
||||
|
||||
-- The UWP picker returns a temporary path rather than a mobile staged name.
|
||||
-- Required imports must use that same picker and remain scoped to the selected
|
||||
-- mod instead of relying on a desktop shell or Android/iOS inbox handling.
|
||||
local pickedKind
|
||||
love.system.pickFile = function(kind)
|
||||
pickedKind = kind
|
||||
return true
|
||||
end
|
||||
love.system.getPickedFile = function()
|
||||
love.system.getPickedFile = function() return nil end
|
||||
return [[C:\LocalState\picked_required_import.bin]]
|
||||
end
|
||||
removedPath = nil
|
||||
local required = RomImporter.new(function() end, { launcher = true })
|
||||
required.mods = { {
|
||||
id = "needs-source",
|
||||
manifest = {
|
||||
id = "needs-source", name = "Needs source",
|
||||
required_imports = { {
|
||||
id = "source", name = "Source", file = "source.bin",
|
||||
md5 = { "00000000000000000000000000000000" },
|
||||
} },
|
||||
},
|
||||
} }
|
||||
required._importRequiredSource = function(self, modId, importId, path)
|
||||
self.requiredPath = { modId = modId, importId = importId, path = path }
|
||||
return true
|
||||
end
|
||||
required:chooseRequiredImport("needs-source", "source")
|
||||
check(pickedKind == "required_import", "UWP requests the required-import picker kind")
|
||||
required:update(0)
|
||||
check(required.requiredPath and required.requiredPath.path
|
||||
== [[C:\LocalState\picked_required_import.bin]],
|
||||
"UWP routes the picked dependency to its declared import")
|
||||
check(required.requiredPath.modId == "needs-source"
|
||||
and required.requiredPath.importId == "source",
|
||||
"UWP preserves the pending mod and import identity")
|
||||
check(removedPath == [[C:\LocalState\picked_required_import.bin]],
|
||||
"UWP removes its temporary required-import copy after validation")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.getPickedFile = saved.getPickedFile
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local S = require("tests.harness").suite("mod battle snapshot")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Gen1BattleState = require("src.battle.BattleState")
|
||||
check(Gen1BattleState.isBattleState == true,
|
||||
"Gen 1 battle states carry the discovery marker")
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load({ type_chart = {
|
||||
types = { NORMAL = { name = "NORMAL", category = "physical" } },
|
||||
matchups = {},
|
||||
} })
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local attacker, defender = { stages = {} }, { stages = {} }
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
|
||||
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
|
||||
attacker, defender), 256, "clean accuracy exposes a certain hit")
|
||||
local Catching = require("src.battle.Catching")
|
||||
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }), 100, "Master Ball preview is certain")
|
||||
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
|
||||
"custom ball logic does not receive a guessed preview")
|
||||
|
||||
local mon = { species = "TESTMON", level = 5, hp = 18,
|
||||
stats = { hp = 20 }, moves = {} }
|
||||
local game = {
|
||||
data = {
|
||||
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
|
||||
accuracy = 95, pp = 35 } },
|
||||
items = { POTION = { name = "POTION" },
|
||||
POKE_BALL = { name = "POKE BALL" } },
|
||||
},
|
||||
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
|
||||
stack = { states = {} },
|
||||
}
|
||||
local battle = {
|
||||
isBattleState = true, phase = "menu", queue = {},
|
||||
ruleset = { oneIn256Miss = true },
|
||||
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
|
||||
curMoves = { { id = "TACKLE", pp = 35 } } },
|
||||
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
|
||||
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
|
||||
}
|
||||
function battle:battleKind() return "wild" end
|
||||
function battle:effectRecord() return { accuracyChecked = true } end
|
||||
function battle:visibleText() return { "Wild TESTMON appeared!" } end
|
||||
function battle:menuLockedAction() return nil end
|
||||
function battle:chooseMenu(choice)
|
||||
self.chosenMenu = choice
|
||||
if choice == "fight" then self.phase = "moveSelect" end
|
||||
return true
|
||||
end
|
||||
function battle:chooseMove(slot)
|
||||
self.chosenMove = slot
|
||||
self.phase = "messages"
|
||||
return true
|
||||
end
|
||||
function battle:cancelMove() self.phase = "menu" return true end
|
||||
function battle:catchChance(ball)
|
||||
return require("src.battle.Catching").chance(ball, self.enemy.mon,
|
||||
game.data.pokemon[self.enemy.mon.species])
|
||||
end
|
||||
game.stack.states = { battle }
|
||||
|
||||
local api = require("src.battle.BattleAPI").new(game)
|
||||
local snapshot = api:snapshot()
|
||||
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
|
||||
"Gen 1 battle is exposed")
|
||||
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
|
||||
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
|
||||
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
|
||||
eq(#snapshot.items, 2, "medicine and balls are exposed")
|
||||
check(type(snapshot.items[1].catchChance) == "number"
|
||||
or type(snapshot.items[2].catchChance) == "number",
|
||||
"stock catch chance is available")
|
||||
snapshot.player.hp = 0
|
||||
snapshot.moves[1].pp = 0
|
||||
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
|
||||
eq(battle.player.curMoves[1].pp, 35,
|
||||
"changing a snapshot cannot change a move")
|
||||
local same = api:snapshot()
|
||||
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
|
||||
battle.enemy.mon.hp = 5
|
||||
check(api:snapshot().revision > same.revision,
|
||||
"observable battle changes advance the revision")
|
||||
game.stack.states = {}
|
||||
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
|
||||
game.stack.states = { battle }
|
||||
|
||||
local menu = api:snapshot()
|
||||
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "stale battle context",
|
||||
"Gen 1 rejects a stale intent")
|
||||
ok, err = api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "missing" })
|
||||
check(not ok and err == "unknown battle menu choice",
|
||||
"Gen 1 rejects an unknown menu choice")
|
||||
check(api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
|
||||
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
|
||||
ok, err = api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
|
||||
local moveMenu = api:snapshot()
|
||||
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
|
||||
kind = "move", slot = 9 })
|
||||
check(not ok and err == "invalid move slot",
|
||||
"Gen 1 rejects an invalid move slot")
|
||||
check(api:submit({ id = 2, revision = moveMenu.revision,
|
||||
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
|
||||
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
|
||||
battle.phase = "moveSelect"
|
||||
local back = api:snapshot()
|
||||
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
|
||||
"Gen 1 accepts move-menu back")
|
||||
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
|
||||
|
||||
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
|
||||
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
|
||||
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
|
||||
moves = {} }
|
||||
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
|
||||
wild = true, turn = 0 }
|
||||
function battle2:moveDisabled() return false end
|
||||
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
|
||||
phase = "menu", menuIndex = 1, moveIndex = 1 }
|
||||
function screen2:chooseMenu(choice)
|
||||
self.chosenMenu = choice
|
||||
if choice == "fight" then self.phase = "moves" end
|
||||
return true
|
||||
end
|
||||
function screen2:chooseMove(slot)
|
||||
self.chosenMove = slot
|
||||
self.phase = "resolving"
|
||||
return true
|
||||
end
|
||||
function screen2:cancelMove() self.phase = "menu" return true end
|
||||
local game2 = {
|
||||
data = {
|
||||
pokemon = { CHIKORITA = { name = "CHIKORITA" },
|
||||
RATTATA = { name = "RATTATA" } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
|
||||
power = 35, accuracy = 95, pp = 35 } },
|
||||
},
|
||||
save = { party = { player2 } }, stack = { states = { screen2 } },
|
||||
}
|
||||
|
||||
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
|
||||
local snapshot2 = api2:snapshot()
|
||||
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
|
||||
"Gold battle is discovered through its screen id")
|
||||
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
|
||||
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
|
||||
snapshot2.player.hp = 0
|
||||
snapshot2.moves[1].pp = 0
|
||||
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
|
||||
eq(player2.moves[1].pp, 35,
|
||||
"changing a snapshot cannot change a Gold move")
|
||||
screen2.message = "A wild RATTATA appeared!"
|
||||
screen2.phase = "resolving"
|
||||
local message2 = api2:snapshot()
|
||||
eq(message2.prompt, "advance", "Gold message state is exposed")
|
||||
check(message2.revision > snapshot2.revision,
|
||||
"Gold battle changes advance the revision")
|
||||
game2.stack.states = {}
|
||||
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
|
||||
game2.stack.states = { screen2 }
|
||||
|
||||
screen2.message = nil
|
||||
screen2.phase = "menu"
|
||||
local menu2 = api2:snapshot()
|
||||
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "stale battle context",
|
||||
"Gold rejects a stale intent")
|
||||
ok, err = api2:submit({ id = 1, revision = menu2.revision,
|
||||
kind = "menu", choice = "missing" })
|
||||
check(not ok and err == "unknown battle menu choice",
|
||||
"Gold rejects an unknown menu choice")
|
||||
check(api2:submit({ id = 1, revision = menu2.revision,
|
||||
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
|
||||
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
|
||||
local moveMenu2 = api2:snapshot()
|
||||
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
|
||||
kind = "move", slot = 9 })
|
||||
check(not ok and err == "invalid move slot",
|
||||
"Gold rejects an invalid move slot")
|
||||
check(api2:submit({ id = 2, revision = moveMenu2.revision,
|
||||
kind = "move", slot = 1 }), "Gold accepts a valid move")
|
||||
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
|
||||
screen2.phase = "moves"
|
||||
local back2 = api2:snapshot()
|
||||
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
|
||||
"Gold accepts move-menu back")
|
||||
eq(screen2.phase, "menu", "Gold back restores the command menu")
|
||||
|
||||
do
|
||||
local Data = require("tests.modkit").fixtures.fresh()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local pressed = {}
|
||||
local game3 = { data = Data, save = save, input = {
|
||||
wasPressed = function(_, key) return pressed[key] == true end,
|
||||
isDown = function() return false end,
|
||||
}, stack = { states = {} } }
|
||||
function game3.stack:top() return self.states[#self.states] end
|
||||
function game3.stack:push(state) self.states[#self.states + 1] = state end
|
||||
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
|
||||
real.phase, real.queue, real.introSlide = "menu", {}, nil
|
||||
game3.stack.states = { real }
|
||||
pressed.a = true
|
||||
real:update(1 / 60)
|
||||
pressed.a = nil
|
||||
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
|
||||
pressed.b = true
|
||||
real:update(1 / 60)
|
||||
pressed.b = nil
|
||||
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
|
||||
end
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local fs = { read = function() end, getInfo = function() end,
|
||||
getDirectoryItems = function() return {} end }
|
||||
local mod = { path = "mods/snapshot_test", manifest = {
|
||||
id = "snapshot_test", version = "1.0.0", permissionSet = {},
|
||||
} }
|
||||
local loader1 = Loader.new({ fs = fs, generation = 1 })
|
||||
loader1.game = game
|
||||
check(loader1:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 1 facade")
|
||||
local loader2 = Loader.new({ fs = fs, generation = 2 })
|
||||
loader2.game = game2
|
||||
check(loader2:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 2 facade")
|
||||
|
||||
S.finish()
|
||||
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
}, "mods/colorlib")
|
||||
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
|
||||
nil, nil, { testTargetManifest, installedColorlib })
|
||||
check(unconditionalConflict.hasIssues == true,
|
||||
"dependency resolver reports an unversioned conflict")
|
||||
|
||||
local function rangeTarget(version, conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_target",
|
||||
name = "Range Target",
|
||||
version = version,
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_target")
|
||||
end
|
||||
local function rangeSource(conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_source",
|
||||
name = "Range Source",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_source")
|
||||
end
|
||||
|
||||
local forwardSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local matchingTarget = rangeTarget("1.4.0")
|
||||
local nonmatchingTarget = rangeTarget("2.0.0")
|
||||
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, matchingTarget })
|
||||
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
|
||||
"dependency resolver applies a matching forward conflict range")
|
||||
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, nonmatchingTarget })
|
||||
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching forward conflict range")
|
||||
|
||||
local reverseSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
|
||||
nil, nil, { reverseSource, matchingTarget })
|
||||
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
|
||||
"dependency resolver applies a matching reverse conflict range")
|
||||
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
|
||||
nil, nil, { reverseSource, nonmatchingTarget })
|
||||
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching reverse conflict range")
|
||||
-- ------- scoped dependency tests
|
||||
local Json = require("src.link.Json")
|
||||
local scopedDepManifest = Manifest.validate({
|
||||
|
||||
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
|
||||
and loader.modOptions.okmod.startMoney == 3000
|
||||
and loader.modOptions.okmod.tag == "BLUE",
|
||||
"RESET DEFAULTS restores every schema default")
|
||||
|
||||
-- optional conditions keep mode-specific rows compact and refresh in place
|
||||
local conditionalSchema = {
|
||||
{ key = "mode", label = "MODE", type = "choice", default = "one",
|
||||
choices = { { "ONE", "one" }, { "TWO", "two" } } },
|
||||
{ key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", equals = "one" } },
|
||||
{ key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", equals = "two" } },
|
||||
{ key = "notOne", label = "NOT ONE", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", not_equals = "one" } },
|
||||
}
|
||||
loader.modOptions.condmod = {}
|
||||
ms.cursor = 1
|
||||
ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema)
|
||||
check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly",
|
||||
"visible_if uses the controlling row default")
|
||||
ms.optionRows[1].step(mgame, 1)
|
||||
check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly"
|
||||
and ms.optionRows[3].id == "notOne" and ms.cursor == 1,
|
||||
"editing a controller refreshes conditions without moving the cursor")
|
||||
press(ms, "b")
|
||||
check(ms.screen == "list", "B leaves the options screen")
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
-- mod.postLog: one-way log reporting to the manifest-declared log_url.
|
||||
-- The things this pins are the strict ones -- https-only destination that
|
||||
-- lives in the manifest (not per-call), a closed list of format switches,
|
||||
-- a body ceiling, opaque per-mod handles, and refusal without the network
|
||||
-- permission.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.mods.Net")
|
||||
local Fetch = require("src.net.Fetch")
|
||||
|
||||
-- Stand in for the worker pool: jobs resolve when the test says so, so no
|
||||
-- test here touches a socket.
|
||||
local submitted, nextId, states = {}, 0, {}
|
||||
Fetch.post = function(url, body, opts)
|
||||
nextId = nextId + 1
|
||||
submitted[nextId] = { url = url, body = body, opts = opts }
|
||||
states[nextId] = { status = "pending", progress = 0 }
|
||||
return nextId
|
||||
end
|
||||
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
|
||||
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
|
||||
Fetch.release = function(id) states[id] = nil end
|
||||
Fetch.cancel = function(id)
|
||||
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
|
||||
end
|
||||
Fetch.available = function() return true end
|
||||
|
||||
local LOGGER = {
|
||||
["mods/log_sender/manifest.json"] = [[{
|
||||
"id": "log_sender",
|
||||
"name": "Log Sender",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/logs"
|
||||
}]],
|
||||
["mods/log_sender/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.send = function(body, opts)
|
||||
local handle, err = mod:postLog(body, opts)
|
||||
if not handle then return nil, err end
|
||||
return handle
|
||||
end
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.release = function(h) return mod.fetch:release(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", '
|
||||
.. '"api": 2%s}'):format(id, extra or "")
|
||||
end
|
||||
|
||||
local NO_URL = {
|
||||
["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'),
|
||||
["mods/log_no_url/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.try = function()
|
||||
local ok, err = pcall(function() return mod:postLog("body") end)
|
||||
return ok, err
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
-- ------------------------------------------------ the closed opts list
|
||||
local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) })
|
||||
T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.log_sender
|
||||
|
||||
local bad, badErr = api.send("body", { format = "xml" })
|
||||
T.eq(bad, nil, "an unknown format is refused")
|
||||
T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones")
|
||||
|
||||
local badKey, keyErr = api.send("body", { envelope = true })
|
||||
T.eq(badKey, nil, "an unknown opt key is refused")
|
||||
T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so")
|
||||
|
||||
-- ------------------------------------------------ body validation
|
||||
local empty, emptyErr = api.send("")
|
||||
T.eq(empty, nil, "an empty body is refused")
|
||||
local big = string.rep("x", Net.MAX_BODY + 1)
|
||||
local bigH, bigErr = api.send(big)
|
||||
T.eq(bigH, nil, "an oversized body is refused")
|
||||
T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit")
|
||||
|
||||
-- ------------------------------------------------- text format (default)
|
||||
local handle, err = api.send("hello log")
|
||||
T.check(handle ~= nil, "a plain text post returns a handle (" .. tostring(err) .. ")")
|
||||
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
|
||||
T.eq(api.poll(handle).status, "pending", "a fresh post polls as pending")
|
||||
|
||||
local sent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end
|
||||
end
|
||||
T.check(sent ~= nil, "the post reached the pool with the manifest URL")
|
||||
T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain")
|
||||
T.check(sent.opts.userAgent:find("log_sender", 1, true),
|
||||
"the request identifies the calling mod")
|
||||
|
||||
-- -------------------------------------------------- json format
|
||||
local jh, jerr = api.send("line one", { format = "json" })
|
||||
T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")")
|
||||
local jsent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.opts.contentType == "application/json" then jsent = job end
|
||||
end
|
||||
T.check(jsent ~= nil, "json posts as application/json")
|
||||
local decoded = require("src.link.Json").decode(jsent.body)
|
||||
T.eq(type(decoded), "table", "the json body is a table")
|
||||
T.eq(decoded.format, "json", "the envelope names its format")
|
||||
T.eq(decoded.mod, "log_sender", "the envelope names the mod")
|
||||
T.eq(decoded.body, "line one", "the payload survives the envelope")
|
||||
|
||||
-- completion flows through poll, like get
|
||||
states[1] = { status = "ok", progress = 1 }
|
||||
local got = api.poll(handle)
|
||||
T.eq(got.status, "ok", "a completed post polls ok")
|
||||
|
||||
api.release(handle)
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------- manifest without log_url refuses
|
||||
local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) })
|
||||
T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")")
|
||||
local okCall, callErr = nurl.loader.exports.log_no_url.try()
|
||||
T.check(not okCall and callErr:find("log_url", 1, true),
|
||||
"postLog without log_url names the missing manifest field")
|
||||
nurl.release()
|
||||
|
||||
-- ------------------------------------------- manifest validation: the gate
|
||||
-- log_url without the network permission is a load violation in a strict
|
||||
-- manifest: the mod declares a network capability it did not opt in to. The
|
||||
-- violation fires inside manifest validation, so the mod never enters
|
||||
-- loader.mods at all.
|
||||
local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({
|
||||
["mods/log_bad/manifest.json"] = manifest("log_bad",
|
||||
', "log_url": "https://logs.example.com/logs"'),
|
||||
["mods/log_bad/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(badManifest.mods.log_bad, nil,
|
||||
"log_url without network: the mod is refused before load")
|
||||
|
||||
-- a non-https log_url is refused even with the permission
|
||||
local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({
|
||||
["mods/log_http/manifest.json"] = manifest("log_http",
|
||||
', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'),
|
||||
["mods/log_http/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(httpManifest.mods.log_http, nil,
|
||||
"an http log_url: the mod is refused before load")
|
||||
|
||||
-- an api 1 manifest carries no strict surface: log_url is ignored, and the
|
||||
-- mod loads (its postLog call still refuses -- there is no log_url to use)
|
||||
local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({
|
||||
["mods/log_api1/manifest.json"] = [[{
|
||||
"id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua",
|
||||
"api": 1, "log_url": "https://logs.example.com/logs"
|
||||
}]],
|
||||
["mods/log_api1/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url ("
|
||||
.. tostring(api1.errors[1]) .. ")")
|
||||
T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads")
|
||||
|
||||
T.finish("mod_postlog")
|
||||
@@ -62,6 +62,9 @@ local PROBE = [[
|
||||
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
|
||||
out.powerInfo = type(love.system.getPowerInfo)
|
||||
out.openUrl = love.system.openURL("https://example.com")
|
||||
-- tls* is forwarded from the real love.system when the engine hung it
|
||||
-- (Gen1Tls / Android JNI); without that it's just nil, not an error.
|
||||
out.tlsOpenType = type(love.system.tlsOpen)
|
||||
out.eventQuit = love.event.quit()
|
||||
out.popen = select(1, io.popen("ls"))
|
||||
|
||||
@@ -200,6 +203,8 @@ 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")
|
||||
T.check(out.tlsOpenType == "function" or out.tlsOpenType == "nil",
|
||||
"tls* is readable through the system shim (nil until the engine hangs it)")
|
||||
|
||||
-- a wrapped callback has to land on the real table or the wrap never fires
|
||||
T.eq(type(installedMouseMoved), "function",
|
||||
|
||||
@@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end
|
||||
|
||||
local RedAPI = require("src.world.WorldAPI")
|
||||
local red = RedAPI.new(redGame, "fixture")
|
||||
local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions()
|
||||
T.eq(#unavailable, 0, "Red lists no actions without an overworld")
|
||||
T.eq(reason, "no overworld", "Red reports a missing overworld")
|
||||
local RedWorld = require("src.world.OverworldController")
|
||||
T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
@@ -59,7 +62,9 @@ T.check(not ok and err == "fishing rod unavailable",
|
||||
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
||||
|
||||
redWorld.player.moving = true
|
||||
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
|
||||
actions, err = red:availableFieldActions()
|
||||
T.eq(#actions, 0, "Red hides actions while moving")
|
||||
T.eq(err, "world is busy", "Red distinguishes a busy world from no actions")
|
||||
ok, err = red:useFieldAction("bicycle")
|
||||
T.check(not ok and err == "world is busy",
|
||||
"Red refuses a stale action while busy")
|
||||
@@ -142,6 +147,9 @@ goldWorld.fieldContext = function(_, mon) return {
|
||||
|
||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||
local gold = GoldAPI.new(goldGame, "fixture")
|
||||
unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions()
|
||||
T.eq(#unavailable, 0, "Gold lists no actions without an overworld")
|
||||
T.eq(reason, "no overworld", "Gold reports a missing overworld")
|
||||
actions = gold:availableFieldActions()
|
||||
byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
@@ -170,4 +178,9 @@ T.check(gold:useFieldAction("squirtbottle"),
|
||||
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
||||
"Gold delegates the SquirtBottle to its field-item path")
|
||||
|
||||
goldWorld.acceptsMenuInput = function() return false end
|
||||
actions, err = gold:availableFieldActions()
|
||||
T.eq(#actions, 0, "Gold hides actions while busy")
|
||||
T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions")
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -373,5 +373,25 @@ do
|
||||
check(main ~= nil, "saveFilename resolves for gold")
|
||||
end
|
||||
|
||||
-- Gold's cache has no text_pointers / trainer_headers / field. Data:load
|
||||
-- used to throw in seedDefaults (self.field.boot) after filling pokemon
|
||||
-- with provenance scalars. That is the Android first-Edit CTD: the APK
|
||||
-- cannot fall back to Red's source-tree copies the way a desktop checkout
|
||||
-- can.
|
||||
do
|
||||
GameVersion.set("gold")
|
||||
local Data = require("src.core.Data")
|
||||
Data.constants = {}
|
||||
Data.pokemon = { generation = 2, CYNDAQUIL = { dex = 155 } }
|
||||
Data.maps = {}
|
||||
Data.field = nil
|
||||
Data.trainer_headers = nil
|
||||
local ok, err = pcall(function() Data:seedDefaults() end)
|
||||
check(ok, "gold seedDefaults survives a Gold-shaped cache: " .. tostring(err))
|
||||
check(type(Data.field) == "table", "seedDefaults creates field when Gold omitted it")
|
||||
eq(Data.constants.dexSize, 155, "dexSize ignores pokemon.generation scalar")
|
||||
GameVersion.set("red")
|
||||
end
|
||||
|
||||
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -146,7 +146,7 @@ function App.load(pathOverride, opts)
|
||||
-- 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
|
||||
if not mods then
|
||||
if not mods or App.dataVersion ~= opts.version then
|
||||
-- One loader per editor session. A previous session leaves Data holding
|
||||
-- that session's merged registries (and possibly the other game's cache),
|
||||
-- and a second builtin registration over them collides -- "statuses
|
||||
|
||||
@@ -40,10 +40,11 @@ end
|
||||
|
||||
local function shellListLua(dir)
|
||||
local out = {}
|
||||
if not (io and io.popen) then return out end
|
||||
if package.config:sub(1, 1) == "\\" then
|
||||
-- cmd has no ls; dir /b prints bare names, so re-attach the directory
|
||||
local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir))
|
||||
if p then
|
||||
local ok, p = pcall(io.popen, string.format('dir /b "%s\\*.lua" 2>nul', dir))
|
||||
if ok and p then
|
||||
for line in p:lines() do
|
||||
if line ~= "" then table.insert(out, dir .. "/" .. line) end
|
||||
end
|
||||
@@ -51,8 +52,8 @@ local function shellListLua(dir)
|
||||
end
|
||||
return out
|
||||
end
|
||||
local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir))
|
||||
if p then
|
||||
local ok, p = pcall(io.popen, string.format('ls "%s"/*.lua 2>/dev/null', dir))
|
||||
if ok and p then
|
||||
for line in p:lines() do
|
||||
table.insert(out, line)
|
||||
end
|
||||
@@ -64,8 +65,8 @@ end
|
||||
local function readText(path)
|
||||
local fs = love and love.filesystem
|
||||
if fs and fs.read and fs.getInfo and fs.getInfo(path) then
|
||||
local body = fs.read(path)
|
||||
if body then return body end
|
||||
local ok, body = pcall(fs.read, path)
|
||||
if ok and body then return body end
|
||||
end
|
||||
local f = io.open(path, "r")
|
||||
if not f then return nil end
|
||||
@@ -78,7 +79,7 @@ end
|
||||
-- scripts show up beside the vanilla EVENT_ ones
|
||||
function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
listFiles = listFiles or function(dir)
|
||||
return loveListLua(dir) or shellListLua(dir)
|
||||
return loveListLua(dir) or shellListLua(dir) or {}
|
||||
end
|
||||
|
||||
local found = {}
|
||||
@@ -97,7 +98,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
dirs[#dirs + 1] = dir
|
||||
end
|
||||
for _, dir in ipairs(dirs) do
|
||||
for _, path in ipairs(listFiles(dir)) do
|
||||
local files = listFiles(dir) or {}
|
||||
for _, path in ipairs(files) do
|
||||
local body = readText(path)
|
||||
if body then eat(body) end
|
||||
end
|
||||
|
||||
@@ -78,15 +78,28 @@ function Gen.bindGoldData(data)
|
||||
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
|
||||
|
||||
local loadGen = function(rel)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local bytes = CacheFs.readActive("data/generated/" .. rel .. ".lua")
|
||||
if type(bytes) == "string" then
|
||||
local chunk = loadstring(bytes, "@gold/data/generated/" .. rel .. ".lua")
|
||||
if chunk then
|
||||
local ok, res = pcall(chunk)
|
||||
if ok and type(res) == "table" then return res end
|
||||
end
|
||||
end
|
||||
elseif data.roofs and data.gen2Roofs == nil then
|
||||
data.gen2Roofs = data.roofs
|
||||
local ok, res = pcall(require, "data.generated." .. rel)
|
||||
if ok and type(res) == "table" then return res end
|
||||
return nil
|
||||
end
|
||||
|
||||
data.gen2Palettes = data.gen2Palettes or loadGen("palettes")
|
||||
data.gen2Icons = data.gen2Icons or loadGen("icons")
|
||||
data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
|
||||
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")
|
||||
data.gen2Roofs = data.gen2Roofs or loadGen("roofs") or data.roofs
|
||||
data.gen2Sprites = data.gen2Sprites or loadGen("sprites")
|
||||
return data
|
||||
end
|
||||
|
||||
@@ -213,10 +226,15 @@ 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
|
||||
if type(save.spawn) == "table" then
|
||||
return save.spawn.map or "PLAYERS_HOUSE_2F", save.spawn.x or 0, save.spawn.y or 0, save.spawn.facing
|
||||
elseif type(save.spawn) == "string" then
|
||||
return save.spawn, 0, 0
|
||||
end
|
||||
return "PLAYERS_HOUSE_2F", 3, 3
|
||||
end
|
||||
local p = save.player or {}
|
||||
return p.map, p.x or 0, p.y or 0
|
||||
return p.map or "REDS_HOUSE_2F", p.x or 0, p.y or 0
|
||||
end
|
||||
|
||||
function Gen.setPlayerHere(save, mapId, x, y, facing)
|
||||
|
||||
Reference in New Issue
Block a user