diff --git a/.gitignore b/.gitignore index c5d32cf7..ab7ceda5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ mobile/dist/ # Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported # ROM cache exactly like data/generated/, so it is never committable. /build/tiled/ + +# per-machine iOS bundle-id pin (see scripts/build_ios.sh) +mobile/ios/bundle_id.local diff --git a/docs/ios-install.md b/docs/ios-install.md new file mode 100644 index 00000000..5d503300 --- /dev/null +++ b/docs/ios-install.md @@ -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 + (5–15 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. diff --git a/main.lua b/main.lua index f88a7381..de69e994 100644 --- a/main.lua +++ b/main.lua @@ -358,7 +358,18 @@ end function love.touchpressed(id, x, y, dx, dy, pressure) 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) end diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 846595d8..25a0f0b6 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -1,5 +1,25 @@ # 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 (`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"`. diff --git a/mobile/ios/native/GRBootstrap.m b/mobile/ios/native/GRBootstrap.m new file mode 100644 index 00000000..4b56de9c --- /dev/null +++ b/mobile/ios/native/GRBootstrap.m @@ -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 + +__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)]; + } + }]; +} diff --git a/mobile/ios/native/GRHealthBridge.swift b/mobile/ios/native/GRHealthBridge.swift new file mode 100644 index 00000000..ccdfc0bc --- /dev/null +++ b/mobile/ios/native/GRHealthBridge.swift @@ -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": "", "to": "" } +// +// 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?, + saveDir: UnsafePointer?) -> 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 + }() +} diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift new file mode 100644 index 00000000..6f19d44f --- /dev/null +++ b/mobile/ios/native/GRPickerBridge.swift @@ -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 + // (/Library/Application Support/). + 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?, + saveDir: UnsafePointer?) -> 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?, + saveDir: UnsafePointer?) -> 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 = ["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?) -> 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?() + } +} diff --git a/mobile/ios/overlays/love-ios.entitlements b/mobile/ios/overlays/love-ios.entitlements new file mode 100644 index 00000000..7beda113 --- /dev/null +++ b/mobile/ios/overlays/love-ios.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.developer.healthkit + + + diff --git a/mobile/ios/overlays/love-ios.plist b/mobile/ios/overlays/love-ios.plist index 5305530a..e9bf5d79 100644 --- a/mobile/ios/overlays/love-ios.plist +++ b/mobile/ios/overlays/love-ios.plist @@ -100,5 +100,16 @@ + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + + NSHealthShareUsageDescription + The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking. diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py new file mode 100644 index 00000000..6ea38c83 --- /dev/null +++ b/mobile/ios/patch_love_src.py @@ -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 +#include +#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(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() diff --git a/mods/pokewalker/CHANGELOG.md b/mods/pokewalker/CHANGELOG.md new file mode 100644 index 00000000..2f4118a6 --- /dev/null +++ b/mods/pokewalker/CHANGELOG.md @@ -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. diff --git a/mods/pokewalker/README.md b/mods/pokewalker/README.md new file mode 100644 index 00000000..9b831710 --- /dev/null +++ b/mods/pokewalker/README.md @@ -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-.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). diff --git a/mods/pokewalker/main.lua b/mods/pokewalker/main.lua new file mode 100644 index 00000000..39231689 --- /dev/null +++ b/mods/pokewalker/main.lua @@ -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 diff --git a/mods/pokewalker/manifest.json b/mods/pokewalker/manifest.json new file mode 100644 index 00000000..5a067330 --- /dev/null +++ b/mods/pokewalker/manifest.json @@ -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." +} diff --git a/mods/pokewalker/mod.card b/mods/pokewalker/mod.card new file mode 100644 index 00000000..17bdb745 --- /dev/null +++ b/mods/pokewalker/mod.card @@ -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 }, +} diff --git a/mods/pokewalker/tests/pokewalker_test.lua b/mods/pokewalker/tests/pokewalker_test.lua new file mode 100644 index 00000000..392da4a4 --- /dev/null +++ b/mods/pokewalker/tests/pokewalker_test.lua @@ -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") diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index c81d470a..6824a2d4 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -4,8 +4,11 @@ # # Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only] # -# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO) -# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM) +# (default) Simulator Debug (ad-hoc signed) +# --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 # --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/ # --package-only Zip game.love + apply plist overlay; skip xcodebuild @@ -35,7 +38,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries" APP_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 — 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)" IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip" APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip" @@ -46,6 +61,7 @@ FETCH=false DEVICE=false RELEASE=false PACKAGE_ONLY=false +INSTALL=false say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } @@ -57,15 +73,44 @@ while [ $# -gt 0 ]; do --device) DEVICE=true ;; --release) RELEASE=true ;; --package-only) PACKAGE_ONLY=true ;; + --install) INSTALL=true ;; -h|--help) sed -n '2,22p' "$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 shift 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 if [ "$(uname -s)" != "Darwin" ]; then fail "iOS builds require macOS (Darwin). This host is $(uname -s). @@ -166,16 +211,23 @@ pack_game_love() { rm -f "$LOVE_FILE" # Same payload as scripts/build.sh / build_android.sh: game sources plus # 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" \ main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ + mods/pokewalker \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -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" \ - | 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" 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)" say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" } @@ -300,18 +352,25 @@ run_xcodebuild() { SYMROOT="$BUILD_DIR/Build/Products" OBJROOT="$BUILD_DIR/Build/Intermediates" PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" - PRODUCT_NAME="$APP_NAME" MARKETING_VERSION="$LOVE_VERSION" ONLY_ACTIVE_ARCH=NO ) if ! $DEVICE; then - # Simulator: no signing required - args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=) + # Simulator: ad-hoc signing (no certificate needed). A plain unsigned + # 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 warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY" 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 if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY") @@ -364,20 +423,56 @@ run_xcodebuild() { local dist_dir="$DIST/${config}-${sdk}" rm -rf "$dist_dir" mkdir -p "$dist_dir" - cp -R "$app" "$dist_dir/" - say "copied to $dist_dir/$(basename "$app")" + cp -R "$app" "$dist_dir/$APP_NAME.app" + say "copied to $dist_dir/$APP_NAME.app" say "iOS app: $app" say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME" 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 say "simulator tip: xcrun simctl install booted \"$app\"" 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 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 ensure_game_love_in_xcode diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 82609f51..ed1b1e19 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -482,7 +482,12 @@ end -- supplied the Edit label is not drawn at all). function RomImporter.new(onComplete, opts) 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 self = setmetatable({ onComplete = onComplete, @@ -490,6 +495,11 @@ function RomImporter.new(onComplete, opts) forceImport = opts.forceImport or false, onEditSave = opts.onEditSave, 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 -- forwards neither touchmoved nor mousemoved while it is up), and its mouse -- 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 -- 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. +-- 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) if not (f and self.android and self.workState ~= "working") then return end -- SAF create-document finished: GameActivity wrote export_done.flag. @@ -860,6 +875,9 @@ function RomImporter:chooseMod() if not love.system.pickFile("mod") then self.modNotice = { ok = false, text = "Could not open the file picker. Copy a mod .zip via USB." } + else + self.pickPending = true + self.pickTimer = 0 end return end @@ -922,6 +940,9 @@ function RomImporter:chooseSaveImport(version) self.androidPendingVersion = nil self.saveNotice[version] = { ok = false, text = "Could not open the file picker. Copy a .sav via USB." } + else + self.pickPending = true + self.pickTimer = 0 end return end @@ -958,6 +979,8 @@ function RomImporter:exportSave(version) end self.androidPendingExportVersion = version if love.system.createFile and love.system.createFile(suggested) then + self.pickPending = true + self.pickTimer = 0 self.saveNotice[version] = { ok = true, text = "Pick where to save " .. suggested .. "..." } else @@ -1008,6 +1031,9 @@ function RomImporter:choose(version) status = "No picker available, copy your ROM into:", detail = love.filesystem.getSaveDirectory(), } + else + self.pickPending = true + self.pickTimer = 0 end return end @@ -1042,9 +1068,49 @@ function RomImporter:choose(version) 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) self.pulse = self.pulse + dt self:_updatePadCursor(dt) + self:_pollPickedFiles(dt) if self.workState ~= "working" or not self.worker then return end local started = love.timer.getTime() repeat