diff --git a/docs/gameboy-hardware-limitations.md b/docs/gameboy-hardware-limitations.md
index c5da32f6..53f740fd 100644
--- a/docs/gameboy-hardware-limitations.md
+++ b/docs/gameboy-hardware-limitations.md
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
-| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
+| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
+- Mods may patch `constants.bagSize` through the public content registry. The
+ native `save.lua` format keeps every existing item when the configured
+ limit changes; exporting to a cartridge `.sav` still writes only the first
+ 20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
diff --git a/docs/modding.md b/docs/modding.md
index 5517a3a8..5aa0bec0 100644
--- a/docs/modding.md
+++ b/docs/modding.md
@@ -187,5 +187,18 @@ 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.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
+normal single-window composite so the mod can decorate around it. `ctx` carries
+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)` / `setEnabled`)
+for driving a second physical display. 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.
+
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
diff --git a/docs/new-features.md b/docs/new-features.md
index 662b2459..9480a6af 100644
--- a/docs/new-features.md
+++ b/docs/new-features.md
@@ -380,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
- cycled one id at a time through ~250 items), the 20-slot bag, PC storage
+ cycled one id at a time through ~250 items), the configurable bag (20 slots
+ by default), PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md
index dfc17853..b0bc4479 100644
--- a/mobile/ANDROID.md
+++ b/mobile/ANDROID.md
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
+### Step bridge (Pokéwalker mod)
+
+`love.system.syncHealthSteps()` → `GameActivity.syncHealthSteps` (same
+JNI route as the picker: `common/android.cpp` →
+`modules/system/System.cpp` → `wrap_System.cpp`). The Java side does a
+one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
+since boot, counted by the OS whether or not any app runs), anchors the
+reading in `SharedPreferences` so a walk is never credited twice
+(a reading below the anchor means the phone rebooted → re-anchor without
+crediting), and stages the delta as `steps_pending.json` in the save
+identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
+the base game calls it; the consumer is the
+[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
+installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
+off).
+
+Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
+permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
+of the build script's permission trim). The first
+`syncHealthSteps()` call shows the system prompt; on grant the sensor
+read runs immediately (`onRequestPermissionsResult`,
+`STEP_PERMISSION_REQUEST_CODE`).
+
### SDK / NDK
love-android 11.5a expects:
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 1f39040d..5cf7b38a 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
+
+
diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp
index 7f0cfdca..7755f891 100644
--- a/mobile/android/love/src/jni/love/src/common/android.cpp
+++ b/mobile/android/love/src/jni/love/src/common/android.cpp
@@ -219,6 +219,18 @@ bool showCreateDocument(const char *suggestedName)
return result;
}
+bool syncHealthSteps()
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+
+ jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
+ jboolean result = env->CallStaticBooleanMethod(activity, method);
+
+ env->DeleteLocalRef(activity);
+ return result;
+}
+
/*
* Helper functions for the filesystem module
*/
@@ -851,4 +863,51 @@ const char *getArg0()
} // android
} // love
+extern "C" __attribute__((visibility("default")))
+int love_android_secondary_ready()
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
+ jboolean ready = JNI_FALSE;
+ if (m)
+ ready = env->CallStaticBooleanMethod(activity, m);
+ else
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+ return ready ? 1 : 0;
+}
+
+extern "C" __attribute__((visibility("default")))
+void love_android_push_secondary(const void *rgba, int w, int h)
+{
+ if (!rgba || w <= 0 || h <= 0)
+ return;
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
+ if (m)
+ {
+ jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
+ env->CallStaticVoidMethod(activity, m, buf, w, h);
+ env->DeleteLocalRef(buf);
+ }
+ else
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+}
+
+extern "C" __attribute__((visibility("default")))
+void love_android_secondary_enable(int on)
+{
+ JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
+ jclass activity = env->FindClass("org/love2d/android/GameActivity");
+ jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
+ if (m)
+ env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
+ else
+ env->ExceptionClear();
+ env->DeleteLocalRef(activity);
+}
+
#endif // LOVE_ANDROID
diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h
index 10cd0802..63f93029 100644
--- a/mobile/android/love/src/jni/love/src/common/android.h
+++ b/mobile/android/love/src/jni/love/src/common/android.h
@@ -74,6 +74,13 @@ bool showFilePicker(const char *destFilename = nullptr);
**/
bool showCreateDocument(const char *suggestedName = nullptr);
+/**
+ * Pokéwalker step bridge: asks GameActivity to read the hardware step
+ * counter and stage steps_pending.json in the save identity dir (see
+ * GameActivity.syncHealthSteps). Returns whether a sync could start.
+ */
+bool syncHealthSteps();
+
/*
* Helper functions for the filesystem module
*/
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
index 0a844e58..0ca2a484 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp
@@ -212,6 +212,15 @@ bool System::createFile(const char *suggestedName) const
#endif
}
+bool System::syncHealthSteps() const
+{
+#ifdef LOVE_ANDROID
+ return love::android::syncHealthSteps();
+#else
+ return false;
+#endif
+}
+
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h
index c3a174fa..8ef6aee4 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/System.h
+++ b/mobile/android/love/src/jni/love/src/modules/system/System.h
@@ -127,6 +127,12 @@ public:
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
+ /**
+ * Pokéwalker: stage pending real-world steps (steps_pending.json in the
+ * save dir) from the platform step source. Android-only; false elsewhere.
+ */
+ virtual bool syncHealthSteps() const;
+
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
index 07d572a1..9127dbb2 100644
--- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
+++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp
@@ -109,6 +109,12 @@ int w_createFile(lua_State *L)
return 1;
}
+int w_syncHealthSteps(lua_State *L)
+{
+ luax_pushboolean(L, instance()->syncHealthSteps());
+ return 1;
+}
+
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +132,7 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
+ { "syncHealthSteps", w_syncHealthSteps },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
index abe2bad9..11c55125 100644
--- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
+++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java
@@ -41,13 +41,20 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
import android.os.Vibrator;
import android.util.Log;
import android.util.DisplayMetrics;
@@ -67,6 +74,7 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
+ public static final int STEP_PERMISSION_REQUEST_CODE = 6;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -83,6 +91,17 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
+ // Step bridge (love.system.syncHealthSteps): pending-steps delivery
+ // consumed by the Pokéwalker mod, same contract as the iOS
+ // GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
+ // (cumulative since boot, counted by the OS whether or not any app is
+ // running), anchored in SharedPreferences so a walk is never credited
+ // twice.
+ private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
+ private static final String STEP_PREFS = "pokewalker_steps";
+ private static final String STEP_PREF_ANCHOR = "anchor";
+ private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
+ private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
@@ -312,12 +331,14 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
+ teardownSecondaryDisplay();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
+ setupSecondaryDisplay();
}
/**
@@ -532,6 +553,160 @@ public class GameActivity extends SDLActivity {
}
}
+ /**
+ * Step sync, called from Lua as love.system.syncHealthSteps()
+ * (see modules/system/wrap_System.cpp). Asynchronous like the picker:
+ * returns whether a sync could be started; the result lands later as
+ * steps_pending.json in the save identity dir, where the Pokéwalker
+ * mod's poll consumes it.
+ *
+ * Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
+ * runtime permission; the first call shows the system prompt and a later
+ * sync (the mod retries on save load / option change) delivers.
+ */
+ @Keep
+ public static boolean syncHealthSteps() {
+ final GameActivity self = (GameActivity) mSingleton;
+ if (self == null) return false;
+ if (android.os.Build.VERSION.SDK_INT >= 29
+ && ActivityCompat.checkSelfPermission(self,
+ Manifest.permission.ACTIVITY_RECOGNITION)
+ != PackageManager.PERMISSION_GRANTED) {
+ self.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ ActivityCompat.requestPermissions(self,
+ new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
+ STEP_PERMISSION_REQUEST_CODE);
+ }
+ });
+ return true;
+ }
+ self.startStepSensorRead();
+ return true;
+ }
+
+ /**
+ * One-shot read of the cumulative hardware step counter. The sensor
+ * usually reports its cached value moments after registration; some
+ * devices hold the event until the next physical step, so the listener
+ * is given 20 seconds before being torn down (the next sync retries).
+ */
+ private void startStepSensorRead() {
+ final SensorManager manager =
+ (SensorManager) getSystemService(Context.SENSOR_SERVICE);
+ if (manager == null) return;
+ Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
+ if (counter == null) {
+ Log.d("GameActivity", "no step counter sensor on this device");
+ return;
+ }
+ final SensorEventListener listener = new SensorEventListener() {
+ private boolean delivered = false;
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ if (delivered || event.values.length == 0) return;
+ delivered = true;
+ manager.unregisterListener(this);
+ deliverSteps((long) event.values[0]);
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ }
+ };
+ if (!manager.registerListener(listener, counter,
+ SensorManager.SENSOR_DELAY_NORMAL)) {
+ Log.d("GameActivity", "step counter listener registration failed");
+ return;
+ }
+ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ // No-op if the listener already delivered and unregistered.
+ manager.unregisterListener(listener);
+ }
+ }, 20000);
+ }
+
+ /**
+ * Convert a cumulative counter reading into pending steps. The counter
+ * resets to zero on reboot: a reading below the stored anchor re-anchors
+ * without crediting (steps walked between the reboot and this sync are
+ * lost, which errs on the honest side).
+ */
+ private void deliverSteps(long counterNow) {
+ SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
+ long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
+ long now = System.currentTimeMillis();
+ if (anchor < 0 || counterNow < anchor) {
+ prefs.edit()
+ .putLong(STEP_PREF_ANCHOR, counterNow)
+ .putLong(STEP_PREF_ANCHOR_WALLTIME, now)
+ .apply();
+ Log.d("GameActivity", "step anchor set at " + counterNow);
+ return;
+ }
+ long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
+ long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
+ if (steps <= 0) return;
+ prefs.edit()
+ .putLong(STEP_PREF_ANCHOR, counterNow)
+ .putLong(STEP_PREF_ANCHOR_WALLTIME, now)
+ .apply();
+
+ File dir = saveIdentityDir();
+ if (!dir.isDirectory() && !dir.mkdirs()) {
+ Log.d("GameActivity", "cannot create save dir for steps: " + dir);
+ return;
+ }
+ File pending = new File(dir, PENDING_STEPS_FILENAME);
+ long total = steps;
+ // Merge with an unconsumed earlier delivery so steps are never lost
+ // (same contract as the iOS bridge).
+ if (pending.isFile()) {
+ try {
+ byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
+ FileInputStream in = new FileInputStream(pending);
+ int read = in.read(raw);
+ in.close();
+ if (read > 0) {
+ org.json.JSONObject old =
+ new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
+ total += Math.max(0, old.optLong("steps", 0));
+ }
+ } catch (Exception e) {
+ Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
+ }
+ }
+ try {
+ org.json.JSONObject payload = new org.json.JSONObject();
+ payload.put("steps", total);
+ payload.put("from", isoTime(fromWalltime));
+ payload.put("to", isoTime(now));
+ File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
+ FileOutputStream out = new FileOutputStream(tmp);
+ out.write(payload.toString().getBytes("UTF-8"));
+ out.close();
+ if (!tmp.renameTo(pending)) {
+ tmp.delete();
+ Log.d("GameActivity", "could not publish pending steps");
+ return;
+ }
+ Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
+ } catch (Exception e) {
+ Log.d("GameActivity", "could not write pending steps: " + e);
+ }
+ }
+
+ private static String isoTime(long millis) {
+ java.text.SimpleDateFormat format =
+ new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
+ format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
+ return format.format(new java.util.Date(millis));
+ }
+
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -762,6 +937,17 @@ public class GameActivity extends SDLActivity {
}
break;
}
+ case STEP_PERMISSION_REQUEST_CODE: {
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ Log.d("GameActivity", "Step permission granted");
+ // Deliver right away so the sync the player just
+ // opted into doesn't wait for the next launch.
+ startStepSensorRead();
+ } else {
+ Log.d("GameActivity", "Did not get step permission.");
+ }
+ break;
+ }
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@@ -944,4 +1130,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
+
+ // 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 volatile SecondaryPresentation secondaryPresentation;
+ private static volatile boolean secondaryEnabled = false;
+
+ @Keep
+ public static void setSecondaryEnabled(final boolean on) {
+ secondaryEnabled = on;
+ final GameActivity self = (GameActivity) mSingleton;
+ if (self == null) return;
+ self.runOnUiThread(new Runnable() {
+ @Override public void run() {
+ if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
+ }
+ });
+ }
+
+ private static void setupSecondaryDisplay() {
+ GameActivity self = (GameActivity) mSingleton;
+ if (self == null || !secondaryEnabled || secondaryPresentation != null) 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];
+ }
+ if (chosen == null) {
+ Log.d("GameActivity", "no secondary display found");
+ return;
+ }
+ SecondaryPresentation p = new SecondaryPresentation(self, chosen);
+ p.show();
+ secondaryPresentation = p;
+ Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
+ } catch (Throwable t) {
+ Log.d("GameActivity", "secondary display setup failed: " + t);
+ secondaryPresentation = null;
+ }
+ }
+
+ private static void teardownSecondaryDisplay() {
+ SecondaryPresentation p = secondaryPresentation;
+ secondaryPresentation = null;
+ if (p != null) {
+ try { p.dismiss(); } catch (Throwable t) {}
+ }
+ }
+
+ @Keep
+ public static boolean hasSecondaryDisplay() {
+ return secondaryPresentation != null;
+ }
+
+ @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);
+ }
+ }
+
+ private static class SecondaryPresentation extends android.app.Presentation {
+ private final FrameView frameView;
+
+ SecondaryPresentation(Context context, Display display) {
+ super(context, display);
+ frameView = new FrameView(context);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ android.view.Window w = getWindow();
+ if (w != null) {
+ w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
+ w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT);
+ }
+ setContentView(frameView);
+ applyImmersive();
+ frameView.post(new Runnable() {
+ @Override public void run() { applyImmersive(); }
+ });
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus) applyImmersive();
+ }
+
+ 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);
+ }
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
+ frameView.updateFrame(buf, w, h);
+ }
+ }
+
+ private static class FrameView extends View {
+ private android.graphics.Bitmap bitmap;
+ private final android.graphics.Rect dst = new android.graphics.Rect();
+ private final android.graphics.Paint paint = new android.graphics.Paint();
+ private final Object lock = new Object();
+ private int fw, fh;
+
+ FrameView(Context context) {
+ super(context);
+ paint.setFilterBitmap(false);
+ paint.setAntiAlias(false);
+ setBackgroundColor(0xFF000000);
+ }
+
+ void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
+ synchronized (lock) {
+ 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);
+ fw = w; fh = h;
+ }
+ buf.rewind();
+ bitmap.copyPixelsFromBuffer(buf);
+ }
+ postInvalidate();
+ }
+
+ @Override
+ protected void onDraw(android.graphics.Canvas canvas) {
+ 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;
+ int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
+ dst.set(dx, dy, dx + dw, dy + dh);
+ canvas.drawColor(0xFF000000);
+ canvas.drawBitmap(bitmap, null, dst, paint);
+ }
+ }
+ }
}
diff --git a/mods/nuzlocke/main.lua b/mods/nuzlocke/main.lua
index e77da325..e4d0dacf 100644
--- a/mods/nuzlocke/main.lua
+++ b/mods/nuzlocke/main.lua
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
- Bag.add(self.game.save, ball, 1)
+ Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
diff --git a/scripts/build_android.sh b/scripts/build_android.sh
index cc4c8232..417ff535 100755
--- a/scripts/build_android.sh
+++ b/scripts/build_android.sh
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
+ # Deliberately NO fused mods: a mod inside game.love sits in the read-only
+ # APK, so the mod manager's Delete can't remove it and it reappears every
+ # launch. Pokewalker ships as an importable .zip instead, which gives it
+ # a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
- say "building APK ($task)"
+ local build_dir="$ANDROID_DIR"
+ # ndk-build is GNU make underneath and cannot cope with spaces anywhere in
+ # the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
+ # When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
+ # shadow the android tree to a space-free location and build there; the
+ # shadow persists across runs so gradle/ndk builds stay incremental.
+ case "$ANDROID_DIR" in
+ *" "*)
+ build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
+ say "path contains spaces (ndk-build cannot handle them);"
+ say "shadow-building in: $build_dir"
+ mkdir -p "$build_dir"
+ rsync -a --delete \
+ --exclude=".gradle" --exclude="app/build" --exclude="love/build" \
+ --exclude="local.properties" \
+ "$ANDROID_DIR/" "$build_dir/"
+ if [ -f "$ANDROID_DIR/local.properties" ]; then
+ cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
+ fi
+ ;;
+ esac
+
+ say "building APK ($task)"
if ! (
- cd "$ANDROID_DIR"
+ cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
- local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
+ local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua
index 35a29a27..21eaf971 100644
--- a/src/battle/BattleState.lua
+++ b/src/battle/BattleState.lua
@@ -2764,10 +2764,24 @@ function BattleState:updateFx()
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
- if self.lowHealthAlarmOn then
- Sound.startLoop(self.data, "Low_Health_Alarm")
+ -- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
+ -- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
+ -- each frame; a mod can wrap this to reshape the toggle (e.g. force
+ -- ctx.on false after some budget) before letting vanilla act on it.
+ if Runtime.wantsHook("battle.low_health_alarm") then
+ Runtime.call("battle.low_health_alarm", function(ctx)
+ if ctx.on then
+ Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
+ else
+ Sound.stopLoop("Low_Health_Alarm")
+ end
+ end, { on = self.lowHealthAlarmOn, battle = self })
else
- Sound.stopLoop("Low_Health_Alarm")
+ if self.lowHealthAlarmOn then
+ Sound.startLoop(self.data, "Low_Health_Alarm")
+ else
+ Sound.stopLoop("Low_Health_Alarm")
+ end
end
end
@@ -3303,7 +3317,10 @@ function BattleState:onFaint(battler)
end
end
-function BattleState:enemyMonFainted()
+-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
+-- and, when a mod's battle.catch_exp hook says so, the catch path
+-- (storeCaughtMon).
+function BattleState:awardExp()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
@@ -3381,27 +3398,44 @@ function BattleState:enemyMonFainted()
end
end
end
- -- with EXP.ALL, participants split half the exp and the other half
- -- is divided among the whole party (engine/battle/experience.asm)
- local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
- for _, mon in ipairs(alive) do
- applyShare(mon, participants * (expAll and 2 or 1), true)
- end
- if expAll then
- -- the second GainExperience pass sets the gain flags for the WHOLE
- -- party, so DivideExpDataByNumMonsGainingExp divides the already
- -- halved-and-participant-divided exp again by the party count, and
- -- .partyMonLoop still skips fainted mons (core.asm:818-858 +
- -- experience.asm:9-13); each mon gets its own GainedText with the
- -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
- -- summary line
- for _, mon in ipairs(self.game.save.party) do
- if mon.hp > 0 then
- applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
+ -- battle.exp_award: the participant/EXP.ALL split, factored out so a
+ -- mod can replace it wholesale (e.g. a flat undivided share to every
+ -- non-fainted party mon) without re-deriving participants/alive.
+ -- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
+ local function vanillaExpAward(ctx)
+ -- with EXP.ALL, participants split half the exp and the other half
+ -- is divided among the whole party (engine/battle/experience.asm)
+ local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
+ for _, mon in ipairs(ctx.alive) do
+ ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
+ end
+ if expAll then
+ -- the second GainExperience pass sets the gain flags for the WHOLE
+ -- party, so DivideExpDataByNumMonsGainingExp divides the already
+ -- halved-and-participant-divided exp again by the party count, and
+ -- .partyMonLoop still skips fainted mons (core.asm:818-858 +
+ -- experience.asm:9-13); each mon gets its own GainedText with the
+ -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
+ -- summary line
+ for _, mon in ipairs(self.game.save.party) do
+ if mon.hp > 0 then
+ ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
+ end
end
end
end
+ local awardCtx = { battle = self, participants = participants, alive = alive,
+ applyShare = applyShare }
+ if Runtime.wantsHook("battle.exp_award") then
+ Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
+ else
+ vanillaExpAward(awardCtx)
+ end
self.participants = {}
+end
+
+function BattleState:enemyMonFainted()
+ self:awardExp()
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
@@ -3974,6 +4008,12 @@ function BattleState:storeCaughtMon()
-- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy)
+ -- battle.catch_exp: vanilla catches never grant exp; a mod can flip
+ -- this to true to pay out the same award a faint would have.
+ if Runtime.wantsHook("battle.catch_exp")
+ and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
+ self:awardExp()
+ end
local game = self.game
local dex = game.save.pokedex
local species = self.enemy.mon.species
diff --git a/src/core/Data.lua b/src/core/Data.lua
index ae292d40..13c0a1dd 100644
--- a/src/core/Data.lua
+++ b/src/core/Data.lua
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
--- The rules the engine still carries as literals. The constants registry
--- deep-merges over these, so a value has to exist before a mod can patch
--- it; each one is the number the engine hard-codes today, so seeding them
--- changes nothing on a mod-free boot.
+-- Vanilla defaults for rules exposed through the constants registry. A
+-- value has to exist before a mod can patch it; each one matches the
+-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
+-- boot.
local CONSTANT_DEFAULTS = {
- bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
+ bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua
new file mode 100644
index 00000000..86c404fb
--- /dev/null
+++ b/src/core/SafeArea.lua
@@ -0,0 +1,40 @@
+-- Usable window rect for mobile chrome (notch / Dynamic Island / home
+-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
+-- the engine provides it; otherwise the full graphics window.
+--
+-- Desktop and headless stubs return the full window, so callers can always
+-- layout against this rect without platform branches. Interactive chrome
+-- (touch overlay, launcher) should prefer this over getDimensions; the game
+-- canvas may still letterbox into the full framebuffer for immersion.
+
+local SafeArea = {}
+
+function SafeArea.rect()
+ local ww, wh = 0, 0
+ if love and love.graphics and love.graphics.getDimensions then
+ ww, wh = love.graphics.getDimensions()
+ end
+ if ww <= 0 then ww = 1 end
+ if wh <= 0 then wh = 1 end
+
+ if not (love and love.window and love.window.getSafeArea) then
+ return 0, 0, ww, wh
+ end
+
+ local x, y, w, h = love.window.getSafeArea()
+ if type(x) ~= "number" or type(y) ~= "number"
+ or type(w) ~= "number" or type(h) ~= "number"
+ or w <= 0 or h <= 0 then
+ return 0, 0, ww, wh
+ end
+
+ -- Clamp to the drawable window so a bad / mid-rotation backend cannot
+ -- push layout outside the surface.
+ x = math.max(0, math.min(x, ww))
+ y = math.max(0, math.min(y, wh))
+ w = math.max(1, math.min(w, ww - x))
+ h = math.max(1, math.min(h, wh - y))
+ return x, y, w, h
+end
+
+return SafeArea
diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua
index 7d537f3c..cc730b58 100644
--- a/src/core/SaveData.lua
+++ b/src/core/SaveData.lua
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
- or not Bag.add(save, entry.id, entry.count or 1) then
+ or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua
index 50178e7c..8678dce8 100644
--- a/src/core/TouchControls.lua
+++ b/src/core/TouchControls.lua
@@ -23,6 +23,7 @@
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
+local SafeArea = require("src.core.SafeArea")
local TouchControls = {}
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
return out
end
--- Pure default layout in LOVE units for a given window size. Shared by
--- layout() and the editor's Reset path so defaults stay in one place.
-function TouchControls.defaultLayout(ww, wh)
+-- Pure default layout in LOVE units for a usable rect of size ww x wh at
+-- origin (ox, oy). Shared by layout() and the editor's Reset path so
+-- defaults stay in one place. ox/oy default to 0 for the headless tests
+-- and for callers that already pass a full-window size.
+function TouchControls.defaultLayout(ww, wh, ox, oy)
+ ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
- dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
- a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
- b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
- start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
- select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
+ dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
+ a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
+ b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
+ start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
+ select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
}
end
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
+ self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
@@ -185,30 +190,37 @@ function TouchControls:visible()
and not self.controllerHidden
end
-local function clampZone(zone, ww, wh)
+-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
+local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
- zone.cx = math.max(half, math.min(ww - half, zone.cx))
- zone.cy = math.max(half, math.min(wh - half, zone.cy))
+ zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
+ zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
--- the window size changes (rotation, resize). Default: d-pad bottom-left,
--- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
--- flanking the bottom center. Custom positions (normalized 0..1) override
--- centers while sizes stay derived from the short edge.
+-- the window or safe area changes (rotation, resize, notch insets).
+-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
+-- diagonal), START/SELECT flanking the bottom center -- all inside the
+-- device safe area so thumbs clear the home indicator / cutouts.
+-- Custom positions (normalized 0..1 within the safe rect) override centers
+-- while sizes stay derived from the short edge.
function TouchControls:layout()
- local ww, wh = love.graphics.getDimensions()
- if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
- self.layoutW, self.layoutH = ww, wh
- self.L = TouchControls.defaultLayout(ww, wh)
+ local ox, oy, sw, sh = SafeArea.rect()
+ if self.layoutW == sw and self.layoutH == sh
+ and self.layoutOx == ox and self.layoutOy == oy and self.L then
+ return self.L
+ end
+ self.layoutW, self.layoutH = sw, sh
+ self.layoutOx, self.layoutOy = ox, oy
+ self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
- zone.cx = p.x * ww
- zone.cy = p.y * wh
- clampZone(zone, ww, wh)
+ zone.cx = ox + p.x * sw
+ zone.cy = oy + p.y * sh
+ clampZone(zone, ox, oy, ox + sw, oy + sh)
end
end
end
@@ -222,21 +234,25 @@ function TouchControls:layout()
end
-- Move one control to a screen-space point and persist its normalized
--- position. Used by the layout editor while dragging.
+-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
- local ww, wh = love.graphics.getDimensions()
+ local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
- clampZone(zone, ww, wh)
+ clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
- self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
+ self.positions[name] = {
+ x = sw > 0 and (zone.cx - ox) / sw or 0,
+ y = sh > 0 and (zone.cy - oy) / sh or 0,
+ }
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
+ self.layoutOx, self.layoutOy = nil, nil
end
local function inCircle(zone, x, y, slop)
diff --git a/src/dev/Console.lua b/src/dev/Console.lua
index d4f92106..5b9f1909 100644
--- a/src/dev/Console.lua
+++ b/src/dev/Console.lua
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end
elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1
- if require("src.inventory.Bag").add(save, id, n) then
+ if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n))
else
self:print("bag full")
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 8ee63c0e..18f9099d 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -1,6 +1,7 @@
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
+local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
@@ -1302,10 +1303,10 @@ local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor()
if self._padCursorActive then return end
- local w, h = love.graphics.getDimensions()
+ local ox, oy, w, h = SafeArea.rect()
if not self._padInited then
- self._padCursor.x = w * 0.5
- self._padCursor.y = h * 0.45
+ self._padCursor.x = ox + w * 0.5
+ self._padCursor.y = oy + h * 0.45
self._padInited = true
end
self._padCursorActive = true
@@ -1351,11 +1352,11 @@ function RomImporter:_updatePadCursor(dt)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
- local w, h = love.graphics.getDimensions()
+ local ox, oy, w, h = SafeArea.rect()
local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt
- self._padCursor.x = math.max(0, math.min(w, nx))
- self._padCursor.y = math.max(0, math.min(h, ny))
+ self._padCursor.x = math.max(ox, math.min(ox + w, nx))
+ self._padCursor.y = math.max(oy, math.min(oy + h, ny))
end
-- Right stick scrolls the active list (save slots or mods), or the whole page
@@ -1733,7 +1734,10 @@ function RomImporter:_resetFrameRects()
end
function RomImporter:draw()
- local width, height = love.graphics.getDimensions()
+ -- Full window for immersive backdrop; safe rect for interactive chrome so
+ -- notch / Dynamic Island / home indicator / Android cutouts are respected.
+ local fullW, fullH = love.graphics.getDimensions()
+ local ox, oy, width, height = SafeArea.rect()
local s = clamp(height / 768, 0.7, 1.6)
local pulse = self.pulse
self._s = s
@@ -1752,8 +1756,9 @@ function RomImporter:draw()
self._anyHover = false
self:_resetFrameRects()
- -- Fonts + size-dependent scenery, rebuilt only when the window size changes.
- local fontKey = ("%dx%d"):format(width, height)
+ -- Fonts + size-dependent scenery, rebuilt only when the window / safe
+ -- area changes (rotation, resize, inset changes).
+ local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
if self.fontKey ~= fontKey then
self.fontKey = fontKey
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
@@ -1777,10 +1782,10 @@ function RomImporter:draw()
-- Background: a radial gradient (bright navy at top-centre -> near black).
-- A triangle fan from the top-centre gives the radial falloff; the screen
-- is cleared to the outer colour first so the corners it does not reach
- -- match seamlessly.
+ -- match seamlessly. Sized to the full window so unsafe edges stay filled.
do
- local cx, cy = width / 2, 0
- local rx, ry = width * 1.3, height * 1.08
+ local cx, cy = fullW / 2, 0
+ local rx, ry = fullW * 1.3, fullH * 1.08
local n = 72
local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
@@ -1794,8 +1799,8 @@ function RomImporter:draw()
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
do
- local cx, cy = width / 2, height * 0.45
- local rx, ry = width * 0.78, height * 0.78
+ local cx, cy = fullW / 2, fullH * 0.45
+ local rx, ry = fullW * 0.78, fullH * 0.78
local n = 72
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
for i = 0, n do
@@ -1817,7 +1822,7 @@ function RomImporter:draw()
self.scanlineImage:setWrap("repeat", "repeat")
self.scanlineImage:setFilter("nearest", "nearest")
end
- self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
+ self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
end
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
@@ -1843,21 +1848,23 @@ function RomImporter:draw()
}
]])
- -- background
+ -- background (full window — unsafe edges stay painted)
col(PAL.bgBot)
- love.graphics.rectangle("fill", 0, 0, width, height)
+ love.graphics.rectangle("fill", 0, 0, fullW, fullH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.bgMesh)
-- Centered content container (max ~1440 scaled units on very wide windows)
-- with a responsive side gutter; every column below derives from these.
+ -- Origin is the safe-area top-left so chrome clears device insets.
local appW = math.min(width, 1440 * s)
- local appX = (width - appW) / 2
+ local appX = ox + (width - appW) / 2
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
local third = appW / 3
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
local stripH = math.max(4, 6 * s)
+ local stripY = oy
local segs = {
{ PAL.red, appX, third },
{ PAL.blue, appX + third, third },
@@ -1865,11 +1872,11 @@ function RomImporter:draw()
}
love.graphics.setBlendMode("add")
for _, seg in ipairs(segs) do
- fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
+ fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
end
love.graphics.setBlendMode("alpha")
for _, seg in ipairs(segs) do
- col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
+ col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
end
-- Footer (Boi's Club Games logo + trust warning), measured first so the
@@ -1891,7 +1898,7 @@ function RomImporter:draw()
math.min(330 * s, appW - 32 * s))
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
- local logoY = stripH + 14 * s
+ local logoY = stripY + stripH + 14 * s
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
-- with "N of 3 ready" right-aligned.
@@ -1921,7 +1928,7 @@ function RomImporter:draw()
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH
local cW = appW - 2 * padH
- local contentBottom = height - footerH - bannerBand
+ local contentBottom = oy + height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop)
-- Page scroll. Everything under the tab bar -- panel, updater banner and
@@ -1932,14 +1939,14 @@ function RomImporter:draw()
-- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was.
- local viewportH = math.max(0, height - contentTop)
+ local viewportH = math.max(0, oy + height - contentTop)
self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport
- pageBand = paged and { contentTop, height } or nil
+ pageBand = paged and { contentTop, oy + height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport.
@@ -2110,10 +2117,10 @@ function RomImporter:draw()
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
- local lx, ly = (width - logoDW) / 2, logoY + bob
+ local lx, ly = ox + (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
- love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
+ love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha")
local shineW = 0.16
@@ -2146,11 +2153,11 @@ function RomImporter:draw()
-- save-slot rename modal (#205), drawn over everything
if self._rename then
col(PAL.bgBot, 0.72)
- love.graphics.rectangle("fill", 0, 0, width, height)
+ love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s
local dx = appX + (appW - dw) / 2
- local dy = (height - dh) / 2
+ local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
@@ -2192,11 +2199,11 @@ function RomImporter:draw()
-- it is typed.
if self._indexPrompt then
col(PAL.bgBot, 0.72)
- love.graphics.rectangle("fill", 0, 0, width, height)
+ love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s
local dx = appX + (appW - dw) / 2
- local dy = (height - dh) / 2
+ local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
@@ -2248,7 +2255,7 @@ function RomImporter:draw()
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
col(PAL.bgBot, 0.72)
- love.graphics.rectangle("fill", 0, 0, width, height)
+ love.graphics.rectangle("fill", 0, 0, fullW, fullH)
end
if self._modConfirm then
local c = self._modConfirm
@@ -2256,7 +2263,7 @@ function RomImporter:draw()
local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2
- local dy = (height - dh) / 2
+ local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2298,7 +2305,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2
- local dy = (height - dh) / 2
+ local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2343,7 +2350,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 520 * s)
local dh = math.min(height - 48 * s, 420 * s)
local dx = appX + (appW - dw) / 2
- local dy = (height - dh) / 2
+ local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2400,7 +2407,7 @@ function RomImporter:draw()
listH = listN * rowH
dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2
- local dy = (height - dh) / 2
+ local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua
index bd5e8f6a..3bfef34f 100644
--- a/src/inventory/Bag.lua
+++ b/src/inventory/Bag.lua
@@ -1,11 +1,26 @@
--- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
--- a distinct item id occupies one slot regardless of quantity; badges
--- live in the inventory table but are not bag items. save.bagOrder
--- keeps acquisition order like wBagItems (SELECT can reorder it).
+-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
+-- constants/menu_constants.asm), but mods may replace that limit through
+-- Data.constants.bagSize. A distinct item id occupies one slot regardless
+-- of quantity; badges live in the inventory table but are not bag items.
+-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
+-- it).
local Bag = {}
-Bag.CAPACITY = 20
+local DEFAULT_CAPACITY = 20
+
+-- `data` is injectable for the save editor and headless mod tests. Normal
+-- gameplay may omit it because the loader merges mods into the Data
+-- singleton before any item can be added. The fallback keeps old/stale
+-- generated caches and isolated callers at the vanilla limit.
+function Bag.capacity(data)
+ data = data or require("src.core.Data")
+ local configured = data and data.constants and data.constants.bagSize
+ if type(configured) == "number" and configured >= 1 then
+ return math.floor(configured)
+ end
+ return DEFAULT_CAPACITY
+end
local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
-function Bag.add(save, id, qty)
+function Bag.add(save, id, qty, data)
local inv = save.inventory
- if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
+ if not inv[id] and not isBadge(id)
+ and Bag.slots(save) >= Bag.capacity(data) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua
index 5c4ee919..a6dfb8dc 100644
--- a/src/render/Renderer.lua
+++ b/src/render/Renderer.lua
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
return merged
end
+-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
+-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
+-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
+-- render.compose mod can composite the world/UI canvases into its own layout.
+function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
+ bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
+ local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
+ if not shader then
+ love.graphics.setScissor(boxX, boxY, boxW, boxH)
+ love.graphics.draw(canvas, bx, by, 0, sx, sy)
+ love.graphics.setScissor()
+ return
+ end
+ love.graphics.setShader(shader)
+ -- a colors == false zone is the trueColor opt-out: its rect draws with
+ -- no shader at all. Nothing sets one without a mod, so a vanilla zone
+ -- list never toggles and issues exactly the calls it always did.
+ local bare = false
+ for _, z in ipairs(zoneList) do
+ local plain = z.colors == false
+ if plain ~= bare then
+ bare = plain
+ love.graphics.setShader(not plain and shader or nil)
+ end
+ if not plain then PaletteFX.sendColors(shader, z.colors) end
+ if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
+ z.w * zoneSx, z.h * zoneSy,
+ boxX, boxY, boxW, boxH, dpiX, dpiY) then
+ love.graphics.draw(canvas, bx, by, 0, sx, sy)
+ end
+ end
+ love.graphics.setScissor()
+ love.graphics.setShader()
+end
+
-- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
+ -- render.compose: hand a mod the finished world + UI canvases (and their
+ -- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
+ -- bridge, letting it lay the two passes out however it likes -- e.g. as two
+ -- stacked Game Boy screens, or driving one onto a second physical display.
+ -- The mod returns true to take over the whole window; anything else (or no
+ -- mod wrapping the hook) falls through to the normal composite below.
+ if Runtime.wantsHook("render.compose") then
+ local ctx = {
+ renderer = self,
+ worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
+ worldOverride = self.worldOverride,
+ worldActive = self.worldActive and true or false,
+ zones = zones, worldZones = worldZones,
+ ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
+ vpw = vpw, vph = vph, uiw = uiw, uih = uih,
+ scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
+ secondScreen = require("src.render.SecondScreen"),
+ }
+ if Runtime.call("render.compose", function() return false end, self, ctx) == true then
+ self.worldActive = false
+ self.uprightActive = false
+ self.worldOverride = nil
+ PaletteFX.setPass(nil)
+ return {
+ width = ww, height = wh, gameX = ox, gameY = oy,
+ gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
+ }
+ end
+ end
+
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
})
end
- -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
- -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
- -- convert zone coords (canvas-space) into screen units.
+ -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
+ -- call sites below stay unchanged.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
- local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
- if not shader then
- love.graphics.setScissor(boxX, boxY, boxW, boxH)
- love.graphics.draw(canvas, bx, by, 0, sx, sy)
- love.graphics.setScissor()
- return
- end
- love.graphics.setShader(shader)
- -- a colors == false zone is the trueColor opt-out: its rect draws with
- -- no shader at all. Nothing sets one without a mod, so a vanilla zone
- -- list never toggles and issues exactly the calls it always did.
- local bare = false
- for _, z in ipairs(zoneList) do
- local plain = z.colors == false
- if plain ~= bare then
- bare = plain
- love.graphics.setShader(not plain and shader or nil)
- end
- if not plain then PaletteFX.sendColors(shader, z.colors) end
- if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
- z.w * zoneSx, z.h * zoneSy,
- boxX, boxY, boxW, boxH, dpiX, dpiY) then
- love.graphics.draw(canvas, bx, by, 0, sx, sy)
- end
- end
- love.graphics.setScissor()
- love.graphics.setShader()
+ return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
+ bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
if self.worldOverride then
diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua
new file mode 100644
index 00000000..c8a6efc3
--- /dev/null
+++ b/src/render/SecondScreen.lua
@@ -0,0 +1,59 @@
+-- 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.
+
+local SecondScreen = {}
+local C = nil
+
+local function log(msg)
+ pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
+end
+
+do
+ local ok, ffi = pcall(require, "ffi")
+ if not (ok and ffi) then
+ log("ffi unavailable (not LuaJIT); second display disabled")
+ else
+ pcall(ffi.cdef, [[
+ int love_android_secondary_ready();
+ void love_android_push_secondary(const void *rgba, int w, int h);
+ void love_android_secondary_enable(int on);
+ ]])
+ local okLib, lib = pcall(ffi.load, "love")
+ if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
+ C = lib
+ log("bridge linked via ffi.load('love')")
+ elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
+ C = ffi.C
+ log("bridge linked via default namespace")
+ else
+ log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
+ :format(tostring(okLib)))
+ end
+ end
+end
+
+function SecondScreen.usable()
+ return C ~= nil
+end
+
+function SecondScreen.available()
+ 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)
+ if not C or not imageData then return false end
+ return pcall(function()
+ C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
+ end)
+end
+
+function SecondScreen.setEnabled(on)
+ if not C then return end
+ pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
+end
+
+return SecondScreen
diff --git a/src/script/Commands.lua b/src/script/Commands.lua
index 61237842..93ed8869 100644
--- a/src/script/Commands.lua
+++ b/src/script/Commands.lua
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText)
- -- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
+ -- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses)
- if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
+ if not require("src.inventory.Bag").add(
+ ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua
index 056f4c6a..5e31ca39 100644
--- a/src/ui/PlayerPC.lua
+++ b/src/ui/PlayerPC.lua
@@ -77,7 +77,7 @@ local function withdraw(game)
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
- if not Bag.add(game.save, item.value, qty) then
+ if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.")
return
end
diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua
index 9d24a8f0..e51eeb17 100644
--- a/src/ui/ShopMenu.lua
+++ b/src/ui/ShopMenu.lua
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough
return
end
- if not Bag.add(game.save, item.value, qty) then
+ if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items."))
return
diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua
index 2a69ea02..73f54bed 100644
--- a/src/ui/TouchControlsEditor.lua
+++ b/src/ui/TouchControlsEditor.lua
@@ -109,34 +109,36 @@ function Editor.update(_dt)
end
function Editor.draw()
- local ww, wh = love.graphics.getDimensions()
+ local SafeArea = require("src.core.SafeArea")
+ local fullW, fullH = love.graphics.getDimensions()
+ local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
- love.graphics.rectangle("fill", 0, 0, ww, wh)
+ love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85)
- love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
+ love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
local btnW = 100 * s
- -- top bar
+ -- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92)
- love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
+ love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
- love.graphics.line(0, barH + pad, ww, barH + pad)
+ love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
- love.graphics.print("Touch Controls", pad, pad + 4 * s)
+ love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
- local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
+ local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
@@ -156,9 +158,9 @@ function Editor.draw()
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
- local cardY = barH + pad + 14 * s
+ local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s
- local cardX, cardW = pad, ww - 2 * pad
+ local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -190,7 +192,7 @@ function Editor.draw()
local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
- love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
+ love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua
index f9d7227f..3d282e17 100644
--- a/src/world/OverworldController.lua
+++ b/src/world/OverworldController.lua
@@ -1810,7 +1810,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
- if not require("src.inventory.Bag").add(save, h.item, 1) then
+ if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true
end
@@ -2441,7 +2441,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
- if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
+ if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return
end
diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua
index 7e1659e8..4cad673d 100644
--- a/tests/drivers/route.lua
+++ b/tests/drivers/route.lua
@@ -2414,14 +2414,15 @@ local function sellItem(id)
return sold
end
--- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
--- slots; a stack of an item already held costs nothing). This is why
+-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
+-- capacity mod; a stack of an item already held costs nothing). This is why
-- every restock had been reporting "HYPER_POTION x0": the buy list
-- opened, the quantity was set, the engine said "no room" -- and the run
-- walked into the Mansion with FULL_HEALs but not one HP restore.
local function freeBagSlots(needed, where)
local used = #(G.save.bagOrder or {})
- local free = 20 - used
+ local capacity = require("src.inventory.Bag").capacity(G.data)
+ local free = capacity - used
for _, id in ipairs(SELLABLE_JUNK) do
if free >= needed then break end
if ((G.save.inventory or {})[id] or 0) > 0 then
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
end
end
local used = #(G.save.bagOrder or {})
- if newKinds > 0 and 20 - used < newKinds then
+ local capacity = require("src.inventory.Bag").capacity(G.data)
+ if newKinds > 0 and capacity - used < newKinds then
freeBagSlots(newKinds, where)
end
end
diff --git a/tests/engine/render_compose_seam.lua b/tests/engine/render_compose_seam.lua
new file mode 100644
index 00000000..cfb34caf
--- /dev/null
+++ b/tests/engine/render_compose_seam.lua
@@ -0,0 +1,62 @@
+-- Unit coverage for the render.compose seam (D14: a public-API test names
+-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
+-- it). render.compose lets a mod take over window composition: a wrap that
+-- returns true without calling next owns the whole window; a wrap that calls
+-- next lets the engine's normal single-window composite run and decorates it.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Runtime = require("src.mods.Runtime")
+local Hooks = require("src.mods.Hooks")
+
+local savedEvents, savedHooks = Runtime.events, Runtime.hooks
+local bus = Hooks.new()
+Runtime.hooks = bus
+
+-- the shape Renderer:endFrame hands the hook
+local function fakeCtx()
+ return {
+ renderer = {}, worldCanvas = {}, uiCanvas = {},
+ worldActive = true, zones = {}, worldZones = nil,
+ ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
+ dpiX = 1, dpiY = 1, secondScreen = {},
+ }
+end
+
+-- takeover: a mod drawing its own layout returns true and never calls next,
+-- so the engine's vanilla composite is skipped entirely
+do
+ local vanillaRan, gotCtx = false, nil
+ bus:wrap("render.compose", function(next, renderer, ctx)
+ gotCtx = ctx
+ return true
+ end, 0, "ds-mod")
+ local handled = Runtime.call("render.compose",
+ function() vanillaRan = true; return false end,
+ { tag = "renderer" }, fakeCtx())
+ T.eq(handled, true, "a mod returning true signals full window takeover")
+ T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
+ T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
+ "the hook receives the frame ctx (metrics, canvases, secondScreen)")
+ bus.chains["render.compose"] = nil
+end
+
+-- decorate: a mod calling next lets the engine composite run, and the
+-- engine's not-handled return (false) flows back through the chain
+do
+ local vanillaRan = false
+ bus:wrap("render.compose", function(next, renderer, ctx)
+ return next()
+ end, 0, "ds-mod")
+ local handled = Runtime.call("render.compose",
+ function() vanillaRan = true; return false end,
+ { tag = "renderer" }, fakeCtx())
+ T.eq(vanillaRan, true, "calling next runs the engine composite")
+ T.eq(handled, false, "the engine's not-handled return flows back through next")
+ bus.chains["render.compose"] = nil
+end
+
+Runtime.events, Runtime.hooks = savedEvents, savedHooks
+
+T.finish("render_compose_seam")
diff --git a/tests/love_stub.lua b/tests/love_stub.lua
index 00dc65e0..8973c8a0 100644
--- a/tests/love_stub.lua
+++ b/tests/love_stub.lua
@@ -203,4 +203,12 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end }
+-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
+stub.window = {
+ getSafeArea = function()
+ local ww, wh = stub.graphics.getDimensions()
+ return 0, 0, ww, wh
+ end,
+}
+
return stub
diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua
index 93c35319..34f696c5 100644
--- a/tests/mod_battle_tests.lua
+++ b/tests/mod_battle_tests.lua
@@ -716,6 +716,68 @@ do
check(actBattle:enemyAction().hooked == true,
"battle.enemy_action hook rewrites the choice")
unsub()
+
+ -- battle.catch_exp: vanilla catches never grant exp; a mod can flip that
+ unsub = hooks:wrap("battle.catch_exp", function() return true end)
+ local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) }
+ local catchExpGame = makeGame(catchExpParty)
+ local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3)
+ catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3)
+ local expBeforeCatch = catchExpParty[1].exp
+ catchExpBattle:storeCaughtMon()
+ check(catchExpParty[1].exp > expBeforeCatch,
+ "battle.catch_exp hook pays out exp on a catch")
+ unsub()
+
+ -- battle.exp_award: a mod can replace the participant/EXP.ALL split
+ -- wholesale via ctx.applyShare
+ unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx)
+ ctx.applyShare(ctx.alive[1], 999, "flatShare")
+ end)
+ local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) }
+ local awardGame = makeGame(awardParty)
+ local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3)
+ local expBeforeAward = awardParty[1].exp
+ awardBattle:awardExp()
+ check(awardParty[1].exp > expBeforeAward,
+ "battle.exp_award hook replaces the award split")
+ unsub()
+end
+
+-- ------- battle.low_health_alarm hook: mirrors the siren toggle
+
+do
+ local Sound = require("src.core.Sound")
+ local calls = {}
+ local origStart, origStop = Sound.startLoop, Sound.stopLoop
+ Sound.startLoop = function(data, name) calls[#calls + 1] = { "start", name } end
+ Sound.stopLoop = function(name) calls[#calls + 1] = { "stop", name } end
+
+ local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
+ local battle = BattleState.newWild(game, "RATTATA", 5)
+ battle.player.mon.hp = 1
+ battle.player.shownHP = 1
+
+ local seenOn = nil
+ local unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
+ seenOn = ctx.on
+ return nextFn(ctx)
+ end)
+ battle:updateFx()
+ check(seenOn == true, "battle.low_health_alarm hook sees the alarm toggle on")
+ check(calls[#calls][1] == "start" and calls[#calls][2] == "Low_Health_Alarm",
+ "an unmodified hook still starts the siren loop")
+ unsub()
+
+ unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
+ ctx.on = false
+ return nextFn(ctx)
+ end)
+ battle:updateFx()
+ check(calls[#calls][1] == "stop", "a mod can force the alarm off before vanilla acts")
+ unsub()
+
+ Sound.startLoop, Sound.stopLoop = origStart, origStop
end
-- ------- battle events: the scripted sequence
diff --git a/tests/modkit/cases/bag_capacity.lua b/tests/modkit/cases/bag_capacity.lua
new file mode 100644
index 00000000..88096cce
--- /dev/null
+++ b/tests/modkit/cases/bag_capacity.lua
@@ -0,0 +1,69 @@
+-- T4: constants.bagSize controls the bag through the public mod API while
+-- vanilla and existing-save behavior remain unchanged.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local T = require("tests.modkit")
+local Bag = require("src.inventory.Bag")
+
+local CAPACITY_MOD = {
+ ["mods/fix_small_bag/manifest.json"] = [[{
+ "id": "fix_small_bag",
+ "name": "Fixture Small Bag",
+ "version": "1.0.0",
+ "entry": "main.lua",
+ "api": 2
+ }]],
+ ["mods/fix_small_bag/main.lua"] = [[
+ local mod = ...
+ mod.content.constants:patch("bagSize", 2)
+ ]],
+}
+
+-- No-mod parity: the new lookup keeps the cartridge's 20-slot limit.
+do
+ local data = T.fixtures.fresh()
+ local run = T.sdk.loadNone({ data = data })
+ T.eq(#run.errors, 0, "the no-mod baseline loads cleanly")
+ T.eq(Bag.capacity(data), 20, "the vanilla bag still has 20 slots")
+ T.eq(Bag.capacity({}), 20, "a stale dataset without bagSize falls back to 20")
+ run.release()
+end
+
+-- The public constants registry changes both the reported and enforced cap.
+do
+ local data = T.fixtures.fresh()
+ local run = T.sdk.loadMods({ "mods/fix_small_bag" },
+ { data = data, fs = T.sdk.memfs(CAPACITY_MOD) })
+ T.eq(#run.errors, 0, "the capacity mod loads cleanly")
+ T.eq(Bag.capacity(data), 2, "Bag.capacity reads merged constants.bagSize")
+
+ local save = { inventory = {} }
+ T.check(Bag.add(save, "FIX_POTION", 1, data), "the first item fits")
+ T.check(Bag.add(save, "FIX_BALL", 1, data), "the second item fits")
+ T.check(not Bag.add(save, "FIX_TM", 1, data),
+ "a new item is refused at the modded limit")
+ T.eq(Bag.slots(save), 2, "a refused add does not change the bag")
+ run.release()
+end
+
+-- Saves are dictionaries, not fixed arrays: lowering the active cap never
+-- truncates an older or modded save. Existing stacks remain usable while a
+-- new item waits until the player makes enough room.
+do
+ local data = T.fixtures.fresh()
+ data.constants.bagSize = 1
+ local save = {
+ inventory = { FIX_POTION = 1, FIX_BALL = 1 },
+ bagOrder = { "FIX_POTION", "FIX_BALL" },
+ }
+ T.eq(Bag.slots(save), 2, "an over-cap save keeps all existing slots")
+ T.check(Bag.add(save, "FIX_POTION", 1, data),
+ "an over-cap save can still add to an existing stack")
+ T.eq(save.inventory.FIX_POTION, 2, "the existing stack is updated")
+ T.check(not Bag.add(save, "FIX_TM", 1, data),
+ "an over-cap save cannot add another item kind")
+ T.eq(Bag.slots(save), 2, "the compatibility path never drops items")
+end
+
+T.finish("bag_capacity")
diff --git a/tests/run_tests.lua b/tests/run_tests.lua
index 59e826c3..5443e196 100644
--- a/tests/run_tests.lua
+++ b/tests/run_tests.lua
@@ -1696,6 +1696,7 @@ end
-- ---------------------------------------------------------------- touch controls layout (#327)
do
local TC = require("src.core.TouchControls")
+ local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil")
@@ -1721,6 +1722,12 @@ do
check(L.a.cx > 200, "default A on right half")
check(L.dpad.cy > 400, "default d-pad in bottom half")
+ -- safe-area origin shifts defaults without changing relative layout
+ local Ls = TC.defaultLayout(400, 800, 20, 30)
+ eq(Ls.dpad.cx, L.dpad.cx + 20, "defaultLayout ox shifts controls")
+ eq(Ls.dpad.cy, L.dpad.cy + 30, "defaultLayout oy shifts controls")
+ eq(Ls.a.cx, L.a.cx + 20, "defaultLayout ox shifts A")
+
-- applyOptions + visible gate (no real images needed for the gate)
TC.enabled = true
TC.active = true
@@ -1743,16 +1750,37 @@ do
-- custom position applied through layout()
local g = love.graphics
local oldDim, oldFont = g.getDimensions, g.newFont
+ local oldSafe = love.window and love.window.getSafeArea
g.getDimensions = function() return 400, 800 end
g.newFont = function() return { getWidth = function() return 10 end,
getHeight = function() return 10 end } end
- TC.layoutW, TC.layoutH, TC.L = nil, nil, nil
+ love.window = love.window or {}
+ love.window.getSafeArea = function() return 0, 0, 400, 800 end
+ TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
local lay = TC:layout()
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
+
+ -- inset safe area: custom positions stay inside the usable rect
+ love.window.getSafeArea = function() return 10, 40, 380, 720 end
+ TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
+ lay = TC:layout()
+ eq(lay.dpad.cx, 10 + 0.25 * 380, "safe-area custom dpad cx")
+ eq(lay.dpad.cy, 40 + 0.75 * 720, "safe-area custom dpad cy")
+ check(lay.dpad.cy <= 40 + 720 - lay.dpad.w * 0.5 + 1e-6,
+ "safe-area dpad clears bottom inset")
+
+ local x, y, w, h = SafeArea.rect()
+ eq(x, 10, "SafeArea.rect x")
+ eq(y, 40, "SafeArea.rect y")
+ eq(w, 380, "SafeArea.rect w")
+ eq(h, 720, "SafeArea.rect h")
+
TC:clearPositions()
check(TC.positions == nil, "clearPositions wipes overrides")
g.getDimensions, g.newFont = oldDim, oldFont
+ if oldSafe then love.window.getSafeArea = oldSafe
+ else love.window.getSafeArea = nil end
end
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua
index b84f9749..34d9a4de 100644
--- a/tests/save_editor_task6_tests.lua
+++ b/tests/save_editor_task6_tests.lua
@@ -181,12 +181,13 @@ end
do
-- the bag has a hard slot cap; the picker must refuse past it
local S = newState()
+ local capacity = Bag.capacity(S.data)
local added = 0
for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end
- if added >= Bag.CAPACITY then break end
+ if added >= capacity then break end
end
- eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap")
+ eq(Bag.slots(S.save), capacity, "the bag filled to its cap")
S.dirty = false
local spare
for _, id in ipairs(S.cat.items) do
diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua
index 9d5a11e3..ce3a0feb 100644
--- a/tools/save-editor/App.lua
+++ b/tools/save-editor/App.lua
@@ -443,7 +443,7 @@ local function tabCount(id)
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
- return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY)
+ return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua
index ffa39889..694418d8 100644
--- a/tools/save-editor/Ops.lua
+++ b/tools/save-editor/Ops.lua
@@ -8,7 +8,8 @@
--
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6
-- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999,
--- item stack 99 and 20 bag slots (src/inventory/Bag).
+-- item stack 99 and the configured bag capacity (20 by default;
+-- src/inventory/Bag).
local Pokemon = require("src.pokemon.Pokemon")
local PartyMod = require("src.pokemon.Party")
@@ -441,11 +442,13 @@ end
function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end
- if Bag.add(S.save, id, 1) then
+ local capacity = Bag.capacity(S.data)
+ if Bag.add(S.save, id, 1, S.data) then
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
- :format(id, Bag.slots(S.save), Bag.CAPACITY))
+ :format(id, Bag.slots(S.save), capacity))
end
- return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY))
+ return Ops.say(S, ("Bag is full (%d/%d slots)")
+ :format(Bag.slots(S.save), capacity))
end
function Ops.bagAdjust(S, id, delta)
@@ -455,7 +458,7 @@ function Ops.bagAdjust(S, id, delta)
if have >= Ops.STACK_MAX then
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
end
- Bag.add(S.save, id, delta)
+ Bag.add(S.save, id, delta, S.data)
else
Bag.remove(S.save, id, -delta)
if not S.save.inventory[id] then
diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua
index f66291ab..ae1f9f68 100644
--- a/tools/save-editor/panels/Items.lua
+++ b/tools/save-editor/panels/Items.lua
@@ -1,4 +1,4 @@
--- Items panel: money, the shared item picker, badges, the 20-slot bag
+-- Items panel: money, the shared item picker, badges, the configurable bag
-- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
-- S.save.pcItems dict with no slot cap).
--
@@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------------------- bag
local order = Bag.order(S.save)
+ local capacity = Bag.capacity(S.data)
Kit.card(bagX, y, listW, h)
Kit.caption(bagX + pad, y + pad, "BAG")
- Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), Bag.CAPACITY),
+ Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity),
bagX + listW - pad, y + pad, PAL.caption)
local barY = y + pad + Kit.textHeight("caption") + 8 * s
- local slotFrac = Bag.slots(S.save) / Bag.CAPACITY
+ local slotFrac = Bag.slots(S.save) / capacity
Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
slotFrac >= 1 and PAL.yellow or PAL.blue)