Compare commits

..

33 Commits

Author SHA1 Message Date
github-actions a34d8611bf chore(ios): update app-repo.json [skip ci] 2026-08-15 23:38:06 -04:00
bryanthaboi 73fbaaa250 Merge pull request #1383 from bryanthaboi/dev
two absolut hogs doing hog level sh
2026-08-15 23:28:08 -04:00
bryanthaboi 9469e39926 Merge pull request #1379 from AverageConsumer/codex/mod-battle-intents
mods: add validated battle menu intents
2026-08-15 23:11:21 -04:00
bryanthaboi 179048a58e Merge pull request #1380 from AverageConsumer/codex/android-secondary-hotplug
android: rebind secondary displays after hotplug
2026-08-15 23:11:13 -04:00
bryanthaboi e1f5c2b217 Merge pull request #1382 from ShaneMcGovernIE/chore/postlog-body-limit
Raise postLog body ceiling to 512 KiB
2026-08-15 23:11:06 -04:00
bryanthaboi 1d8ac1e692 Merge pull request #1381 from ShaneMcGovernIE/fix/postlog-staging-temp-env
HostShell: stage postLog bodies via OS temp env, not tmpnam
2026-08-15 23:10:59 -04:00
Shane McGovern 39df5bdfa6 Raise postLog body ceiling to 512 KiB
A diagnostic ring (boot evidence + recent lines + status) routinely exceeds 64 KiB on a long session: a 651-line evidence ring measured ~90 KB and was rejected with "log body too large" (mod.postLog returned nil and the send was dropped).

The transport stages the body to a file and streams it via curl, so the ceiling is a budget, not a memory spike. 512 KiB is generous for real support logs while staying far under the 5 MiB the reference loghook endpoint accepts.
2026-08-16 03:31:36 +01:00
Shane McGovern 4b7a4daf2c HostShell: stage postLog bodies via OS temp env, not tmpnam
tmpnam() on the Windows CRT returns a bare, CWD-relative name (e.g. \sb4c.2), and io.open on it fails with Permission denied when the game's working directory is not writable -- a Program Files (or otherwise protected) install. postLog then dies before curl runs: the mod reports a send failure and no bytes leave the machine (confirmed on a Windows install: "could not create request body: \sb4c.2: Permission denied").

Stage the request body under the OS temp contract instead: TEMP/TMP on Windows (always set, always per-user writable), TMPDIR with a /tmp fallback on POSIX. The transport stays on plain io/os -- no love.filesystem dependency.

