Merge pull request #1460 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-08-16 22:28:20 -04:00
committed by GitHub
84 changed files with 3867 additions and 527 deletions
+15 -2
View File
@@ -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
+1 -1
View File
@@ -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
+61 -14
View File
@@ -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
+13 -1
View File
@@ -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()
@@ -31,6 +31,9 @@
android:allowBackup="true"
android:icon="@drawable/love"
android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
@@ -49,5 +52,15 @@
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
</activity>
<activity
android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="${ORIENTATION}"
android:taskAffinity="${applicationId}.secondary"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application>
</manifest>
@@ -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()
{
@@ -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
@@ -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
@@ -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:
@@ -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 },
@@ -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<String> 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);
}
}
+9 -2
View File
@@ -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"
+70 -7
View File
@@ -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
+10 -1
View File
@@ -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)))
+51 -8
View File
@@ -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)
+2 -2
View File
@@ -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
+7 -1
View File
@@ -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)
+51 -11
View File
@@ -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
+28 -19
View File
@@ -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
+47 -3
View File
@@ -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
+8 -2
View File
@@ -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
+5
View File
@@ -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
+7 -7
View File
@@ -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()
+43
View File
@@ -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
+64 -7
View File
@@ -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"))
+1 -1
View File
@@ -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
+16 -3
View File
@@ -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
+2
View File
@@ -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()
+1 -1
View File
@@ -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
+22 -4
View File
@@ -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
+62 -7
View File
@@ -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)),
},
+125
View File
@@ -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
+137
View File
@@ -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
+185
View File
@@ -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
+97 -12
View File
@@ -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
+67 -6
View File
@@ -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
+16 -1
View File
@@ -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
+42 -1
View File
@@ -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
+2 -16
View File
@@ -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]
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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
+17 -9
View File
@@ -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
+6 -5
View File
@@ -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
+3 -7
View File
@@ -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:
+2 -1
View File
@@ -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)
+4 -3
View File
@@ -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
+42 -7
View File
@@ -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
+25
View File
@@ -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).
+19 -1
View File
@@ -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,
+91 -2
View File
@@ -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).
+203 -279
View File
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
@@ -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")
@@ -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()
+9 -1
View File
@@ -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")
+56
View File
@@ -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")
+57
View File
@@ -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")
+26
View File
@@ -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
+32
View File
@@ -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")
+31
View File
@@ -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()
+101
View File
@@ -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()
+15
View File
@@ -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")
@@ -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")
@@ -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()
+101
View File
@@ -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")
+70
View File
@@ -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")
@@ -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")
@@ -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
+48
View File
@@ -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({
+63
View File
@@ -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()
+36
View File
@@ -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 } })
+44 -1
View File
@@ -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 }
+7
View File
@@ -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")
+40 -1
View File
@@ -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
+17
View File
@@ -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
+6
View File
@@ -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")
+9 -4
View File
@@ -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)