diff --git a/conf.lua b/conf.lua
index 2894ffe5..1fe4d5da 100644
--- a/conf.lua
+++ b/conf.lua
@@ -6,18 +6,30 @@ function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
+ local companion = nil
if arg then
for _, a in ipairs(arg) do
if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
+ local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
+ if port then companion = { port = tonumber(port), token = token } end
end
end
-- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
+ _G.POKEPORT_DISPLAY_COMPANION = companion
- if editor then
+ if companion then
+ t.identity = "pokemon-love2d-companion"
+ t.window.title = "gen1recomp Secondary Display"
+ t.window.width = 640
+ t.window.height = 576
+ t.window.minwidth = 160
+ t.window.minheight = 144
+ t.window.resizable = true
+ elseif editor then
-- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an
@@ -51,7 +63,8 @@ function love.conf(t)
end
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
- t.modules.joystick = true
+ t.modules.audio = not companion
+ t.modules.joystick = not companion
t.modules.physics = false
-- love.system is not loaded during love.conf; love._os is set by the
diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md
index 48429c6f..e412b4d2 100644
--- a/docs/mod-api-gen2-compat.md
+++ b/docs/mod-api-gen2-compat.md
@@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix.
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
- `render.letterbox`, `render.hud`. Each sits
+ `render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
diff --git a/docs/modding.md b/docs/modding.md
index 264ad9d5..e7c32c39 100644
--- a/docs/modding.md
+++ b/docs/modding.md
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
-contextual `squirtbottle` key item. Fishing rows include the owned rods that
-are valid choices. The list is empty while the world is busy, and omits an
-action whenever its item, move, badge, terrain, or engine state forbids it.
+contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
+eligible `sources`; each source contains its eligible `targets`. Fishing rows
+include the owned rods that are valid choices. The list is empty while the
+world is busy, and omits an action whenever its item, move, badge, terrain, or
+engine state forbids it.
The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
-and chooses automatically when only one rod is available. Invalid, stale, and
-busy requests return `nil` plus a reason without changing game state. Mods do
-not need generation-specific badge, terrain, bike, fishing, or field-move
+and chooses automatically when only one rod is available. Red's `softboiled`
+accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
+record. Invalid, stale, and busy requests return `nil` plus a reason without
+changing game state. Mods do not need generation-specific badge, terrain,
+bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length.
+Red exposes FLY separately because it requires a destination picker:
+`mod.world:canFly()` reports whether FLY is eligible at the current location,
+and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
+Fly town list. Gold does not expose these two methods yet.
+
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
@@ -278,10 +287,16 @@ The shared Red, Blue, Yellow, and Gold intents are:
- `{ kind = "move", slot = 1..4 }`
- `{ kind = "back" }` while the move menu is active
+Red, Blue, and Yellow also expose their generation-specific choices:
+
+- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
+ other accepted actions)
+- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
+
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.
+their mutable logic. Tutorial, link, forced, stale, and covered battle states
+refuse core intents. Use `mod.input` for ordinary text advance.
## Rendering pipelines
@@ -654,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
-is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
+is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
+button }`: `phase` is
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
-are LOVE window units, the same space `render.hud`'s viewport and the touch
-overlay lay out in. The on-screen touch controls keep first refusal: a
+`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
+active game viewport and `insideGame` says whether the pointer is inside it.
+Without a custom viewport both coordinate pairs are identical. The on-screen
+touch controls keep first refusal: a
pointer that begins on a virtual control belongs to the pad for its whole
lifecycle and never reaches the hook, while one that begins outside stays
visible even if it later crosses a control. A real mouse reaches the hook
@@ -691,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
+`render.viewport` lets a layout mod reserve the window-space rectangle in which
+the game renders. It receives `(next, ctx)` with the full window's `width`,
+`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
+returns `{ x, y, width, height }`. The engine clamps that rectangle to the
+window and makes game layout, safe-area calculations, and rendering use it as
+their display. Set `capture = true` to request a composition canvas even when
+the rectangle fills the window. With no subscriber, no canvas is allocated and
+the normal presentation path is unchanged.
+
+When a viewport is active, `render.window` receives `(next, game, ctx)` after
+the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
+`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
+`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
+a wrapper may instead compose that canvas with its own UI. Touch controls remain
+full-size OS-window chrome and draw after this hook.
+
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
@@ -699,11 +733,24 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
-`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
-`setEnabled`) for driving a second physical display. `pollTouch()` returns the
-oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
+`secondScreen` bridge (`available()` / `detected()` / `push(...)` /
+`pollTouch()` / `setEnabled`) for driving a second physical display.
+`detected()` reports a connected target even while its output is being created;
+`available()` means it can accept a frame now. `push(imageData, w, h)` retains
+the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
+arguments request an extended presentation; a preference ending in `:cover`
+fills and crops the target, while other values preserve the whole frame.
+Android also accepts `handheld` or `secondary` (with an optional `:cover`
+suffix) as routing hints; unsupported or unavailable targets fall back to the
+other connected display.
+`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
+coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
+On process-capable Windows, Linux and macOS hosts without a native display
+bridge, enabling this facade opens a second resizable app window instead. It
+uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
+contract, so a mod does not need a desktop-specific rendering path.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
diff --git a/main.lua b/main.lua
index 23b2385a..4fbd44f2 100644
--- a/main.lua
+++ b/main.lua
@@ -8,6 +8,11 @@
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
+if POKEPORT_DISPLAY_COMPANION then
+ return require("src.render.DesktopCompanion").install(
+ POKEPORT_DISPLAY_COMPANION)
+end
+
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
@@ -15,6 +20,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
+local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -503,24 +509,30 @@ end
function love.draw()
if editorMode then
+ GameViewport.reset()
HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp)
return result
end
if TouchEditor then
+ GameViewport.reset()
HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Importer then
+ GameViewport.reset()
HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer)
return result
end
- if not Game then return end
+ if not Game then
+ GameViewport.reset()
+ return
+ end
HostDisplay.beginFrame("game", Game)
Game:draw()
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 5cf7b38a..a2de3161 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -31,6 +31,9 @@
android:allowBackup="true"
android:icon="@drawable/love"
android:label="${NAME}" >
+
+
diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp
index 429e0353..3c327be4 100644
--- a/mobile/android/love/src/jni/love/src/common/android.cpp
+++ b/mobile/android/love/src/jni/love/src/common/android.cpp
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return result;
}
+bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
+{
+ if (url == nullptr || body == nullptr || bodyLen < 0)
+ return false;
+
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ // Same resolution rule as httpDownload: the activity's own class via
+ // SDL_AndroidGetActivity, never FindClass -- this bridge is called off
+ // the main thread (love.thread workers), whose class loader cannot see
+ // app classes.
+ jobject activityObj = (jobject) SDL_AndroidGetActivity();
+ if (activityObj == nullptr)
+ return false;
+ jclass activity = env->GetObjectClass(activityObj);
+ env->DeleteLocalRef(activityObj);
+
+ // Old APK / new liblove skew: report "no transport" the same way a
+ // missing curl does, instead of aborting on a missing method (#597).
+ jmethodID method = env->GetStaticMethodID(activity, "httpPost",
+ "(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
+ if (method == nullptr)
+ {
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+ return false;
+ }
+
+ jstring jurl = env->NewStringUTF(url);
+ // raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
+ // and a jstring would run it through modified UTF-8
+ jbyteArray jbody = env->NewByteArray(bodyLen);
+ if (jbody != nullptr)
+ env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
+ jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
+ jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
+
+ jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
+
+ env->DeleteLocalRef(jurl);
+ if (jbody != nullptr)
+ env->DeleteLocalRef(jbody);
+ if (jct != nullptr)
+ env->DeleteLocalRef(jct);
+ if (jua != nullptr)
+ env->DeleteLocalRef(jua);
+ env->DeleteLocalRef(activity);
+ return result;
+}
+
/*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a
@@ -1180,6 +1229,68 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity);
}
+extern "C" __attribute__((visibility("default")))
+void love_android_secondary_target(int target)
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID method = env->GetStaticMethodID(activity,
+ "setSecondaryDisplayTarget", "(I)V");
+ if (method)
+ env->CallStaticVoidMethod(activity, method, target);
+ else
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+}
+
+extern "C" __attribute__((visibility("default")))
+int love_android_secondary_detected()
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID method = env->GetStaticMethodID(activity,
+ "hasSecondaryDisplayCandidate", "()Z");
+ jboolean detected = JNI_FALSE;
+ if (method)
+ detected = env->CallStaticBooleanMethod(activity, method);
+ else
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+ return detected ? 1 : 0;
+}
+
+extern "C" __attribute__((visibility("default")))
+int love_android_present_secondary(const void *rgba, int width, int height,
+ unsigned int background, int cover)
+{
+ if (!rgba || width <= 0 || height <= 0)
+ return 0;
+ jlong size = (jlong) width * (jlong) height * 4;
+ if (size <= 0)
+ return 0;
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
+ "(Ljava/nio/ByteBuffer;IIIZ)Z");
+ if (!method)
+ {
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+ return 0;
+ }
+ jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
+ if (!frame)
+ {
+ env->DeleteLocalRef(activity);
+ return 0;
+ }
+ jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
+ width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
+ env->DeleteLocalRef(frame);
+ env->DeleteLocalRef(activity);
+ return shown ? 1 : 0;
+}
+
extern "C" __attribute__((visibility("default")))
const char *love_android_poll_secondary_touch()
{
diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h
index 6e2fa017..a412b25c 100644
--- a/mobile/android/love/src/jni/love/src/common/android.h
+++ b/mobile/android/love/src/jni/love/src/common/android.h
@@ -98,6 +98,14 @@ bool restartApp();
**/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
+/**
+ * Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
+ * mirror of httpDownload for mod.postLog log sends, which need POST and
+ * have no curl on Android. contentType / userAgent may be null. Returns
+ * whether the server accepted the send (2xx).
+ **/
+bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
+
/**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
index fce90d63..032228cb 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
#endif
}
+bool System::httpPost(const char *url, const char *body, int bodyLen,
+ const char *contentType, const char *userAgent) const
+{
+#ifdef LOVE_ANDROID
+ return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
+#else
+ LOVE_UNUSED(url);
+ LOVE_UNUSED(body);
+ LOVE_UNUSED(bodyLen);
+ LOVE_UNUSED(contentType);
+ LOVE_UNUSED(userAgent);
+ return false;
+#endif
+}
+
int System::tlsOpen(const char *host, int port) const
{
#ifdef LOVE_ANDROID
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h
index 7ab0484a..bbffd544 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.h
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.h
@@ -151,6 +151,14 @@ public:
virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const;
+ /**
+ * Blocking HTTPS POST of a raw byte body (Android only; false
+ * elsewhere). The mirror of httpDownload for mod.postLog log sends,
+ * which need POST and have no curl on Android (#597).
+ **/
+ virtual bool httpPost(const char *url, const char *body, int bodyLen,
+ const char *contentType = nullptr, const char *userAgent = nullptr) const;
+
/**
* TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract:
diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
index 9dfa18ea..48436638 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
return 1;
}
+int w_httpPost(lua_State *L)
+{
+ const char *url = luaL_checkstring(L, 1);
+ size_t bodyLen = 0;
+ const char *body = luaL_checklstring(L, 2, &bodyLen);
+ const char *ct = luaL_optstring(L, 3, nullptr);
+ const char *ua = luaL_optstring(L, 4, nullptr);
+ luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
+ return 1;
+}
+
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload },
+ { "httpPost", w_httpPost },
{ "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend },
diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
index cec24cde..aafd4af2 100644
--- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
+++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
@@ -60,6 +60,7 @@ import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
+import android.provider.Settings;
import android.util.Log;
import android.util.DisplayMetrics;
import android.view.*;
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
public void onResume() {
super.onResume();
onHostResume();
+ refreshDualScreenDisplayMode();
if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay();
}
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // AYN's panel toggle emits virtual Right Shift, which SDL maps to a
+ // gameplay button. The setting is absent on other Android devices.
+ if (secondaryEnabled && dualScreenDisplayMode != -1
+ && event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
+ && event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
+ return true;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+
/**
* SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no
@@ -741,6 +755,76 @@ public class GameActivity extends SDLActivity {
}
}
+ /**
+ * Blocking HTTPS POST, exposed as love.system.httpPost and used by
+ * src/core/HostShell.lua for mod.postLog. The GET bridge above covers
+ * downloads; log sends need POST, and Android ships no curl, so this is
+ * the only POST transport the platform has. Strictly one-way, matching
+ * the curl branch it mirrors: the response body is drained and
+ * discarded, and only the 2xx verdict comes back.
+ *
+ * Same rules as httpDownload: https only, redirects followed by hand
+ * (re-POSTing the body on each hop, the way curl -X POST behaves), and
+ * the call is blocking on the Lua/worker thread -- never the UI thread.
+ * The body arrives as raw bytes (a jbyteArray across the JNI) because a
+ * log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
+ * corruption on characters outside the BMP.
+ */
+ @Keep
+ public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
+ if (url == null || body == null) return false;
+ HttpURLConnection conn = null;
+ try {
+ String current = url;
+ for (int hop = 0; hop < 5; hop++) {
+ URL parsed = new URL(current);
+ if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
+ conn = (HttpURLConnection) parsed.openConnection();
+ conn.setInstanceFollowRedirects(false);
+ conn.setConnectTimeout(15000);
+ conn.setReadTimeout(60000);
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("User-Agent",
+ userAgent == null ? "gen1recomp" : userAgent);
+ conn.setRequestProperty("Content-Type",
+ contentType == null ? "text/plain" : contentType);
+ OutputStream out = new BufferedOutputStream(conn.getOutputStream());
+ try {
+ out.write(body);
+ } finally {
+ try { out.close(); } catch (IOException ignored) {}
+ }
+ int code = conn.getResponseCode();
+ if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
+ String next = conn.getHeaderField("Location");
+ conn.disconnect();
+ conn = null;
+ if (next == null) return false;
+ current = new URL(parsed, next).toString();
+ continue;
+ }
+ if (code < 200 || code > 299) return false;
+ // drain and discard, so a slow server cannot wedge the
+ // worker on a full socket buffer
+ InputStream in = new BufferedInputStream(conn.getInputStream());
+ try {
+ byte[] buf = new byte[16384];
+ while (in.read(buf) > 0) {}
+ } finally {
+ try { in.close(); } catch (IOException ignored) {}
+ }
+ return true;
+ }
+ return false;
+ } catch (Exception e) {
+ Log.d("GameActivity", "httpPost failed: " + e.getMessage());
+ return false;
+ } finally {
+ if (conn != null) conn.disconnect();
+ }
+ }
+
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
@@ -1406,9 +1490,38 @@ public class GameActivity extends SDLActivity {
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
+ private static final int SECONDARY_TARGET_AUTO = 0;
+ private static final int SECONDARY_TARGET_HANDHELD = 1;
+ private static final int SECONDARY_TARGET_EXTERNAL = 2;
+ // AYN keeps disabled panels registered as ON. This optional setting is the
+ // usable-state signal: 0 = both, 1 = main only, 2 = second only.
+ private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
+ private static final String AYN_SECOND_SCREEN = "Screen-2";
private static volatile SecondaryPresentation secondaryPresentation;
+ private static volatile SecondaryActivity secondaryActivity;
+ private static volatile boolean secondaryActivityPending;
+ private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
+ private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false;
+ private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
+ private static volatile int dualScreenDisplayMode = -1;
+ private static volatile byte[] secondaryFrame;
+ private static volatile int secondaryFrameWidth;
+ private static volatile int secondaryFrameHeight;
+ private static volatile int secondaryBackground;
+ private static volatile boolean secondaryFrameCover;
+ private static final Object secondaryFrameLock = new Object();
+ private static volatile long secondaryDetectionAt;
+ private static volatile boolean secondaryDetected;
private SecondaryDisplayMonitor secondaryDisplayMonitor;
+ private boolean dualScreenModeObserverRegistered;
+ private final android.database.ContentObserver dualScreenModeObserver =
+ new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
+ @Override public void onChange(boolean selfChange, Uri uri) {
+ refreshDualScreenDisplayMode();
+ rebindSecondaryDisplay();
+ }
+ };
private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque secondaryTouches =
new java.util.ArrayDeque<>();
@@ -1421,86 +1534,206 @@ public class GameActivity extends SDLActivity {
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) {
+ self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener();
- setupSecondaryDisplay();
+ rebindSecondaryDisplay();
} else {
self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
+ secondaryRetryAfter = 0;
+ synchronized (secondaryFrameLock) { secondaryFrame = null; }
}
}
});
}
+ @Keep
+ public static void setSecondaryDisplayTarget(int target) {
+ int normalized = target == SECONDARY_TARGET_HANDHELD
+ || target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
+ if (secondaryTarget == normalized) return;
+ secondaryTarget = normalized;
+ secondaryDetectionAt = 0;
+ rebindSecondaryDisplay();
+ }
+
+ private void refreshDualScreenDisplayMode() {
+ int mode = Settings.System.getInt(
+ getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
+ if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
+ dualScreenDisplayMode = mode;
+ }
+
private void registerSecondaryDisplayListener() {
- if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
- SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
- if (monitor.register()) secondaryDisplayMonitor = monitor;
+ if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
+ SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
+ if (monitor.register()) secondaryDisplayMonitor = monitor;
+ }
+ if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
+ getContentResolver().registerContentObserver(
+ Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
+ dualScreenModeObserver);
+ dualScreenModeObserverRegistered = true;
+ }
}
private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister();
+ if (dualScreenModeObserverRegistered) {
+ getContentResolver().unregisterContentObserver(dualScreenModeObserver);
+ dualScreenModeObserverRegistered = false;
+ }
}
- private static void refreshSecondaryDisplay() {
- GameActivity self = (GameActivity) mSingleton;
- if (self == null || !secondaryEnabled) return;
- SecondaryPresentation current = secondaryPresentation;
- Display display = current == null ? null : current.getDisplay();
+ private static boolean secondaryOutputIsPreferred(GameActivity self) {
+ Display preferred = findSecondaryDisplay(self, false);
+ if (preferred == null) return false;
+ SecondaryPresentation presentation = secondaryPresentation;
+ Display display = presentation == null
+ ? null : presentation.getDisplay();
+ if (display == null) {
+ SecondaryActivity activity = secondaryActivity;
+ display = activity == null ? null : getActivityDisplay(activity);
+ }
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
- if (current == null) {
- setupSecondaryDisplay();
- } else if (display == null || monitor == null
- || !monitor.hasDisplay(display.getDisplayId())) {
+ if (display == null || (monitor != null
+ && !monitor.hasDisplay(display.getDisplayId()))) return false;
+ return display.getDisplayId() == preferred.getDisplayId();
+ }
+
+ private static void rebindSecondaryDisplay() {
+ GameActivity self = (GameActivity) mSingleton;
+ if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
+ self.runOnUiThread(() -> {
+ if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay();
setupSecondaryDisplay();
- }
+ });
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
- if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
+ if (self == null || !secondaryEnabled || secondaryPresentation != null
+ || secondaryActivity != null || secondaryActivityPending
+ || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
try {
- android.hardware.display.DisplayManager dm =
- (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
- if (dm == null) return;
- Display chosen = null;
- for (Display d : dm.getDisplays()) {
- android.graphics.Point size = new android.graphics.Point();
- d.getRealSize(size);
- Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
- + " size=" + size.x + "x" + size.y);
- if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
- chosen = d;
- }
- }
- if (chosen == null) {
- Display[] pres =
- dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
- if (pres != null && pres.length > 0) chosen = pres[0];
- }
+ Display chosen = findSecondaryDisplay(self, true);
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
+ if (!isPresentationDisplay(chosen)) {
+ if (android.os.Build.VERSION.SDK_INT < 29) return;
+ secondaryActivityPending = true;
+ secondaryActivityTarget = chosen.getDisplayId();
+ Intent intent = new Intent(self, SecondaryActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
+ options.setLaunchDisplayId(secondaryActivityTarget);
+ self.startActivity(intent, options.toBundle());
+ final int requestedDisplay = secondaryActivityTarget;
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ if (secondaryActivityPending
+ && secondaryActivityTarget == requestedDisplay) {
+ secondaryActivityPending = false;
+ secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
+ }
+ }, 1000);
+ return;
+ }
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
+ p.setOnDismissListener(dialog -> {
+ if (secondaryPresentation == p) {
+ secondaryPresentation = null;
+ rebindSecondaryDisplay();
+ }
+ });
p.show();
secondaryPresentation = p;
+ secondaryRetryAfter = 0;
+ synchronized (secondaryFrameLock) {
+ if (secondaryFrame != null) {
+ p.setBackground(secondaryBackground);
+ p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
+ secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
+ }
+ }
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
- secondaryPresentation = null;
+ secondaryActivityPending = false;
+ secondaryActivityTarget = Display.INVALID_DISPLAY;
+ secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
+ teardownSecondaryDisplay();
}
}
+ private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
+ android.hardware.display.DisplayManager dm =
+ (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
+ if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
+ Display gameDisplay = getActivityDisplay(self);
+ int gameDisplayId = gameDisplay == null
+ ? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
+ Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
+ boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
+ && gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
+ Display external = null;
+ Display[] presentations = dm.getDisplays(
+ android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
+ for (Display d : presentations) {
+ if (logDisplays) {
+ android.graphics.Point size = new android.graphics.Point();
+ d.getRealSize(size);
+ Log.d("GameActivity", "display id=" + d.getDisplayId()
+ + " name=" + d.getName() + " size=" + size.x + "x" + size.y);
+ }
+ if (external == null && d.getDisplayId() != gameDisplayId
+ && isDisplayUsable(d)) external = d;
+ }
+ if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
+ if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
+ return handheldAvailable ? handheld : external;
+ }
+
+ private static Display getActivityDisplay(android.app.Activity activity) {
+ return android.os.Build.VERSION.SDK_INT >= 30
+ ? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
+ }
+
+ private static boolean isPresentationDisplay(Display display) {
+ if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
+ return android.os.Build.VERSION.SDK_INT < 20
+ || (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
+ }
+
+ private static boolean isDisplayUsable(Display display) {
+ if (display == null) return false;
+ if (android.os.Build.VERSION.SDK_INT >= 20
+ && display.getState() == Display.STATE_OFF) return false;
+ if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
+ return false;
+ }
+ return dualScreenDisplayMode != 2
+ || display.getDisplayId() != Display.DEFAULT_DISPLAY;
+ }
+
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
+ SecondaryActivity a = secondaryActivity;
+ secondaryActivity = null;
+ secondaryActivityPending = false;
+ secondaryActivityTarget = Display.INVALID_DISPLAY;
synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
+ if (a != null) {
+ try { a.finish(); } catch (Throwable t) {}
+ }
}
@android.annotation.TargetApi(17)
@@ -1527,21 +1760,85 @@ public class GameActivity extends SDLActivity {
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(); }
+ private void changed() {
+ secondaryDetectionAt = 0;
+ rebindSecondaryDisplay();
+ }
+
+ @Override public void onDisplayAdded(int displayId) { changed(); }
+ @Override public void onDisplayRemoved(int displayId) { changed(); }
+ @Override public void onDisplayChanged(int displayId) { changed(); }
}
@Keep
public static boolean hasSecondaryDisplay() {
- return secondaryPresentation != null;
+ return secondaryPresentation != null || secondaryActivity != null;
+ }
+
+ @Keep
+ public static boolean hasSecondaryDisplayCandidate() {
+ GameActivity self = (GameActivity) mSingleton;
+ if (self == null) return false;
+ if (secondaryPresentation != null || secondaryActivity != null) return true;
+ self.refreshDualScreenDisplayMode();
+ long now = android.os.SystemClock.uptimeMillis();
+ if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
+ return secondaryDetected;
+ }
+ secondaryDetected = findSecondaryDisplay(self, false) != null;
+ secondaryDetectionAt = now;
+ return secondaryDetected;
+ }
+
+ @Keep
+ public static boolean presentSecondaryFrame(
+ java.nio.ByteBuffer rgba, int width, int height,
+ int backgroundColor, boolean cover) {
+ long bytes = (long) width * height * 4;
+ if (rgba == null || width <= 0 || height <= 0
+ || bytes <= 0 || bytes > Integer.MAX_VALUE
+ || rgba.capacity() < bytes) return false;
+ synchronized (secondaryFrameLock) {
+ if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
+ secondaryFrame = new byte[(int) bytes];
+ }
+ rgba.rewind();
+ rgba.get(secondaryFrame, 0, (int) bytes);
+ rgba.rewind();
+ secondaryFrameWidth = width;
+ secondaryFrameHeight = height;
+ secondaryBackground = backgroundColor;
+ secondaryFrameCover = cover;
+ SecondaryPresentation p = secondaryPresentation;
+ SecondaryActivity a = secondaryActivity;
+ if (p == null && a == null) return false;
+ try {
+ if (p != null) {
+ p.setBackground(backgroundColor);
+ p.updateFrame(rgba, width, height, cover);
+ } else {
+ a.setBackground(backgroundColor);
+ a.updateFrame(rgba, width, height, cover);
+ }
+ return true;
+ } catch (Throwable t) {
+ GameActivity self = (GameActivity) mSingleton;
+ if (self != null) self.runOnUiThread(() -> {
+ teardownSecondaryDisplay();
+ setupSecondaryDisplay();
+ });
+ return false;
+ }
+ }
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
- if (p != null && buf != null && w > 0 && h > 0) {
- p.updateFrame(buf, w, h);
+ SecondaryActivity a = secondaryActivity;
+ if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
+ if (p != null) p.updateFrame(buf, w, h);
+ else a.updateFrame(buf, w, h);
}
}
@@ -1552,6 +1849,102 @@ public class GameActivity extends SDLActivity {
}
}
+ private static void applySecondaryImmersive(android.view.Window w) {
+ if (w == null) return;
+ if (android.os.Build.VERSION.SDK_INT >= 30) {
+ w.setDecorFitsSystemWindows(false);
+ android.view.WindowInsetsController c = w.getInsetsController();
+ if (c != null) {
+ c.hide(android.view.WindowInsets.Type.systemBars());
+ c.setSystemBarsBehavior(
+ android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ }
+ } else {
+ w.getDecorView().setSystemUiVisibility(
+ android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
+ | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
+ }
+ }
+
+ public static class SecondaryActivity extends android.app.Activity {
+ private FrameView frameView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Display display = getActivityDisplay(this);
+ if (!secondaryEnabled || display == null
+ || display.getDisplayId() != secondaryActivityTarget) {
+ secondaryActivityPending = false;
+ secondaryActivityTarget = Display.INVALID_DISPLAY;
+ secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
+ finish();
+ return;
+ }
+ frameView = new FrameView(this);
+ android.view.Window w = getWindow();
+ w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
+ setContentView(frameView);
+ applySecondaryImmersive(w);
+ secondaryActivity = this;
+ secondaryActivityPending = false;
+ secondaryRetryAfter = 0;
+ synchronized (secondaryFrameLock) {
+ if (secondaryFrame != null) {
+ setBackground(secondaryBackground);
+ updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
+ secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
+ }
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (secondaryActivity == this) secondaryActivity = null;
+ super.onDestroy();
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus) applySecondaryImmersive(getWindow());
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(android.view.KeyEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchGenericMotionEvent(event)
+ : super.dispatchGenericMotionEvent(event);
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
+ frameView.updateFrame(buf, w, h);
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
+ frameView.updateFrame(buf, w, h, cover);
+ }
+
+ void setBackground(int color) {
+ frameView.setFrameBackground(color);
+ }
+ }
+
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
@@ -1585,31 +1978,36 @@ public class GameActivity extends SDLActivity {
if (hasFocus) applyImmersive();
}
+ @Override
+ public boolean dispatchKeyEvent(android.view.KeyEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
+ GameActivity activity = (GameActivity) mSingleton;
+ return activity != null
+ ? activity.dispatchGenericMotionEvent(event)
+ : super.dispatchGenericMotionEvent(event);
+ }
+
private void applyImmersive() {
- android.view.Window w = getWindow();
- if (w == null) return;
- if (android.os.Build.VERSION.SDK_INT >= 30) {
- w.setDecorFitsSystemWindows(false);
- android.view.WindowInsetsController c = w.getInsetsController();
- if (c != null) {
- c.hide(android.view.WindowInsets.Type.systemBars());
- c.setSystemBarsBehavior(
- android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
- }
- } else {
- w.getDecorView().setSystemUiVisibility(
- android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
- | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
- | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
- | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
- | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
- | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
- }
+ applySecondaryImmersive(getWindow());
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
+ frameView.updateFrame(buf, w, h, cover);
+ }
+
+ void setBackground(int color) {
+ frameView.setFrameBackground(color);
+ }
}
private static class FrameView extends View {
@@ -1618,7 +2016,9 @@ public class GameActivity extends SDLActivity {
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
+ private int backgroundColor = 0xFF000000;
private int activePointer = -1;
+ private boolean cover;
FrameView(Context context) {
super(context);
@@ -1628,7 +2028,12 @@ public class GameActivity extends SDLActivity {
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
+ updateFrame(buf, w, h, false);
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
synchronized (lock) {
+ this.cover = cover;
if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
@@ -1640,6 +2045,13 @@ public class GameActivity extends SDLActivity {
postInvalidate();
}
+ void setFrameBackground(int color) {
+ synchronized (lock) {
+ backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
+ }
+ postInvalidate();
+ }
+
private void enqueueTouch(String event) {
synchronized (secondaryTouches) {
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
@@ -1691,12 +2103,15 @@ public class GameActivity extends SDLActivity {
synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight();
- int s = Math.min(vw / fw, vh / fh);
- if (s < 1) s = 1;
- int dw = fw * s, dh = fh * s;
+ float fit = Math.min((float) vw / fw, (float) vh / fh);
+ if (fit <= 0) return;
+ float scale = cover
+ ? Math.max((float) vw / fw, (float) vh / fh)
+ : fit >= 2f ? (float) Math.floor(fit) : fit;
+ int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
- canvas.drawColor(0xFF000000);
+ canvas.drawColor(backgroundColor);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
diff --git a/src/battle/BattleAPI.lua b/src/battle/BattleAPI.lua
index 6d94339f..2ea4d97b 100644
--- a/src/battle/BattleAPI.lua
+++ b/src/battle/BattleAPI.lua
@@ -218,13 +218,20 @@ function BattleAPI:submit(intent)
return nil, "stale battle context"
end
local kind = battle:battleKind()
- if kind == "oldman" or kind == "link" or kind == "safari" then
+ if kind == "oldman" or kind == "link" 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 intent.kind == "safari" then
+ if kind ~= "safari" then return nil, "safari menu is not active" end
+ ok, err = battle:chooseSafari(intent.action)
+ elseif kind == "safari" then
+ return nil, "battle kind is not controllable"
+ elseif intent.kind == "mimic" then
+ ok, err = battle:chooseMimic(intent.index)
+ elseif 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"
diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua
index 77d0e294..bda28b39 100644
--- a/src/battle/BattleState.lua
+++ b/src/battle/BattleState.lua
@@ -88,6 +88,33 @@ function BattleState:wantsFillScale()
return options and options.battleFit == "fill" or false
end
+-- EXTENDED HUD configurations are admitted one at a time after their own
+-- placement and screenshot review. FIXED supports the three authored battle
+-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
+-- battles retain the paper field required by Gen 1 back sprites, while arena
+-- providers may replace it with their own scene. Only the HUD moves to window
+-- space.
+function BattleState:extendedHUD()
+ local options = self.game and self.game.save and self.game.save.options
+ local bg = options and options.battleBg
+ return self:wideLayout()
+ and options and options.battleHud == "extended"
+ and ((options.battleFit == "fixed"
+ and (bg == "world" or bg == "white" or bg == "black"))
+ or (options.battleFit == "fill" and bg == "white"))
+end
+
+function BattleState:extendedWorldHUD()
+ local options = self.game and self.game.save and self.game.save.options
+ return self:extendedHUD() and options
+ and options.battleFit == "fixed" and options.battleBg == "world"
+end
+
+function BattleState:extendedBlackHUD()
+ local options = self.game and self.game.save and self.game.save.options
+ return self:extendedHUD() and options and options.battleBg == "black"
+end
+
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
-- that grow as the window gets bigger or the view is zoomed out. The battle
-- screen itself is untouched: it keeps its white paper field in every mode.
@@ -1103,6 +1130,15 @@ function BattleState:stepHPDrain()
if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1
+ -- Once the count runs out with nothing left pending (bar and
+ -- number already on the final total), the drain is over, not just
+ -- between steps: leave the field at 0 and BattleSafety.inspect
+ -- reads it as still mid-animation for the rest of the battle,
+ -- since drainHold ~= nil is its settled-presentation gate.
+ if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
+ and not b.draining then
+ b.drainHold = nil
+ end
busy = true
elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
@@ -2019,6 +2055,38 @@ function BattleState:cancelMove()
return true
end
+local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
+
+function BattleState:chooseSafari(action)
+ if self.phase ~= "menu" or not self.safari then
+ return nil, "safari menu is not active"
+ end
+ if self.safari.balls <= 0 then return nil, "no safari balls remain" end
+ local index = SAFARI_ACTION_INDEX[action]
+ if not index then return nil, "invalid safari action" end
+ self.menuIndex = index
+ self:safariAction(action)
+ return true
+end
+
+function BattleState:chooseMimic(index)
+ if self.phase ~= "mimicSelect" then
+ return nil, "mimic menu is not active"
+ end
+ if type(index) ~= "number" or index % 1 ~= 0 then
+ return nil, "invalid mimic slot"
+ end
+ local pick = self.mimicMoves and self.mimicMoves[index]
+ local ctx = self.mimicCtx
+ if not pick or not ctx then return nil, "invalid mimic slot" end
+ self.mimicIndex = index
+ self.mimicMoves, self.mimicCtx = nil, nil
+ self.phase = "messages"
+ self.nextInsert = 0 -- the copy's anim + text go to the queue head
+ self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
+ return true
+end
+
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
@@ -2140,7 +2208,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
- self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
+ self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
@@ -2248,12 +2316,7 @@ function BattleState:update(dt)
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
- local pick = moves[self.mimicIndex]
- local ctx = self.mimicCtx
- self.mimicMoves, self.mimicCtx = nil, nil
- self.phase = "messages"
- self.nextInsert = 0 -- the copy's anim + text go to the queue head
- self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
+ self:chooseMimic(self.mimicIndex)
end
return
end
diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua
index 9654029a..e623b61c 100644
--- a/src/battle/TrainerAI.lua
+++ b/src/battle/TrainerAI.lua
@@ -33,6 +33,15 @@ end
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
+-- Strings.source, not Strings: harvested at require time so the catalog
+-- generator can see the literal, same pattern as MoveEffects.lua's
+-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
+-- the harvester can't discover.
+local STAT_LABEL = {
+ attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
+ speed = Strings.source("SPEED"),
+}
+
-- The trainer's ai_classes record from the merged registry; the direct
-- require covers battles built without a loader. A trainer record's
-- aiClass field picks a record other than its own id.
@@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then
local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
- table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
+ table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
elseif item == "GUARD_SPEC" then
enemy.mist = true
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua
index abe3ef7a..75df4b6f 100644
--- a/src/battle/WideBattle.lua
+++ b/src/battle/WideBattle.lua
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
end
end
+local function battleIsTopState(battle)
+ local stack = battle.game and battle.game.stack
+ return not (stack and stack.top) or stack:top() == battle
+end
+
+local function anchorHUD(battle, x, y, w, h, anchor)
+ if not battle:extendedHUD() or not battleIsTopState(battle) then return end
+ local renderer = battle.game and battle.game.renderer
+ if not (renderer and renderer.setBattleUIAnchor) then return end
+ x = x + (battle.extendedHUDOffsetX or 0)
+ y = y + (battle.extendedHUDOffsetY or 0)
+ local x2 = math.min(WideBattle.WIDTH, x + w)
+ local y2 = math.min(WideBattle.HEIGHT, y + h)
+ x, y = math.max(0, x), math.max(0, y)
+ w, h = x2 - x, y2 - y
+ if w > 0 and h > 0 then
+ renderer:setBattleUIAnchor(x, y, w, h, anchor)
+ end
+end
+
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
+ anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
- drawIntroBalls(battle)
end
local function drawMessageBox(battle)
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
else
Font.drawBox(0, 13, 38, 5)
end
+ anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
+ WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
end
-- Battle animations are authored in the original 160px coordinate space.
@@ -309,17 +331,23 @@ end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
+ local renderer = battle.game and battle.game.renderer
+ local extendedHUD = battle:extendedHUD() and renderer
+ and renderer.beginBattleHUDPass
+ and renderer.endBattleHUDPass
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
- if monoMode() then
- g.setColor(1, 1, 1, 1)
- else
- g.setColor(PaletteFX.paperShade(battle.data))
+ if not (extendedHUD and battle:extendedWorldHUD()) then
+ if monoMode() then
+ g.setColor(1, 1, 1, 1)
+ else
+ g.setColor(PaletteFX.paperShade(battle.data))
+ end
+ g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
- g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
+ drawIntroBalls(battle)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
+ battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
fn()
+ battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
g.pop()
end
- shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle)
- shaken(function() drawTextArea(battle) end)
+
+ if extendedHUD then
+ local previous = renderer:beginBattleHUDPass()
+ shaken(function() drawHUDs(battle, slide) end)
+ shaken(function() drawTextArea(battle) end)
+ if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
+ g.setColor(1, 1, 1, 0.85)
+ g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
+ end
+ renderer:endBattleHUDPass(previous)
+ else
+ shaken(function() drawHUDs(battle, slide) end)
+ shaken(function() drawTextArea(battle) end)
+ end
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua
index 05ea1c2e..e2d5a47e 100644
--- a/src/battle/gen2/Battle.lua
+++ b/src/battle/gen2/Battle.lua
@@ -2429,7 +2429,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
if (side.lightScreen or 0) > 0 then return fail(self) end
side.lightScreen = Battle.SCREEN_TURNS
self:emit({ kind = "message",
- text = self:monName(attacker) .. "'s SPCL.DEF rose!" })
+ text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) })
end
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
@@ -2437,7 +2437,7 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
if (side.reflect or 0) > 0 then return fail(self) end
side.reflect = Battle.SCREEN_TURNS
self:emit({ kind = "message",
- text = self:monName(attacker) .. "'s DEFENSE rose!" })
+ text = Strings("%s's DEFENSE rose!", self:monName(attacker)) })
end
-- engine/battle/move_effects/safeguard.asm:1
diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua
index 3caa03f5..4b79c460 100644
--- a/src/battle/gen2/Mon.lua
+++ b/src/battle/gen2/Mon.lua
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level })
- mon.shiny = Mon.isShiny(mon.dvs,
+ -- shiny is monotonic once true, the same as opts.shiny winning over
+ -- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
+ -- that do not themselves read as shiny) must not un-shiny the moment
+ -- this runs again, and it runs on every SummaryMenu open via
+ -- refreshStats. A mon not already shiny still promotes normally if
+ -- its DVs justify it, e.g. after an edit.
+ mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
diff --git a/src/core/Game.lua b/src/core/Game.lua
index 0f8fb6de..7c3f9aaa 100644
--- a/src/core/Game.lua
+++ b/src/core/Game.lua
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
+local GameViewport = require("src.render.GameViewport")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls")
@@ -305,12 +306,32 @@ function Game.worldBgBattleDim(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.bgMode and state:bgMode() == "world" then
+ -- The extended fixed HUD intentionally exposes the live world across
+ -- the whole physical window. Keep this as a world-backed battle (zero
+ -- is non-nil, so scaling and overlay holds remain active), but do not
+ -- paint the standard dim veil around the native battle rectangle.
+ if state.extendedWorldHUD and state:extendedWorldHUD() then
+ return 0
+ end
return state.BG_WORLD_DIM or 0.55
end
end
return nil
end
+-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
+-- uses this separately from battleDim: the world remains the surround, while
+-- the native-width battle field receives a paper backing from top to bottom.
+function Game.extendedWorldHUDInStack(stack)
+ for i = #(stack and stack.states or {}), 1, -1 do
+ local state = stack.states[i]
+ if state and state.extendedWorldHUD and state:extendedWorldHUD() then
+ return true
+ end
+ end
+ return false
+end
+
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
@@ -403,13 +424,13 @@ function Game.uiAnchorsHeldInStack(stack)
end
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
--- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
--- over the LIVE map, and an opaque state pushed on top of it (the party menu,
--- the bag) becomes that base, cutting the overworld -- and with it the world
--- pass -- out of the frame entirely. The backdrop the battle established
--- then collapses to endFrame's flat black clear for as long as the menu is
--- up. So a world-bg battle keeps the frame starting from underneath itself
--- until it leaves the stack, the same hold uiFill and the dim already use.
+-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
+-- battle must not prevent that battle from drawing. Native WIDE battles own
+-- the 304x144 surround around a centred classic menu, while external arena
+-- providers establish their window-sized scene from BattleState:draw. If the
+-- menu becomes the draw base, neither owner runs and the menu's white field
+-- replaces the whole presentation. BATTLE BG "world" additionally needs the
+-- overworld below the battle, as before.
--
-- Only the START of the draw moves. The clear stays keyed to the real
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
@@ -420,9 +441,13 @@ function Game.drawBaseInStack(stack, visibleBase)
local states = stack and stack.states or {}
for i = visibleBase - 1, 1, -1 do
local state = states[i]
- if state and state.bgMode and state:bgMode() == "world" then
+ local worldBattle = state and state.bgMode and state:bgMode() == "world"
+ local wideBattle = state and state.isWideBattleLayout
+ and state:isWideBattleLayout()
+ if worldBattle or wideBattle then
-- restart the search from under the battle: the highest opaque state at
- -- or below it (the overworld), not the menu sitting over it
+ -- or below it (the battle itself for white/black WIDE, the overworld for
+ -- a non-opaque world-backed battle), not the menu sitting over it
for j = i, 1, -1 do
if states[j].isOpaque then return j end
end
@@ -432,6 +457,14 @@ function Game.drawBaseInStack(stack, visibleBase)
return visibleBase
end
+-- A classic overlay above the approved world-backed extended HUD paints only
+-- its centred area. Keep the wider owner surface transparent so its margins
+-- continue to reveal the world instead of becoming an opaque white sheet.
+function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
+ return worldBelow or (worldDrawn and wideBattle ~= nil
+ and wideBattle.extendedHUD and wideBattle:extendedHUD())
+end
+
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content.
@@ -452,6 +485,7 @@ local function centerClassicZones(zones, offset)
end
function Game:draw()
+ GameViewport.begin(1)
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
-- white clear
@@ -485,6 +519,7 @@ function Game:draw()
-- the stack for the same reason as uiFill above -- a prompt opened during
-- the battle must not drop the dim for a frame.
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
+ Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack)
-- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it,
@@ -502,7 +537,8 @@ function Game:draw()
-- menu to its top right, and the whole UI steps down with the zoom.
Renderer.uiCentered = not Game.dynamicUI(self.save)
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
- Renderer:beginFrame(worldBelow)
+ Renderer:beginFrame(Game.uiCanvasTransparent(
+ worldBelow, worldDrawn, wideBattle))
for i = drawFrom, #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
@@ -563,7 +599,9 @@ function Game:draw()
if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport)
end
- -- on-screen mobile controls: pure screen-space, over the finished frame
+ GameViewport.finish(self)
+ -- OS-window chrome: keep the pad full-size and above any composed companion
+ -- view instead of capturing and shrinking it with the game viewport.
TouchControls:draw()
end
@@ -939,8 +977,10 @@ local function pointerUnclaimed() return false end
-- coordinates are LOVE window units, the same space render.hud's viewport
-- and the touch overlay lay out in
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
+ local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
+ gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
diff --git a/src/core/Game2.lua b/src/core/Game2.lua
index 6365cb37..7871cbdd 100644
--- a/src/core/Game2.lua
+++ b/src/core/Game2.lua
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
-- The mod event/hook buses. Gold reaches them through Runtime like every
-- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime")
+local GameViewport = require("src.render.GameViewport")
-- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua.
@@ -73,8 +74,8 @@ end
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
--- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
--- hooks below never used to fire here; it is not a reason they should not. A
+-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
+-- below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
-- PAYLOAD, at the Gen 1 point in the order:
@@ -86,6 +87,8 @@ end
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
+-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
+-- render.window final OS-window composition (GameViewport.lua:145)
--
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
-- world pass and the UI into ONE canvas, not two -- the call site says so and
@@ -680,7 +683,10 @@ end
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
function Game2:usePartyItem(itemId)
local ItemEffects = require("src.core.gen2.ItemEffects")
- local action = ItemEffects.partyAction(itemId)
+ -- without the merged dataset this can only ever see RECORDS, the
+ -- module's own built-ins, so a mod's field item resolves to no action
+ -- at all and never gets past the .Oak refusal below
+ local action = ItemEffects.partyAction(itemId, self.data)
if not action then return end
local party = (self.save and self.save.party) or {}
if #party == 0 then
@@ -1197,9 +1203,7 @@ function Game2:frameFit(w, h)
dpi = tonumber(love.window.getDPIScale()) or 1
end
local pw, ph = w * dpi, h * dpi
- if love.graphics.getPixelDimensions then
- pw, ph = love.graphics.getPixelDimensions()
- end
+ pw, ph = GameViewport.pixelDimensions()
return scale, ox, oy, dpi, pw, ph
end
@@ -1217,18 +1221,15 @@ function Game2:viewport(w, h)
}
end
--- The screen-space layer, in the Gen 1 order: render.hud and then the
--- on-screen pad (src/core/Game.lua:521 and :524, either side of
--- Renderer:endFrame). Both are window-space, both sit over the finished
--- frame -- post passes, letterbox and all -- and neither ever enters the game
--- canvas. Every exit path of Game2:draw ends here, which is what makes that
--- true of the composed frame a mod owns as well as of the plain one.
+-- The render.hud layer, in Gen 1's order over the finished game frame. The
+-- on-screen pad is drawn separately after GameViewport.finish, because it is
+-- OS-window chrome and must not be captured or scaled with this canvas.
--
-- render.hud: persistent tool status. The call is fenced with
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
-- mod render callback: a subscriber that returns cleanly but leaves a shader
-- bound, the canvas redirected or the colour changed must not corrupt the next
--- frame -- or, now, the pad drawn immediately after it.
+-- frame.
function Game2:drawHud(w, h)
if ModRuntime.wantsHook("render.hud") then
local G = love.graphics
@@ -1236,10 +1237,6 @@ function Game2:drawHud(w, h)
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
G.pop()
end
- -- The pad LAST, so a HUD mod cannot draw over the controls the player is
- -- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
- -- forces it, and nothing ever while a controller is in use.
- TouchControls:draw()
end
-- render.letterbox: SGB borders and custom void art in the bars around the
@@ -1363,9 +1360,9 @@ end
-- is being shown on. Mod post-processes fold in between the two, where
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
-- is then drawn over, rather than something that smears the grid itself.
-function Game2:draw()
+function Game2:drawViewportFrame()
local G = love.graphics
- local w, h = G.getDimensions()
+ local w, h = GameViewport.dimensions()
local GBCFX = require("src.render.GBCFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
@@ -1477,6 +1474,16 @@ function Game2:draw()
self:drawHud(w, h)
end
+function Game2:draw()
+ GameViewport.begin(2)
+ GameViewport.setTarget()
+ self:drawViewportFrame()
+ GameViewport.finish(self)
+ -- OS-window chrome: draw after companion composition so viewport layouts
+ -- neither shrink nor cover the touch pad.
+ TouchControls:draw()
+end
+
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
@@ -1803,8 +1810,10 @@ end
-- coordinates are LOVE window units, the same space render.hud's viewport is in
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
+ local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
+ gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua
index fe19021c..b619ebaa 100644
--- a/src/core/HostShell.lua
+++ b/src/core/HostShell.lua
@@ -272,6 +272,38 @@ function HostShell.quote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
+-- Launch another instance of this packaged app without waiting for it. The
+-- same path works on all process-capable desktop hosts; only the shell's
+-- background spelling differs. Source checkouts include their game folder,
+-- while fused releases and AppImages already carry it in the executable.
+function HostShell.spawnSelfDetached(args)
+ if not require("src.core.Platform").canSpawnProcess() then return false end
+ local fs = love and love.filesystem
+ if not (fs and fs.getExecutablePath) then return false end
+ local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
+ if type(executable) ~= "string" or executable == "" then return false end
+
+ local argv = {}
+ local fused = fs.isFused and fs.isFused()
+ if not os.getenv("APPIMAGE") and not fused and fs.getSource then
+ argv[#argv + 1] = fs.getSource()
+ end
+ for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
+
+ local command = HostShell.quote(executable)
+ for _, value in ipairs(argv) do
+ command = command .. " " .. HostShell.quote(value)
+ end
+ local osName = love.system and love.system.getOS and love.system.getOS()
+ if osName == "Windows" then
+ command = 'start "" /b ' .. command .. " >NUL 2>&1"
+ else
+ command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
+ end
+ local ok, _, code = os.execute(command)
+ return ok == true or ok == 0 or code == 0
+end
+
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
-- `curl --version` process on every single fetch -- twice for a GET through
-- the Android-bridge fallback -- which doubled the number of spawns the lock
@@ -420,9 +452,11 @@ 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).
+-- large body never lands in the command line; where curl is absent (Android
+-- and the other bridge-only platforms) the POST rides the JNI bridge --
+-- love.system.httpPost, the dedicated POST arm added beside httpDownload --
+-- 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
@@ -498,6 +532,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if not haveBridge() then
return nil, "no network transport on this platform"
end
+ -- The GET bridge has no POST; the dedicated love.system.httpPost arm
+ -- (GameActivity.httpPost) is the transport where curl is missing. A
+ -- build without it reports the same "no POST transport" a missing curl
+ -- would -- the old-APK skew path in the JNI bridge returns false.
+ if love.system and type(love.system.httpPost) == "function" then
+ local ok, sent = pcall(love.system.httpPost, url, body, contentType,
+ userAgent)
+ if ok and sent then return true end
+ return nil, "log post rejected"
+ end
return nil, "no POST transport on this platform"
end
diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua
index 37d600a5..2cd150a9 100644
--- a/src/core/SafeArea.lua
+++ b/src/core/SafeArea.lua
@@ -7,12 +7,14 @@
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
+local GameViewport = require("src.render.GameViewport")
+
local SafeArea = {}
-function SafeArea.rect()
+function SafeArea.windowRect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
- ww, wh = love.graphics.getDimensions()
+ ww, wh = GameViewport.fullDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
@@ -55,4 +57,8 @@ function SafeArea.rect()
return x, y, w, h
end
+function SafeArea.rect()
+ return GameViewport.localSafeRect(SafeArea.windowRect())
+end
+
return SafeArea
diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua
index 54627da2..6114385f 100644
--- a/src/core/SaveData.lua
+++ b/src/core/SaveData.lua
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
-- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale.
battleFit = "fixed",
+ -- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
+ -- 304x144 surface. EXTENDED is opt-in window-space placement for selected
+ -- wide layouts; unsupported combinations deliberately fall back to the
+ -- standard composition.
+ battleHud = "standard",
-- BATTLE BG: what fills the screen behind and around the battle.
-- "white" = the display mode's paper shade (the classic look),
-- "black" = plain black bars, "world" = the frozen overworld showing
diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua
index b3422edb..7d879c41 100644
--- a/src/core/TouchControls.lua
+++ b/src/core/TouchControls.lua
@@ -339,7 +339,7 @@ end
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket()
- local _, _, sw, sh = SafeArea.rect()
+ local _, _, sw, sh = SafeArea.windowRect()
local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o]
@@ -363,7 +363,7 @@ end
-- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633).
function TouchControls:layout()
- local ox, oy, sw, sh = SafeArea.rect()
+ local ox, oy, sw, sh = SafeArea.windowRect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
@@ -397,7 +397,7 @@ end
-- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
- local ox, oy, sw, sh = SafeArea.rect()
+ local ox, oy, sw, sh = SafeArea.windowRect()
local L = self:layout()
local zone = L[name]
if not zone then return end
@@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
--- Screen-space, called by Game:draw after Renderer:endFrame -- and by
--- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
--- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
--- layout editor under preview mode.
+-- OS-window space, called after GameViewport.finish so the overlay rides on
+-- top of the game, companion composition and post-processing without being
+-- captured or scaled with any game viewport. Also used by the launcher layout
+-- editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
diff --git a/src/core/gen2/Clock.lua b/src/core/gen2/Clock.lua
index e926cda7..a74d2113 100644
--- a/src/core/gen2/Clock.lua
+++ b/src/core/gen2/Clock.lua
@@ -16,13 +16,56 @@
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
-- and World only ever reads it.
+local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
+local Strings = require("src.core.Strings")
local Clock = {}
Clock.MINUTES_PER_DAY = 24 * 60
Clock.DAYS = 7
+-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so
+-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and
+-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require
+-- time, before Strings.load has a catalog, so Clock.weekdayName looks each
+-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL
+-- is the same pattern). One shared table and one lookup function, so
+-- InitClock's screens, the main menu clock box and the Pokegear clock card
+-- cannot drift apart on what a weekday is called.
+Clock.DAY_NAMES = {
+ Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"),
+ Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"),
+ Strings.source("SATURDAY"),
+}
+
+-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is
+-- out of range.
+function Clock.weekdayName(day)
+ local name = Clock.DAY_NAMES[day]
+ return name and Strings(name)
+end
+
+-- The three words Palettes.clockDaytime can hand back, translated (never
+-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK
+-- map, and is never printed as text). Strings.source, not Strings:
+-- clockDaytime's return value is also an internal key every
+-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT
+-- stays untranslated -- only this table, and Clock.daytimeLabel below, look
+-- a word up, at the UI call sites that actually print it.
+local DAYTIME_LABEL = {
+ MORN = Strings.source("MORN"), DAY = Strings.source("DAY"),
+ NITE = Strings.source("NITE"),
+}
+
+-- clockDaytime's word, translated -- the one InitClock's clock-setting
+-- screen and the Pokegear's clock card print (DisplayHourOClock /
+-- Pokegear_UpdateClock).
+function Clock.daytimeLabel(hour)
+ local daytime = Palettes.clockDaytime(hour)
+ return Strings(DAYTIME_LABEL[daytime] or daytime)
+end
+
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
-- buffer left at the zero ByteFill put there.
Clock.DEFAULT_HOUR = 10
diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua
index 78eead2d..4004a86e 100644
--- a/src/import/LauncherSettings.lua
+++ b/src/import/LauncherSettings.lua
@@ -138,15 +138,72 @@ local function coreRows(opts, hooks)
ladder(opts, "battleStyle",
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
add(Strings("BATTLE LAYOUT"),
- ladder(opts, "battleLayout",
- { { "og", "OG" }, { "wide", "WIDE" } }, "og"))
+ function()
+ return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
+ end,
+ function()
+ opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
+ if opts.battleLayout ~= "wide" then
+ opts.battleHud = "standard"
+ elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
+ opts.battleBg = "white"
+ end
+ return true
+ end)
add(Strings("BATTLE SIZE"),
- ladder(opts, "battleFit",
- { { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
+ function()
+ return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
+ end,
+ function()
+ opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
+ if opts.battleFit == "fill" and opts.battleLayout == "wide"
+ and opts.battleHud == "extended" then
+ opts.battleBg = "white"
+ end
+ return true
+ end)
+ add(Strings("BATTLE HUD"),
+ function()
+ return opts.battleLayout == "wide" and opts.battleHud == "extended"
+ and Strings("EXTENDED")
+ or Strings("STANDARD")
+ end,
+ function()
+ if opts.battleLayout ~= "wide" then
+ opts.battleHud = "standard"
+ return false
+ end
+ opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
+ if opts.battleHud == "extended" and opts.battleFit == "fill" then
+ opts.battleBg = "white"
+ end
+ return true
+ end)
add(Strings("BATTLE BG"),
- ladder(opts, "battleBg",
- { { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
- "white"))
+ function()
+ if opts.battleLayout == "wide" and opts.battleFit == "fill"
+ and opts.battleHud == "extended" then
+ opts.battleBg = "white"
+ return Strings("AUTO")
+ end
+ if opts.battleBg == "black" then return Strings("BLACK") end
+ if opts.battleBg == "world" then return Strings("WORLD") end
+ return Strings("WHITE")
+ end,
+ function(dir)
+ if opts.battleLayout == "wide" and opts.battleFit == "fill"
+ and opts.battleHud == "extended" then
+ opts.battleBg = "white"
+ return false
+ end
+ local order = { "white", "black", "world" }
+ local cur = 1
+ for i, mode in ipairs(order) do
+ if opts.battleBg == mode then cur = i break end
+ end
+ opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
+ return true
+ end)
add(Strings("UI LAYOUT"),
ladder(opts, "uiLayout",
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua
index 5dab216e..fcb6165f 100644
--- a/src/import/LauncherView.lua
+++ b/src/import/LauncherView.lua
@@ -979,7 +979,7 @@ function LauncherView._updateControl(imp)
end
-- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"),
- function() pcall(imp.Check.start) end, false
+ function() pcall(imp.Check.start, true) end, false
end
-- ------------------------------------------------------------ game panel
diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua
index a900fec9..0ef0c6ef 100644
--- a/src/inventory/ItemEffects.lua
+++ b/src/inventory/ItemEffects.lua
@@ -59,6 +59,16 @@ local STONES = {
LEAF_STONE = true, MOON_STONE = true,
}
+-- Strings.source, not Strings: harvested at require time so the catalog
+-- generator can see the literal, same pattern as MoveEffects.lua's
+-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
+-- the harvester can't discover.
+local STAT_LABEL = {
+ hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
+ defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
+ special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
+}
+
-- vitamins: stat-exp boosters (ItemUseVitamin)
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
CARBOS = "speed", CALCIUM = "special" }
@@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"Nothing happened!") }
end
b.stages[stat] = cur + 1
- return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
+ return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active
@@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
- vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
+ Strings(STAT_LABEL[vitaminStat])) }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
@@ -515,7 +525,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if not target then return "failed", { noEffect(data) } end
local speciesDef = data.pokemon[target.species]
local ok = false
- for _, m in ipairs(speciesDef.tmhm) do
+ -- a species record with no tmhm list at all is "teaches nothing", the
+ -- same as one whose list just does not name this move -- not a reason
+ -- to crash instead of refusing normally
+ for _, m in ipairs(speciesDef.tmhm or {}) do
if m == itemDef.machine.move then ok = true break end
end
if not ok then
diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua
index 721fdb64..3066888e 100644
--- a/src/mods/ManagerState.lua
+++ b/src/mods/ManagerState.lua
@@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay()
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(2, ty, 16, th)
+ love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(lines) do
drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
end
@@ -1325,6 +1326,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18)
+ love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then
self:drawList()
diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua
index 6893ff87..92544589 100644
--- a/src/mods/Manifest.lua
+++ b/src/mods/Manifest.lua
@@ -12,7 +12,7 @@ local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true,
- background = true }
+ background = true, compute = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua
index e41190ba..81e25be7 100644
--- a/src/mods/Sandbox.lua
+++ b/src/mods/Sandbox.lua
@@ -28,8 +28,14 @@ local DENIED = {
}
-- Same idea one level up: love.filesystem is reachable by name, and
--- love.thread starts a Lua state this sandbox has no say over.
-local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
+-- love.thread starts a Lua state this sandbox has no say over. jit.util
+-- is the LuaJIT-specific equal of the debug library above -- funcbc,
+-- funck and friends read the bytecode and constants of any function a
+-- chunk can reach, which is enough to walk back to upvalues (the real
+-- _G, love, io) the rest of this file exists to keep out of reach. The
+-- bare `jit` table stays -- env.jit above hands it over directly for
+-- jit.on/off/flush -- so only the submodule require is denied.
+local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
@@ -79,13 +85,25 @@ local BLOCKED_LOVE = {
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
-- by that mod's own overlay and must not be shared.
-local function loveFacade(compat)
+local function loveFacade(compat, permissions)
if not _G.love then return nil end
local overrides = compat and compat.love
return setmetatable({}, {
__index = function(_, key)
local override = overrides and overrides[key]
if override ~= nil then return override end
+ if key == "thread" then
+ -- Threads open a fresh Lua state with a full standard library, so
+ -- they stay blocked unless the mod declares the `compute`
+ -- permission (the mod's own source runs in the worker, and the
+ -- worker ships source-only like every other mod file). The mod
+ -- must never receive arbitrary code from elsewhere: channels
+ -- carry data only.
+ if not (permissions or {}).compute then
+ error('love.thread needs the "compute" permission in manifest.json', 2)
+ end
+ return _G.love.thread
+ end
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
@@ -215,7 +233,7 @@ function Sandbox.envFor(opts)
opts = opts or {}
local compat = opts.compat
local env = baseGlobals()
- env.love = loveFacade(compat)
+ env.love = loveFacade(compat, opts.permissions)
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
local loader = sandboxedLoad(env)
env.load = loader
diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua
index 23d34b37..dfa2fb97 100644
--- a/src/mods/Schemas.lua
+++ b/src/mods/Schemas.lua
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
desc = "{" .. table.concat(parts, ", ") .. "}" }
end
+-- An open record: the listed fields are typed (including f.id
+-- cross-references) and everything else on the value passes through
+-- unexamined, unlike f.rec's nested shapes, which reject any key they do
+-- not name. Map objects are why this exists -- NPCs, signs, items, warps
+-- and static encounters all share one array, and only a full union of
+-- every kind's shape could describe it as f.rec; that is a lot of surface
+-- to keep in sync with the loader for fields nothing here needs to check.
+-- f.partial types just the field that actually names another registry and
+-- leaves every kind-specific field around it alone.
+function f.partial(fields)
+ local names = {}
+ for name in pairs(fields) do names[#names + 1] = name end
+ table.sort(names)
+ local parts = {}
+ for _, name in ipairs(names) do
+ local ft = fields[name]
+ parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
+ end
+ return { kind = "partial", fields = fields,
+ desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
+end
+
function f.union(alts)
local parts = {}
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
end
return
end
+ if kind == "partial" then
+ -- the open counterpart of "rec": listed fields are checked exactly
+ -- like a rec's, and any key not listed is left alone rather than
+ -- flagged, so a heterogeneous blob (map objects) can have one field
+ -- typed without every other shape sharing the array being rejected
+ if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
+ for key, ft in pairs(t.fields) do
+ local sub = value[key]
+ if sub ~= nil then
+ checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
+ elseif ft.kind ~= "opt" and not patchMode then
+ errors[#errors + 1] = ("%s.%s: missing required field (%s)")
+ :format(path, key, ft.desc)
+ end
+ end
+ return
+ end
if kind == "union" then
for _, alt in ipairs(t.alts) do
local scratch = {}
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
for k, v in pairs(value) do
collectRefs(t.value, v, path .. "." .. tostring(k), out)
end
- elseif kind == "rec" and type(value) == "table" then
+ elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
for key, ft in pairs(t.fields) do
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
end
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
and loader.content[ref.registry]
-- A registry with no home in this generation has no id space to
-- check against: its base view resolves to nothing, so EVERY
- -- reference into it would read as dangling. Gold's species carry a
- -- growthRate and an evolution method like Red's do; the ids are
- -- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
- -- namespaces that are not there to confirm them. Skipped for the
- -- same reason an undeclared registry is: unknown, not wrong.
+ -- reference into it would read as dangling. `transitions` is the
+ -- standing example -- Gold draws its own battle intro and never
+ -- reads the merged table, so a mod's transition id there is
+ -- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
+ -- used to sit in that category too, back when Gold had no id space
+ -- for either. Both are routed now: `growth_rates` keeps its Gen 1
+ -- path and is seeded from data.pokemon.growthRates (the extractor's
+ -- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
+ -- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
+ -- literal EVOLVE_* ids, present with or without a ROM import). A
+ -- Gold species' growthRate or evolution method is checked against
+ -- real ids exactly like a Red one's, so a genuine typo is still
+ -- caught here rather than waved through as "unknown, not wrong."
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
refRegistry = nil
end
@@ -887,7 +934,15 @@ R.maps = {
destMap = f.str, destWarp = f.int(0),
destGroup = f.opt(f.int(0)),
destMapNum = f.opt(f.int(0)) })),
- objects = f.opt(f.list(f.any)),
+ -- NPCs, signs, items, warps and static wild encounters all share this
+ -- one array, with no field the loader could use to tell them apart
+ -- ahead of time -- an f.rec strict enough to describe every kind would
+ -- reject the others. f.partial types only `pokemon` (the static
+ -- encounter's species, OverworldController.lua's `d.pokemon` ->
+ -- BattleState.newWild) so a bad id is a load-time error, the same as
+ -- an encounter slot's species, instead of the crash newWild has no
+ -- guard against. Every other object field passes through untouched.
+ objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
signs = f.opt(f.list(f.any)),
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
},
diff --git a/src/render/DesktopCompanion.lua b/src/render/DesktopCompanion.lua
new file mode 100644
index 00000000..0702040b
--- /dev/null
+++ b/src/render/DesktopCompanion.lua
@@ -0,0 +1,125 @@
+-- Minimal second-window process for src/render/DesktopScreen.lua.
+
+local DesktopCompanion = {}
+
+function DesktopCompanion.install(config)
+ local enet = require("enet")
+ local host = assert(enet.host_create())
+ local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
+ local image, sourceW, sourceH, preference
+ local background = { 0, 0, 0, 1 }
+ local connected, commandedQuit = false, false
+ local pointerDown = false
+ local started = love.timer.getTime()
+ local lastContact = started
+
+ local function send(kind, payload)
+ if not connected then return end
+ pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
+ end
+
+ local function receiveFrame(data)
+ local prefix = "F" .. config.token .. "\n"
+ if data:sub(1, #prefix) ~= prefix then return end
+ local split = data:find("\n", #prefix + 1, true)
+ if not split then return end
+ local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
+ :match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
+ w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
+ if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
+ local ok, raw = pcall(love.data.decompress, "string", "lz4",
+ data:sub(split + 1))
+ if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
+ local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
+ if not made then return end
+ if not image or sourceW ~= w or sourceH ~= h then
+ if image and image.release then image:release() end
+ image = love.graphics.newImage(pixels)
+ else
+ image:replacePixels(pixels)
+ end
+ sourceW, sourceH, preference = w, h, mode
+ image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
+ mode:find("cover", 1, true) and "linear" or "nearest")
+ background = {
+ math.floor(rgb / 0x10000) % 0x100 / 255,
+ math.floor(rgb / 0x100) % 0x100 / 255,
+ rgb % 0x100 / 255, 1,
+ }
+ end
+
+ local function service()
+ while true do
+ local event = host:service(0)
+ if not event then break end
+ if event.type == "connect" then
+ connected, lastContact = true, love.timer.getTime()
+ send("H")
+ elseif event.type == "receive" then
+ lastContact = love.timer.getTime()
+ if event.data == "Q" .. config.token then
+ commandedQuit = true
+ love.event.quit()
+ elseif event.data ~= "P" .. config.token then
+ receiveFrame(event.data)
+ end
+ elseif event.type == "disconnect" then
+ love.event.quit()
+ end
+ end
+ end
+
+ local function placement()
+ if not image then return 0, 0, 1 end
+ local ww, wh = love.graphics.getDimensions()
+ local cover = preference and preference:find("cover", 1, true)
+ local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
+ return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
+ end
+
+ local function input(action, x, y)
+ if not image then return false end
+ local dx, dy, scale = placement()
+ local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
+ if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
+ send("I", ("\n%s,%d,%d"):format(action, sx, sy))
+ return true
+ end
+
+ function love.update()
+ service()
+ local t = love.timer.getTime()
+ if (not connected and t - started > 5) or t - lastContact > 5 then
+ love.event.quit()
+ end
+ end
+
+ function love.draw()
+ love.graphics.clear(background[1], background[2], background[3], background[4])
+ if not image then return end
+ local x, y, scale = placement()
+ love.graphics.setColor(1, 1, 1, 1)
+ love.graphics.draw(image, x, y, 0, scale, scale)
+ end
+
+ function love.mousepressed(x, y, button)
+ if button == 1 then pointerDown = input("down", x, y) end
+ end
+ function love.mousereleased(x, y, button)
+ if button == 1 and pointerDown then
+ if not input("up", x, y) then send("I", "\ncancel,0,0") end
+ pointerDown = false
+ end
+ end
+ function love.touchpressed(_, x, y) input("down", x, y) end
+ function love.touchreleased(_, x, y) input("up", x, y) end
+ function love.keypressed(key)
+ if key == "escape" then love.event.quit() end
+ end
+ function love.quit()
+ if not commandedQuit then send("C") end
+ pcall(peer.disconnect_now, peer)
+ end
+end
+
+return DesktopCompanion
diff --git a/src/render/DesktopScreen.lua b/src/render/DesktopScreen.lua
new file mode 100644
index 00000000..8026bb80
--- /dev/null
+++ b/src/render/DesktopScreen.lua
@@ -0,0 +1,137 @@
+-- Cross-platform desktop secondary display. LOVE owns one window, so a
+-- second minimal instance of this same app owns the companion window. ENet
+-- is bundled with LOVE; binding it to loopback keeps frames and input local.
+
+local Platform = require("src.core.Platform")
+local HostShell = require("src.core.HostShell")
+local okEnet, enet = pcall(require, "enet")
+
+local DesktopScreen = {}
+local state = {
+ enabled = false, blocked = false, host = nil, peer = nil,
+ token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
+}
+
+local function now()
+ return love and love.timer and love.timer.getTime and love.timer.getTime()
+ or os.clock()
+end
+
+local function destroy(sendQuit)
+ if state.peer and sendQuit then
+ pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
+ end
+ if state.peer then pcall(state.peer.disconnect_now, state.peer) end
+ if state.host then pcall(state.host.destroy, state.host) end
+ state.host, state.peer, state.token, state.port = nil, nil, nil, nil
+ state.touches = {}
+end
+
+local function token()
+ local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
+ local digest = love.data.hash("sha256", seed)
+ return love.data.encode("string", "hex", digest):sub(1, 24)
+end
+
+local function start()
+ if state.host or state.blocked or now() < state.retryAt then
+ return state.host ~= nil
+ end
+ local base = 49152 + math.floor(now() * 1000) % 12000
+ for attempt = 0, 31 do
+ local port = 49152 + (base - 49152 + attempt * 37) % 12000
+ local ok, host = pcall(enet.host_create,
+ ("127.0.0.1:%d"):format(port), 1, 2)
+ if ok and host then
+ state.host, state.port, state.token = host, port, token()
+ local launched = HostShell.spawnSelfDetached({
+ ("--display-companion=%d,%s"):format(port, state.token),
+ })
+ if launched then return true end
+ destroy(false)
+ break
+ end
+ end
+ state.retryAt = now() + 1
+ return false
+end
+
+local function service()
+ if not state.enabled or state.blocked then return end
+ if not state.host and not start() then return end
+ while state.host do
+ local ok, event = pcall(state.host.service, state.host, 0)
+ if not ok then
+ destroy(false)
+ state.retryAt = now() + 1
+ return
+ end
+ if not event then break end
+ if event.type == "receive" then
+ local data = event.data or ""
+ if data == "H" .. state.token then
+ state.peer = event.peer
+ elseif event.peer == state.peer
+ and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
+ state.touches[#state.touches + 1] = data:sub(#state.token + 3)
+ elseif event.peer == state.peer and data == "C" .. state.token then
+ state.blocked = true
+ destroy(false)
+ return
+ end
+ elseif event.type == "disconnect" and event.peer == state.peer then
+ destroy(false)
+ state.retryAt = now() + 1
+ return
+ end
+ end
+ if state.peer and now() >= state.heartbeatAt then
+ state.heartbeatAt = now() + 1
+ pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
+ end
+end
+
+function DesktopScreen.usable()
+ return okEnet and enet ~= nil and Platform.canSpawnProcess()
+end
+
+function DesktopScreen.available()
+ return DesktopScreen.detected()
+end
+
+function DesktopScreen.detected()
+ service()
+ return state.peer ~= nil
+end
+
+function DesktopScreen.push(imageData, w, h, background, preference)
+ service()
+ if not state.peer or not imageData or not imageData.getString then return false end
+ w, h = tonumber(w), tonumber(h)
+ if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
+ local ok, raw = pcall(imageData.getString, imageData)
+ if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
+ local packed = love.data.compress("string", "lz4", raw, 1)
+ local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
+ tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
+ local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
+ return sent
+end
+
+function DesktopScreen.pollTouch()
+ service()
+ return table.remove(state.touches, 1)
+end
+
+function DesktopScreen.setEnabled(on)
+ on = on == true
+ if on == state.enabled then
+ if on then service() end
+ return
+ end
+ state.enabled = on
+ state.blocked = false
+ if on then start(); service() else destroy(true) end
+end
+
+return DesktopScreen
diff --git a/src/render/GameViewport.lua b/src/render/GameViewport.lua
new file mode 100644
index 00000000..b1ae69a8
--- /dev/null
+++ b/src/render/GameViewport.lua
@@ -0,0 +1,185 @@
+-- Optional game viewport inside the OS window. A layout mod may reserve any
+-- window-space rectangle through render.viewport; the game then renders as if
+-- that rectangle were its whole display. With no subscriber this module is a
+-- pass-through and allocates no canvas.
+
+local Runtime = require("src.mods.Runtime")
+
+local Viewport = {
+ rect = nil,
+ full = nil,
+ canvas = nil,
+ generation = nil,
+ frameActive = false,
+}
+
+local function finite(value)
+ return type(value) == "number" and value == value
+ and value > -math.huge and value < math.huge
+end
+
+local function realMetrics()
+ local G = love.graphics
+ local w, h = G.getDimensions()
+ local pw, ph = w, h
+ if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
+ local dpiX = w > 0 and pw / w or 1
+ local dpiY = h > 0 and ph / h or 1
+ if dpiX < 1e-6 then dpiX = 1 end
+ if dpiY < 1e-6 then dpiY = 1 end
+ return math.max(1, w), math.max(1, h),
+ math.max(1, pw), math.max(1, ph), dpiX, dpiY
+end
+
+local function clampRect(value, w, h)
+ if type(value) ~= "table" then
+ return { x = 0, y = 0, width = w, height = h }
+ end
+ local x = finite(value.x) and math.floor(value.x) or 0
+ local y = finite(value.y) and math.floor(value.y) or 0
+ local rw = finite(value.width) and math.floor(value.width) or w
+ local rh = finite(value.height) and math.floor(value.height) or h
+ x = math.max(0, math.min(x, w - 1))
+ y = math.max(0, math.min(y, h - 1))
+ rw = math.max(1, math.min(rw, w - x))
+ rh = math.max(1, math.min(rh, h - y))
+ return { x = x, y = y, width = rw, height = rh }
+end
+
+local function sameSize(canvas, w, h)
+ return canvas and canvas:getWidth() == w and canvas:getHeight() == h
+end
+
+function Viewport.begin(generation)
+ local w, h, pw, ph, dpiX, dpiY = realMetrics()
+ local context = {
+ width = w, height = h, pixelWidth = pw, pixelHeight = ph,
+ dpiX = dpiX, dpiY = dpiY, generation = generation,
+ }
+ local requested
+ if Runtime.wantsHook("render.viewport") then
+ requested = Runtime.call("render.viewport", function(ctx)
+ return { x = 0, y = 0, width = ctx.width, height = ctx.height }
+ end, context)
+ end
+ local rect = clampRect(requested, w, h)
+ Viewport.full = context
+ Viewport.rect = rect
+ Viewport.generation = generation
+ local active = type(requested) == "table" and requested.capture == true
+ or rect.x ~= 0 or rect.y ~= 0
+ or rect.width ~= w or rect.height ~= h
+ Viewport.frameActive = active
+ if active then
+ if not sameSize(Viewport.canvas, rect.width, rect.height) then
+ if Viewport.canvas and Viewport.canvas.release then
+ Viewport.canvas:release()
+ end
+ Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
+ Viewport.canvas:setFilter("nearest", "nearest")
+ end
+ else
+ if Viewport.canvas and Viewport.canvas.release then
+ Viewport.canvas:release()
+ end
+ Viewport.canvas = nil
+ end
+ return rect
+end
+
+function Viewport.active()
+ return Viewport.frameActive == true and Viewport.canvas ~= nil
+end
+
+function Viewport.dimensions()
+ if Viewport.active() then
+ return Viewport.rect.width, Viewport.rect.height
+ end
+ return love.graphics.getDimensions()
+end
+
+function Viewport.pixelDimensions()
+ if Viewport.active() then
+ if Viewport.canvas.getPixelDimensions then
+ local w, h = Viewport.canvas:getPixelDimensions()
+ return math.max(1, w), math.max(1, h)
+ end
+ return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
+ math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
+ end
+ if love.graphics.getPixelDimensions then
+ return love.graphics.getPixelDimensions()
+ end
+ return love.graphics.getDimensions()
+end
+
+function Viewport.fullDimensions()
+ if Viewport.full then return Viewport.full.width, Viewport.full.height end
+ return love.graphics.getDimensions()
+end
+
+function Viewport.target()
+ return Viewport.canvas
+end
+
+function Viewport.setTarget()
+ love.graphics.setCanvas(Viewport.canvas)
+end
+
+function Viewport.toLocal(x, y)
+ local rect = Viewport.rect
+ if not rect then return x, y, true end
+ local lx, ly = x - rect.x, y - rect.y
+ return lx, ly,
+ lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
+end
+
+function Viewport.localSafeRect(x, y, w, h)
+ local rect = Viewport.rect
+ if not Viewport.active() or not rect then return x, y, w, h end
+ local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
+ local x2 = math.min(x + w, rect.x + rect.width)
+ local y2 = math.min(y + h, rect.y + rect.height)
+ if x2 <= x1 or y2 <= y1 then
+ return 0, 0, rect.width, rect.height
+ end
+ return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
+end
+
+function Viewport.finish(game)
+ if not Viewport.active() then return end
+ local G = love.graphics
+ local rect, full = Viewport.rect, Viewport.full
+ G.setCanvas()
+ G.push("all")
+ G.origin()
+ G.setScissor()
+ G.setShader()
+ G.setBlendMode("alpha")
+ G.clear(0, 0, 0, 1)
+ local context = {
+ canvas = Viewport.canvas,
+ x = rect.x, y = rect.y, width = rect.width, height = rect.height,
+ windowWidth = full.width, windowHeight = full.height,
+ dpiX = full.dpiX, dpiY = full.dpiY,
+ generation = Viewport.generation,
+ }
+ Runtime.call("render.window", function(_, ctx)
+ G.setColor(1, 1, 1, 1)
+ G.draw(ctx.canvas, ctx.x, ctx.y)
+ end, game, context)
+ G.pop()
+end
+
+function Viewport.reset()
+ Viewport.frameActive = false
+ Viewport.rect = nil
+ Viewport.full = nil
+ Viewport.generation = nil
+ if Viewport.canvas and Viewport.canvas.release then
+ Viewport.canvas:release()
+ end
+ Viewport.canvas = nil
+end
+
+return Viewport
diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua
index 7d5553a1..4935daf9 100644
--- a/src/render/Renderer.lua
+++ b/src/render/Renderer.lua
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
+local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
@@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
- local ww, wh = love.graphics.getDimensions()
+ local ww, wh = GameViewport.dimensions()
local pw, ph = ww, wh
- if love.graphics.getPixelDimensions then
- pw, ph = love.graphics.getPixelDimensions()
- end
+ pw, ph = GameViewport.pixelDimensions()
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end
@@ -94,6 +93,7 @@ function Renderer:init()
-- reason -- worldViewSize() already works in drawable pixels.
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
+ self.battleHUDCanvas = nil
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -202,10 +202,38 @@ function Renderer:setUISize(w, h)
w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end
+ if self.battleHUDCanvas and self.battleHUDCanvas.release then
+ self.battleHUDCanvas:release()
+ end
+ self.battleHUDCanvas = nil
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end
+-- Transparent native-pixel surface for an extended WIDE battle HUD. The
+-- battle scene remains in `canvas`; endFrame places registered HUD regions
+-- afterward in physical-window space.
+function Renderer:beginBattleHUDPass()
+ local w, h = self:uiSize()
+ if not self.battleHUDCanvas
+ or self.battleHUDCanvas:getWidth() ~= w
+ or self.battleHUDCanvas:getHeight() ~= h then
+ if self.battleHUDCanvas and self.battleHUDCanvas.release then
+ self.battleHUDCanvas:release()
+ end
+ self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
+ end
+ local previous = love.graphics.getCanvas and love.graphics.getCanvas()
+ or self.canvas
+ love.graphics.setCanvas(self.battleHUDCanvas)
+ love.graphics.clear(0, 0, 0, 0)
+ return previous
+end
+
+function Renderer:endBattleHUDPass(previous)
+ love.graphics.setCanvas(previous or self.canvas)
+end
+
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
@@ -671,6 +699,17 @@ end
-- centred letterbox. Declared during the element's own draw, in UI-canvas
-- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
+local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
+ canvas, extract)
+ renderer.uiAnchors = renderer.uiAnchors or {}
+ renderer.uiAnchors[#renderer.uiAnchors + 1] = {
+ x = x, y = y, w = w, h = h, anchor = anchor,
+ windowClamped = windowClamped and true or false,
+ canvas = canvas,
+ extract = extract ~= false,
+ }
+end
+
function Renderer:setUIAnchor(x, y, w, h, anchor)
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
-- save.options.uiLayout): every element stays where it was drawn in the
@@ -684,9 +723,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
-- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge.
if self.uiAnchorHold then return end
- self.uiAnchors = self.uiAnchors or {}
- self.uiAnchors[#self.uiAnchors + 1] =
- { x = x, y = y, w = w, h = h, anchor = anchor }
+ addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
+end
+
+-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
+-- intentionally allowed while BattleState holds general dialogue/menu
+-- anchors inside the battle surface. Callers must gate it to an explicit
+-- battle HUD mode.
+function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
+ addUIAnchor(self, x, y, w, h, anchor, true,
+ self.battleHUDCanvas or self.canvas, false)
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
@@ -698,7 +744,7 @@ end
-- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
- love.graphics.setCanvas()
+ GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
@@ -799,6 +845,8 @@ function Renderer:endFrame(zones, worldZones)
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
-- a visibly brighter border.
local clearR, clearG, clearB = 0, 0, 0
+ local extendedBlackBand = false
+ local bandR, bandG, bandB = 1, 1, 1
if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game")
local stack = ok and Game and Game.stack
@@ -825,7 +873,15 @@ function Renderer:endFrame(zones, worldZones)
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
-- painted the whole phone white on New Game and in battle (#864), so
-- the lock keeps the default black bars.
- if state and state.letterboxWhite
+ if state and state.extendedBlackHUD and state:extendedBlackHUD()
+ and not FaithfulRes.scaleCap() then
+ -- Extended/Black keeps the author's black surround, but extends the
+ -- fixed battle's paper field vertically through the physical window.
+ -- The band uses the exact centred fixed-width composition bounds, so
+ -- only vertical black bars remain at the sides.
+ extendedBlackBand = true
+ bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
+ elseif state and state.letterboxWhite
and not (state.bgMode and state:bgMode() == "black")
and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
@@ -833,6 +889,10 @@ function Renderer:endFrame(zones, worldZones)
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
+ if extendedBlackBand then
+ love.graphics.setColor(bandR, bandG, bandB, 1)
+ love.graphics.rectangle("fill", uox, 0, uvpw, wh)
+ end
love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
@@ -975,6 +1035,22 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1)
end
+ -- Extended/WORLD keeps the frozen world as the physical surround, but stock
+ -- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
+ -- Back the exact fixed-width composition from physical top to bottom so only
+ -- the left and right sides expose the world. The battle canvas and detached
+ -- HUD remain transparent layers composited afterward.
+ -- A worldOverride is an arena provider's completed scene (for example,
+ -- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
+ -- field, so never cover it with the native back-sprite fallback.
+ if self.extendedWorldBand and not self.worldOverride
+ and not FaithfulRes.scaleCap() then
+ local ok, Game = pcall(require, "src.core.Game")
+ love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
+ love.graphics.rectangle("fill", uox, 0, uvpw, wh)
+ love.graphics.setColor(1, 1, 1, 1)
+ end
+
-- UI: anchored regions against their screen edges, the rest in the classic
-- centred letterbox. With nothing anchored this is the single blit it has
-- always been.
@@ -997,14 +1073,23 @@ function Renderer:endFrame(zones, worldZones)
if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = wh - gapB - dh
+ elseif a.anchor == "top" then
+ dx = uox + a.x * Ux -- horizontally it stays with the letterbox
+ dy = a.y * Uy
elseif a.anchor == "topright" then
dx = ww - gapR - dw
dy = a.y * Uy
else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end
+ if a.windowClamped then
+ dx = math.max(0, math.min(math.max(0, ww - dw), dx))
+ dy = math.max(0, math.min(math.max(0, wh - dh), dy))
+ end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
- rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
+ if a.extract then
+ rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
+ end
end
for _, r in ipairs(rest) do
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
@@ -1013,7 +1098,7 @@ function Renderer:endFrame(zones, worldZones)
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
-- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox.
- blit(self.canvas, Ux, Uy, zones, Ux, Uy,
+ blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end
end
@@ -1064,7 +1149,7 @@ function Renderer:endFrame(zones, worldZones)
end
if present then
- love.graphics.setCanvas()
+ GameViewport.setTarget()
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua
index 2e5fb3b5..eeda7750 100644
--- a/src/render/SecondScreen.lua
+++ b/src/render/SecondScreen.lua
@@ -1,11 +1,13 @@
--- Bridge to native secondary-display output (Android Presentation). The C
--- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
--- Everything is guarded: off Android, or if the symbols cannot be resolved,
--- this stays inert and the renderer keeps the in-window stacked layout.
+-- Shared secondary-display facade. Android uses its native Presentation
+-- bridge; process-capable desktop hosts fall back to a companion window.
+-- Everything is guarded, so unsupported hosts keep the in-window layout.
local SecondScreen = {}
local C = nil
local ffi = nil
+local desktop = nil
+local nativePresent = false
+local nativeTarget = false
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -21,6 +23,10 @@ do
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
+ int love_android_secondary_detected();
+ int love_android_present_secondary(const void *rgba, int w, int h,
+ unsigned int background, int cover);
+ void love_android_secondary_target(int target);
const char *love_android_poll_secondary_touch();
]])
local okLib, lib = pcall(ffi.load, "love")
@@ -34,21 +40,74 @@ do
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
+ if C then
+ local okDetected, detected = pcall(function()
+ return C.love_android_secondary_detected
+ end)
+ local okPresent, present = pcall(function()
+ return C.love_android_present_secondary
+ end)
+ local okTarget, target = pcall(function()
+ return C.love_android_secondary_target
+ end)
+ nativePresent = okDetected and detected ~= nil
+ and okPresent and present ~= nil
+ nativeTarget = okTarget and target ~= nil
+ end
+ end
+end
+
+if not C then
+ local ok, backend = pcall(require, "src.render.DesktopScreen")
+ if ok and backend and backend.usable and backend.usable() then
+ desktop = backend
+ log("desktop companion backend ready")
end
end
function SecondScreen.usable()
- return C ~= nil
+ return C ~= nil or desktop ~= nil
end
function SecondScreen.available()
+ if desktop then return desktop.available() end
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
-function SecondScreen.push(imageData, w, h)
+-- A connected display is not necessarily the current Presentation yet. This
+-- distinction lets a companion retry its first frame after hotplug/re-target.
+function SecondScreen.detected()
+ if desktop then return desktop.detected() end
+ if nativePresent then
+ local ok, r = pcall(C.love_android_secondary_detected)
+ return ok and r ~= 0
+ end
+ return SecondScreen.available()
+end
+
+function SecondScreen.push(imageData, w, h, background, preference)
+ if desktop then
+ return desktop.push(imageData, w, h, background, preference)
+ end
if not C or not imageData then return false end
+ if nativePresent and (background ~= nil or preference ~= nil) then
+ if nativeTarget then
+ local target = 0
+ if preference == "handheld" or preference == "handheld:cover" then
+ target = 1
+ elseif preference == "secondary" or preference == "secondary:cover" then
+ target = 2
+ end
+ pcall(C.love_android_secondary_target, target)
+ end
+ local cover = type(preference) == "string"
+ and preference:sub(-6) == ":cover"
+ local ok, shown = pcall(C.love_android_present_secondary,
+ imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0)
+ return ok and shown ~= 0
+ end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
@@ -57,6 +116,7 @@ end
-- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch()
+ if desktop then return desktop.pollTouch() end
if not C then return nil end
local ok, event = pcall(function()
return C.love_android_poll_secondary_touch()
@@ -66,6 +126,7 @@ function SecondScreen.pollTouch()
end
function SecondScreen.setEnabled(on)
+ if desktop then return desktop.setEnabled(on) end
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua
index e8cb6630..5d6807f1 100644
--- a/src/ui/BagMenu.lua
+++ b/src/ui/BagMenu.lua
@@ -4,6 +4,7 @@
local ItemEffects = require("src.inventory.ItemEffects")
local ListMenu = require("src.ui.ListMenu")
+local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local BagMenu = {}
@@ -46,7 +47,16 @@ end
-- the stack, so every exit that prints has to close it afterwards. For
-- every other item the picker popped itself first and closePicker's identity
-- check makes it a no-op (#252).
-local function useOn(game, battle, id, target, list, moveIndex, picker)
+--
+-- Every result string used to fall through to this one unconditional
+-- function with no seam around it: a mod could not suppress a message,
+-- delay it behind a screen of its own, or replace the outcome for one item
+-- id. The "item.use" hook wraps the whole dispatch (not a name per
+-- result -- a mod deciding what a Poké Doll or a stone does needs the
+-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
+-- the way "battle.overlay" and "ui.party.submenu" already wrap a
+-- screen's own default behavior elsewhere in src/ui.
+local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld)
local function closePicker()
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
showMessages(game, payload, closePicker) -- failed
end
+local function useOn(game, battle, id, target, list, moveIndex, picker)
+ return Runtime.call("item.use", vanillaUseOn,
+ game, battle, id, target, list, moveIndex, picker)
+end
+
local function pickTargetAndUse(game, battle, id, list)
-- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon
diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua
index c83c5450..945aaca1 100644
--- a/src/ui/OptionsMenu.lua
+++ b/src/ui/OptionsMenu.lua
@@ -180,6 +180,11 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
+ if o.battleLayout ~= "wide" then
+ o.battleHud = "standard"
+ elseif o.battleFit == "fill" and o.battleHud == "extended" then
+ o.battleBg = "white"
+ end
return true
end },
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
@@ -195,6 +200,31 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
+ if o.battleFit == "fill" and o.battleLayout == "wide"
+ and o.battleHud == "extended" then
+ o.battleBg = "white"
+ end
+ return true
+ end },
+ { id = "battleHud", label = Strings("BATTLE HUD"),
+ value = function(g)
+ local o = g.save.options
+ return o.battleLayout == "wide" and o.battleHud == "extended"
+ and Strings("EXTENDED")
+ or Strings("STANDARD")
+ end,
+ step = function(g)
+ local o = g.save.options
+ -- The extended HUD is a widescreen-only composition. Keep OG locked
+ -- to the author's standard HUD even if an older save says otherwise.
+ if o.battleLayout ~= "wide" then
+ o.battleHud = "standard"
+ return false
+ end
+ o.battleHud = o.battleHud == "extended" and "standard" or "extended"
+ if o.battleHud == "extended" and o.battleFit == "fill" then
+ o.battleBg = "white"
+ end
return true
end },
-- What sits behind and around the battle. WHITE is the classic paper
@@ -203,13 +233,24 @@ local function buildRows(game)
-- shows through everywhere the battle does not paint).
{ id = "battleBg", label = Strings("BATTLE BG"),
value = function(g)
- local m = g.save.options.battleBg
+ local o = g.save.options
+ if o.battleLayout == "wide" and o.battleFit == "fill"
+ and o.battleHud == "extended" then
+ o.battleBg = "white"
+ return Strings("AUTO")
+ end
+ local m = o.battleBg
if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
step = function(g, dir)
local o = g.save.options
+ if o.battleLayout == "wide" and o.battleFit == "fill"
+ and o.battleHud == "extended" then
+ o.battleBg = "white"
+ return false
+ end
local order = { "white", "black", "world" }
local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua
index 32db27ae..04c73930 100644
--- a/src/ui/PartyMenu.lua
+++ b/src/ui/PartyMenu.lua
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
end
if self.softboiledFrom then
local user = party[self.softboiledFrom]
- local heal = math.floor(user.stats.hp / 5)
- if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
- or user.hp <= heal then
- self.softboiledFrom = nil
- local TextBox = require("src.render.TextBox")
- self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
- else
- user.hp = user.hp - heal
- mon.hp = math.min(mon.stats.hp, mon.hp + heal)
- self.softboiledFrom = nil
- require("src.core.Sound").play(self.game.data, "Heal_HP")
- local def = self.game.data.pokemon[mon.species]
- local TextBox = require("src.render.TextBox")
- self.game.stack:push(TextBox.new(self.game,
- Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
- end
+ self.softboiledFrom = nil
+ self.game.overworld:useSoftboiledFieldMove(user, mon)
elseif self.swapFrom then
if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua
index 2d65eeda..bbbc248c 100644
--- a/src/ui/gen2/BattleState.lua
+++ b/src/ui/gen2/BattleState.lua
@@ -3024,8 +3024,10 @@ function BattleState:useItem(itemId)
-- Everything else the pack can spend on a party mon runs the same
-- item_effects.asm routine the field pack runs: the potion line and the
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
- -- the ETHER / ELIXER family.
- local action = ItemEffects.partyAction(itemId)
+ -- the ETHER / ELIXER family. Without the merged dataset this can only
+ -- ever see RECORDS, the module's own built-ins, the same gap
+ -- Game2:usePartyItem had for the field pack.
+ local action = ItemEffects.partyAction(itemId, self.game and self.game.data)
if action then
return self:useOnPartyMon(itemId, action)
end
diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua
index a3a22df2..aff3f69d 100644
--- a/src/ui/gen2/BattleTransition.lua
+++ b/src/ui/gen2/BattleTransition.lua
@@ -28,6 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette")
+local GameViewport = require("src.render.GameViewport")
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end
function BattleTransition:draw()
- local w, h = love.graphics.getDimensions()
+ local w, h = GameViewport.dimensions()
self:drawWidescreen(w, h)
end
diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua
index d06bd81c..b957c956 100644
--- a/src/ui/gen2/InitClock.lua
+++ b/src/ui/gen2/InitClock.lua
@@ -60,10 +60,11 @@ InitClock.TEXT = TEXT
-- same three and has always had them right.
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
--- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0.
-local DAYS = {
- "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
-}
+-- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this
+-- table: MainMenu's clock box and the Pokegear's clock card read the same
+-- weekday off the same save and must never disagree about what it is
+-- called.
+local DAYS = Clock.DAY_NAMES
InitClock.DAYS = DAYS
function InitClock:wantsFillScale() return true end
@@ -88,12 +89,15 @@ function InitClock.hourString(hour)
local h = math.floor(hour or 0) % 24
local display = h % 12
if display == 0 then display = 12 end
- local word = require("src.world.gen2.Palettes").clockDaytime(h)
+ -- Clock.daytimeLabel, not Palettes.clockDaytime: the printed word,
+ -- translated -- this string reaches the player as-is, unlike the internal
+ -- MORN/DAY/NITE key other palette code compares against.
+ local word = Clock.daytimeLabel(h)
return ("%s %d"):format(word, display)
end
function InitClock.oclockString(hour)
- return InitClock.hourString(hour) .. " o'clock"
+ return Strings("%s o'clock", InitClock.hourString(hour))
end
function InitClock.timeString(hour, minute)
@@ -187,7 +191,7 @@ function InitClock:question()
return Strings(TEXT.whoaMinutes, self.minute)
end
if self.phase == "confirm-day" then
- return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?")
+ return Strings(TEXT.confirmDay, Clock.weekdayName(self.day + 1) or "?")
end
if self.phase == "response" then
return Strings(TEXT[InitClock.responseKey(self.hour)],
@@ -196,11 +200,15 @@ function InitClock:question()
return ""
end
+-- data/text/common_1.asm's "@MIN." suffix (DisplayMinutesWithMinString),
+-- separate from TEXT.whoaMinutes' own "%d min.?" confirmation line above.
+local MINUTES = Strings.source("%d min.")
+
-- The value the picker box shows, or nil while a page is up with no picker.
function InitClock:display()
if self.phase == "hour" then return InitClock.oclockString(self.hour) end
- if self.phase == "minute" then return ("%d min."):format(self.minute) end
- if self.phase == "day" then return DAYS[self.day + 1] or "?" end
+ if self.phase == "minute" then return Strings(MINUTES, self.minute) end
+ if self.phase == "day" then return Clock.weekdayName(self.day + 1) or "?" end
return nil
end
diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua
index ac3ed88e..c8fb68fc 100644
--- a/src/ui/gen2/MainMenu.lua
+++ b/src/ui/gen2/MainMenu.lua
@@ -29,10 +29,11 @@ local MainMenu = {}
MainMenu.__index = MainMenu
MainMenu.isOpaque = true
--- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings.
-local DAYS = {
- "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
-}
+-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. Clock.DAY_NAMES
+-- / Clock.weekdayName is the single translated home for this table (see
+-- InitClock.lua's DAYS), so this screen's clock box cannot drift from the
+-- Pokegear's own.
+local DAYS = Clock.DAY_NAMES
-- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet.
local MENU_MUSIC = "Music_MainMenu"
@@ -164,7 +165,7 @@ function MainMenu:drawClockBox()
-- Textbox at (0,12) with 4 interior rows and 13 interior columns.
Chrome.textbox(0, 12, 13, 4)
local hour, minute, weekday = self:clockParts()
- Chrome.print(DAYS[weekday] or "DAY", 1, 14)
+ Chrome.print(Clock.weekdayName(weekday) or "DAY", 1, 14)
-- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded
-- minutes; the AM/PM half is drawn by PrintHour itself.
local display = hour % 12
diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua
index 0413cdaa..30bbea33 100644
--- a/src/ui/gen2/Pokegear.lua
+++ b/src/ui/gen2/Pokegear.lua
@@ -56,10 +56,6 @@ local CARDS = {
-- card over. One row, so `#self.cards` stays 1 and nothing pages.
local FLY_MAP_CARD = { id = "map", label = "FLY" }
-local DAYS = {
- "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
-}
-
-- ---------------------------------------------------------------- the radio
--
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
@@ -1878,7 +1874,7 @@ function Pokegear:drawClock()
-- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and
-- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at
-- column 12.
- self:text(DAYS[weekday] or "", 6, 6)
+ self:text(Clock.weekdayName(weekday) or "", 6, 6)
local display = hour % 12
if display == 0 then display = 12 end
self:text(Chrome.number(display, 2), 6, 8)
@@ -2203,13 +2199,13 @@ function Pokegear:drawPlain()
if id == "clock" then
local hour, minute, weekday = self:clockParts()
Chrome.box(1, 5, 18, 7)
- Chrome.print(DAYS[weekday] or "DAY", 3, 7)
+ Chrome.print(Clock.weekdayName(weekday) or "DAY", 3, 7)
local display = hour % 12
if display == 0 then display = 12 end
Chrome.print(("%s:%s %s"):format(
Chrome.number(display, 2), Chrome.number(minute, 2, true),
hour < 12 and "AM" or "PM"), 5, 9)
- Chrome.print(Palettes.clockDaytime(hour), 5, 11)
+ Chrome.print(Clock.daytimeLabel(hour), 5, 11)
elseif id == "radio" then
-- Without the gear sheet there is no dial art, so the frequencies go down
-- the screen as a list. A frequency whose test failed still gets a row:
diff --git a/src/ui/kit/Layout.lua b/src/ui/kit/Layout.lua
index d5087e5f..9ccc8309 100644
--- a/src/ui/kit/Layout.lua
+++ b/src/ui/kit/Layout.lua
@@ -17,6 +17,7 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local SafeArea = require("src.core.SafeArea")
+local GameViewport = require("src.render.GameViewport")
local Layout = {}
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
- W, H = love.graphics.getDimensions()
+ W, H = GameViewport.dimensions()
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
diff --git a/src/update/Check.lua b/src/update/Check.lua
index 8e655733..c087485d 100644
--- a/src/update/Check.lua
+++ b/src/update/Check.lua
@@ -155,11 +155,12 @@ local function drain()
end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
--- once a check is in flight or has reached a terminal state it is a no-op.
-function Check.start()
+-- once a check is in flight or has reached a terminal state it is a no-op unless
+-- force=true is passed (e.g. from an explicit button press).
+function Check.start(force)
drain()
if cache.status == "checking" or cache.status == "downloading" then return end
- if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
+ if not force and requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" }
return
diff --git a/src/update/PatchNotes.lua b/src/update/PatchNotes.lua
index 3288c4de..5fb503b0 100644
--- a/src/update/PatchNotes.lua
+++ b/src/update/PatchNotes.lua
@@ -16,6 +16,10 @@ PatchNotes.FILES = {
"assets/PATCH_NOTES.md",
}
+PatchNotes.CACHE_FILES = {
+ "updates/notes_cache.json",
+}
+
PatchNotes.REPO_FILES = {
"mobile/ios/app-repo.json",
}
@@ -48,6 +52,29 @@ local function readPath(path)
return nonempty(text) and text or nil
end
+function PatchNotes.fromCache(engine)
+ for _, path in ipairs(PatchNotes.CACHE_FILES) do
+ local text = readPath(path)
+ if text then
+ local ok, doc = pcall(Json.decode, text)
+ if ok and type(doc) == "table" then
+ if engine and engine ~= "0.0.0-dev" then
+ if doc[engine] and nonempty(doc[engine]) then
+ return doc[engine], engine
+ end
+ else
+ for ver, notes in pairs(doc) do
+ if nonempty(notes) then
+ return notes, ver
+ end
+ end
+ end
+ end
+ end
+ end
+ return nil, nil
+end
+
function PatchNotes.fromFile()
for _, path in ipairs(PatchNotes.FILES) do
local text = readPath(path)
@@ -88,6 +115,7 @@ function PatchNotes.fromRepo(engine)
return row.notes, row.version
end
end
+ return nil, nil
end
return list[1].notes, list[1].version
end
@@ -96,17 +124,24 @@ function PatchNotes.fromRepo(engine)
end
function PatchNotes.body(Check)
- local notes, ver = PatchNotes.fromCheck(Check)
- if notes then return notes, ver end
- notes = PatchNotes.fromFile()
- if notes then return notes, ver end
local Version = require("src.core.Version")
local engine = (Version and Version.engine) or "?"
+
+ local notes, ver = PatchNotes.fromCheck(Check)
+ if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then
+ return notes, ver or engine
+ end
+
+ notes, ver = PatchNotes.fromCache(engine)
+ if notes then return notes, ver end
+
+ notes = PatchNotes.fromFile()
+ if notes then return notes, engine end
+
notes, ver = PatchNotes.fromRepo(engine)
if notes then return notes, ver end
- return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
- .. "They appear here after the launcher checks GitHub for the latest "
- .. "release.", engine
+
+ return "Unable to fetch patch notes.", engine
end
return PatchNotes
diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua
index 7d592ec6..175a5457 100644
--- a/src/update/check_worker.lua
+++ b/src/update/check_worker.lua
@@ -151,6 +151,28 @@ local function gatePasses(rel)
return not (info.minShell and info.minShell > shell)
end
+local function cacheNotes(ver, notes)
+ if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end
+ if not (love and love.filesystem) then return end
+ pcall(function()
+ love.filesystem.createDirectory("updates")
+ local cachePath = "updates/notes_cache.json"
+ local existing = {}
+ if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then
+ local text = love.filesystem.read(cachePath)
+ if text then
+ local ok, doc = pcall(Json.decode, text)
+ if ok and type(doc) == "table" then existing = doc end
+ end
+ end
+ existing[ver] = notes
+ local ok, encoded = pcall(Json.encode, existing)
+ if ok and encoded then
+ love.filesystem.write(cachePath, encoded)
+ end
+ end)
+end
+
-- ---------------------------------------------------------------------------
-- check
-- ---------------------------------------------------------------------------
@@ -177,6 +199,9 @@ local function doCheck()
return
end
pending = rel
+ if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then
+ cacheNotes(rel.version, rel.notes)
+ end
-- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design).
diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua
index 6037233a..52374154 100644
--- a/src/world/OverworldController.lua
+++ b/src/world/OverworldController.lua
@@ -9,6 +9,7 @@ local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion")
+local GameViewport = require("src.render.GameViewport")
local Logger = require("src.core.Logger")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
@@ -827,6 +828,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
return true
end
+function OverworldState:useSoftboiledFieldMove(user, target)
+ local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0
+ if not user or not user.stats or not target or not target.stats
+ or target == user or target.hp <= 0
+ or target.hp >= target.stats.hp or user.hp <= heal then
+ Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
+ return false
+ end
+ user.hp = user.hp - heal
+ target.hp = math.min(target.stats.hp, target.hp + heal)
+ require("src.core.Sound").play(Game.data, "Heal_HP")
+ local def = Game.data.pokemon[target.species]
+ Game.stack:push(TextBox.new(Game,
+ Strings("%s's HP\nwas restored!", target.nickname or def.name)))
+ return true
+end
+
-- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions
@@ -5131,7 +5149,7 @@ function OverworldState:drawWorld()
-- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run.
- local pw, ph = love.graphics.getDimensions()
+ local pw, ph = GameViewport.dimensions()
local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua
index 286e5dd6..69b9eea2 100644
--- a/src/world/WorldAPI.lua
+++ b/src/world/WorldAPI.lua
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
and party[slot] ~= nil
end
+local function outside(game, ow)
+ return Map.isOutside(ow.map.def,
+ FieldDefaults.field(game.data, "outsideTilesets"))
+end
+
+local function knows(mon, moveId)
+ for _, move in ipairs(mon.moves or {}) do
+ if move.id == moveId then return true end
+ end
+ return false
+end
+
+local function monInfo(game, mon, slot)
+ local def = game.data.pokemon[mon.species] or {}
+ return { slot = slot, species = mon.species,
+ name = mon.nickname or def.name or mon.species, level = mon.level,
+ hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp }
+end
+
+local function softboiledSources(game)
+ local party, sources = game.save.party or {}, {}
+ for sourceSlot, source in ipairs(party) do
+ local heal = source.stats and math.floor(source.stats.hp / 5) or 0
+ if knows(source, "SOFTBOILED") and source.hp > heal then
+ local info = monInfo(game, source, sourceSlot)
+ info.targets = {}
+ for targetSlot, target in ipairs(party) do
+ if target ~= source and target.hp > 0 and target.stats
+ and target.hp < target.stats.hp then
+ info.targets[#info.targets + 1] = monInfo(game, target, targetSlot)
+ end
+ end
+ if #info.targets > 0 then sources[#sources + 1] = info end
+ end
+ end
+ return sources
+end
+
+local function flyDestinationAvailable(game, mapId)
+ local field, save = game.data.field or {}, game.save
+ for _, id in ipairs(field.flyOrder or {}) do
+ if id == mapId then
+ local def = game.data.maps and game.data.maps[id]
+ return not not (save.visited and save.visited[id]
+ and field.flyWarps and field.flyWarps[id]
+ and def and Map.isFlyTown(def))
+ end
+ end
+ return false
+end
+
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
@@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions()
and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" }
end
- if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
- FieldDefaults.field(game.data, "outsideTilesets")) then
+ if ow:partyKnows("TELEPORT") and outside(game, ow) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
+ local sources = softboiledSources(game)
+ if #sources > 0 then
+ out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
+ sources = sources }
+ end
return out
end
@@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
+ elseif id == "softboiled" then
+ local sourceSlot = opts and tonumber(opts.sourceSlot)
+ local targetSlot = opts and tonumber(opts.targetSlot)
+ local allowed
+ for _, source in ipairs(found.sources or {}) do
+ if source.slot == sourceSlot then
+ for _, target in ipairs(source.targets or {}) do
+ if target.slot == targetSlot then allowed = true break end
+ end
+ end
+ end
+ if not allowed then return nil, "softboiled target unavailable" end
+ if ow:useSoftboiledFieldMove(game.save.party[sourceSlot],
+ game.save.party[targetSlot]) then return true end
end
return nil, "field action unavailable"
end
+-- FLY needs a destination choice, so it is exposed separately from the
+-- immediate actions above. The request is still checked against the same
+-- visited-town list as the native Town Map picker before the world may warp.
+function WorldAPI:canFly()
+ local game, ow = self.game, self:overworld()
+ return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY"))
+end
+
+function WorldAPI:flyTo(mapId)
+ local game, ow = self.game, self:overworld()
+ if not ow then return nil, NO_OVERWORLD end
+ if not self:canFly() then return nil, "fly unavailable" end
+ if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
+ if not flyDestinationAvailable(game, mapId) then
+ return nil, "destination unavailable"
+ end
+ ow:flyTo(mapId)
+ return true
+end
+
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua
index a62f677f..0e0518d5 100644
--- a/src/world/gen2/World.lua
+++ b/src/world/gen2/World.lua
@@ -34,6 +34,7 @@ local Font = require("src.render.Font")
-- a mod has taken a facade (src/mods/Gen2Compat.lua).
local Gen1Facade = require("src.mods.Gen2Compat")
local GbcPalette = require("src.render.GbcPalette")
+local GameViewport = require("src.render.GameViewport")
local Gen2Save = require("src.core.gen2.Save")
local HallOfFame = require("src.core.gen2.HallOfFame")
local HiddenItems = require("src.world.gen2.HiddenItems")
@@ -65,49 +66,25 @@ local Vm = require("src.script.gen2.Vm")
local Zoom = require("src.render.Zoom")
-- SFX_* indices from audio/sfx_pointers.asm (constants.sfxOrder).
-local SFX_ITEM = 1
--- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
--- it farcalls CheckItemPocket over wCurItem and rings SFX_GET_TM for the TM/HM
--- pocket, SFX_ITEM for every other one. That is the sound inside GiveItemScript,
--- so it is what every `verbosegiveitem` plays.
-local SFX_GET_TM = 0x9b
--- SFX_READ_TEXT_2, the blip PlayTalkObject opens every bg event read on
--- (engine/overworld/events.asm).
-local SFX_READ_TEXT_2 = 8
--- SFX_SECOND_PART_OF_ITEMFINDER, the ding the heal machine rings as each
--- ball lands on it (engine/events/heal_machine_anim.asm .party_loop). Gen 2
--- has no SFX_HEAL_MACHINE: the rising chime over the flashing is MUSIC_HEAL,
--- a song, not an sfx.
-local SFX_SECOND_PART_OF_ITEMFINDER = 0x12
--- .HOF_PlaySFX's pair: the Game Freak chime over the Hall of Fame machine's
--- flashing, then SFX_BOOT_PC as it settles.
-local SFX_GAME_FREAK_LOGO_GS = 0xaa
-local SFX_BOOT_PC = 0x0d
--- SFX_SANDSTORM, the rattle ShakeHeadbuttTree plays over a shaking tree
--- (engine/events/field_moves.asm, right after its WaitSFX).
-local SFX_SANDSTORM = 0x6d
--- The field moves' own sounds, by their index in sfxOrder:
--- SFX_STRENGTH MovementFunction_Strength, as the boulder goes
--- SFX_PLACE_PUZZLE_PIECE_DOWN OWCutAnimation, the snip
--- SFX_SURF PlayWhirlpoolSound, which is a bare SFX_SURF
--- SFX_BUBBLEBEAM Script_UsedWaterfall's playsound
--- SFX_FLASH UseFlashTextScript's text_asm
-local SFX_STRENGTH = 27
-local SFX_PLACE_PUZZLE_PIECE_DOWN = 30
-local SFX_BUBBLEBEAM = 81
-local SFX_SURF = 83
-local SFX_FLASH = 169
--- EMOTE_SHOCK, emote 0 in constants/script_constants.asm. Script_FishCastRod
--- loads it over EMOTE_ROD, so the bubble that pops on a bite is the shock one.
+local SFX = {
+ ITEM = 1,
+ GET_TM = 0x9b,
+ READ_TEXT_2 = 8,
+ SECOND_PART_OF_ITEMFINDER = 0x12,
+ GAME_FREAK_LOGO_GS = 0xaa,
+ BOOT_PC = 0x0d,
+ SANDSTORM = 0x6d,
+ STRENGTH = 27,
+ PLACE_PUZZLE_PIECE_DOWN = 30,
+ BUBBLEBEAM = 81,
+ SURF = 83,
+ FLASH = 169,
+ ENTER_DOOR = 31,
+ WARP_TO = 19,
+ EXIT_BUILDING = 35,
+ JUMP_OVER_LEDGE = 0x16,
+}
local EMOTE_SHOCK = 0
--- GetWarpSFX (home/map.asm) picks one of three by the tile the player is
--- standing on when the warp is taken; these are their sfxOrder indices in this
--- cache (Sfx_EnterDoor, Sfx_WarpTo, Sfx_ExitBuilding), resolved by NAME at the
--- call site so a cache with a different table still finds them.
-local SFX_ENTER_DOOR = 31
-local SFX_WARP_TO = 19
-local SFX_EXIT_BUILDING = 35
-local SFX_JUMP_OVER_LEDGE = 0x16
local World = {}
World.__index = World
@@ -116,134 +93,81 @@ World.__index = World
local DIR_CONN = { up = "north", down = "south", left = "west", right = "east" }
local FACING_ID = { down = 0, up = 1, left = 2, right = 3 }
local NEIGHBOR_HOPS = 2
--- constants/script_constants.asm
-local VAR_FACING = 0x09
--- VAR_WEEKDAY, whose .DayOfWeek arm is `call GetWeekday` -> wCurDay. 39 of the
--- 40 `readvar` sites reachable from a map callback are this one: it is what
--- decides which of the seven travelling siblings is standing on their route,
--- which haircut brother is in, and which day the Goldenrod underground
--- MAPCALLBACK_OBJECTS lets through.
-local VAR_WEEKDAY = 0x0b
--- VAR_BATTLETYPE, the one VAR_* slot a script writes that anything reads back:
--- `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_FORCEITEM` is what makes Lugia,
--- Ho-Oh and the Red Gyarados hold their item, FORCESHINY what makes the
--- Gyarados red, and CANLOSE what lets the Cherrygrove rival beat you.
-local VAR_BATTLETYPE = 0x03
--- constants/battle_constants.asm BATTLETYPE_FORCEITEM: InitEnemyMon's
--- `.WildItem` reads wBaseItem1 unconditionally for this type instead of
--- rolling the ordinary 25%/8% chance, which is how Ho-Oh's SACRED_ASH (and
--- Lugia's, and the Red Gyarados' held item) is guaranteed rather than random.
-local BATTLETYPE_FORCEITEM = 10
--- constants/battle_constants.asm BATTLETYPE_FORCESHINY: the Lake of Rage
--- Gyarados. InitEnemyMon's `.NotRoaming` arm (engine/battle/core.asm:5876)
--- swaps the rolled DVs for ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA, and
--- TryToRunAwayFromBattle refuses to run for this type, which the battle
--- reads off opts.battleType.
-local BATTLETYPE_FORCESHINY = 7
--- constants/battle_constants.asm BATTLETYPE_CANLOSE: the Cherrygrove rival's
--- three arms are the only `loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE` in the
--- game. LostBattle (engine/battle/core.asm) answers this type by sliding the
--- winner's pic in and printing the loss text, then RETURNS -- no grayscale, no
--- whiteout -- and the script that armed it follows `startbattle` with a bare
--- `reloadmap`, never `reloadmapafterbattle`, so Script_BattleWhiteout is
--- unreachable from this battle on either path.
-local BATTLETYPE_CANLOSE = 1
-local VAR_PARTYCOUNT = 0x01
-local VAR_BATTLERESULT = 0x02
-local VAR_TIMEOFDAY = 0x04
-local VAR_DEXCAUGHT = 0x05
-local VAR_DEXSEEN = 0x06
-local VAR_BADGES = 0x07
-local VAR_MOVEMENT = 0x08
-local VAR_HOUR = 0x0a
-local VAR_MAPGROUP = 0x0c
-local VAR_MAPNUMBER = 0x0d
-local VAR_UNOWNCOUNT = 0x0e
-local VAR_ENVIRONMENT = 0x0f
-local VAR_BOXSPACE = 0x10
-local VAR_CONTESTMINUTES = 0x11
-local VAR_XCOORD = 0x12
-local VAR_YCOORD = 0x13
-local VAR_SPECIALPHONECALL = 0x14
--- wPlayerState (constants/ram_constants.asm) as VAR_MOVEMENT reads it raw:
--- NORMAL 0, BIKE 1, SKATE 2, SURF 4, SURF_PIKA 8. FieldMoves only models the
--- four states the port can actually enter; PLAYER_SKATE is written by nothing
--- in Gold.
-local PLAYER_STATE_ID = {
- [FieldMoves.PLAYER_NORMAL] = 0,
- [FieldMoves.PLAYER_BIKE] = 1,
- [FieldMoves.PLAYER_SURF] = 4,
- [FieldMoves.PLAYER_SURF_PIKA] = 8,
+local VAR = {
+ PARTYCOUNT = 0x01,
+ BATTLERESULT = 0x02,
+ BATTLETYPE = 0x03,
+ TIMEOFDAY = 0x04,
+ DEXCAUGHT = 0x05,
+ DEXSEEN = 0x06,
+ BADGES = 0x07,
+ MOVEMENT = 0x08,
+ FACING = 0x09,
+ HOUR = 0x0a,
+ WEEKDAY = 0x0b,
+ MAPGROUP = 0x0c,
+ MAPNUMBER = 0x0d,
+ UNOWNCOUNT = 0x0e,
+ ENVIRONMENT = 0x0f,
+ BOXSPACE = 0x10,
+ CONTESTMINUTES = 0x11,
+ XCOORD = 0x12,
+ YCOORD = 0x13,
+ SPECIALPHONECALL = 0x14,
}
--- The same table backwards, for `loadvar VAR_MOVEMENT, PLAYER_BIKE`: the mount
--- and the dismount are a variable write on the cart, so writeVar has to be
--- able to turn one back into a state name.
-local PLAYER_STATE_BY_ID = {}
-for state, id in pairs(PLAYER_STATE_ID) do PLAYER_STATE_BY_ID[id] = state end
+local BATTLETYPE = {
+ CANLOSE = 1,
+ FORCESHINY = 7,
+ FORCEITEM = 10,
+}
-- constants/collision_constants.asm, for GetWarpSFX below.
-local COLL_DOOR = 0x71
-local COLL_WARP_PANEL = 0x7c
+local COLL = {
+ DOOR = 0x71,
+ WARP_PANEL = 0x7c,
+}
--- constants/sprite_constants.asm: wVariableSprites is indexed from SPRITE_VARS,
--- so an object whose `sprite` is one of $f0..$fc names a SLOT rather than a
--- sheet and only `variablesprite` can say what stands there.
-local SPRITE_VARS = 0xf0
+local SPRITE = {
+ VARS = 0xf0,
+ DAY_CARE_MON_1 = 0xe0,
+ DAY_CARE_MON_2 = 0xe1,
+}
--- constants/sprite_constants.asm:143-145. Neither byte names a sheet: GetMonSprite
--- (engine/overworld/overworld.asm:279-305) tests them BEFORE the SPRITE_VARS
--- range and answers with LoadOverworldMonIcon of wBreedMon1Species /
--- wBreedMon2Species, i.e. the deposited mon's own party-menu icon. Route 34's
--- two yard objects carry them.
-local SPRITE_DAY_CARE_MON_1 = 0xe0
-local SPRITE_DAY_CARE_MON_2 = 0xe1
+local ENGINE = {
+ DAY_CARE_MAN_HAS_EGG = 5,
+ DAY_CARE_MAN_HAS_MON = 6,
+ DAY_CARE_LADY_HAS_MON = 7,
+}
--- constants/engine_flags.asm, const_def, with the five-wide pokegear block
--- first. These three are bits of wDayCareMan / wDayCareLady rather than slots
--- of their own (data/events/engine_flags.asm:18-20), so World:engineFlag reads
--- them straight out of save.dayCare.
-local ENGINE_DAY_CARE_MAN_HAS_EGG = 5
-local ENGINE_DAY_CARE_MAN_HAS_MON = 6
-local ENGINE_DAY_CARE_LADY_HAS_MON = 7
+local MAPSETUP = {
+ WARP = 0xf1,
+ CONTINUE = 0xf2,
+ RELOADMAP = 0xf3,
+ TELEPORT = 0xf4,
+ DOOR = 0xf5,
+ FALL = 0xf6,
+ CONNECTION = 0xf7,
+ LINKRETURN = 0xf8,
+ TRAIN = 0xf9,
+ SUBMENU = 0xfa,
+ BADWARP = 0xfb,
+}
--- constants/map_setup_constants.asm (const_def $f1). The byte picks a row of
--- MapSetupScripts (data/maps/setup_scripts.asm); the port has one map load, so
--- what survives of each script is which fades it is bracketed by.
-local MAPSETUP_WARP = 0xf1
-local MAPSETUP_CONTINUE = 0xf2
-local MAPSETUP_RELOADMAP = 0xf3
-local MAPSETUP_TELEPORT = 0xf4
-local MAPSETUP_DOOR = 0xf5
-local MAPSETUP_FALL = 0xf6
-local MAPSETUP_CONNECTION = 0xf7
-local MAPSETUP_LINKRETURN = 0xf8
-local MAPSETUP_TRAIN = 0xf9
-local MAPSETUP_SUBMENU = 0xfa
-local MAPSETUP_BADWARP = 0xfb
-
--- Which of the eleven setup scripts fades, read off data/maps/setup_scripts.asm
--- with its FALLTHROUGHS honoured -- MapSetupScript_Fall drops into _Door, which
--- drops into _Train, and _Teleport drops into _Warp, so FALL fades out because
--- DOOR's FadeOutToWhite is the next command and not because FALL names one.
---
--- fade out then in : DOOR, FALL, TELEPORT (a FadeOutToWhite opens the list)
--- fade in only : WARP, BADWARP, TRAIN, LINKRETURN, CONTINUE, RELOADMAP
--- neither : CONNECTION, SUBMENU (an edge cross must not hitch)
local MAPSETUP_FADE_OUT = {
- [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true,
+ [MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true,
}
local MAPSETUP_FADE_IN = {
- [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true,
- [MAPSETUP_WARP] = true, [MAPSETUP_BADWARP] = true, [MAPSETUP_TRAIN] = true,
- [MAPSETUP_LINKRETURN] = true, [MAPSETUP_CONTINUE] = true,
- [MAPSETUP_RELOADMAP] = true,
+ [MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true,
+ [MAPSETUP.WARP] = true, [MAPSETUP.BADWARP] = true, [MAPSETUP.TRAIN] = true,
+ [MAPSETUP.LINKRETURN] = true, [MAPSETUP.CONTINUE] = true,
+ [MAPSETUP.RELOADMAP] = true,
}
-- MapSetupScript_Connection and _Submenu are the two with no FadeInFromWhite;
-- naming them keeps the table above readable as the whole eleven-row set.
local MAPSETUP_NO_FADE = {
- [MAPSETUP_CONNECTION] = true, [MAPSETUP_SUBMENU] = true,
+ [MAPSETUP.CONNECTION] = true, [MAPSETUP.SUBMENU] = true,
}
-- MapSetupCommands $26 UpdateRoamMons and $27 JumpRoamMons, read off the same
@@ -261,13 +185,13 @@ local MAPSETUP_NO_FADE = {
-- every beast to a random roam map. Flying across Johto shuffles them;
-- walking through a door does not.
--
--- A plain MAPSETUP_WARP names neither, which is why warping between two floors
+-- A plain MAPSETUP.WARP names neither, which is why warping between two floors
-- of a building leaves them where they were.
local MAPSETUP_ROAM_UPDATE = {
- [MAPSETUP_CONNECTION] = true, [MAPSETUP_DOOR] = true,
- [MAPSETUP_FALL] = true, [MAPSETUP_TRAIN] = true,
+ [MAPSETUP.CONNECTION] = true, [MAPSETUP.DOOR] = true,
+ [MAPSETUP.FALL] = true, [MAPSETUP.TRAIN] = true,
}
-local MAPSETUP_ROAM_JUMP = { [MAPSETUP_TELEPORT] = true }
+local MAPSETUP_ROAM_JUMP = { [MAPSETUP.TELEPORT] = true }
-- FadeOutToWhite / FadeInFromWhite (engine/tilesets/timeofday_pals.asm) are
-- `ld b, $4` steps of ConvertTimePalsIncHL / .DecHL, each followed by
@@ -435,8 +359,8 @@ local function itemByIndex(items, index)
return nil
end
--- CountSetBits over a { key = true } flag table: VAR_DEXCAUGHT, VAR_DEXSEEN
--- and VAR_BADGES are all "how many of these are set" reads off one.
+-- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN
+-- and VAR.BADGES are all "how many of these are set" reads off one.
local function countFlags(flags)
if not flags then return 0 end
local n = 0
@@ -594,7 +518,7 @@ function World.new(game)
-- GBC color state (engine/gfx/color.asm). `daytime` is the resolved
-- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides
-- World:hour for drivers and tests, so the palette, the hour windows and
- -- VAR_HOUR all move together; flashUsed lifts PALETTE_DARK maps.
+ -- VAR.HOUR all move together; flashUsed lifts PALETTE_DARK maps.
palettes = nil,
daytime = nil,
clockHour = nil,
@@ -627,12 +551,12 @@ function World.new(game)
-- A field move that is mid-flow (the used-X text, then its effect).
fieldMove = nil,
-- ---- state the script VM owns ------------------------------------------
- -- wVariableSprites (ram/wram.asm), indexed from SPRITE_VARS: slot -> plain
+ -- wVariableSprites (ram/wram.asm), indexed from SPRITE.VARS: slot -> plain
-- OverworldSprites byte. Cleared on a map load the way the cart's copy is
-- not -- it is real WRAM that survives -- so this one survives too, and
-- every map that needs a slot filled sets it from its own scene script.
variableSprites = {},
- -- The VAR_* slots `writevar` / `loadvar` write. Only VAR_BATTLETYPE is
+ -- The VAR_* slots `writevar` / `loadvar` write. Only VAR.BATTLETYPE is
-- read back today, by the next startbattle.
scriptVars = {},
-- WarpCheck's find. A script that ends standing on a warp tile must not
@@ -1410,8 +1334,8 @@ function World:load()
-- Continue (engine/menus/intro_menu.asm): `ld a, [wSpawnAfterChampion]` is
-- read BEFORE the saved position is honoured, and a pending value replaces
-- it outright -- .SpawnAfterE4 / SpawnAfterRed write wDefaultSpawnpoint and
- -- enter through PostCreditsSpawn's MAPSETUP_WARP instead of
- -- MAPSETUP_CONTINUE. So the champion whose induction saved them standing
+ -- enter through PostCreditsSpawn's MAPSETUP.WARP instead of
+ -- MAPSETUP.CONTINUE. So the champion whose induction saved them standing
-- in the Hall of Fame continues in New Bark Town, not in a room whose only
-- exit is sealed.
local post = self:consumePostGameSpawn()
@@ -1541,7 +1465,7 @@ function World:weekday()
return Clock.weekday(self.game and self.game.save)
end
--- hHours, which VAR_HOUR reads straight off: RTC hour 0..23. `clockHour`
+-- hHours, which VAR.HOUR reads straight off: RTC hour 0..23. `clockHour`
-- overrides the host clock the same way it does for the daytime palette.
function World:hour()
if self.clockHour then return math.floor(self.clockHour) % 24 end
@@ -1559,73 +1483,73 @@ end
-- engine/overworld/variables.asm .VarActionTable, walked in order. readVar
-- and writevar/loadvar share the id space (GetVarAction resolves both), but
--- only the handful of ADDR_DE rows (VAR_BATTLETYPE, VAR_MOVEMENT) are ever
+-- only the handful of ADDR_DE rows (VAR.BATTLETYPE, VAR.MOVEMENT) are ever
-- written back through writeVar/self.scriptVars; the rest are RETVAR_EXECUTE
-- or RETVAR_STRBUF2 rows that just read state the engine already owns.
function World:readVar(varId)
- if varId == VAR_FACING and self.player then
+ if varId == VAR.FACING and self.player then
return FACING_ID[self.player.facing] or 0
end
- if varId == VAR_WEEKDAY then return self:weekday() end
- if varId == VAR_BATTLETYPE then return self.scriptVars[VAR_BATTLETYPE] or 0 end
+ if varId == VAR.WEEKDAY then return self:weekday() end
+ if varId == VAR.BATTLETYPE then return self.scriptVars[VAR.BATTLETYPE] or 0 end
local save = self.game and self.game.save
- if varId == VAR_PARTYCOUNT then
+ if varId == VAR.PARTYCOUNT then
return save and #(save.party or {}) or 0
end
- if varId == VAR_BATTLERESULT then
+ if varId == VAR.BATTLERESULT then
-- wBattleResult masked with ~BATTLERESULT_BITMASK (the box-full flag);
-- the port never sets that bit, so the stored value already matches.
return self.lastBattleResult or 0
end
- if varId == VAR_TIMEOFDAY then return self:timeOfDayId() end
- if varId == VAR_DEXCAUGHT then
+ if varId == VAR.TIMEOFDAY then return self:timeOfDayId() end
+ if varId == VAR.DEXCAUGHT then
return countFlags(save and save.pokedex and save.pokedex.caught)
end
- if varId == VAR_DEXSEEN then
+ if varId == VAR.DEXSEEN then
return countFlags(save and save.pokedex and save.pokedex.seen)
end
- if varId == VAR_BADGES then
+ if varId == VAR.BADGES then
-- wBadges is TWO bytes (Johto then Kanto); CountSetBits walks both.
local player = save and save.player
return countFlags(player and player.badges)
+ countFlags(player and player.kantoBadges)
end
- if varId == VAR_MOVEMENT then
+ if varId == VAR.MOVEMENT then
return PLAYER_STATE_ID[self.playerState] or 0
end
- if varId == VAR_HOUR then return self:hour() end
- if varId == VAR_MAPGROUP then
+ if varId == VAR.HOUR then return self:hour() end
+ if varId == VAR.MAPGROUP then
return (self.map and self.map.def and self.map.def.group) or 0
end
- if varId == VAR_MAPNUMBER then
+ if varId == VAR.MAPNUMBER then
return (self.map and self.map.def and self.map.def.map) or 0
end
- if varId == VAR_UNOWNCOUNT then
+ if varId == VAR.UNOWNCOUNT then
-- CountUnown walks wUnownDex, a list of the distinct Unown FORMS caught in
-- catching order. save.pokedex still only knows the SPECIES; the form list
-- is its own record (save.unownDex, src/core/gen2/Unown.lua), written by
-- the same two events the cart writes it on.
return Unown.count(save)
end
- if varId == VAR_ENVIRONMENT then
+ if varId == VAR.ENVIRONMENT then
return (self.map and self.map.def and self.map.def.environmentId) or 0
end
- if varId == VAR_BOXSPACE then
+ if varId == VAR.BOXSPACE then
if not save then return 0 end
return Boxes.MONS_PER_BOX - Boxes.count(save, save.currentBox)
end
- if varId == VAR_CONTESTMINUTES then
+ if varId == VAR.CONTESTMINUTES then
if not save then return 0 end
local minutes = BugContest.timeLeft(save)
return minutes
end
- if varId == VAR_XCOORD then
+ if varId == VAR.XCOORD then
return (self.player and self.player.cellX) or 0
end
- if varId == VAR_YCOORD then
+ if varId == VAR.YCOORD then
return (self.player and self.player.cellY) or 0
end
- if varId == VAR_SPECIALPHONECALL then
+ if varId == VAR.SPECIALPHONECALL then
return self:specialCall()
end
return 0
@@ -1669,7 +1593,7 @@ function World:engineFlag(flag)
end
-- Badges live in save.player.badges, not in the flag table: on the cart the
-- ENGINE_*BADGE ids ARE the bits of wJohtoBadges/wKantoBadges, so there is
- -- only one store and everything that asks (field moves, VAR_BADGES, the
+ -- only one store and everything that asks (field moves, VAR.BADGES, the
-- trainer card) has to see the same answer. See FieldMoves.BADGE_FLAG.
local badge = FieldMoves.BADGE_FLAG[flag]
if badge and save then
@@ -1686,11 +1610,11 @@ function World:engineFlag(flag)
-- two day-care mon objects; a second copy in save.engineFlags is exactly how
-- the yard stayed empty forever.
if save then
- if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then
+ if flag == ENGINE.DAY_CARE_MAN_HAS_EGG then
return Breeding.dayCare(save).hasEgg == true
- elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON then
+ elseif flag == ENGINE.DAY_CARE_MAN_HAS_MON then
return (Breeding.side(save, "man") or {}).mon ~= nil
- elseif flag == ENGINE_DAY_CARE_LADY_HAS_MON then
+ elseif flag == ENGINE.DAY_CARE_LADY_HAS_MON then
return (Breeding.side(save, "lady") or {}).mon ~= nil
end
end
@@ -1717,18 +1641,18 @@ function World:setEngineFlag(flag, value)
return
end
-- The write half of the day-care aliases. DayCareManScript_Outside's
- -- `clearflag ENGINE_DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart
+ -- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart
-- script that writes any of the three, and it is idempotent because
-- DayCareManOutside already did `res DAYCAREMAN_HAS_EGG_F, [hl]`
-- (engine/events/daycare.asm:393), which is Breeding.collectEgg here. The
-- two HAS_MON bits belong to the deposit/withdraw routines, so a script
-- write to them would be a second store: swallow it.
if save then
- if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then
+ if flag == ENGINE.DAY_CARE_MAN_HAS_EGG then
Breeding.dayCare(save).hasEgg = value and true or false
return
- elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON
- or flag == ENGINE_DAY_CARE_LADY_HAS_MON then
+ elseif flag == ENGINE.DAY_CARE_MAN_HAS_MON
+ or flag == ENGINE.DAY_CARE_LADY_HAS_MON then
return
end
end
@@ -1736,26 +1660,26 @@ function World:setEngineFlag(flag, value)
flags[flag] = value and true or nil
end
--- Script_writevar / Script_loadvar. VAR_BATTLETYPE is the only slot anything
+-- Script_writevar / Script_loadvar. VAR.BATTLETYPE is the only slot anything
-- reads BACK out of scriptVars today, and startScriptedBattle is where it is
-- consumed.
--
--- VAR_MOVEMENT is the exception, and it is not a stored value at all: its row
--- in .VarActionTable is the ADDRESS of wPlayerState, so `loadvar VAR_MOVEMENT,
+-- VAR.MOVEMENT is the exception, and it is not a stored value at all: its row
+-- in .VarActionTable is the ADDRESS of wPlayerState, so `loadvar VAR.MOVEMENT,
-- PLAYER_BIKE` changes the player's state outright. That is the whole of
-- Script_GetOnBike -- the `special UpdatePlayerSprite` after it only reloads
-- the sheet applyPlayerState has already picked.
function World:writeVar(varId, value)
if varId == nil then return end
self.scriptVars[varId] = value or 0
- if varId == VAR_MOVEMENT then
+ if varId == VAR.MOVEMENT then
local state = PLAYER_STATE_BY_ID[value or 0]
if state then self:applyPlayerState(state) end
end
end
function World:battleType()
- return self.scriptVars[VAR_BATTLETYPE] or 0
+ return self.scriptVars[VAR.BATTLETYPE] or 0
end
-- Script_callasm / Script_memcallasm: a bank:address into raw GB code. The
@@ -1850,7 +1774,7 @@ function World:moveObject(objectId, cellX, cellY)
end
end
--- Every POOLED object whose `sprite` is the SPRITE_VARS byte for `slot`, handed
+-- Every POOLED object whose `sprite` is the SPRITE.VARS byte for `slot`, handed
-- the sheet the slot now names -- `special LoadUsedSpritesGFX`, which is the
-- command that sits beside `variablesprite` at every one of its four call sites
-- (maps/Route36.asm:71, FuchsiaGym.asm:36 and :66, CopycatsHouse2F.asm:24).
@@ -1883,7 +1807,7 @@ end
-- off the map until the slot is filled again.
function World:repaintVariableSpritePool(slot)
if not self.npcPool then return end
- local byte = SPRITE_VARS + slot
+ local byte = SPRITE.VARS + slot
for key, npc in pairs(self.npcPool) do
if npc.def and npc.def.sprite == byte then
local name = self:resolveSprite(byte)
@@ -1916,7 +1840,7 @@ function World:setVariableSprite(slot, spriteIndex)
self:rebuildPeople({ seamless = true })
end
--- The other half of the above: an object whose `sprite` is a SPRITE_VARS byte
+-- The other half of the above: an object whose `sprite` is a SPRITE.VARS byte
-- resolves through the slot table and constants.spriteOrder (1-based, because
-- sprite_constants.asm's block is `const_def 1`). An unfilled slot answers nil
-- and the object simply does not spawn, which is the cart's behaviour too.
@@ -2000,20 +1924,20 @@ end
function World:resolveSprite(sprite)
if type(sprite) ~= "number" then return sprite end
- -- GetMonSprite tests the two day-care bytes ABOVE the SPRITE_VARS range, so
+ -- GetMonSprite tests the two day-care bytes ABOVE the SPRITE.VARS range, so
-- they must never reach the wVariableSprites arm. .NoBreedmon answers sprite
-- 1 for an empty slot; nil is the honest port, because an empty slot leaves
-- the object's own event flag (EVENT_DAY_CARE_MON_1/2) set and
-- Route34EggCheckCallback only clears it once checkflag says a mon is there.
- if sprite == SPRITE_DAY_CARE_MON_1 or sprite == SPRITE_DAY_CARE_MON_2 then
+ if sprite == SPRITE.DAY_CARE_MON_1 or sprite == SPRITE.DAY_CARE_MON_2 then
local save = self.game and self.game.save
local slot = save and Breeding.side(save,
- sprite == SPRITE_DAY_CARE_MON_1 and "man" or "lady")
+ sprite == SPRITE.DAY_CARE_MON_1 and "man" or "lady")
local mon = slot and slot.mon
return mon and self:breedmonSpriteDef(mon.species) or nil
end
- if sprite < SPRITE_VARS then return nil end
- local byte = self.variableSprites[sprite - SPRITE_VARS]
+ if sprite < SPRITE.VARS then return nil end
+ local byte = self.variableSprites[sprite - SPRITE.VARS]
if not byte or byte == 0 then return nil end
local order = self.constants and self.constants.spriteOrder
return order and order[byte] or nil
@@ -2136,7 +2060,7 @@ end
-- group/map pair this cache cannot resolve is a silent no-op rather than a
-- crash, the same way Script_warp's own group-0 arm goes nowhere.
--
--- Script_warp's own entry method is MAPSETUP_WARP, whose script opens on
+-- Script_warp's own entry method is MAPSETUP.WARP, whose script opens on
-- DisableLCD rather than on a FadeOutToWhite: the screen goes at once and only
-- the way back in is a fade. A `warpfacing` byte is PLAYERSPRITESETUP_CUSTOM_
-- FACING, which SpawnInCustomFacing applies INSTEAD of SpawnInFacingDown, so a
@@ -2153,7 +2077,7 @@ end
-- run one body and a warp from a mod is indistinguishable from a scripted one.
function World:warpToMapId(mapId, cellX, cellY, facing)
if not (mapId and cellX and cellY) then return false end
- return self:runMapSetup(MAPSETUP_WARP, function()
+ return self:runMapSetup(MAPSETUP.WARP, function()
local ok = self:setMap(mapId, cellX, cellY,
facing or (self.player and self.player.facing) or "down")
if ok and not facing then self:spawnFacing() end
@@ -2163,7 +2087,7 @@ end
-- Script_warp's group-0 arm: `warp NONE, 0, 0`. wDefaultSpawnpoint is
-- SPAWN_N_A, and EnterMapSpawnPoint leaves the map and the coordinates alone
--- when it reads that, so MAPSETUP_BADWARP is a full load of the map the player
+-- when it reads that, so MAPSETUP.BADWARP is a full load of the map the player
-- is already standing on -- HandleNewMap, LoadBlockData and LoadMapObjects
-- included. That is what PlayersHousePCScript's `.Warp` is for: the bedroom's
-- decorations only move when the map is loaded again.
@@ -2178,7 +2102,7 @@ function World:reloadMapBadWarp(reason)
if not (map and p) then return false end
local mapId = map.id
local cx, cy, facing = p.cellX, p.cellY, p.facing
- local ok = self:runMapSetup(MAPSETUP_BADWARP, function()
+ local ok = self:runMapSetup(MAPSETUP.BADWARP, function()
return self:setMap(mapId, cx, cy, facing)
end)
if ok and reason then
@@ -2215,9 +2139,9 @@ end
-- destination. Looked up by name so a cache whose sfx table sits at other
-- indices still finds them.
local WARP_SFX_NAME = {
- [SFX_ENTER_DOOR] = "Sfx_EnterDoor",
- [SFX_WARP_TO] = "Sfx_WarpTo",
- [SFX_EXIT_BUILDING] = "Sfx_ExitBuilding",
+ [SFX.ENTER_DOOR] = "Sfx_EnterDoor",
+ [SFX.WARP_TO] = "Sfx_WarpTo",
+ [SFX.EXIT_BUILDING] = "Sfx_ExitBuilding",
}
-- Play an sfx by its pokegold LABEL, falling back to the index this cache
@@ -2241,20 +2165,20 @@ end
-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes
--- wCurItem's pocket into wItemAttributeValue, and rings SFX_GET_TM for the
--- TM/HM pocket, SFX_ITEM for every other one. It is the sound inside
+-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the
+-- TM/HM pocket, SFX.ITEM for every other one. It is the sound inside
-- GiveItemScript, so every `verbosegiveitem` runs through it -- Sage Li's
-- `verbosegiveitem HM_FLASH` and every gym leader's TM included, all of which
-- rang the ordinary item jingle while the item argument was thrown away. An
--- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX_ITEM.
+-- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX.ITEM.
function World:specialSound(itemIndex)
local id = itemIndex and self:itemIdByIndex(itemIndex)
local items = self.game and self.game.data and self.game.data.items
local def = id and items and items[id]
if def and def.pocket == "TM_HM" then
- self:playSfxNamed("Sfx_GetTm", SFX_GET_TM)
+ self:playSfxNamed("Sfx_GetTm", SFX.GET_TM)
else
- self:playSfxNamed("Sfx_Item", SFX_ITEM)
+ self:playSfxNamed("Sfx_Item", SFX.ITEM)
end
end
@@ -2262,11 +2186,11 @@ function World:warpSound()
local p = self.player
if not (self.map and p) then return end
local coll = self.map:cellCollision(p.cellX, p.cellY)
- local id = SFX_EXIT_BUILDING
- if coll == COLL_DOOR then
- id = SFX_ENTER_DOOR
- elseif coll == COLL_WARP_PANEL then
- id = SFX_WARP_TO
+ local id = SFX.EXIT_BUILDING
+ if coll == COLL.DOOR then
+ id = SFX.ENTER_DOOR
+ elseif coll == COLL.WARP_PANEL then
+ id = SFX.WARP_TO
end
self:playSfxNamed(WARP_SFX_NAME[id], id)
end
@@ -2981,7 +2905,7 @@ end
-- -- it READS the one the officer's `setval` left there -- so `onDone` takes
-- no argument and is only the "the cutscene reached JUMPTABLE_EXIT" signal the
-- coroutine in Specials.block is parked on. The `warpcheck` and the
--- `newloadmap MAPSETUP_TRAIN` that follow it are the script's, not this.
+-- `newloadmap MAPSETUP.TRAIN` that follow it are the script's, not this.
function World:magnetTrain(toGoldenrod, onDone)
local game = self.game
if not (game and game.stack) then
@@ -3190,11 +3114,11 @@ function World:credits(onDone)
-- SPAWN_RED is the one wSpawnAfterChampion value that does not `jp
-- Reset`. SpawnAfterRed writes wDefaultSpawnpoint = SPAWN_MT_SILVER,
-- PostCreditsSpawn clears the byte, and the loop re-enters the overworld
- -- through MAPSETUP_WARP -- play resumes outside Silver Cave, in session,
+ -- through MAPSETUP.WARP -- play resumes outside Silver Cave, in session,
-- with no trip through the title screen.
local spawn = self:consumePostGameSpawn()
if spawn then
- self:runMapSetup(MAPSETUP_WARP, function()
+ self:runMapSetup(MAPSETUP.WARP, function()
return self:setMap(spawn.map, spawn.x, spawn.y, "down")
end)
end
@@ -3336,7 +3260,7 @@ function World:runMapSetup(method, load)
end
if MAPSETUP_NO_FADE[method] then return wrapped() end
if not MAPSETUP_FADE_OUT[method] then
- -- MAPSETUP_WARP and friends open on DisableLCD: the screen simply goes, and
+ -- MAPSETUP.WARP and friends open on DisableLCD: the screen simply goes, and
-- only the way back in is a fade.
local ok = wrapped()
self.fade, self.fadeLevel = "white", 1
@@ -4224,7 +4148,7 @@ end
-- caller) is src/core/gen2/Phone.lua's tryRandomCall, and its carry is
-- Script_ReceivePhoneCall, so a landed call answers true the same way the
-- contest's over-script does. What belongs here is only what the gate reads
--- off the world: CheckStandingOnEntrance (home/map_objects.asm) is COLL_DOOR
+-- off the world: CheckStandingOnEntrance (home/map_objects.asm) is COLL.DOOR
-- / COLL_DOOR_79 / COLL_STAIRCASE / COLL_CAVE under the player's feet.
function World:checkTimeEvents()
local save = self.game and self.game.save
@@ -4456,16 +4380,16 @@ function World:escapeRopeTarget()
return backup.map, destWarp
end
--- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX_WARP_TO,
--- `loadvar VAR_MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP_DOOR` with
+-- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX.WARP_TO,
+-- `loadvar VAR.MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP.DOOR` with
-- the triple already in wNextWarp -- EnterMapWarp and GetWarpDestCoords land
-- the player on the destination warp's own tile. The dig-spin sprite work is
-- not ported, the same standing decision World:flyTo records for the two fly
-- animations.
function World:runEscapeWarp(destMapId, destWarp)
- self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO)
+ self:playSfxNamed("Sfx_WarpTo", SFX.WARP_TO)
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
- return self:runMapSetup(MAPSETUP_DOOR, function()
+ return self:runMapSetup(MAPSETUP.DOOR, function()
local ok = self:setMap(destMapId, destWarp.x, destWarp.y, "down")
if ok then self:spawnFacing() end
return ok
@@ -4809,7 +4733,7 @@ function World:useSacredAsh()
local script = {
{ op = "special", id = self:specialIdNamed("HealParty") },
{ op = "refreshmap" },
- { op = "playsound", id = self:sfxIdNamed("Sfx_WarpTo", SFX_WARP_TO) },
+ { op = "playsound", id = self:sfxIdNamed("Sfx_WarpTo", SFX.WARP_TO) },
}
for _ = 1, 3 do
script[#script + 1] = { op = "special", id = self:specialIdNamed("FadeOutToWhite") }
@@ -4971,7 +4895,7 @@ function World:runHeadbutt(cx, cy, mon)
-- canvas), so the wobble is the frame's, on the same clock and for the
-- same 32 frames as the SFX that goes with it.
self:earthquake(0x40, HEADBUTT_SHAKE_FRAMES)
- self:playSfx(SFX_SANDSTORM)
+ self:playSfx(SFX.SANDSTORM)
end)
end
@@ -5378,27 +5302,27 @@ function World:runCut(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
self:replaceBlock(result.blockIndex, result.replacement)
- self:playSfx(SFX_PLACE_PUZZLE_PIECE_DOWN)
+ self:playSfx(SFX.PLACE_PUZZLE_PIECE_DOWN)
end)
end
-- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and
--- PlayWhirlpoolSound (a bare SFX_SURF) in place of the snip.
+-- PlayWhirlpoolSound (a bare SFX.SURF) in place of the snip.
function World:runWhirlpool(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
self:replaceBlock(result.blockIndex, result.replacement)
- self:playSfx(SFX_SURF)
+ self:playSfx(SFX.SURF)
end)
end
--- Script_UseFlash: the text plays SFX_FLASH from inside itself
+-- Script_UseFlash: the text plays SFX.FLASH from inside itself
-- (UseFlashTextScript's text_asm), and BlindingFlash then sets
-- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there
-- is to it: Palettes.daytimeFor already turns a flashed PALETTE_DARK map into
-- a NITE one, which is the cart's own .UsedFlash arm.
function World:runFlash(result)
- self:playSfx(SFX_FLASH)
+ self:playSfx(SFX.FLASH)
self:showText(Strings(result.text), function()
self.flashUsed = true
if self:applyPalettes() then self:refreshMapImages() end
@@ -5442,7 +5366,7 @@ function World:runStrength(result)
end)
end
--- Script_UsedWaterfall: the line, SFX_BUBBLEBEAM, and then a loop of one
+-- Script_UsedWaterfall: the line, SFX.BUBBLEBEAM, and then a loop of one
-- turn_waterfall UP step at a time.
--
-- .CheckContinueWaterfall writes wScriptVar = 0 while the player is STILL on a
@@ -5452,7 +5376,7 @@ end
function World:runWaterfall(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
- self:playSfx(SFX_BUBBLEBEAM)
+ self:playSfx(SFX.BUBBLEBEAM)
self.fieldMove = { phase = "waterfall" }
self:waterfallStep()
end)
@@ -5512,7 +5436,7 @@ function World:tryPushBoulder(dir, cx, cy)
if e ~= npc and e.cellX == tx and e.cellY == ty then return false end
end
npc:scriptStep(dir)
- self:playSfx(SFX_STRENGTH)
+ self:playSfx(SFX.STRENGTH)
-- Gen 1's four payload keys. Divergence, deliberate: Gen 1 emits from the
-- scriptMove completion callback, once the boulder has settled; Gold's
-- MovementFunction_Strength has no such callback, so this fires as the push
@@ -5576,17 +5500,17 @@ function World:runDigEscape(result)
end
-- TeleportFunction's .TeleportScript: the return line, then WarpToSpawnPoint
--- with `newloadmap MAPSETUP_TELEPORT` -- the same landing a whiteout takes,
+-- with `newloadmap MAPSETUP.TELEPORT` -- the same landing a whiteout takes,
-- which is exactly what World:warpToSpawn resolves (blackoutmod override
-- first, then the SPAWN_* table). PLAYER_NORMAL first, so a teleport off a
--- bike arrives on foot the way `loadvar VAR_MOVEMENT, PLAYER_NORMAL` leaves
+-- bike arrives on foot the way `loadvar VAR.MOVEMENT, PLAYER_NORMAL` leaves
-- it. The teleport spin, like the dig spin, is sprite work and not ported.
function World:runTeleport(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
- self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO)
+ self:playSfxNamed("Sfx_WarpTo", SFX.WARP_TO)
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
- self:runMapSetup(MAPSETUP_TELEPORT, function()
+ self:runMapSetup(MAPSETUP.TELEPORT, function()
self:warpToSpawn()
return true
end)
@@ -5728,7 +5652,7 @@ function World:flyPoints()
self.game and self.game.save, self.landmarks, self:region())
end
--- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP_TELEPORT` brings the
+-- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP.TELEPORT` brings the
-- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens
-- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the
-- same pair of fades a door is, which is where the two fly animations ride:
@@ -5740,7 +5664,7 @@ function World:flyTo(spawnId)
return false
end
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
- local ok = self:runMapSetup(MAPSETUP_TELEPORT, function()
+ local ok = self:runMapSetup(MAPSETUP.TELEPORT, function()
return self:setMap(spawn.map, spawn.x, spawn.y, "down")
end)
-- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off
@@ -5936,7 +5860,7 @@ function World:startBattle(opts, onDone)
game.stack:pop()
-- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2.
-- The port never forfeits or draws a battle, so "lose" is the only
- -- other outcome startBattle's onDone hands back; VAR_BATTLERESULT
+ -- other outcome startBattle's onDone hands back; VAR.BATTLERESULT
-- reads this back masked with ~BATTLERESULT_BITMASK, same as the cart.
self.lastBattleResult = (outcome == "lose") and 1 or 0
-- BattleEnd_HandleRoamMons, which runs on the way out of EVERY wild
@@ -5986,7 +5910,7 @@ function World:startBattle(opts, onDone)
-- (`checkflag ENGINE_BUG_CONTEST_TIMER / iftrue .bug_contest` skips
-- both callasms), so a wipe in the park costs nothing.
--
- -- BATTLETYPE_CANLOSE is the other exception, and it is the battle
+ -- BATTLETYPE.CANLOSE is the other exception, and it is the battle
-- engine's own: LostBattle (engine/battle/core.asm) prints the loss
-- text for this type and returns with the player exactly where they
-- fought, and maps/CherrygroveCity.asm follows the battle with
@@ -5995,7 +5919,7 @@ function World:startBattle(opts, onDone)
-- .FinishRival is what heals the party, not a whiteout. Warping here
-- moved the loser to the spawn point and then ran that walk-off over
-- whatever stood there.
- if outcome == "lose" and opts.battleType ~= BATTLETYPE_CANLOSE then
+ if outcome == "lose" and opts.battleType ~= BATTLETYPE.CANLOSE then
self:healParty()
if not BugContest.isActive(game.save) then
CallAsm.run(self, "HalveMoney")
@@ -6104,22 +6028,22 @@ function World:startScriptedBattle(record, wild, onDone)
}
elseif wild and wild.species then
local id, def = speciesByIndex(data and data.pokemon, wild.species)
- -- InitEnemyMon `.NotRoaming` / BATTLETYPE_FORCESHINY: the DV pair is
+ -- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is
-- forced to ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA (Attack 14, the
-- rest 10) before stats are built, which is the whole of what makes the
-- Red Gyarados red -- and caught, it keeps the DVs and stays shiny.
local monOpts
- if self:battleType() == BATTLETYPE_FORCESHINY then
+ if self:battleType() == BATTLETYPE.FORCESHINY then
monOpts = { dvs = { attack = 14, defense = 10, speed = 10,
special = 10 } }
end
opts.wild = id and Mon.new(data, id, wild.level or 5, monOpts) or nil
- -- InitEnemyMon's `.WildItem` / BATTLETYPE_FORCEITEM: Item1 is handed over
+ -- InitEnemyMon's `.WildItem` / BATTLETYPE.FORCEITEM: Item1 is handed over
-- unconditionally, no roll, which is the only wild-item path modeled --
-- see Mon.new's own note on why the general 25%/8% roll is not. Read
- -- here rather than after startBattle, because scriptVars[VAR_BATTLETYPE]
+ -- here rather than after startBattle, because scriptVars[VAR.BATTLETYPE]
-- is cleared the moment this function hands off to it.
- if opts.wild and self:battleType() == BATTLETYPE_FORCEITEM then
+ if opts.wild and self:battleType() == BATTLETYPE.FORCEITEM then
local given = def and def.items and def.items[1]
if given then opts.wild.item = given end
end
@@ -6128,14 +6052,14 @@ function World:startScriptedBattle(record, wild, onDone)
if onDone then onDone("win") end
return false
end
- -- wBattleType, which `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_*` armed:
+ -- wBattleType, which `writevar VAR.BATTLETYPE / loadvar BATTLETYPE_*` armed:
-- FORCEITEM 10 (Lugia, Ho-Oh, the Red Gyarados), FORCESHINY 7 (Lake of Rage),
-- TRAP 9 (the Rocket base), CANLOSE 1 (the Cherrygrove rival). It is a
-- ONE-SHOT on the cart -- BattleStart_TrainerBattle / StartWildBattle reset
-- it -- so the value is taken and cleared here and handed to the battle,
-- which is the half that still has to act on each case.
opts.battleType = self:battleType()
- self.scriptVars[VAR_BATTLETYPE] = nil
+ self.scriptVars[VAR.BATTLETYPE] = nil
return self:startBattle(opts, onDone)
end
@@ -6342,10 +6266,10 @@ function World:startHealMachineAnim(animType, onDone)
end
-- One frame of the machine, on the cart's own timeline: each party member's
--- ball lands with SFX_SECOND_PART_OF_ITEMFINDER then DelayFrames 30, then
+-- ball lands with SFX.SECOND_PART_OF_ITEMFINDER then DelayFrames 30, then
-- MUSIC_HEAL plays over .FlashPalettes8Times -- eight rotations of the OBJ
-- palette ten frames apart. The Hall of Fame arm swaps the jingle for
--- SFX_GAME_FREAK_LOGO_GS and rings SFX_BOOT_PC once the flashing stops.
+-- SFX.GAME_FREAK_LOGO_GS and rings SFX.BOOT_PC once the flashing stops.
-- The special returns after the last flash's delay, which is when the balls
-- clear -- the cart leaves its OAM to the overworld redraw the ended script
-- allows, and this is that same moment.
@@ -6359,11 +6283,11 @@ function World:stepHealAnim()
if ha.lit < ha.balls then
ha.lit = ha.lit + 1
self:playSfxNamed("Sfx_SecondPartOfItemfinder",
- SFX_SECOND_PART_OF_ITEMFINDER)
+ SFX.SECOND_PART_OF_ITEMFINDER)
else
ha.phase = "flash"
if ha.hof then
- self:playSfxNamed("Sfx_GameFreakLogoGs", SFX_GAME_FREAK_LOGO_GS)
+ self:playSfxNamed("Sfx_GameFreakLogoGs", SFX.GAME_FREAK_LOGO_GS)
else
-- .PlayHealMusic. playOnce hands the map its theme back when the
-- jingle ends; the script's own `pause 30` + RestartMapMusic
@@ -6379,7 +6303,7 @@ function World:stepHealAnim()
ha.timer = 0
if ha.flashes >= 8 then
if ha.hof then
- self:playSfxNamed("Sfx_BootPc", SFX_BOOT_PC)
+ self:playSfxNamed("Sfx_BootPc", SFX.BOOT_PC)
end
local done = ha.onDone
self.healAnim = nil
@@ -6863,7 +6787,7 @@ end
--
-- `hold` is the cart's `pause` when that pause sits INSIDE the box rather than
-- after it: FindItemInBallScript is `writetext .FoundItemText / playsound
--- SFX_ITEM / pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17) and
+-- SFX.ITEM / pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17) and
-- none of those commands takes the box down. It cannot be run as a VM `pause`
-- here, because Game2:update stops at the top state -- while ANY box is on the
-- stack the overworld and the VM under it do not tick at all -- so the wait has
@@ -7494,7 +7418,7 @@ function World:interactBody()
if hidden then
self.talkNpc = nil
-- PlayTalkObject, the SFX every read of a bg event opens on.
- self:playSfxNamed("Sfx_ReadText2", SFX_READ_TEXT_2)
+ self:playSfxNamed("Sfx_ReadText2", SFX.READ_TEXT_2)
interacted(self, fx, fy, "hidden", hidden)
return self.vm:start(HiddenItems.pickupScript(hidden.item, hidden.event))
end
@@ -7548,7 +7472,7 @@ function World:interactBody()
end
function World:fitScale()
- local w, h = love.graphics.getDimensions()
+ local w, h = GameViewport.dimensions()
return math.max(1, math.floor(math.min(w / 160, h / 144)))
end
@@ -8289,7 +8213,7 @@ local function samePalette(name) return name end
-- OverworldState:timeOfDay wraps, and the same job: answer what time of day
-- the WORLD is in. It carries more here because Gold has a real clock behind
-- it (src/core/gen2/Clock.lua), so this is the one write everything downstream
--- reads -- World:timeOfDayId's VAR_TIMEOFDAY, the encounter slots, the object
+-- reads -- World:timeOfDayId's VAR.TIMEOFDAY, the encounter slots, the object
-- hour windows and the palette bake all follow whatever comes back.
--
-- Gen 1's ctx keys (map, mapId, x, y, steps) are kept verbatim; `hour` and
@@ -8320,7 +8244,7 @@ function World:applyPalettes()
local previousTod = self.tod
-- GetTimeOfDay reads hHours, the one clock UpdateTime writes (home/time.asm):
-- the palette, the object hour windows (World:objectTimeVisible), the
- -- day/night encounter slots and VAR_HOUR are all the same read, so this goes
+ -- day/night encounter slots and VAR.HOUR are all the same read, so this goes
-- through World:hour rather than round-tripping the host clock inside
-- Palettes.clockDaytime.
local hour = self:hour()
@@ -8375,7 +8299,7 @@ function World:rebuildNeighbors()
self.neighbors = {}
if not self.map then return end
local s = self:zoomScale()
- local ww, wh = love.graphics.getDimensions()
+ local ww, wh = GameViewport.dimensions()
local vw = math.ceil(ww / s)
local vh = math.ceil(wh / s)
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -8431,7 +8355,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- that exemption, saving inside Kurt's house and continuing re-arms the
-- latch and he repeats the branch the player already saw. The post-credits
-- spawn is not a continue: SpawnAfterE4 / PostCreditsSpawn set
- -- MAPSETUP_WARP, so it takes the reset (engine/menus/intro_menu.asm).
+ -- MAPSETUP.WARP, so it takes the reset (engine/menus/intro_menu.asm).
if not opts.continue then
self.events:resetMapBuffer()
end
@@ -8641,12 +8565,12 @@ end
--
-- WarpToNewMapScript:
-- warpsound
--- newloadmap MAPSETUP_DOOR
+-- newloadmap MAPSETUP.DOOR
-- end
--
-- so a warp taken by walking onto the tile is two things, in that order: the
-- sound GetWarpSFX picks off the tile the player is STANDING on (which is why
--- it has to be read before the load), and the MAPSETUP_DOOR setup script with
+-- it has to be read before the load), and the MAPSETUP.DOOR setup script with
-- the map load inside it. This used to be five lines that called setMap
-- directly, which is why doors were silent and instant.
-- home/map.asm GetDestinationWarpNumber: a `warp_event` whose destination warp
@@ -8767,7 +8691,7 @@ function World:takeWarp(warpDef)
Runtime.emit("player.warped", { fromMap = prevMapId, toMap = destMapId,
x = destX, y = destY, warp = warpDef,
toWarp = destWarpNumber })
- return self:runMapSetup(MAPSETUP_DOOR, function()
+ return self:runMapSetup(MAPSETUP.DOOR, function()
local ok = self:setMap(destMapId, destX, destY,
(self.player and self.player.facing) or "down")
if ok then
@@ -8783,7 +8707,7 @@ end
-- the tile they ARRIVE on, then `call c, SpawnInFacingDown`. A tile that is
-- not in that array keeps the facing they walked in with -- so you enter a
-- building still facing up (the mat inside is a COLL_WARP_CARPET_*) and step
--- out of one facing the street (the doorway outside is COLL_DOOR).
+-- out of one facing the street (the doorway outside is COLL.DOOR).
--
-- It runs AFTER the load for the same reason the cart's does: the array is
-- indexed by wPlayerTileCollision, which is the DESTINATION map's tile.
@@ -8900,7 +8824,7 @@ function World:tryLedgeJump(dir)
p.progress = 0
-- engine/overworld/map_objects.asm:1163
p.stepFrames = Player.STEP_FRAMES * 2
- self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE)
+ self:playSfxNamed("Sfx_JumpOverLedge", SFX.JUMP_OVER_LEDGE)
return true
end
@@ -9742,7 +9666,7 @@ function World:drawGround(s)
if canvas then
bw, bh = canvas:getDimensions()
else
- bw, bh = G.getDimensions()
+ bw, bh = GameViewport.dimensions()
end
BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, self.map.id)
@@ -10002,7 +9926,7 @@ end
function World:draw()
local G = love.graphics
- local w, h = G.getDimensions()
+ local w, h = GameViewport.dimensions()
self:refreshColorMode()
G.clear(0.07, 0.05, 0.02, 1)
diff --git a/tests/drivers/battle_hud_layout_lock_test.lua b/tests/drivers/battle_hud_layout_lock_test.lua
new file mode 100644
index 00000000..24b44732
--- /dev/null
+++ b/tests/drivers/battle_hud_layout_lock_test.lua
@@ -0,0 +1,36 @@
+local U = require("tests.drivers.util")
+
+local OUT = os.getenv("SHOT_DIR") or "battle-hud-layout-lock"
+local BEFORE = OUT .. "/battle_hud_wide_extended.png"
+local AFTER = OUT .. "/battle_hud_og_locked_standard.png"
+
+return function(game)
+ os.remove(BEFORE)
+ os.remove(AFTER)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleHud = "extended"
+
+ local menu = require("src.ui.Screens").push(game, "OptionsMenu")
+ local layoutRow, hudRow
+ for _, row in ipairs(menu.rows) do
+ if row.id == "battleLayout" then layoutRow = row end
+ if row.id == "battleHud" then hudRow = row end
+ end
+ assert(layoutRow and hudRow, "battle layout/HUD rows are present")
+
+ menu.index = 6
+ menu.scroll = 3
+ assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
+ assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
+
+ layoutRow.step(game, 1)
+ assert(options.battleLayout == "og", "layout switched to OG")
+ assert(options.battleHud == "standard", "OG normalized HUD to STANDARD")
+ assert(hudRow.value(game) == "STANDARD", "OG displays STANDARD")
+ assert(U.shot(game, AFTER), "OG/STANDARD screenshot was written")
+
+ print("[driver] BATTLE_HUD_LAYOUT_LOCK_PASS")
+ game.driverDone = true
+end
diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua
new file mode 100644
index 00000000..94311bab
--- /dev/null
+++ b/tests/drivers/fill_extended_auto_menu_test.lua
@@ -0,0 +1,53 @@
+-- Visual and behavioral acceptance for the adaptive BATTLE BG menu rule.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+
+ love.window.setMode(1920, 1080, { resizable = true })
+ U.wait(3)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleFit = "fixed"
+ options.battleHud = "extended"
+ options.battleBg = "black"
+
+ local menu = require("src.ui.Screens").push(game, "OptionsMenu")
+ local fitRow, bgRow
+ local bgIndex
+ for i, row in ipairs(menu.rows) do
+ if row.id == "battleFit" then fitRow = row end
+ if row.id == "battleBg" then bgRow, bgIndex = row, i end
+ end
+ assert(fitRow and bgRow and bgIndex, "battle size/background rows are present")
+
+ fitRow.step(game, 1)
+ assert(options.battleFit == "fill", "battle size switched to FILL")
+ assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
+ assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
+ assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
+ assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
+
+ menu.index = bgIndex
+ menu.scroll = math.max(0, bgIndex - 5)
+ U.wait(2)
+ local autoPath = DIR .. "/fill_extended_auto_menu.png"
+ os.remove(autoPath)
+ local ok = U.shot(game, autoPath)
+
+ fitRow.step(game, -1)
+ assert(options.battleFit == "fixed", "battle size switched back to FIXED")
+ assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
+ assert(bgRow.step(game, 1) == true and options.battleBg == "black",
+ "FIXED can select BLACK")
+ assert(bgRow.step(game, 1) == true and options.battleBg == "world",
+ "FIXED can select WORLD")
+ U.wait(2)
+ local fixedPath = DIR .. "/fixed_extended_background_choices.png"
+ os.remove(fixedPath)
+ ok = U.shot(game, fixedPath) and ok
+
+ U.log(ok and "FILL_EXTENDED_AUTO_MENU_PASS"
+ or "FILL_EXTENDED_AUTO_MENU_FAIL")
+ love.event.quit(ok and 0 or 1)
+end
diff --git a/tests/drivers/fill_extended_white_hud_test.lua b/tests/drivers/fill_extended_white_hud_test.lua
new file mode 100644
index 00000000..fc1a3f2a
--- /dev/null
+++ b/tests/drivers/fill_extended_white_hud_test.lua
@@ -0,0 +1,64 @@
+-- Visual acceptance driver for WIDE + FILL + EXTENDED + WHITE.
+-- The full physical-window backing remains white while the four battle HUD
+-- panels move to their approved window anchors.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ local BattleState = require("src.battle.BattleState")
+ local Pokemon = require("src.pokemon.Pokemon")
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(3)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleFit = "fill"
+ options.battleHud = "extended"
+ options.battleBg = "white"
+
+ game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
+ U.teleport(game, "ROUTE_1", 5, 5, "down")
+ U.wait(60)
+
+ local battle = BattleState.newWild(game, "PIDGEY", 3,
+ { onFinish = function() end })
+ game.overworld:pushBattle(battle)
+ U.wait(360)
+
+ battle.introSlide = 0
+ battle.introBalls = nil
+ battle.showEnemyTrainer = false
+ battle.showPlayerBack = false
+ battle.enemySendingOut = false
+ battle.sendingOut = false
+ battle.phase = "menu"
+ battle.menuIndex = 1
+ U.wait(2)
+
+ assert(battle:extendedHUD(), "FILL/WHITE activates the approved extended HUD")
+ assert(not battle:extendedWorldHUD(), "FILL/WHITE does not use FIXED's paper band")
+
+ local path = DIR .. "/fill_extended_white_separate_layer.png"
+ os.remove(path)
+ local ok = U.shot(game, path)
+
+ love.window.setMode(960, 540, { resizable = true })
+ U.wait(5)
+ local smallPath = DIR .. "/fill_extended_white_small_16x9.png"
+ os.remove(smallPath)
+ ok = U.shot(game, smallPath) and ok
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(5)
+ local NamingScreen = require("src.ui.NamingScreen")
+ game.stack:push(NamingScreen.new(game, {
+ title = "NICKNAME?", maxLen = 10, onDone = function() end,
+ }))
+ U.wait(5)
+ local overlayPath = DIR .. "/fill_extended_white_naming_overlay.png"
+ os.remove(overlayPath)
+ ok = U.shot(game, overlayPath) and ok
+
+ U.log(ok and "FILL_EXTENDED_WHITE_PASS" or "FILL_EXTENDED_WHITE_FAIL")
+ love.event.quit(ok and 0 or 1)
+end
diff --git a/tests/drivers/fixed_extended_black_hud_test.lua b/tests/drivers/fixed_extended_black_hud_test.lua
new file mode 100644
index 00000000..5676c314
--- /dev/null
+++ b/tests/drivers/fixed_extended_black_hud_test.lua
@@ -0,0 +1,64 @@
+local U = require("tests.drivers.util")
+
+local OUT = os.getenv("SHOT_DIR") or "fixed-extended-black"
+local FULL = OUT .. "/fixed_extended_black_full.png"
+local SMALL = OUT .. "/fixed_extended_black_small_16x9.png"
+local OVERLAY = OUT .. "/fixed_extended_black_overlay.png"
+
+return function(game)
+ local BattleState = require("src.battle.BattleState")
+ local Pokemon = require("src.pokemon.Pokemon")
+
+ os.remove(FULL)
+ os.remove(SMALL)
+ os.remove(OVERLAY)
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(3)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleFit = "fixed"
+ options.battleHud = "extended"
+ options.battleBg = "black"
+
+ game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
+ U.teleport(game, "ROUTE_1", 5, 5, "down")
+ U.wait(60)
+
+ local battle = BattleState.newWild(game, "PIDGEY", 3,
+ { onFinish = function() end })
+ game.overworld:pushBattle(battle)
+ U.wait(360)
+ battle.introSlide = 0
+ battle.introBalls = nil
+ battle.showEnemyTrainer = false
+ battle.showPlayerBack = false
+ battle.enemySendingOut = false
+ battle.sendingOut = false
+ battle.phase = "menu"
+ battle.menuIndex = 1
+ U.wait(2)
+
+ assert(battle:extendedHUD(), "BLACK activates the approved extended HUD")
+ assert(battle:extendedBlackHUD(), "BLACK activates the white vertical battle band")
+ assert(not battle:extendedWorldHUD(), "BLACK remains separate from WORLD")
+ assert(U.shot(game, FULL), "full black-background screenshot was written")
+
+ love.window.setMode(960, 540, { resizable = true })
+ U.wait(10)
+ assert(U.shot(game, SMALL), "small black-background screenshot was written")
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(10)
+ battle.blankForAskName = true
+ local naming = require("src.ui.NamingScreen").new(game, {
+ title = "NICKNAME?", maxLen = 10, onDone = function() end,
+ })
+ game.stack:push(naming)
+ U.wait(10)
+ assert(U.shot(game, OVERLAY), "black-background overlay screenshot was written")
+
+ print("[driver] FIXED_EXTENDED_BLACK_PASS")
+ love.event.quit(0)
+end
diff --git a/tests/drivers/fixed_extended_white_hud_test.lua b/tests/drivers/fixed_extended_white_hud_test.lua
new file mode 100644
index 00000000..7f528de5
--- /dev/null
+++ b/tests/drivers/fixed_extended_white_hud_test.lua
@@ -0,0 +1,65 @@
+local U = require("tests.drivers.util")
+
+local OUT = os.getenv("SHOT_DIR") or "fixed-extended-white"
+local FULL = OUT .. "/fixed_extended_white_full.png"
+local SMALL = OUT .. "/fixed_extended_white_small_16x9.png"
+local OVERLAY = OUT .. "/fixed_extended_white_overlay.png"
+
+return function(game)
+ local BattleState = require("src.battle.BattleState")
+ local Pokemon = require("src.pokemon.Pokemon")
+
+ os.remove(FULL)
+ os.remove(SMALL)
+ os.remove(OVERLAY)
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(3)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleFit = "fixed"
+ options.battleHud = "extended"
+ options.battleBg = "white"
+
+ game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
+ U.teleport(game, "ROUTE_1", 5, 5, "down")
+ U.wait(60)
+
+ local battle = BattleState.newWild(game, "PIDGEY", 3,
+ { onFinish = function() end })
+ game.overworld:pushBattle(battle)
+ U.wait(360)
+ battle.introSlide = 0
+ battle.introBalls = nil
+ battle.showEnemyTrainer = false
+ battle.showPlayerBack = false
+ battle.enemySendingOut = false
+ battle.sendingOut = false
+ battle.phase = "menu"
+ battle.menuIndex = 1
+ U.wait(2)
+ assert(battle:extendedHUD(), "WHITE activates the approved extended HUD")
+ assert(not battle:extendedWorldHUD(), "WHITE keeps its opaque paper field")
+ assert(U.shot(game, FULL), "full WHITE screenshot was written")
+
+ love.window.setMode(960, 540, { resizable = true })
+ U.wait(10)
+ assert(U.shot(game, SMALL), "small WHITE screenshot was written")
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(10)
+ battle.blankForAskName = true
+ local naming = require("src.ui.NamingScreen").new(game, {
+ title = "NICKNAME?",
+ maxLen = 10,
+ initial = "",
+ onDone = function() end,
+ })
+ game.stack:push(naming)
+ U.wait(10)
+ assert(U.shot(game, OVERLAY), "WHITE overlay screenshot was written")
+
+ print("[driver] FIXED_EXTENDED_WHITE_PASS")
+ love.event.quit(0)
+end
diff --git a/tests/drivers/fixed_extended_world_bag_overlay_test.lua b/tests/drivers/fixed_extended_world_bag_overlay_test.lua
new file mode 100644
index 00000000..95855569
--- /dev/null
+++ b/tests/drivers/fixed_extended_world_bag_overlay_test.lua
@@ -0,0 +1,44 @@
+-- Visual regression coverage for Professor Oak's scripted Yellow capture Bag
+-- over the WIDE + FIXED + EXTENDED + WORLD composition.
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ local BattleState = require("src.battle.BattleState")
+ local Pokemon = require("src.pokemon.Pokemon")
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(3)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleFit = "fixed"
+ options.battleHud = "extended"
+ options.battleBg = "world"
+
+ game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
+ U.teleport(game, "ROUTE_1", 5, 5, "down")
+ U.wait(30)
+
+ local demo = BattleState.newWild(game, "CHARMANDER", 5)
+ demo:makeOldManDemo("PROF.OAK")
+ demo.onFinish = function() end
+ game.overworld:pushBattle(demo)
+
+ for _ = 1, 100 do
+ if demo.phase == "menu" and (demo.demoTimer or 0) > 5 then break end
+ U.tap(game, "a")
+ U.wait(4)
+ end
+ for _ = 1, 180 do
+ if game.stack:top() ~= demo then break end
+ U.wait(1)
+ end
+ U.wait(3)
+
+ local path = DIR .. "/fixed_extended_world_oak_charmander_bag.png"
+ os.remove(path)
+ local ok = game.stack:top() ~= demo and U.shot(game, path)
+ U.log(ok and "FIXED_EXTENDED_WORLD_BAG_PASS"
+ or "FIXED_EXTENDED_WORLD_BAG_FAIL")
+ love.event.quit(ok and 0 or 1)
+end
diff --git a/tests/drivers/fixed_extended_world_hud_test.lua b/tests/drivers/fixed_extended_world_hud_test.lua
new file mode 100644
index 00000000..8ed309a2
--- /dev/null
+++ b/tests/drivers/fixed_extended_world_hud_test.lua
@@ -0,0 +1,65 @@
+-- Visual acceptance driver for the first EXTENDED HUD configuration only:
+-- WIDE + FIXED + EXTENDED + WORLD.
+-- POKEPORT_DRIVER=tests/drivers/fixed_extended_world_hud_test.lua \
+-- POKEPORT_IDENTITY=fixed-extended-world POKEPORT_TOUCH=0 \
+-- SHOT_DIR=/tmp/shots love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
+ local BattleState = require("src.battle.BattleState")
+ local Pokemon = require("src.pokemon.Pokemon")
+
+ -- Match the 16:9 acceptance screenshot so the fixed 304x144 surface has
+ -- measurable space above and below it.
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(3)
+
+ local options = game.save.options
+ options.battleLayout = "wide"
+ options.battleFit = "fixed"
+ options.battleHud = "extended"
+ options.battleBg = "world"
+
+ game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
+ U.teleport(game, "ROUTE_1", 5, 5, "down")
+ U.wait(60)
+
+ local battle = BattleState.newWild(game, "PIDGEY", 3,
+ { onFinish = function() end })
+ game.overworld:pushBattle(battle)
+ U.wait(360)
+
+ battle.introSlide = 0
+ battle.introBalls = nil
+ battle.showEnemyTrainer = false
+ battle.showPlayerBack = false
+ battle.enemySendingOut = false
+ battle.sendingOut = false
+ battle.phase = "menu"
+ battle.menuIndex = 1
+ U.wait(2)
+
+ local path = DIR .. "/fixed_extended_world_separate_layer.png"
+ os.remove(path)
+ local ok = U.shot(game, path)
+
+ love.window.setMode(960, 540, { resizable = true })
+ U.wait(5)
+ local smallPath = DIR .. "/fixed_extended_world_small_16x9.png"
+ os.remove(smallPath)
+ ok = U.shot(game, smallPath) and ok
+
+ love.window.setMode(2048, 1152, { resizable = true })
+ U.wait(5)
+ local NamingScreen = require("src.ui.NamingScreen")
+ game.stack:push(NamingScreen.new(game, {
+ title = "NICKNAME?", maxLen = 10, onDone = function() end,
+ }))
+ U.wait(5)
+ local overlayPath = DIR .. "/fixed_extended_world_naming_overlay.png"
+ os.remove(overlayPath)
+ ok = U.shot(game, overlayPath) and ok
+
+ U.log(ok and "FIXED_EXTENDED_WORLD_PASS" or "FIXED_EXTENDED_WORLD_FAIL")
+ love.event.quit(ok and 0 or 1)
+end
diff --git a/tests/engine/android_asymmetric_display.lua b/tests/engine/android_asymmetric_display.lua
new file mode 100644
index 00000000..36c02368
--- /dev/null
+++ b/tests/engine/android_asymmetric_display.lua
@@ -0,0 +1,35 @@
+local function read(path)
+ local file = assert(io.open(path, "rb"))
+ local source = file:read("*a")
+ file:close()
+ return source
+end
+
+local function check(value, message)
+ if not value then error(message, 2) end
+end
+
+local java = read(
+ "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
+local manifest = read("mobile/android/app/src/main/AndroidManifest.xml")
+
+check(manifest:find("android.allow_multiple_resumed_activities", 1, true)
+ and manifest:find("GameActivity$SecondaryActivity", 1, true)
+ and manifest:find('android:exported="false"', 1, true),
+ "the private companion Activity opts into Android multi-display resume")
+check(java:find("android.os.Build.VERSION.SDK_INT < 29", 1, true)
+ and java:find("options.setLaunchDisplayId", 1, true),
+ "the primary-display fallback is restricted to Android 10+")
+check(java:find("SECONDARY_TARGET_HANDHELD", 1, true)
+ and java:find("SECONDARY_TARGET_EXTERNAL", 1, true)
+ and java:find("handheldAvailable ? handheld : external", 1, true),
+ "routing hints retain a safe available-display fallback")
+check(java:find("dualScreenDisplayMode != %-1")
+ and java:find("AYN_SECOND_SCREEN", 1, true)
+ and java:find("dualScreenModeObserverRegistered", 1, true),
+ "the optional AYN state is guarded and lifecycle-bound")
+check(java:find("activity.dispatchKeyEvent", 1, true)
+ and java:find("activity.dispatchGenericMotionEvent", 1, true),
+ "companion windows forward controller input to the game Activity")
+
+print("android asymmetric display routing: ok")
diff --git a/tests/engine/android_secondary_present.lua b/tests/engine/android_secondary_present.lua
new file mode 100644
index 00000000..d081410e
--- /dev/null
+++ b/tests/engine/android_secondary_present.lua
@@ -0,0 +1,37 @@
+local function read(path)
+ local file = assert(io.open(path, "rb"))
+ local source = file:read("*a")
+ file:close()
+ return source
+end
+
+local function check(value, message)
+ if not value then error(message, 2) end
+end
+
+local java = read(
+ "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
+local cpp = read("mobile/android/love/src/jni/love/src/common/android.cpp")
+
+check(java:find("hasSecondaryDisplayCandidate", 1, true)
+ and java:find("findSecondaryDisplay(self, false)", 1, true)
+ and java:find("now %- secondaryDetectionAt < 500"),
+ "Android exposes cached physical detection before Presentation is ready")
+check(java:find("presentSecondaryFrame", 1, true)
+ and java:find("secondaryFrame = new byte", 1, true)
+ and java:find("rgba.get(secondaryFrame", 1, true),
+ "extended presentation reuses a retained frame buffer")
+check(java:find("java.nio.ByteBuffer.wrap(secondaryFrame)", 1, true),
+ "a recreated Presentation receives the retained frame")
+check(java:find("0xFF000000 | (color & 0x00FFFFFF)", 1, true),
+ "RGB companion backgrounds become opaque Android colors")
+check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true)
+ and java:find("Math.floor(fit)", 1, true),
+ "FrameView supports cover and pixel-friendly contain fits")
+check(cpp:find("love_android_secondary_detected", 1, true)
+ and cpp:find("love_android_present_secondary", 1, true)
+ and cpp:find("love_android_secondary_target", 1, true)
+ and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true),
+ "JNI exports the optional detected, routing, and presentation calls")
+
+print("android secondary presentation: ok")
diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua
index c9bfea05..318c460c 100644
--- a/tests/engine/battle_checkpoint_boundary.lua
+++ b/tests/engine/battle_checkpoint_boundary.lua
@@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported")
+-- drainHold gates capture (see the refused() case above) exactly because it
+-- marks an HP bar mid-animation. Once stepHPDrain settles the bar it must
+-- let go of that gate too, or the very first drain of a battle leaves the
+-- checkpoint contract refused for everything after it.
+do
+ local game3, _, battle3 = makeGame()
+ battle3.enemy.mon.hp = battle3.enemy.mon.hp - 5
+ local frames = 0
+ while battle3:stepHPDrain() and frames < 10000 do
+ frames = frames + 1
+ end
+ T.eq(battle3.enemy.shownHP, battle3.enemy.mon.hp,
+ "the HP bar settles on the new total")
+ T.eq(battle3.enemy.drainHold, nil,
+ "drainHold releases the checkpoint gate once the bar finishes draining")
+ local capability = Checkpoint.inspect(game3)
+ T.check(capability.canCapture == true,
+ "a checkpoint is capturable again after the drain settles: "
+ .. tostring(capability.reason))
+end
+
T.finish()
diff --git a/tests/engine/battle_fixed_menu_scale.lua b/tests/engine/battle_fixed_menu_scale.lua
index fd5e27d2..33e79812 100644
--- a/tests/engine/battle_fixed_menu_scale.lua
+++ b/tests/engine/battle_fixed_menu_scale.lua
@@ -92,6 +92,11 @@ local menu = { isOpaque = true } -- PartyMenu / ListMenu
local whiteBattle = setmetatable(
{ game = { save = { options = { battleBg = "white" } } } },
{ __index = BattleState })
+local wideWhiteBattle = setmetatable(
+ { game = { save = { options = {
+ battleBg = "white", battleLayout = "wide",
+ } } } },
+ { __index = BattleState })
local function stack(...) return { states = { ... },
visibleBase = function(self)
for i = #self.states, 1, -1 do
@@ -111,7 +116,10 @@ T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
local s3 = stack(overworld, whiteBattle, menu)
T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
- "a white-bg battle has no map to hold, so nothing moves")
+ "a classic white-bg battle has no presentation to hold, so nothing moves")
+local s3wide = stack(overworld, wideWhiteBattle, menu)
+T.eq(Game.drawBaseInStack(s3wide, s3wide:visibleBase()), 2,
+ "an opaque WIDE battle still draws beneath its classic menu")
local s4 = stack(overworld, menu)
T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
"and a menu outside a battle is untouched")
diff --git a/tests/engine/desktop_companion.lua b/tests/engine/desktop_companion.lua
new file mode 100644
index 00000000..36d8c5e1
--- /dev/null
+++ b/tests/engine/desktop_companion.lua
@@ -0,0 +1,56 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local clock, quit = 1, false
+local sent, queue, draws = {}, {}, 0
+local peer = {
+ send = function(_, data) sent[#sent + 1] = data end,
+ disconnect_now = function() end,
+}
+local host = {
+ connect = function() return peer end,
+ service = function()
+ if #queue == 0 then return nil end
+ return table.remove(queue, 1)
+ end,
+}
+local rendered = {
+ setFilter = function() end, replacePixels = function() end,
+ release = function() end,
+}
+love = {
+ timer = { getTime = function() return clock end },
+ data = { decompress = function(_, _, value) return value end },
+ image = { newImageData = function(w, h, format, raw)
+ assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba")
+ return {}
+ end },
+ graphics = {
+ newImage = function() return rendered end,
+ getDimensions = function() return 100, 100 end,
+ clear = function() end, setColor = function() end,
+ draw = function() draws = draws + 1 end,
+ },
+ event = { quit = function() quit = true end },
+}
+package.preload.enet = function()
+ return { host_create = function() return host end }
+end
+
+require("src.render.DesktopCompanion").install({ port = 50000, token = "token" })
+queue[#queue + 1] = { type = "connect" }
+love.update()
+assert(sent[#sent] == "Htoken", "companion authenticates after connecting")
+queue[#queue + 1] = {
+ type = "receive", data = "Ftoken\n1,1,0,auto\nrgba",
+}
+love.update()
+love.draw()
+assert(draws == 1, "companion draws a received frame")
+love.mousepressed(50, 50, 1)
+love.mousereleased(50, 50, 1)
+assert(sent[#sent - 1] == "Itoken\ndown,0,0"
+ and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels")
+queue[#queue + 1] = { type = "receive", data = "Qtoken" }
+love.update()
+assert(quit, "parent can close the companion")
+print("desktop companion: ok")
diff --git a/tests/engine/desktop_second_screen.lua b/tests/engine/desktop_second_screen.lua
new file mode 100644
index 00000000..77116a8b
--- /dev/null
+++ b/tests/engine/desktop_second_screen.lua
@@ -0,0 +1,57 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local clock = 1
+love = {
+ timer = { getTime = function() return clock end },
+ data = {
+ hash = function() return "digest" end,
+ encode = function() return "0123456789abcdef0123456789abcdef" end,
+ compress = function(_, _, value) return value end,
+ },
+}
+
+local sent, spawned, queue = {}, nil, {}
+local peer = {
+ send = function(_, data, channel, flag)
+ sent[#sent + 1] = { data = data, channel = channel, flag = flag }
+ end,
+ disconnect_now = function() end,
+}
+local host = {
+ service = function()
+ if #queue == 0 then return nil end
+ return table.remove(queue, 1)
+ end,
+ destroy = function() end,
+}
+
+package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
+package.loaded["src.core.HostShell"] = {
+ spawnSelfDetached = function(args) spawned = args return true end,
+}
+package.preload.enet = function()
+ return { host_create = function() return host end }
+end
+
+local Screen = require("src.render.SecondScreen")
+assert(Screen.usable(), "the shared facade selects the desktop backend")
+Screen.setEnabled(true)
+assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"),
+ "enabling launches one companion of this app")
+local token = spawned[1]:match(",([%w]+)$")
+queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token }
+assert(Screen.detected(), "a token-authenticated companion becomes detected")
+
+local pixels = { getString = function() return "rgba" end }
+assert(Screen.push(pixels, 1, 1, 0x102030, "auto"),
+ "a connected companion accepts a frame")
+assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"),
+ "frame metadata and pixels stay in one loopback packet")
+
+queue[#queue + 1] = {
+ type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4",
+}
+assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod")
+Screen.setEnabled(false)
+assert(sent[#sent].data == "Q" .. token, "disabling closes the companion")
+print("desktop second screen: ok")
diff --git a/tests/engine/gen2_new_seams.lua b/tests/engine/gen2_new_seams.lua
index f4a54a75..39a3ed56 100644
--- a/tests/engine/gen2_new_seams.lua
+++ b/tests/engine/gen2_new_seams.lua
@@ -468,6 +468,32 @@ do
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
end)
+ -- Mon.syncIdentity (wired into refreshStats, which SummaryMenu.new calls
+ -- on every menu open) used to recompute mon.shiny from DVs unconditionally,
+ -- so opening the summary screen on a forced shiny -- one whose DVs do not
+ -- happen to match the natural pattern -- un-shinied it the moment the menu
+ -- opened. shiny is monotonic once true: a natural roll or a forced one
+ -- both stay shiny through any later refresh, the way opts.shiny already
+ -- wins at construction.
+ do
+ local forced = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs, shiny = true })
+ T.eq(forced.shiny, true, "still shiny straight out of Mon.new")
+ Mon.syncIdentity(forced, DATA)
+ T.eq(forced.shiny, true, "syncIdentity does not clobber a forced shiny")
+ Mon.refreshStats(forced, DATA)
+ T.eq(forced.shiny, true,
+ "refreshStats (SummaryMenu.new's call) does not either")
+
+ -- the natural cases are unaffected: DVs that read shiny stay shiny,
+ -- DVs that do not stay plain
+ local natural = Mon.new(DATA, "SEEDMON", 5, { dvs = shinyDvs })
+ Mon.syncIdentity(natural, DATA)
+ T.eq(natural.shiny, true, "a naturally shiny mon still reads shiny")
+ local plain = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs })
+ Mon.syncIdentity(plain, DATA)
+ T.eq(plain.shiny, false, "a plain mon is not promoted to shiny")
+ end
+
local genderCtx
withHook("gender.roll", function(nextFn, ctx)
genderCtx = ctx
diff --git a/tests/engine/hostshell_spawn_self.lua b/tests/engine/hostshell_spawn_self.lua
new file mode 100644
index 00000000..e3f2b937
--- /dev/null
+++ b/tests/engine/hostshell_spawn_self.lua
@@ -0,0 +1,32 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local osName, fused, command = "Windows", true, nil
+love = {
+ system = { getOS = function() return osName end },
+ filesystem = {
+ getExecutablePath = function() return "C:\\Game\\gen1recomp.exe" end,
+ getSource = function() return "C:\\Source\\gen1recomp" end,
+ isFused = function() return fused end,
+ },
+}
+package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
+local execute = os.execute
+os.execute = function(value) command = value return 0 end
+
+local HostShell = require("src.core.HostShell")
+assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
+assert(command:find('start "" /b ', 1, true)
+ and command:find('"C:\\Game\\gen1recomp.exe"', 1, true),
+ "Windows launches the fused app detached")
+
+osName, fused = "Linux", false
+assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
+assert(command:find("'C:\\Source\\gen1recomp'", 1, true)
+ and command:sub(-1) == "&", "Linux source runs include the game folder")
+
+osName, fused = "OS X", true
+assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
+assert(not command:find("start", 1, true) and command:sub(-1) == "&",
+ "macOS uses the same detached POSIX path")
+os.execute = execute
+print("spawn self detached: ok")
diff --git a/tests/engine/item_tmhm_nil_test.lua b/tests/engine/item_tmhm_nil_test.lua
new file mode 100644
index 00000000..217db2ee
--- /dev/null
+++ b/tests/engine/item_tmhm_nil_test.lua
@@ -0,0 +1,31 @@
+-- ItemEffects.use crashed instead of refusing when a species record carried
+-- no tmhm list at all (ipairs(nil)), which is a different situation from a
+-- species whose list simply does not name the move being taught -- that
+-- case already refuses cleanly with MonCannotLearnMachineMoveText. A mod
+-- species missing the field entirely took the whole game down on the very
+-- first TM/HM use rather than reaching that refusal.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local T = require("tests.harness").suite("item effects tmhm nil")
+local Fixtures = require("tests.modkit.fixtures")
+local ItemEffects = require("src.inventory.ItemEffects")
+local Pokemon = require("src.pokemon.Pokemon")
+
+local Data = Fixtures.fresh()
+Data.pokemon.FIXMON_A.tmhm = nil
+
+local mon = Pokemon.new(Data, "FIXMON_A", 10)
+local save = { player = { name = "RED" } }
+
+local ok, result, payload = pcall(ItemEffects.use, Data, save, "FIX_TM", mon)
+T.check(ok, "using a TM on a species with no tmhm list does not crash: "
+ .. tostring(result))
+if ok then
+ T.eq(result, "failed", "the species refuses the move instead of crashing into it")
+ T.check(type(payload) == "table" and payload[1] ~= nil,
+ "a refusal message is still returned")
+end
+
+T.finish()
diff --git a/tests/engine/item_use_hook.lua b/tests/engine/item_use_hook.lua
new file mode 100644
index 00000000..9107a32c
--- /dev/null
+++ b/tests/engine/item_use_hook.lua
@@ -0,0 +1,101 @@
+-- Public mod-API coverage for the "item.use" hook (src/ui/BagMenu.lua).
+--
+-- Before this hook existed, every result ItemEffects.use returned fell
+-- through to one unconditional call with nothing wrapped around it: a mod
+-- could not suppress a message, delay it behind a screen of its own, or
+-- replace what a specific item id does after the bag decides to use it.
+-- This exercises the seam end to end through the public mod API -- a real
+-- BagMenu list, a real USE selection -- rather than calling the hook
+-- machinery directly.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Bag = require("src.inventory.Bag")
+
+-- Real TextBoxes want a Font atlas; this only cares that useOn reaches the
+-- no-effect fallthrough, so the same stand-in tests/parity_rare_candy_menu.lua
+-- uses for a ROM-backed run works here too.
+local realTextBox = package.loaded["src.render.TextBox"]
+package.loaded["src.render.TextBox"] = {
+ new = function(_, text, done) return { textBox = true, text = text, done = done } end,
+}
+package.loaded["src.ui.BagMenu"] = nil
+local BagMenu = require("src.ui.BagMenu")
+
+local FIXTURE = {
+ ["mods/item_hook_probe/manifest.json"] = [[{
+ "id": "item_hook_probe",
+ "name": "Item Hook Probe",
+ "version": "1.0.0",
+ "entry": "main.lua",
+ "api": 2
+ }]],
+ ["mods/item_hook_probe/main.lua"] = [[
+ local mod = ...
+ mod.hooks:wrap("item.use",
+ function(vanilla, game, battle, id, target, list, moveIndex, picker)
+ mod.exports.calls = (mod.exports.calls or 0) + 1
+ mod.exports.id = id
+ mod.exports.battle = battle
+ mod.exports.target = target
+ return vanilla(game, battle, id, target, list, moveIndex, picker)
+ end)
+ ]],
+}
+
+local function newStack()
+ local stack = { states = {} }
+ function stack:push(s) self.states[#self.states + 1] = s end
+ function stack:pop() return table.remove(self.states) end
+ function stack:top() return self.states[#self.states] end
+ return stack
+end
+
+local run = T.sdk.loadMods({ "mods/item_hook_probe" }, {
+ fs = T.sdk.memfs(FIXTURE),
+})
+T.eq(#run.errors, 0,
+ "the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
+
+local game = {
+ data = run.data,
+ stack = newStack(),
+ save = {
+ player = { name = "RED" }, inventory = {}, money = 0,
+ options = { battleStyle = "set", battleAnim = "on" },
+ pokedex = { seen = {}, owned = {} }, flags = {},
+ },
+}
+Bag.add(game.save, "FIX_POTION", 1)
+
+local list = BagMenu.new(game, {})
+game.stack:push(list)
+local row
+for i, r in ipairs(list.items) do
+ if r.value == "FIX_POTION" then row = i end
+end
+T.check(row ~= nil, "the fixture item is in the bag")
+list.index = row
+list.onChoose(list.items[row], list)
+
+-- out of battle the bag offers USE / TOSS first (start_sub_menus.asm)
+local sub = game.stack:top()
+T.check(sub ~= nil and sub.items and sub.items[1] and sub.items[1].onSelect,
+ "the USE/TOSS submenu opened")
+sub.items[1].onSelect()
+
+local out = run.loader.exports.item_hook_probe or {}
+T.eq(out.calls, 1, "the hook fires exactly once for a bag item use")
+T.eq(out.id, "FIX_POTION", "the hook sees the item id")
+T.eq(out.battle, nil, "the hook sees the field-use battle argument (nil)")
+
+local top = game.stack:top()
+T.check(type(top) == "table" and top.textBox == true,
+ "vanilla still ran: the no-effect message box landed on the stack")
+
+run.release()
+package.loaded["src.render.TextBox"] = realTextBox
+package.loaded["src.ui.BagMenu"] = nil
+
+T.finish()
diff --git a/tests/engine/launcher_patch_notes.lua b/tests/engine/launcher_patch_notes.lua
index 89fbc9c2..6532006f 100644
--- a/tests/engine/launcher_patch_notes.lua
+++ b/tests/engine/launcher_patch_notes.lua
@@ -68,6 +68,12 @@ do
"stashed notes name a release version")
end
+do
+ local notes, ver = PatchNotes.fromRepo("999.999.999")
+ eq(notes, nil, "fromRepo returns nil when a specific engine version is missing")
+ eq(ver, nil, "fromRepo version is nil when missing")
+end
+
do
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
local list = PatchNotes.parseRepo(f:read("*a"))
@@ -78,6 +84,15 @@ do
eq(notes, list[2].notes, "fromRepo returns that version's notes")
end
+do
+ local oldVersion = package.loaded["src.core.Version"]
+ package.loaded["src.core.Version"] = { engine = "999.999.999" }
+ local body, ver = PatchNotes.body(nil)
+ eq(body, "Unable to fetch patch notes.", "returns Unable to fetch patch notes when version is uncached and unlisted")
+ eq(ver, "999.999.999", "returns the requested engine version")
+ package.loaded["src.core.Version"] = oldVersion
+end
+
imp._appPatchNotes = true
local modal = drawAndCapture(imp)
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
diff --git a/tests/engine/luajit_source_limits_test.lua b/tests/engine/luajit_source_limits_test.lua
new file mode 100644
index 00000000..e002b411
--- /dev/null
+++ b/tests/engine/luajit_source_limits_test.lua
@@ -0,0 +1,54 @@
+-- Source file LuaJIT limits gate.
+-- Verifies that every game engine source file compiles cleanly under
+-- LuaJIT without exceeding LuaJIT's strict 200 local variables per-scope limit,
+-- 60 upvalue limit, or bytecode compiler limits.
+-- luajit tests/engine/luajit_source_limits_test.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+
+-- Find all .lua files in a directory recursively.
+local function findLuaFiles(dir, out)
+ out = out or {}
+ local p = io.popen("find " .. dir .. " -type f -name '*.lua'")
+ if p then
+ for line in p:lines() do
+ out[#out + 1] = line
+ end
+ p:close()
+ end
+ table.sort(out)
+ return out
+end
+
+local files = findLuaFiles("src")
+findLuaFiles("tools/save-editor", files)
+files[#files + 1] = "main.lua"
+files[#files + 1] = "conf.lua"
+
+check(#files > 50, "discovered project source files (found " .. tostring(#files) .. ")")
+
+for _, path in ipairs(files) do
+ local f = assert(io.open(path, "rb"), "could not open " .. path)
+ local source = f:read("*a")
+ f:close()
+
+ -- Compile through LuaJIT loadstring: detects 'main function has more than 200 local variables'
+ -- or 'function has more than 200 local variables' across any function scope in the file.
+ local chunk, err = loadstring(source, "@" .. path)
+ check(chunk ~= nil, path .. " compiles under LuaJIT: " .. tostring(err))
+end
+
+-- Meta-test: prove that exceeding 200 locals fails the gate
+do
+ local overflowLocals = {}
+ for i = 1, 201 do overflowLocals[i] = "v" .. i end
+ local badCode = "local " .. table.concat(overflowLocals, ", ")
+ local chunk, err = loadstring(badCode, "@overflow_test.lua")
+ check(chunk == nil, "LuaJIT strictly rejects chunks exceeding 200 locals")
+ check(tostring(err):find("200 local variables", 1, true) ~= nil, "error message specifies 200 local variable limit")
+end
+
+T.finish("luajit_source_limits")
diff --git a/tests/engine/map_object_pokemon_ref_test.lua b/tests/engine/map_object_pokemon_ref_test.lua
new file mode 100644
index 00000000..905f400d
--- /dev/null
+++ b/tests/engine/map_object_pokemon_ref_test.lua
@@ -0,0 +1,89 @@
+-- A map object's `pokemon` field (the static wild encounter kind --
+-- OverworldController.lua's `d.pokemon`, handed straight to
+-- BattleState.newWild with no existence check of its own) used to go
+-- completely unchecked: R.maps.objects was f.opt(f.list(f.any)), so a
+-- typo'd species sat in a loaded mod and only surfaced as a crash the
+-- moment a player stepped up to that object. Every other kind sharing the
+-- objects array (NPCs, signs-as-objects, warps) has fields this schema
+-- still does not know about, which is what f.partial is for: it types only
+-- `pokemon` and leaves the rest of an object's shape alone.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+
+local function manifest(id)
+ return ([[{
+ "id": "%s", "name": "%s", "version": "1.0.0",
+ "entry": "main.lua", "api": 2
+ }]]):format(id, id)
+end
+
+-- ------- a bad species id is caught as a load error, not left to crash
+
+local BAD = {
+ ["mods/bad_static_encounter/manifest.json"] = manifest("bad_static_encounter"),
+ ["mods/bad_static_encounter/main.lua"] = [[
+ local mod = ...
+ mod.content.maps:patch("FIX_ROUTE", {
+ objects = {
+ { pokemon = "NOT_A_SPECIES", level = 30, text = "Gyaoo!" },
+ },
+ })
+ ]],
+}
+
+do
+ local run = T.sdk.loadMods({ "mods/bad_static_encounter" },
+ { fs = T.sdk.memfs(BAD) })
+ local dangling = {}
+ for _, message in ipairs(run.errors) do
+ if message:match("unresolved reference") then
+ dangling[#dangling + 1] = message
+ end
+ end
+ T.eq(#dangling, 1,
+ "a bad static-encounter species is reported once ("
+ .. table.concat(dangling, "; ") .. ")")
+ T.check(dangling[1] and dangling[1]:match("maps%.FIX_ROUTE%.objects")
+ and dangling[1]:match("pokemon"),
+ "the report names the map, the objects field and the pokemon registry: "
+ .. tostring(dangling[1]))
+ run.release()
+end
+
+-- ------- a real species resolves, and an NPC-shaped object beside it (no
+-- pokemon field at all, and fields this schema never named -- sprite,
+-- movement, range) is untouched
+
+local GOOD = {
+ ["mods/good_static_encounter/manifest.json"] = manifest("good_static_encounter"),
+ ["mods/good_static_encounter/main.lua"] = [[
+ local mod = ...
+ mod.content.maps:patch("FIX_ROUTE", {
+ objects = {
+ { index = 1, name = "FIXROUTE_TRAINER", sprite = "SPRITE_FIX_NPC",
+ movement = "STAY", range = "NONE", text = "TEXT_FIXROUTE_TRAINER",
+ x = 5, y = 9 },
+ { pokemon = "FIXMON_A", level = 30, text = "Gyaoo!" },
+ },
+ })
+ ]],
+}
+
+do
+ local run = T.sdk.loadMods({ "mods/good_static_encounter" },
+ { fs = T.sdk.memfs(GOOD) })
+ T.eq(#run.errors, 0,
+ "a real species and an untyped NPC object both load clean ("
+ .. tostring(run.errors[1]) .. ")")
+ local objects = run.data.maps.FIX_ROUTE.objects
+ T.eq(#objects, 2, "both objects landed on the map")
+ T.eq(objects[1].sprite, "SPRITE_FIX_NPC",
+ "the NPC object's untyped fields passed through unexamined")
+ T.eq(objects[2].pokemon, "FIXMON_A",
+ "the static encounter's species field passed through too")
+ run.release()
+end
+
+T.finish()
diff --git a/tests/engine/render_viewport.lua b/tests/engine/render_viewport.lua
new file mode 100644
index 00000000..7c100295
--- /dev/null
+++ b/tests/engine/render_viewport.lua
@@ -0,0 +1,101 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+love = require("tests.love_stub")
+
+local Hooks = require("src.mods.Hooks")
+local Runtime = require("src.mods.Runtime")
+local savedHooks = Runtime.hooks
+local hooks = Hooks.new()
+Runtime.hooks = hooks
+
+local Viewport = require("src.render.GameViewport")
+local SafeArea = require("src.core.SafeArea")
+local TouchControls = require("src.core.TouchControls")
+
+Viewport.begin(1)
+assert(not Viewport.active(), "vanilla frame must not allocate a viewport")
+local w, h = Viewport.dimensions()
+assert(w == 640 and h == 576, "vanilla dimensions must stay unchanged")
+
+hooks:wrap("render.viewport", function(next, ctx)
+ local full = next(ctx)
+ assert(full.width == 640 and full.height == 576,
+ "viewport hook receives OS-independent window geometry")
+ return { x = 320, y = 12, width = 320, height = 288 }
+end, 0, "fixture")
+
+local presented
+hooks:wrap("render.window", function(next, game, ctx)
+ presented = ctx
+ return next(game, ctx)
+end, 0, "fixture")
+
+Viewport.begin(2)
+assert(Viewport.active(), "a reserved rectangle creates a game target")
+w, h = Viewport.dimensions()
+assert(w == 320 and h == 288, "game renders against reserved dimensions")
+Viewport.target().getPixelDimensions = function() return 737, 664 end
+local pw, ph = Viewport.pixelDimensions()
+assert(pw == 737 and ph == 664,
+ "captured rendering uses the target's real high-DPI pixel dimensions")
+local x, y, inside = Viewport.toLocal(400, 100)
+assert(x == 80 and y == 88 and inside,
+ "window pointers expose viewport-local coordinates")
+local _, _, outside = Viewport.toLocal(20, 20)
+assert(not outside, "reserved companion space is outside the game viewport")
+local _, _, localW, localH = SafeArea.rect()
+local _, _, windowW, windowH = SafeArea.windowRect()
+assert(localW == 320 and localH == 288,
+ "game chrome may still use viewport-local safe geometry")
+assert(windowW == 640 and windowH == 576,
+ "OS chrome can retain the full-window safe geometry")
+TouchControls:init()
+local controls = TouchControls:layout()
+assert(controls.dpad.cx < 160 and controls.a.cx > 480,
+ "touch controls stay laid out across the full OS window")
+local function source(path)
+ local file = assert(io.open(path, "r"))
+ local text = file:read("*a")
+ file:close()
+ return text
+end
+for _, path in ipairs({ "src/core/Game.lua", "src/core/Game2.lua" }) do
+ local text = source(path)
+ local finish = assert(text:find("GameViewport.finish(self)", 1, true))
+ local controlsDraw = assert(text:find("TouchControls:draw()", finish, true))
+ assert(controlsDraw > finish,
+ path .. " draws touch controls after final window composition")
+end
+Viewport.setTarget()
+assert(love.graphics.getCanvas() == Viewport.target(),
+ "game rendering is redirected into the viewport canvas")
+Viewport.finish({})
+assert(presented and presented.x == 320 and presented.y == 12
+ and presented.width == 320 and presented.height == 288
+ and presented.windowWidth == 640 and presented.windowHeight == 576
+ and presented.generation == 2,
+ "window composition receives game and host geometry")
+assert(love.graphics.getCanvas() == nil,
+ "window composition restores the OS render target")
+Viewport.reset()
+assert(not Viewport.active(),
+ "viewport geometry cannot leak into the launcher after presentation")
+
+hooks.chains["render.viewport"] = nil
+hooks:wrap("render.viewport", function(next, ctx)
+ local full = next(ctx)
+ full.capture = true
+ return full
+end, 0, "capture-fixture")
+Viewport.begin(1)
+assert(Viewport.active() and Viewport.dimensions() == 640,
+ "a full-window capture allocates a final composition target")
+presented = nil
+Viewport.setTarget()
+Viewport.finish({})
+assert(presented and presented.width == 640 and presented.height == 576,
+ "a full-window capture reaches final window composition")
+Viewport.reset()
+
+Runtime.hooks = savedHooks
+print("render viewport: ok")
diff --git a/tests/engine/second_screen_present.lua b/tests/engine/second_screen_present.lua
new file mode 100644
index 00000000..dafb7ac8
--- /dev/null
+++ b/tests/engine/second_screen_present.lua
@@ -0,0 +1,70 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.harness")
+local name = "src.render.SecondScreen"
+local oldModule = package.loaded[name]
+local oldFfi = package.loaded.ffi
+local oldPreload = package.preload.ffi
+local calls = {}
+local null = {}
+
+local C = {
+ love_android_secondary_ready = function() return 0 end,
+ love_android_push_secondary = function(ptr, w, h)
+ calls.push = { ptr, w, h }
+ end,
+ love_android_secondary_enable = function(on) calls.enabled = on end,
+ love_android_secondary_target = function(target) calls.target = target end,
+ love_android_secondary_detected = function() return 1 end,
+ love_android_present_secondary = function(ptr, w, h, background, cover)
+ calls.present = { ptr, w, h, background, cover }
+ return 1
+ end,
+ love_android_poll_secondary_touch = function() return null end,
+}
+local fakeFfi = {
+ C = C,
+ NULL = null,
+ cdef = function() end,
+ load = function() return C end,
+ string = function(value) return value end,
+}
+
+package.loaded[name] = nil
+package.loaded.ffi = nil
+package.preload.ffi = function() return fakeFfi end
+
+local SecondScreen = require(name)
+local image = { getFFIPointer = function() return "pixels" end }
+
+T.eq(SecondScreen.available(), false,
+ "an unbound presentation is not render-ready")
+T.eq(SecondScreen.detected(), true,
+ "physical display detection is independent of presentation readiness")
+T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary:cover"), true,
+ "extended Android presentation accepts frame metadata")
+T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 },
+ "cover and RGB background reach the native bridge")
+T.eq(calls.target, 2, "secondary routing reaches the optional native bridge")
+T.eq(SecondScreen.push(image, 160, 144, 0x112233, "handheld"), true,
+ "handheld routing remains a contain presentation")
+T.eq(calls.target, 1, "handheld routing reaches the optional native bridge")
+T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true,
+ "contain presentation remains available")
+T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 },
+ "contain is the default native fit")
+T.eq(SecondScreen.push(image, 160, 144, nil, "secondary:cover"), true,
+ "a fit preference can request extended presentation by itself")
+T.same(calls.present, { "pixels", 160, 144, 0, 1 },
+ "preference-only presentation defaults to a black background")
+T.eq(calls.target, 2, "a suffixed route keeps its target")
+T.eq(SecondScreen.push(image, 160, 144), true,
+ "the original push ABI remains available")
+T.same(calls.push, { "pixels", 160, 144 },
+ "legacy callers retain the original frame path")
+
+package.loaded[name] = oldModule
+package.loaded.ffi = oldFfi
+package.preload.ffi = oldPreload
+
+T.finish("Android secondary presentation facade")
diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua
new file mode 100644
index 00000000..83b4da0c
--- /dev/null
+++ b/tests/engine/stat_rise_message_translation_test.lua
@@ -0,0 +1,141 @@
+-- The stat name substituted into X-item/vitamin "rose!" messages, and
+-- Gold's whole Light Screen / Reflect "rose!" messages, must reach a
+-- translation catalog, not just the surrounding sentence template (RBY:
+-- src/inventory/ItemEffects.lua, src/battle/TrainerAI.lua; Gold:
+-- src/battle/gen2/Battle.lua). With no catalog loaded the message stays
+-- English (the existing baseline); with one loaded that translates the
+-- relevant word(s), the substitution must change too -- that is the
+-- actual bug this suite guards against, which passing/failing sentences
+-- alone (as other suites already check) cannot tell apart from text that
+-- never reached Strings() at all.
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Data = T.fixtures.fresh()
+local Pokemon = require("src.pokemon.Pokemon")
+local SaveData = require("src.core.SaveData")
+local ItemEffects = require("src.inventory.ItemEffects")
+local TrainerAI = require("src.battle.TrainerAI")
+local Strings = require("src.core.Strings")
+
+local function withCatalog(catalog, fn)
+ Strings.load({ strings = catalog })
+ local ok, err = pcall(fn)
+ Strings.load(nil)
+ if not ok then error(err, 0) end
+end
+
+-- ------------------------------------------------------- player X-item
+
+local save = SaveData.newGame()
+local player = { name = "FIXMON", stages = {} }
+local xBattle = { player = player, kind = "wild" }
+
+local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
+T.check(baseline[1]:find("ATTACK", 1, true) ~= nil,
+ "X ATTACK's rose! message names the stat in English with no catalog")
+
+withCatalog({ ATTACK = "ATTAQUE" }, function()
+ player.stages.attack = nil
+ local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
+ T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil,
+ "a catalog translating ATTACK reaches the X ATTACK rose! message")
+ T.check(msgs[1]:find("ATTACK", 1, true) == nil,
+ "...and the untranslated English stat name is gone")
+end)
+
+-- --------------------------------------------------------- player vitamin
+
+local target = Pokemon.new(Data, "FIXMON_A", 10)
+withCatalog({ DEFENSE = "DEFENSE_FR" }, function()
+ local _, msgs = ItemEffects.use(Data, save, "IRON", target)
+ T.check(msgs[1]:find("DEFENSE_FR", 1, true) ~= nil,
+ "a catalog translating DEFENSE reaches the IRON (vitamin) rose! message")
+end)
+
+local hpTarget = Pokemon.new(Data, "FIXMON_A", 10)
+withCatalog({ HP = "PV" }, function()
+ local _, msgs = ItemEffects.use(Data, save, "HP_UP", hpTarget)
+ T.check(msgs[1]:find("PV", 1, true) ~= nil,
+ "a catalog translating HP reaches the HP UP rose! message")
+end)
+
+-- ------------------------------------------------------- AI trainer X-item
+
+local enemy = { name = "FOE", stages = {} }
+local aiBattle = { enemy = enemy, trainer = { name = "TRAINER" }, data = Data }
+
+withCatalog({ SPEED = "VITESSE" }, function()
+ local msgs = TrainerAI.useItem(aiBattle, "X_SPEED")
+ T.check(msgs[2]:find("VITESSE", 1, true) ~= nil,
+ "a catalog translating SPEED reaches the AI trainer's X SPEED rose! message")
+end)
+
+-- ------------------------------------------------------- Gold: Light Screen / Reflect
+
+local Gen2Battle = require("src.battle.gen2.Battle")
+local Gen2Mon = require("src.battle.gen2.Mon")
+
+local GEN2_DATA = {
+ pokemon = {
+ MACHOP = {
+ id = "MACHOP", index = 66, name = "MACHOP",
+ baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
+ specialAttack = 35, specialDefense = 35 },
+ types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
+ growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
+ levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
+ },
+ },
+ moves = {
+ TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
+ accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" },
+ },
+ type_chart = { types = { NORMAL = { id = "NORMAL", index = 0,
+ category = "physical" } }, matchups = {} },
+ items = {},
+}
+local perfectDvs = { attack = 15, defense = 15, speed = 15, special = 15 }
+perfectDvs.hp = Gen2Mon.hpDV(perfectDvs)
+
+local function newGen2Battle()
+ local player = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs })
+ player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
+ local wild = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs })
+ wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
+ return Gen2Battle.new({ data = GEN2_DATA, party = { player }, wild = wild })
+end
+
+withCatalog({ ["%s's SPCL.DEF rose!"] = "%s voit sa DEF.SPÉ augmenter !" },
+ function()
+ local lsBattle = newGen2Battle()
+ Gen2Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN(lsBattle, lsBattle.player)
+ local events = lsBattle:takeEvents()
+ local found = false
+ for _, event in ipairs(events) do
+ if event.kind == "message"
+ and event.text:find("DEF.SPÉ augmenter", 1, true) then
+ found = true
+ end
+ end
+ T.check(found,
+ "a catalog translating Light Screen's rose! message reaches it")
+ end)
+
+withCatalog({ ["%s's DEFENSE rose!"] = "%s voit sa DEFENSE augmenter !" },
+ function()
+ local refBattle = newGen2Battle()
+ Gen2Battle.MOVE_EFFECTS.EFFECT_REFLECT(refBattle, refBattle.player)
+ local events = refBattle:takeEvents()
+ local found = false
+ for _, event in ipairs(events) do
+ if event.kind == "message"
+ and event.text:find("DEFENSE augmenter", 1, true) then
+ found = true
+ end
+ end
+ T.check(found,
+ "a catalog translating Reflect's rose! message reaches it")
+ end)
+
+T.finish("stat rise message translation")
diff --git a/tests/engine/wide_battle_shake_bug562.lua b/tests/engine/wide_battle_shake_bug562.lua
index b445748c..ac5c3468 100644
--- a/tests/engine/wide_battle_shake_bug562.lua
+++ b/tests/engine/wide_battle_shake_bug562.lua
@@ -77,6 +77,7 @@ local function battleWith(fx, sprites)
statusHUDVisible = function() return true end,
bottomUIVisible = function() return true end,
caughtMarkerVisible = function() return false end,
+ extendedHUD = function() return false end,
}
end
diff --git a/tests/gen2_battle_items_test.lua b/tests/gen2_battle_items_test.lua
index dbfc8527..186e4596 100644
--- a/tests/gen2_battle_items_test.lua
+++ b/tests/gen2_battle_items_test.lua
@@ -117,6 +117,27 @@ local DATA = {
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" },
OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD",
fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" },
+ -- a mod's own battle-pack item: its action lives only in
+ -- gen2ItemEffects below, which ItemEffects.RECORDS (the module's
+ -- built-in table) has never heard of (#8)
+ MOD_ITEM = item("MOD_ITEM"),
+ },
+ gen2ItemEffects = {
+ -- a status cure rather than an HP heal: HP is exposed to the wild
+ -- mon's own reply once the item spends the turn, which would make a
+ -- direct before/after HP check depend on incidental battle math this
+ -- fix has nothing to do with. Status is not.
+ MOD_ITEM = {
+ action = "status", field = true, needsTarget = true,
+ use = function(ctx)
+ local mon = ctx.mon
+ if mon.status ~= "poison" then
+ return { used = false, text = "It won't have\nany effect." }
+ end
+ mon.status = nil
+ return { used = true, text = "MOD ITEM used!" }
+ end,
+ },
},
}
@@ -236,6 +257,33 @@ do
eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched")
end
+-- ---- a mod's own battle-pack item (#8) -------------------------------------
+-- BattleState:useItem asked ItemEffects.partyAction for the item's family
+-- with no `data` argument, the same omission Game2:usePartyItem had for the
+-- field pack, so a mod item's action -- present only in the merged
+-- gen2ItemEffects table -- resolved to nil and the pack fell straight to
+-- "That isn't going to help here." instead of opening the party list.
+do
+ local sick = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
+ sick.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
+ sick.status = "poison"
+ local screen, _, _, save, pushed = newScreen({
+ player = sick, party = { sick }, inventory = { MOD_ITEM = 1 },
+ })
+ check(runToMenu(screen), "reached the menu")
+
+ screen:useItem("MOD_ITEM")
+ eq(screen.phase, "submenu",
+ "a mod's own gen2ItemEffects record opens UseItem_SelectMon")
+ local picker = pushed[#pushed]
+ eq(getmetatable(picker), PartyMenu, "and the pick is the party screen")
+ if picker and picker.onChoose then
+ picker.onChoose(1, sick)
+ eq(sick.status, nil, "the mod item's own use() ran through the real screens")
+ eq(save.inventory.MOD_ITEM, nil, "and the mod item was spent")
+ end
+end
+
-- ---- IsItemUsedOnConfusedMon: the battle-only arm --------------------------
do
local screen, battle, player, save, pushed = newScreen({
diff --git a/tests/gen2_clock_test.lua b/tests/gen2_clock_test.lua
index 91848816..3d82d007 100644
--- a/tests/gen2_clock_test.lua
+++ b/tests/gen2_clock_test.lua
@@ -18,6 +18,7 @@ require("src.core.Logger").warn = function() end
local Clock = require("src.core.gen2.Clock")
local InitClock = require("src.ui.gen2.InitClock")
+local Strings = require("src.core.Strings")
-- A stub input the screen drives off, the same shape Input:wasPressed has.
local function fakeInput()
@@ -254,4 +255,66 @@ do
end
end
+-- Clock.DAY_NAMES / Clock.weekdayName / Clock.daytimeLabel: the single home
+-- InitClock, MainMenu and the Pokegear clock card all share, so a weekday
+-- cannot be named one way on one screen and another way on the next.
+do
+ eq(Clock.weekdayName(1), "SUNDAY", "1-based, SUNDAY first")
+ eq(Clock.weekdayName(6), "FRIDAY", "and the rest in wCurDay's order")
+ check(Clock.weekdayName(0) == nil, "day 0 is out of range")
+ check(Clock.weekdayName(8) == nil, "and so is day 8")
+
+ eq(Clock.daytimeLabel(4), "MORN", "daytimeLabel matches clockDaytime's word")
+ eq(Clock.daytimeLabel(10), "DAY", "for every hour band")
+ eq(Clock.daytimeLabel(20), "NITE", "including the wrap back to NITE")
+
+ local MainMenu = require("src.ui.gen2.MainMenu")
+ check(MainMenu.DAYS == Clock.DAY_NAMES,
+ "MainMenu reuses the same table InitClock and the Pokegear do")
+end
+
+-- ------------------------------------------------- a translation mod's turn
+--
+-- DAYS, the clockDaytime word and the "o'clock"/"min." suffixes used to
+-- bypass Strings entirely, so a translation mod's `strings` registry had no
+-- seam to catch them: the picker kept printing the English day name and
+-- "o'clock" no matter the catalog (reported from a real Gold build).
+do
+ Strings.load({
+ strings = {
+ SUNDAY = "DIMANCHE",
+ MORN = "MATIN",
+ ["%s o'clock"] = "%s heures",
+ ["%d min."] = "%d min",
+ },
+ })
+
+ local wheel = InitClock.new({ input = fakeInput() }, { mode = "day", save = {} })
+ eq(wheel:display(), "DIMANCHE", "a translated catalog reaches the day wheel")
+
+ eq(InitClock.hourString(4), "MATIN 4",
+ "and the clockDaytime word, through Clock.daytimeLabel")
+ eq(InitClock.oclockString(4), "MATIN 4 heures",
+ "and the o'clock suffix, template and all")
+
+ local minutePicker = InitClock.new({ input = fakeInput() }, { save = {} })
+ minutePicker.phase = "minute"
+ minutePicker.minute = 30
+ eq(minutePicker:display(), "30 min", "and the minutes picker's own suffix")
+
+ -- Palettes.clockDaytime itself must stay untranslated even with a catalog
+ -- loaded: FORCED_DAYTIME and the rest of Palettes.lua's own lookups
+ -- compare against its return value as an internal key, not display text.
+ local Palettes = require("src.world.gen2.Palettes")
+ eq(Palettes.clockDaytime(4), "MORN",
+ "the internal palette key is untouched by the loaded catalog")
+
+ -- Module state is process-global and tests/run_tests.lua runs every suite
+ -- in one process (see tests/mod_strings_tests.lua's own note): leaving the
+ -- catalog loaded would translate the day/hour of every suite after this
+ -- one.
+ Strings.load({})
+ check(not Strings.active(), "the catalog is unloaded for the suites after this one")
+end
+
S.finish()
diff --git a/tests/gen2_field_items_test.lua b/tests/gen2_field_items_test.lua
index c3f9ab68..63e36568 100644
--- a/tests/gen2_field_items_test.lua
+++ b/tests/gen2_field_items_test.lua
@@ -102,6 +102,24 @@ local DATA = {
TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191,
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE",
teaches = "SWIFT" },
+ -- a mod's own field item, whose action lives only in gen2ItemEffects
+ -- below -- ItemEffects.RECORDS (the module's built-in table) has never
+ -- heard of it, so resolving it at all requires the merged dataset (#8)
+ MOD_ITEM = { id = "MOD_ITEM", name = "MOD ITEM", pocket = "ITEM",
+ index = 250, fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" },
+ },
+ gen2ItemEffects = {
+ MOD_ITEM = {
+ action = "heal", field = true, needsTarget = true,
+ use = function(ctx)
+ local mon = ctx.mon
+ if mon.hp >= mon.maxHp then
+ return { used = false, text = "It won't have\nany effect." }
+ end
+ mon.hp = math.min(mon.maxHp, mon.hp + 5)
+ return { used = true, text = "MOD ITEM used!" }
+ end,
+ },
},
gen2MenuGfx = {},
gen2Icons = {
@@ -461,6 +479,24 @@ do
eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent")
end
+do
+ -- #8 regression: Game2:usePartyItem asked ItemEffects.partyAction for the
+ -- item's family with no `data` argument, so it could only ever see
+ -- RECORDS -- the module's own built-ins. A mod's field item, whose
+ -- action exists only in the merged gen2ItemEffects table, resolved to a
+ -- nil action and fell straight through to the "isn't going to help here"
+ -- refusal instead of opening the party list at all.
+ local mon = fixtureMon(12, { hp = 10 })
+ local host = newHost({ MOD_ITEM = 1 }, { mon })
+ host:useFieldItem("MOD_ITEM")
+ local party = host.stack:top()
+ check(party ~= nil and party.prompt ~= nil,
+ "a mod's own gen2ItemEffects record opens the party list")
+ drive(host, function() return host.stack:top() ~= party end)
+ eq(mon.hp, 15, "the mod item's own use() ran through the real menu")
+ eq(host.save.inventory.MOD_ITEM, nil, "and the mod item was spent")
+end
+
do
local mon = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua
index 863fb00a..851d3646 100644
--- a/tests/mod_battle_snapshot_test.lua
+++ b/tests/mod_battle_snapshot_test.lua
@@ -48,7 +48,7 @@ local battle = {
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
}
-function battle:battleKind() return "wild" end
+function battle:battleKind() return self.kind or "wild" end
function battle:effectRecord() return { accuracyChecked = true } end
function battle:visibleText() return { "Wild TESTMON appeared!" } end
function battle:menuLockedAction() return nil end
@@ -63,6 +63,14 @@ function battle:chooseMove(slot)
return true
end
function battle:cancelMove() self.phase = "menu" return true end
+function battle:chooseSafari(action)
+ self.chosenSafari, self.phase = action, "messages"
+ return true
+end
+function battle:chooseMimic(slot)
+ self.chosenMimic, self.phase = slot, "messages"
+ return true
+end
function battle:catchChance(ball)
return require("src.battle.Catching").chance(ball, self.enemy.mon,
game.data.pokemon[self.enemy.mon.species])
@@ -123,6 +131,18 @@ 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")
+battle.kind, battle.safari = "safari", { balls = 30 }
+local safari = api:snapshot()
+check(api:submit({ id = 4, revision = safari.revision,
+ kind = "safari", action = "rock" }), "Gen 1 accepts a Safari action")
+eq(battle.chosenSafari, "rock", "Gen 1 uses the semantic Safari path")
+battle.kind, battle.safari = "wild", nil
+battle.phase, battle.mimicMoves = "mimicSelect", { { slot = 1 } }
+local mimic = api:snapshot()
+check(api:submit({ id = 5, revision = mimic.revision,
+ kind = "mimic", index = 1 }), "Gen 1 accepts a Mimic choice")
+eq(battle.chosenMimic, 1, "Gen 1 uses the semantic Mimic path")
+
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,
@@ -228,6 +248,29 @@ do
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
end
+do
+ local state = setmetatable({ phase = "menu", safari = { balls = 30 },
+ menuIndex = 1 }, { __index = Gen1BattleState })
+ function state:safariAction(action) self.safariChoice = action end
+ local ok, err = state:chooseSafari("missing")
+ check(not ok and err == "invalid safari action",
+ "native Safari rejects an unknown action")
+ check(state:chooseSafari("rock"), "native Safari choice is accepted")
+ eq(state.menuIndex, 3, "native Safari cursor follows the semantic choice")
+ eq(state.safariChoice, "rock", "native Safari action uses the shared path")
+
+ state.phase = "mimicSelect"
+ state.mimicMoves = { { slot = 4 } }
+ state.mimicCtx = { user = {}, target = {}, moveInst = {} }
+ function state:applyMimic(_, _, _, slot) self.mimicSlot = slot end
+ ok, err = state:chooseMimic(2)
+ check(not ok and err == "invalid mimic slot",
+ "native Mimic rejects an unknown choice")
+ check(state:chooseMimic(1), "native Mimic choice is accepted")
+ eq(state.phase, "messages", "native Mimic choice resumes battle messages")
+ eq(state.mimicSlot, 4, "native Mimic choice copies the selected move slot")
+end
+
local Loader = require("src.mods.Loader")
local fs = { read = function() end, getInfo = function() end,
getDirectoryItems = function() return {} end }
diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua
index 27133e15..5f9c06c8 100644
--- a/tests/modkit/cases/sandbox.lua
+++ b/tests/modkit/cases/sandbox.lua
@@ -44,6 +44,11 @@ local PROBE = [[
out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi")
+ -- jit.util exposes bytecode/constant introspection over any function this
+ -- chunk can reach -- the same class of escape the debug library is denied
+ -- for -- so it must fail the same way require("debug") does rather than
+ -- walking straight through under the bare "jit" global's cover.
+ out.requireJitUtil = attempt(require, "jit.util")
out.requireSocket = attempt(require, "socket")
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
@@ -180,6 +185,8 @@ T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _
T.eq(out.debug, nil, "the debug library is still absent")
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
+T.check(out.requireJitUtil ~= false,
+ "require(\"jit.util\") is still refused: it is bytecode/constant introspection")
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua
index 0e90146e..e6e237e7 100644
--- a/tests/modkit/cases/world_field_items.lua
+++ b/tests/modkit/cases/world_field_items.lua
@@ -24,7 +24,11 @@ local redWorld = {
}
local redGame = {
data = { field = { outsideTilesets = { "OVERWORLD" } },
- items = { OLD_ROD = { name = "OLD ROD" } } },
+ items = { OLD_ROD = { name = "OLD ROD" } },
+ maps = { PALLET_TOWN = { index = 0, tileset = "OVERWORLD" },
+ ROUTE_4 = { index = 11, tileset = "OVERWORLD" } },
+ pokemon = { CHANSEY = { name = "CHANSEY" },
+ PIKACHU = { name = "PIKACHU" } } },
save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } },
@@ -42,6 +46,7 @@ T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function"
and type(RedWorld.useFlashFieldMove) == "function"
and type(RedWorld.useStrengthFieldMove) == "function"
+ and type(RedWorld.useSoftboiledFieldMove) == "function"
and type(RedWorld.stopSurfing) == "function",
"Red keeps field-action execution in its world")
local actions = red:availableFieldActions()
@@ -93,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
and redWorld.flashUsed and redWorld.teleportUsed,
"Red delegates every move to its overworld path")
+local source = { species = "CHANSEY", level = 30, hp = 80,
+ stats = { hp = 100 }, moves = { { id = "SOFTBOILED" } } }
+local target = { species = "PIKACHU", level = 20, hp = 10,
+ stats = { hp = 50 }, moves = {} }
+redGame.save.party = { source, target }
+redWorld.useSoftboiledFieldMove = function(self, user, recipient)
+ self.softboiled = { user, recipient }
+ return true
+end
+byId = {}
+for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
+T.check(byId.softboiled and byId.softboiled.sources[1].targets[1].slot == 2,
+ "Red lists only valid SOFTBOILED targets")
+T.check(red:useFieldAction("softboiled", { sourceSlot = 1, targetSlot = 2 }),
+ "Red accepts a listed SOFTBOILED transfer")
+T.check(redWorld.softboiled[1] == source and redWorld.softboiled[2] == target,
+ "Red delegates SOFTBOILED to its overworld path")
+ok, err = red:useFieldAction("softboiled", { sourceSlot = 2, targetSlot = 1 })
+T.check(not ok and err == "softboiled target unavailable",
+ "Red rejects an invalid SOFTBOILED source")
+
+redGame.save.inventory.THUNDERBADGE = 1
+redGame.save.visited = { PALLET_TOWN = true, ROUTE_4 = true }
+redGame.data.field.flyOrder = { "PALLET_TOWN", "ROUTE_4" }
+redGame.data.field.flyWarps = { PALLET_TOWN = true, ROUTE_4 = true }
+redMoves.FLY = source
+redWorld.flyTo = function(self, mapId) self.flewTo = mapId end
+T.check(red:canFly(), "Red exposes FLY only in a valid outdoor context")
+T.check(red:flyTo("PALLET_TOWN") and redWorld.flewTo == "PALLET_TOWN",
+ "Red validates and delegates a visited FLY destination")
+ok, err = red:flyTo("ROUTE_4")
+T.check(not ok and err == "destination unavailable",
+ "Red rejects a fly warp that is not a native town destination")
+
redSurf = "dismount"
redWorld.player.surfing = true
redWorld.stopSurfing = function(self) self.dismounted = true end
diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua
index 365ec70e..db9474d8 100644
--- a/tests/save_editor_gen2_tests.lua
+++ b/tests/save_editor_gen2_tests.lua
@@ -324,6 +324,23 @@ do
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets")
+
+ -- bindGoldData bound gen2Palettes/gen2Icons/gen2Pokedex/gen2Landmarks/
+ -- gen2Roofs/gen2Sprites through loadGen but never gen2Constants, so any
+ -- mod reading mod.content.constants:get(...) under a save-editor Gold
+ -- bootstrap saw an empty table where it expected the cart's ordered name
+ -- lists. loadGen falls back to require("data.generated.constants") when
+ -- the ROM cache has nothing active, which is what a checkout with no
+ -- ROM imported hits too -- stub that module the same way to prove the
+ -- wiring without needing a real Gold extraction.
+ package.loaded["data.generated.constants"] = { badges = { "ZEPHYR" } }
+ local withConstants = Gen.bindGoldData({})
+ package.loaded["data.generated.constants"] = nil
+ check(withConstants.gen2Constants ~= nil,
+ "bindGoldData populates gen2Constants")
+ check(withConstants.gen2Constants and withConstants.gen2Constants.badges
+ and withConstants.gen2Constants.badges[1] == "ZEPHYR",
+ "gen2Constants carries the extractor's own name lists")
end
do
diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua
index 695aa9b8..56258a85 100644
--- a/tools/save-editor/Gen.lua
+++ b/tools/save-editor/Gen.lua
@@ -95,6 +95,12 @@ function Gen.bindGoldData(data)
end
data.gen2Palettes = data.gen2Palettes or loadGen("palettes")
+ -- Namespaced AND differently shaped in Schemas.GEN2 (the cart's ordered
+ -- name lists, not Gen 1's rule table), same as palettes/icons below --
+ -- omitting it left mod.content.constants:get(...) reading an empty table
+ -- under a Gold save-editor boot, which is what misreads "generation" and
+ -- rejects every record a mod shapes off it.
+ data.gen2Constants = data.gen2Constants or loadGen("constants")
data.gen2Icons = data.gen2Icons or loadGen("icons")
data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")
diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua
index 36d13c64..77ba080a 100644
--- a/tools/save-editor/Ops.lua
+++ b/tools/save-editor/Ops.lua
@@ -430,7 +430,7 @@ function Ops.setDv(S, mon, key, value)
end
function Ops.cycleMove(S, mon, slot)
- if not mon then return false end
+ if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end
local moves = S.cat.moves
local current = mon.moves and mon.moves[slot] and mon.moves[slot].id
local idx = 0
@@ -439,9 +439,14 @@ function Ops.cycleMove(S, mon, slot)
if id == current then idx = i break end
end
end
- local nextId = moves[(idx % #moves) + 1]
- MonOps.setMove(S.data, mon, slot, nextId)
- return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
+ for step = 1, #moves do
+ local nextId = moves[((idx + step - 1) % #moves) + 1]
+ if S.data and S.data.moves and S.data.moves[nextId] then
+ MonOps.setMove(S.data, mon, slot, nextId)
+ return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
+ end
+ end
+ return false
end
function Ops.clearMove(S, mon, slot)