iOS: native document-picker + Apple Health bridges, working device builds

Makes the iOS build a first-class citizen: ROM/mod/save import through
the system document picker (the README's missing "UIDocumentPicker
handoff"), Files-app drop-in support, and an opt-in Apple Health
step-sync seam consumed by a new gallery mod (Pokewalker).

Native layer (mobile/ios/native/, wired by mobile/ios/patch_love_src.py
on every build, so the fetched love-src tree stays pristine + re-patchable):
- GRPickerBridge.swift: love.system.pickFile("rom"|"mod"|"sav") and
  love.system.createFile on iOS with the same contract as love-android's
  SAF picker (picked_rom.gb / picked_mod.zip / picked_save.sav /
  export_done.flag in the save dir). Reached from wrap_System.cpp via the
  ObjC runtime, so liblove needs no Swift interop.
- GRBootstrap.m: sweeps .gb/.gbc/.zip/.sav dropped in Documents (Files
  app / Finder sharing) into the save dir on every activation;
  UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace in the plist
  overlay. Drop a ROM, open the app, it imports with zero taps.
- GRHealthBridge.swift: love.system.syncHealthSteps() -> read-only
  HealthKit step query anchored to the last sync, delivered as
  steps_pending.json (merge-not-overwrite). HealthKit entitlement +
  usage description included.

Lua:
- RomImporter: iOS rides the Android mobile flows; a 0.5s poll consumes
  picker deliveries (iOS pickers are in-process modals, so the Android
  refocus rescan never fires); failed pick copies surface as an
  on-screen notice via pick_error.txt.
- main.lua: on iOS, stop forwarding touchpressed to the Importer - LOVE
  already synthesizes a mousepressed for the primary touch, and the
  resulting same-frame double-present made the document picker
  auto-dismiss with zero documents (silent import failure).
- mods/pokewalker: opt-in Pokewalker mod (manifest v2, MECHANIC,
  permissions declared, mod.card, CHANGELOG, headless test suite 9/9,
  modkit validate --base imported + lint clean). Fused into iOS
  game.love only; loads dormant anywhere without the bridge.

Build (scripts/build_ios.sh):
- Fix Xcode 26: the global PRODUCT_NAME override also renamed liblove.a
  and broke the app link; the app bundle is renamed after the build
  instead.
- Fix nondeterministic pack failures: grep -q + pipefail races SIGPIPE
  on the game.love content checks.
- Simulator builds sign ad-hoc so entitlements embed (HealthKit works in
  the simulator).
- Device builds: signing team auto-detected from the keychain,
  CODE_SIGN_STYLE=Automatic + -allowProvisioningUpdates for CLI-only
  provisioning, per-team derived bundle ID (explicit App IDs are
  globally unique, so third parties can't sign the project default),
  gitignored mobile/ios/bundle_id.local pin, and --install to push to a
  connected iPhone.
- docs/ios-install.md: a zero-knowledge walkthrough from bare Mac to
  playing on an iPhone.

Backward compatibility: no behavior change on desktop or Android. The
new love.system functions exist only under LOVE_IOS; RomImporter's
mobile flag simply includes iOS alongside Android; the main.lua change
is iOS-gated; the Pokewalker mod is packed only by the iOS build script
and its option defaults off.

Verified on an iPhone 17 Pro simulator and an iPhone 16 Pro device:
scripted ROM import to title screen, Files-drop zero-tap import,
picker-driven mod install and save import/export, HealthKit permission
sheet + step credit (4000 steps -> +200 EXP at the default rate through
the engine growth curve).
This commit is contained in:
Myles Resnick
2026-07-30 09:46:48 -04:00
parent 29afbdaec7
commit 902f0d73d4
18 changed files with 1315 additions and 15 deletions
+3
View File
@@ -39,3 +39,6 @@ mobile/dist/
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported # Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
# ROM cache exactly like data/generated/, so it is never committable. # ROM cache exactly like data/generated/, so it is never committable.
/build/tiled/ /build/tiled/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
+116
View File
@@ -0,0 +1,116 @@
# Build & install on your iPhone — step by step
This guide assumes **zero** programming experience. Follow it top to
bottom and you'll have the game running on your own iPhone in roughly an
hour (most of it is waiting for downloads).
## What you need
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
- An **iPhone** and its **charging cable**
- A free **Apple ID** (the same account you use for the App Store)
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
`.gb` file). This project ships no game data — the app rebuilds
everything from *your* cartridge dump and verifies it before use.
- About **15 GB free disk space** (Xcode is enormous; the game itself is
tiny)
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
> apps signed with a free account stop launching after **7 days** — just
> re-run the install command (step 5) to re-sign; your save data is kept.
> A paid Apple Developer account ($99/yr) extends that to a year.
---
## Step 1 — Install Xcode (once)
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
It's a ~10 GB download — go make tea.
2. Open Xcode once. Accept the license. If it offers to install extra
components or the **iOS platform**, say yes and let it finish.
## Step 2 — Sign Xcode into your Apple ID (once)
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
3. Close the window. (This quietly creates the "signing certificate" the
build uses — you never have to touch it again.)
## Step 3 — Get this project onto the Mac
If you received it as a folder, put it somewhere easy like your home
folder. If it's on GitHub, click the green **Code** button → **Download
ZIP**, then double-click the zip to unpack it.
## Step 4 — Build it (one command)
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
return).
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
project folder** from Finder onto the Terminal window — it fills in
the path — and press **return**.
3. Paste this and press return:
```sh
scripts/build_ios.sh --fetch
```
The first run downloads the LÖVE engine and compiles everything
(515 minutes). Lines of build output scrolling by is normal. You're
done when you see **`==> done`**.
## Step 5 — Put it on your iPhone
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
unlocked. If it asks **"Trust This Computer?" → Trust**.
2. Paste this and press return:
```sh
scripts/build_ios.sh --device --install
```
The script finds your signing identity and your phone by itself. If it
complains, it tells you exactly what to fix (usually: the phone was
locked — unlock and re-run).
3. First time only, the iPhone will want two approvals:
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
**Developer Mode** → turn on → restart the phone → confirm.
- **Trust the developer**: Settings → **General** → **VPN & Device
Management** → tap the entry under *Developer App* → **Trust**.
Then re-run the command in step 5.2 if the install had failed.
## Step 6 — Give it your ROM and play
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
it in iCloud Drive / Files.
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
`.gb` file in the file browser that appears. (Alternative: in the
**Files** app, drop the `.gb` into **On My iPhone → the game's
folder** and just reopen the app — it imports automatically.)
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
## Optional goodies
- **Pokéwalker mode** (real steps → EXP): in-game **mod manager →
POKEWALKER → SYNC STEPS on**, allow step access when iOS asks, and go
for a walk.
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
- **Save import/export**: buttons on each game's tab, using the normal
iOS file picker.
## When things go wrong
| Symptom | Fix |
|---|---|
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
| `no Apple signing identity found` | Do Step 2, then re-run |
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
| App icon appears then won't open | Do the two approvals in Step 5.3 |
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
Every build/install command is safe to re-run; your saves live on the
phone and survive reinstalls.
+12 -1
View File
@@ -358,7 +358,18 @@ end
function love.touchpressed(id, x, y, dx, dy, pressure) function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end if editorMode then return end
if Importer then return Importer:mousepressed(x, y, 1) end if Importer then
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
-- and love.mousepressed below forwards that to the Importer, so
-- forwarding here too fires every launcher button twice per tap. The
-- resulting double-present was fatal for the document picker: the
-- second sheet stole the first one's weakly-held delegate, so picking
-- a file silently did nothing. Android keeps the forward for upstream
-- parity (its SAF picker is a separate activity and tolerates the
-- re-launch).
if love.system.getOS() == "iOS" then return end
return Importer:mousepressed(x, y, 1)
end
Game:touchpressed(id, x, y) Game:touchpressed(id, x, y)
end end
+20
View File
@@ -1,5 +1,25 @@
# iOS build (LÖVE 11.5) # iOS build (LÖVE 11.5)
> **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
> that `patch_love_src.py` wires into the LÖVE tree on every build:
>
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
> `picked_save.sav`; exports signal via `export_done.flag`).
> - The Info.plist overlay enables `UIFileSharingEnabled` +
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
> into the LÖVE save dir on every activation — drop a ROM, open the app,
> and it imports with no taps.
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
> for picker results (iOS pickers are in-process modals, so Android's
> refocus rescan never fires).
>
> The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge.
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)), (`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
matching `conf.lua`'s `t.version = "11.5"`. matching `conf.lua`'s `t.version = "11.5"`.
+23
View File
@@ -0,0 +1,23 @@
// gen1recomp iOS native bridge bootstrap.
//
// Runs the Files-app inbox sweep (GRPickerBridge.sweepInbox) every time the
// app becomes active, so ROMs/mods/saves dropped into the app's Documents
// folder land in the LÖVE save directory before the Lua importer rescans.
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h>
__attribute__((constructor))
static void GRBootstrapInstall(void)
{
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
Class bridge = NSClassFromString(@"GRPickerBridge");
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
[bridge performSelector:@selector(sweepInbox)];
}
}];
}
+121
View File
@@ -0,0 +1,121 @@
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
//
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
// love.system.syncHealthSteps() see mobile/ios/patch_love_src.py. The Lua
// side (mods/pokewalker) opts in, calls sync, and later consumes
// steps_pending.json from the LÖVE save directory:
//
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
//
// Steps are counted from a persisted anchor (last successful sync; first
// run starts at midnight today) so the same walk is never credited twice.
// An unconsumed pending file is merged, not overwritten, so steps survive
// the player quitting before entering the overworld.
import Foundation
import HealthKit
@objc(GRHealthBridge)
public final class GRHealthBridge: NSObject {
private static let store = HKHealthStore()
private static let anchorKey = "GRHealthLastSyncDate"
private static let pendingName = "steps_pending.json"
// Sanity clamp per sync: guards against absurd backlogs (device clock
// changes, months-old anchors) turning into instant level-100 parties.
private static let maxStepsPerSync = 50_000
/// love.system.syncHealthSteps() -> bool ("sync started").
/// Requests read authorization on first use (system sheet appears over
/// the game), then asynchronously writes the pending-steps file.
@objc(syncStepsWithCommand:saveDir:)
public static func syncSteps(command: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
guard HKHealthStore.isHealthDataAvailable(),
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
else { return false }
let dir = saveDir.map { String(cString: $0) } ?? ""
guard !dir.isEmpty else { return false }
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
if let error = error {
NSLog("GRHealthBridge: authorization error: \(error)")
return
}
// Read authorization is intentionally opaque (a denied query
// just returns no samples), so always run the query.
runQuery(stepType: stepType, dir: dir)
}
return true
}
// MARK: - Query
private static func runQuery(stepType: HKQuantityType, dir: String) {
let defaults = UserDefaults.standard
let now = Date()
let anchor = (defaults.object(forKey: anchorKey) as? Date)
?? Calendar.current.startOfDay(for: now)
guard anchor < now else { return }
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { _, stats, error in
if let error = error {
// Typical on first run before the user answers the sheet,
// or when access is denied; the anchor is left untouched so
// the next sync retries the same window.
NSLog("GRHealthBridge: step query error: \(error)")
return
}
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
defaults.set(now, forKey: anchorKey)
deliver(steps: min(steps, maxStepsPerSync),
from: anchor, to: now, dir: dir)
}
store.execute(query)
}
// MARK: - Pending file
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
// Merge with an unconsumed earlier delivery so steps are never lost.
var total = steps
var fromDate = from
if let data = try? Data(contentsOf: url),
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
total += (old["steps"] as? Int) ?? 0
if let oldFrom = old["from"] as? String,
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
fromDate = parsed
}
}
guard total > 0 else { return }
let payload: [String: Any] = [
"steps": total,
"from": isoFormatter.string(from: fromDate),
"to": isoFormatter.string(from: to),
]
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: dir, isDirectory: true),
withIntermediateDirectories: true)
let data = try JSONSerialization.data(withJSONObject: payload)
try data.write(to: url, options: .atomic)
NSLog("GRHealthBridge: %d steps pending", total)
} catch {
NSLog("GRHealthBridge: could not write pending steps: \(error)")
}
}
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
}
+218
View File
@@ -0,0 +1,218 @@
// gen1recomp iOS native bridge: document picker + Files-app inbox sweep.
//
// liblove's wrap_System.cpp calls into this class through the Objective-C
// runtime (objc_getClass / objc_msgSend), so nothing here may be renamed
// without updating that patch (see mobile/ios/patch_love_src.py).
//
// Contract (mirrors love-android's GameActivity.showFilePicker):
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
// picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav
// through the system picker, then writes export_done.flag.
import UIKit
import UniformTypeIdentifiers
@objc(GRPickerBridge)
public final class GRPickerBridge: NSObject {
// Every live picker keeps its own delegate here
// (UIDocumentPickerViewController holds its delegate weakly). A single
// "current delegate" slot would break under double activation: the
// engine's touch handling can fire a button twice (touch + synthesized
// mouse), the second present would replace the first picker's delegate,
// and the sheet the user actually sees would then pick into nil
// silently doing nothing.
private static var liveDelegates: [PickerDelegate] = []
// conf.lua t.identity where LÖVE puts the fused save directory on iOS
// (<sandbox>/Library/Application Support/<identity>).
private static let loveIdentity = "pokemon-love2d"
// MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
let kindStr = kind.map { String(cString: $0) } ?? "rom"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let destName: String
var types: [UTType] = []
switch kindStr {
case "mod":
destName = "picked_mod.zip"
types = [.zip]
case "sav":
destName = "picked_save.sav"
default:
destName = "picked_rom.gb"
for ext in ["gb", "gbc"] {
if let t = UTType(filenameExtension: ext) { types.append(t) }
}
}
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
// .data as well keeps every real file selectable. The importer
// validates by size + SHA-1, so a wrong pick is rejected safely.
types.append(.data)
if !types.contains(.item) { types.append(.item) }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: true)
picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in
guard let src = urls.first else { return }
copyItem(at: src, into: dir, named: destName)
}
return present(picker, with: delegate)
}
@objc(presentExportWithName:saveDir:)
public static func presentExport(name: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
let suggested = name.map { String(cString: $0) } ?? "export.sav"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let staged = dir.appendingPathComponent("pending_export.sav")
guard FileManager.default.fileExists(atPath: staged.path) else { return false }
// Stage under the suggested name so the picker's filename field is
// prefilled; forExporting moves/copies it to the user's destination.
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent(suggested)
try? FileManager.default.removeItem(at: tmp)
do {
try FileManager.default.copyItem(at: staged, to: tmp)
} catch {
NSLog("GRPickerBridge: staging export failed: \(error)")
return false
}
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
let delegate = PickerDelegate { urls in
guard !urls.isEmpty else { return }
// Same completion signal love-android's GameActivity writes;
// RomImporter:focus consumes it and clears pending_export.sav.
let flag = dir.appendingPathComponent("export_done.flag")
try? "ok".data(using: .utf8)?.write(to: flag)
}
return present(picker, with: delegate)
}
// Moves ROM/mod/save files the user dropped into the app's Documents
// folder (Files app / Finder file sharing) into the LÖVE save directory,
// where the importer's pending-file scan looks. Called on every
// UIApplicationDidBecomeActive (see GRBootstrap.m).
@objc public static func sweepInbox() {
let fm = FileManager.default
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
let appSupport = fm.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else { return }
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
guard let items = try? fm.contentsOfDirectory(at: docs,
includingPropertiesForKeys: nil) else { return }
for url in items where wanted.contains(url.pathExtension.lowercased()) {
ensureDirectory(saveDir)
let dest = saveDir.appendingPathComponent(url.lastPathComponent)
try? fm.removeItem(at: dest)
do {
try fm.moveItem(at: url, to: dest)
NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent)
} catch {
NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)")
}
}
}
// MARK: - Helpers
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
var dir = cstr.map { String(cString: $0) } ?? ""
if dir.isEmpty {
guard let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
else { return nil }
dir = appSupport.appendingPathComponent(loveIdentity).path
}
let url = URL(fileURLWithPath: dir, isDirectory: true)
ensureDirectory(url)
return url
}
private static func ensureDirectory(_ url: URL) {
try? FileManager.default.createDirectory(at: url,
withIntermediateDirectories: true)
}
private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
ensureDirectory(dir)
let dest = dir.appendingPathComponent(name)
try? FileManager.default.removeItem(at: dest)
do {
try FileManager.default.copyItem(at: src, to: dest)
NSLog("GRPickerBridge: delivered %@", name)
} catch {
NSLog("GRPickerBridge: copy failed: \(error)")
// Surface the failure in-game: the Lua pick poll turns this
// into an on-screen notice instead of a silent no-op.
let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription
try? report.data(using: .utf8)?
.write(to: dir.appendingPathComponent("pick_error.txt"))
}
}
private static func present(_ picker: UIDocumentPickerViewController,
with delegate: PickerDelegate) -> Bool {
let doPresent = { () -> Bool in
// Double activation (touch + synthesized mouse) arrives within
// one frame before UIKit even exposes the first sheet via
// presentedViewController so gate on our own delegate list.
// Without this the stacked present makes the picker auto-dismiss
// with zero documents (observed as didPickDocumentsAt 0 urls)
// and the user's pick silently does nothing.
guard liveDelegates.isEmpty else {
NSLog("GRPickerBridge: picker already active; ignoring re-present")
return true
}
guard var top = UIApplication.shared.windows
.first(where: { $0.isKeyWindow })?.rootViewController
else { return false }
while let presented = top.presentedViewController { top = presented }
picker.delegate = delegate
liveDelegates.append(delegate)
delegate.onFinish = { [weak delegate] in
liveDelegates.removeAll { $0 === delegate }
}
top.present(picker, animated: true)
return true
}
if Thread.isMainThread { return doPresent() }
var ok = false
DispatchQueue.main.sync { ok = doPresent() }
return ok
}
}
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
private let onPick: ([URL]) -> Void
var onFinish: (() -> Void)?
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
func documentPicker(_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]) {
NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count)
onPick(urls)
onFinish?()
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
// No file was written; the Lua side's pending-file poll simply
// never finds anything.
NSLog("GRPickerBridge: picker cancelled")
onFinish?()
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- gen1recomp iOS: read-only step counts for the Pokéwalker mod
(GRHealthBridge.swift / mods/pokewalker). -->
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+11
View File
@@ -100,5 +100,16 @@
</dict> </dict>
</dict> </dict>
</array> </array>
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
Finder so ROMs, mod .zips, and .sav files can be dropped in without
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
in-game mod manager) and converts them to Pokémon EXP. -->
<key>NSHealthShareUsageDescription</key>
<string>The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking.</string>
</dict> </dict>
</plist> </plist>
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
run stashes a pristine `.orig` copy of every file it rewrites, and later
runs always start over from that copy — so editing the patch content here
just works on the next build, no manual restore needed.
What it does:
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
love.system.createFile, and love.system.syncHealthSteps on iOS (each
calls a GR*Bridge Swift class through the Objective-C runtime, so
liblove never links against Swift directly).
3. Patches love.xcodeproj so the love-ios app target compiles the native
files (Swift 5, iOS 14 deployment for UTType/forExporting APIs).
"""
import re
import shutil
import sys
from pathlib import Path
IOS_DIR = Path(__file__).resolve().parent
LOVE_SRC = IOS_DIR / "love-src"
NATIVE_SRC = IOS_DIR / "native"
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
MARKER = "gen1recomp iOS picker bridge"
# Headers must land outside `namespace love { namespace system {`.
WRAP_INCLUDES = """
// %s: headers for the native-bridge functions below.
#ifdef LOVE_IOS
#include <objc/runtime.h>
#include <objc/message.h>
#include "filesystem/Filesystem.h"
#endif
""" % MARKER
WRAP_FUNCS = """
// --- %s -------------------------------------------------
// love.system.pickFile / createFile / syncHealthSteps for iOS. pickFile and
// createFile mirror the love-android extension this project's importer
// already targets; syncHealthSteps feeds the Pokéwalker mod. Implemented in
// Swift (GR*Bridge classes, love-ios app target); reached via the ObjC
// runtime so liblove itself needs no Swift interop.
#ifdef LOVE_IOS
static const char *gr_saveDirectory()
{
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
return fs != nullptr ? fs->getSaveDirectory() : "";
}
static int gr_callBridge(lua_State *L, const char *className,
const char *selector, const char *arg)
{
Class cls = objc_getClass(className);
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
arg, gr_saveDirectory());
lua_pushboolean(L, ok != 0);
return 1;
}
int w_pickFile(lua_State *L)
{
const char *kind = luaL_optstring(L, 1, "rom");
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
}
int w_createFile(lua_State *L)
{
const char *name = luaL_optstring(L, 1, "export.sav");
return gr_callBridge(L, "GRPickerBridge", "presentExportWithName:saveDir:", name);
}
int w_syncHealthSteps(lua_State *L)
{
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
}
#endif // LOVE_IOS
// ---------------------------------------------------------------------------
""" % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
#endif
"""
# Deterministic 24-hex-digit object IDs, chosen not to collide with the
# upstream project (grep-verified against love-11.5's pbxproj).
ID_FILE_PICKER = "6E1AC0DE0001000000000001"
ID_FILE_OBJC = "6E1AC0DE0001000000000002"
ID_FILE_HEALTH = "6E1AC0DE0001000000000003"
ID_BUILD_PICKER = "6E1AC0DE0002000000000001"
ID_BUILD_OBJC = "6E1AC0DE0002000000000002"
ID_BUILD_HEALTH = "6E1AC0DE0002000000000003"
SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase
IOS_APP_CONFIG_IDS = (
"FA0B7F261A95AAF4000E1D17", # Debug
"FA0B7F271A95AAF4000E1D17", # Release
"FA0B7F281A95AAF4000E1D17", # Distribution
)
PBX_SOURCES = (
("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"),
("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"),
("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"),
)
def fail(msg):
print(f"patch_love_src: error: {msg}", file=sys.stderr)
sys.exit(1)
def pristine(path: Path) -> str:
"""Text of `path` before any of our patching: backed by a `.orig` stash.
The stash is only trusted if it is itself unpatched; that protects
against a stash accidentally taken after an earlier patch run.
"""
orig = path.with_suffix(path.suffix + ".orig")
if orig.is_file():
text = orig.read_text()
if MARKER not in text and ID_FILE_PICKER not in text:
return text
text = path.read_text()
if MARKER in text or ID_FILE_PICKER in text:
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
orig.write_text(text)
return text
def copy_native_files():
NATIVE_DST.mkdir(parents=True, exist_ok=True)
for name in NATIVE_FILES:
src = NATIVE_SRC / name
if not src.is_file():
fail(f"missing {src}")
shutil.copy2(src, NATIVE_DST / name)
if not ENTITLEMENTS_SRC.is_file():
fail(f"missing {ENTITLEMENTS_SRC}")
shutil.copy2(ENTITLEMENTS_SRC, NATIVE_DST / "love-ios.entitlements")
print(f"patch_love_src: native files -> {NATIVE_DST}")
def patch_wrap_system():
text = pristine(WRAP_SYSTEM)
include_anchor = '#include "sdl/System.h"\n'
if include_anchor not in text:
fail(f"include anchor not found in {WRAP_SYSTEM}")
text = text.replace(include_anchor, include_anchor + WRAP_INCLUDES, 1)
anchor = "static const luaL_Reg functions[] ="
if anchor not in text:
fail(f"anchor not found in {WRAP_SYSTEM}")
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
if reg_anchor not in text:
fail(f"registration anchor not found in {WRAP_SYSTEM}")
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps)")
def patch_pbxproj():
text = pristine(PBXPROJ)
build_files = "".join(
f"\t\t{build_id} /* {name} in Sources */ = "
f"{{isa = PBXBuildFile; fileRef = {file_id} /* {name} */; }};\n"
for name, file_id, build_id, _ in PBX_SOURCES
)
anchor = "/* Begin PBXBuildFile section */\n"
if anchor not in text:
fail("PBXBuildFile section not found")
text = text.replace(anchor, anchor + build_files, 1)
file_refs = "".join(
f"\t\t{file_id} /* {name} */ = "
f"{{isa = PBXFileReference; lastKnownFileType = {ftype}; "
f"name = {name}; path = ios/native/{name}; "
f"sourceTree = SOURCE_ROOT; }};\n"
for name, file_id, _, ftype in PBX_SOURCES
)
anchor = "/* Begin PBXFileReference section */\n"
if anchor not in text:
fail("PBXFileReference section not found")
text = text.replace(anchor, anchor + file_refs, 1)
# Add the files to the love-ios Sources phase.
phase_re = re.compile(
re.escape(SOURCES_PHASE_ID)
+ r" /\* Sources \*/ = \{.*?files = \(\n", re.S)
m = phase_re.search(text)
if not m:
fail("love-ios Sources phase not found")
insertion = "".join(
f"\t\t\t\t{build_id} /* {name} in Sources */,\n"
for name, _, build_id, _ in PBX_SOURCES
)
text = text[: m.end()] + insertion + text[m.end():]
# Swift + modern deployment target + HealthKit entitlements on the
# love-ios app target only (UTType and forExporting need iOS 14;
# liblove stays as upstream).
for config_id in IOS_APP_CONFIG_IDS:
cfg_re = re.compile(
re.escape(config_id) + r" /\* \w+ \*/ = \{.*?buildSettings = \{\n",
re.S)
m = cfg_re.search(text)
if not m:
fail(f"build configuration {config_id} not found")
settings = (
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
)
text = text[: m.end()] + settings + text[m.end():]
PBXPROJ.write_text(text)
print("patch_love_src: love.xcodeproj patched (native sources + Swift + entitlements)")
def main():
if not LOVE_SRC.is_dir():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files()
patch_wrap_system()
patch_pbxproj()
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
# Changelog
All notable changes to this mod are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.0.0] - 2026-07-30
### Added
- Opt-in SYNC STEPS option: Apple Health step counts (delivered by the iOS
build's native bridge as `steps_pending.json`) convert to EXP.
- STEPS PER EXP option (10 / 20 / 50, default 20).
- GIVE EXP TO option: lead mon (default) or whole party split.
- Level-ups applied with the engine's growth curves and rare-candy stat
math; walk-report textbox at quiet moments.
- Guardrails: steps anchored to the last sync (never credited twice),
50,000-step clamp per sync, engine `levelCap` respected.
+92
View File
@@ -0,0 +1,92 @@
# Pokéwalker (Apple Health) — a Gen1Recomp mod
Your real-world steps become EXP for your Pokémon party — the HeartGold/
SoulSilver Pokéwalker, except it's the iPhone already in your pocket.
A mod for [gen1recomp](https://github.com/bryanthaboi/gen1recomp)
(the Gen 1 Recompilation Project). Opt-in, data-safe, and dormant on any
platform that doesn't provide the native step source (see
[Requirements](#requirements)).
## Install
Grab `pokewalker-<version>.modpkg` from
[Releases](https://github.com/mresnick67/Gen1ReComp-Pokewalker/releases)
(or use GitHub's *Code → Download ZIP* — the importer handles both), then:
- **In the launcher:** MODS tab → **Import mod .zip** → pick the file, or
drag it onto the window on desktop.
- **iOS:** you can also drop the zip into the app's folder in the Files
app; it installs on next launch.
Then, in the **mod manager → POKEWALKER → options**, turn on **SYNC
STEPS**. iOS asks for read-only access to your step count the first time.
## Options
| Option | Values | Default |
|---|---|---|
| SYNC STEPS | on / off | **off** |
| STEPS PER EXP | 10 / 20 / 50 | 20 |
| GIVE EXP TO | lead mon / whole party (split) | lead mon |
## Mechanics & guardrails
- EXP applies through the engine's own growth curves and rare-candy stat
math, so levels, stats, and HP top-ups are exact.
- Steps are anchored to the last sync — the same walk is never credited
twice — and any single sync is clamped to 50,000 steps.
- The engine `levelCap` constant is respected.
- Credits land at quiet moments (save load, map change, battle end) with a
walk-report textbox.
## Requirements
The Lua mod is platform-neutral, but it feeds on a **native step bridge**
that currently ships in an iOS build of gen1recomp. Without the bridge the
mod loads and stays dormant — safe to install anywhere.
### The bridge contract (for porters)
Any platform can light this mod up by providing:
- `love.system.syncHealthSteps()``boolean` — kick off an async step
query (requesting OS permission on first use). On completion, write
**`steps_pending.json`** to the LÖVE save directory:
```json
{ "steps": 4312, "from": "2026-07-30T08:00:00Z", "to": "2026-07-30T17:00:00Z" }
```
Count steps from a persisted anchor (last successful sync) so a walk is
never delivered twice, and **merge** with an unconsumed pending file
rather than overwriting it. The mod consumes and deletes the file.
The reference iOS implementation is a small Swift class (HealthKit
`HKStatisticsQuery` over `stepCount`) exposed to Lua through a one-line
`wrap_System.cpp` addition. Open an issue here if you're porting the
bridge (Android: Health Connect / Google Fit would slot straight in).
## Known limitations (v1)
- Level-ups granted while walking don't prompt for new moves, and level
evolutions wait for the next in-battle level — same behavior as
over-leveling with rare candies.
- Steps sync on launch/activation; no background delivery yet.
## Developing
From a gen1recomp checkout with this mod at `mods/pokewalker` and an
imported data cache:
```sh
luajit mods/pokewalker/tests/pokewalker_test.lua
python3 tools/modkit.py validate mods/pokewalker --base imported
python3 tools/modkit.py pack mods/pokewalker
```
## License
MIT — see [LICENSE](LICENSE). Not affiliated with Nintendo, Game Freak,
or The Pokémon Company. This mod contains no ROM-derived content
(`modkit lint` clean).
+144
View File
@@ -0,0 +1,144 @@
-- Pokéwalker: Apple Health steps become party EXP (iOS builds).
--
-- The Swift side (mobile/ios/native/GRHealthBridge.swift) owns HealthKit:
-- love.system.syncHealthSteps() requests read access on first use, counts
-- steps since the last sync anchor, and drops steps_pending.json in the
-- save dir. This mod consumes that file at quiet moments (save loaded, map
-- transitions, battle end), converts steps to EXP, and applies level-ups
-- with the same stat math the engine uses.
--
-- Opt-in: everything is inert until SYNC STEPS is enabled in this mod's
-- options (the HealthKit permission sheet appears on first enable). On
-- non-iOS platforms love.system.syncHealthSteps does not exist and the mod
-- stays dormant.
--
-- Known v1 limits (documented in README.md): level-ups applied here do not
-- prompt for new moves (like over-leveling past a learnset entry with rare
-- candies) and do not trigger level evolutions until the next battle candy
-- or level gained in battle.
local PENDING = "steps_pending.json"
return function(mod)
mod.options:define({
{ key = "enabled", label = "SYNC STEPS", type = "toggle", default = false },
{ key = "rate", label = "STEPS PER EXP", type = "choice", default = "20",
choices = { { "10", "10" }, { "20", "20" }, { "50", "50" } } },
{ key = "target", label = "GIVE EXP TO", type = "choice", default = "lead",
choices = { { "LEAD MON", "lead" }, { "WHOLE PARTY", "party" } } },
})
local Json = require("src.link.Json")
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local game
local function active()
return love.system.syncHealthSteps ~= nil and mod.options:get("enabled")
end
-- Ask the native side to refresh steps_pending.json. Async: results are
-- picked up by a later consume() (next map change / battle end).
local function requestSync()
if active() then love.system.syncHealthSteps() end
end
-- Add EXP to one mon, bumping levels with the engine's own stat math
-- (mirrors the rare-candy path in src/inventory/ItemEffects.lua).
-- Returns the EXP actually absorbed and any levels gained.
local function applyToMon(mon, xp, data)
local def = data.pokemon[mon.species]
if not def or not mon.level then return 0, {} end
local cap = (data.constants and data.constants.levelCap) or 100
if mon.level >= cap then return 0, {} end
local maxExp = Growth.expForLevel(def.growthRate, cap, data.growth_rates)
local before = mon.exp or 0
mon.exp = math.min(maxExp, before + xp)
local absorbed = mon.exp - before
if absorbed <= 0 then return 0, {} end
local levels = {}
local newLevel = Growth.levelForExp(def.growthRate, mon.exp, cap,
data.growth_rates)
while mon.level < newLevel do
mon.level = mon.level + 1
local old = mon.stats
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = math.min(mon.stats.hp,
(mon.hp or 0) + (mon.stats.hp - (old and old.hp or 0)))
levels[#levels + 1] = mon.level
end
return absorbed, levels
end
-- A short walk-report textbox, shown only when the overworld is idle;
-- when a script is already running the report is silently skipped (the
-- EXP is applied regardless, and the log has the numbers).
local function report(steps, total, leveled)
local msg = ("You walked %d steps!\nYour party gained %d EXP."):format(steps, total)
if #leveled > 0 then
msg = msg .. ("\n%s grew to L%d!"):format(leveled[1].name, leveled[1].level)
end
-- The report is decoration: it must never break the credit. mod.world
-- materializes lazily (and can itself error in headless contexts), so
-- the access lives inside the pcall too.
pcall(function()
local world = mod.world
if world then world:queueScript({ { "show_text", msg } }) end
end)
end
local function consume()
if not (game and active()) then return end
local raw = love.filesystem.read(PENDING)
if not raw then return end
local decoded = Json.decode(raw)
local steps = decoded and tonumber(decoded.steps) or 0
love.filesystem.remove(PENDING)
if steps <= 0 then return end
local party = game.save and game.save.party
if not party or #party == 0 then return end
local rate = tonumber(mod.options:get("rate")) or 20
local xp = math.floor(steps / rate)
if xp <= 0 then return end
local total, leveled = 0, {}
local targets = {}
if mod.options:get("target") == "party" then
for _, mon in ipairs(party) do targets[#targets + 1] = mon end
else
targets[1] = party[1]
end
local share = math.max(1, math.floor(xp / #targets))
for _, mon in ipairs(targets) do
local absorbed, levels = applyToMon(mon, share, game.data)
total = total + absorbed
for _, level in ipairs(levels) do
leveled[#leveled + 1] =
{ name = mon.nickname or mon.species, level = level }
end
end
if total <= 0 then return end
mod.log:info("credited %d steps -> %d EXP (%d level-ups)",
steps, total, #leveled)
report(steps, total, leveled)
end
-- game.ready is the sanctioned way to obtain the Game object; the party
-- only exists once a save is loaded or created.
mod.events:on("game.ready", function(payload)
game = payload.game
requestSync()
end)
mod.events:on("save.loaded", function()
requestSync()
consume()
end)
mod.events:on("save.created", function() requestSync() end)
-- Quiet moments where a walk report can safely appear.
mod.events:on("map.entered", function() consume() end)
mod.events:on("battle.ended", function() consume() end)
-- Flipping SYNC STEPS on triggers the HealthKit permission sheet
-- immediately rather than on the next boot.
mod.events:on("mod.options_changed", function() requestSync() end)
end
+16
View File
@@ -0,0 +1,16 @@
{
"id": "pokewalker",
"name": "Pokewalker (Apple Health)",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "MECHANIC",
"game_version": ">=0.0.0-0 <2.0.0",
"permissions": ["network", "engine_internals"],
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Your real-world steps (Apple Health) become EXP for your party. Opt-in: enable SYNC STEPS in this mod's options. Needs an iOS build with the Health bridge; dormant elsewhere."
}
+33
View File
@@ -0,0 +1,33 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Real-world steps from Apple Health become EXP for your party.",
author = "mresnick67",
contact = "https://github.com/mresnick67/Gen1ReComp-Pokewalker",
tags = { "mechanic", "ios", "health", "opt-in", "field" },
differences = {
changed = {},
added = {
"an opt-in SYNC STEPS option: Apple Health step counts convert to "
.. "EXP at a configurable rate (10/20/50 steps per EXP)",
"EXP lands on the lead mon or splits across the party, applied with "
.. "the engine's own growth curves and rare-candy stat math",
"a walk-report textbox at quiet moments (save load, map change, "
.. "battle end)",
},
known = {
"needs an iOS build that ships the native Health bridge "
.. "(love.system.syncHealthSteps); on every other platform the mod "
.. "loads but stays dormant",
"level-ups granted while walking do not prompt for new moves and do "
.. "not trigger level evolutions until the next in-battle level "
.. "(same as over-leveling with rare candies)",
"steps sync on launch/activation; no background delivery yet",
},
},
credits = {
{ who = "Nintendo's Pokewalker (HGSS)", for_ = "the idea this recreates" },
{ who = "bryanthaboi/gen1recomp", for_ = "the engine and mod platform" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
+54
View File
@@ -0,0 +1,54 @@
-- Standalone: luajit mods/pokewalker/tests/pokewalker_test.lua
-- Exercises the stated effect: opt-in gating, the native-bridge seam, and
-- steps converting to EXP through the engine's own growth math.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
-- The native Health bridge only exists inside the iOS app; stand it in so
-- the mod sees the same surface it does on device.
local syncCalls = 0
love.system = love.system or {}
love.system.syncHealthSteps = function()
syncCalls = syncCalls + 1
return true
end
local run = T.sdk.loadMod("mods/pokewalker", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local events = run.loader.events
local Pokemon = require("src.pokemon.Pokemon")
local mon = Pokemon.new(Data, "PIDGEY", 5)
local game = { data = Data, save = { party = { mon } } }
-- Dormant until opted in: seeded steps survive every event untouched and
-- the native bridge is never poked (no permission prompt without consent).
love.filesystem.write("steps_pending.json", '{"steps": 4000}')
events:emit("game.ready", { game = game })
events:emit("map.entered", {})
T.check(love.filesystem.read("steps_pending.json") ~= nil,
"opt-out leaves pending steps untouched")
T.eq(syncCalls, 0, "opt-out never calls the native bridge")
-- Opted in: 4000 steps at the default 20 steps/EXP credit the lead mon.
run.loader.modOptions.pokewalker = { enabled = true }
local expBefore = mon.exp
events:emit("save.loaded", {})
T.eq(mon.exp, expBefore + 200, "4000 steps at 20 steps/EXP = +200 EXP")
T.eq(mon.level, 8, "level-ups ride the engine growth curve (5 -> 8)")
T.check(mon.stats.hp > 0 and mon.hp <= mon.stats.hp,
"stat recalc keeps HP within the new maximum")
T.check(love.filesystem.read("steps_pending.json") == nil,
"pending file is consumed exactly once")
T.check(syncCalls > 0, "opt-in requests a native sync")
-- A consumed file plus more events must not double-credit.
local expAfter = mon.exp
events:emit("map.entered", {})
T.eq(mon.exp, expAfter, "no pending file, no phantom EXP")
run.release()
T.finish("pokewalker")
+108 -13
View File
@@ -4,8 +4,11 @@
# #
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only] # Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
# #
# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO) # (default) Simulator Debug (ad-hoc signed)
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM) # --device iphoneos SDK; signing team auto-detected from the
# keychain when DEVELOPMENT_TEAM is not set
# --install after a --device build, install the app onto the
# first connected iPhone/iPad (unlock it first)
# --release Release configuration # --release Release configuration
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/ # --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild # --package-only Zip game.love + apply plist overlay; skip xcodebuild
@@ -35,7 +38,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="gen1recomp" APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp" DISPLAY_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred" # Bundle ID resolution, most specific wins:
# 1. GEN1_BUNDLE_ID env var
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
# so rebuilds keep updating the same app on your phone)
# 3. device builds: com.gen1recomp.t<your team id> — explicit App IDs are
# globally unique across ALL Apple accounts (and required once
# 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:-}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)" LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip" IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip" APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
@@ -46,6 +61,7 @@ FETCH=false
DEVICE=false DEVICE=false
RELEASE=false RELEASE=false
PACKAGE_ONLY=false PACKAGE_ONLY=false
INSTALL=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -57,15 +73,44 @@ while [ $# -gt 0 ]; do
--device) DEVICE=true ;; --device) DEVICE=true ;;
--release) RELEASE=true ;; --release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;; --package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
-h|--help) -h|--help)
sed -n '2,22p' "$0" sed -n '2,22p' "$0"
exit 0 exit 0
;; ;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;; *) fail "unknown argument: $1 (try --fetch, --device, --release, --install, or --package-only)" ;;
esac esac
shift shift
done done
# ---------------------------------------------------------- signing identity
# Auto-detect the Apple Development team when the caller didn't set one: the
# OU field of the first Apple Development certificate in the keychain (Xcode
# creates that certificate when you sign into Settings -> Accounts).
detect_team() {
security find-certificate -c "Apple Development" -p 2>/dev/null \
| openssl x509 -noout -subject 2>/dev/null \
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' | head -1
}
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
DEVELOPMENT_TEAM="$(detect_team || true)"
if [ -n "$DEVELOPMENT_TEAM" ]; then
say "signing team auto-detected from keychain: $DEVELOPMENT_TEAM"
else
fail "no Apple signing identity found.
Open Xcode -> Settings -> Accounts, press +, and sign in with your
Apple ID (a free account works). That creates the certificate this
script signs with. Then re-run this command."
fi
fi
if [ -z "$BUNDLE_ID" ]; then
if $DEVICE; then
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
else
BUNDLE_ID="com.theboisclub.pokemonred"
fi
fi
# --------------------------------------------------------------- host checks # --------------------------------------------------------------- host checks
if [ "$(uname -s)" != "Darwin" ]; then if [ "$(uname -s)" != "Darwin" ]; then
fail "iOS builds require macOS (Darwin). This host is $(uname -s). fail "iOS builds require macOS (Darwin). This host is $(uname -s).
@@ -166,16 +211,23 @@ pack_game_love() {
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources plus # Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process. # tools/save-editor, which the launcher's Edit button opens in-process.
# mods/pokewalker rides inside game.love on iOS only: physfs merges the
# fused archive with the save dir, so the loader discovers it like any
# installed mod, and its Apple Health sync is a no-op everywhere else.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \ main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
mods/pokewalker \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
# dies of SIGPIPE (141), and the pipeline "fails" nondeterministically.
# >/dev/null keeps grep reading the whole stream instead.
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then | grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
fail "game.love unexpectedly contains generated ROM data" fail "game.love unexpectedly contains generated ROM data"
fi fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \ unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|| fail "game.love is missing the save editor (Edit on a save row would crash)" || fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
} }
@@ -300,18 +352,25 @@ run_xcodebuild() {
SYMROOT="$BUILD_DIR/Build/Products" SYMROOT="$BUILD_DIR/Build/Products"
OBJROOT="$BUILD_DIR/Build/Intermediates" OBJROOT="$BUILD_DIR/Build/Intermediates"
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
PRODUCT_NAME="$APP_NAME"
MARKETING_VERSION="$LOVE_VERSION" MARKETING_VERSION="$LOVE_VERSION"
ONLY_ACTIVE_ARCH=NO ONLY_ACTIVE_ARCH=NO
) )
if ! $DEVICE; then if ! $DEVICE; then
# Simulator: no signing required # Simulator: ad-hoc signing (no certificate needed). A plain unsigned
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=) # build would drop the entitlements file, and HealthKit refuses to run
# without the com.apple.developer.healthkit entitlement even in the
# simulator.
args+=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=-)
else else
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY" warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM") # Automatic signing + provisioning updates lets xcodebuild register the
# bundle ID / create a development profile from the CLI, so a device
# build works without ever opening the project in Xcode.
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"
CODE_SIGN_STYLE=Automatic
-allowProvisioningUpdates)
fi fi
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY") args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
@@ -364,20 +423,56 @@ run_xcodebuild() {
local dist_dir="$DIST/${config}-${sdk}" local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir" rm -rf "$dist_dir"
mkdir -p "$dist_dir" mkdir -p "$dist_dir"
cp -R "$app" "$dist_dir/" cp -R "$app" "$dist_dir/$APP_NAME.app"
say "copied to $dist_dir/$(basename "$app")" say "copied to $dist_dir/$APP_NAME.app"
say "iOS app: $app" say "iOS app: $app"
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME" say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
if $DEVICE; then if $DEVICE; then
warn "signing/provisioning is manual, see mobile/ios/README.md" if $INSTALL; then
install_to_device "$app"
else
say "install with: scripts/build_ios.sh --device --install (iPhone plugged in + unlocked)"
fi
else else
say "simulator tip: xcrun simctl install booted \"$app\"" say "simulator tip: xcrun simctl install booted \"$app\""
fi fi
} }
# ------------------------------------------------------------ device install
# Installs the freshly built .app onto the first connected iPhone/iPad via
# devicectl. The phone must be paired (plugged in at least once + "Trust
# This Computer") and UNLOCKED during the install.
install_to_device() {
local app="$1"
local line udid
line="$(xcrun devicectl list devices 2>/dev/null \
| grep -E 'iPhone|iPad' | grep -v 'Watch' | head -1 || true)"
udid="$(printf '%s' "$line" \
| grep -Eo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \
| head -1 || true)"
if [ -z "$udid" ]; then
fail "no iPhone/iPad found.
Plug the phone in with a cable, unlock it, tap 'Trust This Computer'
if asked, then re-run: scripts/build_ios.sh --device --install"
fi
say "installing onto: $(printf '%s' "$line" | sed 's/ .*//') ($udid)"
if xcrun devicectl device install app --device "$udid" "$app"; then
say "installed. On the phone: tap the new app on your Home Screen."
say "first launch may ask you to enable Developer Mode (Settings ->"
say "Privacy & Security -> Developer Mode) and to trust the developer"
say "(Settings -> General -> VPN & Device Management)."
else
fail "install failed. Most common cause: the phone was locked.
Unlock it, keep it plugged in, and re-run:
scripts/build_ios.sh --device --install"
fi
}
# --------------------------------------------------------------- main # --------------------------------------------------------------- main
apply_ios_branding apply_ios_branding
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
pack_game_love pack_game_love
ensure_game_love_in_xcode ensure_game_love_in_xcode
+67 -1
View File
@@ -482,7 +482,12 @@ end
-- supplied the Edit label is not drawn at all). -- supplied the Edit label is not drawn at all).
function RomImporter.new(onComplete, opts) function RomImporter.new(onComplete, opts)
opts = opts or {} opts = opts or {}
local android = love.system.getOS() == "Android" -- iOS rides the same mobile import flows as Android: the save-dir
-- pending-file scan plus love.system.pickFile / createFile, provided
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
-- keeps its historical name so every Android call site stays untouched.
local mobileOS = love.system.getOS()
local android = mobileOS == "Android" or mobileOS == "iOS"
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local self = setmetatable({ local self = setmetatable({
onComplete = onComplete, onComplete = onComplete,
@@ -490,6 +495,11 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false, forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave, onEditSave = opts.onEditSave,
android = android, android = android,
ios = mobileOS == "iOS",
-- One startup poll pass: files dropped through the Files app are swept
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
-- event necessarily follows, so consume them via the first poll tick.
pickPending = mobileOS == "iOS" or nil,
-- Android drag: the launcher is handed no move events at all (main.lua -- Android drag: the launcher is handed no move events at all (main.lua
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse -- forwards neither touchmoved nor mousemoved while it is up), and its mouse
-- emulation is what "no reliable pointer polling" below refers to. -- emulation is what "no reliable pointer polling" below refers to.
@@ -617,6 +627,11 @@ end
-- up without the player needing to tap the button again. Mod and save SAF -- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover -- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready. -- ROM pick cannot steal the focus path when both games are already ready.
-- NOTE (iOS): do NOT clear pickPending here. The picker's dismissal focus
-- event can arrive before the Swift delegate has finished copying the pick
-- into the save dir; if this scan runs early and finds nothing, the poll in
-- _pollPickedFiles must stay armed so it consumes the file when it lands
-- moments later (it clears pickPending itself once something is found).
function RomImporter:focus(f) function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag. -- SAF create-document finished: GameActivity wrote export_done.flag.
@@ -860,6 +875,9 @@ function RomImporter:chooseMod()
if not love.system.pickFile("mod") then if not love.system.pickFile("mod") then
self.modNotice = { ok = false, self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." } text = "Could not open the file picker. Copy a mod .zip via USB." }
else
self.pickPending = true
self.pickTimer = 0
end end
return return
end end
@@ -922,6 +940,9 @@ function RomImporter:chooseSaveImport(version)
self.androidPendingVersion = nil self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false, self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." } text = "Could not open the file picker. Copy a .sav via USB." }
else
self.pickPending = true
self.pickTimer = 0
end end
return return
end end
@@ -958,6 +979,8 @@ function RomImporter:exportSave(version)
end end
self.androidPendingExportVersion = version self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then if love.system.createFile and love.system.createFile(suggested) then
self.pickPending = true
self.pickTimer = 0
self.saveNotice[version] = { ok = true, self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." } text = "Pick where to save " .. suggested .. "..." }
else else
@@ -1008,6 +1031,9 @@ function RomImporter:choose(version)
status = "No picker available, copy your ROM into:", status = "No picker available, copy your ROM into:",
detail = love.filesystem.getSaveDirectory(), detail = love.filesystem.getSaveDirectory(),
} }
else
self.pickPending = true
self.pickTimer = 0
end end
return return
end end
@@ -1042,9 +1068,49 @@ function RomImporter:choose(version)
end end
end end
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
-- separate SAF activity there is no love.focus(true) when it dismisses.
-- While a pick is outstanding, poll the save dir for the bridge's delivered
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
-- and run the same refocus import path Android uses.
function RomImporter:_pollPickedFiles(dt)
if not (self.ios and self.pickPending) then return end
if self.workState == "working" then return end
self.pickTimer = (self.pickTimer or 0) + dt
if self.pickTimer < 0.5 then return end
self.pickTimer = 0
-- The Swift bridge reports a failed pick copy through pick_error.txt;
-- surface it on whichever tab the player is looking at rather than
-- letting the pick silently do nothing.
local pickError = love.filesystem.read("pick_error.txt")
if pickError then
love.filesystem.remove("pick_error.txt")
self.pickPending = nil
self.modNotice = { ok = false, text = pickError }
self.notice = { version = self.chooseVersion or "red",
status = "File import failed:", detail = pickError }
return
end
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
found = true
break
end
end
end
if found then
self.pickPending = nil
self:focus(true)
end
end
function RomImporter:update(dt) function RomImporter:update(dt)
self.pulse = self.pulse + dt self.pulse = self.pulse + dt
self:_updatePadCursor(dt) self:_updatePadCursor(dt)
self:_pollPickedFiles(dt)
if self.workState ~= "working" or not self.worker then return end if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime() local started = love.timer.getTime()
repeat repeat