mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
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:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user