fix merge conflicts with dev

This commit is contained in:
1jamie
2026-08-13 07:39:11 -05:00
60 changed files with 1900 additions and 251 deletions
+2 -2
View File
@@ -94,8 +94,8 @@ jobs:
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: gen1recomp-ios-ipa name: gen1recomp++-ios-ipa
path: dist/ios/gen1recomp.ipa path: dist/ios/gen1recomp++.ipa
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
- name: clean up signing keychain - name: clean up signing keychain
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: | run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')" artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')"
[ -n "$artifact_id" ] || exit 0 [ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}" head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')" pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
@@ -44,7 +44,7 @@ jobs:
uses: thollander/actions-comment-pull-request@v3 uses: thollander/actions-comment-pull-request@v3
with: with:
message: | message: |
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }}) [gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }}) **Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}` **Build Time**: `${{ steps.build-info.outputs.time }}`
+12 -11
View File
@@ -444,9 +444,9 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa" ipa="dist/ios/gen1recomp++.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; } [ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa" cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip" swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; } [ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
@@ -580,7 +580,7 @@ jobs:
"dist/release/gen1recomp-${v}-linux.zip" "dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage" "dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa" "dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip" "dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-xbox-uwp.zip" "dist/release/gen1recomp-${v}-xbox-uwp.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" "dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
@@ -602,14 +602,15 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
v="${{ needs.version.outputs.version }}" v="${{ needs.version.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa" ipa="dist/release/gen1recomp++-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json" app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; } [ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; } [ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
date="$(date -u +"%Y-%m-%d")" date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')" size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa" download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
bundle_id="com.theboisclub.gen1recompplusplus"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke" localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)" release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then if [ -n "$release_notes" ]; then
@@ -623,15 +624,15 @@ jobs:
--argjson size "$size" \ --argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')" '{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
if jq -e --arg version "$v" \ if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \ 'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
"$app_repo" >/dev/null; then "$app_repo" >/dev/null; then
jq --arg version "$v" --argjson entry "$entry" \ jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \ '(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
"$app_repo" > "$app_repo.tmp" "$app_repo" > "$app_repo.tmp"
else else
jq --argjson entry "$entry" \ jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \ '(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
"$app_repo" > "$app_repo.tmp" "$app_repo" > "$app_repo.tmp"
fi fi
mv "$app_repo.tmp" "$app_repo" mv "$app_repo.tmp" "$app_repo"
+1 -1
View File
@@ -242,7 +242,7 @@ in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
## iOS ## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To (Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md). [docs/ios-install.md](docs/ios-install.md).
+2 -2
View File
@@ -1,6 +1,6 @@
# Sideload the iOS build with AltStore # Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on Every GitHub Release ships an IPA (`gen1recomp++-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore 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 re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode. Xcode.
@@ -23,7 +23,7 @@ Stuck? Start here:
## 2. Install the game ## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from 1. Download `gen1recomp++-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). [Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same 2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer). WiFi, or keep the phone plugged into the computer).
+12 -1
View File
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
## Version.lua fields ## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the `src/core/Version.lua` carries four fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched): untouched):
@@ -37,6 +37,11 @@ untouched):
as a valid payload to chainload). as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable - `shell` - the native-shell contract this build's fused executable
implements. implements.
- `payloadHost` - the native host family an in-place payload targets. Ordinary
LÖVE packages use `"love"`. A specialized native package uses a distinct,
stable identifier and accepts only payloads carrying that same identifier.
A missing field defaults to `"love"`, preserving compatibility with payloads
released before this field existed.
- `minShell` - the lowest shell contract required to *run* this payload. - `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped Bump `minShell` only when a payload needs something the currently-shipped
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
installer instead. Do not bump `minShell` for an ordinary Lua/data release; installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for. that is exactly the case the updater exists to avoid a reinstall for.
Change `payloadHost` only when the packaged Lua depends on a different native
host family. This is separate from `minShell`: the host name answers *which*
native integration the payload targets, while the shell number answers *which
revision* of that integration it requires. A mismatched-host payload is never
mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets ## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives Each tagged release `vX.Y.Z` carries the existing per-platform archives
+29 -5
View File
@@ -14,6 +14,7 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions") local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay") local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks") local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
@@ -197,14 +198,19 @@ end
local touchEditorHost local touchEditorHost
local closeTouchControlsEditor -- forward declaration local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor() -- `version` is the launcher tab the gear was opened on, and it decides which
-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist).
local function openTouchControlsEditor(version)
touchEditorHost = Importer touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff() Importer:prepareOverlayHandoff()
end end
Importer = nil Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor") TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) TouchEditor.load({
version = version,
onClose = function() closeTouchControlsEditor() end,
})
end end
function closeTouchControlsEditor() function closeTouchControlsEditor()
@@ -425,6 +431,7 @@ function love.load(args)
end end
function love.update(dt) function love.update(dt)
HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false) SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync() NxDisplay.sync()
@@ -472,11 +479,27 @@ function love.update(dt)
end end
function love.draw() function love.draw()
if editorMode then return EditorApp.draw() end if editorMode then
if TouchEditor then return TouchEditor.draw() end HostDisplay.beginFrame("editor", EditorApp)
if Importer then return Importer:draw() end local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp)
return result
end
if TouchEditor then
HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Importer then
HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer)
return result
end
if not Game then return end if not Game then return end
HostDisplay.beginFrame("game", Game)
Game:draw() Game:draw()
-- frame capture requested by a driver -- frame capture requested by a driver
if Game.capturePath then if Game.capturePath then
@@ -491,6 +514,7 @@ function love.draw()
end end
end) end)
end end
HostDisplay.endFrame("game", Game)
end end
function love.keypressed(key, scancode, isrepeat) function love.keypressed(key, scancode, isrepeat)
@@ -143,16 +143,36 @@ public class GameActivity extends SDLActivity {
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst); private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
/**
* Native libraries required by an optional Android host extension.
*
* Subclasses supplied by another product flavor may override this method.
* The libraries are loaded after LÖVE's dependencies and before liblove;
* liblove must remain last because SDL treats the final entry as the main
* shared object.
*/
protected String[] getHostLibraries() {
return new String[0];
}
@Override @Override
protected String[] getLibraries() { protected String[] getLibraries() {
return new String[] { String[] hostLibraries = getHostLibraries();
"c++_shared", String[] libraries = new String[hostLibraries.length + 4];
"mpg123", libraries[0] = "c++_shared";
"openal", libraries[1] = "mpg123";
"love", libraries[2] = "openal";
}; System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);
libraries[libraries.length - 1] = "love";
return libraries;
} }
protected void onHostCreateBeforeSDL(Bundle savedInstanceState) {}
protected void onHostCreateAfterSDL(Bundle savedInstanceState) {}
protected void onHostResume() {}
protected void onHostPause() {}
protected void onHostDestroy() {}
@Override @Override
protected String getMainSharedObject() { protected String getMainSharedObject() {
String[] libs = getLibraries(); String[] libs = getLibraries();
@@ -192,7 +212,9 @@ public class GameActivity extends SDLActivity {
intent.setData(null); intent.setData(null);
} }
onHostCreateBeforeSDL(savedInstanceState);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
onHostCreateAfterSDL(savedInstanceState);
if (savedInstanceState != null) { if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to // Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for. // a recreated activity still lands under the basename it asked for.
@@ -341,6 +363,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
onHostDestroy();
super.onDestroy(); super.onDestroy();
} }
@@ -351,12 +374,14 @@ public class GameActivity extends SDLActivity {
vibrator.cancel(); vibrator.cancel();
} }
teardownSecondaryDisplay(); teardownSecondaryDisplay();
onHostPause();
super.onPause(); super.onPause();
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
onHostResume();
setupSecondaryDisplay(); setupSecondaryDisplay();
} }
+9 -9
View File
@@ -7,7 +7,7 @@ an iOS app with LÖVE 12.0.
The app uses the public iOS Documents directory as its LÖVE save directory. The app uses the public iOS Documents directory as its LÖVE save directory.
There is no `pokemon-love2d` subdirectory and the app does not create a There is no `pokemon-love2d` subdirectory and the app does not create a
README file there. When browsing `On My iPhone > gen1recomp` in Files, the README file there. When browsing `On My iPhone > gen1recomp++` in Files, the
directory contains the app's runtime data directly, including: directory contains the app's runtime data directly, including:
- installed mods and downloaded ROMs - installed mods and downloaded ROMs
@@ -46,7 +46,7 @@ scripts/build_ios.sh --device --release --install
Device builds require a paired, unlocked device and a valid Apple signing Device builds require a paired, unlocked device and a valid Apple signing
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
signing cannot select the intended account. Add `--ipa` to create signing cannot select the intended account. Add `--ipa` to create
`dist/ios/gen1recomp.ipa`. `dist/ios/gen1recomp++.ipa`.
The script verifies the final app before packaging it: The script verifies the final app before packaging it:
@@ -76,10 +76,10 @@ option.
Simulator and device app bundles are copied to: Simulator and device app bundles are copied to:
```text ```text
dist/ios/Debug-iphonesimulator/gen1recomp.app dist/ios/Debug-iphonesimulator/gen1recomp++.app
dist/ios/Release-iphonesimulator/gen1recomp.app dist/ios/Release-iphonesimulator/gen1recomp++.app
dist/ios/Debug-iphoneos/gen1recomp.app dist/ios/Debug-iphoneos/gen1recomp++.app
dist/ios/Release-iphoneos/gen1recomp.app dist/ios/Release-iphoneos/gen1recomp++.app
``` ```
The intermediate Xcode products are under `mobile/ios/build/`. Both locations The intermediate Xcode products are under `mobile/ios/build/`. Both locations
@@ -94,9 +94,9 @@ saves; those are created at runtime in Documents.
| Field | Default | | Field | Default |
| --- | --- | | --- | --- |
| Display name | `gen1recomp` | | Display name | `gen1recomp++` |
| Product name | `gen1recomp` | | Product name | `gen1recomp++` |
| Bundle identifier | `com.theboisclub.gen1recomp` | | Bundle identifier | `com.theboisclub.gen1recompplusplus` |
| Save directory | Public `Documents` root | | Save directory | Public `Documents` root |
| Orientation | Portrait | | Orientation | Portrait |
+18 -4
View File
@@ -1,17 +1,31 @@
{ {
"name": "gen1recomp App Repo", "name": "gen1recomp++ App Repo",
"identifier": "com.theboisclub.gen1recomp.repo", "identifier": "com.theboisclub.gen1recompplusplus.repo",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png", "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
"apps": [ "apps": [
{ {
"name": "gen1recomp", "name": "gen1recomp++",
"bundleIdentifier": "com.theboisclub.gen1recomp", "bundleIdentifier": "com.theboisclub.gen1recompplusplus",
"developerName": "bryanthaboi", "developerName": "bryanthaboi",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png", "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke", "localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.81",
"date": "2026-08-13",
"size": 9886854,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
},
{
"version": "0.1.80",
"date": "2026-08-12",
"size": 10964568,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.80/gen1recomp-0.1.80-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1100 Allow for control edit and vibration toggle on gold\n- #1107 [Gold] When playing without a save slot in the launcher, no save will be created at all when saving ingame\n- #1112 [Gold] In the shop when buying items and pressing \"down\", it should not default to x99\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1126 [Gold] Jumping off ledge animation missing\n- #1130 A separate program for Gen2 (Gen2 Recomp)\n- #1145 Gen1recomp internal update - Mod didn't filter for Gen1 and Gen2 when click on individual tab\n- #1150 Control in Gold freeze when change\n- #1151 [Gold] Following up of #1127\n- #1152 [Gold] You cannot try to run away from wild battle after one of your Pokemon gets fainted.\n- #1161 window/borderless & resolution selection for gen2\n- #1162 [gold] learning TM/HM doesn't show if possible or not\n- #1164 Goldenrod Tower Softlock\n- #1165 [Gold] NPC trading behavior and ledge jumping missing still there despite fix\n- #1166 [Gold] nickname screen grey\n- #1167 [Gold] items in ilex forest missing\n- #1168 [Gold] multi-hit attacks only hits twice\n- #1169 [Gold] the sales lady in goldenrod underground\n- #1172 [Gold] {last item u found/got} used ROCK SMASH\n- #1173 [Gold] ROCK SMASH rocks keep slide left\n- #1177 [Gold] Touch controls position\n- #1178 No Vibration Setting for Gold\n- #1180 [Gold] something feels off with the DV calculation\n- #1184 [Gold] Kurt is in the wrong spot, blocking you from progressing\n- #1185 [Gold] Text messages is in the wrong order when a Pokemon learns a new skill after a trainer fight\n- #1188 GoldenRod tower softlock\n- #1190 POKEMON GOLD GEN1RECOMP++\n- #1192 cant import gold on RGXX build\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @thibautbus"
},
{ {
"version": "0.1.79", "version": "0.1.79",
"date": "2026-08-12", "date": "2026-08-12",
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>gen1recomp</string> <string>gen1recomp++</string>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
<dict> <dict>
@@ -32,7 +32,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>gen1recomp</string> <string>gen1recomp++</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+52 -2
View File
@@ -29,6 +29,9 @@ WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj" PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm" APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp" FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
IOS_MM = LOVE_SRC / "src" / "common" / "ios.mm"
IOS_H = LOVE_SRC / "src" / "common" / "ios.h"
SYSTEM_CPP = LOVE_SRC / "src" / "modules" / "system" / "System.cpp"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements" ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m") NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
@@ -120,7 +123,7 @@ int w_pickFileKinds(lua_State *L)
typedef const char *(*GRUTF8)(id, SEL); typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(kinds, const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
sel_registerName("UTF8String")); sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\0') if (bytes == nullptr || bytes[0] == '\\0')
{ {
lua_pushnil(L); lua_pushnil(L);
return 1; return 1;
@@ -358,6 +361,50 @@ def patch_public_documents():
print("patch_love_src: iOS save directory routed to Documents root") print("patch_love_src: iOS save directory routed to Documents root")
def patch_ios_haptics():
text = pristine(IOS_MM, ("UIImpactFeedbackGenerator",))
original = """void vibrate()
{
@autoreleasepool
{
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}
}
"""
replacement = """void vibrate(double seconds)
{
@autoreleasepool
{
UIImpactFeedbackStyle style = UIImpactFeedbackStyleLight;
if (seconds >= 0.035)
style = UIImpactFeedbackStyleHeavy;
else if (seconds >= 0.02)
style = UIImpactFeedbackStyleMedium;
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc]
initWithStyle:style];
[generator prepare];
[generator impactOccurred];
}
}
"""
if original not in text:
fail(f"iOS haptic anchor not found in {IOS_MM}")
IOS_MM.write_text(text.replace(original, replacement, 1))
header = pristine(IOS_H, ("void vibrate(double seconds);",))
header_original = "void vibrate();"
if header_original not in header:
fail(f"iOS haptic declaration not found in {IOS_H}")
IOS_H.write_text(header.replace(header_original, "void vibrate(double seconds);", 1))
system = pristine(SYSTEM_CPP, ("love::ios::vibrate(seconds)",))
system_original = "love::ios::vibrate();"
if system_original not in system:
fail(f"iOS haptic call site not found in {SYSTEM_CPP}")
SYSTEM_CPP.write_text(system.replace(system_original, "love::ios::vibrate(seconds);", 1))
print("patch_love_src: iOS haptics use Taptic Engine impact presets")
def patch_pbxproj(): def patch_pbxproj():
text = pristine(PBXPROJ) text = pristine(PBXPROJ)
@@ -408,7 +455,9 @@ def patch_pbxproj():
fail(f"build configuration {config_id} not found") fail(f"build configuration {config_id} not found")
settings = ( settings = (
"\t\t\t\tSWIFT_VERSION = 5.0;\n" "\t\t\t\tSWIFT_VERSION = 5.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n" "\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;\n"
"\t\t\t\tPRODUCT_NAME = \"gen1recomp++\";\n"
"\t\t\t\tEXECUTABLE_NAME = \"gen1recomp++\";\n"
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n' '\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
) )
text = text[: m.end()] + settings + text[m.end():] text = text[: m.end()] + settings + text[m.end():]
@@ -422,6 +471,7 @@ def main():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first") fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files() copy_native_files()
patch_public_documents() patch_public_documents()
patch_ios_haptics()
patch_wrap_system() patch_wrap_system()
patch_pbxproj() patch_pbxproj()
+1 -1
View File
@@ -13,7 +13,7 @@
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage) # dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under # dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/) # mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays # dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/) # under mobile/ios/build/Build/Products/)
set -euo pipefail set -euo pipefail
+43 -25
View File
@@ -20,9 +20,9 @@
# - macOS + Xcode (xcodebuild) # - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md) # - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# #
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy) # Output: dist/ios/<Config>-<sdk>/gen1recomp++.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only) # dist/ios/gen1recomp++.ipa (device builds only)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app # mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp++.app
set -euo pipefail set -euo pipefail
@@ -39,8 +39,9 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
LOVE_FILE="$RESOURCES_DIR/game.love" LOVE_FILE="$RESOURCES_DIR/game.love"
LIBS_DIR="$XCODE_DIR/ios/libraries" LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="gen1recomp" APP_NAME="gen1recomp++"
DISPLAY_NAME="gen1recomp" DISPLAY_NAME="gen1recomp++"
PRODUCT_NAME="gen1recomp++"
# Bundle ID resolution, most specific wins: # Bundle ID resolution, most specific wins:
# 1. GEN1_BUNDLE_ID env var # 1. GEN1_BUNDLE_ID env var
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install # 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
@@ -50,7 +51,7 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default # capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app # lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved) # 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}" BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")" BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi fi
@@ -138,11 +139,7 @@ if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
fi fi
fi fi
if [ -z "$BUNDLE_ID" ]; then if [ -z "$BUNDLE_ID" ]; then
if $DEVICE; then BUNDLE_ID="com.theboisclub.gen1recompplusplus"
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
else
BUNDLE_ID="com.theboisclub.pokemonred"
fi
fi fi
# --------------------------------------------------------------- host checks # --------------------------------------------------------------- host checks
@@ -226,13 +223,26 @@ verify_documents_overlay() {
} }
apply_ios_icon() { apply_ios_icon() {
local source="$ROOT/assets/logo/gen1recomp_cover.png" local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset" local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source" [ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target" [ -d "$target" ] || fail "missing iOS app icon set: $target"
local icon="$BUILD_DIR/gen1recomp-ios-icon.png"
mkdir -p "$BUILD_DIR"
if command -v magick >/dev/null 2>&1; then
magick -size 1024x1024 xc:black \
\( "$source" -resize 900x900 \) -gravity center -composite \
-alpha off "$icon" || fail "could not create iOS app icon: $source"
else
local scaled="$BUILD_DIR/gen1recomp-logo.png"
sips -Z 900 "$source" --out "$scaled" >/dev/null \
|| fail "could not resize iOS app icon source: $source"
sips -p 1024 1024 --padColor 000000 "$scaled" --out "$icon" >/dev/null \
|| fail "could not center iOS app icon source: $source"
fi
local entry name size local entry name size
while IFS=: read -r name size; do while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null sips -z "$size" "$size" "$icon" --out "$target/$name" >/dev/null
done <<'EOF' done <<'EOF'
icon-1024pt@1x.png:1024 icon-1024pt@1x.png:1024
icon-29pt@1x.png:29 icon-29pt@1x.png:29
@@ -647,6 +657,7 @@ run_xcodebuild() {
CURRENT_PROJECT_VERSION="$project_version" CURRENT_PROJECT_VERSION="$project_version"
INFOPLIST_KEY_UIFileSharingEnabled=YES INFOPLIST_KEY_UIFileSharingEnabled=YES
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
IPHONEOS_DEPLOYMENT_TARGET=15.0
ONLY_ACTIVE_ARCH=NO ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
) )
@@ -666,6 +677,9 @@ run_xcodebuild() {
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY") args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
fi fi
if [ "${GEN1_DISABLE_HEALTHKIT:-0}" = "1" ]; then
args+=(CODE_SIGN_ENTITLEMENTS=)
fi
fi fi
if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then
@@ -708,18 +722,22 @@ run_xcodebuild() {
fi fi
local products="$BUILD_DIR/Build/Products/${config}-${sdk}" local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
local app="$products/$APP_NAME.app" local app=""
if [ ! -d "$app" ]; then local candidate
# PRODUCT_NAME override can still leave love.app on older projects for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
if [ -d "$products/love.app" ]; then if [ -d "$candidate" ]; then
app="$products/$APP_NAME.app" app="$candidate"
mv "$products/love.app" "$app" break
warn "renamed love.app to $APP_NAME.app"
else
warn "xcodebuild finished but no .app under $products"
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
return 0
fi fi
done
if [ -z "$app" ]; then
warn "xcodebuild finished but no .app under $products"
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
return 0
fi
if [ "$app" != "$products/$APP_NAME.app" ]; then
mv "$app" "$products/$APP_NAME.app"
app="$products/$APP_NAME.app"
fi fi
verify_documents_configuration "$app" verify_documents_configuration "$app"
@@ -760,7 +778,7 @@ run_xcodebuild() {
fi fi
} }
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools. # Pack Payload/<app>.app into dist/ios/gen1recomp++.ipa for release / sideload tools.
package_ipa() { package_ipa() {
local app="$1" local app="$1"
local ipa="$DIST/$APP_NAME.ipa" local ipa="$DIST/$APP_NAME.ipa"
+18 -18
View File
@@ -1619,26 +1619,26 @@ function Battle:useMove(attacker, defender, moveId)
return return
end end
-- Damage that skips the formula entirely (constantdamage). -- Damage that skips the formula entirely. The move's own power goes with
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random, def) -- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
def.power)
if fixed then if fixed then
-- StaticDamage's effect script runs resettypematchup after constantdamage -- The constant-damage effect list carries `resettypematchup` instead of
-- (data/moves/effects.asm): immunities still miss (Sonic Boom vs Ghost). -- `stab`, and that command misses the move outright when the matchup byte
-- Flat damage is otherwise unscaled — no STAB / effectiveness multiply. -- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
if def.effect == "EFFECT_STATIC_DAMAGE" then -- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defTypes = (self:speciesDef(defender) or {}).types or defender.types local defenderTypes = (self:speciesDef(defender) or {}).types
local matchups = self.data.type_chart and self.data.type_chart.matchups or defender.types
local mult = Damage.typeMultiplier(def.type, defTypes, matchups) local matchups = self.data.type_chart and self.data.type_chart.matchups
if mult == 0 then if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed() self:markMissed()
self:emit({ kind = "message", self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." }) text = "It doesn't affect " .. self:monName(defender) .. "..." })
return return
end
end end
self:dealDamage(attacker, defender, fixed, { self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
move = def, moveId = moveId, effectiveness = 10,
})
return return
end end
+12 -8
View File
@@ -179,10 +179,9 @@ end
-- --------------------------------------------------------------- fixed damage -- --------------------------------------------------------------- fixed damage
-- BattleCommand_ConstantDamage / LevelDamage / SuperFang / Psywave / -- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
-- StaticDamage (Sonic Boom, Dragon Rage): skip the damage formula entirely. -- one command for the whole SuperFang / Psywave / StaticDamage list.
-- `move` is optional; EFFECT_STATIC_DAMAGE reads move.power (20 / 40). function Effects.fixedDamage(effect, attacker, defender, random, power)
function Effects.fixedDamage(effect, attacker, defender, random, move)
if effect == "EFFECT_LEVEL_DAMAGE" then if effect == "EFFECT_LEVEL_DAMAGE" then
return math.max(1, attacker.level or 1) return math.max(1, attacker.level or 1)
end end
@@ -190,12 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random, move)
return math.max(1, math.floor((defender.hp or 1) / 2)) return math.max(1, math.floor((defender.hp or 1) / 2))
end end
if effect == "EFFECT_PSYWAVE" then if effect == "EFFECT_PSYWAVE" then
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here. -- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2)) -- top of the range is that ceiling minus one (effect_commands.asm:3163).
return math.max(1, (random and random(ceiling) or 0) + 1) local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
end end
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
-- (20 and 40) IS the damage, never a formula input
-- (effect_commands.asm:3157-3161).
if effect == "EFFECT_STATIC_DAMAGE" then if effect == "EFFECT_STATIC_DAMAGE" then
return math.max(1, (move and move.power) or 1) return math.max(1, math.floor(power or 0))
end end
return nil return nil
end end
+12
View File
@@ -313,6 +313,16 @@ local function equalData(a, b)
return okA and okB and encodedA == encodedB return okA and okB and encodedA == encodedB
end end
local function normalizeVerificationMetadata(actual, expected)
if type(actual) == "table" and type(actual.identity) == "table"
and type(expected) == "table" and type(expected.identity) == "table" then
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
-- state. A fresh recapture stamps the running engine, so normalize only
-- that metadata before differential verification.
actual.identity.engineVersion = expected.identity.engineVersion
end
end
local function firstDifference(a, b, path) local function firstDifference(a, b, path)
path = path or "$" path = path or "$"
if type(a) ~= type(b) then return path .. " (type)" end if type(a) ~= type(b) then return path .. " (type)" end
@@ -394,6 +404,7 @@ function Checkpoint.restore(game, checkpoint)
if ok then if ok then
local restored, verifyCode = Checkpoint.capture(game) local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then if restored and equalData(restored, validated) then
emitRestored(game, validated) emitRestored(game, validated)
return true return true
@@ -474,6 +485,7 @@ function Checkpoint.resume(game, checkpoint)
if ok then if ok then
local restored, verifyCode = Checkpoint.capture(game) local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then if restored and equalData(restored, validated) then
emitRestored(game, validated) emitRestored(game, validated)
return true return true
+41
View File
@@ -0,0 +1,41 @@
-- Optional native-host display lifecycle.
--
-- The engine always owns simulation and drawing. A packaged host may install
-- one backend to observe per-frame updates and prepare/finalize a render target
-- around an otherwise unchanged draw. With no backend installed every method
-- is a no-op, which is the normal desktop and mobile path.
--
-- Backend methods are optional:
-- backend:update(dt)
-- backend:beginFrame(kind, subject)
-- backend:endFrame(kind, subject)
--
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
-- the object whose existing draw method runs between beginFrame and endFrame.
local HostDisplay = {}
local backend
function HostDisplay.setBackend(value)
if value ~= nil and type(value) ~= "table" then
error("host display backend must be a table or nil", 2)
end
backend = value
end
function HostDisplay.update(dt)
local fn = backend and backend.update
if fn then return fn(backend, dt) end
end
function HostDisplay.beginFrame(kind, subject)
local fn = backend and backend.beginFrame
if fn then return fn(backend, kind, subject) end
end
function HostDisplay.endFrame(kind, subject)
local fn = backend and backend.endFrame
if fn then return fn(backend, kind, subject) end
end
return HostDisplay
+31 -8
View File
@@ -335,11 +335,11 @@ function SaveData.defaultOptions()
-- TouchControls.normalizeConfig folds it into both orientations on load. -- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true }, touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806): -- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate -- off | light | normal | strong, mapped to a love.system.vibrate duration
-- duration in src/core/TouchControls.lua. LIGHT by default, like the -- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
-- overlay itself defaulting on, so an options.lua predating this key -- predating this key gets the tick without going looking for the row.
-- gets the tick without going looking for the row. Inert wherever the -- Inert wherever the overlay never appears (desktop) or LOVE has no
-- overlay never appears (desktop) or LOVE has no vibrator. -- vibrator.
haptics = "light", haptics = "light",
-- Shared UI/mod timestamp presentation. DEVICE follows the process time -- Shared UI/mod timestamp presentation. DEVICE follows the process time
-- locale where the platform exposes one; otherwise DateTime falls back to -- locale where the platform exposes one; otherwise DateTime falls back to
@@ -818,9 +818,19 @@ end
function SaveData.slotSummary(save) function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil local name = save.player and save.player.name or nil
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
local dexCount = 0 local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do if gen2 then
dexCount = dexCount + 1 for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
if has then dexCount = dexCount + 1 end
end
else
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
end end
-- playTime is a plain seconds count in a Gen 1 save but a -- playTime is a plain seconds count in a Gen 1 save but a
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching -- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
@@ -838,8 +848,21 @@ function SaveData.slotSummary(save)
end end
local timeText = ("%d:%02d"):format(math.floor(t / 3600), local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60) math.floor(t / 60) % 60)
local badges
if gen2 then
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
-- (engine/menus/intro_menu.asm:461-469).
badges = 0
local p = save.player or {}
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
for _, has in pairs(p.kantoBadges or {}) do
if has then badges = badges + 1 end
end
else
badges = Badges.count(nil, save)
end
return name, { return name, {
badges = Badges.count(nil, save), badges = badges,
timeText = timeText, timeText = timeText,
dexCount = dexCount, dexCount = dexCount,
} }
+9 -5
View File
@@ -105,17 +105,21 @@ end
-- would be dropped on every editor save. -- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a -- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long, -- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so -- while iOS maps each duration to a matching Taptic Engine impact.
-- there the three levels all read as simply on. TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
TouchControls.HAPTIC_DEFAULT = "light" TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 } local HAPTIC_SECONDS = {
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
}
local HAPTIC_LABELS = { local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY", off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
} }
function TouchControls.normalizeHaptics(level) function TouchControls.normalizeHaptics(level)
if level == "physical" then return "light" end
if level == "medium" then return "normal" end
if level == "heavy" then return "strong" end
if HAPTIC_SECONDS[level] then return level end if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT return TouchControls.HAPTIC_DEFAULT
end end
+3
View File
@@ -8,6 +8,9 @@ local Version = {
-- "-dev" placeholder; CI stamps the real X.Y.Z into -- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree. -- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements shell = 1, -- native-shell contract this build implements
payloadHost = "love", -- native host family for in-place Lua payloads.
-- A payload must name the same family; this prevents
-- mounting code packaged for a different native host.
minShell = 1, -- lowest shell contract that can RUN this payload. minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native -- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell -- binary (e.g. a LOVE version bump); an older shell
+5
View File
@@ -764,9 +764,14 @@ end
function Save.summary(save) function Save.summary(save)
if type(save) ~= "table" then return nil end if type(save) ~= "table" then return nil end
local badges = 0 local badges = 0
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
-- (engine/menus/intro_menu.asm:461-469).
for _, has in pairs(save.player and save.player.badges or {}) do for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end if has then badges = badges + 1 end
end end
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
if has then badges = badges + 1 end
end
local caught = 0 local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end if has then caught = caught + 1 end
+67 -56
View File
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are -- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render -- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data. -- module grew a dependency on live game data.
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
--
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
-- so `opts` is whichever table the gear is editing and the rows never have to
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
-- of them, so a phone player could turn the pad and the buzz off in Red and
-- had no way to reach either in Gold.
local function addTouchRows(rows, add, opts, hooks)
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
end
local function coreRows(opts, hooks) local function coreRows(opts, hooks)
local rows = {} local rows = {}
local function add(label, value, step) local function add(label, value, step)
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
end) end)
end end
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's addTouchRows(rows, add, opts, hooks)
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive -- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the -- (src/core/Input.lua:applyBindings layers options.bindings over the
@@ -462,7 +471,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold" local GEN2_KEY = "gold"
local function gen2Rows(opts) local function gen2Rows(opts, hooks)
local rows = {} local rows = {}
local function add(label, value, step) local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step } rows[#rows + 1] = { label = label, value = value, step = step }
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
end) end)
end end
addTouchRows(rows, add, opts, hooks)
return rows return rows
end end
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block opts[GEN2_KEY] = block
end end
sections = { sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block) }, { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
} }
else else
sections = { sections = {
+11 -1
View File
@@ -2652,11 +2652,14 @@ function RomImporter:_openSettings()
-- hook rather than reaching for main.lua's handler itself. Closing the -- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves) -- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to. -- and leaves no modal behind the editor to return to.
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {} local hooks = {}
if self.onEditTouchControls then if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function() hooks.editTouchControls = function()
self:_closeSettings() self:_closeSettings()
self.onEditTouchControls() self.onEditTouchControls(version)
end end
end end
-- The tab the gear was opened on decides the row set: Gold reads a -- The tab the gear was opened on decides the row set: Gold reads a
@@ -2741,6 +2744,13 @@ function RomImporter:keypressed(key)
end end
if self._modConfirm or self._modVersions or self._modReleaseNotes if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then or self._findDetails then
-- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this
-- inside the modal branch so text fields retain exclusive keyboard input.
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then if key == "escape" then
if self._findDetails then if self._findDetails then
self._findDetails = nil self._findDetails = nil
+4 -1
View File
@@ -266,7 +266,10 @@ function OakSpeech.new(game, onDone)
-- RedSprite: the walking sprite the pic shrinks into (frame 0 = -- RedSprite: the walking sprite the pic shrinks into (frame 0 =
-- standing, facing down) -- standing, facing down)
local playerSprites = (game.data.field and game.data.field.playerSprites) or {} local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED -- The fallback has to read the same guarded table: reaching for
-- game.data.sprites.SPRITE_RED after the `and` already found it nil threw.
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
self.walkSheet = tryImage(red and red.image) self.walkSheet = tryImage(red and red.image)
return self return self
end end
+2 -2
View File
@@ -501,7 +501,7 @@ local function buildRows(game)
return true return true
end }, end },
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT / -- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
-- MEDIUM / HEAVY, where the intensity is a vibration duration -- -- NORMAL / STRONG, where the intensity is a vibration duration --
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below, -- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
-- since the only thing that buzzes is a virtual button press. -- since the only thing that buzzes is a virtual button press.
{ id = "haptics", label = Strings("VIBRATION"), { id = "haptics", label = Strings("VIBRATION"),
@@ -515,7 +515,7 @@ local function buildRows(game)
o.haptics = TC.cycleHaptics(o.haptics, dir) o.haptics = TC.cycleHaptics(o.haptics, dir)
TC:applyOptions(o) TC:applyOptions(o)
-- sample the level being selected: stepping the row is the only way -- sample the level being selected: stepping the row is the only way
-- to compare LIGHT against HEAVY without leaving the menu -- to compare LIGHT against STRONG without leaving the menu
TC.buzz(o.haptics) TC.buzz(o.haptics)
return true return true
end }, end },
+54 -11
View File
@@ -106,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.") local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?") local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165). -- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
local TEXT_ASK_FORGET_MOVE = Strings.source( local TEXT_ASK_FORGET_MOVE = Strings.source(
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." "%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
@@ -548,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6
-- box's own bottom centre. -- box's own bottom centre.
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 } local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
-- carries both as the volatile `vanished` flag.
function BattleState.isVanished(mon)
local volatiles = mon and mon.volatile
return (volatiles and volatiles.vanished) and true or false
end
function BattleState:drawPic(mon, back) function BattleState:drawPic(mon, back)
-- During the intro slide the player-side pic belongs to presentSlide's -- During the intro slide the player-side pic belongs to presentSlide's
-- backpic overlay, not to the baked bands (see BattleAnimView). -- backpic overlay, not to the baked bands (see BattleAnimView).
@@ -571,6 +583,13 @@ function BattleState:drawPic(mon, back)
-- The box is empty either because the animation running right now has -- The box is empty either because the animation running right now has
-- cleared it, or because the last one ENDED with it cleared (picHidden). -- cleared it, or because the last one ENDED with it cleared (picHidden).
if (anim and anim.hidden) or self.picHidden[side] then return end if (anim and anim.hidden) or self.picHidden[side] then return end
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
local G = love.graphics local G = love.graphics
local w, h = image:getDimensions() local w, h = image:getDimensions()
local px, py local px, py
@@ -582,10 +601,12 @@ function BattleState:drawPic(mon, back)
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h) py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
boxTiles = BattleState.PLAYER_PIC_TILES boxTiles = BattleState.PLAYER_PIC_TILES
else else
-- Bottom-aligned and horizontally centred inside the 7x7 box. -- PadFrontpic pads a short pic and never a long one
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
local box = BattleState.ENEMY_PIC_TILES * 8 local box = BattleState.ENEMY_PIC_TILES * 8
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2) px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h) py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
boxTiles = BattleState.ENEMY_PIC_TILES boxTiles = BattleState.ENEMY_PIC_TILES
end end
-- One tile per two frames to the right, SlideBattlePicOut's own step. -- One tile per two frames to the right, SlideBattlePicOut's own step.
@@ -903,11 +924,12 @@ function BattleState:startAnim(key, opts)
param = opts.param or 0, param = opts.param or 0,
sfxOrder = audio.sfxOrder, sfxOrder = audio.sfxOrder,
ballPalette = opts.ballPalette, ballPalette = opts.ballPalette,
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
-- on the mon's volatile table, not on the mon itself.
flying = { flying = {
player = self.battle and self.battle.player player = BattleState.isVanished(self.battle and self.battle.player),
and self.battle.player.vanished or false, enemy = BattleState.isVanished(self.battle and self.battle.enemy),
enemy = self.battle and self.battle.enemy
and self.battle.enemy.vanished or false,
}, },
hooks = { hooks = {
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls -- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
@@ -1365,6 +1387,13 @@ function BattleState:advanceQueue()
end end
end end
end end
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
-- the take-off or the burrow on the turn the substatus goes up; the box
-- only empties once THIS animation is done with.
if BattleState.isVanished(self:activeMon(event.side)) then
self.vanishAnim = self.anim
end
elseif event.kind == "damage" and event.side then elseif event.kind == "damage" and event.side then
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), -- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203). -- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
@@ -1864,6 +1893,20 @@ function BattleState:update(_dt)
return return
end end
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
if self.phase == "shift-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self:nextPage()
if not self.messagePages then self.phase = "ask-shift" end
return
end
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls -- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310). -- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
if self.phase == "ask-shift" then if self.phase == "ask-shift" then
@@ -2464,13 +2507,13 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231). -- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon) function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1 self.shiftIndex = 1
self.phase = "ask-shift"
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe" local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name) local player = (self.save and self.save.player and self.save.player.name)
or "GOLD" or "GOLD"
self.message = trainer .. " is about to use " .. self:name(mon) -- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
.. ". Will " .. player .. " change POKéMON?" -- mon is NAMED on its own page, and only the second carries the yes/no box.
self.messageTimer = MESSAGE_FRAMES self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
self.phase = self.messagePages and "shift-intro" or "ask-shift"
end end
function BattleState:answerUseNextMon(yes) function BattleState:answerUseNextMon(yes)
+48 -13
View File
@@ -80,7 +80,8 @@ local function purgeBundledModules()
end end
end end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err -- Boot.probePayload(rel)
-- -> { engine = string, minShell = number, payloadHost = string } | nil, err
-- --
-- Mount the archive at rel (a save-directory-relative path) on an isolated -- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with -- mountpoint, read its src/core/Version.lua by executing the source with
@@ -104,24 +105,50 @@ function Boot.probePayload(rel)
if type(v) ~= "table" or type(v.engine) ~= "string" then if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table" return nil, "payload has no usable Version table"
end end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 } return {
engine = v.engine,
minShell = tonumber(v.minShell) or 1,
payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
}
end end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete -- Pure host gate shared by boot selection and the download worker. Missing
-- payloadHost fields mean "love" so payloads made before this contract remain
-- compatible with ordinary LOVE packages.
function Boot.canHost(info, bundledShell, bundledPayloadHost)
if type(info) ~= "table" then return false end
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host and (tonumber(info.minShell) or 1)
<= (tonumber(bundledShell) or 1)
end
local function samePayloadHost(info, bundledPayloadHost)
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host
end
-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
-- -> chosen | nil, toDelete
-- --
-- Pure (no love.*): decide which payload to run and which to delete. -- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }. -- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and -- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host). -- whose payloadHost matches and minShell <= bundledShell.
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we -- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen). -- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too -- A newer incompatible payload is kept: a matching host or future shell
-- high) is kept: a future shell upgrade may be able to run it. -- may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell) function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
local chosen local chosen
for _, c in ipairs(candidates) do for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0 local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
if newer and runnable then if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c chosen = c
@@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell)
local toDelete = {} local toDelete = {}
for _, c in ipairs(candidates) do for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0 local stale = false
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then -- Never clean up another host family's payloads. A shared save directory
stale = true -- may be opened by multiple native packages, and only the matching host
-- can decide whether one of its own archives is stale.
if samePayloadHost(c, bundledPayloadHost) then
stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
end end
if stale then toDelete[#toDelete + 1] = c.name end if stale then toDelete[#toDelete + 1] = c.name end
end end
@@ -223,6 +256,7 @@ local function runInner(args)
name = entry, name = entry,
engine = info.engine, engine = info.engine,
minShell = info.minShell, minShell = info.minShell,
payloadHost = info.payloadHost,
} }
end end
end end
@@ -230,7 +264,8 @@ local function runInner(args)
end end
local Version = require("src.core.Version") local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell) local chosen, toDelete = Boot.select(candidates, Version.engine,
Version.shell, Version.payloadHost)
for _, victim in ipairs(toDelete) do for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim) love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
+3 -2
View File
@@ -153,8 +153,9 @@ local function gatePasses(rel)
local info = Boot.probePayload(rel) local info = Boot.probePayload(rel)
if not info then return true end if not info then return true end
local shell = (Version and Version.shell) or 1 local shell = (Version and Version.shell) or 1
if info.minShell and info.minShell > shell then return false end local payloadHost = (Version and Version.payloadHost) or "love"
return true if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
return not (info.minShell and info.minShell > shell)
end end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
+18 -5
View File
@@ -1397,14 +1397,16 @@ function OverworldState:checkLedgeHop(dir)
return false return false
end end
require("src.core.Sound").play(Game.data, "Ledge") require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic) local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end) self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
return true return true
end end
if not Collision.occupied(self.entities, lx, ly, p) if not Collision.occupied(self.entities, lx, ly, p)
and self.map:isWalkableCell(lx, ly) then and self.map:isWalkableCell(lx, ly) then
require("src.core.Sound").play(Game.data, "Ledge") require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic) local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 2) self:scriptMove(p, dir, 2)
return true return true
end end
@@ -3515,10 +3517,21 @@ end
function OverworldState:onStepComplete() function OverworldState:onStepComplete()
local p = self.player local p = self.player
local suppressWildEncounter = self.wildEncounterGraceSteps > 0 -- Defaulted: a state built without the constructor (a mod harness, a test
if suppressWildEncounter then -- fixture) reaches this before :234 ever ran, and nil > 0 threw the step.
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1 local grace = self.wildEncounterGraceSteps or 0
if grace > 0 then
self.wildEncounterGraceSteps = grace - 1
end end
-- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable
-- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the
-- player did not take never rolls, which is why Oak's escort walks to the lab
-- through Pallet's grass without being jumped.
local runner = self.runner
local scripted = (runner and runner.isRunning and runner:isRunning())
or #(self.scriptMoves or {}) > 0
or self.engaging or self.emote or self.teleportOut
local suppressWildEncounter = grace > 0 or scripted and true or false
self.todSteps = (self.todSteps or 0) + 1 self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm) -- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save) require("src.world.PikachuFollower").onStep(Game.save)
+2 -1
View File
@@ -3946,7 +3946,8 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
local ctx = { local ctx = {
mapId = map and map.id, mapId = map and map.id,
terrain = terrain, terrain = terrain,
rng = love.math.random, -- Same guard World:rockRandom uses: a headless suite has no love global.
rng = (love and love.math and love.math.random) or math.random,
kind = kind, kind = kind,
daytime = self.daytime, daytime = self.daytime,
environment = map and map.def and map.def.environment, environment = map and map.def and map.def.environment,
+80
View File
@@ -0,0 +1,80 @@
-- #1091: does GAME SPEED change the wild encounter rate per STEP?
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love .
local Encounter = require("src.battle.gen2.Encounter")
local Player = require("src.world.gen2.Player")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local rolls, hits, steps = 0, 0, 0
local realTriggers = Encounter.triggers
Encounter.triggers = function(rate, random)
rolls = rolls + 1
if realTriggers(rate, random) then hits = hits + 1 end
return false
end
local World = require("src.world.gen2.World")
local ticks = 0
local realWorldStep = World.step
World.step = function(self)
ticks = ticks + 1
return realWorldStep(self)
end
local realUpdate = Player.update
Player.update = function(self)
local landed = realUpdate(self)
if landed then steps = steps + 1 end
return landed
end
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local function measure(speed, yields)
world:setMap("ROUTE_29", 44, 12, "right")
wait(20)
world.wildCooldown = 0
game.speedOverride = speed
rolls, hits, steps, ticks = 0, 0, 0, 0
local dir = "right"
for i = 1, yields do
if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
end
clearDirs()
game.speedOverride = 1
wait(5)
return rolls, hits, steps, ticks
end
local r1, h1, s1, t1 = measure(1, 900)
local r2, h2, s2, t2 = measure(200, 60)
print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0))
print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0))
Encounter.triggers = realTriggers
Player.update = realUpdate
World.step = realWorldStep
local rate1 = s1 > 0 and r1 / s1 or 0
local rate2 = s2 > 0 and r2 / s2 or 0
assert(s1 > 20 and s2 > 20, "not enough walked cells to judge")
assert(math.abs(rate1 - rate2) < 0.05,
("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2))
print("[driver] PASS encounter rolls are one per walked cell at every speed")
end
+79
View File
@@ -0,0 +1,79 @@
-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP
-- puts the player inside the mountain on Route 31.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love .
local Mon = require("src.battle.gen2.Mon")
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
local function hold(dir, frames, trace)
local last
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
local w = game.world
local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY)
if trace and at ~= last then print(" " .. at); last = at end
end
clearDirs()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
-- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP
-- arrival until the errand is over; set its event so the walk is not spent
-- behind a phone call. tests/drivers/gold/flag_names.lua:1132.
if world.events then world.events:set(64, true) end
-- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3.
world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down")
wait(20)
hold("down", 40)
-- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14)
for _ = 1, 900 do
if not world:busy() then break end
coroutine.yield()
end
wait(20)
print(("[driver] out of the cave at %s (%d,%d)")
:format(world.map.id, world.player.cellX, world.player.cellY))
assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31")
print("[driver] holding UP:")
hold("up", 120, true)
wait(30)
local m, x, y = world.map.id, world.player.cellX, world.player.cellY
print(("[driver] ended at %s (%d,%d)"):format(m, x, y))
-- Anything above y=5 on Route 31 in that column is the inside of the
-- mountain; the honest outcomes are re-entering the cave or bumping.
local inside = (m == "ROUTE_31" and y < 5)
assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y))
-- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is
-- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with
-- ordinary FLOOR above them in the collision data.
for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do
local mapId, wx, wy = c[1], c[2], c[3]
world:setMap(mapId, wx, wy, "up")
wait(20)
hold("up", 90)
wait(20)
if world.map.id == mapId then
assert(world.player.cellY >= wy,
("%s: walked above the cave mouth to (%d,%d)")
:format(mapId, world.player.cellX, world.player.cellY))
end
print(("[driver] %s mouth ended at %s (%d,%d)"):format(
mapId, world.map.id, world.player.cellX, world.player.cellY))
end
print("[driver] PASS the cave mouth cannot be walked through")
end
+78
View File
@@ -0,0 +1,78 @@
-- #1104: the Ruins of Alph ladders warp somewhere else.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love .
local Mon = require("src.battle.gen2.Mon")
-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126,
-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings.
local CASES = {
{ "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 },
{ "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" },
{ "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" },
{ "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" },
{ "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" },
{ "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
-- Hold until the map changes, then let the load settle and STOP: holding
-- past the arrival would walk the player off the landing tile.
local function walkUntilWarp(dir, frames, fromMap)
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
if game.world.map and game.world.map.id ~= fromMap then
clearDirs()
-- The ARRIVAL cell is the one the warp table names. Standing on a
-- door/cave/staircase tile then forces one step DOWN off it
-- (engine/overworld/player_movement.asm:201-213), so the settled cell
-- is legitimately one row lower on an outdoor mouth.
local ax, ay = game.world.player.cellX, game.world.player.cellY
for _ = 1, 45 do coroutine.yield() end
return game.world.map.id, ax, ay,
game.world.player.cellX, game.world.player.cellY
end
end
clearDirs()
return game.world.map and game.world.map.id, nil, nil
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local bad = 0
for _, c in ipairs(CASES) do
local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6]
-- default approach is from below, the way the report describes facing the
-- ladder from below; a chamber's exit sits on the bottom row instead
local dir = c[7] or "up"
local sy = (dir == "up") and wy + 1 or wy - 1
world:setMap(src, wx, sy, dir)
wait(12)
local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY)
local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src)
local ok = (m == wantMap and ax == wantX and ay == wantY)
if not ok then bad = bad + 1 end
print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)")
:format(ok and "OK" or "MISMATCH", from, tostring(m),
tostring(ax), tostring(ay), tostring(sx), tostring(sy),
wantMap, wantX, wantY))
end
print(("[driver] %s ruins warps: %d mismatches")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+100
View File
@@ -0,0 +1,100 @@
-- #1155: "MAGNITUDE always rolls a 4".
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local ROLLS = 15
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local caster = Mon.new(game.data, "DUNSPARCE", 50)
local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua")
caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } }
print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect)
.. ", stored power = " .. tostring(def.power))
game.save.party = { caster }
local seen, damages, shown = {}, {}, 0
local screen
for i = 1, ROLLS do
if not (screen and game.stack:top() == screen and not screen.battle.over) then
local dummy = Mon.new(game.data, "SNORLAX", 60)
dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = dummy }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 300)
end
local foe = screen.battle.enemy
local before = foe.hp
caster.moves[1].pp = def.pp
screen:submit({ kind = "move", move = "MAGNITUDE" })
for _, event in ipairs(screen.queue) do
local number = event.text and event.text:match("^Magnitude (%d+)")
if number then
seen[number] = (seen[number] or 0) + 1
if shown < 1 then
U.wait(1)
U.shot(game, out .. "/01-magnitude-text.png")
shown = 1
end
end
end
drain(game, screen, 400)
local dealt = before - foe.hp
if dealt > 0 then damages[dealt] = true end
if i % 10 == 0 then U.wait(5) end
end
local numbers = {}
for number, count in pairs(seen) do
numbers[#numbers + 1] = number .. "x" .. count
end
table.sort(numbers)
print("[driver] magnitudes over " .. ROLLS .. " uses: "
.. table.concat(numbers, " "))
local distinctPowers = 0
for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
check(#numbers > 1, "MAGNITUDE rolls more than one number")
check(seen["4"] == nil or seen["4"] < ROLLS,
"and is not stuck on MAGNITUDE 4")
check(distinctPowers > 1, "the rolled power reaches the damage (" ..
distinctPowers .. " distinct damage figures)")
if #failures > 0 then
error(#failures .. " magnitude checks failed")
end
print("[driver] #1155 clear; shot in " .. out)
end
+113
View File
@@ -0,0 +1,113 @@
-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- The reporter's own matchup, three times over.
local caster = Mon.new(game.data, "MAGNEMITE", 20)
local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing")
print("[driver] SONICBOOM effect = " .. tostring(boom.effect)
.. ", stored power = " .. tostring(boom.power))
caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } }
game.save.party = { caster }
-- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not
-- count: what matters is that every use that CONNECTS is 20.
local dealt, hits, wrong = {}, 0, 0
for i = 1, 6 do
local rattata = Mon.new(game.data, "RATTATA", 11)
rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = rattata }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = rattata.hp
caster.hp = caster.maxHp or caster.hp
caster.moves[1].pp = boom.pp
screen:submit({ kind = "move", move = "SONICBOOM" })
drain(game, screen, 400)
dealt[i] = before - rattata.hp
if dealt[i] > 0 then
hits = hits + 1
if dealt[i] ~= 20 then wrong = wrong + 1 end
end
if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(45)
end
print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ")
.. " (0 is a miss)")
check(hits >= 3, "SONIC BOOM connected at least three times")
check(wrong == 0, "and every hit was exactly 20")
-- The two siblings on the same command.
local dragon = Mon.new(game.data, "DRATINI", 25)
local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing")
local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing")
dragon.moves = {
{ id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp },
{ id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp },
}
game.save.party = { dragon }
local target = Mon.new(game.data, "SNORLAX", 60)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = target.hp
screen:submit({ kind = "move", move = "DRAGON_RAGE" })
drain(game, screen, 400)
local rageDealt = before - target.hp
before = target.hp
screen:submit({ kind = "move", move = "SEISMIC_TOSS" })
drain(game, screen, 400)
local tossDealt = before - target.hp
U.shot(game, out .. "/02-siblings.png")
print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)")
:format(rageDealt, tossDealt, dragon.level))
check(rageDealt == 40, "DRAGON RAGE deals exactly 40")
check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level")
if #failures > 0 then
error(#failures .. " constant-damage checks failed")
end
print("[driver] #1157 fixed; shots in " .. out)
end
+96
View File
@@ -0,0 +1,96 @@
-- #1158: you could not see the trainer's incoming mon before the switch offer.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function runToPhase(game, screen, phase, frames)
for _ = 1, (frames or 600) do
if screen.phase == phase then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch).
game.options = game.options or {}
game.options.battleStyle = "SHIFT"
local lead = Mon.new(game.data, "TYPHLOSION", 40)
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(game.data, "FERALIGATR", 40)
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, bench }
local foe1 = Mon.new(game.data, "PIDGEY", 5)
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local foe2 = Mon.new(game.data, "PIDGEOTTO", 20)
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER",
party = { foe1, foe2 } } }), "trainer startBattle failed")
local screen = battleScreen(game)
runToPhase(game, screen, "menu", 400)
foe1.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
-- Page by page, collecting what the box actually showed.
local pages, sawBoxWithName = {}, false
local incoming = foe2.nickname or foe2.species or "PIDGEOTTO"
for _ = 1, 900 do
local message = screen.message
if message and pages[#pages] ~= message
and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then
pages[#pages + 1] = message
if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end
if message:find(incoming, 1, true) then
U.shot(game, out .. "/02-names-the-mon.png")
sawBoxWithName = true
end
end
if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then
break
end
U.tap(game, "a")
U.wait(4)
end
U.shot(game, out .. "/03-yes-no.png")
for i, page in ipairs(pages) do
print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / "))))
end
check(sawBoxWithName,
"the incoming " .. incoming .. " is named in the box before the yes/no")
check(screen.phase == "ask-shift",
"and the prompt still ends on the YES/NO question")
check(#pages > 1, "the offer runs as pages, not one over-long line")
if #failures > 0 then
error(#failures .. " send-out order checks failed")
end
print("[driver] #1158 fixed; shots in " .. out)
end
+131
View File
@@ -0,0 +1,131 @@
-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics).
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local BattleState = require("src.ui.gen2.BattleState")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- One drawPic call with love.graphics.draw intercepted: how many blits it made
-- and where the first one landed.
local function probePic(screen, mon, back)
local G = love.graphics
local real = G.draw
local count, x, y = 0, nil, nil
G.draw = function(image, a, b, ...)
count = count + 1
if count == 1 then
-- draw(image, x, y, ...) or draw(image, quad, x, y, ...)
if type(a) == "number" then x, y = a, b else x, y = b, (...) end
end
end
local ok, err = pcall(screen.drawPic, screen, mon, back)
G.draw = real
if not ok then error(err) end
return count, x, y
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
------------------------------------------------------------------ #1159
local digger = Mon.new(game.data, "SANDSHREW", 30)
local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache")
digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } }
game.save.party = { digger }
local target = Mon.new(game.data, "SNORLAX", 40)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = probePic(screen, screen.battle.player, true)
check(before > 0, "the back pic is on the field before DIG")
U.shot(game, out .. "/01-before-dig.png")
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == true,
"SUBSTATUS_UNDERGROUND is set after the charge turn")
local underground = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws while underground: " .. underground)
check(underground == 0, "the back pic is GONE for the charge turn")
U.shot(game, out .. "/02-underground.png")
-- The enemy front pic is untouched by the player's own dig.
local foe = probePic(screen, screen.battle.enemy, false)
check(foe > 0, "the enemy front pic is unaffected")
-- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it.
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == false,
"the substatus is cleared when the stored attack lands")
local after = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws after the attack: " .. after)
check(after > 0, "the back pic is back on the field")
U.shot(game, out .. "/03-resurfaced.png")
------------------------------------------------------------------ #1160
-- A stubbed pic of each size through the same placement code. trueColor is
-- set so the probe never enters the GBC palette shader.
local realPic = screen.pic
local function place(size, back)
local data = love.image.newImageData(size, size)
local image = love.graphics.newImage(data)
screen.pic = function() return image, true, "mods/front" .. size .. ".png" end
local n, x, y = probePic(screen, screen.battle.enemy, back)
screen.pic = realPic
return n, x, y
end
local _, x56, y56 = place(56, false)
print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56)))
check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)")
local _, x40, y40 = place(40, false)
print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40)))
check(x40 == 104 and y40 == 16,
"a small 40x40 front pic still stands on the box's ground line")
local _, x64, y64 = place(64, false)
print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64)))
check(x64 == 96 and y64 == 0,
"a 64x64 front pic pins to the box corner and overlaps the HUD")
if #failures > 0 then
error(#failures .. " checks failed")
end
print("[driver] #1159 / #1160 fixed; shots in " .. out)
end
+82
View File
@@ -0,0 +1,82 @@
-- #1167: the Ilex Forest hidden Ether and hidden Super Potion.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love .
local Mon = require("src.battle.gen2.Mon")
local HiddenItems = require("src.world.gen2.HiddenItems")
local CASES = {
{ name = "ETHER", x = 27, y = 1, event = 136 },
{ name = "SUPER_POTION", x = 17, y = 7, event = 137 },
{ name = "FULL_HEAL", x = 9, y = 17, event = 138 },
}
local FACE = {
up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function tap(btn)
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
coroutine.yield()
game.input.state[btn] = false
coroutine.yield()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
world:setMap("ILEX_FOREST", 9, 18, "up")
wait(30)
assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST")
-- what the engine itself thinks it has for this map
local listed = HiddenItems.unfound(world.map.def, world.events)
print("[driver] engine hidden items on ILEX_FOREST:")
for _, row in ipairs(listed) do
print((" (%2d,%2d) item=%s flag=%d")
:format(row.x, row.y, tostring(row.item), row.event))
end
local function bagCount(name)
local n = 0
for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do
if row.item == name or row.id == name then n = n + (row.count or 1) end
end
return n
end
local bad = 0
for _, c in ipairs(CASES) do
local got = false
for dir, d in pairs(FACE) do
local sx, sy = c.x + d[1], c.y + d[2]
if world.map:isWalkable(sx, sy) then
world:setMap("ILEX_FOREST", sx, sy, dir)
wait(20)
world.player.facing = dir
local before = bagCount(c.name)
tap("a")
wait(60)
for _ = 1, 240 do
if not world:busy() then break end
tap("a")
end
wait(20)
if world.events:get(c.event) or bagCount(c.name) > before then
got = true
print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s")
:format(c.name, c.x, c.y, dir))
break
end
end
end
if not got then
bad = bad + 1
print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y))
end
end
print(("[driver] %s ilex hidden items: %d missing")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+48
View File
@@ -0,0 +1,48 @@
-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love .
local U = require("tests.drivers.util")
local SaveData = require("src.core.SaveData")
local GoldSave = require("src.core.gen2.Save")
return function(game)
U.wait(45)
local save = game.save
assert(save and save.player, "gold save did not boot")
save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
STORM = true, MINERAL = true, GLACIER = true, RISING = true }
save.player.kantoBadges = { BOULDER = true, CASCADE = true }
save.pokedex = save.pokedex or {}
save.pokedex.caught = { [155] = true, [158] = true, [152] = true }
local ok, err = game:writeSave()
if not ok then
U.log("FAIL gold writeSave:", tostring(err))
else
local want = GoldSave.summary(save)
local active = SaveData.activeSlot("gold")
local row
for _, slot in ipairs(SaveData.listSlots("gold")) do
if slot.id == active then row = slot end
end
if not (row and row.meta) then
U.log("FAIL launcher has no slot row for", tostring(active))
else
U.log(("launcher: %d badges - %s - %d caught"):format(
row.meta.badges, row.meta.timeText, row.meta.dexCount))
U.log(("continue: %d badges - %d:%02d - %d caught"):format(
want.badges, want.hours, want.minutes, want.caught))
if row.meta.badges == want.badges and row.meta.dexCount == want.caught then
U.log("PASS launcher summary matches CONTINUE for slot", tostring(active))
else
U.log("FAIL launcher summary disagrees with CONTINUE")
end
end
end
while true do
coroutine.yield()
end
end
@@ -0,0 +1,58 @@
-- GameActivity exposes an optional Android host seam without requiring any
-- host implementation (or its native libraries) in the stock build.
-- Self-contained: luajit tests/engine/android_host_extension_test.lua
local path = "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java"
local file = assert(io.open(path, "rb"))
local source = file:read("*a")
file:close()
local function check(value, message)
if not value then error(message, 2) end
end
local function position(text)
local p = source:find(text, 1, true)
check(p, "missing: " .. text)
return p
end
check(source:find("protected String[] getHostLibraries()", 1, true),
"host libraries are an overridable protected extension")
check(source:find("return new String[0];", 1, true),
"the vanilla host library list is empty")
local cpp = position('libraries[0] = "c++_shared";')
local mpg = position('libraries[1] = "mpg123";')
local openal = position('libraries[2] = "openal";')
local host = position("System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);")
local love = position('libraries[libraries.length - 1] = "love";')
check(cpp < mpg and mpg < openal and openal < host and host < love,
"optional libraries load after dependencies while liblove remains last")
for _, hook in ipairs({
"onHostCreateBeforeSDL", "onHostCreateAfterSDL", "onHostResume",
"onHostPause", "onHostDestroy",
}) do
check(source:find("protected void " .. hook, 1, true),
hook .. " is a protected extension hook")
end
check(position("onHostCreateBeforeSDL(savedInstanceState);") <
position("super.onCreate(savedInstanceState);") and
position("super.onCreate(savedInstanceState);") <
position("onHostCreateAfterSDL(savedInstanceState);"),
"create hooks bracket SDL creation")
check(position("super.onResume();") < position("onHostResume();"),
"resume hook runs after SDL resumes")
check(position("onHostPause();") < position("super.onPause();"),
"pause hook runs before SDL pauses")
check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and
not source:find("QuestBridge", 1, true),
"generic Android activity must not require Quest classes")
print("android_host_extension_test: ok")
+73
View File
@@ -0,0 +1,73 @@
-- Optional native-host display lifecycle. The default path is inert; a fake
-- backend proves callback order and arguments without graphics or a ROM.
-- luajit tests/engine/host_display_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostDisplay = require("src.core.HostDisplay")
-- Vanilla: no backend, no state requirement, and no invented return value.
HostDisplay.setBackend(nil)
eq(HostDisplay.update(1 / 60), nil, "default update is a no-op")
eq(HostDisplay.beginFrame("game", {}), nil, "default beginFrame is a no-op")
eq(HostDisplay.endFrame("game", {}), nil, "default endFrame is a no-op")
-- Bad installation fails at the boundary instead of producing a later frame
-- error whose source is difficult for a native host to diagnose.
local ok, err = pcall(HostDisplay.setBackend, function() end)
check(not ok, "non-table backend is rejected")
check(tostring(err):find("table or nil", 1, true) ~= nil,
"backend type error explains the accepted shape")
local calls = {}
local subject = { tag = "launcher-instance" }
local fake = {}
function fake:update(dt)
calls[#calls + 1] = { "update", self, dt }
return "updated"
end
function fake:beginFrame(kind, gotSubject)
calls[#calls + 1] = { "begin", self, kind, gotSubject }
return "begun"
end
function fake:endFrame(kind, gotSubject)
calls[#calls + 1] = { "end", self, kind, gotSubject }
return "ended"
end
HostDisplay.setBackend(fake)
eq(HostDisplay.update(0.25), "updated", "update return is forwarded")
eq(HostDisplay.beginFrame("launcher", subject), "begun",
"beginFrame return is forwarded")
eq(HostDisplay.endFrame("launcher", subject), "ended",
"endFrame return is forwarded")
eq(#calls, 3, "each lifecycle callback fires exactly once")
eq(calls[1][1], "update", "update is first")
eq(calls[1][2], fake, "update receives the backend as self")
eq(calls[1][3], 0.25, "update receives dt")
eq(calls[2][1], "begin", "beginFrame precedes endFrame")
eq(calls[2][3], "launcher", "beginFrame receives the frame kind")
eq(calls[2][4], subject, "beginFrame receives the drawn subject")
eq(calls[3][1], "end", "endFrame is last")
eq(calls[3][3], "launcher", "endFrame receives the frame kind")
eq(calls[3][4], subject, "endFrame receives the drawn subject")
-- Every callback is optional. Replacing and clearing a backend must not retain
-- callbacks from the old host across a restart or test process.
local partialCalls = 0
HostDisplay.setBackend({
endFrame = function() partialCalls = partialCalls + 1 end,
})
eq(HostDisplay.update(1), nil, "missing optional update remains a no-op")
eq(HostDisplay.beginFrame("editor", {}), nil,
"missing optional beginFrame remains a no-op")
HostDisplay.endFrame("editor", {})
eq(partialCalls, 1, "present optional callback still runs")
HostDisplay.setBackend(nil)
HostDisplay.endFrame("game", {})
eq(partialCalls, 1, "clearing backend detaches old callbacks")
T.finish("host display")
+114
View File
@@ -0,0 +1,114 @@
-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has
-- to look like a phone; POKEPORT_TOUCH=1 is the other way in.
local realGetOS = love.system.getOS
love.system.getOS = function() return "Android" end
local LauncherSettings = require("src.import.LauncherSettings")
local TouchControls = require("src.core.TouchControls")
local function labels(model)
local out = {}
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do out[#out + 1] = row.label end
end
return out
end
local function findRow(model, label)
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do
if row.label == label then return row end
end
end
end
local function has(model, label)
for _, l in ipairs(labels(model)) do
if l == label then return true end
end
return false
end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
local red = LauncherSettings.open(hooks, "red")
check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD")
check(has(red, "VIBRATION"), "and VIBRATION")
check(has(red, "TOUCH CONTROLS"), "and the layout editor")
findRow(red, "TOUCH PAD").step(1)
eq(red.opts.touchControls.enabled, false, "writing to the flat key")
eq(red.opts.gold, nil, "with no gold block invented")
eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- With no hook (the standalone save editor's shape) the editor row is gone
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
-- game's block to write the dragged layout into: TouchControlsEditor persists
-- to opts.gold only when it was loaded with version = "gold". Source-shape
-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins
-- the handoff either side of this one.
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local importerSrc = read("src/import/RomImporter.lua")
check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil,
"the gear hands the launcher tab to the host")
local mainSrc = read("main.lua")
local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)")
eq(body, "version", "main.lua's opener takes that tab")
check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil,
"and loads the editor with it, so Gold's layout lands in the gold block")
love.system.getOS = realGetOS
T.finish("launcher gold touch rows")
@@ -0,0 +1,90 @@
-- Launcher modal focus routing. The immediate-mode focus ring is shared by
-- the main launcher and its mod dialogs, so arrows and an armed confirmation
-- must be handled before the modal key guard returns.
-- luajit tests/engine/launcher_modal_focus_input_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local function resetFocus()
Kit.focusId = "modal-primary"
Kit._navQueue = nil
Kit._activateId = nil
end
local function importer(field)
return setmetatable({ _flex = true, [field] = {} }, RomImporter)
end
local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
}
for _, field in ipairs(modalFields) do
resetFocus()
local imp = importer(field)
imp:keypressed("right")
eq(Kit._navQueue, "right", field .. " accepts focus navigation")
check(imp._ringArmed, field .. " navigation arms the focus ring")
imp:keypressed("return")
eq(Kit._activateId, "modal-primary",
field .. " activates the focused modal control")
check(imp[field] ~= nil, field .. " remains open until its button dispatches")
end
-- Enter without prior focus navigation must not invent an activation. This is
-- the vanilla compatibility contract behind the launcher's legacy Enter-to-
-- play shortcut; a modal simply absorbs that otherwise-unhandled key.
do
resetFocus()
local imp = importer("_modConfirm")
imp:keypressed("return")
eq(Kit._activateId, nil, "unarmed modal Enter does not activate focus")
check(not imp._ringArmed, "unarmed modal Enter does not arm focus")
end
-- A focused text field remains exclusive. The modal fix must not move the
-- ring when an arrow is intended for a launcher text-input state.
do
resetFocus()
local imp = setmetatable({
_flex = true,
_findSearchFocus = true,
findQuery = "PIKA",
}, RomImporter)
imp:keypressed("right")
eq(Kit._navQueue, nil, "focused search field does not navigate the ring")
check(not imp._ringArmed, "focused search field does not arm the ring")
end
-- Escape remains owned by the existing modal close path.
do
resetFocus()
local imp = importer("_findDetails")
imp:keypressed("escape")
eq(imp._findDetails, nil, "Escape closes details")
end
do
resetFocus()
local imp = importer("_modReleaseNotes")
imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end
do
resetFocus()
local imp = importer("_modVersions")
imp._modConfirm = {}
imp:keypressed("escape")
eq(imp._modConfirm, nil, "Escape closes confirmation")
eq(imp._modVersions, nil, "Escape clears versions behind confirmation")
end
resetFocus()
T.finish("launcher modal focus input")
@@ -51,7 +51,9 @@ check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil, and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
"main.lua mentions prepareOverlayHandoff + touch editor") "main.lua mentions prepareOverlayHandoff + touch editor")
-- prepare must run inside openTouchControlsEditor, not only openEditor -- prepare must run inside openTouchControlsEditor, not only openEditor
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend") -- The signature takes the launcher tab since #1100, so match any parameter
-- list rather than pinning the arity.
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(.-%)(.-)\nend")
check(touchOpen ~= nil, "openTouchControlsEditor body found") check(touchOpen ~= nil, "openTouchControlsEditor body found")
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil, check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
"openTouchControlsEditor prepares overlay handoff like the save editor") "openTouchControlsEditor prepares overlay handoff like the save editor")
+56
View File
@@ -104,6 +104,34 @@ local function nameSet(list)
return s return s
end end
-- Host-family compatibility is independent from the numeric shell revision.
-- Missing fields preserve the historical ordinary-LOVE defaults.
do
check(Boot.canHost({}, 1, "love"),
"canHost: legacy payload defaults to ordinary LOVE host and shell 1")
check(Boot.canHost({ payloadHost = "love", minShell = 2 }, 2, "love"),
"canHost: matching host and sufficient shell pass")
check(not Boot.canHost({ payloadHost = "love", minShell = 2 }, 1, "love"),
"canHost: newer shell requirement fails")
check(not Boot.canHost({ payloadHost = "special", minShell = 1 }, 9, "love"),
"canHost: a high shell revision cannot override a host mismatch")
check(Boot.canHost({ payloadHost = "special", minShell = 3 }, 3, "special"),
"canHost: a specialized host accepts its own payload")
check(not Boot.canHost(nil, 1, "love"),
"canHost: malformed payload metadata fails closed")
end
-- Even an older mismatched-host payload is not ours to clean up.
do
local candidates = {
{ name = "other-old.love", engine = "0.5.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, nil, "select: old payload for another host is not runnable")
eq(#del, 0, "select: old payload for another host is not deleted")
end
-- empty candidate list: nothing to run, nothing to delete -- empty candidate list: nothing to run, nothing to delete
do do
local chosen, del = Boot.select({}, "1.0.0", 1) local chosen, del = Boot.select({}, "1.0.0", 1)
@@ -127,6 +155,34 @@ do
check(not d["b.love"], "select: the chosen payload is never deleted") check(not d["b.love"], "select: the chosen payload is never deleted")
end end
-- A newer payload for another native host is neither selected nor deleted.
-- It may be valid for another full package sharing this save directory.
do
local candidates = {
{ name = "other.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
{ name = "ours.love", engine = "1.5.0", minShell = 1,
payloadHost = "love" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, "ours.love", "select: chooses the matching host payload")
eq(#del, 0, "select: incompatible newer host payload is retained")
end
-- The same candidate set selects the specialized payload when the bundled
-- package identifies as that host family.
do
local candidates = {
{ name = "ordinary.love", engine = "2.1.0", minShell = 1,
payloadHost = "love" },
{ name = "special.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "special")
eq(chosen, "special.love", "select: specialized package chooses its host payload")
eq(#del, 0, "select: newer ordinary payload remains available to its host")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an -- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it -- otherwise-newer one for a future shell upgrade instead of deleting it
do do
+5
View File
@@ -252,10 +252,15 @@ do
local outcome local outcome
screen.onDone = function(result) outcome = result end screen.onDone = function(result) outcome = result end
-- Battle lines end in `prompt` and PromptButton waits on A/B with no
-- countdown (home/joypad.asm:383-412), so the drain presses.
local function drain(cap) local function drain(cap)
for _ = 1, (cap or 3000) do for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step() Input:step()
screen:update(1 / 60) screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" or screen.phase == "done" then return true end if screen.phase == "menu" or screen.phase == "done" then return true end
end end
return false return false
+6
View File
@@ -159,10 +159,16 @@ local function newScreen(opts)
return screen, battle, player, save, pushed return screen, battle, player, save, pushed
end end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
local function runToMenu(screen, cap) local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step() Input:step()
screen:update(1 / 60) screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" then return true end if screen.phase == "menu" then return true end
end end
return false return false
+6
View File
@@ -157,10 +157,16 @@ local function newBattleOverWorld(inventory)
return screen, battle, world, save, pushed, player return screen, battle, world, save, pushed, player
end end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
local function runToMenu(screen, cap) local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step() Input:step()
screen:update(1 / 60) screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" then return true end if screen.phase == "menu" then return true end
end end
return false return false
+2 -2
View File
@@ -1119,10 +1119,10 @@ check("seismic toss deals the level",
do do
check("static damage uses move power", check("static damage uses move power",
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil, Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
{ power = 40 }), 40) 40), 40)
check("sonic boom fixed damage is 20", check("sonic boom fixed damage is 20",
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil, Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
{ power = 20 }), 20) 20), 20)
local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" }) local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" })
boomWild.hp = 100 boomWild.hp = 100
+39 -21
View File
@@ -262,10 +262,21 @@ local function run(screen, frames)
end end
end end
-- One frame of a drain. Battle lines end in `prompt` and PromptButton waits
-- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits
-- the same way (engine/battle/core.asm:7069), so a drain presses rather than
-- idling for a timer that never runs out.
local function drainStep(screen)
local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box"
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
end
local function runToMenu(screen, cap) local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do for _ = 1, (cap or 3000) do
Input:step() drainStep(screen)
screen:update(1 / 60)
if screen.phase == "menu" then return true end if screen.phase == "menu" then return true end
end end
return false return false
@@ -366,8 +377,7 @@ do
local crawled, restarted = false, false local crawled, restarted = false, false
for _ = 1, 3000 do for _ = 1, 3000 do
Input:step() drainStep(screen)
screen:update(1 / 60)
if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then
crawled = true crawled = true
end end
@@ -404,8 +414,7 @@ do
local maxBefore = player.maxHp local maxBefore = player.maxHp
screen:submit({ kind = "move", move = "TACKLE" }) screen:submit({ kind = "move", move = "TACKLE" })
for _ = 1, 3000 do for _ = 1, 3000 do
Input:step() drainStep(screen)
screen:update(1 / 60)
if screen.phase == "done" then break end if screen.phase == "done" then break end
end end
check(player.maxHp > maxBefore, "the level-up raised the maximum") check(player.maxHp > maxBefore, "the level-up raised the maximum")
@@ -601,8 +610,7 @@ do
local function runToPhase(screen, phase, cap) local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 4000) do for _ = 1, (cap or 4000) do
Input:step() drainStep(screen)
screen:update(1 / 60)
if screen.phase == phase then return true end if screen.phase == phase then return true end
end end
return false return false
@@ -622,9 +630,15 @@ do
noBattle.enemy.hp = 1 noBattle.enemy.hp = 1
local noTurn = noBattle.turn local noTurn = noBattle.turn
noScreen:submit({ kind = "move", move = "TACKLE" }) noScreen:submit({ kind = "move", move = "TACKLE" })
check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch") -- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the
-- offer (data/text/battle.asm:222-231): the incoming mon is named on an
-- earlier page, and only the last one carries the yes/no box (#1158).
check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch")
check((noScreen.message or ""):find("is about to use"), check((noScreen.message or ""):find("is about to use"),
"with BattleText_EnemyIsAboutToUseWillPlayerChangeMon") "with BattleText_EnemyIsAboutToUseWillPlayerChangeMon")
check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question")
check((noScreen.message or ""):find("change POK"),
"whose last page is the one YesNoBox opens over")
-- NO falls through to the send-out with nothing switched and nothing spent. -- NO falls through to the send-out with nothing switched and nothing spent.
noScreen.messageTimer = 0 noScreen.messageTimer = 0
@@ -1083,8 +1097,7 @@ do
-- not ring is anything from the faint onward. -- not ring is anything from the faint onward.
local latched, rangAfterFaint, sawExpLine = false, false, false local latched, rangAfterFaint, sawExpLine = false, false, false
for _ = 1, 3000 do for _ = 1, 3000 do
Input:step() drainStep(screen)
screen:update(1 / 60)
if screen.lowHealthAlarmDisabled then latched = true end if screen.lowHealthAlarmDisabled then latched = true end
if latched and siren then rangAfterFaint = true end if latched and siren then rangAfterFaint = true end
if latched and (screen.message or ""):find("EXP") then sawExpLine = true end if latched and (screen.message or ""):find("EXP") then sawExpLine = true end
@@ -1155,7 +1168,9 @@ do
eq(screen.phase, "refuse-move", "the disabled row is refused") eq(screen.phase, "refuse-move", "the disabled row is refused")
eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled") eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled")
eq(wild.hp, before, "and the enemy got no free turn") eq(wild.hp, before, "and the enemy got no free turn")
run(screen, 120) -- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the
-- list comes back on a press, not on a timer.
tap("a")
eq(screen.phase, "moves", "the list comes back") eq(screen.phase, "moves", "the list comes back")
-- The same for a spent row. -- The same for a spent row.
@@ -1168,7 +1183,7 @@ do
eq(screen.message, "There's no PP left for this move!", eq(screen.message, "There's no PP left for this move!",
"with BattleText_TheresNoPPLeftForThisMove") "with BattleText_TheresNoPPLeftForThisMove")
eq(wild.hp, before, "and still no enemy turn") eq(wild.hp, before, "and still no enemy turn")
run(screen, 120) tap("a")
eq(screen.phase, "moves", "and the list comes back again") eq(screen.phase, "moves", "and the list comes back again")
end end
@@ -1221,8 +1236,7 @@ end
-- so the question is reached by pages rather than by one button. -- so the question is reached by pages rather than by one button.
local function runToPhase(screen, phase, cap) local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 900) do for _ = 1, (cap or 900) do
Input:step() drainStep(screen)
screen:update(1 / 60)
if screen.phase == phase then return true end if screen.phase == phase then return true end
end end
return false return false
@@ -1236,11 +1250,15 @@ do
check(runToPhase(screen, "ask-forget"), "and its pages run into the question") check(runToPhase(screen, "ask-forget"), "and its pages run into the question")
eq(screen.message, "move to make room\nfor EMBER?", eq(screen.message, "move to make room\nfor EMBER?",
"whose last page is the one YesNoBox opens over") "whose last page is the one YesNoBox opens over")
run(screen, 60)
local tap = tapper(screen) local tap = tapper(screen)
-- The question's own `prompt` is read first and YesNoBox opens after it,
-- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the
-- answer is the second press, not the first.
tap("a")
eq(screen.phase, "ask-forget", "the last page holds until it is read")
tap("b") tap("b")
eq(screen.phase, "stop-learning", "NO there asks whether to stop learning") eq(screen.phase, "stop-learning", "NO there asks whether to stop learning")
run(screen, 60) tap("a")
tap("b") tap("b")
eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask") eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask")
check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit") check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit")
@@ -1251,8 +1269,8 @@ end
do do
local screen, lead = learnScreen() local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question") check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen) local tap = tapper(screen)
tap("a") -- read the question
tap("a") -- YES tap("a") -- YES
eq(screen.phase, "choose-forget", "YES opens the picker") eq(screen.phase, "choose-forget", "YES opens the picker")
tap("a") -- slot 1 tap("a") -- slot 1
@@ -1263,9 +1281,9 @@ end
do do
local screen, lead = learnScreen() local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question") check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen) local tap = tapper(screen)
tap("a") tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "the picker is up") eq(screen.phase, "choose-forget", "the picker is up")
screen.forgetIndex = 4 screen.forgetIndex = 4
tap("a") tap("a")
@@ -1273,7 +1291,7 @@ do
eq(screen.message, "HM moves can't be\nforgotten now.", eq(screen.message, "HM moves can't be\nforgotten now.",
"with MoveCantForgetHMText") "with MoveCantForgetHMText")
eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1") eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1")
run(screen, 60) tap("a")
eq(screen.phase, "choose-forget", "the picker is still up after the line") eq(screen.phase, "choose-forget", "the picker is still up after the line")
eq(lead.moves[4].id, "SURF", "and SURF is still there") eq(lead.moves[4].id, "SURF", "and SURF is still there")
end end
+10 -5
View File
@@ -356,11 +356,14 @@ local optionsGame, optionsInput = newGame(Save.newGame())
local options = OptionsMenu.new(optionsGame, { local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(), options = Save.defaultOptions(),
}) })
-- The cart's seven value rows, then the port's audio, speed and display -- The cart's seven value rows, then CONTROLS, the port's audio, speed and
-- rows, then CANCEL -- which is what makes this screen scroll. -- display rows, the three touch rows and CANCEL -- which is what makes this
check("sixteen rows", #OptionsMenu.ROWS, 16) -- screen scroll. The touch three are gated to mobile by buildRows; ROWS
-- itself carries every descriptor.
check("twenty rows", #OptionsMenu.ROWS, 20)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
check("starts on TEXT SPEED", options:row().key, "textSpeed") check("starts on TEXT SPEED", options:row().key, "textSpeed")
check("default text speed", options.options.textSpeed, "MID") check("default text speed", options.options.textSpeed, "MID")
@@ -401,7 +404,9 @@ local exiting = OptionsMenu.new(exitGame, {
options = Save.defaultOptions(), options = Save.defaultOptions(),
onDone = function(o) savedOptions = o end, onDone = function(o) savedOptions = o end,
}) })
exiting.index = #OptionsMenu.ROWS -- The screen's own rows, not ROWS: buildRows drops the touch three off a
-- desktop, so the raw descriptor count overshoots CANCEL.
exiting.index = #exiting.rows
exitInput:press("a") exitInput:press("a")
exiting:update(0) exiting:update(0)
check("CANCEL leaves", savedOptions ~= nil, true) check("CANCEL leaves", savedOptions ~= nil, true)
+5 -2
View File
@@ -17,14 +17,17 @@ local Nests = require("src.core.gen2.Nests")
-- Landmark indices from constants/landmark_constants.asm: Johto runs below -- Landmark indices from constants/landmark_constants.asm: Johto runs below
-- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28. -- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28.
local DATA = { local DATA = {
maps = { -- gen2Maps, matching Game2's key for the Gold map table.
gen2Maps = {
ROUTE_29 = { landmark = 2 }, -- Johto ROUTE_29 = { landmark = 2 }, -- Johto
ILEX_FOREST = { landmark = 11 }, -- Johto ILEX_FOREST = { landmark = 11 }, -- Johto
ROUTE_1 = { landmark = 0x2f }, -- Kanto ROUTE_1 = { landmark = 0x2f }, -- Kanto
UNION_CAVE_1F = { landmark = 9 }, -- Johto UNION_CAVE_1F = { landmark = 9 }, -- Johto
NOWHERE = { }, -- no landmark at all NOWHERE = { }, -- no landmark at all
}, },
encounters = { -- Game2 loads the encounter tables under gen2Encounters, not `encounters`:
-- the flat name is Gen 1's and Gen2Compat only maps it for mods.
gen2Encounters = {
grass = { grass = {
ROUTE_29 = { slots = { ROUTE_29 = { slots = {
MORN = { { species = "PIDGEY" }, { species = "SENTRET" } }, MORN = { { species = "PIDGEY" }, { species = "SENTRET" } },
+8 -5
View File
@@ -417,22 +417,25 @@ check("only the written byte is stored", memCount, 1)
local _, _, _, loadReport = Save.load("gold") local _, _, _, loadReport = Save.load("gold")
check("load reports a clean save", Save.emptyReport(loadReport), true) check("load reports a clean save", Save.emptyReport(loadReport), true)
-- A second write backs the first up, so the previous file is always recoverable. -- A second write backs the first up, so the previous file is always
-- recoverable. The file lives at saves/<version>/<slot>.lua since Gold grew
-- launcher slots (#1107); the flat save_gold.lua is only the migration source.
local SLOT = "saves/gold/slot1.lua"
written.player.money = 5555 written.player.money = 5555
Save.save(written) Save.save(written)
check("backup written", files["save_gold.lua.bak"] ~= nil, true) check("backup written", files[SLOT .. ".bak"] ~= nil, true)
check("new value loads", Save.load("gold").player.money, 5555) check("new value loads", Save.load("gold").player.money, 5555)
-- A corrupt main file falls back to the backup rather than losing the game. -- A corrupt main file falls back to the backup rather than losing the game.
files["save_gold.lua"] = "this is not a lua table" files[SLOT] = "this is not a lua table"
local recoveredSave, how = Save.load("gold") local recoveredSave, how = Save.load("gold")
check("recovered from the backup", recoveredSave ~= nil, true) check("recovered from the backup", recoveredSave ~= nil, true)
check("recovery reported", how, "bak") check("recovery reported", how, "bak")
check("backup held the previous money", recoveredSave.player.money, 4321) check("backup held the previous money", recoveredSave.player.money, 4321)
-- A staged .tmp is preferred over the backup: it is the newer of the two. -- A staged .tmp is preferred over the backup: it is the newer of the two.
files["save_gold.lua"] = nil files[SLOT] = nil
files["save_gold.lua.tmp"] = files["save_gold.lua.bak"] files[SLOT .. ".tmp"] = files[SLOT .. ".bak"]
local staged, stagedHow = Save.load("gold") local staged, stagedHow = Save.load("gold")
check("recovered from the staged copy", staged ~= nil, true) check("recovered from the staged copy", staged ~= nil, true)
check("staged recovery reported", stagedHow, "tmp") check("staged recovery reported", stagedHow, "tmp")
+2 -1
View File
@@ -3315,7 +3315,8 @@ local function grassLatchChecks()
check(hp.jumping, "and it is a jump") check(hp.jumping, "and it is a jump")
check(not hp.grassShake, "no rustle spawns for the airborne cells") check(not hp.grassShake, "no rustle spawns for the airborne cells")
check(not hp.inGrass, "and IN_GRASS is clear for the whole hop") check(not hp.inGrass, "and IN_GRASS is clear for the whole hop")
for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end -- A hop clears two cells, so it runs two step-times, not one (#1165).
for _ = 1, PlayerMod.STEP_FRAMES * 2 + 1 do hopGrass:step() end
check(hp.inGrass, "the landing tile latches it once the hop ends") check(hp.inGrass, "the landing tile latches it once the hop ends")
end end
grassLatchChecks() grassLatchChecks()
@@ -48,10 +48,15 @@ function fs.load(path)
end end
function fs.getDirectoryItems(path) function fs.getDirectoryItems(path)
local items = {} local items = {}
-- -print plus a basename in Lua, not -printf: that is a GNU extension and
-- BSD find (macOS) fails the whole call, which silently emptied the listing
-- and left the probe mod undiscovered.
local pipe = io.popen("find " .. quote(full(path)) local pipe = io.popen("find " .. quote(full(path))
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null") .. " -mindepth 1 -maxdepth 1 -print 2>/dev/null")
if pipe then if pipe then
for item in pipe:lines() do items[#items + 1] = item end for item in pipe:lines() do
items[#items + 1] = item:match("[^/]+$") or item
end
pipe:close() pipe:close()
end end
table.sort(items) table.sort(items)
+12 -3
View File
@@ -21,6 +21,7 @@ local Net = require("src.link.Net")
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Session = require("src.link.Session")
local S = require("tests.harness").suite("mod link") local S = require("tests.harness").suite("mod link")
local check, eq = S.check, S.eq local check, eq = S.check, S.eq
@@ -529,11 +530,19 @@ local function linkGame(name, species, data)
return { data = data or Data, save = save, stack = stack, input = mkInput() } return { data = data or Data, save = save, stack = stack, input = mkInput() }
end end
-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua
-- :75 wraps every backend the same way), so a loopback end has to be wrapped
-- here too or :update reaches for a getStatus the transport does not have.
local function linkSession(transport, role)
return Session.new(transport, { role = role, kind = "link" })
end
-- two paired states, host already listening and guest already dialling -- two paired states, host already listening and guest already dialling
local function pairStates(gameA, gameB) local function pairStates(gameA, gameB)
local netA, netB = Net.loopbackPair() local netA, netB = Net.loopbackPair()
local host, guest = LinkState.new(gameA), LinkState.new(gameB) local host, guest = LinkState.new(gameA), LinkState.new(gameB)
host.net, guest.net = netA, netB host.net = linkSession(netA, "host")
guest.net = linkSession(netB, "guest")
host.stage, guest.stage = "hosting", "joining" host.stage, guest.stage = "hosting", "joining"
gameA.stack:push(host) gameA.stack:push(host)
gameB.stack:push(guest) gameB.stack:push(guest)
@@ -573,7 +582,7 @@ check(host.trade.strict, "a v2 verdict unpacks strictly")
local gameOld = linkGame("RED", "PIDGEY") local gameOld = linkGame("RED", "PIDGEY")
local oldNet, peerNet = Net.loopbackPair() local oldNet, peerNet = Net.loopbackPair()
local v1guest = LinkState.new(gameOld) local v1guest = LinkState.new(gameOld)
v1guest.net = oldNet v1guest.net = linkSession(oldNet, "guest")
v1guest.stage = "joining" v1guest.stage = "joining"
gameOld.stack:push(v1guest) gameOld.stack:push(v1guest)
v1guest:update(1 / 60) v1guest:update(1 / 60)
@@ -591,7 +600,7 @@ check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")
local gameLone = linkGame("RED", "PIDGEY") local gameLone = linkGame("RED", "PIDGEY")
local loneNet, silentNet = Net.loopbackPair() local loneNet, silentNet = Net.loopbackPair()
local v1host = LinkState.new(gameLone) local v1host = LinkState.new(gameLone)
v1host.net = loneNet v1host.net = linkSession(loneNet, "host")
v1host.stage = "hosting" v1host.stage = "hosting"
gameLone.stack:push(v1host) gameLone.stack:push(v1host)
v1host:update(1 / 60) v1host:update(1 / 60)
@@ -207,6 +207,11 @@ if type(storage) == "table" then
T.check(type(normalBytes) == "string" and normalBytes ~= "", T.check(type(normalBytes) == "string" and normalBytes ~= "",
"first checkpoint anchor is durably represented before restart") "first checkpoint anchor is durably represented before restart")
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
-- Model a durable checkpoint captured by the previous shipped engine.
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime state.
checkpoint.identity.engineVersion = "0.1.79"
SaveData.resetSlotState() SaveData.resetSlotState()
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true) local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
titleRuntime.save.options = { volume = 9, bindings = {} } titleRuntime.save.options = { volume = 9, bindings = {} }
@@ -224,7 +229,14 @@ if type(storage) == "table" then
version = "red", meta = { playthroughId = originalId }, version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt, }, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save") "title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint, local recaptured = checkpoints:capture(titleRuntime)
T.eq(recaptured and recaptured.identity
and recaptured.identity.engineVersion, Version.engine,
"cross-version resume recaptures the running engine version")
if recaptured and recaptured.identity then
recaptured.identity.engineVersion = checkpoint.identity.engineVersion
end
T.same(recaptured, checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint") "bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1, T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"a successfully verified title resume emits checkpoint.restored exactly once") "a successfully verified title resume emits checkpoint.restored exactly once")
+16
View File
@@ -64,6 +64,17 @@ local function freshGame(mapX, mapY)
worldViewSize = function() return 160, 144 end, worldViewSize = function() return 160, 144 end,
setSGBZones = function() end, setSGBZones = function() end,
} }
-- OverworldState is a singleton and :enter does not clear the movement
-- latches, so a scenario that runs after a few thousand other checks can
-- inherit a half-finished script and never walk. Reset them here: this
-- suite asserts a frame budget, so it has to start from a known state.
OverworldState.scriptMoves = {}
OverworldState.pendingScripts = {}
OverworldState.emote = nil
OverworldState.engaging = false
OverworldState.teleportOut = nil
OverworldState.transitioning = nil
OverworldState.wildEncounterGraceSteps = 0
StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up") StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up")
Game.overworld = OverworldState Game.overworld = OverworldState
return pressed return pressed
@@ -114,6 +125,11 @@ end
-- keeps running straight into the lab. -- keeps running straight into the lab.
-- ===================================================================== -- =====================================================================
scenario(function() scenario(function()
-- The trigger tile is in Pallet's north grass, so the escort walk rolls for
-- wild encounters as it goes. Pin the stream: run standalone this suite got
-- one draw sequence and run inside tests/run_tests.lua another, and one of
-- them dropped a battle on top of the escort and ate the frame budget.
math.randomseed(require("tests.harness").SEED)
GameVersion.set("red") GameVersion.set("red")
local pressed = freshGame(8, 2) local pressed = freshGame(8, 2)
local played = {} local played = {}
+12 -4
View File
@@ -2788,15 +2788,23 @@ do
press("down") press("down")
eq(om.index, 25, "cursor reaches CONTROLS") eq(om.index, 25, "cursor reaches CONTROLS")
press("down") press("down")
eq(om.index, 26, "CANCEL stays the fixed final row") eq(om.index, 26, "cursor reaches DATE FORMAT")
eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen") press("down")
eq(om.index, 27, "cursor reaches TIME FORMAT")
press("down")
-- CANCEL is appended after the descriptor list rather than living in it, so
-- it lands one past #rows and the window holds the last six boxes. Counted
-- off #rows so the next row added here is not read as a wrap bug.
local cancelRow = #om.rows + 1
eq(om.index, cancelRow, "CANCEL stays the fixed final row")
eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub om:draw() -- smoke: scrolled layout draws under the headless stub
press("a") press("a")
check(popped, "A on CANCEL closes the options menu") check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og) local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 26, "up from the top wraps to CANCEL") eq(om2.index, cancelRow, "up from the top wraps to CANCEL")
eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail") eq(om2.scroll, cancelRow - 5, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state -- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options) require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options)