Merge pull request #1355 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-08-15 14:29:27 -04:00
committed by GitHub
18 changed files with 467 additions and 108 deletions
+1
View File
@@ -16,6 +16,7 @@ save directory as:
| nil / `"rom"` | `picked_rom.gb` (open) | | nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) | | `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) | | `"sav"` / `"save"` | `picked_save.sav` (open) |
| `"required_import"` | `picked_required_import.bin` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)` Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies `GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
+12
View File
@@ -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 Android now returns `false` for unknown picker kinds instead of treating them
as game-ROM picks. as game-ROM picks.
### Platform import flow
The same per-mod validation and private `mods/<mod-id>/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) ## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle The mod API is one API across both generations, but Gold runs its own battle
+13 -5
View File
@@ -126,11 +126,19 @@ bundled game, in that case.
already driving the frame. A payload that must change `love.run` itself 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 needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior. than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua` - **Android and iOS use the native download bridge, not curl.** Neither
shells out to curl for both the release check and the download; curl is platform ships curl, so the old `check_worker.lua` path (shell out to curl)
absent on Android, so `Check` degrades to `status = "error"` there (the always landed on `error` and the launcher chip's "Check for updates" tap
launcher UI hides on that status) and the player is directed to the was a no-op. The worker now talks through `HostShell`, the same transport
releases page via `Check.releaseUrl()` instead. 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 - **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the `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 `"0.0.0-dev"` placeholder that always reports up to date, so a source
+48 -3
View File
@@ -148,9 +148,44 @@ local function openEditor(version, slotId)
editorMode = true editorMode = true
resizeForEditor() resizeForEditor()
addEditorRequirePath() addEditorRequirePath()
EditorApp = require("App") local okReq, appOrErr = pcall(require, "App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true, if not okReq then
onClose = function() closeEditor() end }) 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 end
-- Back to the launcher. Everything the editor mounted or cached has to come -- Back to the launcher. Everything the editor mounted or cached has to come
@@ -166,6 +201,11 @@ function closeEditor()
require("src.import.CacheFs").unmountVersion(version) require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated() require("src.core.Data"):unloadGenerated()
end 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 editorVersion = nil
restoreWindow() restoreWindow()
Importer = editorHost Importer = editorHost
@@ -263,6 +303,11 @@ function love.load(args)
-- of each flashing their own cmd.exe window (#606). No-op elsewhere. -- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole() 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 -- 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 -- the love loaders once so every generated-asset read falls back to the
-- versioned save-dir copy. Never installed on desktop/Android/iOS. -- versioned save-dir copy. Never installed on desktop/Android/iOS.
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; 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_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; 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 PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag"; private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Written when a SAF pick cannot be read at all, with the destination // 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. * picker-agnostic and unchanged.
* *
* @param destFilename basename under the app save identity (e.g. * @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. */ /** Legacy single-argument entry; resolves the save dir itself. */
@Keep @Keep
@@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); 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 { try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true; return true;
@@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT); Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); intent.setType("*/*");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
try { try {
self.startActivityForResult( self.startActivityForResult(
Intent.createChooser(intent, "Choose a file"), Intent.createChooser(intent, "Choose a file"),
@@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity {
return showFilePicker(PICKED_SAVE_FILENAME); 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 * Relaunches the whole app for love.system.restartApp, used by
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575). * src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
+39 -15
View File
@@ -14,6 +14,14 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches. -- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" } 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 -- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the -- 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 -- 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 -- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine. -- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults() 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 for key, value in pairs(CONSTANT_DEFAULTS) do
if constants[key] == nil then constants[key] = copy(value) end if constants[key] == nil then constants[key] = copy(value) end
end end
@@ -108,7 +120,11 @@ function Data:seedDefaults()
if constants.dexSize == nil then if constants.dexSize == nil then
local highest = 0 local highest = 0
for _, def in pairs(self.pokemon) do 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 end
constants.dexSize = highest constants.dexSize = highest
end end
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
if not chunk then return false, err end if not chunk then return false, err end
return pcall(chunk) return pcall(chunk)
end 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 CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local path = "data/generated/" .. name .. ".lua" local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path) local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path) local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end if chunk then
return pcall(chunk) local ok, res = pcall(chunk)
if ok then return true, res end
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 end
function Data:load() function Data:load()
local dir = os.getenv("POKEPORT_DATA_DIR") local dir = os.getenv("POKEPORT_DATA_DIR")
local gen2 = require("src.core.GameVersion").generation() == 2
for _, name in ipairs(MODULES) do for _, name in ipairs(MODULES) do
local ok, mod = loadModule(dir, name) local ok, mod = loadModule(dir, name)
if not ok then 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)") error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
:format(dir, name, mod)) :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 end
error(("missing generated data module 'data/generated/%s.lua'.\n" .. else
"Import the ROM again or rebuild developer data.\n(%s)") self[name] = mod
:format(name, mod))
end end
self[name] = mod
end end
for _, name in ipairs(OPTIONAL) do for _, name in ipairs(OPTIONAL) do
local ok, mod = loadModule(dir, name) local ok, mod = loadModule(dir, name)
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
if not pristine[key] then self[key] = nil end if not pristine[key] then self[key] = nil end
end end
end end
self._pristineKeys = nil
for _, name in ipairs(MODULES) do for _, name in ipairs(MODULES) do
package.loaded["data.generated." .. name] = nil package.loaded["data.generated." .. name] = nil
self[name] = nil
end end
for _, name in ipairs(OPTIONAL) do for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil package.loaded["data.generated." .. name] = nil
self[name] = nil
end end
end end
+14 -1
View File
@@ -662,7 +662,13 @@ end
local function systemShim(ctx) local function systemShim(ctx)
local function real() return _G.love and _G.love.system or nil end 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() getOS = function()
local sys = real() local sys = real()
return sys and sys.getOS and sys.getOS() or "Unknown" return sys and sys.getOS and sys.getOS() or "Unknown"
@@ -697,6 +703,13 @@ local function systemShim(ctx)
return false return false
end, 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 end
local function eventShim(ctx) local function eventShim(ctx)
+100
View File
@@ -0,0 +1,100 @@
-- Load gen1tls from next to the exe and put its poll API on love.system --
-- same tlsOpen / tlsSend / ... shape Android already exposes through JNI.
--
-- Mods can't require("ffi") under the sandbox, so they can't load the DLL
-- themselves even when it's sitting right there. We do it here, before any
-- mod runs. LegacyCompat's love.system shim forwards the tls* keys through
-- (clipboard / openURL stay stubbed).
--
-- true = tlsOpen is ready. Already present (Android), no FFI, or no DLL:
-- just return false and move on; plain ws:// rooms don't care.
local Gen1Tls = {}
local function exeDir()
if love and love.filesystem and love.filesystem.getSourceBaseDirectory then
local base = love.filesystem.getSourceBaseDirectory()
if type(base) == "string" and base ~= "" then return base end
end
if type(arg) == "table" and type(arg[0]) == "string" then
local dir = arg[0]:match("^(.*)[/\\]")
if dir and dir ~= "" then return dir end
end
return "."
end
local function fileReadable(path)
local f = io.open(path, "rb")
if not f then return false end
f:close()
return true
end
local function libNames()
local osName = (love and love.system and love.system.getOS and love.system.getOS()) or ""
if osName == "Windows" then return { "gen1tls.dll" } end
if osName == "OS X" then return { "libgen1tls.dylib", "gen1tls.dylib" } end
if osName == "Linux" then return { "libgen1tls.so", "gen1tls.so" } end
return { "gen1tls.dll", "libgen1tls.so", "libgen1tls.dylib" }
end
function Gen1Tls.install()
if not (love and love.system) then return false end
if type(love.system.tlsOpen) == "function" then return true end
local okFfi, ffi = pcall(require, "ffi")
if not okFfi or type(ffi) ~= "table" then return false end
ffi.cdef[[
int gen1tls_open(const char *host, int port);
int gen1tls_status(int handle);
int gen1tls_send(int handle, const char *data, int length);
int gen1tls_receive(int handle, char *buf, int max);
int gen1tls_error(int handle, char *buf, int max);
void gen1tls_close(int handle);
]]
local dir = exeDir()
local lib
for _, name in ipairs(libNames()) do
local path = dir .. "/" .. name
if fileReadable(path) then
local ok, loaded = pcall(ffi.load, path)
if ok then lib = loaded; break end
end
local ok, loaded = pcall(ffi.load, name)
if ok then lib = loaded; break end
end
if not lib then return false end
local errBuf = ffi.new("char[512]")
local recvBuf = ffi.new("char[65536]")
love.system.tlsOpen = function(host, port)
return lib.gen1tls_open(host, tonumber(port) or 0)
end
love.system.tlsStatus = function(handle)
return lib.gen1tls_status(handle)
end
love.system.tlsSend = function(handle, data)
data = data or ""
return lib.gen1tls_send(handle, data, #data)
end
love.system.tlsReceive = function(handle, max)
max = math.min(tonumber(max) or 8192, 65536)
if max <= 0 then return "" end
local n = lib.gen1tls_receive(handle, recvBuf, max)
if n <= 0 then return "" end
return ffi.string(recvBuf, n)
end
love.system.tlsError = function(handle)
if lib.gen1tls_error(handle, errBuf, 512) == 0 then return nil end
return ffi.string(errBuf)
end
love.system.tlsClose = function(handle)
lib.gen1tls_close(handle)
end
return true
end
return Gen1Tls
+3 -3
View File
@@ -8,9 +8,9 @@
-- "update_check_state" worker -> main: { status, latest, progress, error } -- "update_check_state" worker -> main: { status, latest, progress, error }
-- --
-- Nothing here ever blocks or throws into the game loop: when love.thread is -- 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), -- absent (the headless test stub) or the worker cannot run, state() reports
-- state() simply reports "error" and the UI hides itself. See the shared -- "error" (or the worker reports "needs_full" when there is no transport).
-- contract in the task brief for the status vocabulary and the file layout. -- See the shared contract in the task brief for the status vocabulary.
-- --
-- The release-JSON extraction and the sums parsing are exported as pure -- 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 -- functions (no love.* calls) so plain-Lua tests can cover them, and so the
+65 -62
View File
@@ -5,11 +5,10 @@
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } -- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error } -- "update_check_state" out: { status, latest, progress, error }
-- --
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+ -- Transport is HostShell: curl via io.popen on desktop, the JNI
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error, -- love.system.httpDownload bridge on Android (same path the mod catalog
-- or a hung download degrades to a "error"/"needs_full" state rather than -- already uses). A missing transport, an HTTP error, or a hung download
-- blocking or crashing the game. On Android curl is absent and the check -- degrades to "error"/"needs_full" rather than blocking or crashing the game.
-- soft-fails to "error", which the UI hides.
-- --
-- Fresh love threads do not carry the "src.*" package searcher, so sibling -- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like -- 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 local pending = nil
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- shell / curl -- shell / fetch
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
local UA = "gen1recomp-updater"
local GH_ACCEPT = "application/vnd.github+json"
local function shq(s) local function shq(s)
s = tostring(s) s = tostring(s)
if isWindows then if isWindows then
@@ -74,31 +76,17 @@ local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'" return "'" .. s:gsub("'", "'\\''") .. "'"
end end
-- run curl and return its response body (text), or nil on any failure. Used -- Small text resources (release JSON, sums file) through HostShell so Android
-- for the small text resources (release JSON, sums file); -f makes curl exit -- hits the JNI bridge instead of a curl binary that is never on the device.
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure. local function fetchText(url, accept)
local function curlCapture(url) if not HostShell then return nil end
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " local body = HostShell.httpGet(url, UA, accept)
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " " if type(body) ~= "string" or body == "" then return nil end
.. "-H " .. shq("Accept: application/vnd.github+json") .. " " return body
.. 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
end end
local function haveCurl() local function canFetch()
local pipe = HostShell.popen("curl --version") return HostShell and HostShell.canFetch()
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
end end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -170,12 +158,14 @@ end
local function doCheck() local function doCheck()
post({ status = "checking" }) post({ status = "checking" })
if not haveCurl() then if not canFetch() then
post({ status = "error", error = "curl not available" }) -- 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 return
end end
local body = curlCapture(API_URL) local body = fetchText(API_URL, GH_ACCEPT)
if not body then if not body then
post({ status = "error", error = "release check failed" }) post({ status = "error", error = "release check failed" })
return return
@@ -212,7 +202,7 @@ local function doCheck()
-- pulling the bytes again. -- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then 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 sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel) love.filesystem.remove(finalRel)
@@ -279,39 +269,52 @@ local function doDownload()
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done" local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0 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 -- 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 -- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0 local waited, lastSize, lastChange = 0, -1, 0
while true do while true do
-- A queued quit means the window already closed. Bail so the join in -- 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 -- Check.shutdown does not hold the dead window's process (and, on
-- Windows, its folder) open for up to the whole transfer (#727). The -- Windows, its folder) open for up to the whole transfer (#727). The
-- quit stays on the channel for the command loop; the detached curl -- 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 -- times out on its own and the next launch's doCheck verifies and
-- re-offers whatever landed. -- re-offers whatever landed.
local peeked = cmdCh:peek() local peeked = cmdCh:peek()
if type(peeked) == "table" and peeked.cmd == "quit" then return end if type(peeked) == "table" and peeked.cmd == "quit" then return end
if love.filesystem.getInfo(doneRel) then break end if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel) local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0 local cur = (pinfo and pinfo.size) or 0
if size > 0 then if size > 0 then
local p = cur / size local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready" if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p }) post({ status = "downloading", latest = rel.version, progress = p })
else else
post({ status = "downloading", latest = rel.version }) 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 end
if cur ~= lastSize then lastSize, lastChange = cur, waited end love.filesystem.remove(doneRel)
if waited - lastChange > 60 then break end -- 60s with no growth: give up else
if waited > 960 then break end -- absolute ceiling -- Android JNI bridge: blocking write, same as fetch_worker. Progress
love.timer.sleep(0.25) -- cannot be sampled from inside httpDownload.
waited = waited + 0.25 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 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 if not sums then
love.filesystem.remove(partRel) love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" }) post({ status = "error", error = "checksum fetch failed" })
@@ -55,4 +55,24 @@ check(not source:find("QuestActivity", 1, true) and
not source:find("QuestBridge", 1, true), not source:find("QuestBridge", 1, true),
"generic Android activity must not require Quest classes") "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") print("android_host_extension_test: ok")
@@ -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")
+40
View File
@@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]],
check(removedPath == [[C:\LocalState\picked_mod.zip]], check(removedPath == [[C:\LocalState\picked_mod.zip]],
"removes the temporary copy after installation") "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.getOS = saved.getOS
love.system.pickFile = saved.pickFile love.system.pickFile = saved.pickFile
love.system.getPickedFile = saved.getPickedFile love.system.getPickedFile = saved.getPickedFile
+5
View File
@@ -62,6 +62,9 @@ local PROBE = [[
out.assignGarbage = attempt(function() love.mousemoved = 7 end) out.assignGarbage = attempt(function() love.mousemoved = 7 end)
out.powerInfo = type(love.system.getPowerInfo) out.powerInfo = type(love.system.getPowerInfo)
out.openUrl = love.system.openURL("https://example.com") out.openUrl = love.system.openURL("https://example.com")
-- tls* is forwarded from the real love.system when the engine hung it
-- (Gen1Tls / Android JNI); without that it's just nil, not an error.
out.tlsOpenType = type(love.system.tlsOpen)
out.eventQuit = love.event.quit() out.eventQuit = love.event.quit()
out.popen = select(1, io.popen("ls")) out.popen = select(1, io.popen("ls"))
@@ -200,6 +203,8 @@ T.check(out.loveAssign ~= false,
"a mod cannot replace a love module table: " .. tostring(out.loveAssign)) "a mod cannot replace a love module table: " .. tostring(out.loveAssign))
T.eq(out.powerInfo, "function", T.eq(out.powerInfo, "function",
"love.system reads through to the same information mod.device exposes") "love.system reads through to the same information mod.device exposes")
T.check(out.tlsOpenType == "function" or out.tlsOpenType == "nil",
"tls* is readable through the system shim (nil until the engine hangs it)")
-- a wrapped callback has to land on the real table or the wrap never fires -- a wrapped callback has to land on the real table or the wrap never fires
T.eq(type(installedMouseMoved), "function", T.eq(type(installedMouseMoved), "function",
+20
View File
@@ -373,5 +373,25 @@ do
check(main ~= nil, "saveFilename resolves for gold") check(main ~= nil, "saveFilename resolves for gold")
end 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)) print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end if failed > 0 then os.exit(1) end
+1 -1
View File
@@ -146,7 +146,7 @@ function App.load(pathOverride, opts)
-- the same mod set the game loads, merged into Data before the catalogs -- the same mod set the game loads, merged into Data before the catalogs
-- build, so modded species/items/moves are editable and MonOps stops -- build, so modded species/items/moves are editable and MonOps stops
-- asserting on them -- 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 -- One loader per editor session. A previous session leaves Data holding
-- that session's merged registries (and possibly the other game's cache), -- that session's merged registries (and possibly the other game's cache),
-- and a second builtin registration over them collides -- "statuses -- and a second builtin registration over them collides -- "statuses
+10 -8
View File
@@ -40,10 +40,11 @@ end
local function shellListLua(dir) local function shellListLua(dir)
local out = {} local out = {}
if not (io and io.popen) then return out end
if package.config:sub(1, 1) == "\\" then if package.config:sub(1, 1) == "\\" then
-- cmd has no ls; dir /b prints bare names, so re-attach the directory -- 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)) local ok, p = pcall(io.popen, string.format('dir /b "%s\\*.lua" 2>nul', dir))
if p then if ok and p then
for line in p:lines() do for line in p:lines() do
if line ~= "" then table.insert(out, dir .. "/" .. line) end if line ~= "" then table.insert(out, dir .. "/" .. line) end
end end
@@ -51,8 +52,8 @@ local function shellListLua(dir)
end end
return out return out
end end
local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir)) local ok, p = pcall(io.popen, string.format('ls "%s"/*.lua 2>/dev/null', dir))
if p then if ok and p then
for line in p:lines() do for line in p:lines() do
table.insert(out, line) table.insert(out, line)
end end
@@ -64,8 +65,8 @@ end
local function readText(path) local function readText(path)
local fs = love and love.filesystem local fs = love and love.filesystem
if fs and fs.read and fs.getInfo and fs.getInfo(path) then if fs and fs.read and fs.getInfo and fs.getInfo(path) then
local body = fs.read(path) local ok, body = pcall(fs.read, path)
if body then return body end if ok and body then return body end
end end
local f = io.open(path, "r") local f = io.open(path, "r")
if not f then return nil end if not f then return nil end
@@ -78,7 +79,7 @@ end
-- scripts show up beside the vanilla EVENT_ ones -- scripts show up beside the vanilla EVENT_ ones
function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
listFiles = listFiles or function(dir) listFiles = listFiles or function(dir)
return loveListLua(dir) or shellListLua(dir) return loveListLua(dir) or shellListLua(dir) or {}
end end
local found = {} local found = {}
@@ -97,7 +98,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
dirs[#dirs + 1] = dir dirs[#dirs + 1] = dir
end end
for _, dir in ipairs(dirs) do 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) local body = readText(path)
if body then eat(body) end if body then eat(body) end
end end
+27 -9
View File
@@ -78,15 +78,28 @@ function Gen.bindGoldData(data)
if data.palettes and data.gen2Palettes == nil then if data.palettes and data.gen2Palettes == nil then
data.gen2Palettes = data.palettes data.gen2Palettes = data.palettes
end end
if data.gen2Roofs == nil and data.roofs == nil then
local ok, roofs = pcall(require, "data.generated.roofs") local loadGen = function(rel)
if ok and type(roofs) == "table" then local CacheFs = require("src.import.CacheFs")
data.roofs = roofs local bytes = CacheFs.readActive("data/generated/" .. rel .. ".lua")
data.gen2Roofs = roofs 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 end
elseif data.roofs and data.gen2Roofs == nil then local ok, res = pcall(require, "data.generated." .. rel)
data.gen2Roofs = data.roofs if ok and type(res) == "table" then return res end
return nil
end 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 return data
end end
@@ -213,10 +226,15 @@ function Gen.playerMap(save)
if Gen.of(save) == 2 then if Gen.of(save) == 2 then
local p = save.position local p = save.position
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end 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 end
local p = save.player or {} 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 end
function Gen.setPlayerHere(save, mapId, x, y, facing) function Gen.setPlayerHere(save, mapId, x, y, facing)