mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<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)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
|
||||
+13
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -166,6 +201,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
|
||||
@@ -263,6 +303,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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+39
-15
@@ -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)
|
||||
@@ -280,11 +301,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+65
-62
@@ -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" })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,9 @@ local PROBE = [[
|
||||
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
|
||||
out.powerInfo = type(love.system.getPowerInfo)
|
||||
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.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))
|
||||
T.eq(out.powerInfo, "function",
|
||||
"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
|
||||
T.eq(type(installedMouseMoved), "function",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user