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)