Compare commits

..

15 Commits

Author SHA1 Message Date
bryanthaboi efad8b325b Merge pull request #490 from bryanthaboi/dev
ios and some bugs
2026-07-30 16:12:24 -04:00
bryanthaboi 4c5f776128 Update README.md 2026-07-30 16:04:34 -04:00
bryanthaboi c1495e0d5e ios 2026-07-30 16:03:35 -04:00
bryanthaboi a97168a4f0 Update ios-install.md 2026-07-30 15:53:49 -04:00
bryanthaboi a50ad30176 Merge pull request #464 from mresnick67/pokewalker-0.2.0-followup
iOS: stop fusing mods; Pokewalker moves to its own repo
2026-07-30 15:51:55 -04:00
bryanthaboi c1f65106de Merge pull request #457 from johnjohto/fix-trainer-evolution-order
Delay evolution until trainer text ends
2026-07-30 14:56:24 -04:00
johnjohto 4250e3aa32 Delay evolution until trainer text ends 2026-07-30 14:03:36 -04:00
bryanthaboi cecb24bb0e Merge pull request #480 from bryanthaboi/dev
johnny boys fixes
2026-07-30 13:55:19 -04:00
bryanthaboi b99b497893 Merge pull request #479 from johnjohto/fix-save-editor-items
Fix save editor item crash on Android
2026-07-30 13:53:24 -04:00
johnjohto e16a45d6ba Avoid invalid save editor clips
On compact Android viewports the item picker can end up with no list
space. Passing its negative height to setScissor crashes LÖVE.
Use an empty clip region instead.
2026-07-30 13:28:18 -04:00
bryanthaboi 0e29046c31 Merge pull request #458 from johnjohto/fix-yellow-bills-pikachu
Add Bill's House Pikachu scenes
2026-07-30 13:02:50 -04:00
johnjohto 5cbada493a Add Bill's House Pikachu scenes 2026-07-30 12:52:17 -04:00
bryanthaboi 4c79db3d5d Merge pull request #472 from bryanthaboi/dev
stupid ass android
2026-07-30 12:28:57 -04:00
bryanthaboi c7e46b6563 stupid ass android 2026-07-30 12:26:27 -04:00
Myles Resnick fe1714d50e iOS: stop fusing mods; Pokewalker moves to its own repo
Follow-up to #452, from dogfooding the merged build on a real iPhone.

A mod fused into game.love sits in the read-only app bundle: the mod
manager's Delete can't remove it and it reappears every launch, and an
imported newer version can never fully replace it. iOS now packs no
mods, matching every other platform - mods install as .zips at runtime
with a real install/upgrade/delete lifecycle.

With nothing fusing it, mods/pokewalker moves out of this tree to its
canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker
(already at 0.2.0 there with move learning and a paged credit dialog;
in-tree 0.1.0 was stale). docs/ios-install.md points at it. The
event:save.loaded coverage-debt entry returns to gate_meta_coverage:
its coverage came from the mod's test suite and leaves with it.

