From 18d61779eb84fe80b167f3ff53dc7263c0af119d Mon Sep 17 00:00:00 2001 From: Solidus Snake <63137482+TheRealSolidusSnake@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:32:37 -0400 Subject: [PATCH 1/9] Hang gen1tls on love.system and forward tls* through LegacyCompat. We already ship the DLL, but mods can't ffi-load it under the sandbox, and the compat love.system shim wasn't passing tls* through either. So wss:// still died on stock builds. Engine loads the dialer at boot; compat forwards those keys; clipboard/openURL stay stubbed. Co-authored-by: Cursor --- main.lua | 5 +++++ src/mods/LegacyCompat.lua | 15 ++++++++++++++- src/net/Gen1Tls.lua | Bin 0 -> 6938 bytes tests/modkit/cases/sandbox.lua | 5 +++++ 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/net/Gen1Tls.lua diff --git a/main.lua b/main.lua index 29f1e803..eb47215f 100644 --- a/main.lua +++ b/main.lua @@ -263,6 +263,11 @@ function love.load(args) -- of each flashing their own cmd.exe window (#606). No-op elsewhere. require("src.core.HostShell").hideHostConsole() + -- Hang gen1tls on love.system before mods boot. Android already has tls* + -- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws:// + -- rooms still work, wss:// just won't. + pcall(function() require("src.net.Gen1Tls").install() end) + -- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap -- the love loaders once so every generated-asset read falls back to the -- versioned save-dir copy. Never installed on desktop/Android/iOS. diff --git a/src/mods/LegacyCompat.lua b/src/mods/LegacyCompat.lua index 0f8a1cf2..05f5a3b6 100644 --- a/src/mods/LegacyCompat.lua +++ b/src/mods/LegacyCompat.lua @@ -662,7 +662,13 @@ end local function systemShim(ctx) local function real() return _G.love and _G.love.system or nil end - return { + -- tls* comes from the engine (Android JNI, or desktop gen1tls hung on + -- love.system at boot). Forward those; keep clipboard / openURL stubbed. + local TLS = { + tlsOpen = true, tlsStatus = true, tlsSend = true, + tlsReceive = true, tlsError = true, tlsClose = true, + } + local shim = { getOS = function() local sys = real() return sys and sys.getOS and sys.getOS() or "Unknown" @@ -697,6 +703,13 @@ local function systemShim(ctx) return false end, } + return setmetatable(shim, { + __index = function(_, key) + if not TLS[key] then return nil end + local sys = real() + return sys and sys[key] + end, + }) end local function eventShim(ctx) diff --git a/src/net/Gen1Tls.lua b/src/net/Gen1Tls.lua new file mode 100644 index 0000000000000000000000000000000000000000..3d9a33e90095522504ac219b48634323da30cf7d GIT binary patch literal 6938 zcmb`LYj0ac5Qg_N692(6AL>-pZ9%P|r5}2s6(~^Df<&Ys=5yz|b??zxd}q*mHZr)fX+b@e74rct_`rfHZax<5)|Jv-6WNKf8t zyru7{{T^$iWuyG>YX%a|(z(qV=oblRnmyE~l^&*F(hvGS)y$SgPSabh?rPSBzEfSJ z=Y~F?r_b!arSW}@BHt}~E_qnkN;hX?FKqwPX=b=zO;|^1fMVT z9M7u0Zdd zt>cO~T{90Rc0JYaNPgqR!2I`^qX*2odeTb2o7TSUa0xAw$8&7&>HkR2xOZk;6tN6z#+h?n8vl_FxYmqt zj%P>uhmv?^k#Jegt5f6BDE*dR2x%fVl`bN~)w$;5bzc!)#FMFc3mOkhJK2@FJTRLw zBOT+14VIo6o^W&|jjhya@OsCvA6ZPErl(>ou^z+vYQi1Q_`j1KkmnR#|Q z{c4&WsSoL+&1=p*GVUBm>cHej9eSesv5?uZRWNlRUvjm{-eg9RnM3KW>rm9Q)$_XI z?4hi9DJ#J4KzEPi56HPKhZ>_|j*TnM$-C_m@xUbJw$gu>GkF@mHMx{$!`%MN_xjG`l(O1HRM~*B30Vb zT(OsKrB~IurWau+oC1kv4En|)kAPd@GVh7eRHOsd9BTSs`t7Fc(zPen|EbR_tMH{< z_bMa?esRAEdNH>~tG*+ra_+WeRohU`v)1QNWWfu`qPGOr9q|LNkBgit8B2Ci)5ycF zzTThnDr!($vFugYI4)Er>H;z%Doys3l_>i|%#KuVsMg-MOMcgQMC}EGEMYw&^nso_ zzwbaMRaGewK!40~EIMYD*UE@_&gFG=rhFdt<+&o z#j-qOTkCcWm2>UO)+4gmzKIZBW|==*WK+%J)rfnRt=GPIMiZU+OquVub+_&sMpRmQ z`LN2BI@dSP4fwl#Rd8*&D=f>E`aDr~y{50Lq8_!SWIO0Jd&;Tyo=;V=2F6c64S20M zQw`J0X33H5*>_)yZ_#P$9=aX6q2Hx;Wp`ip9;y57&3d|Ly6;cx zEPbsUh~99Z>yhm)*b{P2V~=`WSJb7cVMHaOH*hW+>uM+6-$;vdkZwrD4k_rMva!$G zT#{F?#ZrjQGC$~^AkCbx0L`4MypwL`3w&TlIk0{ADmyZUJ$ZgqX=c~*eq*mW<*|Qf z7ag{j=(*q9>w7Die)?|mqU_;|G?wvtr9Bor3@(;^ICe_(GEVfd121<5<$SM&A<55= zt@PTk8woRi?|?-_kzEjH3s~hVqv!k1fGnePhP-A4h+ULteb~y|3Oesi>n(m4x=Tr( z^PB9^a?LE)H?5&+5J~W0x5vqKz8*!U$1GNaANhoZY(8!ylCuqHpf9`Zkb^ePk3HeZ zZj%-DZUK4oBW3ii?m^&Nd+;f(uPv|Zb>MNe+FK&vnfl1`-SLfh@viQjc-o#-*S&N{ zZ}q-voKQ+>UTfa!^h*6$FYm4--br7lyM@eP9CGL1?R8cA>h~UGYO~vRsxFYLf5?Uz z*qYBfj;QZ`^PaElH)UVpEIyKtCt@E6a#D#76CL!Hs%7Y_W4YS1msa(?_XL8x?aS{e z@UHAcp=EQg>i)J^+IBYk>`m+?|D(5Mgs?qET}r;2KYwiFsasZKR@%Vz7c<@2qHX+t zAr|BwyD19MQ_J}Im`m_C1;?uHC#ruVv231qAtPA#jWYCG Date: Sat, 15 Aug 2026 12:33:22 -0400 Subject: [PATCH 2/9] Fix Gen1Tls.lua encoding (was UTF-16). Co-authored-by: Cursor --- src/net/Gen1Tls.lua | Bin 6938 -> 3368 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/net/Gen1Tls.lua b/src/net/Gen1Tls.lua index 3d9a33e90095522504ac219b48634323da30cf7d..b0d7b7bc66a7193d683fe15f11e913e5a4d78655 100644 GIT binary patch literal 3368 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 Date: Sat, 15 Aug 2026 10:04:43 -0500 Subject: [PATCH 3/9] Harden required import picker for Android 13 --- .../java/org/love2d/android/GameActivity.java | 18 ++++++++++++++++- tests/engine/android_host_extension_test.lua | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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 dbd409e8..7a30850c 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 @@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity { private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; private static final String PICKED_MOD_FILENAME = "picked_mod.zip"; private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; + // Kept separate from the game-ROM destination so a dependency pick can + // never be mistaken for a game import when the picker returns on Android. + private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"; private static final String PENDING_EXPORT_FILENAME = "pending_export.sav"; private static final String EXPORT_DONE_FILENAME = "export_done.flag"; // Written when a SAF pick cannot be read at all, with the destination @@ -504,7 +507,8 @@ public class GameActivity extends SDLActivity { * picker-agnostic and unchanged. * * @param destFilename basename under the app save identity (e.g. - * picked_rom.gb, picked_mod.zip, picked_save.sav) + * picked_rom.gb, picked_mod.zip, picked_save.sav, or + * picked_required_import.bin) */ /** Legacy single-argument entry; resolves the save dir itself. */ @Keep @@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); + // The Storage Access Framework grants the returned content URI + // directly to this activity. Request the read grant explicitly as + // well: Android 13's scoped storage deliberately does not expose + // arbitrary paths or require broad media/storage permissions. + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); try { self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); return true; @@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); try { self.startActivityForResult( Intent.createChooser(intent, "Choose a file"), @@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity { return showFilePicker(PICKED_SAVE_FILENAME); } + /** Required-mod-file wrapper used by love.system.pickFile("required_import"). */ + @Keep + public static boolean showRequiredImportFilePicker() { + return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME); + } + /** * Relaunches the whole app for love.system.restartApp, used by * src/core/HostShell.lua when a mod toggle needs a cold boot (#575). diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 14d7ae5a..13d25a5e 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -55,4 +55,24 @@ check(not source:find("QuestActivity", 1, true) and not source:find("QuestBridge", 1, true), "generic Android activity must not require Quest classes") +-- Required mod files use Android's Storage Access Framework, which works with +-- Android 13 scoped storage without broad media/storage permissions. Keep the +-- native destination distinct so it cannot be consumed as a game ROM. +check(source:find('PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"', + 1, true), "required imports use their own Android picker destination") +check(source:find("showRequiredImportFilePicker", 1, true), + "Android exposes a required-import picker entry point") +check(source:find("Intent.ACTION_OPEN_DOCUMENT", 1, true) + and source:find("Intent.FLAG_GRANT_READ_URI_PERMISSION", 1, true), + "Android 13 uses SAF with an explicit read grant") + +local systemPath = "mobile/android/love/src/jni/love/src/modules/system/System.cpp" +local systemFile = assert(io.open(systemPath, "rb")) +local system = systemFile:read("*a") +systemFile:close() +check(system:find('strcmp(kind, "required_import")', 1, true) + and system:find('dest = "picked_required_import.bin"', 1, true) + and system:find('return "rom,mod,sav,required_import"', 1, true), + "native Android bridge advertises and routes required imports") + print("android_host_extension_test: ok") From 9bf15c33fd6112e117dce3cb06a3d92ca1687dc2 Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:56:58 -0500 Subject: [PATCH 4/9] Cover required imports across platforms --- docs/launcher.md | 1 + docs/modding.md | 12 ++++++ .../ios_required_import_picker_test.lua | 32 +++++++++++++++ tests/engine/uwp_native_picker_test.lua | 40 +++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 tests/engine/ios_required_import_picker_test.lua diff --git a/docs/launcher.md b/docs/launcher.md index aa087e48..24f79d0d 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -16,6 +16,7 @@ save directory as: | nil / `"rom"` | `picked_rom.gb` (open) | | `"mod"` | `picked_mod.zip` (open) | | `"sav"` / `"save"` | `picked_save.sav` (open) | +| `"required_import"` | `picked_required_import.bin` (open) | Export uses a separate API: `love.system.createFile(suggestedName)` → `GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies diff --git a/docs/modding.md b/docs/modding.md index 48074cbd..b369f504 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -148,6 +148,18 @@ older iOS-only `"stadium"` picker kind remains temporarily for compatibility. Android now returns `false` for unknown picker kinds instead of treating them as game-ROM picks. +### Platform import flow + +The same per-mod validation and private `mods//baseroms/` destination +applies on every supported platform. Windows, macOS, and Linux use the +launcher file chooser. Android uses the Storage Access Framework, and iOS uses +the Files document picker; both stage the choice as `picked_required_import.bin` +before validation. Xbox/UWP uses its native picker and hands the launcher a +temporary path. Switch/NX has no host picker, so the player copies a file to +`imports/baseroms/` over MTP and chooses the import again. No platform grants +the mod a host filesystem path or bypasses the manifest's size, format, and MD5 +checks. + ## Mods and Gold (Gen 2) The mod API is one API across both generations, but Gold runs its own battle diff --git a/tests/engine/ios_required_import_picker_test.lua b/tests/engine/ios_required_import_picker_test.lua new file mode 100644 index 00000000..56a13b1c --- /dev/null +++ b/tests/engine/ios_required_import_picker_test.lua @@ -0,0 +1,32 @@ +-- iOS required imports travel through the same document-picker contract as +-- Android. Keep the Swift bridge and liblove patch aligned: a build that has +-- only one side would show the import button but fail on device. +local function read(path) + local file = assert(io.open(path, "rb")) + local data = file:read("*a") + file:close() + return data +end + +local function check(value, message) + if not value then error(message, 2) end +end + +local bridge = read("mobile/ios/native/GRPickerBridge.swift") +check(bridge:find('case "required_import":', 1, true) + and bridge:find('destName = "picked_required_import.bin"', 1, true), + "iOS routes required imports to their own staged filename") +check(bridge:find("types.append(.data)", 1, true) + and bridge:find("types.append(.item)", 1, true), + "iOS required imports accept user-owned binary ROM files") +check(bridge:find('"rom,mod,sav,stadium,required_import"', 1, true), + "iOS advertises required_import to Lua before opening the picker") + +local patch = read("mobile/ios/patch_love_src.py") +check(patch:find("int w_pickFileKinds", 1, true) + and patch:find('{ "pickFileKinds", w_pickFileKinds }', 1, true), + "iOS liblove patch exposes the picker capability query") +check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true), + "iOS build patch compiles the required-import picker bridge") + +print("ios_required_import_picker_test: ok") diff --git a/tests/engine/uwp_native_picker_test.lua b/tests/engine/uwp_native_picker_test.lua index ab5c34d2..69fefef6 100644 --- a/tests/engine/uwp_native_picker_test.lua +++ b/tests/engine/uwp_native_picker_test.lua @@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]], check(removedPath == [[C:\LocalState\picked_mod.zip]], "removes the temporary copy after installation") +-- The UWP picker returns a temporary path rather than a mobile staged name. +-- Required imports must use that same picker and remain scoped to the selected +-- mod instead of relying on a desktop shell or Android/iOS inbox handling. +local pickedKind +love.system.pickFile = function(kind) + pickedKind = kind + return true +end +love.system.getPickedFile = function() + love.system.getPickedFile = function() return nil end + return [[C:\LocalState\picked_required_import.bin]] +end +removedPath = nil +local required = RomImporter.new(function() end, { launcher = true }) +required.mods = { { + id = "needs-source", + manifest = { + id = "needs-source", name = "Needs source", + required_imports = { { + id = "source", name = "Source", file = "source.bin", + md5 = { "00000000000000000000000000000000" }, + } }, + }, +} } +required._importRequiredSource = function(self, modId, importId, path) + self.requiredPath = { modId = modId, importId = importId, path = path } + return true +end +required:chooseRequiredImport("needs-source", "source") +check(pickedKind == "required_import", "UWP requests the required-import picker kind") +required:update(0) +check(required.requiredPath and required.requiredPath.path + == [[C:\LocalState\picked_required_import.bin]], + "UWP routes the picked dependency to its declared import") +check(required.requiredPath.modId == "needs-source" + and required.requiredPath.importId == "source", + "UWP preserves the pending mod and import identity") +check(removedPath == [[C:\LocalState\picked_required_import.bin]], + "UWP removes its temporary required-import copy after validation") + love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile love.system.getPickedFile = saved.getPickedFile From 3a997e8a62cc4f20f7a3978841f0dcd409edd683 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Fri, 14 Aug 2026 22:57:50 -0500 Subject: [PATCH 5/9] fix(save-editor): clean up module registry and pristine Data state on editor unload so subsequent edits perform safely --- main.lua | 5 +++++ src/core/Data.lua | 3 +++ 2 files changed, 8 insertions(+) diff --git a/main.lua b/main.lua index eb47215f..8b70276f 100644 --- a/main.lua +++ b/main.lua @@ -166,6 +166,11 @@ function closeEditor() require("src.import.CacheFs").unmountVersion(version) require("src.core.Data"):unloadGenerated() end + for k in pairs(package.loaded) do + if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then + package.loaded[k] = nil + end + end editorVersion = nil restoreWindow() Importer = editorHost diff --git a/src/core/Data.lua b/src/core/Data.lua index 405fe107..13c997b2 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -280,11 +280,14 @@ function Data:unloadGenerated() if not pristine[key] then self[key] = nil end end end + self._pristineKeys = nil for _, name in ipairs(MODULES) do package.loaded["data.generated." .. name] = nil + self[name] = nil end for _, name in ipairs(OPTIONAL) do package.loaded["data.generated." .. name] = nil + self[name] = nil end end From f06c4d45845382a9c2573f2a88b67e10decebc5f Mon Sep 17 00:00:00 2001 From: 1jamie Date: Fri, 14 Aug 2026 22:59:17 -0500 Subject: [PATCH 6/9] fix(save-editor): guard event scraping for mobile environments and fix editor state reset on close --- tools/save-editor/Catalog.lua | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 9ef209e7..12fe8e7f 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -40,10 +40,11 @@ end local function shellListLua(dir) local out = {} + if not (io and io.popen) then return out end if package.config:sub(1, 1) == "\\" then -- cmd has no ls; dir /b prints bare names, so re-attach the directory - local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir)) - if p then + local ok, p = pcall(io.popen, string.format('dir /b "%s\\*.lua" 2>nul', dir)) + if ok and p then for line in p:lines() do if line ~= "" then table.insert(out, dir .. "/" .. line) end end @@ -51,8 +52,8 @@ local function shellListLua(dir) end return out end - local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir)) - if p then + local ok, p = pcall(io.popen, string.format('ls "%s"/*.lua 2>/dev/null', dir)) + if ok and p then for line in p:lines() do table.insert(out, line) end @@ -64,8 +65,8 @@ end local function readText(path) local fs = love and love.filesystem if fs and fs.read and fs.getInfo and fs.getInfo(path) then - local body = fs.read(path) - if body then return body end + local ok, body = pcall(fs.read, path) + if ok and body then return body end end local f = io.open(path, "r") if not f then return nil end @@ -78,7 +79,7 @@ end -- scripts show up beside the vanilla EVENT_ ones function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) listFiles = listFiles or function(dir) - return loveListLua(dir) or shellListLua(dir) + return loveListLua(dir) or shellListLua(dir) or {} end local found = {} @@ -97,7 +98,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) dirs[#dirs + 1] = dir end for _, dir in ipairs(dirs) do - for _, path in ipairs(listFiles(dir)) do + local files = listFiles(dir) or {} + for _, path in ipairs(files) do local body = readText(path) if body then eat(body) end end From b29b6fd7bd3fbfab1083aaee4ab6dea1af7f7652 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sat, 15 Aug 2026 11:25:33 -0500 Subject: [PATCH 7/9] fix(save-editor): reload Data and mods if App.dataVersion differs from active session --- tools/save-editor/App.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 260aadab..5e45e56f 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -146,7 +146,7 @@ function App.load(pathOverride, opts) -- the same mod set the game loads, merged into Data before the catalogs -- build, so modded species/items/moves are editable and MonOps stops -- asserting on them - if not mods then + if not mods or App.dataVersion ~= opts.version then -- One loader per editor session. A previous session leaves Data holding -- that session's merged registries (and possibly the other game's cache), -- and a second builtin registration over them collides -- "statuses From e24410f0fb11ce8b63050f1a9c318d35f3c20237 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sat, 15 Aug 2026 12:30:58 -0500 Subject: [PATCH 8/9] fix: improve Gen2 data loading and error handling to prevent crashes when cache modules are missing on android --- main.lua | 41 +++++++++++++++++++++++-- src/core/Data.lua | 51 ++++++++++++++++++++++---------- tests/save_editor_gen2_tests.lua | 20 +++++++++++++ tools/save-editor/Gen.lua | 36 ++++++++++++++++------ 4 files changed, 121 insertions(+), 27 deletions(-) diff --git a/main.lua b/main.lua index 8b70276f..23b2385a 100644 --- a/main.lua +++ b/main.lua @@ -148,9 +148,44 @@ local function openEditor(version, slotId) editorMode = true resizeForEditor() addEditorRequirePath() - EditorApp = require("App") - EditorApp.load(path, { version = version, slotId = slotId, embedded = true, - onClose = function() closeEditor() end }) + local okReq, appOrErr = pcall(require, "App") + if not okReq then + editorMode = false + if version then + require("src.import.CacheFs").unmountVersion(version) + end + restoreWindow() + Importer = editorHost + editorHost = nil + editorVersion = nil + if Importer and Importer.resumeAfterOverlay then + Importer:resumeAfterOverlay() + end + refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").") + return + end + EditorApp = appOrErr + local okLoad, loadErr = pcall(EditorApp.load, path, { + version = version, slotId = slotId, embedded = true, + onClose = function() closeEditor() end, + }) + if not okLoad then + editorMode = false + if EditorApp.unload then pcall(EditorApp.unload) end + EditorApp = nil + if version then + require("src.import.CacheFs").unmountVersion(version) + require("src.core.Data"):unloadGenerated() + end + restoreWindow() + Importer = editorHost + editorHost = nil + editorVersion = nil + if Importer and Importer.resumeAfterOverlay then + Importer:resumeAfterOverlay() + end + refuse("Could not open the save editor (" .. tostring(loadErr) .. ").") + end end -- Back to the launcher. Everything the editor mounted or cached has to come diff --git a/src/core/Data.lua b/src/core/Data.lua index 13c997b2..31d7932a 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -14,6 +14,14 @@ local MODULES = { -- Optional for compatibility with developer and stale caches. local OPTIONAL = { "audio", "palettes", "icons" } +-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has +-- maps/text/pokemon/items, not text_pointers / trainer_headers / field). +-- Desktop can still `require` Red's copies from the source tree, so Gold +-- Edit appeared to work there; an Android APK has only the per-version +-- cache, so Data:load used to throw on the first Gold Edit and take the +-- activity down. Empty tables are enough for seedDefaults / the editor. +local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true } + -- Vanilla defaults for rules exposed through the constants registry. A -- value has to exist before a mod can patch it; each one matches the -- engine's no-mod behavior, so seeding them changes nothing on a vanilla @@ -99,7 +107,11 @@ end -- Fills only what the cache is missing, so an importer that learns to -- stamp one of these keys silently takes over from the engine. function Data:seedDefaults() - local constants = self.constants + local constants = self.constants or {} + self.constants = constants + self.field = self.field or {} + self.maps = self.maps or {} + self.pokemon = self.pokemon or {} for key, value in pairs(CONSTANT_DEFAULTS) do if constants[key] == nil then constants[key] = copy(value) end end @@ -108,7 +120,11 @@ function Data:seedDefaults() if constants.dexSize == nil then local highest = 0 for _, def in pairs(self.pokemon) do - if def.dex and def.dex > highest then highest = def.dex end + -- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation + -- scalars beside species rows. + if type(def) == "table" and def.dex and def.dex > highest then + highest = def.dex + end end constants.dexSize = highest end @@ -212,36 +228,41 @@ local function loadModule(dir, name) if not chunk then return false, err end return pcall(chunk) end - local ok, mod = pcall(require, "data.generated." .. name) - if ok then return true, mod end - -- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's - -- cache explicitly when require cannot see the mounted tree. local CacheFs = require("src.import.CacheFs") local GameVersion = require("src.core.GameVersion") local path = "data/generated/" .. name .. ".lua" local bytes = CacheFs.readActive(path) if type(bytes) == "string" then - local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path) - if not chunk then return false, err or mod end - return pcall(chunk) + local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path) + if chunk then + local ok, res = pcall(chunk) + if ok then return true, res end + end end - return false, mod + local ok, mod = pcall(require, "data.generated." .. name) + if ok then return true, mod end + return false, nil end function Data:load() local dir = os.getenv("POKEPORT_DATA_DIR") + local gen2 = require("src.core.GameVersion").generation() == 2 for _, name in ipairs(MODULES) do local ok, mod = loadModule(dir, name) if not ok then - if dir then + if gen2 and GEN2_OPTIONAL[name] then + self[name] = {} + elseif dir then error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)") :format(dir, name, mod)) + else + error(("missing generated data module 'data/generated/%s.lua'.\n" .. + "Import the ROM again or rebuild developer data.\n(%s)") + :format(name, mod)) end - error(("missing generated data module 'data/generated/%s.lua'.\n" .. - "Import the ROM again or rebuild developer data.\n(%s)") - :format(name, mod)) + else + self[name] = mod end - self[name] = mod end for _, name in ipairs(OPTIONAL) do local ok, mod = loadModule(dir, name) diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index 3114ae98..365ec70e 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -373,5 +373,25 @@ do check(main ~= nil, "saveFilename resolves for gold") end +-- Gold's cache has no text_pointers / trainer_headers / field. Data:load +-- used to throw in seedDefaults (self.field.boot) after filling pokemon +-- with provenance scalars. That is the Android first-Edit CTD: the APK +-- cannot fall back to Red's source-tree copies the way a desktop checkout +-- can. +do + GameVersion.set("gold") + local Data = require("src.core.Data") + Data.constants = {} + Data.pokemon = { generation = 2, CYNDAQUIL = { dex = 155 } } + Data.maps = {} + Data.field = nil + Data.trainer_headers = nil + local ok, err = pcall(function() Data:seedDefaults() end) + check(ok, "gold seedDefaults survives a Gold-shaped cache: " .. tostring(err)) + check(type(Data.field) == "table", "seedDefaults creates field when Gold omitted it") + eq(Data.constants.dexSize, 155, "dexSize ignores pokemon.generation scalar") + GameVersion.set("red") +end + print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index 9050fbd0..695aa9b8 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -78,15 +78,28 @@ function Gen.bindGoldData(data) if data.palettes and data.gen2Palettes == nil then data.gen2Palettes = data.palettes end - if data.gen2Roofs == nil and data.roofs == nil then - local ok, roofs = pcall(require, "data.generated.roofs") - if ok and type(roofs) == "table" then - data.roofs = roofs - data.gen2Roofs = roofs + + local loadGen = function(rel) + local CacheFs = require("src.import.CacheFs") + local bytes = CacheFs.readActive("data/generated/" .. rel .. ".lua") + if type(bytes) == "string" then + local chunk = loadstring(bytes, "@gold/data/generated/" .. rel .. ".lua") + if chunk then + local ok, res = pcall(chunk) + if ok and type(res) == "table" then return res end + end end - elseif data.roofs and data.gen2Roofs == nil then - data.gen2Roofs = data.roofs + local ok, res = pcall(require, "data.generated." .. rel) + if ok and type(res) == "table" then return res end + return nil end + + data.gen2Palettes = data.gen2Palettes or loadGen("palettes") + data.gen2Icons = data.gen2Icons or loadGen("icons") + data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex") + data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks") + data.gen2Roofs = data.gen2Roofs or loadGen("roofs") or data.roofs + data.gen2Sprites = data.gen2Sprites or loadGen("sprites") return data end @@ -213,10 +226,15 @@ function Gen.playerMap(save) if Gen.of(save) == 2 then local p = save.position if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end - return save.spawn, 0, 0 + if type(save.spawn) == "table" then + return save.spawn.map or "PLAYERS_HOUSE_2F", save.spawn.x or 0, save.spawn.y or 0, save.spawn.facing + elseif type(save.spawn) == "string" then + return save.spawn, 0, 0 + end + return "PLAYERS_HOUSE_2F", 3, 3 end local p = save.player or {} - return p.map, p.x or 0, p.y or 0 + return p.map or "REDS_HOUSE_2F", p.x or 0, p.y or 0 end function Gen.setPlayerHere(save, mapId, x, y, facing) From cec1f196be159cd745c75fdf06a2e97581e2dd30 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sat, 15 Aug 2026 12:45:31 -0500 Subject: [PATCH 9/9] refactor: implement HostShell transport abstraction for multi-platform update fetching and downloads so the "check for updates" button works on mobile os --- docs/updater.md | 18 +++-- src/update/Check.lua | 6 +- src/update/check_worker.lua | 127 ++++++++++++++++++------------------ 3 files changed, 81 insertions(+), 70 deletions(-) diff --git a/docs/updater.md b/docs/updater.md index f117c898..7815874a 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -126,11 +126,19 @@ bundled game, in that case. already driving the frame. A payload that must change `love.run` itself needs a `minShell` bump so an older shell refuses to chainload it rather than running with half its intended behavior. -- **Android has no in-app download transport yet.** `check_worker.lua` - shells out to curl for both the release check and the download; curl is - absent on Android, so `Check` degrades to `status = "error"` there (the - launcher UI hides on that status) and the player is directed to the - releases page via `Check.releaseUrl()` instead. +- **Android and iOS use the native download bridge, not curl.** Neither + platform ships curl, so the old `check_worker.lua` path (shell out to curl) + always landed on `error` and the launcher chip's "Check for updates" tap + was a no-op. The worker now talks through `HostShell`, the same transport + as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile. + On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on + iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded + APK or IPA can therefore check GitHub and fetch the `.love` payload + in-app. If neither transport exists, the worker reports `needs_full` and + the launcher chip opens `Check.releaseUrl()`. Native package-only changes + still need a full reinstall (`minShell` / `payloadHost` gate → + `needs_full`). Applying a downloaded payload on Android relaunches via + `love.system.restartApp`; iOS still uses in-process `quit("restart")`. - **Dev/source runs never self-update.** `Boot.run` returns immediately when `love.filesystem.isFused()` is false, and a working tree's `engine` is the `"0.0.0-dev"` placeholder that always reports up to date, so a source diff --git a/src/update/Check.lua b/src/update/Check.lua index 728789aa..8e655733 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -8,9 +8,9 @@ -- "update_check_state" worker -> main: { status, latest, progress, error } -- -- Nothing here ever blocks or throws into the game loop: when love.thread is --- absent (the headless test stub) or the worker cannot run (no curl, Android), --- state() simply reports "error" and the UI hides itself. See the shared --- contract in the task brief for the status vocabulary and the file layout. +-- absent (the headless test stub) or the worker cannot run, state() reports +-- "error" (or the worker reports "needs_full" when there is no transport). +-- See the shared contract in the task brief for the status vocabulary. -- -- The release-JSON extraction and the sums parsing are exported as pure -- functions (no love.* calls) so plain-Lua tests can cover them, and so the diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index d60a349b..7d592ec6 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -5,11 +5,10 @@ -- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } -- "update_check_state" out: { status, latest, progress, error } -- --- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+ --- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error, --- or a hung download degrades to a "error"/"needs_full" state rather than --- blocking or crashing the game. On Android curl is absent and the check --- soft-fails to "error", which the UI hides. +-- Transport is HostShell: curl via io.popen on desktop, the JNI +-- love.system.httpDownload bridge on Android (same path the mod catalog +-- already uses). A missing transport, an HTTP error, or a hung download +-- degrades to "error"/"needs_full" rather than blocking or crashing the game. -- -- Fresh love threads do not carry the "src.*" package searcher, so sibling -- modules are pulled in with love.filesystem.load exactly like @@ -63,9 +62,12 @@ local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/la local pending = nil -- --------------------------------------------------------------------------- --- shell / curl +-- shell / fetch -- --------------------------------------------------------------------------- +local UA = "gen1recomp-updater" +local GH_ACCEPT = "application/vnd.github+json" + local function shq(s) s = tostring(s) if isWindows then @@ -74,31 +76,17 @@ local function shq(s) return "'" .. s:gsub("'", "'\\''") .. "'" end --- run curl and return its response body (text), or nil on any failure. Used --- for the small text resources (release JSON, sums file); -f makes curl exit --- non-zero and emit nothing on an HTTP error, so an empty read is a failure. -local function curlCapture(url) - local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " - .. "-H " .. shq("User-Agent: gen1recomp-updater") .. " " - .. "-H " .. shq("Accept: application/vnd.github+json") .. " " - .. shq(url) - local pipe = HostShell.popen(cmd) - if not pipe then return nil end - local out = pipe:read("*a") - -- HostShell.pclose, not pipe:close(): a close outside the spawn lock can - -- free a FILE while another thread's popen walks the stream list, which - -- deadlocks that thread permanently (see HostShell's popen notes). - HostShell.pclose(pipe) - if not out or out == "" then return nil end - return out +-- Small text resources (release JSON, sums file) through HostShell so Android +-- hits the JNI bridge instead of a curl binary that is never on the device. +local function fetchText(url, accept) + if not HostShell then return nil end + local body = HostShell.httpGet(url, UA, accept) + if type(body) ~= "string" or body == "" then return nil end + return body end -local function haveCurl() - local pipe = HostShell.popen("curl --version") - if not pipe then return false end - local out = pipe:read("*a") - HostShell.pclose(pipe) - return out ~= nil and out:find("curl", 1, true) ~= nil +local function canFetch() + return HostShell and HostShell.canFetch() end -- --------------------------------------------------------------------------- @@ -170,12 +158,14 @@ end local function doCheck() post({ status = "checking" }) - if not haveCurl() then - post({ status = "error", error = "curl not available" }) + if not canFetch() then + -- No curl and no JNI bridge: the chip becomes "Open releases" so a tap + -- still does something instead of retrying a check that cannot succeed. + post({ status = "needs_full" }) return end - local body = curlCapture(API_URL) + local body = fetchText(API_URL, GH_ACCEPT) if not body then post({ status = "error", error = "release check failed" }) return @@ -212,7 +202,7 @@ local function doCheck() -- pulling the bytes again. local finalRel = "updates/" .. rel.payloadName if love.filesystem.getInfo(finalRel) then - local sums = curlCapture(rel.sums.url) + local sums = fetchText(rel.sums.url) if sums and verifyPayload(finalRel, rel.payloadName, sums) then if gatePasses(finalRel) == false then love.filesystem.remove(finalRel) @@ -279,39 +269,52 @@ local function doDownload() local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done" local size = rel.payload.size or 0 - launchDownload(rel.payload.url, partAbs, doneAbs) + if HostShell and HostShell.haveCurl() then + launchDownload(rel.payload.url, partAbs, doneAbs) - -- poll the .part size for progress until curl drops the done-marker; a - -- stalled or run-away transfer breaks out and lets verification fail cleanly - local waited, lastSize, lastChange = 0, -1, 0 - while true do - -- A queued quit means the window already closed. Bail so the join in - -- Check.shutdown does not hold the dead window's process (and, on - -- Windows, its folder) open for up to the whole transfer (#727). The - -- quit stays on the channel for the command loop; the detached curl - -- times out on its own and the next launch's doCheck verifies and - -- re-offers whatever landed. - local peeked = cmdCh:peek() - if type(peeked) == "table" and peeked.cmd == "quit" then return end - if love.filesystem.getInfo(doneRel) then break end - local pinfo = love.filesystem.getInfo(partRel) - local cur = (pinfo and pinfo.size) or 0 - if size > 0 then - local p = cur / size - if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" - post({ status = "downloading", latest = rel.version, progress = p }) - else - post({ status = "downloading", latest = rel.version }) + -- poll the .part size for progress until curl drops the done-marker; a + -- stalled or run-away transfer breaks out and lets verification fail cleanly + local waited, lastSize, lastChange = 0, -1, 0 + while true do + -- A queued quit means the window already closed. Bail so the join in + -- Check.shutdown does not hold the dead window's process (and, on + -- Windows, its folder) open for up to the whole transfer (#727). The + -- quit stays on the channel for the command loop; the detached curl + -- times out on its own and the next launch's doCheck verifies and + -- re-offers whatever landed. + local peeked = cmdCh:peek() + if type(peeked) == "table" and peeked.cmd == "quit" then return end + if love.filesystem.getInfo(doneRel) then break end + local pinfo = love.filesystem.getInfo(partRel) + local cur = (pinfo and pinfo.size) or 0 + if size > 0 then + local p = cur / size + if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" + post({ status = "downloading", latest = rel.version, progress = p }) + else + post({ status = "downloading", latest = rel.version }) + end + if cur ~= lastSize then lastSize, lastChange = cur, waited end + if waited - lastChange > 60 then break end -- 60s with no growth: give up + if waited > 960 then break end -- absolute ceiling + love.timer.sleep(0.25) + waited = waited + 0.25 end - if cur ~= lastSize then lastSize, lastChange = cur, waited end - if waited - lastChange > 60 then break end -- 60s with no growth: give up - if waited > 960 then break end -- absolute ceiling - love.timer.sleep(0.25) - waited = waited + 0.25 + love.filesystem.remove(doneRel) + else + -- Android JNI bridge: blocking write, same as fetch_worker. Progress + -- cannot be sampled from inside httpDownload. + local ok = HostShell and HostShell.httpDownload( + rel.payload.url, partAbs, UA, nil, 900) + if not ok then + love.filesystem.remove(partRel) + post({ status = "error", error = "download failed" }) + return + end + post({ status = "downloading", latest = rel.version, progress = 0.999 }) end - love.filesystem.remove(doneRel) - local sums = curlCapture(rel.sums and rel.sums.url or "") + local sums = fetchText(rel.sums and rel.sums.url or "") if not sums then love.filesystem.remove(partRel) post({ status = "error", error = "checksum fetch failed" })