Tests updated to mock os.getenv and assert the staged path sits under the temp dir; 10/10 checks pass.
2026-08-16 03:29:33 +01:00
AverageConsumer e1d233d026 fix(android): rebind secondary displays after hotplug 2026-08-16 03:07:54 +02:00
AverageConsumer c22888a7fd feat(mods): add validated battle menu intents 2026-08-16 02:56:03 +02:00
github-actions 0e4fc3c54a chore(ios): update app-repo.json [skip ci] 2026-08-15 20:31:09 -04:00
bryanthaboi 82b91e36ca Merge pull request #1377 from bryanthaboi/dev 2026-08-15 20:22:42 -04:00
bryanthaboi 3588a5f3fe Merge pull request #1369 from AverageConsumer/codex/mod-option-conditional-rows 2026-08-15 20:20:26 -04:00
bryanthaboi 992dc80aa7 Merge pull request #1370 from AverageConsumer/codex/mod-field-action-busy 2026-08-15 20:20:12 -04:00
bryanthaboi 6e7073d32b Merge pull request #1372 from AverageConsumer/codex/android-async-canvas-readback 2026-08-15 20:19:58 -04:00
bryanthaboi 000e691966 Merge pull request #1374 from AverageConsumer/codex/mod-battle-snapshot 2026-08-15 20:19:45 -04:00
bryanthaboi fdbc51c5d1 Merge pull request #1375 from ShaneMcGovernIE/fix/postlog-pipe-mode 2026-08-15 20:19:29 -04:00
Shane McGovern 3e3566d1b4 Fix desktop postLog transport 2026-08-16 01:04:01 +01:00
AverageConsumer fcb5d1d348 feat(mods): expose read-only battle snapshots 2026-08-16 01:32:23 +02:00
AverageConsumer b45d783dee feat(android): add asynchronous canvas readback 2026-08-16 01:09:10 +02:00
AverageConsumer d99072b44e feat(mods): report field action availability state 2026-08-16 01:01:16 +02:00
AverageConsumer 0ac55b0f9e feat(mods): support conditional option rows 2026-08-16 00:54:52 +02:00
github-actions ab48572852 chore(ios): update app-repo.json [skip ci] 2026-08-15 17:06:08 -04:00
bryanthaboi 5d2c13ed2b Merge pull request #1366 from bryanthaboi/dev
baloney
2026-08-15 16:57:46 -04:00
bryanthaboi d87f6b8ad1 Merge pull request #1357 from anxiousintrovert/agent/fix-launcher-versioned-conflicts
Fix versioned mod conflicts in launcher
2026-08-15 16:51:52 -04:00
bryanthaboi 24cf367758 Merge pull request #1363 from ShaneMcGovernIE/feat/fetch-postlog
feat(mods): mod.postLog — one-way log reporting for mod owners
2026-08-15 16:51:33 -04:00
bryanthaboi 829d398a94 Merge pull request #1365 from bryanthaboi/dependabot/github_actions/actions/setup-dotnet-6
build(deps): bump actions/setup-dotnet from 4 to 6
2026-08-15 16:51:14 -04:00
bryanthaboi 3ee50a27c5 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-15 16:50:43 -04:00
bryanthaboi 871087a16b Delete timekeepers_hut 2026-08-15 16:50:40 -04:00
dependabot[bot] 180ce6b2e7 build(deps): bump actions/setup-dotnet from 4 to 6
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 20:32:54 +00:00
Shane McGovern cf335f67de feat(mods): add mod.postLog one-way log reporting to a manifest-declared URL 2026-08-15 21:03:24 +01:00
github-actions 7e0d81a431 chore(ios): update app-repo.json [skip ci] 2026-08-15 14:38:33 -04:00
anxiousintrovert b8ec4fe6b5 Fix versioned mod conflicts in launcher 2026-08-15 13:17:04 -05:00
36 changed files with 1828 additions and 139 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- name: Setup .NET 8 - name: Setup .NET 8
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: "8.0.x" dotnet-version: "8.0.x"
- name: Publish gen1tls (win-x64 Native AOT) - name: Publish gen1tls (win-x64 Native AOT)
+9 -4
View File
@@ -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 `choice`, `number`, and `text`. Their optional fields retain the meanings
established by the existing in-game option UI: choices are `[label, value]` 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 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 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 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, For compatibility with files produced by the original unversioned prototype,
a missing `schema_version` means version 1. Consumers must ignore documents a missing `schema_version` means version 1. Consumers must ignore documents
with a newer version rather than guessing at their shape. Producers must bump 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 the version whenever they change the document envelope or the meaning of an
existing field. Version 1 is therefore the legacy unversioned format as well existing field. New optional row fields that older consumers can safely ignore
as the explicitly versioned format shown above. do not require a bump. Version 1 is therefore the legacy unversioned format as
well as the explicitly versioned format shown above.
## Migration note ## Migration note
+82
View File
@@ -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. | | `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. | | `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | | `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. | | `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping ### Declaring Dependencies & Scoping
@@ -225,6 +226,8 @@ exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that 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 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. 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 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" }` the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
@@ -234,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 logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length. 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 ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
@@ -864,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 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. 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 ## Background jobs
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on `mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
@@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
return module->newImageData(r.w, r.h, dataformat); 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) void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
{ {
if (gfx->isCanvasActive(this)) if (gfx->isCanvasActive(this))
@@ -232,4 +242,3 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
} // graphics } // graphics
} // love } // love
@@ -81,6 +81,8 @@ public:
int getRequestedMSAA() const; int getRequestedMSAA() const;
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect); 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 void generateMipmaps() = 0;
virtual int getMSAA() const = 0; virtual int getMSAA() const = 0;
@@ -23,6 +23,7 @@
#include "Graphics.h" #include "Graphics.h"
#include <algorithm> // For min/max #include <algorithm> // For min/max
#include <cstring>
namespace love namespace love
{ {
@@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings)
, texture(0) , texture(0)
, renderbuffer(0) , renderbuffer(0)
, actualSamples(0) , actualSamples(0)
, readbackBuffer(0)
, readbackFence(nullptr)
, readbackSize(0)
{ {
format = getSizedFormat(format); format = getSizedFormat(format);
@@ -314,6 +318,14 @@ bool Canvas::loadVolatile()
void Canvas::unloadVolatile() 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) if (fbo != 0 || renderbuffer != 0 || texture != 0)
{ {
// This is a bit ugly, but we need some way to destroy the cached FBO // 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; 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() void Canvas::generateMipmaps()
{ {
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE) if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
@@ -54,6 +54,8 @@ public:
ptrdiff_t getHandle() const override; ptrdiff_t getHandle() const override;
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) 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; void generateMipmaps() override;
int getMSAA() const override int getMSAA() const override
@@ -107,6 +109,9 @@ private:
GLenum status; GLenum status;
int actualSamples; int actualSamples;
GLuint readbackBuffer;
GLsync readbackFence;
size_t readbackSize;
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM]; static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM]; static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
@@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L)
return 1; 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) int w_Canvas_generateMipmaps(lua_State *L)
{ {
Canvas *c = luax_checkcanvas(L, 1); Canvas *c = luax_checkcanvas(L, 1);
@@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] =
{ "getMSAA", w_Canvas_getMSAA }, { "getMSAA", w_Canvas_getMSAA },
{ "renderTo", w_Canvas_renderTo }, { "renderTo", w_Canvas_renderTo },
{ "newImageData", w_Canvas_newImageData }, { "newImageData", w_Canvas_newImageData },
{ "requestImageData", w_Canvas_requestImageData },
{ "pollImageData", w_Canvas_pollImageData },
{ "generateMipmaps", w_Canvas_generateMipmaps }, { "generateMipmaps", w_Canvas_generateMipmaps },
{ "getMipmapMode", w_Canvas_getMipmapMode }, { "getMipmapMode", w_Canvas_getMipmapMode },
{ 0, 0 } { 0, 0 }
@@ -366,6 +366,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
unregisterSecondaryDisplayListener();
onHostDestroy(); onHostDestroy();
super.onDestroy(); super.onDestroy();
} }
@@ -376,6 +377,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
unregisterSecondaryDisplayListener();
teardownSecondaryDisplay(); teardownSecondaryDisplay();
onHostPause(); onHostPause();
super.onPause(); super.onPause();
@@ -385,6 +387,7 @@ public class GameActivity extends SDLActivity {
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
onHostResume(); onHostResume();
if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay(); setupSecondaryDisplay();
} }
@@ -1405,6 +1408,7 @@ public class GameActivity extends SDLActivity {
// in src/jni/love/src/common/android.cpp. // in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation; private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false; private static volatile boolean secondaryEnabled = false;
private SecondaryDisplayMonitor secondaryDisplayMonitor;
private static final int MAX_SECONDARY_TOUCHES = 32; private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches = private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>(); new java.util.ArrayDeque<>();
@@ -1416,11 +1420,44 @@ public class GameActivity extends SDLActivity {
if (self == null) return; if (self == null) return;
self.runOnUiThread(new Runnable() { self.runOnUiThread(new Runnable() {
@Override public void run() { @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() { private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return; if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
@@ -1466,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 @Keep
public static boolean hasSecondaryDisplay() { public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null; return secondaryPresentation != null;
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.96",
"date": "2026-08-16",
"size": 11375356,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.96/gen1recomp++-0.1.96-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"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", "version": "0.1.92",
"date": "2026-08-15", "date": "2026-08-15",
-1
View File
@@ -1 +0,0 @@
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
+257
View File
@@ -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
View File
@@ -38,6 +38,7 @@ local romText = RomText
local BattleState = {} local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true BattleState.isOpaque = true
BattleState.isBattleState = true
-- Category identity for per-category GAME SPEED (RFC 0007), the same -- Category identity for per-category GAME SPEED (RFC 0007), the same
-- style OverworldController.isOverworld already uses. Every battle -- -- style OverworldController.isOverworld already uses. Every battle --
@@ -156,6 +157,13 @@ function BattleState:caughtMarkerVisible()
function() return false end, self) == true function() return false end, self) == true
end 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() function BattleState:moveGridNavigation()
if self:wideLayout() then return true end if self:wideLayout() then return true end
if not Runtime.wantsHook("battle.move_grid_navigation") then return false 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 npos = text:find("[\n\v]", pos)
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos) local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
local codes = Font.encode(chunk) 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 self.total = self.total + #codes
if not npos then break end if not npos then break end
cont = text:sub(npos, npos) == "\v" cont = text:sub(npos, npos) == "\v"
@@ -1199,6 +1207,18 @@ function BattleState:beginMsgLine()
self.shown[#self.shown + 1] = {} self.shown[#self.shown + 1] = {}
end 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() function BattleState:updateQueue()
if self.waitingUI then if self.waitingUI then
if self.game.stack:top() ~= self then return true end if self.game.stack:top() ~= self then return true end
@@ -1931,6 +1951,77 @@ function BattleState:playerHasPP()
return false return false
end 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) function BattleState:swapMoves(i, j)
if i == j then return end if i == j then return end
local moves = self.player.curMoves local moves = self.player.curMoves
@@ -2100,42 +2191,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1 self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] self:chooseMenu(({ "fight", "party", "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
end end
return return
end end
@@ -2164,8 +2220,7 @@ function BattleState:update(dt)
end end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil self:cancelMove()
self.phase = "menu"
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then if self.moveSwapIndex then
@@ -2173,19 +2228,7 @@ function BattleState:update(dt)
self.moveSwapIndex = nil self.moveSwapIndex = nil
return return
end end
local mv = moves[self.moveIndex] self:chooseMove(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
end end
return return
end end
+29 -14
View File
@@ -27,6 +27,17 @@ Catching.BALLS = BALLS
-- divisor, which is what the old per-field `or` defaults resolved to -- divisor, which is what the old per-field `or` defaults resolved to
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 } 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) function Catching.registerInto(registry, _, owner)
for id, record in pairs(BALLS) do for id, record in pairs(BALLS) do
registry:register(id, record, owner) registry:register(id, record, owner)
@@ -41,21 +52,9 @@ end
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses) local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
if def.autoCatch then return true, 3 end if def.autoCatch then return true, 3 end
local randMax = def.randMax 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 s = targetMon.status
local record = Status.recordFor(statuses, s) local rate, statusBonus, f, record = stockFactors(
local statusBonus = record and record.catchBonus or 0 def, targetMon, targetDef, rateOverride, statuses)
-- 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 function shakes() local function shakes()
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
@@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
return false, shakes() return false, shakes()
end 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 -- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate). -- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
-- opts (all optional): ballDef = the merged ball record, statuses = the -- opts (all optional): ballDef = the merged ball record, statuses = the
+22 -12
View File
@@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
return rng(0, 255) < b return rng(0, 255) < b
end end
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by -- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still -- threshold public lets read-only UIs preview the same rules the roll uses.
-- misses on 255. function Damage.accuracyThreshold(ruleset, move, attacker, defender)
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
rng = rng or love.math.random
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses -- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
-- (MoveHitTest returns before any accuracy math, 1/256 included) -- (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 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 -- CalcHitChance scales by the accuracy stage and the evasion stage as
-- two separate ratio multiplications, clamping each result -- two separate ratio multiplications, clamping each result
acc = math.min(255, Stats.applyStage(acc, acc = math.min(255, Stats.applyStage(acc, accuracyStage))
attacker.stages and attacker.stages.accuracy or 0)) acc = math.min(255, Stats.applyStage(acc, -evasionStage))
acc = math.min(255, Stats.applyStage(acc,
-(defender.stages and defender.stages.evasion or 0)))
if not ruleset.oneIn256Miss and move.accuracy >= 100 if not ruleset.oneIn256Miss and move.accuracy >= 100
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then and accuracyStage >= evasionStage then
return true return 256
end 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 return rng(0, 255) < acc
end end
+172
View File
@@ -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
+84
View File
@@ -417,4 +417,88 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
return body return body
end 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 return HostShell
+6
View File
@@ -83,6 +83,12 @@ function ItemEffects.healsHP(id)
or id == "REVIVE" or id == "MAX_REVIVE" or id == "REVIVE" or id == "MAX_REVIVE"
end 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? -- Does this item need a party-member target?
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection -- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
function ItemEffects.needsTarget(id, itemDef, data) function ItemEffects.needsTarget(id, itemDef, data)
+10 -2
View File
@@ -215,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
return m and not m.experimental return m and not m.experimental
end 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 -- (a) Conflicts declared by target manifest
if type(manifest.conflictSpecs) == "table" then if type(manifest.conflictSpecs) == "table" then
for _, spec in ipairs(manifest.conflictSpecs) do for _, spec in ipairs(manifest.conflictSpecs) do
local conflictId = spec.id local conflictId = spec.id
local installedOther = installedMap[conflictId] 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 conflictIdsSeen[conflictId] = true
hasIssues = true hasIssues = true
depsResult[#depsResult + 1] = { depsResult[#depsResult + 1] = {
@@ -238,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
-- (b) Reverse conflicts declared by installed mods against target manifest -- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then if manifest.id then
local installedTarget = installedMap[manifest.id] or manifest
for _, other in ipairs(manifests) do for _, other in ipairs(manifests) do
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
local conflicts = other.conflictSpecs or {} local conflicts = other.conflictSpecs or {}
for _, spec in ipairs(conflicts) do 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 conflictIdsSeen[other.id] = true
hasIssues = true hasIssues = true
depsResult[#depsResult + 1] = { depsResult[#depsResult + 1] = {
+27 -2
View File
@@ -1094,6 +1094,23 @@ function Loader:_api(mod)
return { available = function() return false end, return { available = function() return false end,
get = refuse, poll = refuse, release = refuse, cancel = refuse } get = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(), 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 -- Background compute, behind the "background" permission. The worker
-- rebuilds this mod's sandbox before loading the script, so a job is the -- 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 -- 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 -- 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 -- 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 -- acts on is still being wired when the entry chunk runs
local world local world, battle
setmetatable(api, { __index = function(_, key) setmetatable(api, { __index = function(_, key)
-- mod.game is the live service owner, resolved per generation the way -- 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 -- 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 -- 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. -- src.core.Game, which under Gold hands back a table nothing instantiated.
if key == "game" then return loader:_game() end 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 key ~= "world" then return nil end
if world then return world 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 -- 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 -- state and its flags are a bitfield, so the resolution differs even
-- where the method set does not (src/world/gen2/WorldAPI.lua) -- where the method set does not (src/world/gen2/WorldAPI.lua)
+43 -1
View File
@@ -986,12 +986,45 @@ end
function ManagerState:buildOptionRows(m, schema) function ManagerState:buildOptionRows(m, schema)
local rows = {} local rows = {}
local modId = m.id 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 for _, row in ipairs(schema) do
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == "" if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
or not OPTION_TYPES[row.type] then or not OPTION_TYPES[row.type] then
-- malformed rows are skipped, reported where the errors screen reads -- malformed rows are skipped, reported where the errors screen reads
Runtime.reportError(modId, "options row skipped: " Runtime.reportError(modId, "options row skipped: "
.. tostring(type(row) == "table" and (row.key or row.type) or row)) .. 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 elseif row.type == "toggle" then
rows[#rows + 1] = { id = row.key, label = row.label or row.key, rows[#rows + 1] = { id = row.key, label = row.label or row.key,
value = function() value = function()
@@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema)
end, end,
step = function() step = function()
self:setOption(modId, row.key, not self:optionValue(modId, row)) self:setOption(modId, row.key, not self:optionValue(modId, row))
refresh(row.key)
return true return true
end } end }
elseif row.type == "choice" then elseif row.type == "choice" then
@@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema)
end end
index = clampIndex(index + dir, #choices) index = clampIndex(index + dir, #choices)
self:setOption(modId, row.key, choices[index][2]) self:setOption(modId, row.key, choices[index][2])
refresh(row.key)
return true return true
end } end }
elseif row.type == "number" then elseif row.type == "number" then
@@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema)
step = function(_, dir) step = function(_, dir)
local cur = tonumber(self:optionValue(modId, row)) or 0 local cur = tonumber(self:optionValue(modId, row)) or 0
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1))) self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
refresh(row.key)
return true return true
end, end,
activate = function() activate = function()
@@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema)
max = row.max or 99, max = row.max or 99,
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1), start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
onDone = function(qty) 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,
})) }))
end } end }
@@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema)
default = self:optionValue(modId, row), default = self:optionValue(modId, row),
onDone = function(name) onDone = function(name)
self:setOption(modId, row.key, name) self:setOption(modId, row.key, name)
refresh(row.key)
end, end,
})) }))
end } end }
@@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema)
self:setOption(modId, row.key, row.default) self:setOption(modId, row.key, row.default)
end end
end end
self.optionRows = self:buildOptionRows(m, schema)
self.cursor = clampIndex(self.cursor, #self.optionRows)
self:notify("DEFAULTS RESTORED") self:notify("DEFAULTS RESTORED")
end } end }
return rows return rows
+19
View File
@@ -308,6 +308,24 @@ function Manifest.validate(raw, path)
local github = Manifest.parseGithub(raw.github) 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", assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean") "experimental must be a boolean")
local experimental = raw.experimental == true local experimental = raw.experimental == true
@@ -414,6 +432,7 @@ function Manifest.validate(raw, path)
affects_link = affectsLink, affects_link = affectsLink,
permissions = permissions, permissions = permissions,
permissionSet = permissionSet, permissionSet = permissionSet,
log_url = logUrl,
options_schema = optionalFile(raw.options_schema, "options_schema"), options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
required_imports = requiredImports, required_imports = requiredImports,
+64
View File
@@ -32,6 +32,14 @@ local Net = {}
Net.MAX_INFLIGHT = 4 Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely. -- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30 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() local function fetch()
return require("src.net.Fetch") return require("src.net.Fetch")
@@ -99,6 +107,62 @@ function Net.get(loader, modId, url, opts)
return handle return handle
end 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 -- 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 -- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever. -- one cannot spin waiting on it forever.
+11
View File
@@ -132,6 +132,17 @@ function Fetch.get(url, opts)
accept = opts.accept, maxSeconds = opts.maxSeconds }) accept = opts.accept, maxSeconds = opts.maxSeconds })
end 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. -- 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. -- Progress is reported as a 0..1 fraction when `size` is known.
function Fetch.download(url, saveRel, opts) function Fetch.download(url, saveRel, opts)
+17
View File
@@ -99,6 +99,20 @@ local function doDownload(job)
post({ id = job.id, ok = true, path = rel, done = true }) post({ id = job.id, ok = true, path = rel, done = true })
end 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 while true do
local job = cmdCh:demand() local job = cmdCh:demand()
-- The flag is checked before the job's KIND, so a worker woken by a -- 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 elseif job.kind == "get" then
local ok, err = pcall(doGet, job) local ok, err = pcall(doGet, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end 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 elseif job.kind == "download" then
local ok, err = pcall(doDownload, job) local ok, err = pcall(doDownload, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
+10
View File
@@ -253,6 +253,16 @@ function TextBox:beginLine()
table.insert(self.shown, {}) table.insert(self.shown, {})
end 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) function TextBox:update(dt)
local input = self.game.input local input = self.game.input
self.blink = (self.blink + 1) % 60 self.blink = (self.blink + 1) % 60
+63 -43
View File
@@ -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 -- 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. -- $e1/$e2), which is what makes it fit a six-tile column.
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" } 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_BOX_X = 8
local MENU_COL_SPACING = 6 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 {} return (self.battle and self.battle.player and self.battle.player.moves) or {}
end 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). -- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under -- 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 -- 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 or self.menuIndex - 2
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2") self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex] self:chooseMenu(MENU_ACTION[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
end end
return return
end end
@@ -1884,22 +1914,12 @@ function BattleState:update(_dt)
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it -- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2") self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil self:cancelMove()
self.phase = "menu"
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move -- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it -- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2") self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil self:chooseMove(self.moveIndex)
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 })
end end
return return
end end
+4 -2
View File
@@ -95,8 +95,10 @@ end
-- are listed; callers receive copied labels and never inspect world internals. -- are listed; callers receive copied labels and never inspect world internals.
function WorldAPI:availableFieldActions() function WorldAPI:availableFieldActions()
local game, ow, out = self.game, self:overworld(), {} local game, ow, out = self.game, self:overworld(), {}
if not (game and game.save and ow and ow.map and ow.player) if not (game and game.save and ow and ow.map and ow.player) then
or not acceptsMenuInput(game, ow) then return out end 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 save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {} local items = game.data and game.data.items or {}
+4 -2
View File
@@ -78,8 +78,10 @@ end
-- collision and fishing rules. -- collision and fishing rules.
function WorldAPI:availableFieldActions() function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {} local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player) if not (world and game and game.save and world.map and world.player) then
or not world:acceptsMenuInput() then return out end return out, NO_OVERWORLD
end
if not world:acceptsMenuInput() then return out, "world is busy" end
local inventory = game.save.inventory or {} local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then if (inventory.BICYCLE or 0) > 0 then
@@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"),
check(position("onHostDestroy();") < position("super.onDestroy();"), check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction") "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), check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR") "generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and check(not source:find("QuestActivity", 1, true) and
+81
View File
@@ -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")
+246
View File
@@ -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()
+45
View File
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
version = "1.0.0", version = "1.0.0",
entry = "main.lua", entry = "main.lua",
}, "mods/colorlib") }, "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 -- ------- scoped dependency tests
local Json = require("src.link.Json") local Json = require("src.link.Json")
local scopedDepManifest = Manifest.validate({ local scopedDepManifest = Manifest.validate({
+21
View File
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
and loader.modOptions.okmod.startMoney == 3000 and loader.modOptions.okmod.startMoney == 3000
and loader.modOptions.okmod.tag == "BLUE", and loader.modOptions.okmod.tag == "BLUE",
"RESET DEFAULTS restores every schema default") "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") press(ms, "b")
check(ms.screen == "list", "B leaves the options screen") check(ms.screen == "list", "B leaves the options screen")
+169
View File
@@ -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")
+14 -1
View File
@@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end
local RedAPI = require("src.world.WorldAPI") local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture") 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") local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function" T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "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") T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
redWorld.player.moving = true 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") ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy", T.check(not ok and err == "world is busy",
"Red refuses a stale action while 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 GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture") 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() actions = gold:availableFieldActions()
byId = {} byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end 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", T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path") "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() T.finish()