diff --git a/data/scripts/flavor/celadon_mansion_3f.lua b/data/scripts/flavor/celadon_mansion_3f.lua index a7aca328..f0ad74aa 100644 --- a/data/scripts/flavor/celadon_mansion_3f.lua +++ b/data/scripts/flavor/celadon_mansion_3f.lua @@ -79,7 +79,7 @@ return { end) -- the PRNT stand-in always reports where the PNG landed game.stack:push(TextBox.new(game, saved - and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved) + and (Strings("There you go!\f") .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)), done)) end)) end)) diff --git a/data/scripts/yellow_beach_house.lua b/data/scripts/yellow_beach_house.lua index 418cc5fd..b1642aef 100644 --- a/data/scripts/yellow_beach_house.lua +++ b/data/scripts/yellow_beach_house.lua @@ -119,8 +119,7 @@ return { Font.draw(Strings("%d pts", hi), 96, 40) end) push(game, saved - and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.", - saved) + and (Strings("Printed!\f") .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)), done) end)) end) diff --git a/main.lua b/main.lua index 95d26351..256f65f1 100644 --- a/main.lua +++ b/main.lua @@ -428,11 +428,19 @@ local function returnToLauncher(opts) -- Leave the cart's scope behind: the launcher's own settings and slots are -- the base game's, not the cart's. The speed ladder is cart state too, so -- a 1x/2x cart must not pin the launcher or the next game. - require("src.core.SaveData").setCart(nil) + local SaveData = require("src.core.SaveData") + local cartId = SaveData.getCart() + SaveData.setCart(nil) require("src.core.GameSpeed").setAllowed(nil) SessionLifecycle.endMountedSession(currentVersion) + -- Slot lists are resolved once per process. Invalidate only the game + -- (and cart, if any) we just left so the new launcher can migrate a flat + -- in-game SAVE into a visible slot -- nothing else is rewritten. + SaveData.refreshSlotResolution(currentVersion) + if cartId then SaveData.refreshSlotResolution("cart_" .. cartId) end + applySavedOrientation() local preload = require("src.mods.LauncherMods").translationStrings() @@ -444,6 +452,10 @@ local function returnToLauncher(opts) end Importer = makeLauncher({ initialTab = opts and opts.tab or nil }) + -- Finger that confirmed EXIT GAME is often still down over Import Save. + if Importer.ignoreReturningPointer then + Importer:ignoreReturningPointer() + end end local pendingLauncherReturn diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index 8f9a1f99..16175604 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -5,7 +5,7 @@ # it in `app.name_byte_array` #app.name=LÖVE for Android -app.application_id=com.theboisclub.pokemonred +app.application_id=com.theboisclub.pokemonred.androidfixes # fullUser: allow every orientation the player's device permits (portrait and # landscape), honouring their auto-rotate lock. Was "portrait" (locked). app.orientation=fullUser @@ -17,4 +17,4 @@ android.enableJetifier=false android.useAndroidX=true android.nonTransitiveRClass=true android.nonFinalResIds=true -app.name=gen1recomp +app.name=gen1recomp (test) 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 52d7ee3d..49b52e44 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -249,6 +249,32 @@ bool showCreateDocument(const char *suggestedName) return result; } +bool exportImageToGallery(const char *relativePath) +{ + if (relativePath == nullptr || relativePath[0] == '\0') + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + + jmethodID method = env->GetStaticMethodID(activity, "exportImageToGallery", + "(Ljava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + jstring jpath = env->NewStringUTF(relativePath); + jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jsavedir); + env->DeleteLocalRef(jsavedir); + env->DeleteLocalRef(jpath); + + env->DeleteLocalRef(activity); + return result; +} + bool syncHealthSteps() { JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); 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 ba420b0f..f6d2dc1b 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); +/** + * Copy a PNG/JPEG under the LOVE save identity (relative path, e.g. + * prints/diploma_….png) into Pictures/Gen1Recomp and media-scan the + * in-app copy so USB / file managers can see it (#2103). + **/ +bool exportImageToGallery(const char *relativePath = nullptr); + /** * Pokéwalker step bridge: asks GameActivity to read the hardware step * counter and stage steps_pending.json in the save identity dir (see 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 1dd3b56b..3af28e9b 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 @@ -228,6 +228,16 @@ bool System::createFile(const char *suggestedName) const #endif } +bool System::exportImage(const char *relativePath) const +{ +#ifdef LOVE_ANDROID + return love::android::exportImageToGallery(relativePath); +#else + LOVE_UNUSED(relativePath); + return false; +#endif +} + bool System::syncHealthSteps() const { #ifdef LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 7ed44292..963f1464 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 @@ -129,6 +129,12 @@ public: **/ virtual bool createFile(const char *suggestedName = nullptr) const; + /** + * Copy a PNG/JPEG from the LOVE save identity into Pictures/Gen1Recomp + * and media-scan the in-app file (#2103). Android only; false elsewhere. + **/ + virtual bool exportImage(const char *relativePath = 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. 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 fa8d2eca..f6462ffc 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 @@ -119,6 +119,13 @@ int w_createFile(lua_State *L) return 1; } +int w_exportImage(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + luax_pushboolean(L, instance()->exportImage(path)); + return 1; +} + int w_syncHealthSteps(lua_State *L) { luax_pushboolean(L, instance()->syncHealthSteps()); @@ -341,6 +348,7 @@ static const luaL_Reg functions[] = { "pickFile", w_pickFile }, { "pickFileKinds", w_pickFileKinds }, { "createFile", w_createFile }, + { "exportImage", w_exportImage }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "installApk", w_installApk }, 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 eeca2f56..306de1f8 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 @@ -46,6 +46,8 @@ import android.app.AlarmManager; import android.app.AlertDialog; import android.app.PendingIntent; import android.app.UiModeManager; +import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; import android.content.ClipData; import android.content.DialogInterface; @@ -64,12 +66,15 @@ import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; +import android.media.MediaScannerConnection; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.Looper; import android.os.Vibrator; +import android.provider.MediaStore; import android.provider.Settings; import android.util.Log; import android.util.DisplayMetrics; @@ -1328,7 +1333,13 @@ public class GameActivity extends SDLActivity { self.pendingCreateSuggestedName = suggestedName; Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("application/octet-stream"); + // PNG exports (diploma / dex prints) should open as images so the + // picker offers Photos / Gallery, not only generic "Documents". + String mime = "application/octet-stream"; + String lower = suggestedName.toLowerCase(Locale.US); + if (lower.endsWith(".png")) mime = "image/png"; + else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) mime = "image/jpeg"; + intent.setType(mime); intent.putExtra(Intent.EXTRA_TITLE, suggestedName); try { self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE); @@ -1339,6 +1350,116 @@ public class GameActivity extends SDLActivity { } } + /** + * Copy a PNG/JPEG already written under the LOVE save identity (e.g. + * prints/diploma_….png) into the public Pictures/Gen1Recomp album and + * media-scan the in-app copy so USB / file managers can see it (#2103). + * No picker: diploma / dex print should not interrupt the game. + */ + @Keep + public static boolean exportImageToGallery(String relativePath, String saveDir) { + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (relativePath == null || relativePath.length() == 0) return false; + if (relativePath.indexOf("..") >= 0) { + Log.d("GameActivity", "refusing unsafe export path: " + relativePath); + return false; + } + self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; + File source = new File(self.saveIdentityDir(), relativePath); + if (!source.isFile()) { + Log.d("GameActivity", "exportImage missing at " + source); + return false; + } + String name = source.getName(); + String lower = name.toLowerCase(Locale.US); + String mime = "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) mime = "image/jpeg"; + else if (!lower.endsWith(".png")) { + Log.d("GameActivity", "exportImage refusing non-image: " + name); + return false; + } + + // Make the app-private prints/ copy visible over MTP / file managers. + MediaScannerConnection.scanFile(self, + new String[]{ source.getAbsolutePath() }, + new String[]{ mime }, + null); + + try { + if (Build.VERSION.SDK_INT >= 29) { + ContentResolver resolver = self.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, name); + values.put(MediaStore.MediaColumns.MIME_TYPE, mime); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + "/Gen1Recomp"); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + Uri collection = MediaStore.Images.Media + .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + Uri uri = resolver.insert(collection, values); + if (uri == null) { + Log.d("GameActivity", "MediaStore insert returned null"); + return false; + } + OutputStream out = resolver.openOutputStream(uri); + if (out == null) { + resolver.delete(uri, null, null); + return false; + } + try { + copyStream(new FileInputStream(source), out); + } finally { + out.close(); + } + values.clear(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(uri, values, null, null); + return true; + } + + // Pre-Q: write into public Pictures and ask the scanner to index it. + File pictures = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES); + File album = new File(pictures, "Gen1Recomp"); + if (!album.exists() && !album.mkdirs()) { + Log.d("GameActivity", "could not create " + album); + return false; + } + File dest = new File(album, name); + copyFileToFile(source, dest); + MediaScannerConnection.scanFile(self, + new String[]{ dest.getAbsolutePath() }, + new String[]{ mime }, + null); + return true; + } catch (Exception e) { + Log.d("GameActivity", "exportImage failed: " + e.getMessage()); + return false; + } + } + + private static void copyStream(InputStream in, OutputStream out) throws IOException { + try { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) >= 0) { + if (n > 0) out.write(buf, 0, n); + } + } finally { + try { in.close(); } catch (IOException ignored) {} + } + } + + private static void copyFileToFile(File source, File dest) throws IOException { + FileOutputStream out = new FileOutputStream(dest); + try { + copyStream(new FileInputStream(source), out); + } finally { + out.close(); + } + } + private File saveIdentityDir() { // Prefer the mounted save dir the last bridge call reported: the // recomputation below can name a different volume than the one LOVE diff --git a/src/core/Game2.lua b/src/core/Game2.lua index abe0112f..185bb499 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -552,7 +552,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) end if ok then -- data/text/common_3.asm:119 - return self:say(("%s learned\n%s!"):format(name, moveName), + return self:say(Strings("%s learned\n%s!", name, moveName), function() finish(true) end, TextBox.soundOpts(self, "Sfx_DexFanfare5079")) end @@ -560,15 +560,14 @@ function Game2:learnMoveOn(mon, moveId, onDone) local askForget, pickMove, askStop -- DidNotLearnMoveText, then `ld b, 0` (learn.asm:110-113). local function decline() - self:say(("%s\ndid not learn\v%s."):format(name, moveName), + self:say(Strings("%s\ndid not learn\v%s.", name, moveName), function() finish(false) end) end -- ForgetMove's AskForgetMoveText + YesNoBox (learn.asm:123-127). askForget = function() self.stack:push(TextBox.new(self, - ("%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." - .. "\fDelete an older\nmove to make room\vfor %s?") - :format(name, moveName, name, moveName), + Strings("%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves.\fDelete an older\nmove to make room\vfor %s?", + name, moveName, name, moveName), nil, { choice = function(yes) if yes then return pickMove() end return askStop() @@ -577,7 +576,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- StopLearningMoveText, whose NO is `jp c, .loop` (learn.asm:104-108). askStop = function() self.stack:push(TextBox.new(self, - ("Stop learning\n%s?"):format(moveName), nil, + Strings("Stop learning\n%s?", moveName), nil, { choice = function(yes) if yes then return decline() end return askForget() @@ -600,7 +599,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- MoveCantForgetHMText, then `jr .loop` (learn.asm:183-197): the -- question stays up and the list comes back over it. if old and HM_MOVES[old.id] then - return self:say("HM moves can't be\nforgotten now.", pushList) + return self:say(Strings("HM moves can't be\nforgotten now."), pushList) end self.stack:pop() -- the question the list stood on local oldDef = (self.data.moves or {})[old and old.id] @@ -610,9 +609,8 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- pokemon.move_learned is raised here too. ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) -- engine/pokemon/learn.asm:225-229, data/text/common_3.asm:165-173 - self:say(("1, 2 and…" .. TextBox.PAUSE .. " Poof!" .. TextBox.PAUSE - .. "\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") - :format(name, oldName, name, moveName), + self:say(Strings("1, 2 and…\1 Poof!\1\f%s forgot\n%s.\fAnd…\f%s learned\n%s!", + name, oldName, name, moveName), function() finish(true) end, TextBox.soundOpts(self, "Sfx_DexFanfare5079", { pauseSounds = { "Sfx_SwitchPokemon" } })) @@ -622,7 +620,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- MoveAskForgetText, a `done` text: the box stays while the list stands on -- it (learn.asm:136-137). pickMove = function() - self.stack:push(TextBox.new(self, "Which move should\nbe forgotten?", nil, + self.stack:push(TextBox.new(self, Strings("Which move should\nbe forgotten?"), nil, { stay = { onShown = pushList } })) end askForget() @@ -657,13 +655,13 @@ function Game2:useFieldItem(itemId) if id == moveId then allowed = true end end if not allowed then - self:say(("%s can't learn %s!"):format( + self:say(Strings("%s can't learn %s!", require("src.battle.gen2.Mon").displayName(mon), moveName)) return end for _, move in ipairs(mon.moves or {}) do if move.id == moveId then - self:say(("%s already knows %s!"):format( + self:say(Strings("%s already knows %s!", require("src.battle.gen2.Mon").displayName(mon), moveName)) return end diff --git a/src/core/Printer.lua b/src/core/Printer.lua index 7fae79a1..45ce4db9 100644 --- a/src/core/Printer.lua +++ b/src/core/Printer.lua @@ -4,17 +4,45 @@ -- under prints/ in the save directory instead, and the caller shows a -- dialog with where it landed. Scaled up 4x so the "print" is legible -- on a modern screen. +-- +-- On Android the app-private prints/ folder is often invisible over USB / +-- file managers even when the write succeeded (#2103). After the PNG is +-- written, love.system.exportImage copies it into Pictures/Gen1Recomp and +-- media-scans the in-app copy so both Gallery and the save folder work. local Logger = require("src.core.Logger") local Printer = {} local SCALE = 4 +local ANDROID_ALBUM = "Pictures/Gen1Recomp" + +-- After a successful Android gallery export, the public album path shown +-- in the "Printed!" dialog. Nil on desktop / when the bridge is absent. +Printer.lastAlbumPath = nil + +local function exportToAndroidGallery(relPath) + Printer.lastAlbumPath = nil + if not (love.system and love.system.getOS + and love.system.getOS() == "Android") then + return false + end + local exportImage = love.system.exportImage + if type(exportImage) ~= "function" then return false end + local ok, exported = pcall(exportImage, relPath) + if ok and exported then + Printer.lastAlbumPath = ANDROID_ALBUM + return true + end + return false +end -- Render drawFn (which draws a w x h GB-pixel image at 0,0) into -- prints/_.png. Returns the save-dir-relative path, or nil -- and an error string (headless / no canvas support degrades gracefully). +-- On Android a successful gallery copy also sets Printer.lastAlbumPath. function Printer.save(name, w, h, drawFn) + Printer.lastAlbumPath = nil if not (love.graphics and love.graphics.newCanvas) then return nil, "no graphics" end @@ -38,7 +66,18 @@ function Printer.save(name, w, h, drawFn) if not encOk then return nil, tostring(err) end Logger.info("printed %s -> %s/%s", name, love.filesystem.getSaveDirectory(), path) + exportToAndroidGallery(path) return path end +-- Second page of the "Printed!" dialog: public album on Android when the +-- gallery bridge worked, otherwise the save-dir-relative PNG path. +function Printer.savedWhereText(path) + local Strings = require("src.core.Strings") + if Printer.lastAlbumPath then + return Strings("Saved to\n%s.", Printer.lastAlbumPath) + end + return Strings("Saved as\n%s\vin the save\nfolder.", path) +end + return Printer diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index b0a1616a..78311903 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1440,13 +1440,26 @@ function SaveData.deleteSlot(version, slotId) return deleteSlotIn(version, slotId) end --- Test seam: drop the process-global slot cache (and the active cart) so a --- suite can exercise migration/resolution against a freshly injected --- filesystem. Unused by the game, which resolves each scope exactly once per --- boot. -function SaveData.resetSlotState() +-- Drop the process-global "have we resolved slots for this scope" cache so +-- the next listSlots/saveNames re-reads disk (and can migrate a flat legacy +-- SAVE into slot1). Pass a version id or cart scope key to invalidate just +-- that list; nil clears every scope (test seam / resetSlotState). +-- Does not touch carts, seals, options.lua, or any other launcher setup. +function SaveData.refreshSlotResolution(scope) + if scope ~= nil then + if type(scope) ~= "string" or scope == "" then return end + activeSlotCache[scope] = nil + slotsChecked[scope] = nil + return + end for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end +end + +-- Test seam: full process-global reset (slot resolution + cart + seal) so a +-- suite can exercise migration against a freshly injected filesystem. +function SaveData.resetSlotState() + SaveData.refreshSlotResolution() freshPlaythrough = nil activeCart, activeCartHash = nil, nil sealBroken = false diff --git a/src/core/gen2/ItemEffects.lua b/src/core/gen2/ItemEffects.lua index b0b8a8a6..9ef47adc 100644 --- a/src/core/gen2/ItemEffects.lua +++ b/src/core/gen2/ItemEffects.lua @@ -16,6 +16,7 @@ local Happiness = require("src.core.gen2.Happiness") local Mon = require("src.battle.gen2.Mon") +local Strings = require("src.core.Strings") local ItemEffects = {} @@ -72,26 +73,33 @@ local BITTER = { } -- _ItemWontHaveEffectText / _ItemCantUseOnEggText (data/text/common_3.asm). -ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect." -ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG." +-- Strings.source keeps these in the catalog harvest even though they are +-- declared here and only formatted/looked up at each use site below (#186, +-- #245): every use site was a bare table read or a direct :format() call +-- until now, which meant a mod's translation catalog never had a chance to +-- apply -- every language showed this same English text. +ItemEffects.TEXT_NO_EFFECT = Strings.source("It won't have any\neffect.") +ItemEffects.TEXT_CANT_USE_ON_EGG = Strings.source("That can't be used\non an EGG.") -- _ItemCantUseOnMonText (data/text/common_3.asm:1265). -ItemEffects.TEXT_CANT_USE_ON_MON = "That can't be used\non this #MON." +ItemEffects.TEXT_CANT_USE_ON_MON = Strings.source("That can't be used\non this #MON.") -- _PPRestoredText (data/text/common_3.asm). -ItemEffects.TEXT_PP_RESTORED = "PP was restored." +ItemEffects.TEXT_PP_RESTORED = Strings.source("PP was restored.") -- _PPIsMaxedOutText / _PPsIncreasedText (data/text/common_3.asm). -ItemEffects.TEXT_PP_MAXED = "%s's PP\nis maxed out." -ItemEffects.TEXT_PP_INCREASED = "%s's PP\nincreased." +ItemEffects.TEXT_PP_MAXED = Strings.source("%s's PP\nis maxed out.") +ItemEffects.TEXT_PP_INCREASED = Strings.source("%s's PP\nincreased.") -- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm), -- keyed by the class GetItemHealingAction resolves. Each is the two rows the --- cart prints: the nickname line, then the fixed line. +-- cart prints: the nickname line, then the fixed line. Strings.source for +-- the same reason as the constants above: healStatus() below formats these +-- directly at each use, so without it a mod's catalog never sees them. local STATUS_TEXT = { - psn = "%s's\ncured of poison.", - par = "%s's\nrid of paralysis.", - brn = "%s's\nburn was healed.", - frz = "%s\nwas defrosted.", - slp = "%s\nwoke up.", - all = "%s's\nhealth returned.", + psn = Strings.source("%s's\ncured of poison."), + par = Strings.source("%s's\nrid of paralysis."), + brn = Strings.source("%s's\nburn was healed."), + frz = Strings.source("%s\nwas defrosted."), + slp = Strings.source("%s\nwoke up."), + all = Strings.source("%s's\nhealth returned."), } -- The port's party records spell status several ways (the battle writes the @@ -145,7 +153,7 @@ local function restoreHp(itemId, mon) local amount = ItemEffects.HEAL_HP[itemId] local maxHp = maxHpOf(mon) if fainted(mon) or (mon.hp or 0) >= maxHp then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local healed = math.min(maxHp, (mon.hp or 0) + amount) local gained = healed - (mon.hp or 0) @@ -157,7 +165,7 @@ local function restoreHp(itemId, mon) used = true, -- data/text/common_1.asm:30 -- home/text.asm:772 - text = ("%s\nrecovered %dHP!"):format(monName(mon), gained), + text = Strings("%s\nrecovered %dHP!", monName(mon), gained), } end @@ -181,23 +189,23 @@ end -- confusion arm reads wPlayerSubStatus3, which does not exist out of battle. local function healStatus(itemId, mon, class, data) if fainted(mon) then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local have = ItemEffects.healClassOf(mon.status, data) if not have or (class ~= "all" and have ~= class) then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end clearStatus(mon) bitterHappiness(itemId, mon) local shape = STATUS_TEXT[class == "all" and "all" or have] - return { used = true, text = shape:format(monName(mon)) } + return { used = true, text = Strings(shape, monName(mon)) } end -- RevivePokemon: only a fainted mon accepts; REVIVE stands it up at half max -- HP (ReviveHalfHP's `srl d / rr e`), the other two at full. local function revive(itemId, mon) if not fainted(mon) then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local maxHp = maxHpOf(mon) mon.hp = (ItemEffects.REVIVE[itemId] == "half") @@ -206,7 +214,7 @@ local function revive(itemId, mon) bitterHappiness(itemId, mon) return { used = true, - text = ("%s\nis revitalized."):format(monName(mon)), + text = Strings("%s\nis revitalized.", monName(mon)), } end @@ -218,7 +226,7 @@ end -- exactly the new level, for the caller to offer. local function rareCandy(mon, data) if (mon.level or 0) >= Mon.MAX_LEVEL then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local def = data and data.pokemon and data.pokemon[mon.species] -- through Mon.growthFor, so a growth_rates record a mod registered is the @@ -245,14 +253,18 @@ local function rareCandy(mon, data) learned = learned, -- data/text/common_1.asm:86 sfx = "Sfx_DexFanfare5079", - text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), + text = Strings("%s grew to\nlevel %d!", monName(mon), newLevel), } end --- engine/items/item_effects.asm:1216 StatStrings. +-- engine/items/item_effects.asm:1216 StatStrings. Strings.source per entry, +-- the same pattern as the identical stat-name tables in MoveEffects.lua/ +-- TrainerAI.lua/gen2/Effects.lua/ContestMenu.lua/SummaryMenu.lua, so a mod's +-- catalog harvest finds these independently of any other stat-name call site. local VITAMIN_LABEL = { - hp = "HEALTH", attack = "ATTACK", defense = "DEFENSE", - speed = "SPEED", special = "SPECIAL", + hp = Strings.source("HEALTH"), attack = Strings.source("ATTACK"), + defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"), + special = Strings.source("SPECIAL"), } -- engine/items/item_effects.asm:1149 VitaminEffect. @@ -261,7 +273,7 @@ local function vitamin(itemId, mon, data) mon.statExp = mon.statExp or Mon.newStatExp() local cur = mon.statExp[stat] or 0 if cur >= 25600 then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end mon.statExp[stat] = math.min(Mon.MAX_STAT_EXP, cur + 2560) local def = data and data.pokemon and data.pokemon[mon.species] @@ -272,7 +284,7 @@ local function vitamin(itemId, mon, data) Happiness.change(mon, "USEDITEM") return { used = true, - text = ("%s's\n%s rose."):format(monName(mon), VITAMIN_LABEL[stat]), + text = Strings("%s's\n%s rose.", monName(mon), Strings(VITAMIN_LABEL[stat])), } end @@ -392,15 +404,15 @@ end -- The one-call families (everything but PP, which needs a move pick first). -- Returns { used, text, learned?, level? }. function ItemEffects.useOnMon(itemId, mon, data) - if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if not mon then return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end if mon.isEgg then - return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } + return { used = false, text = Strings(ItemEffects.TEXT_CANT_USE_ON_EGG) } end local record = ItemEffects.recordFor(itemId, data) -- The PP family has its own entry point; reaching it here is the same -- "nothing happens" the unported items get. if not record or not record.use or record.action == "pp" then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end return record.use({ item = itemId, mon = mon, data = data }) end @@ -423,13 +435,13 @@ end -- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and counts -- -- one restored move is enough for the item to be spent. function ItemEffects.usePpItem(itemId, mon, slot, data) - if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if not mon then return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end if mon.isEgg then - return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } + return { used = false, text = Strings(ItemEffects.TEXT_CANT_USE_ON_EGG) } end local record = ItemEffects.recordFor(itemId, data) if not record or not record.use or record.action ~= "pp" then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end return record.use({ item = itemId, mon = mon, data = data, slot = slot }) end @@ -478,9 +490,9 @@ for itemId, row in pairs(ItemEffects.RESTORE_PP) do any = restoreMove(moves[ctx.slot], row.amount) end if not any then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end - return { used = true, text = ItemEffects.TEXT_PP_RESTORED } + return { used = true, text = Strings(ItemEffects.TEXT_PP_RESTORED) } end) end @@ -488,24 +500,24 @@ end record("PP_UP", "pp", function(ctx) local move = (ctx.mon.moves or {})[ctx.slot] if type(move) ~= "table" or not move.id then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local row = ((ctx.data and ctx.data.moves) or {})[move.id] local name = (row and row.name) or move.id -- constants/pokemon_data_constants.asm:216 PP_UP_MASK. if move.id == "SKETCH" or (move.ppUps or 0) >= 3 then - return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) } + return { used = false, text = Strings(ItemEffects.TEXT_PP_MAXED, name) } end local base = (row and row.pp) or move.maxPp if not base then - return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) } + return { used = false, text = Strings(ItemEffects.TEXT_PP_MAXED, name) } end -- engine/items/item_effects.asm:2736 ComputeMaxPP. local bonus = math.min(math.floor(base / 5), 7) move.ppUps = (move.ppUps or 0) + 1 move.maxPp = base + move.ppUps * bonus move.pp = (move.pp or 0) + bonus - return { used = true, text = ItemEffects.TEXT_PP_INCREASED:format(name) } + return { used = true, text = Strings(ItemEffects.TEXT_PP_INCREASED, name) } end) for itemId in pairs(ItemEffects.VITAMIN) do @@ -522,12 +534,12 @@ for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE", "THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do record(itemId, "stone", function(ctx) if ctx.mon.item == "EVERSTONE" then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local Evolution = require("src.core.gen2.Evolution") local entry = Evolution.checkMon(ctx.data, ctx.mon, { force = true, item = ctx.item }) - if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if not entry then return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end return { used = true, evolution = entry } end) end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7d9d22b4..8232a64e 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -249,6 +249,10 @@ end function LauncherView.touchpressed(imp, id, x, y) if not imp._flex then return end + -- Leftover finger from EXIT GAME / Close editor: do not start a tap. + if imp._ignoreTouch and imp._ignoreTouch[tostring(id)] then + return + end imp._touchAt = imp._touchAt or {} imp._touchAt[tostring(id)] = { x = x, y = y, started = love.timer.getTime(), @@ -284,8 +288,21 @@ end -- A tap dispatches on RELEASE (not press) so a drag can disqualify it. function LauncherView.touchreleased(imp, id, x, y) if not imp._flex then return end - local start = imp._touchAt and imp._touchAt[tostring(id)] - if imp._touchAt then imp._touchAt[tostring(id)] = nil end + local tid = tostring(id) + if imp._ignoreTouch and imp._ignoreTouch[tid] then + imp._ignoreTouch[tid] = nil + if imp._touchAt then imp._touchAt[tid] = nil end + imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP + return + end + local start = imp._touchAt and imp._touchAt[tid] + if imp._touchAt then imp._touchAt[tid] = nil end + -- A release with no matching press is leftover from the previous host + -- (game / save editor), not a launcher tap (#2079). + if not start then + imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP + return + end if start and (start.dragged or start.longPressed) then -- Suppress the mouse click SDL will synthesize for this same gesture. imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 2d70d87e..23ad8e55 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3176,10 +3176,48 @@ function RomImporter:prepareOverlayHandoff() end end +-- EXIT GAME / Close editor leave the confirming finger still down, often +-- sitting where Import Save is drawn. The launcher must not treat that +-- leftover hold as a new press once a short suppress window expires +-- (#2079): update() would then arm on the still-down pointer and the +-- later lift would open the system file picker. Swallow this gesture +-- (held mouse, already-down touches) and debounce clicks briefly. +local RETURN_POINTER_HOLD = 0.5 + +function RomImporter:ignoreReturningPointer() + local now = 0 + if love.timer and love.timer.getTime then + now = love.timer.getTime() + end + self._suppressClickUntil = now + RETURN_POINTER_HOLD + self._suppressMouseUntil = now + RETURN_POINTER_HOLD + self._clickPt = nil + self._mouseAt = nil + self._touchAt = nil + -- Already-down is not a rising edge. Always mark the poll as held so + -- the first frame after remount cannot mint a press from a leftover. + self._prevMouseDown = true + local ignore = {} + if love.touch and love.touch.getTouches then + local ok, ids = pcall(love.touch.getTouches) + if ok and type(ids) == "table" then + for i = 1, #ids do + ignore[tostring(ids[i])] = true + end + end + end + self._ignoreTouch = ignore + if package.loaded["src.ui.kit.Kit"] then + local Kit = require("src.ui.kit.Kit") + if Kit.dragEnd then pcall(Kit.dragEnd) end + end +end + -- After an overlay closes: re-arm the pad cursor when a stick is already -- connected so NX / handhelds are not stranded without a pointer until the -- next stick bump (same class of bug as opening Touch Controls). function RomImporter:resumeAfterOverlay() + self:ignoreReturningPointer() if not self.launcher then return end if not (love.joystick and love.joystick.getJoystickCount) then return end if love.joystick.getJoystickCount() <= 0 then return end diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index c50b9a6f..a8bd6453 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -340,8 +340,8 @@ local function printBox(game) love.graphics.setColor(1, 1, 1, 1) end) game.stack:push(TextBox.new(game, saved - and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.", - game.save.currentBox or 1, saved) + and (Strings("Printed BOX %d!\f", game.save.currentBox or 1) + .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)))) end diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 57111ec1..9c3dab80 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -237,8 +237,8 @@ local function chooseEntry(item, dexList) DexEntryMenu.render(game, def, ok and sprite or nil, false) end) game.stack:push(TextBox.new(game, saved - and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", - def.name, saved) + and (Strings("Printed %s's\ndata!\f", def.name) + .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)))) end } end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index a036a7ed..16b7a450 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -3909,7 +3909,7 @@ function BattleState:useItem(itemId) if not ok then -- _ItemWontHaveEffectText's own `line` break, the same one -- ItemEffects.TEXT_NO_EFFECT carries (data/text/common_3.asm). - self.message = ItemEffects.TEXT_NO_EFFECT + self.message = Strings(ItemEffects.TEXT_NO_EFFECT) self.messageTimer = MESSAGE_FRAMES self.phase = "resolving" return @@ -4014,7 +4014,7 @@ function BattleState:cureBattleConfusion(itemId) local mon = self.battle.player local state = mon and self.battle:volatile(mon) if not (state and state.confuseCount) then - self.message = oneLine(ItemEffects.TEXT_NO_EFFECT) + self.message = oneLine(Strings(ItemEffects.TEXT_NO_EFFECT)) self.messageTimer = MESSAGE_FRAMES self.phase = "resolving" return diff --git a/src/ui/gen2/BattleTowerMenu.lua b/src/ui/gen2/BattleTowerMenu.lua index b47c0178..a121c249 100644 --- a/src/ui/gen2/BattleTowerMenu.lua +++ b/src/ui/gen2/BattleTowerMenu.lua @@ -76,7 +76,7 @@ function BattleTowerMenu.new(game, opts) -- `ld a, $1 / ld [wcd4f], a` (../pokecrystal/mobile/mobile_46.asm:1152-1153) self.cursor = 1 self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) return self end @@ -116,17 +116,17 @@ function BattleTowerMenu:confirm() if not row then -- ../pokecrystal/mobile/mobile_46.asm:1291-1303 `.asm_118a3c` self.phase = "quit" - self.message = QUIT_TEXT + self.message = Strings(QUIT_TEXT) self.yes = true return end if BattleTower.levelCheck(self.party, row.group) then - return self:refuse(TOPS_TEXT) + return self:refuse(Strings(TOPS_TEXT)) end local uber = BattleTower.ubersCheck(self.party, row.group) if uber then local name = (self.monName and self.monName(uber)) or uber - return self:refuse(string.format(UBER_TEXT, name)) + return self:refuse(Strings(UBER_TEXT, name)) end self:finish(row.group) end @@ -147,7 +147,7 @@ function BattleTowerMenu:updatePick() elseif input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self.phase = "quit" - self.message = QUIT_TEXT + self.message = Strings(QUIT_TEXT) self.yes = true end end @@ -165,12 +165,12 @@ function BattleTowerMenu:updateQuit() self:playSfx("Sfx_ReadText2") if self.yes then return self:finish(nil) end self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) self.cursor = 1 elseif input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) self.cursor = 1 end end @@ -181,7 +181,7 @@ function BattleTowerMenu:update(_dt) self.wait = (self.wait or 0) - 1 if self.wait > 0 then return end self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) self.cursor = 1 return end @@ -198,15 +198,15 @@ function BattleTowerMenu:drawPanel() end if self.phase == "quit" then Chrome.box(YN_X, YN_Y, YN_W, YN_H) - Chrome.print(YES_LABEL, YN_TEXT_X, YES_Y) - Chrome.print(NO_LABEL, YN_TEXT_X, NO_Y) + Chrome.print(Strings(YES_LABEL), YN_TEXT_X, YES_Y) + Chrome.print(Strings(NO_LABEL), YN_TEXT_X, NO_Y) Chrome.cursor(YN_TEXT_X - 1, self.yes and YES_Y or NO_Y) love.graphics.setColor(1, 1, 1, 1) return end Chrome.box(PICK_X, PICK_Y, PICK_W, PICK_H) local row = self.rows[self.cursor] - Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or CANCEL_LABEL, + Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or Strings(CANCEL_LABEL), ROW_X, ROW_Y) Chrome.print(UP_ARROW, ARROW_X, UP_Y) Chrome.print(DOWN_ARROW, ARROW_X, DOWN_Y) diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index d4748242..5226f6ac 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -396,7 +396,7 @@ function PartyMenu:finishSoftboiled() local before, after = FieldMoves.softboiledTransfer(user, target, self.softboiledCost or 0) if not before then - self:showItemResult(slot, { text = ItemEffects.TEXT_CANT_USE_ON_MON }) + self:showItemResult(slot, { text = Strings(ItemEffects.TEXT_CANT_USE_ON_MON) }) return end self.softboiledFrom, self.softboiledCost = nil, nil diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index eb4812a0..9b994618 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -825,8 +825,7 @@ function PokedexMenu:printEntry() -- Word for word what Yellow's PRNT says (src/ui/PokedexMenu.lua), so the two -- generations share one catalog entry and a translation covers both. local text = saved - and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", - name, tostring(saved)) + and (Strings("Printed %s's\ndata!\f", name) .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)) if self.game and self.game.stack then self.game.stack:push(TextBox.new(self.game, text)) diff --git a/tests/engine/launcher_return_pointer_bug2079.lua b/tests/engine/launcher_return_pointer_bug2079.lua new file mode 100644 index 00000000..2a865248 --- /dev/null +++ b/tests/engine/launcher_return_pointer_bug2079.lua @@ -0,0 +1,125 @@ +-- #2079: EXIT GAME / Close editor leave the confirming finger down over +-- Import Save. After remount the launcher must not treat that leftover as +-- a new tap (the file picker opening a few seconds later is that lift). +-- luajit tests/engine/launcher_return_pointer_bug2079.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local clock = 1000 +love.timer.getTime = function() return clock end + +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function launcher() + return setmetatable({ + _flex = true, + _actAt = {}, + _uiActions = {}, + }, RomImporter) +end + +-- ------- ignoreReturningPointer swallows a leftover hold + +do + local down = true + love.mouse.isDown = function() return down end + love.mouse.getPosition = function() return 280, 640 end + local imp = launcher() + imp:ignoreReturningPointer() + check(imp._prevMouseDown == true, + "returning marks the pointer already down so the first poll is not a press") + check(imp._suppressClickUntil > clock, + "and raises a click debounce over the leftover gesture") + check(imp._clickPt == nil, "any queued click is cleared") + + LauncherView.update(imp, 0.016) + check(imp._mouseAt == nil, "the leftover hold does not arm a drag/tap") + check(imp._clickPt == nil) + + clock = 1000.7 + LauncherView.update(imp, 0.016) + check(imp._mouseAt == nil, + "a still-down finger after the debounce window still does not arm (#2079)") + check(imp._clickPt == nil) + + down = false + LauncherView.update(imp, 0.016) + check(imp._clickPt == nil, "lifting the leftover finger is not a launcher tap") + check(imp._prevMouseDown == false, "the poll then sees the pointer idle") + + down = true + LauncherView.update(imp, 0.016) + check(imp._mouseAt ~= nil, "a new press after the leftover lifts arms normally") + down = false + LauncherView.update(imp, 0.016) + check(imp._clickPt ~= nil, "and that new press+release is a tap") + love.mouse.isDown = nil + love.mouse.getPosition = nil +end + +-- ------- unmatched / leftover touch release is not a tap + +do + clock = 2000 + local imp = launcher() + LauncherView.touchreleased(imp, "finger", 280, 640) + check(imp._clickPt == nil, + "a touch up with no matching press does not click Import Save") +end + +do + clock = 3000 + love.touch = love.touch or {} + love.touch.getTouches = function() return { "held" } end + local imp = launcher() + imp:ignoreReturningPointer() + check(imp._ignoreTouch.held == true, "already-down touch ids are ignored") + LauncherView.touchpressed(imp, "held", 280, 640) + check(imp._touchAt == nil or imp._touchAt.held == nil, + "the leftover finger does not start a launcher tap") + LauncherView.touchreleased(imp, "held", 280, 640) + check(imp._clickPt == nil, "and its lift does not click") + + local ran = false + LauncherView.queueAction(imp, "import-save", function() ran = true end) + eq(#imp._uiActions, 0, "debounce drops Import Save during the hold window") + check(not ran) + + clock = 3000.6 + LauncherView.touchpressed(imp, "fresh", 40, 40) + LauncherView.touchreleased(imp, "fresh", 40, 40) + check(imp._clickPt ~= nil, "a new finger after the window still taps") + love.touch.getTouches = nil +end + +-- ------- Close editor / overlay resume always debounces, even with no stick + +do + clock = 4000 + local imp = launcher() + imp.launcher = true + love.joystick = love.joystick or {} + love.joystick.getJoystickCount = function() return 0 end + imp:resumeAfterOverlay() + check(imp._prevMouseDown == true, + "resumeAfterOverlay debounces even when it does not re-arm the pad") + check(imp._suppressClickUntil > clock, + "so Close editor cannot click Import Save") +end + +-- ------- EXIT GAME rebuilds the launcher with the same guard + +do + local main = assert(io.open("main.lua")):read("*a") + local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") + check(body ~= nil, "main.lua still has returnToLauncher") + check(body:find("ignoreReturningPointer", 1, true) ~= nil, + "EXIT GAME asks the new launcher to ignore the leftover pointer (#2079)") +end + +T.finish("launcher_return_pointer_bug2079") diff --git a/tests/engine/printer_android_gallery_bug2103.lua b/tests/engine/printer_android_gallery_bug2103.lua new file mode 100644 index 00000000..dd5efb8f --- /dev/null +++ b/tests/engine/printer_android_gallery_bug2103.lua @@ -0,0 +1,108 @@ +-- #2103: Android diploma / dex prints write under prints/ in the app-private +-- save dir, which USB and file managers often show as empty. Printer.save +-- must call love.system.exportImage so GameActivity can copy into +-- Pictures/Gen1Recomp and media-scan the in-app file. +-- luajit tests/engine/printer_android_gallery_bug2103.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Printer = require("src.core.Printer") + +-- ------- Android: gallery bridge is called with the prints/ path + +do + local exported = {} + love.system.getOS = function() return "Android" end + love.system.exportImage = function(path) + exported[#exported + 1] = path + return true + end + + local path, err = Printer.save("diploma", 40, 36, function() end) + check(path ~= nil, "Printer.save still returns the prints/ path (" .. tostring(err) .. ")") + check(path:match("^prints/diploma_.*%.png$") ~= nil, + "the PNG stays under prints/ for the in-app copy") + eq(#exported, 1, "Android asks the native bridge to export the image") + eq(exported[1], path, "and hands it the same relative path") + eq(Printer.lastAlbumPath, "Pictures/Gen1Recomp", + "the Printed dialog can say Pictures/Gen1Recomp") + local where = Printer.savedWhereText(path) + check(where:find("Pictures/Gen1Recomp", 1, true) ~= nil, + "savedWhereText points at the public album") + check(where:find("save", 1, true) == nil + or where:find("Pictures", 1, true) ~= nil, + "and does not claim the invisible save folder alone") +end + +-- ------- Desktop: no gallery bridge call + +do + local calls = 0 + love.system.getOS = function() return "Linux" end + love.system.exportImage = function() + calls = calls + 1 + return true + end + local path = Printer.save("dex_1", 40, 36, function() end) + check(path ~= nil, "desktop print still writes") + eq(calls, 0, "desktop does not call exportImage") + eq(Printer.lastAlbumPath, nil, "desktop has no album hint") + local where = Printer.savedWhereText(path) + check(where:find(path, 1, true) ~= nil, + "desktop still names the save-folder PNG") +end + +-- ------- Missing bridge: print still succeeds (new .love on old APK) + +do + love.system.getOS = function() return "Android" end + love.system.exportImage = nil + local path = Printer.save("box_1", 40, 36, function() end) + check(path ~= nil, "print works when exportImage is absent (old APK)") + eq(Printer.lastAlbumPath, nil, "no album hint without the bridge") + local where = Printer.savedWhereText(path) + check(where:find(path, 1, true) ~= nil, + "old APK falls back to the save-folder path in the Printed dialog") + check(where:find("Pictures/Gen1Recomp", 1, true) == nil, + "and does not claim the gallery copy that never happened") +end + +-- ------- Bridge present but returns false / throws: same soft fallback + +do + love.system.getOS = function() return "Android" end + love.system.exportImage = function() error("JNI method missing") end + local path = Printer.save("surf_hiscore", 40, 20, function() end) + check(path ~= nil, "print survives a throwing exportImage bridge") + eq(Printer.lastAlbumPath, nil, "a failed bridge leaves no album hint") + + love.system.exportImage = function() return false end + path = Printer.save("box_2", 40, 20, function() end) + check(path ~= nil, "print survives exportImage returning false") + eq(Printer.lastAlbumPath, nil, "false from the bridge is not a gallery success") +end + +-- ------- Native + Lua surface pins + +do + local java = assert(io.open( + "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")):read("*a") + check(java:find("exportImageToGallery", 1, true) ~= nil, + "GameActivity exports exportImageToGallery") + check(java:find("Pictures", 1, true) ~= nil + and java:find("Gen1Recomp", 1, true) ~= nil, + "gallery target is Pictures/Gen1Recomp") + check(java:find("MediaScannerConnection", 1, true) ~= nil, + "and media-scans the in-app prints/ copy for USB") + + local wrap = assert(io.open( + "mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp")):read("*a") + check(wrap:find('"exportImage"', 1, true) ~= nil, + "love.system.exportImage is registered") +end + +T.finish("printer_android_gallery_bug2103") diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index 7b258b64..00054006 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -300,6 +300,47 @@ do "saveFilename still resolves the flat name with no slot in use") end +-- ---------------------------------------------- returnToLauncher slot refresh +-- In-process EXIT GAME (Android/iOS) keeps the process-wide slotsChecked +-- cache. Without refreshSlotResolution, a flat SAVE written after the +-- launcher already resolved "no slots" stays invisible until cold start. + +do + local files = fresh() + T.eq(#SaveData.listSlots("red"), 0, "session starts with no slots resolved") + + local save = SaveData.newGame() + save.player.name = "EXIT" + T.check(SaveData.save(save), "in-game SAVE writes the flat legacy file") + T.check(files["save.lua"] ~= nil, "flat save.lua exists") + + T.eq(#SaveData.listSlots("red"), 0, + "without a refresh the cached 'no slots' answer sticks") + T.eq(files["saves/red/slot1.lua"], nil, "and migration has not run yet") + + SaveData.setCart("nuzlocke", "abc") + SaveData.refreshSlotResolution("red") + T.eq(SaveData.getCart(), "nuzlocke", + "refreshSlotResolution leaves the active cart alone") + + local slots = SaveData.listSlots("red") + T.eq(#slots, 1, "after refresh, listSlots migrates the flat save") + T.eq(slots[1].id, "slot1", "into slot1") + T.eq(slots[1].name, "EXIT", "with the saved player name") + T.eq(files["save.lua"], nil, "and removes the flat legacy file") + T.check(files["saves/red/slot1.lua"] ~= nil, "as saves/red/slot1.lua") +end + +do + local main = assert(io.open("main.lua")):read("*a") + local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") + T.check(body ~= nil, "main.lua still has returnToLauncher") + T.check(body:find("refreshSlotResolution%(currentVersion%)", 1, false) ~= nil, + "EXIT GAME refreshes only the version just left") + T.check(body:find("resetSlotState", 1, true) == nil, + "and does not call resetSlotState (that would clear cart/seal state)") +end + love.filesystem = realFS T.finish("save_slots") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 5e9379aa..a507c4d6 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -58,6 +58,9 @@ stub.graphics = { newCanvas = function(w, h) local canvas = setmetatable({ w = w, h = h, setFilter = noop, released = false }, Image) function canvas:release() self.released = true end + function canvas:newImageData() + return love.image.newImageData(self.w or 8, self.h or 8) + end return canvas end, newSpriteBatch = function(image, size) @@ -352,7 +355,13 @@ function ImageData:getPixel() return 0, 0, 0, 1 end function ImageData:setPixel() end function ImageData:mapPixel() end function ImageData:paste() end -function ImageData:encode() return { getString = function() return "" end } end +function ImageData:encode(format, filename) + local bytes = "\137PNG\r\n\26\n" + if type(filename) == "string" and love.filesystem and love.filesystem.write then + love.filesystem.write(filename, bytes) + end + return { getString = function() return bytes end } +end stub.image = { newImageData = function(a, b)