From caa65182dc4d8fa66b51a6756747e56c5e10bfb8 Mon Sep 17 00:00:00 2001 From: Myles Resnick Date: Thu, 30 Jul 2026 15:58:13 -0400 Subject: [PATCH 1/8] Android: hardware step-counter bridge for step-sync mods love.system.syncHealthSteps() now exists on Android, matching the iOS Health bridge merged in #452 and using the same JNI route as the SAF picker (wrap_System.cpp -> System.cpp -> common/android.cpp -> GameActivity over JNI): - GameActivity.syncHealthSteps: one-shot read of the hardware TYPE_STEP_COUNTER sensor (cumulative since boot, counted by the OS whether or not any app runs). The reading is anchored in SharedPreferences so a walk is never credited twice; a reading below the anchor means the phone rebooted, which re-anchors without crediting. Deltas (50k clamp) merge into steps_pending.json in the save identity dir - the same contract as the iOS GRHealthBridge, so the Pokewalker mod works unchanged on both platforms. - ACTIVITY_RECOGNITION declared in the app manifest (Android 10+ runtime prompt on first sync; granted -> the sensor read runs immediately via onRequestPermissionsResult). The build script's permission trim leaves it alone. - Nothing in the base game calls the new seam; without a consumer mod the only cost is one dormant manifest permission. - build_android.sh: shadow-build from a space-free temp dir when the checkout path contains spaces - ndk-build is GNU make underneath and cannot cope with paths like "xCode Projects". - mobile/ANDROID.md: step-bridge dev notes. Co-Authored-By: Claude Fable 5 --- mobile/ANDROID.md | 23 +++ .../android/app/src/main/AndroidManifest.xml | 5 + .../love/src/jni/love/src/common/android.cpp | 12 ++ .../love/src/jni/love/src/common/android.h | 7 + .../jni/love/src/modules/system/System.cpp | 9 + .../src/jni/love/src/modules/system/System.h | 6 + .../love/src/modules/system/wrap_System.cpp | 7 + .../java/org/love2d/android/GameActivity.java | 184 ++++++++++++++++++ scripts/build_android.sh | 32 ++- 9 files changed, 282 insertions(+), 3 deletions(-) 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..5ddc77f7 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 */ 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 f8374566..d43e1e93 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). private String pendingPickFilename = PICKED_ROM_FILENAME; // Suggested download name for the in-flight SAF create (set by showCreateDocument). @@ -516,6 +535,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; @@ -739,6 +912,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); } 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 {} \; From 6fd9741fab3242fa12d4463f213ef7a5ee0efc76 Mon Sep 17 00:00:00 2001 From: sirj0k3r Date: Fri, 31 Jul 2026 21:12:21 +0100 Subject: [PATCH 2/8] + Implemented Low Health Alert hook for modding --- src/battle/BattleState.lua | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4306cd56..2f90f29e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2677,10 +2677,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 From ddcce5b95a1d08f71c1ed1494c9552cfc654e772 Mon Sep 17 00:00:00 2001 From: sirj0k3r Date: Fri, 31 Jul 2026 21:14:23 +0100 Subject: [PATCH 3/8] + Implemented Exp hook --- src/battle/BattleState.lua | 62 +++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4306cd56..84a5fab9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3216,7 +3216,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 @@ -3294,27 +3297,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 @@ -3875,6 +3895,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 From 0fad22256997aa632fb87bf1600371700b67521f Mon Sep 17 00:00:00 2001 From: sirj0k3r Date: Fri, 31 Jul 2026 22:10:26 +0100 Subject: [PATCH 4/8] + Implemented tests for `battle.catch_exp` and `battle.exp_award` hooks --- tests/mod_battle_tests.lua | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 93c35319..4c44bc89 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -716,6 +716,32 @@ 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 events: the scripted sequence From 33899c237e64f49cb98f0e16453fd0fc004ec049 Mon Sep 17 00:00:00 2001 From: sirj0k3r Date: Fri, 31 Jul 2026 22:17:19 +0100 Subject: [PATCH 5/8] + Implemented tests for `battle.low_health_alarm` hook --- tests/mod_battle_tests.lua | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 93c35319..a6794031 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -718,6 +718,42 @@ do 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 do From fccb122c59d32002834a0b9c9a38ec87608296d4 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Fri, 31 Jul 2026 23:38:03 -0300 Subject: [PATCH 6/8] Respect iOS/Android safe areas in launcher and touch chrome. Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge. Co-authored-by: Cursor --- src/core/SafeArea.lua | 40 ++++++++++++++++++ src/core/TouchControls.lua | 68 ++++++++++++++++++------------ src/import/RomImporter.lua | 77 ++++++++++++++++++---------------- src/ui/TouchControlsEditor.lua | 24 ++++++----- tests/love_stub.lua | 8 ++++ tests/run_tests.lua | 30 ++++++++++++- 6 files changed, 174 insertions(+), 73 deletions(-) create mode 100644 src/core/SafeArea.lua 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/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/import/RomImporter.lua b/src/import/RomImporter.lua index eea9a618..9e5870a6 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 @@ -1295,10 +1296,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 @@ -1344,11 +1345,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 @@ -1726,7 +1727,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 @@ -1745,8 +1749,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 @@ -1770,10 +1775,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 } } @@ -1787,8 +1792,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 @@ -1810,7 +1815,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 @@ -1836,21 +1841,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 }, @@ -1858,11 +1865,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 @@ -1884,7 +1891,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. @@ -1914,7 +1921,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 @@ -1925,14 +1932,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. @@ -2103,10 +2110,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 @@ -2139,11 +2146,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) @@ -2185,11 +2192,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) @@ -2241,7 +2248,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 @@ -2249,7 +2256,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)) @@ -2291,7 +2298,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)) @@ -2336,7 +2343,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)) @@ -2393,7 +2400,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/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/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/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) From 6e724cedf7750ec650617ff8efd943b319281bc6 Mon Sep 17 00:00:00 2001 From: erereck <131626550+erereck@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:32:00 -0300 Subject: [PATCH 7/8] Honor modded bag capacity --- docs/gameboy-hardware-limitations.md | 6 ++- docs/new-features.md | 3 +- mods/nuzlocke/main.lua | 2 +- src/core/Data.lua | 10 ++-- src/core/SaveData.lua | 2 +- src/dev/Console.lua | 2 +- src/inventory/Bag.lua | 30 +++++++++--- src/script/Commands.lua | 5 +- src/ui/PlayerPC.lua | 2 +- src/ui/ShopMenu.lua | 2 +- src/world/OverworldController.lua | 4 +- tests/drivers/route.lua | 10 ++-- tests/modkit/cases/bag_capacity.lua | 69 ++++++++++++++++++++++++++++ tests/save_editor_task6_tests.lua | 5 +- tools/save-editor/App.lua | 2 +- tools/save-editor/Ops.lua | 13 ++++-- tools/save-editor/panels/Items.lua | 7 +-- 17 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 tests/modkit/cases/bag_capacity.lua 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/new-features.md b/docs/new-features.md index afc5d3d5..be76940e 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/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/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/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/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/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/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 a2e3944d..eb07c939 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -76,7 +76,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/world/OverworldController.lua b/src/world/OverworldController.lua index c67683f7..81b76b00 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1803,7 +1803,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 @@ -2423,7 +2423,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/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/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 018d108a..0faf902f 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -427,7 +427,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 0a883a22..e6212e29 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") @@ -338,11 +339,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) @@ -352,7 +355,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) From 218b9249a2858d5bd0c324e1f97a6ec5ade2d56b Mon Sep 17 00:00:00 2001 From: Bart in 't Veld Date: Sat, 1 Aug 2026 09:35:24 +0200 Subject: [PATCH 8/8] render: add render.compose seam + second-screen bridge Expose a generic seam so a mod can drive a second screen without the engine owning any dual-screen layout policy: - render.compose hook in Renderer:endFrame hands a mod the finished world + UI canvases, their SGB zones, the frame metrics, Renderer:blitCanvas (lifted from the internal blit closure) and the SecondScreen bridge. Return true to take over the window; no wrap (or calling next) runs the normal single-window composite byte-for-byte. - SecondScreen.lua + the Android Presentation bridge (love_android_ secondary_* in common/android.cpp, GameActivity secondary display) as the optional physical-second-display transport. No battle-render changes: a mod lays out the two screens (including any battle split) itself. Ships with a unit test, no-mod parity via gate_hooks, and docs/modding.md (D14). --- docs/modding.md | 13 ++ .../love/src/jni/love/src/common/android.cpp | 47 +++++ .../java/org/love2d/android/GameActivity.java | 180 ++++++++++++++++++ src/render/Renderer.lua | 99 +++++++--- src/render/SecondScreen.lua | 59 ++++++ tests/engine/render_compose_seam.lua | 62 ++++++ 6 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 src/render/SecondScreen.lua create mode 100644 tests/engine/render_compose_seam.lua 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/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 7f0cfdca..2facbdc4 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -851,4 +851,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/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index f8374566..b20b2380 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 @@ -296,12 +296,14 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + teardownSecondaryDisplay(); super.onPause(); } @Override public void onResume() { super.onResume(); + setupSecondaryDisplay(); } /** @@ -921,4 +923,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/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/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")