From 1df04aa18deb4da21209d4a9149b226fa39a950c Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:23:11 +0200
Subject: [PATCH 1/6] fix(ios): route native picker results
---
mobile/ios/overlays/love-ios.plist | 6 +-----
scripts/build.sh | 5 +++++
scripts/build_ios.sh | 33 +++++++++++++++++++++++++++++-
src/import/RomImporter.lua | 13 +++++++++---
tests/rom_importer_cursor_test.lua | 9 ++++++++
5 files changed, 57 insertions(+), 9 deletions(-)
diff --git a/mobile/ios/overlays/love-ios.plist b/mobile/ios/overlays/love-ios.plist
index d4c9c52d..0d8e0f02 100644
--- a/mobile/ios/overlays/love-ios.plist
+++ b/mobile/ios/overlays/love-ios.plist
@@ -5,7 +5,7 @@
CFBundleDevelopmentRegion
en
CFBundleDisplayName
- Pokemon Red
+ gen1recomp
CFBundleDocumentTypes
@@ -49,10 +49,6 @@
UILaunchStoryboardName
Launch Screen
- UIRequiredDeviceCapabilities
-
- opengles-2
-
UIStatusBarHidden
UIViewControllerBasedStatusBarAppearance
diff --git a/scripts/build.sh b/scripts/build.sh
index eb719f8b..409eebe6 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -35,6 +35,7 @@ TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
IOS_RELEASE=false
+IOS_IPA=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) IOS_RELEASE=true ;;
+ --ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
@@ -298,6 +300,9 @@ build_android() {
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
+ if [ "$IOS_IPA" = true ]; then
+ args+=(--ipa)
+ fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh
index 869287d1..11b81a62 100755
--- a/scripts/build_ios.sh
+++ b/scripts/build_ios.sh
@@ -50,7 +50,7 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
-BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
+BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
@@ -65,6 +65,7 @@ DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
+CREATE_IPA=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
@@ -82,6 +83,7 @@ while [ $# -gt 0 ]; do
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
+ --ipa) CREATE_IPA=true ;;
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
@@ -92,6 +94,10 @@ while [ $# -gt 0 ]; do
shift
done
+if $CREATE_IPA; then
+ DEVICE=true
+fi
+
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -232,6 +238,30 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest"
}
+apply_ios_icon() {
+ local source="$ROOT/assets/logo/logo.png"
+ local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
+ [ -f "$source" ] || fail "missing iOS icon source: $source"
+ [ -d "$target" ] || fail "missing iOS app icon set: $target"
+ local entry name size
+ while IFS=: read -r name size; do
+ sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
+ done <<'EOF'
+icon-1024pt@1x.png:1024
+icon-29pt@1x.png:29
+icon-29pt@2x.png:58
+icon-29pt@3x.png:87
+icon-40pt@1x.png:40
+icon-40pt@2x.png:80
+icon-40pt@3x.png:120
+icon-60pt@2x.png:120
+icon-60pt@3x.png:180
+icon-76pt@1x.png:76
+icon-76pt@2x.png:152
+icon-83.5pt@2x.png:167
+EOF
+}
+
# --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
@@ -640,6 +670,7 @@ install_to_device() {
# --------------------------------------------------------------- main
apply_ios_branding
+apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 51d83c71..46a14a2f 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -818,7 +818,11 @@ local function resetPointerCursor(self)
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
return
end
- self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
+ if not self.arrowCursor then
+ local ok, cursor = pcall(love.mouse.getSystemCursor, "arrow")
+ if not ok then return end
+ self.arrowCursor = cursor
+ end
love.mouse.setCursor(self.arrowCursor)
end
@@ -2512,8 +2516,11 @@ function RomImporter:draw()
if self._hoverEnabled and not self._padCursorActive
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
if self._anyHover then
- self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
- love.mouse.setCursor(self.handCursor)
+ if not self.handCursor then
+ local ok, cursor = pcall(love.mouse.getSystemCursor, "hand")
+ if ok then self.handCursor = cursor end
+ end
+ if self.handCursor then love.mouse.setCursor(self.handCursor) end
else
resetPointerCursor(self)
end
diff --git a/tests/rom_importer_cursor_test.lua b/tests/rom_importer_cursor_test.lua
index 21e8ad13..2f8a41f3 100644
--- a/tests/rom_importer_cursor_test.lua
+++ b/tests/rom_importer_cursor_test.lua
@@ -36,4 +36,13 @@ ri:play("red")
eq(booted, "red", "android play still boots")
eq(currentCursor, "hand", "android play leaves the cursor alone")
+booted = nil
+currentCursor = "hand"
+ri.android = false
+ri.arrowCursor = nil
+love.mouse.getSystemCursor = function() error("CreateSystemCursor is not currently supported") end
+ri:play("red")
+eq(booted, "red", "unsupported system cursors still allow boot")
+eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone")
+
S.finish()
From 92c8bac71442a3c3da8d37f5f829b932340dd74b Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:36:18 +0200
Subject: [PATCH 2/6] fix(ios): support direct picker results
---
src/import/RomImporter.lua | 59 ++++++++++++++++++++++++++++++++++++++
1 file changed, 59 insertions(+)
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 46a14a2f..808b0030 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -637,6 +637,8 @@ function RomImporter.new(onComplete, opts)
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
+ iosPendingKind = nil,
+ iosPendingVersion = nil,
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
-- right stick scrolls the save-slot / mods lists.
@@ -1008,6 +1010,14 @@ end
-- which focus/Choose consumes on return.
function RomImporter:chooseMod()
if self.workState == "working" then return end
+ if self.ios and love.system.getPickedFile then
+ self.iosPendingKind = "mod"
+ if not pickFile("mod") then
+ self.iosPendingKind = nil
+ self.modNotice = { ok = false, text = "Could not open the file picker." }
+ end
+ return
+ end
if self.android then
local name = findPendingMod(true, self.pickSkip)
if name then
@@ -1069,6 +1079,16 @@ end
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
+ if self.ios and love.system.getPickedFile then
+ self.iosPendingKind = "sav"
+ self.iosPendingVersion = version
+ if not pickFile("sav") then
+ self.iosPendingKind = nil
+ self.iosPendingVersion = nil
+ self.saveNotice[version] = { ok = false, text = "Could not open the file picker." }
+ end
+ return
+ end
if self.android then
local name = findPendingSav(true, self.pickSkip)
if name then
@@ -1158,6 +1178,14 @@ end
function RomImporter:choose(version)
if self.workState == "working" then return end
self.chooseVersion = version or "red"
+ if self.ios and love.system.getPickedFile then
+ self.iosPendingKind = "rom"
+ if not pickFile("rom") then
+ self.iosPendingKind = nil
+ self:setError("Could not open the file picker.")
+ end
+ return
+ end
if self.android then
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
@@ -1272,6 +1300,37 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
self:_updatePadCursor(dt)
+ if self.ios and love.system.getPickedFile and self.workState ~= "working" then
+ local path = love.system.getPickedFile()
+ if path then
+ local kind = self.iosPendingKind or "rom"
+ local version = self.iosPendingVersion
+ self.iosPendingKind = nil
+ self.iosPendingVersion = nil
+ if kind == "mod" then
+ self:_installMod(path)
+ elseif kind == "sav" then
+ self:_importSave(version or self:_savedropTarget(), path)
+ else
+ self:startPath(path)
+ end
+ elseif love.system.getPickError then
+ local errorText = love.system.getPickError()
+ if errorText then
+ local kind = self.iosPendingKind or "rom"
+ local version = self.iosPendingVersion or self:_savedropTarget()
+ self.iosPendingKind = nil
+ self.iosPendingVersion = nil
+ if kind == "mod" then
+ self.modNotice = { ok = false, text = errorText }
+ elseif kind == "sav" then
+ self.saveNotice[version] = { ok = false, text = errorText }
+ else
+ self:setError(errorText)
+ end
+ end
+ end
+ end
self:_pollPickedFiles(dt)
if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime()
From 81edc97d8671ed616fb230124f63a6fa10f00c8a Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:44:26 +0200
Subject: [PATCH 3/6] fix(ios): package updater boot module
---
scripts/build_ios.sh | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh
index 11b81a62..70311d34 100755
--- a/scripts/build_ios.sh
+++ b/scripts/build_ios.sh
@@ -356,8 +356,9 @@ pack_game_love() {
# in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
- for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
- tools/save-editor/panels/Party.lua $MANIFESTS; do
+ for required in src/update/Boot.lua tools/save-editor/App.lua \
+ tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
+ $MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required"
done
From 95e4715bc3211c9c06a72e0ac6344b77a3d0466f Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:54:05 +0200
Subject: [PATCH 4/6] fix(ios): declare Love 12 compatibility
---
conf.lua | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/conf.lua b/conf.lua
index e96a18a7..08bd3583 100644
--- a/conf.lua
+++ b/conf.lua
@@ -49,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
- t.version = "11.5"
+ t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
From c4ec5dc1c59ab7ce2aaa5661484be6f8f1f6928b Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:58:42 +0200
Subject: [PATCH 5/6] test(ios): cover save export picker
---
tests/rom_importer_ios_save_export_test.lua | 60 +++++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 tests/rom_importer_ios_save_export_test.lua
diff --git a/tests/rom_importer_ios_save_export_test.lua b/tests/rom_importer_ios_save_export_test.lua
new file mode 100644
index 00000000..b55b3ad6
--- /dev/null
+++ b/tests/rom_importer_ios_save_export_test.lua
@@ -0,0 +1,60 @@
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local S = require("tests.harness").suite("rom importer iOS save export")
+local eq = S.eq
+local check = S.check
+
+love.system = love.system or {}
+local saved = {
+ createFile = love.system.createFile,
+ read = love.filesystem.read,
+ write = love.filesystem.write,
+}
+
+local writes = {}
+love.filesystem.read = function(name)
+ eq(name, "exports/red.sav", "export reads the generated save")
+ return "save-data"
+end
+love.filesystem.write = function(name, data)
+ writes[#writes + 1] = { name = name, data = data }
+ return true
+end
+
+local createCalls = {}
+love.system.createFile = function(name)
+ createCalls[#createCalls + 1] = name
+ return true
+end
+
+package.loaded["src.import.SaveFileIO"] = {
+ exportActiveSlot = function(version)
+ eq(version, "red", "exports the selected game save")
+ return true, "exports/red.sav"
+ end,
+}
+
+local RomImporter = require("src.import.RomImporter")
+local ri = setmetatable({
+ android = true,
+ ios = true,
+ workState = nil,
+ saveNotice = {},
+}, RomImporter)
+
+ri:exportSave("red")
+
+eq(#writes, 1, "stages the save for iOS export")
+eq(writes[1].name, "pending_export.sav", "uses the native bridge staging name")
+eq(writes[1].data, "save-data", "preserves the exported save data")
+eq(#createCalls, 1, "opens the system destination picker")
+eq(createCalls[1], "red.sav", "suggests the original save name")
+check(ri.saveNotice.red.ok, "reports that the destination picker opened")
+
+love.system.createFile = saved.createFile
+love.filesystem.read = saved.read
+love.filesystem.write = saved.write
+package.loaded["src.import.SaveFileIO"] = nil
+
+S.finish()
From 90437a5d8d59163e5ca20346f6bf2b2dbb3a2cc6 Mon Sep 17 00:00:00 2001
From: Adrian Castro <22133246+castdrian@users.noreply.github.com>
Date: Sat, 1 Aug 2026 13:07:36 +0200
Subject: [PATCH 6/6] fix(ios): export saves from Love directory
---
src/import/RomImporter.lua | 2 +-
tests/rom_importer_ios_save_export_test.lua | 10 +++++++---
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 808b0030..9464029a 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -1141,7 +1141,7 @@ function RomImporter:exportSave(version)
return
end
self.androidPendingExportVersion = version
- if love.system.createFile and love.system.createFile(suggested) then
+ if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
self.pickPending = true
self.pickTimer = 0
self.saveNotice[version] = { ok = true,
diff --git a/tests/rom_importer_ios_save_export_test.lua b/tests/rom_importer_ios_save_export_test.lua
index b55b3ad6..692945b9 100644
--- a/tests/rom_importer_ios_save_export_test.lua
+++ b/tests/rom_importer_ios_save_export_test.lua
@@ -10,6 +10,7 @@ local saved = {
createFile = love.system.createFile,
read = love.filesystem.read,
write = love.filesystem.write,
+ getSaveDirectory = love.filesystem.getSaveDirectory,
}
local writes = {}
@@ -21,10 +22,11 @@ love.filesystem.write = function(name, data)
writes[#writes + 1] = { name = name, data = data }
return true
end
+love.filesystem.getSaveDirectory = function() return "/tmp/gen1recomp-save" end
local createCalls = {}
-love.system.createFile = function(name)
- createCalls[#createCalls + 1] = name
+love.system.createFile = function(name, saveDirectory)
+ createCalls[#createCalls + 1] = { name = name, saveDirectory = saveDirectory }
return true
end
@@ -49,12 +51,14 @@ eq(#writes, 1, "stages the save for iOS export")
eq(writes[1].name, "pending_export.sav", "uses the native bridge staging name")
eq(writes[1].data, "save-data", "preserves the exported save data")
eq(#createCalls, 1, "opens the system destination picker")
-eq(createCalls[1], "red.sav", "suggests the original save name")
+eq(createCalls[1].name, "red.sav", "suggests the original save name")
+eq(createCalls[1].saveDirectory, "/tmp/gen1recomp-save", "passes the exact staging directory")
check(ri.saveNotice.red.ok, "reports that the destination picker opened")
love.system.createFile = saved.createFile
love.filesystem.read = saved.read
love.filesystem.write = saved.write
+love.filesystem.getSaveDirectory = saved.getSaveDirectory
package.loaded["src.import.SaveFileIO"] = nil
S.finish()