Local reproduction of the ci workflow on this branch: all tiers, mod
lint, fingerprint + meta-coverage gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:44:16 -04:00
27 changed files with 484 additions and 398 deletions
+1
View File
@@ -40,6 +40,7 @@ body:
- Windows
- Linux
- Android
- iOS
- Multiple platforms
validations:
required: true
+17 -3
View File
@@ -1,8 +1,8 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -170,6 +170,15 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build iOS
run: |
set -euo pipefail
# Device Release IPA; signs with the Apple Development identity on
# the runner (auto team detection). Users on other Apple IDs still
# re-sign or build via docs/ios-install.md.
scripts/build_ios.sh --fetch --device --release \
--version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
@@ -226,6 +235,10 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -344,6 +357,7 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
+19 -11
View File
@@ -50,25 +50,26 @@ supplied by the player.
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input.
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
played side by side.
## Quick Start
Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -141,11 +142,11 @@ famous bugs included:
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
scripts/run.sh
```
@@ -176,6 +177,13 @@ even on a different computer, as long as the same folder comes along.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md).
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
+8
View File
@@ -78,6 +78,14 @@ function love.conf(t)
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
-- is held upright, so it streams past-deadzone joystickaxis events that
-- both hid the touch overlay every instant (it reads as "a controller is
-- in use") and, via the generic-joystick axis mapping (#459), walked the
-- player around by tilt. Nothing in the game reads the accelerometer,
-- so drop the device entirely (#468).
t.accelerometerjoystick = false
else
t.window.resizable = true
end
+4
View File
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
done()
end
if ow.player.facing == "down" then
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
if not flags.EVENT_MET_BILL_2 then
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
end
end,
}
+3 -3
View File
@@ -1,5 +1,8 @@
# Build & install on your iPhone — step by step
Don't want to install Xcode? Download the release IPA and sideload it with
AltStore instead — see [ios-sideload.md](ios-sideload.md).
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).
@@ -93,9 +96,6 @@ ZIP**, then double-click the zip to unpack 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.
+48
View File
@@ -0,0 +1,48 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode.
## 1. Install AltStore
Follow the official guide for your computer:
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
You will install **AltServer** on the computer, then use it to put AltStore
on the phone. What AltServer is and why it needs to stay running:
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
Stuck? Start here:
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer).
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
16+), and Settings → **General → VPN & Device Management** → Trust your
Apple ID if asked.
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
play.
## Refresh / 7-day limit
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
AltServer running so AltStore can refresh them, or open AltStore and refresh
manually before they expire. Saves on the phone are kept across refreshes.
## Prefer building it yourself?
Building from source on a Mac (no AltStore) is covered in
[ios-install.md](ios-install.md).
+1 -1
View File
@@ -40,7 +40,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
-17
View File
@@ -1,17 +0,0 @@
# 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
@@ -1,92 +0,0 @@
# 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
@@ -1,144 +0,0 @@
-- 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
@@ -1,16 +0,0 @@
{
"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
@@ -1,33 +0,0 @@
-- 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 },
}
-61
View File
@@ -1,61 +0,0 @@
-- 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.
--
-- Shipped-mod suites are auto-run by the ROM-free T4 tier, so this runs
-- against the committed fixture dataset (T.fixtures), never data/generated.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
-- The native step bridge only exists inside the iOS/Android apps; 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 Growth = require("src.pokemon.Growth")
local Pokemon = require("src.pokemon.Pokemon")
local mon = Pokemon.new(Data, "FIXMON_A", 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": 20000}')
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: 20000 steps at the default 20 steps/EXP credit the lead mon.
run.loader.modOptions.pokewalker = { enabled = true }
local def = Data.pokemon[mon.species]
local expBefore = mon.exp
events:emit("save.loaded", {})
T.eq(mon.exp, expBefore + 1000, "20000 steps at 20 steps/EXP = +1000 EXP")
local expectedLevel = Growth.levelForExp(def.growthRate, mon.exp, 100,
Data.growth_rates)
T.check(mon.level > 5, "enough EXP to actually level (fixture curve)")
T.eq(mon.level, expectedLevel, "level matches the engine growth curve")
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")
+3
View File
@@ -308,6 +308,9 @@ build_ios() {
if [ "$IOS_RELEASE" = true ]; then
args+=(--release)
fi
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
}
+66 -13
View File
@@ -2,7 +2,8 @@
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
# official iOS Xcode project (love-11.5-ios-source.zip).
#
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
# [--version X.Y.Z] [--package-only]
#
# (default) Simulator Debug (ad-hoc signed)
# --device iphoneos SDK; signing team auto-detected from the
@@ -10,6 +11,7 @@
# --install after a --device build, install the app onto the
# first connected iPhone/iPad (unlock it first)
# --release Release configuration
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
#
@@ -19,6 +21,7 @@
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
set -euo pipefail
@@ -62,6 +65,7 @@ DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -74,23 +78,43 @@ while [ $# -gt 0 ]; do
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,22p' "$0"
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, --install, or --package-only)" ;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
esac
shift
done
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
major="${VERSION%%.*}"
rest="${VERSION#*.}"
minor="${rest%%.*}"
patch="${rest##*.}"
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
fi
# ---------------------------------------------------------- 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).
# Auto-detect the Apple Development team when the caller didn't set one.
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
# the cert id, not the team), then read that cert's OU. Scanning every
# "Apple Development" certificate picks expired personal/work certs first.
detect_team() {
security find-certificate -c "Apple Development" -p 2>/dev/null \
local cn
cn="$(security find-identity -v -p codesigning 2>/dev/null \
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
| head -1)"
[ -n "$cn" ] || return 1
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
| openssl x509 -noout -subject 2>/dev/null \
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' | head -1
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
| head -1
}
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
DEVELOPMENT_TEAM="$(detect_team || true)"
@@ -211,13 +235,14 @@ 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.
# Deliberately NO fused mods: a mod inside game.love sits in the
# read-only app bundle, so the mod manager's Delete can't remove it and
# it reappears every launch. Mods install as .zips at runtime instead
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
# other platform.
(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
@@ -343,6 +368,15 @@ run_xcodebuild() {
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
local marketing_version="$LOVE_VERSION"
local project_version="1"
if [ -n "$VERSION" ]; then
marketing_version="$VERSION"
project_version="$VERSION_CODE"
fi
local args=(
-project "$PROJECT"
-target love-ios
@@ -352,7 +386,8 @@ run_xcodebuild() {
SYMROOT="$BUILD_DIR/Build/Products"
OBJROOT="$BUILD_DIR/Build/Intermediates"
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
MARKETING_VERSION="$LOVE_VERSION"
MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version"
ONLY_ACTIVE_ARCH=NO
)
@@ -426,6 +461,10 @@ run_xcodebuild() {
cp -R "$app" "$dist_dir/$APP_NAME.app"
say "copied to $dist_dir/$APP_NAME.app"
if $DEVICE; then
package_ipa "$dist_dir/$APP_NAME.app"
fi
say "iOS app: $app"
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
if $DEVICE; then
@@ -439,6 +478,20 @@ run_xcodebuild() {
fi
}
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
package_ipa() {
local app="$1"
local ipa="$DIST/$APP_NAME.ipa"
local tmp
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
mkdir -p "$tmp/Payload"
cp -R "$app" "$tmp/Payload/$(basename "$app")"
rm -f "$ipa"
(cd "$tmp" && zip -q -r "$ipa" Payload)
rm -rf "$tmp"
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
}
# ------------------------------------------------------------ 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
+15
View File
@@ -491,21 +491,36 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
local function isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
Input:joystickpressed(joystick, button)
end
function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
end
function Game:joystickaxis(joystick, axis, value)
if isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game:joystickhat(joystick, hat, direction)
if isAccelerometer(joystick) then return end
if direction ~= "c" then TouchControls:noteGamepad() end
Input:joystickhat(joystick, hat, direction)
end
+12 -1
View File
@@ -265,7 +265,18 @@ function Commands.start_battle(ctx, kind, a, b)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
ctx.overworld:afterBattle(result, battle)
-- A map script often follows a trainer battle with its own text.
-- Keep a level evolution behind that text: otherwise afterBattle
-- pushes the evolution screen, then this runner resumes and pushes
-- the trainer's text on top of it.
if result == "win" then
ctx.afterScript = ctx.afterScript or {}
table.insert(ctx.afterScript, function()
ctx.overworld:afterBattle(result, battle)
end)
else
ctx.overworld:afterBattle(result, battle)
end
end
runner:resume()
end
+4
View File
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
end
self.co = coroutine.create(function()
self:exec(script, ctx)
-- Commands can defer a game action until the script's own dialogue is
-- done. start_battle uses this for win-path evolutions, which must not
-- be covered by post-battle trainer text.
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
if ctx.onDone then ctx.onDone() end
if Runtime.wants("script.ended") then
Runtime.emit("script.ended", { ctx = ctx, completed = true })
+1
View File
@@ -2093,6 +2093,7 @@ function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
require("src.world.PikachuFollower").onBillExitedMachine(Game, self)
local function done()
Game.save.flags.EVENT_MET_BILL = true
Game.save.flags.EVENT_MET_BILL_2 = true
+66
View File
@@ -189,6 +189,9 @@ function PikachuFollower.current(ow)
end
function PikachuFollower.onMapEntered(game, ow, opts)
-- Bill's House owns a short scripted scene that deliberately keeps
-- Pikachu off the normal trailing loop. A new map instance ends it.
ow.pikachuBillsScene = nil
remove(ow)
if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive:
@@ -393,6 +396,7 @@ end
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
if ow.pikachuBillsScene then return end
local npc = findFollower(ow)
if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
@@ -741,6 +745,68 @@ function PikachuFollower.picLift(emote)
return 0
end
-- Bill's House has three map-scripted Yellow companion beats
-- (BillsHouseScript0/2/5): Pikachu walks over to investigate Bill, waits at
-- the cell separator, then reacts when Bill reappears. Keep it at the
-- machine until this map instance is discarded, just like the cartridge's
-- disabled following state.
local function billsHouseEmotion(game, ow, npc, bubble)
local Sprites = require("src.pokemon.Sprites")
ow.emote = {
npc = npc, frames = 50, bubble = bubbleIndex(game, bubble) or false,
pikaPic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" }),
}
end
local function movePikachu(ow, npc, steps, onDone)
npc.goalX, npc.goalY = nil, nil
idleReset(npc)
local function nextStep(i)
local step = steps[i]
if not step then
if onDone then onDone() end
return
end
ow:scriptMove(npc, step[1], step[2], function() nextStep(i + 1) end)
end
nextStep(1)
end
function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return
end
if game.save.flags.EVENT_MET_BILL_2 then return end
local npc = findFollower(ow)
if not npc then return end
ow.pikachuBillsScene = true
movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function()
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end)
end
function PikachuFollower.onBillEnteredMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
if not npc then return end
local steps = ow.player.facing == "down"
and { { "up", 3 } }
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
movePikachu(ow, npc, steps, function()
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end)
end
function PikachuFollower.onBillExitedMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
if not npc then return end
idleReset(npc)
npc.facing = "left"
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
end
-- ---------------------------------------------------------------------
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
-- engine/events/pokecenter.asm once the heal is accepted): the companion
+1
View File
@@ -121,6 +121,7 @@ local DEBT = {
["event:pokemon.evolved"] = "M7",
["event:pokemon.level_up"] = "M7",
["event:pokemon.move_learned"] = "M7",
["event:save.loaded"] = "M11",
["event:save.loading"] = "M11",
["event:save.writing"] = "M11",
["event:trade.completed"] = "M12",
+84
View File
@@ -0,0 +1,84 @@
-- A trainer script's post-battle text must finish before a level evolution.
-- Its start_battle row resumes the script after OverworldState:afterBattle,
-- so this covers the same handoff used by trainer and Rocket encounters.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local S = require("tests.harness").suite("parity trainer evolution order")
local check = S.check
local Game = require("src.core.Game")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local ScriptRunner = require("src.script.ScriptRunner")
local OverworldState = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
require("src.render.Font").load(Data)
Game.data = Data
Game.save = SaveData.newGame()
Game.stack = StateStack; StateStack:init()
Game.renderer = { worldViewSize = function() return 160, 144 end }
local caterpie = Pokemon.new(Data, "CATERPIE", 7)
Game.save.party = { caterpie }
local fakeBattle
local originalBattleState = package.loaded["src.battle.BattleState"]
package.loaded["src.battle.BattleState"] = {
newTrainer = function()
fakeBattle = { leveledUp = { [caterpie] = true } }
return fakeBattle
end,
}
local overworld = setmetatable({}, { __index = OverworldState })
overworld:enter("ROUTE_1", 5, 5, "down")
local runner = ScriptRunner.new(Game, overworld)
runner:run({
{ "start_battle", "trainer", "OPP_YOUNGSTER", 1 },
{ "show_text", "THE TRAINER TALKS AFTER THE FIGHT" },
{ "show_text", "THE TRAINER HAS MORE TO SAY" },
})
check(fakeBattle ~= nil, "trainer battle starts and yields the script")
Game.stack:pop()
fakeBattle.onFinish("win")
package.loaded["src.battle.BattleState"] = originalBattleState
local function stateHas(state, text)
for _, page in ipairs(state.pages or {}) do
for _, line in ipairs(page) do
if type(line) == "string" and line:find(text, 1, true) then return true end
end
end
return false
end
local afterText = Game.stack:top()
check(stateHas(afterText, "THE TRAINER TALKS"),
"the trainer's after-battle text is shown first")
check(#Game.stack.states == 1,
"the evolution screen waits until trainer after-text closes")
Game.stack:pop()
afterText.onDone()
local moreText = Game.stack:top()
check(stateHas(moreText, "MORE TO SAY"),
"all trainer after-text finishes before evolution")
check(#Game.stack.states == 1,
"the evolution still waits for the last trainer text")
Game.stack:pop()
moreText.onDone()
local evolutionText = Game.stack:top()
check(stateHas(evolutionText, "evolving"),
"the level evolution starts after trainer after-text closes")
S.finish()
+89
View File
@@ -0,0 +1,89 @@
-- Yellow starts Bill's House with Pikachu's confused reaction. The map
-- script owns that one-shot, while PikachuFollower owns the movement.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("parity Yellow Bill's Pikachu")
local check = S.check
local entered = 0
local originalFollower = package.loaded["src.world.PikachuFollower"]
package.loaded["src.world.PikachuFollower"] = {
onBillsHouseEnter = function()
entered = entered + 1
end,
}
local story = dofile("data/scripts/story.lua")
local game = { save = { flags = {} } }
story.BILLS_HOUSE.onEnter(game, {})
check(entered == 1,
"entering Bill's House before meeting Bill starts Pikachu's reaction")
entered = 0
game.save.flags.EVENT_MET_BILL_2 = true
story.BILLS_HOUSE.onEnter(game, {})
check(entered == 0,
"Pikachu's Bill reaction does not replay after Bill is met")
package.loaded["src.world.PikachuFollower"] = originalFollower
local GameVersion = require("src.core.GameVersion")
local PikachuFollower = require("src.world.PikachuFollower")
GameVersion.set("yellow")
local npc = {
pikachuFollower = true, cellX = 3, cellY = 8, px = 48, py = 128,
facing = "up",
}
local moves = {}
local yellowGame = {
save = { flags = {} },
data = {
pokemon = { PIKACHU = { spriteFront = "pikachu.png" } },
field = { emotionBubbles = {
bubbles = {
{ name = "QUESTION_BUBBLE" }, { name = "EXCLAMATION_BUBBLE" },
},
} },
},
}
local ow = {
map = { id = "BILLS_HOUSE" }, npcs = { npc }, entities = { npc },
player = { cellX = 3, cellY = 7 },
scriptMove = function(_, entity, dir, tiles, onDone)
moves[#moves + 1] = { entity = entity, dir = dir, tiles = tiles,
onDone = onDone }
end,
}
PikachuFollower.onBillsHouseEnter(yellowGame, ow)
check(ow.pikachuBillsScene and #moves == 1
and moves[1].entity == npc and moves[1].dir == "right"
and moves[1].tiles == 3,
"Bill's House entry parks Pikachu and walks it to Bill")
moves[1].onDone()
check(#moves == 2 and moves[2].dir == "up" and moves[2].tiles == 1,
"Pikachu finishes its cartridge entry route beside Bill")
moves[2].onDone()
check(ow.emote and ow.emote.bubble == 1,
"Pikachu shows its confused reaction after reaching Bill")
ow.player.facing = "down"
PikachuFollower.onBillEnteredMachine(yellowGame, ow)
check(#moves == 3 and moves[3].dir == "up" and moves[3].tiles == 3,
"Pikachu walks to the cell separator after Bill enters it")
moves[3].onDone()
check(ow.emote and ow.emote.bubble == 1,
"Pikachu wonders at the cell separator")
npc.goalX, npc.goalY = 9, 9
PikachuFollower.update(yellowGame, ow)
check(npc.goalX == 9 and npc.goalY == 9,
"Pikachu stays parked in Bill's House during the scene")
PikachuFollower.onBillExitedMachine(yellowGame, ow)
check(ow.emote and ow.emote.bubble == 2 and npc.facing == "left",
"Pikachu reacts when Bill comes back out")
GameVersion.set("red")
S.finish()
+28
View File
@@ -431,6 +431,34 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- #476: on Android a high-DPI 1560x720 capture can leave the editor with
-- only a compact logical viewport. The Items picker must not hand a
-- negative list height to love.graphics.setScissor in that layout.
local tmpPath = os.tmpname() .. "-items-compact-save.lua"
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(SaveData.newGame()))
f:close()
local oldDimensions = love.graphics.getDimensions
local oldScissor = love.graphics.setScissor
love.graphics.getDimensions = function() return 520, 240 end
love.graphics.setScissor = function(_, _, width, height)
if width and (width < 0 or height < 0) then
error("Can't set scissor with negative width and/or height.")
end
end
App.load(tmpPath, { version = "red" })
App.getState().tab = "items"
local ok, err = pcall(App.draw)
check(ok, "the Items tab draws in a compact Android viewport: " .. tostring(err))
love.graphics.getDimensions = oldDimensions
love.graphics.setScissor = oldScissor
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- Whole-editor smoke test: every tab has to survive a real headless draw,
-- which is what catches a layout that divides by a nil font metric or
+2
View File
@@ -3278,6 +3278,8 @@ runSuites(orderedGlob("tests/parity_*.lua", {
"tests/parity_static.lua", "tests/parity_trashcans.lua",
"tests/parity_hof.lua", "tests/parity_trade_gift.lua",
"tests/parity_yellow_trades.lua",
"tests/parity_yellow_bills_pikachu.lua",
"tests/parity_trainer_evolution_order.lua",
"tests/parity_intro.lua", "tests/parity_tilt.lua",
"tests/parity_gbcfx.lua",
}))
+12 -3
View File
@@ -381,13 +381,22 @@ end
-- Clip drawing to a rect (list bodies). No-ops under the headless stub.
function Kit.pushClip(x, y, w, h)
if G and G.setScissor then
G.setScissor(math.floor(x), math.floor(y), math.ceil(w), math.ceil(h))
-- A compact mobile viewport can leave a panel with no room for a list.
-- LÖVE rejects negative scissor dimensions, so treat an exhausted clip
-- region as empty instead of passing invalid geometry through to it.
Kit._clipActive = G and G.setScissor ~= nil
if Kit._clipActive then
if w <= 0 or h <= 0 then
G.setScissor(0, 0, 0, 0)
else
G.setScissor(math.floor(x), math.floor(y), math.ceil(w), math.ceil(h))
end
end
end
function Kit.popClip()
if G and G.setScissor then G.setScissor() end
if Kit._clipActive and G and G.setScissor then G.setScissor() end
Kit._clipActive = false
end
return Kit