mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
fix merge conflicts with dev
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 }}`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
Wi‑Fi, or keep the phone plugged into the computer).
|
Wi‑Fi, or keep the phone plugged into the computer).
|
||||||
|
|||||||
+12
-1
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" } },
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user