This commit is contained in:
bryanthaboi
2026-09-04 09:34:16 -04:00
26 changed files with 677 additions and 89 deletions
+1 -1
View File
@@ -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))
+1 -2
View File
@@ -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)
+13 -1
View File
@@ -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
+2 -2
View File
@@ -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)
@@ -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();
@@ -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
@@ -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
@@ -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.
@@ -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 },
@@ -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
+11 -13
View File
@@ -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
+39
View File
@@ -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/<name>_<stamp>.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
+18 -5
View File
@@ -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
+53 -41
View File
@@ -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
+19 -2
View File
@@ -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
+38
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+11 -11
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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))
@@ -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")
@@ -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")
+41
View File
@@ -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")
+10 -1
View File
@@ -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)