Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f77a98bfe6 | |||
| dd024d0e50 | |||
| 770798b4b6 | |||
| f1e5fa4ec3 | |||
| 5cf53bb13f | |||
| 95764c7d97 |
@@ -12,11 +12,10 @@ name: ci
|
|||||||
#
|
#
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Integration branch + release branch. PRs already run via pull_request
|
||||||
|
# (any base); this list is only for post-merge push runs.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
# PRs into dev only: a dev -> main ship PR reuses the required checks the
|
|
||||||
# dev push already put on the same head SHA, so it needs no second run.
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [dev]
|
|
||||||
|
|
||||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -27,6 +26,64 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
macos-changes:
|
||||||
|
name: detect macOS changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
changed: ${{ steps.paths.outputs.changed }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: paths
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||||
|
HEAD_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(conf\.lua$|scripts/(build\.sh|build_love_macos\.sh|run\.sh|setup\.sh)$|Play-Mac\.command$|mobile/macos/|src/core/SaveData\.lua$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
macos-build:
|
||||||
|
name: macOS LÖVE 12 build
|
||||||
|
needs: macos-changes
|
||||||
|
if: needs.macos-changes.outputs.changed == 'true'
|
||||||
|
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- name: Build LÖVE 12 macOS runtime
|
||||||
|
run: scripts/build_love_macos.sh --fetch
|
||||||
|
- name: Build and verify macOS app
|
||||||
|
env:
|
||||||
|
LOVE_APP: ${{ github.workspace }}/.bazinga/love12/love.app
|
||||||
|
MAC_STAGE_DIR: ${{ runner.temp }}/gen1recomp-mac-stage
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scripts/build.sh mac --no-notarize --identity - --version 0.0.0
|
||||||
|
unzip -tqq 'dist/mac/gen1recomp++-macos.zip'
|
||||||
|
app="$MAC_STAGE_DIR/gen1recomp++.app"
|
||||||
|
[ -d "$app" ]
|
||||||
|
[ -x "$app/Contents/MacOS/gen1recomp++" ]
|
||||||
|
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")" = 'gen1recomp++' ]
|
||||||
|
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$app/Contents/Info.plist")" = 'gen1recomp++' ]
|
||||||
|
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" = 'com.theboisclub.gen1recompplusplus' ]
|
||||||
|
[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = 0.0.0 ]
|
||||||
|
codesign --verify --deep --strict "$app"
|
||||||
|
otool -L "$app/Contents/Frameworks/love.framework/love" | grep -q '/Metal.framework/'
|
||||||
|
- name: Upload macOS build
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: gen1recomp++-macos
|
||||||
|
path: dist/mac/gen1recomp++-macos.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
ios-changes:
|
ios-changes:
|
||||||
name: detect iOS changes
|
name: detect iOS changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -121,7 +178,7 @@ jobs:
|
|||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then
|
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|platform-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -319,10 +376,52 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_linux_arm64.sh --version 0.0.0
|
scripts/build_linux_arm64.sh --version 0.0.0
|
||||||
# Shared with the release workflow so shipped images get the same
|
|
||||||
# self-contained / glibc-floor checks as PR builds.
|
|
||||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||||
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||||
|
|
||||||
|
# --appimage-extract needs no FUSE, so this works on a runner
|
||||||
|
# without /dev/fuse and still exercises the real payload.
|
||||||
|
"$image" --appimage-extract >/dev/null
|
||||||
|
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||||
|
[ -e "squashfs-root/$required" ] \
|
||||||
|
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||||
|
# applied; an unresolved soname here is a user-visible launch crash.
|
||||||
|
#
|
||||||
|
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||||
|
# The first version of this build bundled Debian's SDL2, which
|
||||||
|
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||||
|
# started on a full desktop -- a bare runner is what exposed it.
|
||||||
|
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||||
|
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||||
|
| grep 'not found' || true)"
|
||||||
|
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||||
|
|
||||||
|
# Nothing may hard-link a driver, session or audio-stack library:
|
||||||
|
# those must be reached through dlopen so the AppImage runs on a box
|
||||||
|
# with only ALSA, only Wayland, or only KMSDRM.
|
||||||
|
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||||
|
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||||
|
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||||
|
[ -z "$linked" ] \
|
||||||
|
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||||
|
|
||||||
|
# The whole point of compiling on bullseye. If a future change moves
|
||||||
|
# the builder to a newer base, the glibc floor silently rises and
|
||||||
|
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||||
|
# it here instead of in a release.
|
||||||
|
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||||
|
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||||
|
echo "highest required glibc symbol version: $floor"
|
||||||
|
[ -n "$floor" ] \
|
||||||
|
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||||
|
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||||
|
[ "$highest" = "GLIBC_2.31" ] \
|
||||||
|
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||||
- name: Upload the AppImage
|
- name: Upload the AppImage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -359,6 +458,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||||
- run: python3 -m pip install --upgrade pillow
|
- run: python3 -m pip install --upgrade pillow
|
||||||
|
|
||||||
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
||||||
@@ -379,8 +479,13 @@ jobs:
|
|||||||
print(f"\n{len(paths)} fixture assets valid")
|
print(f"\n{len(paths)} fixture assets valid")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
|
# the fingerprint golden is the parity tripwire; prove it still
|
||||||
# run in the headless job via run_engine; this job only guards the PNGs
|
# matches the dataset on a clean checkout
|
||||||
|
- name: fingerprint gate
|
||||||
|
run: luajit tests/engine/gate_fingerprint.lua
|
||||||
|
|
||||||
|
- name: parity-guarantee meta-test
|
||||||
|
run: luajit tests/engine/gate_meta_coverage.lua
|
||||||
|
|
||||||
# Only the differ is under test here, and the job is named for that. The
|
# Only the differ is under test here, and the job is named for that. The
|
||||||
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
||||||
@@ -468,24 +573,3 @@ jobs:
|
|||||||
if [ "$found" = "0" ]; then
|
if [ "$found" = "0" ]; then
|
||||||
echo "no committed mods to lint"
|
echo "no committed mods to lint"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
luacheck:
|
|
||||||
name: engine lint (luacheck)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: install luacheck
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
|
||||||
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
|
||||||
luacheck --version
|
|
||||||
|
|
||||||
- name: luacheck gate (undefined globals, unreachable code)
|
|
||||||
run: ./scripts/lint.sh --gate
|
|
||||||
|
|
||||||
- name: luacheck full report (advisory)
|
|
||||||
continue-on-error: true
|
|
||||||
run: ./scripts/lint.sh
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
name: iOS artifact comment
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [ci]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: artifact
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
|
||||||
run: |
|
|
||||||
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
|
|
||||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
|
||||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
|
||||||
[ -n "$pr_number" ] || exit 0
|
|
||||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
|
||||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
|
|
||||||
- name: Get build info
|
|
||||||
id: build-info
|
|
||||||
env:
|
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: |
|
|
||||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
|
||||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
|
||||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: comment iOS artifact
|
|
||||||
if: steps.artifact.outputs.pr_number != ''
|
|
||||||
uses: thollander/actions-comment-pull-request@v3
|
|
||||||
with:
|
|
||||||
message: |
|
|
||||||
[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 }})
|
|
||||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
|
||||||
|
|
||||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
|
||||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
|
||||||
comment-tag: ios-build-result
|
|
||||||
github-token: ${{ github.token }}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: platform artifact comment
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ci]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- id: artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||||
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
artifacts="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts")"
|
||||||
|
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||||
|
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||||
|
[ -n "$pr_number" ] || exit 0
|
||||||
|
|
||||||
|
message="## CI build artifacts"
|
||||||
|
found=0
|
||||||
|
while IFS='|' read -r platform artifact_name artifact_file; do
|
||||||
|
artifact_id="$(printf '%s' "$artifacts" | jq -r --arg name "$artifact_name" '[.artifacts[] | select(.name == $name) | .id][0] // empty')"
|
||||||
|
if [ -n "$artifact_id" ] && [ "$artifact_id" != null ]; then
|
||||||
|
artifact_url="https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id"
|
||||||
|
message+=$'\n'"- **$platform**: [$artifact_file]($artifact_url)"
|
||||||
|
found=1
|
||||||
|
fi
|
||||||
|
done <<'ARTIFACTS'
|
||||||
|
macOS|gen1recomp++-macos|gen1recomp++-macos.zip
|
||||||
|
iOS|gen1recomp++-ios-ipa|gen1recomp++.ipa
|
||||||
|
Nintendo Switch|gen1recomp-switch-nro|gen1recomp-switch.nro
|
||||||
|
Xbox UWP|gen1recomp-xbox-uwp|gen1recomp-xbox-uwp.zip
|
||||||
|
Linux arm64|gen1recomp-linux-arm64|gen1recomp-linux-arm64.AppImage
|
||||||
|
ARTIFACTS
|
||||||
|
|
||||||
|
[ "$found" -eq 1 ] || exit 0
|
||||||
|
commit_hash="$(printf '%s' "${{ github.event.workflow_run.head_sha }}" | cut -c1-7)"
|
||||||
|
build_time="$(date -u "+%Y-%m-%d %H:%M:%S UTC")"
|
||||||
|
message+=$'\n\n'"**Commit**: [#$commit_hash](https://github.com/$HEAD_REPOSITORY/commit/${{ github.event.workflow_run.head_sha }})"
|
||||||
|
message+=$'\n'"**Build Time**: \`$build_time\`"
|
||||||
|
message+=$'\n\n'"<sub>This comment was automatically generated. [View workflow run](https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID)</sub>"
|
||||||
|
|
||||||
|
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo 'message<<BUILD_MESSAGE'
|
||||||
|
printf '%s\n' "$message"
|
||||||
|
echo 'BUILD_MESSAGE'
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
- name: comment platform artifacts
|
||||||
|
if: steps.artifacts.outputs.pr_number != ''
|
||||||
|
uses: thollander/actions-comment-pull-request@v3
|
||||||
|
with:
|
||||||
|
message: ${{ steps.artifacts.outputs.message }}
|
||||||
|
pr-number: ${{ steps.artifacts.outputs.pr_number }}
|
||||||
|
comment-tag: platform-build-result
|
||||||
|
github-token: ${{ github.token }}
|
||||||
@@ -161,8 +161,6 @@ jobs:
|
|||||||
scripts/build_linux_arm64.sh \
|
scripts/build_linux_arm64.sh \
|
||||||
--version "${{ needs.version.outputs.version }}" \
|
--version "${{ needs.version.outputs.version }}" \
|
||||||
--game-love .bazinga/work/game.love
|
--game-love .bazinga/work/game.love
|
||||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
|
||||||
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
|
|
||||||
- name: Upload Linux arm64 release
|
- name: Upload Linux arm64 release
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -287,7 +285,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
|
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -309,15 +307,6 @@ jobs:
|
|||||||
name: gen1tls-win-x64
|
name: gen1tls-win-x64
|
||||||
path: dist/native/win-x64
|
path: dist/native/win-x64
|
||||||
|
|
||||||
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
|
|
||||||
# every release asset ships one identical payload (build.sh's own pack
|
|
||||||
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
|
|
||||||
- name: Download shared payload
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
name: gen1recomp-release-love
|
|
||||||
path: dist/payload
|
|
||||||
|
|
||||||
- name: Import signing certificate into a temporary keychain
|
- name: Import signing certificate into a temporary keychain
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -355,9 +344,16 @@ jobs:
|
|||||||
echo "Identities available to codesign:"
|
echo "Identities available to codesign:"
|
||||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
- name: Build LÖVE 12 macOS runtime
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scripts/build_love_macos.sh --fetch
|
||||||
|
|
||||||
- name: Build macOS + Windows + Linux
|
- name: Build macOS + Windows + Linux
|
||||||
env:
|
env:
|
||||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||||
|
LOVE_APP: ${{ github.workspace }}/.bazinga/love12/love.app
|
||||||
|
MAC_STAGE_DIR: ${{ runner.temp }}/gen1recomp-mac-stage
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Sign in-build (identity auto-detected from the temp keychain);
|
# Sign in-build (identity auto-detected from the temp keychain);
|
||||||
@@ -368,36 +364,14 @@ jobs:
|
|||||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
|
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||||
--game-love dist/payload/game.love
|
|
||||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||||
|
|
||||||
- name: Materialize Android release signing key
|
|
||||||
env:
|
|
||||||
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
[ -n "$KEYSTORE_B64" ] || {
|
|
||||||
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
python3 - <<'PY'
|
|
||||||
import base64, os, pathlib
|
|
||||||
encoded = os.environ["KEYSTORE_B64"]
|
|
||||||
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
|
|
||||||
path.write_bytes(base64.b64decode(encoded, validate=True))
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Build Android
|
- name: Build Android
|
||||||
env:
|
|
||||||
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
|
|
||||||
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
|
||||||
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
|
||||||
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
|
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Install xcbeautify
|
- name: Install xcbeautify
|
||||||
run: |
|
run: |
|
||||||
@@ -461,8 +435,8 @@ jobs:
|
|||||||
set -a; . "$ci_dir/notary.env"; set +a
|
set -a; . "$ci_dir/notary.env"; set +a
|
||||||
echo "::add-mask::$APPLE_APP_PASSWORD"
|
echo "::add-mask::$APPLE_APP_PASSWORD"
|
||||||
|
|
||||||
app=".bazinga/work/gen1recomp.app"
|
app="$RUNNER_TEMP/gen1recomp-mac-stage/gen1recomp++.app"
|
||||||
zip="dist/mac/gen1recomp-macos.zip"
|
zip="dist/mac/gen1recomp++-macos.zip"
|
||||||
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
||||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
|
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
|
||||||
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
||||||
@@ -507,7 +481,7 @@ jobs:
|
|||||||
outdir="dist/release"
|
outdir="dist/release"
|
||||||
rm -rf "$outdir"
|
rm -rf "$outdir"
|
||||||
mkdir -p "$outdir"
|
mkdir -p "$outdir"
|
||||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
cp "dist/mac/gen1recomp++-macos.zip" "$outdir/gen1recomp++-${v}-macos.zip"
|
||||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||||
|
|
||||||
@@ -519,8 +493,8 @@ jobs:
|
|||||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; 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"
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
name: Switch artifact comment
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [ci]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- id: artifact
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
|
||||||
run: |
|
|
||||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
|
|
||||||
[ -n "$artifact_id" ] || exit 0
|
|
||||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
|
||||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
|
||||||
[ -n "$pr_number" ] || exit 0
|
|
||||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
|
||||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
|
|
||||||
- name: Get build info
|
|
||||||
id: build-info
|
|
||||||
env:
|
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: |
|
|
||||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
|
||||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
|
||||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: comment Switch artifact
|
|
||||||
if: steps.artifact.outputs.pr_number != ''
|
|
||||||
uses: thollander/actions-comment-pull-request@v3
|
|
||||||
with:
|
|
||||||
message: |
|
|
||||||
[gen1recomp-switch.nro](${{ 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 }})
|
|
||||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
|
||||||
|
|
||||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
|
||||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
|
||||||
comment-tag: switch-build-result
|
|
||||||
github-token: ${{ github.token }}
|
|
||||||
@@ -23,10 +23,6 @@ read_globals = {
|
|||||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||||
table = { fields = { "unpack" } },
|
table = { fields = { "unpack" } },
|
||||||
"POKEPORT_DISPLAY_COMPANION",
|
|
||||||
"POKEPORT_EDITOR_MODE",
|
|
||||||
"rawlen",
|
|
||||||
package = { fields = { "searchers" } },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Vendored/native trees and the test suites have their own conventions.
|
-- Vendored/native trees and the test suites have their own conventions.
|
||||||
@@ -34,7 +30,6 @@ exclude_files = {
|
|||||||
"mobile/",
|
"mobile/",
|
||||||
"tests/",
|
"tests/",
|
||||||
"tools/save-editor/",
|
"tools/save-editor/",
|
||||||
"tools/save_convert/vendor/",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ignore = {
|
ignore = {
|
||||||
|
|||||||
@@ -140,17 +140,17 @@ ship text.
|
|||||||
|
|
||||||
### 4. `games` (and the legacy `gen2compat`)
|
### 4. `games` (and the legacy `gen2compat`)
|
||||||
|
|
||||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
||||||
overworld, script VM and save format. The mod API is shared across both
|
VM and save format. The mod API is shared across both generations (same hook
|
||||||
generations (same hook names, same event names, same registry names) but Gen 2
|
names, same event names, same registry names) but Gold cannot serve all of it
|
||||||
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"games": ["gen1", "gen2"]
|
"games": ["gen1", "gen2"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
||||||
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||||
@@ -159,10 +159,10 @@ gen1,gen2` writes the key for you. The mod still installs to one directory,
|
|||||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
||||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||||
have actually run your mod on Gold or Silver.
|
run your mod on Gold.
|
||||||
|
|
||||||
Every token is enforced, per game: the loader gates on the same
|
Every token is enforced, per game: the loader gates on the same
|
||||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||||
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
|
|||||||
when you mean everywhere.
|
when you mean everywhere.
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||||
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
|
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||||
writes with a report, and which hooks and events are still to come.
|
writes with a report, and which hooks and events are still to come.
|
||||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||||
|
|||||||
@@ -27,9 +27,15 @@ ask() { # ask "question" -> yes by default
|
|||||||
printf '\n \033[1mPokémon Red - LÖVE2D port\033[0m\n\n'
|
printf '\n \033[1mPokémon Red - LÖVE2D port\033[0m\n\n'
|
||||||
|
|
||||||
have_love() {
|
have_love() {
|
||||||
command -v love >/dev/null 2>&1 && return 0
|
local app version
|
||||||
[ -x "/Applications/love.app/Contents/MacOS/love" ] && return 0
|
for app in ".bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
|
||||||
[ -x "$HOME/Applications/love.app/Contents/MacOS/love" ] && return 0
|
"/Applications/love.app" "$HOME/Applications/love.app"; do
|
||||||
|
[ -x "$app/Contents/MacOS/love" ] || continue
|
||||||
|
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
printf '%s' "$version" | grep -Eq '^12(\.|$)' || continue
|
||||||
|
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
|
||||||
|
| grep -q '/Metal.framework/' && return 0
|
||||||
|
done
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,22 +63,11 @@ if ! command -v python3 >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ----------------------------------------------------------------- Homebrew
|
if ! have_love && ! command -v xcodebuild >/dev/null 2>&1; then
|
||||||
if ! have_love && ! command -v brew >/dev/null 2>&1 \
|
warn "LÖVE 12 is not installed and Xcode is required to build it for macOS."
|
||||||
&& [ ! -x /opt/homebrew/bin/brew ] && [ ! -x /usr/local/bin/brew ]; then
|
warn "Install Xcode from the App Store, launch it once, then run this again."
|
||||||
warn "LÖVE (the game engine) is not installed; the easiest installer is Homebrew"
|
pause_exit 1
|
||||||
if ask "Install Homebrew now? (asks for your macOS password)"; then
|
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \
|
|
||||||
|| { err "Homebrew install failed"; pause_exit 1; }
|
|
||||||
else
|
|
||||||
warn "OK, download LÖVE 11.x yourself from https://love2d.org,"
|
|
||||||
warn "drop love.app into /Applications, then run this again."
|
|
||||||
pause_exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
# make brew visible in THIS shell (fresh installs aren't on PATH yet)
|
|
||||||
[ -x /opt/homebrew/bin/brew ] && eval "$(/opt/homebrew/bin/brew shellenv)"
|
|
||||||
[ -x /usr/local/bin/brew ] && eval "$(/usr/local/bin/brew shellenv)"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- build
|
# ------------------------------------------------------------------- build
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -55,15 +55,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
|
|
||||||
[](https://youtu.be/yi7LkWQPKKM)
|
[](https://youtu.be/yi7LkWQPKKM)
|
||||||
|
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
|
||||||
Silver ROM is the only game content input.
|
is the only game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
The ROM is verified, used during import, and then released from memory. It is
|
||||||
not copied into the cache. Later launches load the private generated cache and
|
not copied into the cache. Later launches load the private generated cache and
|
||||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
||||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
||||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -71,14 +72,13 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
|||||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||||
game starts automatically.
|
game starts automatically.
|
||||||
|
|
||||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
||||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
accepted. The importer verifies SHA-1 before creating any game data:
|
||||||
|
|
||||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||||
sound effects, and cries are synthesized while the game runs from compact
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
@@ -194,8 +194,9 @@ By default the game keeps your save, options, and the private ROM-derived
|
|||||||
data cache in your OS's normal per-user app data folder. To keep everything
|
data cache in your OS's normal per-user app data folder. To keep everything
|
||||||
next to the game instead (handy for a USB stick or portable drive you carry
|
next to the game instead (handy for a USB stick or portable drive you carry
|
||||||
between computers), drop an empty file named `portable.txt` next to the app
|
between computers), drop an empty file named `portable.txt` next to the app
|
||||||
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
(next to `gen1recomp++.app` on macOS or `gen1recomp.exe` on Windows, or next
|
||||||
running from source), then launch the game. Portable mode is desktop-only
|
to `main.lua`/`conf.lua` when running from source), then launch the game.
|
||||||
|
Portable mode is desktop-only
|
||||||
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||||
runs from a read-only package.
|
runs from a read-only package.
|
||||||
|
|
||||||
@@ -219,7 +220,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
|||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -133,8 +133,6 @@ mkdir -p "$GAME_SRC"
|
|||||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
|
||||||
tools/rom_manifest_silver.json \
|
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ mkdir -p "$GAME_SRC"
|
|||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||||
printf '%s\n' "$payload_list" \
|
printf '%s\n' "$payload_list" \
|
||||||
@@ -100,8 +99,6 @@ printf '%s\n' "$payload_list" \
|
|||||||
&& fail "payload unexpectedly contains generated ROM data"
|
&& fail "payload unexpectedly contains generated ROM data"
|
||||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
|
||||||
|| fail "payload is missing tools/rom_manifest_silver.json"
|
|
||||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
rm -f "$WORK/game-payload.zip"
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
@@ -196,26 +193,6 @@ get_controls
|
|||||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||||
|
|
||||||
GAMEDIR="$SHDIR/gen1recomp"
|
GAMEDIR="$SHDIR/gen1recomp"
|
||||||
# Anbernic stock keeps the launcher and the game folder side by side, so the
|
|
||||||
# SHDIR-relative path above is correct there and is tried first.
|
|
||||||
#
|
|
||||||
# Other firmwares (muOS, and PortMaster's layout on several devices) keep
|
|
||||||
# launcher scripts and port data in SEPARATE trees -- scripts under roms/ports,
|
|
||||||
# data under ports -- so the sibling folder holds no game.
|
|
||||||
#
|
|
||||||
# Probe for the BINARY, not the directory: on a split layout this script has
|
|
||||||
# usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
|
|
||||||
# failed run (see mkdir/tee below), so an existence test matches a decoy of our
|
|
||||||
# own making. Stock is unaffected -- its sibling holds the real binary and wins
|
|
||||||
# on the first test.
|
|
||||||
if [ ! -f "$GAMEDIR/bin/love.aarch64" ]; then
|
|
||||||
for candidate in "/$directory/ports/gen1recomp" \
|
|
||||||
"/mnt/sdcard/ports/gen1recomp" \
|
|
||||||
"/mnt/mmc/ports/gen1recomp" \
|
|
||||||
"/roms/ports/gen1recomp"; do
|
|
||||||
if [ -f "$candidate/bin/love.aarch64" ]; then GAMEDIR="$candidate"; break; fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
CONFDIR="$GAMEDIR/conf"
|
CONFDIR="$GAMEDIR/conf"
|
||||||
mkdir -p "$CONFDIR"
|
mkdir -p "$CONFDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function love.conf(t)
|
|||||||
t.window.minwidth = 480
|
t.window.minwidth = 480
|
||||||
t.window.minheight = 360
|
t.window.minheight = 360
|
||||||
end
|
end
|
||||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
t.version = (love._os == "iOS" or love._os == "OS X") and "12.0" or "11.5"
|
||||||
t.window.vsync = 1
|
t.window.vsync = 1
|
||||||
t.modules.audio = not companion
|
t.modules.audio = not companion
|
||||||
t.modules.joystick = not companion
|
t.modules.joystick = not companion
|
||||||
|
|||||||
@@ -25,10 +25,8 @@
|
|||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
|
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||||
-- extractor now collects any top-level label in a dedicated text file
|
-- extractor never collects it and the pamphlet's text is inlined.
|
||||||
-- regardless (tools/extract/text.py), so this is the real ROM label --
|
|
||||||
-- the literal below is only the fallback for a catalog without it.
|
|
||||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||||
.. "There are 50 TMs\nin all.\f"
|
.. "There are 50 TMs\nin all.\f"
|
||||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||||
@@ -72,8 +70,7 @@ return {
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if fx == 3 and fy == 4 then
|
if fx == 3 and fy == 4 then
|
||||||
local text = game.data.text or {}
|
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||||
game.stack:push(TextBox.new(game, text.TMNotebookText or TM_NOTEBOOK_TEXT))
|
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ return {
|
|||||||
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
||||||
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
||||||
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
||||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
|
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
|
||||||
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
|
-- into data/generated/text.lua (no leading underscore in
|
||||||
-- collects them regardless (tools/extract/text.py); the literals
|
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
|
||||||
-- below are only the fallback for a catalog without them.
|
-- ported here verbatim.
|
||||||
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
||||||
@@ -27,16 +27,13 @@ return {
|
|||||||
local dish
|
local dish
|
||||||
if roll <= 2 then
|
if roll <= 2 then
|
||||||
-- bit 7 of hRandomAdd set (~50%)
|
-- bit 7 of hRandomAdd set (~50%)
|
||||||
dish = t.SSAnneKitchenCook7SalmonDuSaladText
|
dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
||||||
or "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
|
||||||
elseif roll == 3 then
|
elseif roll == 3 then
|
||||||
-- bit 4 set, bit 7 clear (~25%)
|
-- bit 4 set, bit 7 clear (~25%)
|
||||||
dish = t.SSAnneKitchenCook7EelsAuBarbecueText
|
dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
||||||
or "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
|
||||||
else
|
else
|
||||||
-- neither bit set (~25%)
|
-- neither bit set (~25%)
|
||||||
dish = t.SSAnneKitchenCook7PrimeBeefSteakText
|
dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
||||||
or "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
|
||||||
end
|
end
|
||||||
push(game, dish, done)
|
push(game, dish, done)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -60,23 +60,22 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- you want to know about the two kinds of caterpillar Pokemon;
|
-- you want to know about the two kinds of caterpillar Pokemon;
|
||||||
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
||||||
-- ViridianCityYoungster2OkThenText and
|
-- ViridianCityYoungster2OkThenText and
|
||||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
|
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||||
-- without a leading underscore in pokered/text/ViridianCity.asm, but
|
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||||
-- tools/extract/text.py now collects them regardless -- the literal
|
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||||
-- strings below are only the fallback for a catalog without them.
|
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||||
-- Those fallbacks have to carry the extractor's markers, not plain
|
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||||
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
|
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||||
-- \n and \n\n put all six lines on one page with nothing to wait on,
|
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||||
-- so the whole speech scrolled past without a button press (#250).
|
-- without a button press (#250).
|
||||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
push(game, t.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
|
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||||
or "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
|
||||||
else
|
else
|
||||||
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
|
push(game, "Oh, OK then!", done)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -38,22 +38,6 @@ local function retryTmGive(game, ow, victoryKey, done)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The badge line + its jingle, armed for the battle screen the way
|
|
||||||
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
|
|
||||||
local function badgeEndBattleText(game, victoryKey)
|
|
||||||
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
|
||||||
if not (reward and reward.dialogue) then return nil end
|
|
||||||
local text = game.data.text or {}
|
|
||||||
local pages = {}
|
|
||||||
for _, label in ipairs(reward.dialogue) do
|
|
||||||
if text[label] and text[label] ~= "" then
|
|
||||||
pages[#pages + 1] = text[label]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #pages == 0 then return nil end
|
|
||||||
return table.concat(pages, "\f"), reward.badgeSound
|
|
||||||
end
|
|
||||||
|
|
||||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||||
@@ -74,8 +58,7 @@ M.PEWTER_GYM.talk = {
|
|||||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||||
else
|
else
|
||||||
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
ow:engageTrainer(npc, done)
|
||||||
ow:engageTrainer(npc, done, text, nil, sound)
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -108,8 +91,7 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[adviceLabel] or fallback, finish))
|
game.data.text[adviceLabel] or fallback, finish))
|
||||||
else
|
else
|
||||||
local text, sound = badgeEndBattleText(game, victoryKey)
|
ow:engageTrainer(npc, done)
|
||||||
ow:engageTrainer(npc, done, text, nil, sound)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local back = function(text)
|
local back = function(text)
|
||||||
game.stack:push(TextBox.new(game, text, function()
|
game.stack:push(TextBox.new(game, text, function()
|
||||||
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
ow:scriptMove(ow.player, "down", 1, done)
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||||
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
|||||||
if shipLeft then
|
if shipLeft then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||||
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
|
|||||||
ask .. "\f"
|
ask .. "\f"
|
||||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
or "You need a ticket\nto get aboard."),
|
or "You need a ticket\nto get aboard."),
|
||||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
@@ -837,14 +837,13 @@ M.SILPH_CO_11F = {
|
|||||||
-- every Silph rocket leaves off-screen (the street rockets are
|
-- every Silph rocket leaves off-screen (the street rockets are
|
||||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||||
-- run here: the battle's own callbacks are still unwinding, so
|
-- run here: the battle's own callbacks are still unwinding, so
|
||||||
-- queueScript starts it on the first idle overworld frame (#722).
|
-- queueScript starts it on the first idle overworld frame --
|
||||||
|
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||||
|
-- pushes (#722).
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
ow:queueScript(silphAftermathRows())
|
ow:queueScript(silphAftermathRows())
|
||||||
end
|
end
|
||||||
end,
|
end, nil, true)
|
||||||
-- "Arrgh!!" is armed for the battle screen, not the map
|
|
||||||
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
|
|
||||||
game.data.text._SilphCo10FGiovanniILostAgainText, true)
|
|
||||||
end)
|
end)
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||||
function()
|
function()
|
||||||
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
ow:scriptMove(ow.player, back, 1)
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
@@ -769,7 +769,7 @@ M.MUSEUM_1F = {
|
|||||||
if y == 4 and (x == 9 or x == 10)
|
if y == 4 and (x == 9 or x == 10)
|
||||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
museumClerk(game, ow, nil, function()
|
museumClerk(game, ow, nil, function()
|
||||||
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
ow:scriptMove(ow.player, "down", 1)
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||||
-- so fleeing does not leave you standing on a cell that
|
-- so fleeing does not leave you standing on a cell that
|
||||||
-- immediately re-fires.
|
-- immediately re-fires.
|
||||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
ow:scriptMove(ow.player, "right", 1)
|
||||||
end
|
end
|
||||||
ow:afterBattle(result, battle)
|
ow:afterBattle(result, battle)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -216,10 +216,7 @@ local function dojoMasterGate(game, ow, x, y)
|
|||||||
if not master or ow:trainerDefeated(master) then return false end
|
if not master or ow:trainerDefeated(master) then return false end
|
||||||
ow.player.facing = "right"
|
ow.player.facing = "right"
|
||||||
master:facePlayer(ow.player)
|
master:facePlayer(ow.player)
|
||||||
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
|
ow:engageTrainer(master)
|
||||||
ow:engageTrainer(master, nil,
|
|
||||||
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
|
|
||||||
nil, nil, false)
|
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -734,7 +731,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[dontRunText] or "Don't run away!", function()
|
game.data.text[dontRunText] or "Don't run away!", function()
|
||||||
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
ow:scriptMove(ow.player, "up", 1)
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -122,8 +122,9 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
|||||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||||
-- leading underscore; tools/extract/text.py now collects it regardless,
|
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||||
-- so preFallback below is just the safety net for a catalog without it.
|
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||||
|
-- fallback; Yellow resolves the ROM string instead.
|
||||||
M.VIRIDIAN_CITY = {
|
M.VIRIDIAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||||
@@ -145,11 +146,9 @@ M.SILPH_CO_2F = {
|
|||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||||
-- the label carries no leading underscore (#393); collected like any
|
-- the label carries no leading underscore: pokered keeps this one in
|
||||||
-- other text/*.asm label now, preFallback is just the safety net
|
-- the script bank, not the far-text bank (#393)
|
||||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||||
preFallback = "Eeek!\nNo! Stop! Help!\fOh, you're not\nwith TEAM ROCKET."
|
|
||||||
.. "\vI thought...\vI'm sorry. Here,\vplease take this!",
|
|
||||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||||
@@ -240,7 +239,7 @@ local function stepGate(opts)
|
|||||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||||
ow.player.facing = opts.push
|
ow.player.facing = opts.push
|
||||||
if not ow:checkLedgeHop(opts.push) then
|
if not ow:checkLedgeHop(opts.push) then
|
||||||
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
ow:scriptMove(ow.player, opts.push, 1)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
@@ -647,29 +646,27 @@ end
|
|||||||
local rocketRows = {
|
local rocketRows = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||||
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
||||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||||
{ "jump_if_true", 10 }, -- 5
|
{ "jump_if_true", 9 }, -- 5
|
||||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||||
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||||
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
{ "jump_if_false", "end" }, -- 8
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||||
{ "jump_if_false", "end" }, -- 9
|
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||||
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
|
||||||
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
|
||||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||||
-- the same in story.lua; either route is enough).
|
-- the same in story.lua; either route is enough).
|
||||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||||
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
@@ -820,8 +817,7 @@ M.PEWTER_POKECENTER = {
|
|||||||
-- on the west-side cells and walks you back
|
-- on the west-side cells and walks you back
|
||||||
local function bikeGateGuard(coords, stopText, explainText)
|
local function bikeGateGuard(coords, stopText, explainText)
|
||||||
return function(game, ow, x, y)
|
return function(game, ow, x, y)
|
||||||
local bike = game.save.inventory.BICYCLE
|
if game.save.inventory.BICYCLE then return false end
|
||||||
if bike and bike ~= 0 then return false end
|
|
||||||
if not inCoords(coords, x, y) then return false end
|
if not inCoords(coords, x, y) then return false end
|
||||||
-- walk the player up to the tile beside the counter, no further:
|
-- walk the player up to the tile beside the counter, no further:
|
||||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||||
@@ -843,10 +839,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
-- (PlayerMovingRightScript). Without it the player was left
|
-- (PlayerMovingRightScript). Without it the player was left
|
||||||
-- parked beside the guard's counter with no way past. #518
|
-- parked beside the guard's counter with no way past. #518
|
||||||
local function shoveRight()
|
local function shoveRight()
|
||||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
ow:scriptMove(ow.player, "right", 1)
|
||||||
end
|
end
|
||||||
if dist > 0 then
|
if dist > 0 then
|
||||||
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||||
else
|
else
|
||||||
shoveRight()
|
shoveRight()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -105,9 +105,8 @@ trixie.
|
|||||||
This is a statement about the *compile environment*, not about where the
|
This is a statement about the *compile environment*, not about where the
|
||||||
artifact runs — building on your own newer distro would silently raise that
|
artifact runs — building on your own newer distro would silently raise that
|
||||||
floor and strand every user on an older one, with no symptom until they
|
floor and strand every user on an older one, with no symptom until they
|
||||||
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
|
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||||
both CI (`linux-arm64-build`) and the release workflow: the build fails if
|
required glibc symbol version climbs above 2.31.
|
||||||
the highest required glibc symbol version climbs above 2.31.
|
|
||||||
|
|
||||||
### Why five libraries are built from source
|
### Why five libraries are built from source
|
||||||
|
|
||||||
@@ -173,15 +172,13 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
|||||||
exclude list still classifies known sonames correctly, that AppRun still
|
exclude list still classifies known sonames correctly, that AppRun still
|
||||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||||
fires. Needs no container and no arm64 machine.
|
fires. Needs no container and no arm64 machine.
|
||||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
|
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||||
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
|
the artifact and asserts the layout, that every bundled object resolves
|
||||||
the layout, that every bundled object resolves under AppRun's
|
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||||
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
|
Uploads the AppImage for 7 days.
|
||||||
AppImage for 7 days.
|
|
||||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||||
`game.love` from the `love-payload` job, runs the same
|
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||||
`verify_appimage.sh` checks on the shipped image, and the AppImage is
|
published like every other release asset.
|
||||||
staged and published like every other release asset.
|
|
||||||
|
|
||||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||||
it runs on fork PRs too.
|
it runs on fork PRs too.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
|||||||
merged.** The write is taken, dropped, and named once per mod in the same
|
merged.** The write is taken, dropped, and named once per mod in the same
|
||||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||||
- **40 event names and 44 hook names have a call site in both generations**, so
|
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||||
reads those names back out of the source and fails if a site is renamed or
|
reads those names back out of the source and fails if a site is renamed or
|
||||||
deleted on either side, and fails again if a new shared site appears without
|
deleted on either side, and fails again if a new shared site appears without
|
||||||
@@ -55,10 +55,9 @@ The short version, for an author deciding what to write:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
||||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
||||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
`GameVersion.generation`, so nothing anywhere restates the game list.
|
||||||
`"gen2"` now expands to both Gold and Silver.
|
|
||||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||||
loader's gate reads.
|
loader's gate reads.
|
||||||
@@ -539,28 +538,15 @@ gains a field instead of the name gaining a prefix.
|
|||||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||||
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
|
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||||
`battle.turn_order`,
|
|
||||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||||
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
|
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||||
difference: Gen 1's vanilla
|
|
||||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||||
`ctx.battle.data` instead gets nil on Gold.
|
`ctx.battle.data` instead gets nil on Gold.
|
||||||
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
|
||||||
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
|
||||||
it silently, so one mod source can print a single summary line for a
|
|
||||||
party-wide award instead of a box per recipient. Gold honours it **only when
|
|
||||||
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
|
||||||
against a seam that always announced on Gold and keeps announcing there,
|
|
||||||
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
|
||||||
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
|
||||||
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
|
||||||
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
|
||||||
happen either way.
|
|
||||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||||
`pokemon.caught` once the mon is in the party or the box, and
|
`pokemon.caught` once the mon is in the party or the box, and
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
|||||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
|
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||||
@@ -122,40 +122,21 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
|||||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||||
`description` gives players dump or region guidance in the import panel.
|
`description` gives players dump or region guidance in the import panel.
|
||||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||||
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
per-import ceiling when an exact size is not appropriate. Every import also
|
||||||
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||||
use the launcher's streaming large-file path rather than being materialized as
|
filesystem reports an invalid size.
|
||||||
one Lua string.
|
|
||||||
|
|
||||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||||
mod: the launcher never scans or copies another mod's imported files merely
|
mod: the launcher never scans or copies another mod's imported files merely
|
||||||
because its manifest names the same digest. Small sources can still be read
|
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||||
with the existing scoped `mod:read` API, for example
|
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||||
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
|
||||||
`mod.imports` facade described below; no host path or new general filesystem
|
|
||||||
permission is exposed. Missing `required_imports` block the mod before its
|
permission is exposed. Missing `required_imports` block the mod before its
|
||||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||||
launcher panel but do not block loading.
|
launcher panel but do not block loading.
|
||||||
|
|
||||||
#### Bounded access to validated imports
|
|
||||||
|
|
||||||
A loaded mod can address only ids declared by its own `required_imports` or
|
|
||||||
`optional_imports` arrays:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local info, err = mod.imports:info("stadium2")
|
|
||||||
local header, err = mod.imports:read("stadium2", 0, 4096)
|
|
||||||
```
|
|
||||||
|
|
||||||
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
|
||||||
rechecks the stored import before exposing it, seeks into the engine-owned
|
|
||||||
copy, and never gives the mod a host path or file handle. This is intended for
|
|
||||||
large source formats whose table/index can be parsed with small reads before
|
|
||||||
selectively reading the payloads a transform actually needs.
|
|
||||||
|
|
||||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||||
@@ -460,28 +441,6 @@ default** (1x front, 2x back).
|
|||||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||||
mon still grows into place from the ball, grounded the whole way.
|
mon still grows into place from the ball, grounded the whole way.
|
||||||
|
|
||||||
## Installation-scoped generated cache
|
|
||||||
|
|
||||||
Generated data derived from a validated user source often belongs to the mod
|
|
||||||
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
|
||||||
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
|
||||||
local info = mod.cache:info("extract/v1/arena.bin")
|
|
||||||
mod.cache:delete("extract/v1/arena.bin")
|
|
||||||
```
|
|
||||||
|
|
||||||
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
|
|
||||||
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
|
|
||||||
The cache does not rewind with checkpoints and is not scoped to game version,
|
|
||||||
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
|
|
||||||
policy, and completion marker; the engine treats the bytes as opaque data.
|
|
||||||
|
|
||||||
Use `mod.storage` instead when the data belongs to one playthrough. Use
|
|
||||||
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
|
|
||||||
from a declared user source.
|
|
||||||
|
|
||||||
## Durable tool storage and runtime checkpoints
|
## Durable tool storage and runtime checkpoints
|
||||||
|
|
||||||
`mod.save` remains the right place for state that should travel with the next
|
`mod.save` remains the right place for state that should travel with the next
|
||||||
@@ -674,17 +633,6 @@ the selected indices. Mods remain responsible for selection policy and should
|
|||||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||||
contract and compatibility guarantees.
|
contract and compatibility guarantees.
|
||||||
|
|
||||||
Both battle engines expose the guarded `battle.charge_required` hook when a
|
|
||||||
charge-capable move is selected for its initial turn and the active ruleset
|
|
||||||
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
|
||||||
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
|
||||||
skip only that initial charge and continue through the ordinary move pipeline;
|
|
||||||
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
|
||||||
the active ruleset already skips charging (for example, Gold Solarbeam in
|
|
||||||
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
|
||||||
by the engine. With no subscriber, the vanilla decision runs without building
|
|
||||||
the hook context.
|
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Persistent custom options** stored separately from game saves
|
* **Persistent custom options** stored separately from game saves
|
||||||
* **Optional widescreen battle layout**
|
* **Optional widescreen battle layout**
|
||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
|
||||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
|
||||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
|||||||
|
|
||||||
| token | means |
|
| token | means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
|
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||||
| `"all"` | every game this engine has |
|
| `"all"` | every game this engine has |
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# What This Port Requires
|
# What This Port Requires
|
||||||
|
|
||||||
The packaged desktop app requires one user-supplied input on first boot: a
|
The packaged desktop app requires one user-supplied input on first boot: a
|
||||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM, or a canonical 2 MiB US
|
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||||
Pokemon Gold or Silver ROM.
|
|
||||||
|
|
||||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||||
@@ -16,8 +15,7 @@ Python and Pillow are not required by the packaged app.
|
|||||||
|
|
||||||
Assembly removes high-level names and some relationships that the Lua port
|
Assembly removes high-level names and some relationships that the Lua port
|
||||||
needs. The version-specific files `tools/rom_manifest.json`,
|
needs. The version-specific files `tools/rom_manifest.json`,
|
||||||
`tools/rom_manifest_blue.json`, `tools/rom_manifest_yellow.json`,
|
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||||
`tools/rom_manifest_gold.json`, and `tools/rom_manifest_silver.json` therefore
|
|
||||||
contain:
|
contain:
|
||||||
|
|
||||||
- the ROM symbol addresses actually read by the extractor
|
- the ROM symbol addresses actually read by the extractor
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
|
|
||||||
the existing launcher and public mod API still assume imported bytes are small:
|
|
||||||
|
|
||||||
* the Windows desktop picker stages a selected required import through a fixed
|
|
||||||
`%TEMP%/pokeport_required_import.bin` path before validation;
|
|
||||||
* the fallback import path materializes the selected file as one Lua string;
|
|
||||||
* after validation a mod can only use `mod:read("baseroms/...")`, which also
|
|
||||||
materializes the whole file;
|
|
||||||
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
|
|
||||||
not an appropriate home for a one-time generated asset cache shared by every
|
|
||||||
save using the same installed mod.
|
|
||||||
|
|
||||||
This makes optical-disc-sized user sources impractical even though the manifest
|
|
||||||
schema already accepts them. A failed temporary staging copy can also turn a
|
|
||||||
valid large source into a smaller temporary file and produce a misleading
|
|
||||||
"wrong file size" rejection.
|
|
||||||
|
|
||||||
A mod should be able to consume its own already-validated source incrementally
|
|
||||||
and compile derived runtime data once, without receiving a host path or general
|
|
||||||
filesystem access.
|
|
||||||
|
|
||||||
## Decision being extended
|
|
||||||
|
|
||||||
This extends the same legal/sandbox direction as **D11 asset transforms**
|
|
||||||
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
|
|
||||||
user-owned sources rather than shipping ROM-derived data. It also follows the
|
|
||||||
**D14 parity-gate** contract referenced by `tests/harness.lua` and
|
|
||||||
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
|
|
||||||
extension points ship public-API coverage, no-mod parity coverage, and docs in
|
|
||||||
the same change.
|
|
||||||
|
|
||||||
The historical D11 plan document is referenced by source comments but is not
|
|
||||||
present in the current repository tree; this RFC is the checked-in design
|
|
||||||
record for the new surface.
|
|
||||||
|
|
||||||
## Exact API delta
|
|
||||||
|
|
||||||
No manifest field changes. Existing `required_imports` and `optional_imports`
|
|
||||||
remain the declaration/validation authority.
|
|
||||||
|
|
||||||
Two additive facades are added to the `mod` object.
|
|
||||||
|
|
||||||
### `mod.imports`
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local info, err = mod.imports:info("source_id")
|
|
||||||
local bytes, err = mod.imports:read("source_id", offset, length)
|
|
||||||
```
|
|
||||||
|
|
||||||
* `source_id` must name an import declared by the calling mod.
|
|
||||||
* the import is rechecked through `RequiredImports.validateStored` before it is
|
|
||||||
exposed, so missing, replaced, or invalid optional imports are not readable;
|
|
||||||
* `offset` and `length` are zero-based byte coordinates;
|
|
||||||
* one read is capped at 8 MiB;
|
|
||||||
* no host path or file handle is returned;
|
|
||||||
* production reads seek into the engine-owned stored copy instead of reading
|
|
||||||
the whole source.
|
|
||||||
|
|
||||||
`info()` returns declaration metadata plus stored size. It does not expose a
|
|
||||||
host path.
|
|
||||||
|
|
||||||
### `mod.cache`
|
|
||||||
|
|
||||||
```lua
|
|
||||||
mod.cache:write("extract/v1/model.bin", bytes)
|
|
||||||
local bytes = mod.cache:read("extract/v1/model.bin")
|
|
||||||
local info = mod.cache:info("extract/v1/model.bin")
|
|
||||||
mod.cache:delete("extract/v1/model.bin")
|
|
||||||
```
|
|
||||||
|
|
||||||
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
|
|
||||||
backend, and is independent of game version, launcher slot, and playthrough.
|
|
||||||
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
|
|
||||||
escapes remain unavailable. A single cache write is capped at 64 MiB so large
|
|
||||||
generated datasets are naturally split into independently replaceable files.
|
|
||||||
|
|
||||||
The engine does not interpret cache bytes. Mods own generated-format versioning,
|
|
||||||
fingerprints, transactional completion markers, and rebuild policy.
|
|
||||||
|
|
||||||
## Launcher/import transport delta
|
|
||||||
|
|
||||||
For large raw required imports:
|
|
||||||
|
|
||||||
1. desktop pickers return the original selected path instead of staging it
|
|
||||||
through a fixed temporary file;
|
|
||||||
2. the engine opens that source itself;
|
|
||||||
3. bytes are copied directly to the existing engine-owned
|
|
||||||
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
|
|
||||||
4. MD5 is updated incrementally during the copy;
|
|
||||||
5. the normal size/MD5 validation receipt is written only after the complete
|
|
||||||
destination passes validation;
|
|
||||||
6. partial destinations are removed on short reads, write failure, size
|
|
||||||
mismatch, or digest mismatch.
|
|
||||||
|
|
||||||
N64 imports stay on the existing canonicalization path because byte-order and
|
|
||||||
copier-header normalization require transformation rather than a raw copy.
|
|
||||||
|
|
||||||
If a validation receipt for an already-stored large raw import is missing, the
|
|
||||||
engine rebuilds it with streaming MD5 rather than a whole-file read.
|
|
||||||
|
|
||||||
## Backward compatibility / migration
|
|
||||||
|
|
||||||
**Existing mods do nothing.** This is additive.
|
|
||||||
|
|
||||||
* manifest v1/v2 fields are unchanged;
|
|
||||||
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
|
|
||||||
retain their existing behavior;
|
|
||||||
* small required imports retain the existing in-memory validation path;
|
|
||||||
* N64 imports retain canonicalization and existing accepted byte orders;
|
|
||||||
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
|
|
||||||
files and observes no new behavior.
|
|
||||||
|
|
||||||
The mod API integer is not bumped because no existing member changes meaning or
|
|
||||||
shape.
|
|
||||||
|
|
||||||
## Security and legal posture
|
|
||||||
|
|
||||||
The launcher remains the authority that validates user-supplied bytes. The new
|
|
||||||
facade narrows access rather than widening it: a mod can read only ids declared
|
|
||||||
in its own manifest, only after validation, and only in bounded ranges. It does
|
|
||||||
not receive host paths, `io`, or a raw filesystem handle.
|
|
||||||
|
|
||||||
`mod.cache` is writable only beneath the calling mod's generated-cache root.
|
|
||||||
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
|
|
||||||
continue to enforce the existing legal posture.
|
|
||||||
|
|
||||||
## Parity guarantee
|
|
||||||
|
|
||||||
The change ships with:
|
|
||||||
|
|
||||||
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
|
|
||||||
`mod:read` load do not create cache data or change the old surface;
|
|
||||||
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
|
|
||||||
real `Loader` load, including bounded reads, undeclared/missing imports,
|
|
||||||
cache isolation, and traversal rejection;
|
|
||||||
* incremental MD5 vectors and a large-import streaming regression test;
|
|
||||||
* the existing engine suite, required-import suite, and mod lint gates.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# RFC 0011: Charge-required battle hook
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Proposed.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
A battle-mechanics mod can change damage through `battle.damage` and register
|
|
||||||
move effects, but it cannot conditionally skip the first turn of an existing
|
|
||||||
charge move. In Gen 1, the engine decides and stores the charge continuation
|
|
||||||
before any public effect callback can run. Reaching into `user.charging`,
|
|
||||||
`user.chargeReady`, or generation-specific volatile state is private,
|
|
||||||
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
|
|
||||||
|
|
||||||
Weather is the immediate example: a portable sun rule needs Solarbeam to
|
|
||||||
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
|
|
||||||
and secondary effects to the engine. The capability is generic and useful to
|
|
||||||
other ruleset and move-mechanics mods.
|
|
||||||
|
|
||||||
## Decision and plan extended
|
|
||||||
|
|
||||||
This implements **D-AT-002: charge-stage policy remains mod authority through a
|
|
||||||
generic guarded engine decision seam**. The consuming design is tracked in the
|
|
||||||
Adaptive Trainers implementation plan,
|
|
||||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
|
||||||
Task 8. The delta follows the additive, guarded hook convention documented by
|
|
||||||
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
|
|
||||||
Adaptive Trainers policy.
|
|
||||||
|
|
||||||
## Exact API delta
|
|
||||||
|
|
||||||
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
mod.hooks:wrap("battle.charge_required", function(next, ctx)
|
|
||||||
-- ctx = {
|
|
||||||
-- battle = live battle controller,
|
|
||||||
-- user = attacking battler,
|
|
||||||
-- target = defending battler,
|
|
||||||
-- move = merged move record,
|
|
||||||
-- charge = true,
|
|
||||||
-- isCalled = false,
|
|
||||||
-- }
|
|
||||||
if should_resolve_now(ctx) then return false end
|
|
||||||
return next(ctx)
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
The call site is the initial-use charge decision, after announcement and PP
|
|
||||||
handling but before charge state, invulnerability, charge animation, or charge
|
|
||||||
text is created. It runs only when the active engine rules would otherwise
|
|
||||||
require a charge. It does not run on the release turn. Returning exactly
|
|
||||||
`false` skips that initial charge and continues through the engine-owned move
|
|
||||||
pipeline. Any other downstream return preserves the charge. `isCalled` is true
|
|
||||||
when Metronome or Mirror Move selected the move.
|
|
||||||
|
|
||||||
Gold keeps its native sun decision first, so Solarbeam in native sun already
|
|
||||||
requires no charge and does not invoke the hook. Gen 1 link battles use the
|
|
||||||
shared Gen 1 move pipeline and therefore receive the same seam; normal link
|
|
||||||
mod-compatibility rules continue to govern deterministic peers.
|
|
||||||
|
|
||||||
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
|
|
||||||
subscriber, no hook payload table is allocated and the existing branch runs
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
## Migration and compatibility
|
|
||||||
|
|
||||||
Existing mods change nothing. The hook name and payload are additive. With no
|
|
||||||
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
|
|
||||||
charge state, PP use, text, animation, accuracy, damage, and native weather
|
|
||||||
behavior. Existing charge-move data and effect records require no migration.
|
|
||||||
|
|
||||||
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
|
|
||||||
skip this charge. It should not mutate private charge fields or re-run the move.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
|
|
||||||
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
|
|
||||||
release-turn behavior, called-move PP semantics, shared payload shape, and
|
|
||||||
native Gold sun behavior.
|
|
||||||
- The same test proves no-mod charge/release parity and replaces
|
|
||||||
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
|
|
||||||
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
|
|
||||||
chains preserve vanilla values and allocation behavior.
|
|
||||||
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
|
|
||||||
generations and keeps the compatibility reference list complete.
|
|
||||||
|
|
||||||
## Deprecation etiquette
|
|
||||||
|
|
||||||
Nothing is removed, renamed, superseded, or deprecated.
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Proposed.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
|
||||||
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
|
||||||
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
|
||||||
a mod that pays the whole party prints **one** summary line instead of a box
|
|
||||||
per recipient.
|
|
||||||
|
|
||||||
Gold accepts the argument and ignores it, as its own comment above the hook
|
|
||||||
call says. So the same mod source, running the same code, prints one line on
|
|
||||||
Red and six on Gold — one for the participant plus one for every bench mon it
|
|
||||||
paid.
|
|
||||||
|
|
||||||
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
|
||||||
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
|
||||||
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
|
||||||
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
|
||||||
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
|
||||||
into six boxes to click through.
|
|
||||||
|
|
||||||
There is no mod-side fix. The announcement is emitted inside
|
|
||||||
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
|
||||||
because the argument that means "quietly" is discarded. The only workaround is
|
|
||||||
to intercept the battle's event queue afterwards and delete the boxes, which is
|
|
||||||
what a mod written for this had to do — a mod reaching into engine internals to
|
|
||||||
undo something the public seam should never have done.
|
|
||||||
|
|
||||||
## Decision and plan extended
|
|
||||||
|
|
||||||
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
|
||||||
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
|
||||||
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
|
||||||
The third `applyShare` argument is the one part of that ctx whose meaning did
|
|
||||||
not survive the crossing, so the promise the catalog already makes is what this
|
|
||||||
change delivers.
|
|
||||||
|
|
||||||
The delta follows Route B's additive, guarded convention in
|
|
||||||
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
|
||||||
exists today changes behaviour.
|
|
||||||
|
|
||||||
## Exact API delta
|
|
||||||
|
|
||||||
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
|
||||||
|
|
||||||
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
|
||||||
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
|
||||||
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
|
||||||
| `applyShare(mon, split, true)` | announces | announces | announces |
|
|
||||||
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
|
||||||
|
|
||||||
The argument is honoured **only when it is actually passed**, decided by
|
|
||||||
argument count rather than by value:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local function applyShare(mon, split, ...)
|
|
||||||
local announce = ...
|
|
||||||
local silent = select("#", ...) > 0 and not announce
|
|
||||||
...
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
|
||||||
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
|
||||||
the first is a Gen 2-era call written against a seam that always announced, and
|
|
||||||
the second is a deliberate "pay this one quietly".
|
|
||||||
|
|
||||||
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
|
||||||
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
|
||||||
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
|
||||||
happen exactly as before, in the same order.
|
|
||||||
|
|
||||||
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
|
||||||
defaults to announcing, so both of the cart's own passes are untouched.
|
|
||||||
|
|
||||||
## Migration and compatibility
|
|
||||||
|
|
||||||
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
|
||||||
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
|
||||||
file is modified. The v1 surface — `content.X:register/override/get`,
|
|
||||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
|
||||||
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
|
||||||
calls this seam nor loads differently.
|
|
||||||
|
|
||||||
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
|
||||||
seam has always documented. Exp Share already does, and needs no edit to get
|
|
||||||
its own README's behaviour on Gold.
|
|
||||||
|
|
||||||
One residual difference is deliberate and now documented rather than silent: an
|
|
||||||
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
|
||||||
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
|
||||||
B rejects. Passing the argument makes the two generations agree, so the rule an
|
|
||||||
author needs is one sentence: *say what you mean and both games do the same
|
|
||||||
thing.*
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
|
||||||
checks it already had are unchanged — which is itself the vanilla-parity
|
|
||||||
evidence for this file.
|
|
||||||
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
|
||||||
participant still prints one line and the EXP.SHARE double pass still
|
|
||||||
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
|
||||||
the ctx allocation, and `vanillaAward` never passes `silent`.
|
|
||||||
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
|
||||||
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
|
||||||
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
|
||||||
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
|
||||||
are identical either way, and no `experience` event leaks into the queue on
|
|
||||||
a silent pass.
|
|
||||||
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
|
||||||
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
|
||||||
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
|
||||||
gate list moves.
|
|
||||||
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
|
||||||
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
|
||||||
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
|
||||||
|
|
||||||
## Docs with the change
|
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
|
||||||
existing `battle.low_health_alarm` payload note, in the same section that lists
|
|
||||||
`battle.exp_award` as shared — including the argument-count rule and the
|
|
||||||
residual difference above.
|
|
||||||
|
|
||||||
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
|
||||||
`tools/gen_registry_docs.lua` has nothing new to emit.
|
|
||||||
|
|
||||||
## Deprecation etiquette
|
|
||||||
|
|
||||||
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
|
||||||
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
|
||||||
docs name the explicit form as the one that behaves the same on both games.
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Touch skins and the Skin Studio
|
# Touch skins and the Skin Studio
|
||||||
|
|
||||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||||
control layout, and a screen-placement anchor. Engine:
|
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||||
(draw and input), `src/render/Renderer.lua` (screen placement),
|
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||||
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||||
`tests/engine/skin_studio_ux.lua`,
|
`tests/engine/skin_studio_ux.lua`,
|
||||||
`tests/engine/skin_studio_image_import.lua`,
|
`tests/engine/skin_studio_image_import.lua`,
|
||||||
@@ -13,10 +13,8 @@ control layout, and a screen-placement anchor. Engine:
|
|||||||
`tests/engine/launcher_skins_tab.lua`,
|
`tests/engine/launcher_skins_tab.lua`,
|
||||||
`tests/engine/launcher_skins_ux.lua`.
|
`tests/engine/launcher_skins_ux.lua`.
|
||||||
|
|
||||||
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||||
and is the one place that turns skin use off. **My Skins** holds the visual
|
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||||
grid, pagination, edit, delete and per-skin export actions.
|
|
||||||
`options.touchControls.skin` holds the folder name.
|
|
||||||
|
|
||||||
## Formats
|
## Formats
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ as-is. Supported keys:
|
|||||||
| `overlays` | page count |
|
| `overlays` | page count |
|
||||||
| `overlayN_name` | page name, the target of `next_target` |
|
| `overlayN_name` | page name, the target of `next_target` |
|
||||||
| `overlayN_overlay` | bezel image |
|
| `overlayN_overlay` | bezel image |
|
||||||
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
| `overlayN_full_screen` | stretch the page to the window |
|
||||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||||
@@ -100,8 +98,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
|
|||||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||||
window, and puts the Game Boy picture in the leftover space above -- the
|
window, and puts the Game Boy picture in the leftover space above -- the
|
||||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||||
game into it. Host functions map to
|
stretch to the window the way Delta does. Host functions map to
|
||||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||||
@@ -137,7 +135,7 @@ to decoration and never captures a touch.
|
|||||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||||
how a skin button reaches a mod hotkey.
|
how a skin button reaches a mod hotkey.
|
||||||
|
|
||||||
## Screen placement
|
## The screen viewport
|
||||||
|
|
||||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||||
@@ -148,17 +146,6 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
|||||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||||
|
|
||||||
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
|
||||||
the world, and the start menu stays at the hole's fit scale instead of
|
|
||||||
shrinking with the map.
|
|
||||||
|
|
||||||
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
|
||||||
as a controller deck: it is contained without deformation and pinned to the
|
|
||||||
bottom on taller screens. The spare space belongs to the game above it.
|
|
||||||
|
|
||||||
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
|
||||||
skin rather than the normal Center / Upper / Top setting.
|
|
||||||
|
|
||||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||||
channel and writes the rect.
|
channel and writes the rect.
|
||||||
@@ -166,8 +153,10 @@ channel and writes the rect.
|
|||||||
## Bezels versus pads
|
## Bezels versus pads
|
||||||
|
|
||||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||||
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||||
**desktop** as well as mobile; a gamepad does not hide them.
|
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||||
|
them. Anything that binds a button still follows the usual mobile /
|
||||||
|
`POKEPORT_TOUCH` rule.
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -201,32 +190,9 @@ shipping branding.
|
|||||||
|
|
||||||
## The studio
|
## The studio
|
||||||
|
|
||||||
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||||
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||||
and import actions, with each card owning Edit, Export and (for installed
|
because it wants a mouse, typed coordinates and room for an inspector.
|
||||||
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
|
||||||
New/Load workspace controls are deliberately not duplicated inside that editor.
|
|
||||||
|
|
||||||
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
|
||||||
compact lower tray: add/control binding, button and bezel artwork, pages,
|
|
||||||
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
|
||||||
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
|
||||||
**Detect this screen** (also on the tray) sets the mock device to the live
|
|
||||||
window size so a phone skin is authored at that phone's form factor rather
|
|
||||||
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
|
||||||
workspace so the screen hole can be dragged larger than the bezel while the
|
|
||||||
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
|
||||||
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
|
||||||
same controls that a mouse edits on desktop.
|
|
||||||
|
|
||||||
My Skins and the editor chrome sit inside the platform safe area (notch,
|
|
||||||
status bar, home indicator), the same inset the launcher uses. The mock
|
|
||||||
device still represents the full window, because a skin covers the whole
|
|
||||||
screen at play time.
|
|
||||||
|
|
||||||
The launcher’s **Turn skins off** button clears the selected skin and disables
|
|
||||||
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
|
||||||
is not itself a skin card.
|
|
||||||
|
|
||||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||||
phone proportions on a desktop monitor.
|
phone proportions on a desktop monitor.
|
||||||
@@ -234,7 +200,6 @@ phone proportions on a desktop monitor.
|
|||||||
| Preset | Size |
|
| Preset | Size |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||||
| This screen | the live window, so a phone is authored at its own height |
|
|
||||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||||
| Steam Deck | 1280x800 |
|
| Steam Deck | 1280x800 |
|
||||||
| Desktop 1080p | 1920x1080 |
|
| Desktop 1080p | 1920x1080 |
|
||||||
@@ -251,16 +216,13 @@ and of the page itself when it comes within a few pixels, and the guide it
|
|||||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||||
pressed images are per control; the bezel, the pages and the screen anchor are
|
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||||
per page. The SCREEN anchor is itself draggable and resizable; its default
|
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||||
shape is freeform, with an optional 10:9 lock.
|
|
||||||
|
|
||||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||||
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
||||||
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
||||||
as `key:` controls, so a mobile button invokes the exact same game path as
|
how a pipe bind like `left|down` is built without typing it.
|
||||||
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
|
||||||
which is how a pipe bind like `left|down` is built without typing it.
|
|
||||||
|
|
||||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||||
@@ -285,13 +247,12 @@ the **Import** button there and beside each row opens the host file picker (`src
|
|||||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||||
window does the same for whichever slot was last touched. A new bezel does not
|
window does the same for whichever slot was last touched. A new bezel does not
|
||||||
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
||||||
the art's alpha.
|
the art's alpha.
|
||||||
|
|
||||||
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||||
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||||
Clicking presses real Game Boy buttons and the footer reports what is held.
|
it, and boots the game with it.
|
||||||
**Play** saves the skin, selects it, and boots the game with it.
|
|
||||||
|
|
||||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ or the Switch-related workflow YAML), CI runs:
|
|||||||
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
|
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
|
||||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||||
(comment tag `switch-build-result`; see
|
(comment tag `platform-build-result`; see
|
||||||
`.github/workflows/switch-artifact-comment.yml`).
|
`.github/workflows/platform-artifact-comment.yml`).
|
||||||
|
|
||||||
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||||
|
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
|||||||
|
|
||||||
This project ships **no** game data. On first launch:
|
This project ships **no** game data. On first launch:
|
||||||
|
|
||||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
|
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
||||||
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All five can sit in the
|
launcher also shows the live save-dir path). All four can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
|
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
|
||||||
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
|
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
|
||||||
never imports from the Yellow tab (and vice versa). Gold and Silver are
|
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
||||||
Beta in the launcher; a clean US dump of either is enough to Play.
|
launcher; a clean US Gold dump is enough to Play.
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
@@ -111,12 +111,10 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||||
| Silver | `imports/saves/silver/` | `exports/silver/` |
|
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||||
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
|
Gold cart `.sav` import/export is not supported yet -- the folders exist so
|
||||||
exist so MTP browsing matches the other games. Gold and Silver progress still
|
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
||||||
saves in-engine.)
|
|
||||||
|
|
||||||
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,8 +54,7 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
|
||||||
`exports/<red|blue|yellow|gold|silver>/`
|
|
||||||
path the launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
|||||||
|
|
||||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||||
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
`-android.apk`) plus two assets the updater itself consumes:
|
||||||
PortMaster archives) plus two assets the updater itself consumes:
|
|
||||||
|
|
||||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||||
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||||
@@ -76,9 +75,8 @@ PortMaster archives) plus two assets the updater itself consumes:
|
|||||||
filename otherwise to match the asset name exactly.
|
filename otherwise to match the asset name exactly.
|
||||||
|
|
||||||
A release missing either asset is treated as "no in-place update available":
|
A release missing either asset is treated as "no in-place update available":
|
||||||
`Check` reports `needs_full`. It also selects the exact current platform asset
|
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||||
from the same release and persists the requirement, so it is visible again on
|
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||||
every launch, including offline launches.
|
|
||||||
|
|
||||||
## Save-directory layout
|
## Save-directory layout
|
||||||
|
|
||||||
@@ -87,7 +85,6 @@ Under the save directory (identity `pokemon-love2d`):
|
|||||||
```
|
```
|
||||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||||
updates/pending.txt crash-guard marker
|
updates/pending.txt crash-guard marker
|
||||||
updates/full-update.json persistent native-package requirement
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||||
@@ -109,8 +106,7 @@ bundled game, in that case.
|
|||||||
against the GitHub releases API; safe to call every frame, it is a no-op
|
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||||
once a check is in flight or has reached a terminal state. `Check.state()`
|
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||||
reports `idle | checking | uptodate | available | downloading | ready |
|
reports `idle | checking | uptodate | available | downloading | ready |
|
||||||
needs_full | full_downloading | full_ready | error` plus the latest version,
|
needs_full | error` plus the latest version and download progress.
|
||||||
download progress, and (when applicable) the selected full-package asset.
|
|
||||||
3. **Download + verify**: on `available`, `Check.download()` tells the
|
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||||
worker to fetch the payload, polling the growing `.part` file for
|
worker to fetch the payload, polling the growing `.part` file for
|
||||||
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||||
@@ -121,17 +117,6 @@ bundled game, in that case.
|
|||||||
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||||
player relaunches; the next launch's Boot step (1) is what actually
|
player relaunches; the next launch's Boot step (1) is what actually
|
||||||
mounts and runs it. There is no in-session hot-swap.
|
mounts and runs it. There is no in-session hot-swap.
|
||||||
5. **Native-package requirement**: when `minShell` or `payloadHost` is
|
|
||||||
incompatible, the worker writes `full-update.json` and surfaces a
|
|
||||||
persistent launcher control. Android downloads the release APK, verifies
|
|
||||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
|
||||||
Installer. The installer asks the user for consent and enforces package,
|
|
||||||
version-code, and signing-certificate compatibility. A legacy APK without
|
|
||||||
the installer bridge links its full package for one manual bootstrap
|
|
||||||
update, including when its downloaded payload already reports the latest
|
|
||||||
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
|
||||||
desktop, and PortMaster builds link their correctly named full package.
|
|
||||||
Switch keeps its native OTA flow.
|
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -155,13 +140,6 @@ bundled game, in that case.
|
|||||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||||
- **Android full updates are user-confirmed and certificate-bound.** The app
|
|
||||||
uses a private `FileProvider` cache path plus
|
|
||||||
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
|
|
||||||
"install unknown apps" setting, and never requests a silent install. The
|
|
||||||
release job must use the original long-lived Android signing key; a new key
|
|
||||||
causes Android to reject an in-place update and requires a one-time manual
|
|
||||||
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
|
|
||||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
|||||||
|
|
||||||
-- 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
|
||||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
local defaultErrorHandler = love.errorhandler
|
||||||
function love.errorhandler(msg)
|
function love.errorhandler(msg)
|
||||||
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||||
if ok and hint and type(msg) == "string" then
|
if hint and type(msg) == "string" then
|
||||||
msg = msg .. "\n\n" .. hint
|
msg = msg .. "\n\n" .. hint
|
||||||
end
|
end
|
||||||
if defaultErrorHandler then
|
if defaultErrorHandler then
|
||||||
@@ -82,43 +82,6 @@ end
|
|||||||
local editorHost, editorVersion, editorWindow
|
local editorHost, editorVersion, editorWindow
|
||||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||||
|
|
||||||
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
|
||||||
-- version session (save editor or game). closeEditor and returnToLauncher
|
|
||||||
-- both go through here so neither path can forget a singleton the other resets.
|
|
||||||
local function teardownMountedSession(version)
|
|
||||||
if version then
|
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
end
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
local Runtime = require("src.mods.Runtime")
|
|
||||||
if Runtime.reset then Runtime.reset() end
|
|
||||||
local Assets = require("src.render.Assets")
|
|
||||||
if Assets.installLoader then Assets.installLoader(nil) end
|
|
||||||
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
|
||||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Evict every save-editor module from package.loaded without a hardcoded
|
|
||||||
-- panel whitelist. Flat require names (App, Party, …) resolve under
|
|
||||||
-- tools/save-editor/; path-style keys may also appear. A key is flushed
|
|
||||||
-- when it names a save-editor path or when tools/save-editor/{panels/}K.lua
|
|
||||||
-- exists for a flat name K -- new panels are picked up automatically.
|
|
||||||
local function flushEditorPackageLoaded()
|
|
||||||
local fs = love and love.filesystem
|
|
||||||
local function isEditorFlat(name)
|
|
||||||
if not (fs and fs.getInfo) then return false end
|
|
||||||
if name:find("[./]") then return false end
|
|
||||||
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
|
||||||
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
|
||||||
end
|
|
||||||
for k in pairs(package.loaded) do
|
|
||||||
if type(k) == "string"
|
|
||||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
|
||||||
package.loaded[k] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||||
-- their directories have to be on the require path. It must be
|
-- their directories have to be on the require path. It must be
|
||||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||||
@@ -217,9 +180,9 @@ local function openEditor(version, slotId)
|
|||||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
if version then
|
||||||
teardownMountedSession(version)
|
require("src.import.CacheFs").unmountVersion(version)
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
end
|
end
|
||||||
flushEditorPackageLoaded()
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -234,17 +197,21 @@ end
|
|||||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||||
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
|
-- with this one's data.
|
||||||
-- next Edit or Play does not inherit the editor's dead mod loader.
|
|
||||||
function closeEditor()
|
function closeEditor()
|
||||||
local version = editorVersion
|
local version = editorVersion
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
if version then
|
||||||
teardownMountedSession(version)
|
require("src.import.CacheFs").unmountVersion(version)
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
|
end
|
||||||
|
for k in pairs(package.loaded) do
|
||||||
|
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||||
|
package.loaded[k] = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
flushEditorPackageLoaded()
|
|
||||||
editorVersion = nil
|
editorVersion = nil
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
@@ -335,8 +302,6 @@ local function makeLauncher()
|
|||||||
forceImport = forceImport,
|
forceImport = forceImport,
|
||||||
onEditSave = openEditor,
|
onEditSave = openEditor,
|
||||||
onEditTouchControls = openTouchControlsEditor,
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
|
||||||
-- Keep the compatibility predicate so external hosts using it still work.
|
|
||||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||||
and openSkinStudio or nil,
|
and openSkinStudio or nil,
|
||||||
})
|
})
|
||||||
@@ -362,15 +327,19 @@ local function returnToLauncher()
|
|||||||
if love.audio and love.audio.stop then
|
if love.audio and love.audio.stop then
|
||||||
pcall(love.audio.stop)
|
pcall(love.audio.stop)
|
||||||
end
|
end
|
||||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
teardownMountedSession(currentVersion)
|
if currentVersion then
|
||||||
|
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||||
if Game.reset then
|
|
||||||
pcall(function() Game:reset() end)
|
|
||||||
end
|
end
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
if Runtime.reset then
|
||||||
|
Runtime.reset()
|
||||||
|
end
|
||||||
|
|
||||||
Game = nil
|
Game = nil
|
||||||
autopilot = nil
|
autopilot = nil
|
||||||
driverCo = nil
|
driverCo = nil
|
||||||
@@ -417,12 +386,11 @@ function bootGame(version)
|
|||||||
love.window.setTitle(Version.title(
|
love.window.setTitle(Version.title(
|
||||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||||
end
|
end
|
||||||
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||||
-- tables, save shape and screen registry -- so Gold and Silver boot their
|
-- tables, save shape and screen registry -- so Gold boots its own service
|
||||||
-- own service owner, which mounts src/world/gen2 (walk / warps /
|
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
|
||||||
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
|
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
|
||||||
-- wiring.
|
if GameVersion.isGold() then
|
||||||
if GameVersion.generation() == 2 then
|
|
||||||
Game = require("src.core.Game2").new()
|
Game = require("src.core.Game2").new()
|
||||||
Game:load()
|
Game:load()
|
||||||
else
|
else
|
||||||
@@ -907,8 +875,6 @@ love.handlers = love.handlers or {}
|
|||||||
function love.handlers.audiosuspend()
|
function love.handlers.audiosuspend()
|
||||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||||
local Sound = package.loaded["src.core.Sound"]
|
|
||||||
if Sound then pcall(Sound.onDeviceReset) end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.handlers.audioreset()
|
function love.handlers.audioreset()
|
||||||
@@ -961,7 +927,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchpressed(id, x, y)
|
return TouchEditor.touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.touchpressed(id, x, y) end
|
if Studio then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||||
@@ -978,7 +944,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchmoved(id, x, y)
|
return TouchEditor.touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.touchmoved(id, x, y) end
|
if Studio then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
@@ -992,7 +958,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchreleased(id, x, y)
|
return TouchEditor.touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.touchreleased(id, x, y) end
|
if Studio then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
@@ -1045,12 +1011,7 @@ function love.mousepressed(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousepressed(x, y, button)
|
return TouchEditor.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
if Studio then
|
if Studio then return Studio.mousepressed(x, y, button) end
|
||||||
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
|
||||||
-- Studio consumes the real finger stream above, so discard the twin.
|
|
||||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
|
||||||
return Studio.mousepressed(x, y, button)
|
|
||||||
end
|
|
||||||
if Importer then
|
if Importer then
|
||||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||||
@@ -1085,10 +1046,7 @@ function love.mousereleased(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousereleased(x, y, button)
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
if Studio then
|
if Studio then return Studio.mousereleased(x, y, button) end
|
||||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
|
||||||
return Studio.mousereleased(x, y, button)
|
|
||||||
end
|
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
@@ -1106,10 +1064,7 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousemoved(x, y)
|
return TouchEditor.mousemoved(x, y)
|
||||||
end
|
end
|
||||||
if Studio then
|
if Studio then return Studio.mousemoved(x, y) end
|
||||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
|
||||||
return Studio.mousemoved(x, y)
|
|
||||||
end
|
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch then
|
if mouseTouch then
|
||||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ transport, exactly as a missing curl does.
|
|||||||
love-android 11.5a expects:
|
love-android 11.5a expects:
|
||||||
|
|
||||||
- **JDK 17**
|
- **JDK 17**
|
||||||
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
|
- Android SDK with **API 34**
|
||||||
- NDK **25.2.9519653** (Apple Silicon host supported)
|
- NDK **25.2.9519653** (Apple Silicon host supported)
|
||||||
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
|
||||||
|
|
||||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||||
@@ -110,10 +109,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
|||||||
|
|
||||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||||
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
|
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||||
Android packer verifies the Yellow, Gold, and Silver manifests before it
|
packer verifies the Yellow manifest before it packages; if a partial source
|
||||||
packages; if a partial source export omitted one, it restores the file from
|
export omitted it, it restores the file from this checkout's Git data and then
|
||||||
this checkout's Git data and then falls back to the project's GitHub copy. Generated game data,
|
falls back to the project's GitHub copy. Generated game data,
|
||||||
scripts, tests, and mobile build sources are excluded.
|
scripts, tests, and mobile build sources are excluded.
|
||||||
|
|
||||||
## Branding (applied by the build script)
|
## Branding (applied by the build script)
|
||||||
@@ -123,24 +122,15 @@ scripts, tests, and mobile build sources are excluded.
|
|||||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||||
| `app.name` | Pokemon Red |
|
| `app.name` | Pokemon Red |
|
||||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
|
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||||
`gen1recomp-<version>-android.apk`.
|
`PokemonRed-<version>-android.apk`.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
Production APKs are built with `scripts/build_android.sh --release`. They must
|
Signed with the default Android keystore (no setup required).
|
||||||
be signed with the same long-lived certificate as the currently installed app:
|
|
||||||
Android's Package Installer rejects an update with a different signing
|
|
||||||
certificate. Store that keystore and its passwords only in CI secrets, expose
|
|
||||||
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
|
|
||||||
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
|
|
||||||
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
|
|
||||||
created certificate cannot update users who have an APK signed by a different
|
|
||||||
legacy key; those users need one final manual reinstall before in-app updates
|
|
||||||
can take over.
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Quick Start:
|
|||||||
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
||||||
Android Studio bundles its own JDK 17.
|
Android Studio bundles its own JDK 17.
|
||||||
|
|
||||||
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
|
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
|
||||||
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ android {
|
|||||||
applicationId project.properties["app.application_id"]
|
applicationId project.properties["app.application_id"]
|
||||||
versionCode project.properties["app.version_code"].toInteger()
|
versionCode project.properties["app.version_code"].toInteger()
|
||||||
versionName project.properties["app.version_name"]
|
versionName project.properties["app.version_name"]
|
||||||
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
minSdk 16
|
||||||
// the native toolchain and package-installer bridge on a supported ABI.
|
compileSdk 34
|
||||||
minSdk 19
|
targetSdk 34
|
||||||
// Android 16 / API 36: current Android distribution target.
|
|
||||||
compileSdk 36
|
|
||||||
targetSdk 36
|
|
||||||
|
|
||||||
def getAppName = {
|
def getAppName = {
|
||||||
def nameArray = project.properties["app.name_byte_array"]
|
def nameArray = project.properties["app.name_byte_array"]
|
||||||
@@ -41,31 +38,10 @@ android {
|
|||||||
ORIENTATION:project.properties["app.orientation"],
|
ORIENTATION:project.properties["app.orientation"],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
// Release signing lives outside the repository. The release build script
|
|
||||||
// requires all five values below, while debug builds intentionally remain
|
|
||||||
// usable without them.
|
|
||||||
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
|
|
||||||
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
|
|
||||||
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
|
|
||||||
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
|
|
||||||
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
|
|
||||||
|
|
||||||
if (hasReleaseSigning) {
|
|
||||||
signingConfigs {
|
|
||||||
release {
|
|
||||||
storeFile file(releaseStore)
|
|
||||||
storePassword releaseStorePassword
|
|
||||||
keyAlias releaseKeyAlias
|
|
||||||
keyPassword releaseKeyPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
if (hasReleaseSigning) signingConfig signingConfigs.release
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flavorDimensions = ['mode', 'recording']
|
flavorDimensions = ['mode', 'recording']
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||||
scripts/build_android.sh must not strip this again. -->
|
scripts/build_android.sh must not strip this again. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- Required only to hand a checksum-verified, user-selected GitHub release
|
|
||||||
APK to Android's own Package Installer. Android still shows the install
|
|
||||||
confirmation and enforces package/signing-key/version compatibility. -->
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||||
counter, which Android 10+ gates behind this runtime permission.
|
counter, which Android 10+ gates behind this runtime permission.
|
||||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||||
@@ -39,22 +35,10 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.allow_multiple_resumed_activities"
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
<!-- The full-update APK is copied into this small cache subdirectory
|
|
||||||
before it is handed to Package Installer. Keep the provider private
|
|
||||||
and expose only that directory, never a storage root. -->
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.full_update_provider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/full_update_paths" />
|
|
||||||
</provider>
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity"
|
android:name="org.love2d.android.GameActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
android:label="${NAME}"
|
android:label="${NAME}"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:screenOrientation="${ORIENTATION}"
|
android:screenOrientation="${ORIENTATION}"
|
||||||
@@ -71,7 +55,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -8,5 +8,4 @@
|
|||||||
<color name="shortcut_blue">#1E88E5</color>
|
<color name="shortcut_blue">#1E88E5</color>
|
||||||
<color name="shortcut_yellow">#FDD835</color>
|
<color name="shortcut_yellow">#FDD835</color>
|
||||||
<color name="shortcut_gold">#D4AF37</color>
|
<color name="shortcut_gold">#D4AF37</color>
|
||||||
<color name="shortcut_silver">#BEC6D2</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
|
|
||||||
arbitrary app, external, or shared storage. -->
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<cache-path name="full_update" path="full-update/" />
|
|
||||||
</paths>
|
|
||||||
@@ -18,8 +18,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
|
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||||
classpath 'com.android.tools.build:gradle:8.9.2'
|
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ app.version_name=11.5a
|
|||||||
# No need to modify anything past this line!
|
# No need to modify anything past this line!
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.nonFinalResIds=true
|
android.nonFinalResIds=true
|
||||||
app.name=gen1recomp
|
app.name=gen1recomp
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ android {
|
|||||||
ndkVersion '25.2.9519653'
|
ndkVersion '25.2.9519653'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 19
|
minSdk 16
|
||||||
compileSdk 36
|
compileSdk 34
|
||||||
targetSdk 36
|
targetSdk 34
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||||
@@ -63,7 +63,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -283,40 +283,6 @@ bool restartApp()
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool installApk(const char *path)
|
|
||||||
{
|
|
||||||
if (path == nullptr || path[0] == '\0')
|
|
||||||
return false;
|
|
||||||
|
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
|
||||||
// This may be called from Lua's main thread, but use the activity object
|
|
||||||
// class just like httpDownload so a future worker caller does not depend on
|
|
||||||
// the system JNI class loader finding the app class.
|
|
||||||
void *rawActivity = SDL_AndroidGetActivity();
|
|
||||||
if (rawActivity == nullptr)
|
|
||||||
return false;
|
|
||||||
jobject activityObj = (jobject) rawActivity;
|
|
||||||
jclass activity = env->GetObjectClass(activityObj);
|
|
||||||
env->DeleteLocalRef(activityObj);
|
|
||||||
|
|
||||||
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
|
||||||
if (method == nullptr)
|
|
||||||
{
|
|
||||||
env->ExceptionClear();
|
|
||||||
env->DeleteLocalRef(activity);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
jstring jpath = env->NewStringUTF(path);
|
|
||||||
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
|
||||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
|
||||||
env->DeleteLocalRef(jroot);
|
|
||||||
env->DeleteLocalRef(jpath);
|
|
||||||
env->DeleteLocalRef(activity);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||||
{
|
{
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
|||||||
@@ -90,12 +90,6 @@ bool syncHealthSteps();
|
|||||||
**/
|
**/
|
||||||
bool restartApp();
|
bool restartApp();
|
||||||
|
|
||||||
/**
|
|
||||||
* Stages a checksum-verified APK from the current save directory and starts
|
|
||||||
* Android's user-confirmed Package Installer flow. Android-only.
|
|
||||||
**/
|
|
||||||
bool installApk(const char *path);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||||
**/
|
**/
|
||||||
|
|||||||
@@ -245,16 +245,6 @@ bool System::restartApp() const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool System::installApk(const char *path) const
|
|
||||||
{
|
|
||||||
#ifdef LOVE_ANDROID
|
|
||||||
return love::android::installApk(path);
|
|
||||||
#else
|
|
||||||
LOVE_UNUSED(path);
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -143,9 +143,6 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool restartApp() const;
|
virtual bool restartApp() const;
|
||||||
|
|
||||||
/** Starts Android's user-confirmed install flow for a verified APK. */
|
|
||||||
virtual bool installApk(const char *path) const;
|
|
||||||
|
|
||||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||||
virtual std::string getLaunchGame() const;
|
virtual std::string getLaunchGame() const;
|
||||||
|
|
||||||
|
|||||||
@@ -132,13 +132,6 @@ int w_restartApp(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
int w_installApk(lua_State *L)
|
|
||||||
{
|
|
||||||
const char *path = luaL_checkstring(L, 1);
|
|
||||||
luax_pushboolean(L, instance()->installApk(path));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int w_httpDownload(lua_State *L)
|
int w_httpDownload(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *url = luaL_checkstring(L, 1);
|
const char *url = luaL_checkstring(L, 1);
|
||||||
@@ -332,7 +325,6 @@ static const luaL_Reg functions[] =
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
{ "installApk", w_installApk },
|
|
||||||
{ "updateShortcuts", w_updateShortcuts },
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
{ "getLaunchGame", w_getLaunchGame },
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import android.app.AlarmManager;
|
|||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.ClipData;
|
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
@@ -78,7 +77,6 @@ import android.view.*;
|
|||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
import androidx.core.content.FileProvider;
|
|
||||||
|
|
||||||
public class GameActivity extends SDLActivity {
|
public class GameActivity extends SDLActivity {
|
||||||
private static DisplayMetrics metrics = null;
|
private static DisplayMetrics metrics = null;
|
||||||
@@ -400,15 +398,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
secondaryHostResumed = false;
|
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
unregisterSecondaryDisplayListener();
|
||||||
teardownSecondaryDisplay();
|
|
||||||
secondaryEnabled = false;
|
|
||||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
|
||||||
unregisterAudioDeviceCallback();
|
unregisterAudioDeviceCallback();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
onHostDestroy();
|
onHostDestroy();
|
||||||
@@ -417,7 +411,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
secondaryHostResumed = false;
|
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
@@ -433,7 +426,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
secondaryHostResumed = true;
|
|
||||||
onHostResume();
|
onHostResume();
|
||||||
requestGameAudioFocus();
|
requestGameAudioFocus();
|
||||||
registerAudioDeviceCallback();
|
registerAudioDeviceCallback();
|
||||||
@@ -698,103 +690,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
return true; // unreachable, but keeps the JNI signature honest
|
return true; // unreachable, but keeps the JNI signature honest
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stages a verified release APK in cache and asks Android's Package
|
|
||||||
* Installer to update this package. This never silently installs an APK:
|
|
||||||
* the platform owns both the unknown-sources consent and final install
|
|
||||||
* confirmation. `updateRoot` comes from the native save directory and is
|
|
||||||
* checked before any file is read, so a Lua caller cannot turn this into a
|
|
||||||
* general-purpose local-file sharing bridge.
|
|
||||||
*/
|
|
||||||
@Keep
|
|
||||||
public static boolean installApk(final String sourcePath, final String updateRoot) {
|
|
||||||
final GameActivity self = (GameActivity) mSingleton;
|
|
||||||
if (self == null || sourcePath == null || updateRoot == null) return false;
|
|
||||||
final File source;
|
|
||||||
try {
|
|
||||||
source = new File(sourcePath).getCanonicalFile();
|
|
||||||
File root = new File(updateRoot, "updates").getCanonicalFile();
|
|
||||||
String rootPath = root.getPath() + File.separator;
|
|
||||||
if (!source.getPath().startsWith(rootPath)
|
|
||||||
|| !source.isFile() || source.length() == 0
|
|
||||||
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android 8+ lets the user decide whether this app is trusted to
|
|
||||||
// request package installs. Send them to the per-app setting first;
|
|
||||||
// they deliberately tap Install again after granting it.
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 26
|
|
||||||
&& !self.getPackageManager().canRequestPackageInstalls()) {
|
|
||||||
try {
|
|
||||||
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
|
||||||
Uri.parse("package:" + self.getPackageName()));
|
|
||||||
self.startActivity(settings);
|
|
||||||
return true;
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copying an APK can be large; keep both I/O and checksum-verified
|
|
||||||
// source access off the UI thread. The FileProvider exposes this cache
|
|
||||||
// child only after it has been fully written and renamed.
|
|
||||||
new Thread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
File stagedDir = new File(self.getCacheDir(), "full-update");
|
|
||||||
File partial = new File(stagedDir, "update.apk.part");
|
|
||||||
File staged = new File(stagedDir, "update.apk");
|
|
||||||
try {
|
|
||||||
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
|
|
||||||
copyFile(source, partial);
|
|
||||||
if (staged.exists() && !staged.delete()) return;
|
|
||||||
if (!partial.renameTo(staged)) return;
|
|
||||||
self.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() { launchPackageInstaller(self, staged); }
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
|
|
||||||
} finally {
|
|
||||||
if (partial.exists()) partial.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, "gen1recomp-apk-stage").start();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void copyFile(File source, File destination) throws IOException {
|
|
||||||
InputStream in = new BufferedInputStream(new FileInputStream(source));
|
|
||||||
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
|
|
||||||
try {
|
|
||||||
byte[] buffer = new byte[32768];
|
|
||||||
int count;
|
|
||||||
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
|
||||||
} finally {
|
|
||||||
try { out.close(); } catch (IOException ignored) {}
|
|
||||||
try { in.close(); } catch (IOException ignored) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void launchPackageInstaller(GameActivity activity, File apk) {
|
|
||||||
try {
|
|
||||||
Context context = activity.getApplicationContext();
|
|
||||||
Uri uri = FileProvider.getUriForFile(context,
|
|
||||||
context.getPackageName() + ".full_update_provider", apk);
|
|
||||||
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
|
||||||
install.setData(uri);
|
|
||||||
install.setClipData(ClipData.newRawUri("apk", uri));
|
|
||||||
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
activity.startActivity(install);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public static String getLaunchGame() {
|
public static String getLaunchGame() {
|
||||||
return initialGame != null ? initialGame : "";
|
return initialGame != null ? initialGame : "";
|
||||||
@@ -2038,7 +1933,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
private static volatile long secondaryRetryAfter;
|
private static volatile long secondaryRetryAfter;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
private static volatile boolean secondaryHostResumed = false;
|
|
||||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||||
private static volatile int dualScreenDisplayMode = -1;
|
private static volatile int dualScreenDisplayMode = -1;
|
||||||
private static volatile byte[] secondaryFrame;
|
private static volatile byte[] secondaryFrame;
|
||||||
@@ -2069,7 +1963,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on && secondaryHostResumed) {
|
if (on) {
|
||||||
self.refreshDualScreenDisplayMode();
|
self.refreshDualScreenDisplayMode();
|
||||||
self.registerSecondaryDisplayListener();
|
self.registerSecondaryDisplayListener();
|
||||||
rebindSecondaryDisplay();
|
rebindSecondaryDisplay();
|
||||||
@@ -2141,11 +2035,9 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void rebindSecondaryDisplay() {
|
private static void rebindSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||||
|| secondaryOutputIsPreferred(self)) return;
|
|
||||||
self.runOnUiThread(() -> {
|
self.runOnUiThread(() -> {
|
||||||
if (!secondaryHostResumed || !secondaryEnabled
|
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||||
|| secondaryOutputIsPreferred(self)) return;
|
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
});
|
});
|
||||||
@@ -2153,8 +2045,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void setupSecondaryDisplay() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||||
|| secondaryPresentation != null
|
|
||||||
|| secondaryActivity != null || secondaryActivityPending
|
|| secondaryActivity != null || secondaryActivityPending
|
||||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,160 +12,6 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
|
||||||
"version": "0.2.19",
|
|
||||||
"date": "2026-08-22",
|
|
||||||
"size": 13778435,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.18",
|
|
||||||
"date": "2026-08-21",
|
|
||||||
"size": 13772120,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.17",
|
|
||||||
"date": "2026-08-21",
|
|
||||||
"size": 13771903,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.16",
|
|
||||||
"date": "2026-08-21",
|
|
||||||
"size": 13769300,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.15",
|
|
||||||
"date": "2026-08-21",
|
|
||||||
"size": 13753007,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.14",
|
|
||||||
"date": "2026-08-20",
|
|
||||||
"size": 13749372,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.13",
|
|
||||||
"date": "2026-08-20",
|
|
||||||
"size": 13749377,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.12",
|
|
||||||
"date": "2026-08-20",
|
|
||||||
"size": 13737259,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Can’t sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.11",
|
|
||||||
"date": "2026-08-20",
|
|
||||||
"size": 13735190,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.10",
|
|
||||||
"date": "2026-08-19",
|
|
||||||
"size": 13662645,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.10/gen1recomp++-0.2.10-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #998 Jingles not finishing before game proceeds\n- #1472 Message for sending out Pokemon not closing automatically\n- #1526 No screen shake when getting poisoned\n- #1529 Messages missing when interacting with PC\n- #1530 No message for interacting with bikes in the bike shop\n- #1532 Thrash animation incomplete\n- #1534 Dialogue missing when switching out Pokemon\n- #1547 Save states can be used to bypass certain NPCs\n- #1549 Menu Cartridge 3D model has visual issues\n- #1550 Nugget Bridge Rocket repeating dialogue\n- #1551 No scripted dialogue after beating Nugget Bridge Rocket\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.9",
|
|
||||||
"date": "2026-08-19",
|
|
||||||
"size": 13656293,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.8",
|
|
||||||
"date": "2026-08-19",
|
|
||||||
"size": 13653911,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.7",
|
|
||||||
"date": "2026-08-18",
|
|
||||||
"size": 13597177,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.6",
|
|
||||||
"date": "2026-08-18",
|
|
||||||
"size": 13589036,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.6/gen1recomp++-0.2.6-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1393 [Launcher -> Mods] Only the pages that you manually clicked to are used for sorting\n- #1418 (Pokémon Gold) Framerate and void fill options missing\n- #1430 [Gold] Shop ui off because of a border\n- #1519 Poison damage after battle inconsistent\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.5",
|
|
||||||
"date": "2026-08-18",
|
|
||||||
"size": 13586158,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.4",
|
|
||||||
"date": "2026-08-18",
|
|
||||||
"size": 13582747,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.3",
|
|
||||||
"date": "2026-08-18",
|
|
||||||
"size": 13579254,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.2",
|
|
||||||
"date": "2026-08-18",
|
|
||||||
"size": 13575387,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.1",
|
|
||||||
"date": "2026-08-17",
|
|
||||||
"size": 13575320,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"date": "2026-08-17",
|
|
||||||
"size": 13575299,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.99",
|
|
||||||
"date": "2026-08-17",
|
|
||||||
"size": 11391467,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.98",
|
|
||||||
"date": "2026-08-16",
|
|
||||||
"size": 11380117,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "0.1.97",
|
"version": "0.1.97",
|
||||||
"date": "2026-08-16",
|
"date": "2026-08-16",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
a5522634f1e581a1ebab73bf3ab4bd7a853b7a3e
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
97d4465e8c81099f79696ea4b4bb8b1f9083bc1a
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
12.0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# macOS build
|
||||||
|
|
||||||
|
The desktop app uses the pinned LÖVE 12 runtime built from the LÖVE source
|
||||||
|
tree and the matching Apple dependency repository. The runtime enables Metal
|
||||||
|
and is fused into the branded `gen1recomp++.app` bundle.
|
||||||
|
|
||||||
|
Build the runtime and desktop app from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/build_love_macos.sh --fetch
|
||||||
|
LOVE_APP="$PWD/.bazinga/love12/love.app" scripts/build.sh mac --no-notarize --identity -
|
||||||
|
```
|
||||||
|
|
||||||
|
The source and dependency revisions are recorded in `LOVE_SOURCE_REF` and
|
||||||
|
`APPLE_DEPENDENCIES_REF`. Delete `.bazinga/love12/source` or use `--clean`
|
||||||
|
when changing those pins.
|
||||||
|
|
||||||
|
The packaged executable is `gen1recomp++` inside `gen1recomp++.app`, and the
|
||||||
|
bundle declares LÖVE 12.0 compatibility.
|
||||||
@@ -6,10 +6,9 @@
|
|||||||
#
|
#
|
||||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||||
# [--notary-profile NAME] [--no-notarize]
|
# [--notary-profile NAME] [--no-notarize]
|
||||||
# [--game-love PATH] # fuse a prebuilt payload (scripts/pack_love.sh) instead of packing one
|
|
||||||
# [--release] # ios only: release config instead of debug
|
# [--release] # ios only: release config instead of debug
|
||||||
#
|
#
|
||||||
# Output: dist/mac/gen1recomp-macos.zip
|
# Output: dist/mac/gen1recomp++-macos.zip
|
||||||
# dist/win/gen1recomp-win64.zip
|
# dist/win/gen1recomp-win64.zip
|
||||||
# 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
|
||||||
@@ -28,21 +27,41 @@ ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
|||||||
|
|
||||||
APP_NAME="gen1recomp"
|
APP_NAME="gen1recomp"
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||||
|
MAC_APP_NAME="gen1recomp++"
|
||||||
|
MAC_BUNDLE_ID="com.theboisclub.gen1recompplusplus"
|
||||||
LOVE_VERSION="11.5"
|
LOVE_VERSION="11.5"
|
||||||
|
LOVE_MAC_VERSION="$(tr -d '[:space:]' < "$ROOT/mobile/macos/LOVE_VERSION" 2>/dev/null || echo 12.0)"
|
||||||
|
LOVE_MAC_APP="$HERE/love12/love.app"
|
||||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
VERSION_EXPLICIT=false
|
VERSION_EXPLICIT=false
|
||||||
IDENTITY=""
|
IDENTITY=""
|
||||||
TARGET="all"
|
TARGET="all"
|
||||||
NOTARY_PROFILE="notary-profile"
|
NOTARY_PROFILE="notary-profile"
|
||||||
NOTARIZE=true
|
NOTARIZE=true
|
||||||
|
FETCH_MAC_RUNTIME=false
|
||||||
IOS_RELEASE=false
|
IOS_RELEASE=false
|
||||||
IOS_IPA=false
|
IOS_IPA=false
|
||||||
GAME_LOVE_IN=""
|
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
strip_bundle_metadata() {
|
||||||
|
local bundle="$1"
|
||||||
|
xattr -rc "$bundle"
|
||||||
|
xattr -rd com.apple.FinderInfo "$bundle" 2>/dev/null || true
|
||||||
|
xattr -rd 'com.apple.fileprovider.fpfs#P' "$bundle" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_love12_app() {
|
||||||
|
local app="$1" version
|
||||||
|
[ -x "$app/Contents/MacOS/love" ] || return 1
|
||||||
|
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
printf '%s' "$version" | grep -Eq '^12(\.|$)' || return 1
|
||||||
|
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
|
||||||
|
| grep -q '/Metal.framework/'
|
||||||
|
}
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
||||||
@@ -50,7 +69,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--identity) IDENTITY="$2"; shift ;;
|
--identity) IDENTITY="$2"; shift ;;
|
||||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||||
--no-notarize) NOTARIZE=false ;;
|
--no-notarize) NOTARIZE=false ;;
|
||||||
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
|
--fetch) FETCH_MAC_RUNTIME=true ;;
|
||||||
--release) IOS_RELEASE=true ;;
|
--release) IOS_RELEASE=true ;;
|
||||||
--ipa) IOS_IPA=true ;;
|
--ipa) IOS_IPA=true ;;
|
||||||
*) fail "unknown argument: $1" ;;
|
*) fail "unknown argument: $1" ;;
|
||||||
@@ -65,23 +84,16 @@ mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
|||||||
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||||
# love.filesystem's require path, so it has to live inside the archive.
|
# love.filesystem's require path, so it has to live inside the archive.
|
||||||
|
say "packing game.love"
|
||||||
LOVE_FILE="$WORK/game.love"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
if [ -n "$GAME_LOVE_IN" ]; then
|
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
|
||||||
[ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
|
# the vendored libs/flexlove tree it replaced is gone.
|
||||||
say "using prebuilt payload: $GAME_LOVE_IN"
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
cp "$GAME_LOVE_IN" "$LOVE_FILE"
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
else
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
say "packing game.love"
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
# the vendored libs/flexlove tree it replaced is gone.
|
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
|
||||||
main.lua conf.lua src data assets tools/save-editor \
|
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
|
||||||
tools/rom_manifest_silver.json \
|
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
|
||||||
fi
|
|
||||||
# Materialize the listing once and grep the file: piping unzip straight into
|
# Materialize the listing once and grep the file: piping unzip straight into
|
||||||
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
||||||
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
||||||
@@ -100,8 +112,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json; do
|
||||||
tools/rom_manifest_silver.json; do
|
|
||||||
grep -qxF "$required" "$LOVE_LISTING" \
|
grep -qxF "$required" "$LOVE_LISTING" \
|
||||||
|| fail "game.love is missing $required"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
@@ -116,26 +127,18 @@ say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
|||||||
# mistaken for a release. The stamp is then read back out of the archive and the
|
# mistaken for a release. The stamp is then read back out of the archive and the
|
||||||
# build fails if it did not take.
|
# build fails if it did not take.
|
||||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
if [ -n "$GAME_LOVE_IN" ]; then
|
say "stamping engine version $VERSION into game.love"
|
||||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
stamp_dir="$WORK/stamp"
|
||||||
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
rm -rf "$stamp_dir"
|
||||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
mkdir -p "$stamp_dir/src/core"
|
||||||
|| fail "prebuilt payload does not report engine $VERSION (pack it with pack_love.sh --version $VERSION)"
|
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||||
say "prebuilt payload already stamped: $VERSION"
|
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||||
else
|
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||||
say "stamping engine version $VERSION into game.love"
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
stamp_dir="$WORK/stamp"
|
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||||
rm -rf "$stamp_dir"
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
mkdir -p "$stamp_dir/src/core"
|
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
say "stamped engine version: $VERSION"
|
||||||
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
|
||||||
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
|
||||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
|
||||||
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
|
||||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
|
||||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
|
||||||
say "stamped engine version: $VERSION"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||||
fi
|
fi
|
||||||
@@ -169,24 +172,55 @@ make_ico() { # $1 = output .ico path
|
|||||||
# --------------------------------------------------------------- macOS
|
# --------------------------------------------------------------- macOS
|
||||||
build_mac() {
|
build_mac() {
|
||||||
say "building macOS app"
|
say "building macOS app"
|
||||||
local love_app="${LOVE_APP:-/Applications/love.app}"
|
local love_app="${LOVE_APP:-}"
|
||||||
[ -d "$love_app" ] || fail "LÖVE.app not found at $love_app (install it or set LOVE_APP=/path/to/love.app)"
|
if [ -z "$love_app" ]; then
|
||||||
|
for candidate in "$LOVE_MAC_APP" "/Applications/love12.app" "$HOME/Applications/love12.app" \
|
||||||
|
"/Applications/love.app" "$HOME/Applications/love.app"; do
|
||||||
|
if [ -d "$candidate" ] && valid_love12_app "$candidate"; then
|
||||||
|
love_app="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ ! -d "$love_app" ] && [ -z "${LOVE_APP:-}" ] && $FETCH_MAC_RUNTIME; then
|
||||||
|
"$ROOT/scripts/build_love_macos.sh" --fetch
|
||||||
|
love_app="$LOVE_MAC_APP"
|
||||||
|
fi
|
||||||
|
[ -d "$love_app" ] || fail "LÖVE 12 Metal app not found; run scripts/build_love_macos.sh --fetch or set LOVE_APP=/path/to/love.app"
|
||||||
|
local love_version
|
||||||
|
love_version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$love_app/Contents/Info.plist" 2>/dev/null || true)
|
||||||
|
printf '%s' "$love_version" | grep -Eq '^12(\.|$)' \
|
||||||
|
|| fail "macOS build requires LÖVE $LOVE_MAC_VERSION at $love_app (set LOVE_APP to a LÖVE 12 app)"
|
||||||
|
[ -f "$love_app/Contents/Frameworks/love.framework/love" ] \
|
||||||
|
|| fail "macOS build requires love.framework at $love_app"
|
||||||
|
otool -L "$love_app/Contents/Frameworks/love.framework/love" \
|
||||||
|
| grep -q '/Metal.framework/' \
|
||||||
|
|| fail "macOS build requires a LÖVE runtime linked to Metal at $love_app"
|
||||||
|
|
||||||
local out_app="$WORK/$APP_NAME.app"
|
local stage_dir="${MAC_STAGE_DIR:-${RUNNER_TEMP:-/tmp}/gen1recomp-mac-stage}"
|
||||||
|
local out_app="$stage_dir/$MAC_APP_NAME.app"
|
||||||
|
mkdir -p "$stage_dir"
|
||||||
rm -rf "$out_app"
|
rm -rf "$out_app"
|
||||||
cp -R "$love_app" "$out_app"
|
ditto --norsrc "$love_app" "$out_app"
|
||||||
|
|
||||||
|
local love_executable="$out_app/Contents/MacOS/love"
|
||||||
|
local app_executable="$out_app/Contents/MacOS/$MAC_APP_NAME"
|
||||||
|
[ -f "$love_executable" ] || fail "LÖVE app is missing Contents/MacOS/love"
|
||||||
|
mv "$love_executable" "$app_executable"
|
||||||
|
|
||||||
# drop any bundled placeholder .love and fuse ours in
|
# drop any bundled placeholder .love and fuse ours in
|
||||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||||
|
|
||||||
local plist="$out_app/Contents/Info.plist"
|
local plist="$out_app/Contents/Info.plist"
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleName $MAC_APP_NAME" "$plist" 2>/dev/null \
|
||||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$plist"
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $MAC_APP_NAME" "$plist"
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $MAC_APP_NAME" "$plist" 2>/dev/null \
|
||||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$plist"
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $MAC_APP_NAME" "$plist"
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $MAC_APP_NAME" "$plist" 2>/dev/null \
|
||||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$plist"
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleExecutable string $MAC_APP_NAME" "$plist"
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $MAC_BUNDLE_ID" "$plist" 2>/dev/null \
|
||||||
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $MAC_BUNDLE_ID" "$plist"
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$plist" 2>/dev/null \
|
||||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$plist"
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $VERSION" "$plist"
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
||||||
@@ -208,6 +242,11 @@ build_mac() {
|
|||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|
||||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
|
||||||
|
|
||||||
|
strip_bundle_metadata "$out_app"
|
||||||
|
|
||||||
|
rm -rf "$WORK/$MAC_APP_NAME.app"
|
||||||
|
ln -s "$out_app" "$WORK/$MAC_APP_NAME.app"
|
||||||
|
|
||||||
local id="$IDENTITY"
|
local id="$IDENTITY"
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
id="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/^[^"]*"(.*)"$/\1/' || true)"
|
id="$(security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/^[^"]*"(.*)"$/\1/' || true)"
|
||||||
@@ -229,9 +268,9 @@ build_mac() {
|
|||||||
warn "keychain profile '$NOTARY_PROFILE' not found/working, skipping notarization."
|
warn "keychain profile '$NOTARY_PROFILE' not found/working, skipping notarization."
|
||||||
warn "set it up with: xcrun notarytool store-credentials \"$NOTARY_PROFILE\" --apple-id ... --team-id ... --password ..."
|
warn "set it up with: xcrun notarytool store-credentials \"$NOTARY_PROFILE\" --apple-id ... --team-id ... --password ..."
|
||||||
else
|
else
|
||||||
local notarize_zip="$WORK/$APP_NAME-notarize.zip"
|
local notarize_zip="$stage_dir/$MAC_APP_NAME-notarize.zip"
|
||||||
rm -f "$notarize_zip"
|
rm -f "$notarize_zip"
|
||||||
(cd "$WORK" && ditto -c -k --keepParent "$APP_NAME.app" "$notarize_zip")
|
(cd "$stage_dir" && ditto -c -k --keepParent "$MAC_APP_NAME.app" "$notarize_zip")
|
||||||
say "submitting to Apple notary service (this can take a few minutes)"
|
say "submitting to Apple notary service (this can take a few minutes)"
|
||||||
xcrun notarytool submit "$notarize_zip" --keychain-profile "$NOTARY_PROFILE" --wait
|
xcrun notarytool submit "$notarize_zip" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||||
say "stapling notarization ticket"
|
say "stapling notarization ticket"
|
||||||
@@ -240,9 +279,11 @@ build_mac() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local zip_out="$DIST/mac/$APP_NAME-macos.zip"
|
strip_bundle_metadata "$out_app"
|
||||||
|
|
||||||
|
local zip_out="$DIST/mac/$MAC_APP_NAME-macos.zip"
|
||||||
rm -f "$zip_out"
|
rm -f "$zip_out"
|
||||||
(cd "$WORK" && ditto -c -k --sequesterRsrc --keepParent "$APP_NAME.app" "$zip_out")
|
(cd "$stage_dir" && ditto -c -k --norsrc --keepParent "$MAC_APP_NAME.app" "$zip_out")
|
||||||
say "macOS build: $zip_out"
|
say "macOS build: $zip_out"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
||||||
#
|
#
|
||||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||||
# --release build the production-signed release APK (requires the
|
|
||||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
|
||||||
# --package-only zip game.love + apply branding; skip gradle
|
# --package-only zip game.love + apply branding; skip gradle
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||||
# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653)
|
# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653)
|
||||||
# - JDK 17
|
# - JDK 17
|
||||||
#
|
#
|
||||||
# Output (after gradle):
|
# Output (after gradle):
|
||||||
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
# dist/android/debug/*.apk (convenience copy)
|
||||||
|
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -27,17 +26,13 @@ APP_NAME="gen1recomp"
|
|||||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
ANDROID_API="36"
|
|
||||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||||
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
|
||||||
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
RELEASE=false
|
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -47,12 +42,11 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--version) VERSION="$2"; shift ;;
|
--version) VERSION="$2"; shift ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
--release) RELEASE=true ;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,20p' "$0"
|
sed -n '2,20p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
@@ -66,22 +60,7 @@ if [ -n "$VERSION" ]; then
|
|||||||
rest="${VERSION#*.}"
|
rest="${VERSION#*.}"
|
||||||
minor="${rest%%.*}"
|
minor="${rest%%.*}"
|
||||||
patch="${rest##*.}"
|
patch="${rest##*.}"
|
||||||
# Reserve three digits for each lower component. This stays monotonic across
|
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||||
# 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside
|
|
||||||
# Android's signed 32-bit versionCode range for normal release versions.
|
|
||||||
if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then
|
|
||||||
fail "--version components exceed Android versionCode limits"
|
|
||||||
fi
|
|
||||||
VERSION_CODE=$((major * 1000000 + minor * 1000 + patch))
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $RELEASE; then
|
|
||||||
for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \
|
|
||||||
GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do
|
|
||||||
[ -n "${!var:-}" ] || fail "--release requires $var"
|
|
||||||
done
|
|
||||||
[ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \
|
|
||||||
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
@@ -198,54 +177,6 @@ ensure_gold_manifest() {
|
|||||||
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
||||||
}
|
}
|
||||||
|
|
||||||
silver_manifest_is_valid() {
|
|
||||||
local path="$1"
|
|
||||||
python3 - "$path" <<'PY'
|
|
||||||
import json, pathlib, sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
||||||
except (OSError, ValueError):
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
|
||||||
"49b163f7e57702bc939d642a18f591de55d92dae" else 1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_silver_manifest() {
|
|
||||||
local manifest="$ROOT/$SILVER_MANIFEST_RELATIVE"
|
|
||||||
local staged
|
|
||||||
staged="$(mktemp)"
|
|
||||||
|
|
||||||
if silver_manifest_is_valid "$manifest"; then
|
|
||||||
rm -f "$staged"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
warn "Silver import manifest is missing or invalid; recovering it before packaging"
|
|
||||||
if git -C "$ROOT" show "HEAD:$SILVER_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
|
||||||
&& silver_manifest_is_valid "$staged"; then
|
|
||||||
mkdir -p "$(dirname "$manifest")"
|
|
||||||
mv "$staged" "$manifest"
|
|
||||||
say "restored Silver import manifest from this checkout's Git data"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1 \
|
|
||||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
|
||||||
--output "$staged" "$SILVER_MANIFEST_URL" \
|
|
||||||
&& silver_manifest_is_valid "$staged"; then
|
|
||||||
mkdir -p "$(dirname "$manifest")"
|
|
||||||
mv "$staged" "$manifest"
|
|
||||||
say "downloaded Silver import manifest from the project repository"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$staged"
|
|
||||||
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||||
@@ -311,7 +242,6 @@ pack_game_love() {
|
|||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
ensure_yellow_manifest
|
ensure_yellow_manifest
|
||||||
ensure_gold_manifest
|
ensure_gold_manifest
|
||||||
ensure_silver_manifest
|
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
@@ -326,7 +256,6 @@ pack_game_love() {
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
# List once and match against the captured text: piping unzip straight into
|
# List once and match against the captured text: piping unzip straight into
|
||||||
@@ -348,8 +277,6 @@ pack_game_love() {
|
|||||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||||
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
||||||
|| fail "game.love is missing the Gold ROM import manifest"
|
|| fail "game.love is missing the Gold ROM import manifest"
|
||||||
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
|
||||||
|| fail "game.love is missing the Silver ROM import manifest"
|
|
||||||
# This gate exists because the launcher's UI toolkit once lived outside
|
# This gate exists because the launcher's UI toolkit once lived outside
|
||||||
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
||||||
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||||
@@ -408,18 +335,13 @@ require_android_sdk() {
|
|||||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||||
or create mobile/android/local.properties with:
|
or create mobile/android/local.properties with:
|
||||||
sdk.dir=/path/to/Android/sdk
|
sdk.dir=/path/to/Android/sdk
|
||||||
love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION
|
love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION
|
||||||
(see mobile/ANDROID.md)."
|
(see mobile/ANDROID.md)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export ANDROID_SDK_ROOT="$sdk"
|
export ANDROID_SDK_ROOT="$sdk"
|
||||||
export ANDROID_HOME="$sdk"
|
export ANDROID_HOME="$sdk"
|
||||||
|
|
||||||
if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then
|
|
||||||
fail "Android SDK platform android-$ANDROID_API is not installed.
|
|
||||||
Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager."
|
|
||||||
fi
|
|
||||||
|
|
||||||
local props="$ANDROID_DIR/local.properties"
|
local props="$ANDROID_DIR/local.properties"
|
||||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||||
@@ -436,12 +358,7 @@ require_android_sdk() {
|
|||||||
|
|
||||||
# --------------------------------------------------------------- gradle
|
# --------------------------------------------------------------- gradle
|
||||||
run_gradle() {
|
run_gradle() {
|
||||||
local variant="debug"
|
local task="assembleEmbedNoRecordDebug"
|
||||||
$RELEASE && variant="release"
|
|
||||||
# Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}).
|
|
||||||
local variant_title="Debug"
|
|
||||||
$RELEASE && variant_title="Release"
|
|
||||||
local task="assembleEmbedNoRecord$variant_title"
|
|
||||||
local build_dir="$ANDROID_DIR"
|
local build_dir="$ANDROID_DIR"
|
||||||
|
|
||||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||||
@@ -476,12 +393,12 @@ run_gradle() {
|
|||||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant"
|
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
||||||
if [ -d "$out_dir" ]; then
|
if [ -d "$out_dir" ]; then
|
||||||
say "APK output:"
|
say "APK output:"
|
||||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||||
|
|
||||||
local dist_dir="$DIST/$variant"
|
local dist_dir="$DIST/debug"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
MACOS_DIR="$ROOT/mobile/macos"
|
||||||
|
CACHE="$ROOT/.bazinga/love12"
|
||||||
|
SOURCE_DIR="${LOVE_SOURCE_DIR:-$CACHE/source}"
|
||||||
|
RUNTIME_APP="${LOVE_APP_OUTPUT:-$CACHE/love.app}"
|
||||||
|
BUILD_DIR="$CACHE/build"
|
||||||
|
LOVE_VERSION="$(tr -d '[:space:]' < "$MACOS_DIR/LOVE_VERSION")"
|
||||||
|
LOVE_SOURCE_REF="$(tr -d '[:space:]' < "$MACOS_DIR/LOVE_SOURCE_REF")"
|
||||||
|
APPLE_DEPENDENCIES_REF="$(tr -d '[:space:]' < "$MACOS_DIR/APPLE_DEPENDENCIES_REF")"
|
||||||
|
LOVE_SOURCE_REPO="${LOVE_SOURCE_REPO:-https://github.com/love2d/love.git}"
|
||||||
|
APPLE_DEPENDENCIES_REPO="${APPLE_DEPENDENCIES_REPO:-https://github.com/love2d/love-apple-dependencies.git}"
|
||||||
|
|
||||||
|
FETCH=false
|
||||||
|
CLEAN=false
|
||||||
|
|
||||||
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
strip_bundle_metadata() {
|
||||||
|
local bundle="$1"
|
||||||
|
xattr -rc "$bundle"
|
||||||
|
xattr -rd com.apple.FinderInfo "$bundle" 2>/dev/null || true
|
||||||
|
xattr -rd 'com.apple.fileprovider.fpfs#P' "$bundle" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--fetch) FETCH=true ;;
|
||||||
|
--clean) CLEAN=true ;;
|
||||||
|
-h|--help)
|
||||||
|
printf '%s\n' 'usage: scripts/build_love_macos.sh [--fetch] [--clean]'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) fail "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$(uname -s)" = "Darwin" ] || fail "macOS LÖVE builds require Darwin"
|
||||||
|
command -v git >/dev/null 2>&1 || fail "git is required to fetch LÖVE sources"
|
||||||
|
command -v xcodebuild >/dev/null 2>&1 || fail "xcodebuild is required to build LÖVE for macOS"
|
||||||
|
command -v xattr >/dev/null 2>&1 || fail "xattr is required to normalize the runtime bundle"
|
||||||
|
|
||||||
|
source_ready() {
|
||||||
|
[ -d "$SOURCE_DIR/platform/xcode/love.xcodeproj" ] \
|
||||||
|
&& [ -d "$SOURCE_DIR/platform/xcode/macosx/Frameworks/Lua.framework" ] \
|
||||||
|
&& [ -d "$SOURCE_DIR/platform/xcode/shared/Frameworks/SDL3.xcframework" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
source_is_pinned() {
|
||||||
|
[ "$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true)" = "$LOVE_SOURCE_REF" ] \
|
||||||
|
&& [ -f "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref" ] \
|
||||||
|
&& [ "$(tr -d '[:space:]' < "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref")" = "$APPLE_DEPENDENCIES_REF" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_repo() {
|
||||||
|
local repo_url="$1"
|
||||||
|
local ref="$2"
|
||||||
|
local destination="$3"
|
||||||
|
mkdir -p "$destination"
|
||||||
|
git -C "$destination" init -q
|
||||||
|
git -C "$destination" remote add origin "$repo_url"
|
||||||
|
git -C "$destination" fetch --depth 1 origin "$ref"
|
||||||
|
git -C "$destination" checkout --detach -q FETCH_HEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_sources() {
|
||||||
|
local tmp
|
||||||
|
mkdir -p "$CACHE"
|
||||||
|
tmp="$(mktemp -d "$CACHE/fetch.XXXXXX")"
|
||||||
|
say "fetching LÖVE source $LOVE_SOURCE_REF"
|
||||||
|
fetch_repo "$LOVE_SOURCE_REPO" "$LOVE_SOURCE_REF" "$tmp/love"
|
||||||
|
say "fetching Apple dependencies $APPLE_DEPENDENCIES_REF"
|
||||||
|
fetch_repo "$APPLE_DEPENDENCIES_REPO" "$APPLE_DEPENDENCIES_REF" "$tmp/dependencies"
|
||||||
|
mkdir -p "$tmp/love/platform/xcode/macosx/Frameworks" "$tmp/love/platform/xcode/shared"
|
||||||
|
cp -R "$tmp/dependencies/macOS/Frameworks/." "$tmp/love/platform/xcode/macosx/Frameworks/"
|
||||||
|
cp -R "$tmp/dependencies/shared/." "$tmp/love/platform/xcode/shared/"
|
||||||
|
rm -rf "$SOURCE_DIR"
|
||||||
|
mkdir -p "$(dirname "$SOURCE_DIR")"
|
||||||
|
mv "$tmp/love" "$SOURCE_DIR"
|
||||||
|
printf '%s\n' "$APPLE_DEPENDENCIES_REF" > "$SOURCE_DIR/.gen1recomp-apple-dependencies-ref"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
say "LÖVE source ready at $SOURCE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
if $CLEAN; then
|
||||||
|
[ -z "${LOVE_SOURCE_DIR:-}" ] \
|
||||||
|
|| fail "--clean cannot be used with LOVE_SOURCE_DIR"
|
||||||
|
rm -rf "$SOURCE_DIR" "$BUILD_DIR" "$RUNTIME_APP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! source_ready || ! source_is_pinned; then
|
||||||
|
if ! $FETCH; then
|
||||||
|
fail "pinned LÖVE 12 sources are missing at $SOURCE_DIR; run scripts/build_love_macos.sh --fetch"
|
||||||
|
fi
|
||||||
|
[ -z "${LOVE_SOURCE_DIR:-}" ] \
|
||||||
|
|| fail "LOVE_SOURCE_DIR is not the pinned LÖVE commit $LOVE_SOURCE_REF"
|
||||||
|
fetch_sources
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$SOURCE_DIR/platform/xcode/love.xcodeproj"
|
||||||
|
rm -rf "$BUILD_DIR" "$RUNTIME_APP"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
say "building LÖVE 12 macOS runtime"
|
||||||
|
xcodebuild \
|
||||||
|
-quiet \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-target love-macosx \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk macosx \
|
||||||
|
SYMROOT="$BUILD_DIR" \
|
||||||
|
OBJROOT="$BUILD_DIR/Intermediates" \
|
||||||
|
ARCHS="arm64 x86_64" \
|
||||||
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
|
MACOSX_DEPLOYMENT_TARGET=12.0 \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY=-
|
||||||
|
|
||||||
|
BUILT_APP="$BUILD_DIR/Release/love.app"
|
||||||
|
[ -d "$BUILT_APP" ] || fail "xcodebuild produced no LÖVE app at $BUILT_APP"
|
||||||
|
ditto --norsrc "$BUILT_APP" "$RUNTIME_APP"
|
||||||
|
strip_bundle_metadata "$RUNTIME_APP"
|
||||||
|
|
||||||
|
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$RUNTIME_APP/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
version_re="$(printf '%s' "$LOVE_VERSION" | sed 's/\./\\./g')"
|
||||||
|
printf '%s' "$version" | grep -Eq "^${version_re}(\.|$)" \
|
||||||
|
|| fail "built runtime reports LÖVE version '$version'"
|
||||||
|
[ -x "$RUNTIME_APP/Contents/MacOS/love" ] \
|
||||||
|
|| fail "built runtime is missing Contents/MacOS/love"
|
||||||
|
[ -f "$RUNTIME_APP/Contents/Frameworks/love.framework/love" ] \
|
||||||
|
|| fail "built runtime is missing love.framework"
|
||||||
|
otool -L "$RUNTIME_APP/Contents/Frameworks/love.framework/love" \
|
||||||
|
| grep -q '/Metal.framework/' \
|
||||||
|
|| fail "built LÖVE runtime is not linked to Metal"
|
||||||
|
|
||||||
|
archs="$(lipo -archs "$RUNTIME_APP/Contents/MacOS/love")"
|
||||||
|
printf '%s' "$archs" | grep -qw arm64 \
|
||||||
|
|| fail "built LÖVE app is missing arm64"
|
||||||
|
printf '%s' "$archs" | grep -qw x86_64 \
|
||||||
|
|| fail "built LÖVE app is missing x86_64"
|
||||||
|
framework_archs="$(lipo -archs "$RUNTIME_APP/Contents/Frameworks/love.framework/love")"
|
||||||
|
printf '%s' "$framework_archs" | grep -qw arm64 \
|
||||||
|
|| fail "built LÖVE framework is missing arm64"
|
||||||
|
printf '%s' "$framework_archs" | grep -qw x86_64 \
|
||||||
|
|| fail "built LÖVE framework is missing x86_64"
|
||||||
|
|
||||||
|
say "LÖVE 12 macOS runtime: $RUNTIME_APP"
|
||||||
@@ -7,8 +7,7 @@
|
|||||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||||
#
|
#
|
||||||
# scripts/lint.sh full advisory report over every shipped tree
|
# scripts/lint.sh lint src/
|
||||||
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
|
|
||||||
# scripts/lint.sh src tools lint specific paths
|
# scripts/lint.sh src tools lint specific paths
|
||||||
#
|
#
|
||||||
# Install once with: luarocks install luacheck
|
# Install once with: luarocks install luacheck
|
||||||
@@ -16,27 +15,9 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
|
|
||||||
|
|
||||||
GATE=0
|
|
||||||
if [ "${1:-}" = "--gate" ]; then
|
|
||||||
GATE=1
|
|
||||||
shift
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v luacheck >/dev/null 2>&1; then
|
if ! command -v luacheck >/dev/null 2>&1; then
|
||||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$#" -gt 0 ]; then
|
luacheck "${@:-src}"
|
||||||
PATHS=("$@")
|
|
||||||
else
|
|
||||||
PATHS=("${DEFAULT_PATHS[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$GATE" = "1" ]; then
|
|
||||||
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
|
|
||||||
fi
|
|
||||||
|
|
||||||
luacheck "${PATHS[@]}"
|
|
||||||
|
|||||||
@@ -366,8 +366,6 @@ cp "$IN/game.love" "$APPDIR/game.love"
|
|||||||
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
||||||
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||||
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
|
||||||
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
|
||||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||||
|
|||||||
@@ -169,7 +169,5 @@ unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
|||||||
|| fail "shared payload version was not stamped"
|
|| fail "shared payload version was not stamped"
|
||||||
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||||
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
|
||||||
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
|
||||||
|
|
||||||
say "Linux arm64 self-test passed"
|
say "Linux arm64 self-test passed"
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Verifies a built arm64 AppImage is self-contained and bullseye-compatible.
|
|
||||||
# Usage: scripts/linux-arm64/verify_appimage.sh <AppImage>
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
image="${1:?usage: verify_appimage.sh <AppImage>}"
|
|
||||||
[ -f "$image" ] || { echo "::error::no such AppImage: $image"; exit 1; }
|
|
||||||
image="$(cd "$(dirname "$image")" && pwd)/$(basename "$image")"
|
|
||||||
|
|
||||||
workdir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$workdir"' EXIT
|
|
||||||
cd "$workdir"
|
|
||||||
|
|
||||||
# --appimage-extract needs no FUSE, so this works on a runner
|
|
||||||
# without /dev/fuse and still exercises the real payload.
|
|
||||||
"$image" --appimage-extract >/dev/null
|
|
||||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
|
||||||
[ -e "squashfs-root/$required" ] \
|
|
||||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
|
||||||
# applied; an unresolved soname here is a user-visible launch crash.
|
|
||||||
#
|
|
||||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
|
||||||
# The first version of this build bundled Debian's SDL2, which
|
|
||||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
|
||||||
# started on a full desktop -- a bare runner is what exposed it.
|
|
||||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
|
||||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep 'not found' || true)"
|
|
||||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
|
||||||
|
|
||||||
# Nothing may hard-link a driver, session or audio-stack library:
|
|
||||||
# those must be reached through dlopen so the AppImage runs on a box
|
|
||||||
# with only ALSA, only Wayland, or only KMSDRM.
|
|
||||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
|
||||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
|
||||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
|
||||||
[ -z "$linked" ] \
|
|
||||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
|
||||||
|
|
||||||
# The whole point of compiling on bullseye. If a future change moves
|
|
||||||
# the builder to a newer base, the glibc floor silently rises and
|
|
||||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
|
||||||
# it here instead of in a release.
|
|
||||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
|
||||||
echo "highest required glibc symbol version: $floor"
|
|
||||||
[ -n "$floor" ] \
|
|
||||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
|
||||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
|
||||||
[ "$highest" = "GLIBC_2.31" ] \
|
|
||||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
|
||||||
|
|
||||||
echo "AppImage verified: $image"
|
|
||||||
@@ -50,7 +50,6 @@ rm -f "$OUTPUT"
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||||
@@ -95,7 +94,6 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
tools/rom_manifest_silver.json \
|
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
src/import/LauncherView.lua; do
|
src/import/LauncherView.lua; do
|
||||||
grep -qxF "$required" "$LISTING" \
|
grep -qxF "$required" "$LISTING" \
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
|
|
||||||
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
|
||||||
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
|
||||||
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
|
|
||||||
fail "generated data missing, run scripts/setup.sh first"
|
fail "generated data missing, run scripts/setup.sh first"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -34,8 +32,30 @@ find_love() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
LOVE_BIN="$(find_love)" \
|
find_love12() {
|
||||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
local app version
|
||||||
|
for app in "${LOVE_APP:-}" "$ROOT/.bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
|
||||||
|
"/Applications/love.app" "$HOME/Applications/love.app"; do
|
||||||
|
[ -n "$app" ] || continue
|
||||||
|
if [ -x "$app/Contents/MacOS/love" ]; then
|
||||||
|
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
if printf '%s' "$version" | grep -Eq '^12(\.|$)' \
|
||||||
|
&& otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null | grep -q '/Metal.framework/'; then
|
||||||
|
echo "$app/Contents/MacOS/love"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$(uname -s)" = "Darwin" ]; then
|
||||||
|
LOVE_BIN="$(find_love12)" \
|
||||||
|
|| fail "LÖVE 12 with Metal not found, run scripts/setup.sh or scripts/build_love_macos.sh --fetch"
|
||||||
|
else
|
||||||
|
LOVE_BIN="$(find_love)" \
|
||||||
|
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||||
|
fi
|
||||||
|
|
||||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||||
|
|||||||
@@ -70,11 +70,32 @@ find_love() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if LOVE_BIN="$(find_love)"; then
|
valid_love12_app() {
|
||||||
|
local app="$1" version
|
||||||
|
[ -x "$app/Contents/MacOS/love" ] || return 1
|
||||||
|
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
printf '%s' "$version" | grep -Eq '^12(\.|$)' || return 1
|
||||||
|
otool -L "$app/Contents/Frameworks/love.framework/love" 2>/dev/null \
|
||||||
|
| grep -q '/Metal.framework/'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$(uname -s)" = "Darwin" ]; then
|
||||||
|
if valid_love12_app "$ROOT/.bazinga/love12/love.app"; then
|
||||||
|
say "LÖVE 12 found: $ROOT/.bazinga/love12/love.app"
|
||||||
|
elif valid_love12_app "/Applications/love12.app"; then
|
||||||
|
say "LÖVE 12 found: /Applications/love12.app"
|
||||||
|
elif valid_love12_app "$HOME/Applications/love12.app"; then
|
||||||
|
say "LÖVE 12 found: $HOME/Applications/love12.app"
|
||||||
|
elif valid_love12_app "/Applications/love.app"; then
|
||||||
|
say "LÖVE 12 found: /Applications/love.app"
|
||||||
|
elif valid_love12_app "$HOME/Applications/love.app"; then
|
||||||
|
say "LÖVE 12 found: $HOME/Applications/love.app"
|
||||||
|
else
|
||||||
|
say "building LÖVE 12 for macOS"
|
||||||
|
"$ROOT/scripts/build_love_macos.sh" --fetch
|
||||||
|
fi
|
||||||
|
elif LOVE_BIN="$(find_love)"; then
|
||||||
say "LÖVE found: $LOVE_BIN"
|
say "LÖVE found: $LOVE_BIN"
|
||||||
elif [ "$(uname -s)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
|
|
||||||
say "installing LÖVE via Homebrew"
|
|
||||||
brew install --cask love
|
|
||||||
else
|
else
|
||||||
fail "LÖVE 11.x is not installed; install it from https://love2d.org"
|
fail "LÖVE 11.x is not installed; install it from https://love2d.org"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
||||||
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
|
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -103,8 +103,6 @@ write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
|||||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||||
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
|
||||||
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
@@ -113,8 +111,6 @@ write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
|||||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||||
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
|
||||||
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -144,12 +140,10 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -271,12 +271,10 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -66,17 +66,7 @@ run_tier() {
|
|||||||
|
|
||||||
# ------- ROM-free tiers: these are what CI runs
|
# ------- ROM-free tiers: these are what CI runs
|
||||||
|
|
||||||
if command -v luacheck >/dev/null 2>&1; then
|
|
||||||
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
|
|
||||||
./scripts/lint.sh --gate
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
|
|
||||||
echo " luarocks install luacheck; CI installs and gates on it regardless)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
|
||||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ local BattleSafety = {}
|
|||||||
local BATTLE_BUSY_FIELDS = {
|
local BATTLE_BUSY_FIELDS = {
|
||||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
"shrinkOut",
|
|
||||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,6 @@ end
|
|||||||
|
|
||||||
local function makeBattler(data, mon, isPlayer, save)
|
local function makeBattler(data, mon, isPlayer, save)
|
||||||
local def = data.pokemon[mon.species]
|
local def = data.pokemon[mon.species]
|
||||||
require("src.pokemon.Stats").ensure(def, mon)
|
|
||||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||||
local badges = nil
|
local badges = nil
|
||||||
if isPlayer and save then
|
if isPlayer and save then
|
||||||
@@ -1365,7 +1364,7 @@ function BattleState:updateQueue()
|
|||||||
-- subanimation (or just the coarse fx when animations are off).
|
-- subanimation (or just the coarse fx when animations are off).
|
||||||
-- item.hit carries the target's blink + damage sound, applied when
|
-- item.hit carries the target's blink + damage sound, applied when
|
||||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||||
-- Mimic, whose animation waits on a successful copy).
|
-- thrash/rage continuation turns that skip the announcement).
|
||||||
if item.anim or item.hitRow then
|
if item.anim or item.hitRow then
|
||||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||||
@@ -1719,14 +1718,9 @@ function BattleState:enter()
|
|||||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||||
-- playerMonFainted queues on the battle screen; there is no battle
|
-- playerMonFainted queues on the battle screen; there is no battle
|
||||||
-- screen to queue them on here, so they print over the map.
|
-- screen to queue them on here, so they print over the map.
|
||||||
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
|
|
||||||
-- a different ROM address and is unused anywhere in this engine -- not
|
|
||||||
-- a fallback for this one, just pokered printing the same paragraph
|
|
||||||
-- from a second call site elsewhere.
|
|
||||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||||
self:romText("_PlayerBlackedOutText2",
|
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||||
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||||
blackedOut))
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
@@ -1863,8 +1857,7 @@ function BattleState:enter()
|
|||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||||
self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||||
foeName, self.enemy.name))
|
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -2496,12 +2489,9 @@ function BattleState:openOldManBag()
|
|||||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||||
-- the Viridian tutorial and Oak's catch, has one.
|
-- the Viridian tutorial and Oak's catch, has one.
|
||||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||||
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
|
|
||||||
-- screen (engine/battle/core.asm:2210)
|
|
||||||
list = ListMenu.new(game, "ITEMS", {
|
list = ListMenu.new(game, "ITEMS", {
|
||||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||||
}, {
|
}, {
|
||||||
itemBox = true,
|
|
||||||
script = function(l)
|
script = function(l)
|
||||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||||
if l.scriptTimer == 81 then
|
if l.scriptTimer == 81 then
|
||||||
@@ -2712,11 +2702,9 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||||
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
|
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||||
-- runs before the mon is recalled
|
|
||||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
Timing.SWITCH_PLAYER_MON)
|
Timing.SWITCH_PLAYER_MON)
|
||||||
self:queueRetreatAnim()
|
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
@@ -3328,26 +3316,6 @@ function BattleState:queueSendOutAnim(append)
|
|||||||
if append then self:act(fn) else self:actNext(fn) end
|
if append then self:act(fn) else self:actNext(fn) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
|
|
||||||
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
|
|
||||||
function BattleState:queueRetreatAnim()
|
|
||||||
if self:starterPikachuSendOut() then
|
|
||||||
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
|
|
||||||
self:waitNext(24)
|
|
||||||
self:actNext(function()
|
|
||||||
-- .clearScreenArea keeps the 7x7 area blank until the swap
|
|
||||||
-- (pokeyellow core.asm:1867-1871) (#1545)
|
|
||||||
self.sendingOut = true
|
|
||||||
self:slidePic("playerMon")
|
|
||||||
end)
|
|
||||||
else
|
|
||||||
self:actNext(function()
|
|
||||||
self.shrinkOut = { battler = self.player, frame = 0 }
|
|
||||||
end)
|
|
||||||
self:waitNext(7)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||||
@@ -3564,12 +3532,6 @@ function BattleState:updateFx()
|
|||||||
self.growIn.frame = self.growIn.frame + 1
|
self.growIn.frame = self.growIn.frame + 1
|
||||||
if self.growIn.frame >= 12 then self.growIn = nil end
|
if self.growIn.frame >= 12 then self.growIn = nil end
|
||||||
end
|
end
|
||||||
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
|
|
||||||
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
|
|
||||||
if self.shrinkOut then
|
|
||||||
self.shrinkOut.frame = self.shrinkOut.frame + 1
|
|
||||||
if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end
|
|
||||||
end
|
|
||||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
@@ -3676,13 +3638,12 @@ function BattleState:executeAction(user, target, action)
|
|||||||
})
|
})
|
||||||
self.aiUses = self:aiUsesFor()
|
self.aiUses = self:aiUsesFor()
|
||||||
markSeen(self.game, self.enemy.mon.species)
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||||
self.trainer.name, oldName))
|
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||||
self.trainer.name, self.enemy.name))
|
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -3834,8 +3795,6 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
|||||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||||
screens = target })
|
screens = target })
|
||||||
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
||||||
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
|
|
||||||
self:animNext("POUND", not user.isPlayer)
|
|
||||||
self:clearVolatiles(user, true)
|
self:clearVolatiles(user, true)
|
||||||
self:applyDamage(user, dmg)
|
self:applyDamage(user, dmg)
|
||||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||||
@@ -3928,28 +3887,18 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.moveAnimRow = nil
|
self.moveAnimRow = nil
|
||||||
local thrashing = user.thrashTurns and moveInst == user.thrashMove
|
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
||||||
and user.thrashAnnounced or false
|
|
||||||
if thrashing then
|
|
||||||
-- .ThrashingAboutCheck (core.asm:3531-3552)
|
|
||||||
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
|
|
||||||
displayName(user)))
|
|
||||||
user.thrashTurns = user.thrashTurns - 1
|
|
||||||
if user.thrashTurns <= 0 then
|
|
||||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
|
||||||
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||||
end
|
-- the move's animation plays right after the announcement; the
|
||||||
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
-- damage path attaches the target's hit blink to this row so the
|
||||||
-- = false) queues it from applyMimic after a successful copy
|
-- blink follows the animation (pokered's order). Mimic is the
|
||||||
if not (record and record.announceAnim == false) then
|
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
-- only after a successful copy, never on a miss -- applyMimic queues it
|
||||||
-- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
|
if not (record and record.announceAnim == false) then
|
||||||
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
attackerIsPlayer = user.isPlayer }
|
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Runtime.emit("battle.move_used", {
|
Runtime.emit("battle.move_used", {
|
||||||
battle = self, user = user, target = target, move = move,
|
battle = self, user = user, target = target, move = move,
|
||||||
@@ -3957,9 +3906,6 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
})
|
})
|
||||||
|
|
||||||
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
||||||
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
|
|
||||||
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
|
|
||||||
ctx.thrashing = thrashing
|
|
||||||
|
|
||||||
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
||||||
-- already said its failure text
|
-- already said its failure text
|
||||||
@@ -3981,17 +3927,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
||||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||||
-- ChargeEffect sets INVULNERABLE for both)
|
-- ChargeEffect sets INVULNERABLE for both)
|
||||||
local chargeRequired = record and record.charge ~= nil and not releasing
|
if record and record.charge and not releasing then
|
||||||
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
|
|
||||||
local required = Runtime.call("battle.charge_required", function(c)
|
|
||||||
return c.charge
|
|
||||||
end, {
|
|
||||||
battle = self, user = user, target = target, move = move,
|
|
||||||
charge = true, isCalled = isCalled or false,
|
|
||||||
})
|
|
||||||
chargeRequired = required ~= false
|
|
||||||
end
|
|
||||||
if chargeRequired then
|
|
||||||
self:cancelMoveAnim()
|
self:cancelMoveAnim()
|
||||||
user.charging = moveInst
|
user.charging = moveInst
|
||||||
user.chargeReady = true
|
user.chargeReady = true
|
||||||
@@ -4206,12 +4142,8 @@ function BattleState:onFaint(battler)
|
|||||||
-- acknowledged core.asm:797-798 bug.)
|
-- acknowledged core.asm:797-798 bug.)
|
||||||
self:actNext(function() self:playVictoryMusic() end)
|
self:actNext(function() self:playVictoryMusic() end)
|
||||||
end
|
end
|
||||||
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
||||||
-- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
|
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
||||||
-- would double it up
|
|
||||||
self:sayNext(battler.isPlayer
|
|
||||||
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
|
|
||||||
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
|
|
||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
self:act(function() self:playerMonFainted() end)
|
self:act(function() self:playerMonFainted() end)
|
||||||
else
|
else
|
||||||
@@ -4242,11 +4174,8 @@ function BattleState:awardExp()
|
|||||||
end
|
end
|
||||||
local function applyShare(mon, split, announce)
|
local function applyShare(mon, split, announce)
|
||||||
local playerId = self.game.save.player and self.game.save.player.id
|
local playerId = self.game.save.player and self.game.save.player.id
|
||||||
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
local traded = mon.traded == true
|
||||||
-- stored MON_OTID against wPlayerID every award; no persistent flag
|
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
||||||
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
|
|
||||||
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
|
|
||||||
or (mon.otId == nil and mon.traded == true))
|
|
||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, traded)
|
split, traded)
|
||||||
@@ -4390,44 +4319,22 @@ function BattleState:enemyMonFainted()
|
|||||||
-- "X is" off so "about to use" stays above the name, instead of the
|
-- "X is" off so "about to use" stays above the name, instead of the
|
||||||
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||||
-- "change POKéMON?" with YES/NO.
|
-- "change POKéMON?" with YES/NO.
|
||||||
--
|
|
||||||
-- _TrainerAboutToUseText combines both \f-paged, but unlike
|
|
||||||
-- _ItemUseBallText00's say()+say() merge above, this is say()+
|
|
||||||
-- sayChoice(): tried merging into one romText/sayChoice call and
|
|
||||||
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
|
|
||||||
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
|
||||||
-- page a sayChoice string the same way -- left as two calls.
|
|
||||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||||
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
|
||||||
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
|
||||||
self:sayChoice(
|
self:sayChoice(
|
||||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||||
function(yes)
|
function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
local game = self.game
|
local game = self.game
|
||||||
local shiftOpts, reopenShift
|
Screens.push(game, "PartyMenu", {
|
||||||
reopenShift = function(text)
|
|
||||||
table.insert(self.queue, 1, { ui = function()
|
|
||||||
return self:buildScreen("PartyMenu", shiftOpts)
|
|
||||||
end })
|
|
||||||
table.insert(self.queue, 1, { text = text })
|
|
||||||
end
|
|
||||||
shiftOpts = {
|
|
||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon == self.player.mon then
|
if mon ~= self.player.mon and mon.hp > 0 then
|
||||||
reopenShift(self:romText("_AlreadyOutText",
|
|
||||||
"%s is\nalready out!", self.player.name))
|
|
||||||
elseif mon.hp <= 0 then
|
|
||||||
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
|
|
||||||
else
|
|
||||||
shiftSwitchMon = mon
|
shiftSwitchMon = mon
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
})
|
||||||
Screens.push(game, "PartyMenu", shiftOpts)
|
|
||||||
end, { box = Theme.trainerSwitchBox })
|
end, { box = Theme.trainerSwitchBox })
|
||||||
end
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
@@ -4455,8 +4362,7 @@ function BattleState:enemyMonFainted()
|
|||||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||||
self.trainer.name, self.enemy.name))
|
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4470,11 +4376,10 @@ function BattleState:enemyMonFainted()
|
|||||||
local mon = shiftSwitchMon
|
local mon = shiftSwitchMon
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||||
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
|
-- hold, then the recall and the send-out
|
||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
Timing.SWITCH_PLAYER_MON)
|
Timing.SWITCH_PLAYER_MON)
|
||||||
self:queueRetreatAnim()
|
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||||
@@ -4546,22 +4451,9 @@ function BattleState:enemyMonFainted()
|
|||||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||||
-- prints once, so a `para` page carries no second copy (#566).
|
-- prints once, so a `para` page carries no second copy (#566).
|
||||||
local tag = self.trainer and self.trainer.name
|
local tag = self.trainer and self.trainer.name
|
||||||
-- the badge jingle (sound_get_item_1 and friends) rides the armed
|
|
||||||
-- line's first page, as the script's text command would (#1606)
|
|
||||||
local sfx = self.endBattleSound
|
|
||||||
local data = self.data
|
|
||||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||||
if page ~= "" then
|
if page ~= "" then
|
||||||
local line = tag and (tag .. ": " .. page) or page
|
self:sayNext(tag and (tag .. ": " .. page) or page)
|
||||||
if sfx then
|
|
||||||
local id = sfx
|
|
||||||
self:sayNextWaitSfx(line, function()
|
|
||||||
return require("src.core.Sound").play(data, id)
|
|
||||||
end)
|
|
||||||
sfx = nil
|
|
||||||
else
|
|
||||||
self:sayNext(line)
|
|
||||||
end
|
|
||||||
tag = nil
|
tag = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4986,8 +4878,7 @@ function BattleState:storeCaughtMon()
|
|||||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||||
-- the box rather than firing when the dex bit is set
|
-- the box rather than firing when the dex bit is set
|
||||||
self:sayNextWaitSfx(
|
self:sayNextWaitSfx(
|
||||||
self:romText("_ItemUseBallText06",
|
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||||
"New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
|
||||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
@@ -5008,12 +4899,9 @@ function BattleState:storeCaughtMon()
|
|||||||
if boxNum then
|
if boxNum then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
||||||
self:sayNext(self:romText(
|
and "BILL's PC" or Strings("someone's PC")
|
||||||
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
||||||
metBill and "%s was\ntransferred to\nBILL's PC!"
|
|
||||||
or "%s was\ntransferred to\nsomeone's PC!",
|
|
||||||
self.enemy.name))
|
|
||||||
else
|
else
|
||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
end
|
end
|
||||||
@@ -5119,18 +5007,8 @@ function BattleState:throwBall(ball)
|
|||||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||||
-- so it is not a ghost battle any more (#444)
|
-- so it is not a ghost battle any more (#444)
|
||||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||||
-- TextBox.new() (which splits \f itself), the battle queue's own
|
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||||
-- startMessage() only splits on \n/\v -- confirmed live: the \f
|
|
||||||
-- landed mid-line and the second sentence overflowed off the box
|
|
||||||
-- instead of starting a fresh page. Resolve the label once, then
|
|
||||||
-- split it the same way TextBox.lua does and queue one sayNext per
|
|
||||||
-- page, so the two ROM sentences still render as two pages.
|
|
||||||
local dodgeText = self:romText("_ItemUseBallText00",
|
|
||||||
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
|
|
||||||
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
|
|
||||||
self:sayNext(page)
|
|
||||||
end
|
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
@@ -5177,14 +5055,10 @@ function BattleState:openParty()
|
|||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
|
||||||
if mon == self.player.mon then
|
if mon == self.player.mon then
|
||||||
self:say(self:romText("_AlreadyOutText",
|
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||||
"%s is\nalready out!", self.player.name))
|
|
||||||
self:act(function() self:openParty() end)
|
|
||||||
elseif mon.hp <= 0 then
|
elseif mon.hp <= 0 then
|
||||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
self:act(function() self:openParty() end)
|
|
||||||
else
|
else
|
||||||
self:resolveSwitch(mon)
|
self:resolveSwitch(mon)
|
||||||
end
|
end
|
||||||
@@ -5328,16 +5202,6 @@ function BattleState:growInScale(battler)
|
|||||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||||
end
|
end
|
||||||
|
|
||||||
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
|
|
||||||
-- (core.asm:1769-1796)
|
|
||||||
function BattleState:shrinkOutScale(battler)
|
|
||||||
local shrink = self.shrinkOut
|
|
||||||
if not shrink or shrink.battler ~= battler then return nil end
|
|
||||||
-- scale 0 past Delay3: the area stays cleared until the swap
|
|
||||||
-- (core.asm:1790-1796) (#1563)
|
|
||||||
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
|
|
||||||
end
|
|
||||||
|
|
||||||
-- battler hidden this frame? (damage blink)
|
-- battler hidden this frame? (damage blink)
|
||||||
--
|
--
|
||||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||||
@@ -5974,18 +5838,15 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|||||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||||
imagePathOf(self.player.sprite),
|
imagePathOf(self.player.sprite),
|
||||||
self.player.mon and self.player.mon.species)
|
self.player.mon and self.player.mon.species)
|
||||||
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
|
local gs = self:growInScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||||
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||||
|
-- pinned, mod scale composed with the grow stage
|
||||||
local eff = s * gs
|
local eff = s * gs
|
||||||
if eff > 0 then
|
if eff > 0 then
|
||||||
-- the retreat stages sit one tile right of the grow-in's
|
|
||||||
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
|
|
||||||
local shrinkX = self.shrinkOut
|
|
||||||
and self.shrinkOut.battler == self.player and 8 or 0
|
|
||||||
love.graphics.draw(img,
|
love.graphics.draw(img,
|
||||||
8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||||
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -108,17 +108,13 @@ end
|
|||||||
|
|
||||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||||
-- stage keeps the original's exact check order and rng consumption
|
-- stage keeps the original's exact check order and rng consumption
|
||||||
-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy ->
|
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
||||||
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
||||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||||
local user, target = ctx.user, ctx.target
|
local user, target = ctx.user, ctx.target
|
||||||
local move, moveInst = ctx.move, ctx.moveInst
|
local move, moveInst = ctx.move, ctx.moveInst
|
||||||
local neverMiss = record and record.neverMiss
|
local neverMiss = record and record.neverMiss
|
||||||
|
|
||||||
-- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before
|
|
||||||
-- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565)
|
|
||||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
|
||||||
|
|
||||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
@@ -147,6 +143,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
|
|
||||||
local hits = hitCount(ctx, record)
|
local hits = hitCount(ctx, record)
|
||||||
|
|
||||||
|
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||||
|
|
||||||
if not neverMiss then
|
if not neverMiss then
|
||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
@@ -223,7 +221,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||||
-- Mimic queues no announcement anim (announceAnim = false) -- a bare
|
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||||
-- hitRow carries the blink instead.
|
-- hitRow carries the blink instead.
|
||||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||||
|
|||||||
@@ -582,15 +582,30 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
THRASH_PETAL_DANCE_EFFECT = {
|
THRASH_PETAL_DANCE_EFFECT = {
|
||||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||||
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
|
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
||||||
beforeAccuracy = function(ctx)
|
beforeAccuracy = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
if ctx.thrashing or user.thrashTurns then return end
|
if not user.thrashTurns then
|
||||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
ctx.battle:animBeforeMove(
|
||||||
user.thrashMove = ctx.moveInst
|
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||||
user.thrashAnnounced = true
|
end
|
||||||
ctx.battle:animBeforeMove(
|
end,
|
||||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
afterDamage = function(ctx)
|
||||||
|
local user = ctx.user
|
||||||
|
if not user.thrashTurns then
|
||||||
|
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||||
|
user.thrashMove = ctx.moveInst
|
||||||
|
user.thrashAnnounced = true
|
||||||
|
else
|
||||||
|
user.thrashTurns = user.thrashTurns - 1
|
||||||
|
if user.thrashTurns <= 0 then
|
||||||
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
|
if not user.confusedTurns then
|
||||||
|
user.confusedTurns = ctx.rng(2, 5)
|
||||||
|
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
@@ -645,9 +660,7 @@ MoveEffects.full = {
|
|||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
ctx.battle:cancelMoveAnim()
|
ctx.battle:cancelMoveAnim()
|
||||||
ctx.battle:animBeforeMove(
|
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||||
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
|
|
||||||
user.isPlayer)
|
|
||||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -166,9 +166,6 @@ Battle.SUBSTATUS_ITEMS = {
|
|||||||
-- be run from or Roared away.
|
-- be run from or Roared away.
|
||||||
Battle.BATTLETYPE_FORCESHINY = 7
|
Battle.BATTLETYPE_FORCESHINY = 7
|
||||||
Battle.BATTLETYPE_TRAP = 9
|
Battle.BATTLETYPE_TRAP = 9
|
||||||
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
|
||||||
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
|
||||||
Battle.BATTLETYPE_CANLOSE = 1
|
|
||||||
|
|
||||||
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
||||||
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
||||||
@@ -892,27 +889,16 @@ Battle.PRIORITY = {
|
|||||||
EFFECT_ENDURE = 3,
|
EFFECT_ENDURE = 3,
|
||||||
EFFECT_COUNTER = -1,
|
EFFECT_COUNTER = -1,
|
||||||
EFFECT_MIRROR_COAT = -1,
|
EFFECT_MIRROR_COAT = -1,
|
||||||
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
|
EFFECT_VITAL_THROW = -1,
|
||||||
}
|
}
|
||||||
|
|
||||||
function Battle:movePriority(moveId)
|
function Battle:movePriority(moveId)
|
||||||
-- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z`
|
|
||||||
-- (engine/battle/core.asm:787-789).
|
|
||||||
if moveId == "VITAL_THROW" then return -1 end
|
|
||||||
local def = self:moveDef(moveId)
|
local def = self:moveDef(moveId)
|
||||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
|
|
||||||
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
|
|
||||||
|
|
||||||
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
||||||
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
|
function Battle:canAct(mon)
|
||||||
local function clearBide(state)
|
|
||||||
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function checkTurn(self, mon, moveId)
|
|
||||||
local name = self:monName(mon)
|
local name = self:monName(mon)
|
||||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||||
@@ -933,11 +919,7 @@ local function checkTurn(self, mon, moveId)
|
|||||||
local beforeMove = record and record.beforeMove
|
local beforeMove = record and record.beforeMove
|
||||||
if beforeMove
|
if beforeMove
|
||||||
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
||||||
-- engine/battle/effect_commands.asm:188-200
|
return beforeMove(self, mon, name) and true or false
|
||||||
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
|
|
||||||
local acted = beforeMove(self, mon, name) and true or false
|
|
||||||
if acted or not bypass then return acted end
|
|
||||||
beforeMove = nil
|
|
||||||
end
|
end
|
||||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||||
@@ -971,14 +953,6 @@ local function checkTurn(self, mon, moveId)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
|
|
||||||
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
|
|
||||||
function Battle:canAct(mon, moveId)
|
|
||||||
local acted = checkTurn(self, mon, moveId)
|
|
||||||
if not acted then clearBide(self:volatile(mon)) end
|
|
||||||
return acted
|
|
||||||
end
|
|
||||||
|
|
||||||
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
||||||
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
||||||
-- `.struggle` for the enemy). It lives in the move table like any other move
|
-- `.struggle` for the enemy). It lives in the move table like any other move
|
||||||
@@ -1254,15 +1228,10 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
|||||||
if opts.critical then
|
if opts.critical then
|
||||||
self:emit({ kind = "message", text = "A critical hit!" })
|
self:emit({ kind = "message", text = "A critical hit!" })
|
||||||
end
|
end
|
||||||
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
|
|
||||||
-- The cart breaks both across the box's two lines and hyphenates "super-"
|
|
||||||
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
|
|
||||||
-- charmap carries at $75, not three periods.
|
|
||||||
if opts.effectiveness and opts.effectiveness > 10 then
|
if opts.effectiveness and opts.effectiveness > 10 then
|
||||||
self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
|
self:emit({ kind = "message", text = "It's super effective!" })
|
||||||
elseif opts.effectiveness and opts.effectiveness < 10 then
|
elseif opts.effectiveness and opts.effectiveness < 10 then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message", text = "It's not very effective..." })
|
||||||
text = Strings("It's not very\neffective…") })
|
|
||||||
end
|
end
|
||||||
if endured then
|
if endured then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
@@ -1414,18 +1383,9 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
-- free of PP and obedience in exactly the same way.
|
-- free of PP and obedience in exactly the same way.
|
||||||
local rolling = state.rolloutLock == moveId
|
local rolling = state.rolloutLock == moveId
|
||||||
|
|
||||||
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
|
if not (charging or rampaging or rolling) then
|
||||||
-- engine/battle/move_effects/bide.asm:62-68
|
|
||||||
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
|
|
||||||
|
|
||||||
-- engine/battle/effect_commands.asm:6222-6234, :949-951
|
|
||||||
local called = (self.copyDepth or 0) > 0
|
|
||||||
|
|
||||||
if not (charging or rampaging or rolling or biding or called) then
|
|
||||||
if move and (move.pp or 0) <= 0 then
|
if move and (move.pp or 0) <= 0 then
|
||||||
-- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
|
self:emit({ kind = "message", text = "No PP left for this move!" })
|
||||||
self:emit({ kind = "message",
|
|
||||||
text = Strings("There's no PP left\nfor this move!") })
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if move then move.pp = (move.pp or 1) - 1 end
|
if move then move.pp = (move.pp or 1) - 1 end
|
||||||
@@ -1505,38 +1465,9 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
|
|
||||||
if def.effect == "EFFECT_SLEEP_TALK" then
|
|
||||||
local picked
|
|
||||||
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
|
|
||||||
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
|
|
||||||
local pool = {}
|
|
||||||
for _, own in ipairs(attacker.moves or {}) do
|
|
||||||
local ownDef = self:moveDef(own.id)
|
|
||||||
local effect = ownDef and ownDef.effect
|
|
||||||
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
|
|
||||||
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
|
|
||||||
pool[#pool + 1] = own.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
|
|
||||||
end
|
|
||||||
if not picked then
|
|
||||||
self:markMissed()
|
|
||||||
self:emit({ kind = "message", text = "But it failed!" })
|
|
||||||
return
|
|
||||||
end
|
|
||||||
state.lastMove = nil
|
|
||||||
self.copyDepth = (self.copyDepth or 0) + 1
|
|
||||||
self:useMove(attacker, defender, picked)
|
|
||||||
self.copyDepth = self.copyDepth - 1
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Everything past here counts as "the user's last move" for Mirror Move,
|
-- Everything past here counts as "the user's last move" for Mirror Move,
|
||||||
-- Encore and Disable. A called move skips the write
|
-- Encore and Disable.
|
||||||
-- (engine/battle/used_move_text.asm:30-36).
|
state.lastMove = moveId
|
||||||
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
|
|
||||||
state.turnsTaken = (state.turnsTaken or 0) + 1
|
state.turnsTaken = (state.turnsTaken or 0) + 1
|
||||||
state.usedMoves = state.usedMoves or {}
|
state.usedMoves = state.usedMoves or {}
|
||||||
local seen = false
|
local seen = false
|
||||||
@@ -1557,15 +1488,6 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
||||||
charge = nil
|
charge = nil
|
||||||
end
|
end
|
||||||
if charge and not charging and Runtime.wantsHook("battle.charge_required") then
|
|
||||||
local required = Runtime.call("battle.charge_required", function(c)
|
|
||||||
return c.charge
|
|
||||||
end, {
|
|
||||||
battle = self, user = attacker, target = defender, move = def,
|
|
||||||
charge = true, isCalled = (self.copyDepth or 0) > 0,
|
|
||||||
})
|
|
||||||
if required == false then charge = nil end
|
|
||||||
end
|
|
||||||
if charge and not charging then
|
if charge and not charging then
|
||||||
state.chargeMove = moveId
|
state.chargeMove = moveId
|
||||||
state.vanished = charge.vanish or nil
|
state.vanished = charge.vanish or nil
|
||||||
@@ -1579,13 +1501,6 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
|
|
||||||
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
|
|
||||||
self:markMissed()
|
|
||||||
self:emit({ kind = "message", text = "But it failed!" })
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
||||||
-- and fail outright when nothing of the right kind landed.
|
-- and fail outright when nothing of the right kind landed.
|
||||||
local counterKind = Effects.COUNTER[def.effect]
|
local counterKind = Effects.COUNTER[def.effect]
|
||||||
@@ -1817,12 +1732,8 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
landed = landed + 1
|
landed = landed + 1
|
||||||
end
|
end
|
||||||
if landed > 1 then
|
if landed > 1 then
|
||||||
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
|
self:emit({ kind = "message",
|
||||||
-- are "Hit @ times!". Gen 2 has no singular form of this line, so the
|
text = ("Hit %d time(s)!"):format(landed) })
|
||||||
-- plural stands even at one hit rather than the "(s)" this printed.
|
|
||||||
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
|
|
||||||
-- _HitXTimesText).
|
|
||||||
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- move_effects/pay_day.asm:13
|
-- move_effects/pay_day.asm:13
|
||||||
@@ -2042,11 +1953,8 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
|
|||||||
if mine.perish and theirs.perish then return fail(self) end
|
if mine.perish and theirs.perish then return fail(self) end
|
||||||
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
||||||
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
||||||
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
|
|
||||||
-- sentence no cart prints; the Gen 2 line names both sides and counts in
|
|
||||||
-- digits.
|
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
|
text = "All POKéMON hearing the song will faint in three turns!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
||||||
@@ -2176,11 +2084,6 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
|
|||||||
local side = self:sideOf(defender)
|
local side = self:sideOf(defender)
|
||||||
if self.spikes[side] then return fail(self) end
|
if self.spikes[side] then return fail(self) end
|
||||||
self.spikes[side] = true
|
self.spikes[side] = true
|
||||||
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
|
|
||||||
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
|
|
||||||
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
|
|
||||||
-- printMessage cuts past two rows, so the cart's line cannot be told here
|
|
||||||
-- yet without the name being dropped on screen. Left as it stands.
|
|
||||||
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2215,8 +2118,6 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
if not state.bideTurns then
|
if not state.bideTurns then
|
||||||
state.bideTurns = Effects.bideTurns(self.random)
|
state.bideTurns = Effects.bideTurns(self.random)
|
||||||
state.bideStored = 0
|
state.bideStored = 0
|
||||||
-- engine/battle/core.asm:574-576
|
|
||||||
state.bideMove = moveId
|
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " is storing energy!" })
|
text = self:monName(attacker) .. " is storing energy!" })
|
||||||
return
|
return
|
||||||
@@ -2228,7 +2129,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local damage = Effects.bideDamage(state.bideStored)
|
local damage = Effects.bideDamage(state.bideStored)
|
||||||
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
state.bideTurns, state.bideStored = nil, nil
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " unleashed energy!" })
|
text = self:monName(attacker) .. " unleashed energy!" })
|
||||||
if damage <= 0 then return fail(self) end
|
if damage <= 0 then return fail(self) end
|
||||||
@@ -2418,9 +2319,7 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
|||||||
{ move = def, moveId = def and def.id })
|
{ move = def, moveId = def and def.id })
|
||||||
landed = landed + 1
|
landed = landed + 1
|
||||||
end
|
end
|
||||||
-- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
|
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
|
||||||
-- land exactly once, which is the case the cart still prints as "times".
|
|
||||||
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
||||||
@@ -2503,14 +2402,11 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
|||||||
self.enemyIndex = target
|
self.enemyIndex = target
|
||||||
self.enemy = party[target]
|
self.enemy = party[target]
|
||||||
self.enemy.volatile = carried
|
self.enemy.volatile = carried
|
||||||
-- engine/battle/move_effects/baton_pass.asm:59
|
|
||||||
self:resetParticipants()
|
|
||||||
end
|
end
|
||||||
local sent = side == "player" and self.player or self.enemy
|
self:emit({ kind = "send", side = side,
|
||||||
self:emit({ kind = "send", side = side, mon = sent,
|
mon = side == "player" and self.player or self.enemy,
|
||||||
hp = sent.hp or 0, status = sent.status or false,
|
text = "Go! " .. self:monName(side == "player" and self.player
|
||||||
level = sent.level, experience = sent.experience,
|
or self.enemy) .. "!" })
|
||||||
text = "Go! " .. self:monName(sent) .. "!" })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
||||||
@@ -2767,12 +2663,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
|||||||
self.enemyIndex = pick
|
self.enemyIndex = pick
|
||||||
self.enemy = incoming
|
self.enemy = incoming
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
-- ForceEnemySwitch (engine/battle/core.asm:2937)
|
|
||||||
self:resetParticipants()
|
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||||
hp = incoming.hp or 0, status = incoming.status or false,
|
|
||||||
level = incoming.level, experience = incoming.experience,
|
|
||||||
text = self:monName(incoming) .. " was dragged out!" })
|
text = self:monName(incoming) .. " was dragged out!" })
|
||||||
self:breakTrapsOnSend(incoming)
|
self:breakTrapsOnSend(incoming)
|
||||||
self:spikesDamage(incoming)
|
self:spikesDamage(incoming)
|
||||||
@@ -3178,12 +3070,6 @@ end
|
|||||||
|
|
||||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||||
function Battle:resolveFaints()
|
function Battle:resolveFaints()
|
||||||
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
|
|
||||||
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
|
|
||||||
self.participantsCleared = self.player
|
|
||||||
if self.playerIndex then self.participants[self.playerIndex] = nil end
|
|
||||||
end
|
|
||||||
|
|
||||||
if (self.enemy.hp or 0) <= 0 then
|
if (self.enemy.hp or 0) <= 0 then
|
||||||
self:emit({ kind = "faint", side = "enemy",
|
self:emit({ kind = "faint", side = "enemy",
|
||||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||||
@@ -3198,7 +3084,6 @@ function Battle:resolveFaints()
|
|||||||
if self.trainer then
|
if self.trainer then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
||||||
self:printWinLossText("win")
|
|
||||||
self:awardPrizeMoney()
|
self:awardPrizeMoney()
|
||||||
end
|
end
|
||||||
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
||||||
@@ -3225,8 +3110,6 @@ function Battle:resolveFaints()
|
|||||||
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
replacement = true,
|
replacement = true,
|
||||||
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
|
||||||
level = self.enemy.level, experience = self.enemy.experience,
|
|
||||||
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
@@ -3269,11 +3152,6 @@ function Battle:resolveFaints()
|
|||||||
local nextIndex = Battle.firstHealthy(self.party)
|
local nextIndex = Battle.firstHealthy(self.party)
|
||||||
if not nextIndex then
|
if not nextIndex then
|
||||||
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
||||||
-- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE
|
|
||||||
-- reaches PrintWinLossText on a loss; every other loss whites out.
|
|
||||||
if self.battleType == Battle.BATTLETYPE_CANLOSE then
|
|
||||||
self:printWinLossText("lose")
|
|
||||||
end
|
|
||||||
self:endBattle("lose")
|
self:endBattle("lose")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -3299,23 +3177,14 @@ function Battle:resolveFaints()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
|
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
|
||||||
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
|
-- and the frontpic slide: the four quarters are dealt between the wallet and
|
||||||
function Battle:printWinLossText(result)
|
-- Mom's savings and then one StdBattleTextbox names the figure.
|
||||||
local trainer = self.trainer
|
--
|
||||||
if not trainer then return end
|
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
|
||||||
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
|
-- PrintWinLossText is the trainer's own after-battle line, which this port
|
||||||
-- the slide (engine/battle/core.asm:2310, :2320-2323).
|
-- runs from the script on the way out of the battle rather than from here.
|
||||||
-- The CANLOSE loss arm runs ClearBox first (:2770-2773).
|
-- The payout is not gated on it either way.
|
||||||
self:emit({ kind = "trainer-return", cleared = result == "lose" or nil })
|
|
||||||
local text = (result == "lose") and trainer.lossText or trainer.winText
|
|
||||||
if type(text) ~= "string" or text == "" then return end
|
|
||||||
-- FarPrintText prints the pointer alone: no trainer-name tag in front of
|
|
||||||
-- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355).
|
|
||||||
self:emit({ kind = "win-text", text = text })
|
|
||||||
end
|
|
||||||
|
|
||||||
-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323)
|
|
||||||
function Battle:awardPrizeMoney()
|
function Battle:awardPrizeMoney()
|
||||||
local save = self.save
|
local save = self.save
|
||||||
if not (save and save.player) then return nil end
|
if not (save and save.player) then return nil end
|
||||||
@@ -3368,16 +3237,7 @@ end
|
|||||||
-- `count` is the pass's own divisor -- the participant count for the first
|
-- `count` is the pass's own divisor -- the participant count for the first
|
||||||
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
||||||
-- any Share holder taxed the whole pool.
|
-- any Share holder taxed the whole pool.
|
||||||
--
|
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||||
-- `silent` suppresses only the GainedText line. It exists for the
|
|
||||||
-- battle.exp_award seam below, where a mod paying the bench wants one summary
|
|
||||||
-- line rather than a box per mon; the cart's own two passes never pass it, so
|
|
||||||
-- vanilla prints exactly what it always did. Everything else about the pass
|
|
||||||
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
|
|
||||||
-- and the forget prompt -- is unaffected, because a silent award is still an
|
|
||||||
-- award.
|
|
||||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
|
||||||
silent)
|
|
||||||
for _, index in ipairs(recipients) do
|
for _, index in ipairs(recipients) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
||||||
@@ -3428,12 +3288,10 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
|||||||
index = index,
|
index = index,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
if not silent then
|
self:emit({ kind = "experience", index = index, amount = amount,
|
||||||
self:emit({ kind = "experience", index = index, amount = amount,
|
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
text = self:monName(mon) .. " gained "
|
||||||
text = self:monName(mon) .. " gained "
|
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
|
||||||
end
|
|
||||||
if result.levels > 0 then
|
if result.levels > 0 then
|
||||||
-- "level up happiness mod", the cart's own comment, sitting right
|
-- "level up happiness mod", the cart's own comment, sitting right
|
||||||
-- after the stat recalc and before the "grew to level" text. It fires
|
-- after the stat recalc and before the "grew to level" text. It fires
|
||||||
@@ -3448,9 +3306,7 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
|||||||
local moveDef = self:moveDef(moveId)
|
local moveDef = self:moveDef(moveId)
|
||||||
local moveName = (moveDef and moveDef.name) or moveId
|
local moveName = (moveDef and moveDef.name) or moveId
|
||||||
if ok then
|
if ok then
|
||||||
-- data/text/common_3.asm:119
|
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
|
||||||
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
||||||
elseif reason == "full" then
|
elseif reason == "full" then
|
||||||
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
||||||
@@ -3529,38 +3385,22 @@ function Battle:awardExperience(loser)
|
|||||||
|
|
||||||
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
||||||
-- with the same ctx: the participant COUNT, the live participants, and an
|
-- with the same ctx: the participant COUNT, the live participants, and an
|
||||||
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
|
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
|
||||||
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
|
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
|
||||||
--
|
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
||||||
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
|
-- accepted and ignored rather than changing what is printed. `recipients`,
|
||||||
-- applyShare) and means the same thing here: truthy prints the mon's
|
-- `holders` and `halved` are the Gen 2 additions.
|
||||||
-- GainedText, falsy pays it silently. That is what lets one mod source
|
|
||||||
-- print ONE summary line for a party-wide award on both generations instead
|
|
||||||
-- of a box per mon -- which is what the Exp Share mod documents and could
|
|
||||||
-- not do on Gold, because this argument used to be accepted and ignored.
|
|
||||||
--
|
|
||||||
-- It is honoured only when it is actually PASSED, by argument count rather
|
|
||||||
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
|
|
||||||
-- against a seam that always announced and keeps announcing; a caller that
|
|
||||||
-- passes the argument -- including an explicit nil, which is what a "pay
|
|
||||||
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
|
|
||||||
-- mod changes behaviour, and a mod that opts in gets parity.
|
|
||||||
if Runtime.wantsHook("battle.exp_award") then
|
if Runtime.wantsHook("battle.exp_award") then
|
||||||
local alive = {}
|
local alive = {}
|
||||||
for _, index in ipairs(participants) do
|
for _, index in ipairs(participants) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||||
end
|
end
|
||||||
local function applyShare(mon, split, ...)
|
local function applyShare(mon, split)
|
||||||
local announce = ...
|
|
||||||
-- select("#") counts an explicit nil; `announce == nil` alone could not
|
|
||||||
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
|
|
||||||
-- those two have to mean different things here.
|
|
||||||
local silent = select("#", ...) > 0 and not announce
|
|
||||||
for index, candidate in ipairs(self.party) do
|
for index, candidate in ipairs(self.party) do
|
||||||
if candidate == mon then
|
if candidate == mon then
|
||||||
return self:giveExperiencePass(loser, def, { index },
|
return self:giveExperiencePass(loser, def, { index },
|
||||||
math.max(1, split or 1), halved, silent)
|
math.max(1, split or 1), halved)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3575,7 +3415,8 @@ function Battle:awardExperience(loser)
|
|||||||
|
|
||||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||||
self:resetParticipants()
|
self.participants = {}
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||||
@@ -3596,9 +3437,7 @@ function Battle:resolveForget(index, slot, entry, moveName)
|
|||||||
end
|
end
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
||||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
|
||||||
text = self:monName(mon) .. " learned "
|
text = self:monName(mon) .. " learned "
|
||||||
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
||||||
-- The forget path writes the slot itself rather than going through
|
-- The forget path writes the slot itself rather than going through
|
||||||
@@ -3637,13 +3476,6 @@ function Battle:switchLocked()
|
|||||||
return self:volatile(self.enemy).trapsTarget == true
|
return self:volatile(self.enemy).trapsTarget == true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
|
||||||
-- (engine/battle/core.asm:3033 and :3037).
|
|
||||||
function Battle:resetParticipants()
|
|
||||||
self.participants = {}
|
|
||||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
||||||
-- (engine/battle/core.asm:2959-2961).
|
-- (engine/battle/core.asm:2959-2961).
|
||||||
function Battle:shiftSwitch(index)
|
function Battle:shiftSwitch(index)
|
||||||
@@ -3666,7 +3498,6 @@ function Battle:switch(index)
|
|||||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||||
-- announce its own faint again; see resolveFaints.
|
-- announce its own faint again; see resolveFaints.
|
||||||
self.faintAnnounced = nil
|
self.faintAnnounced = nil
|
||||||
self.participantsCleared = nil
|
|
||||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||||
self.pendingSwitch = nil
|
self.pendingSwitch = nil
|
||||||
self.player = mon
|
self.player = mon
|
||||||
@@ -3674,8 +3505,6 @@ function Battle:switch(index)
|
|||||||
self.participants[index] = true
|
self.participants[index] = true
|
||||||
self.stages.player = Battle.newStages()
|
self.stages.player = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "player", mon = mon,
|
self:emit({ kind = "send", side = "player", mon = mon,
|
||||||
hp = mon.hp or 0, status = mon.status or false,
|
|
||||||
level = mon.level, experience = mon.experience,
|
|
||||||
text = "Go! " .. self:monName(mon) .. "!" })
|
text = "Go! " .. self:monName(mon) .. "!" })
|
||||||
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
||||||
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
||||||
@@ -3904,41 +3733,19 @@ function Battle:lockedInMove(mon)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
|
|
||||||
-- at :5650
|
|
||||||
function Battle:fightLockedMove(mon)
|
|
||||||
local state = self:volatile(mon)
|
|
||||||
if state.bideTurns then return state.bideMove end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- engine/battle/core.asm:627-629
|
|
||||||
function Battle:cancelBide(mon)
|
|
||||||
clearBide(self:volatile(mon))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
||||||
-- (to grey out the move list) and by the enemy's own choice below.
|
-- (to grey out the move list) and by the enemy's own choice below.
|
||||||
-- engine/battle/core.asm:561-566
|
|
||||||
local function encoredMove(state, mon)
|
|
||||||
if not state.encore then return nil end
|
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
|
||||||
if move.id == state.encore and (move.pp or 0) > 0 then
|
|
||||||
return state.encore
|
|
||||||
end
|
|
||||||
end
|
|
||||||
state.encore, state.encoreTurns = nil, nil
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
function Battle:forcedMove(mon)
|
function Battle:forcedMove(mon)
|
||||||
local locked = self:lockedInMove(mon)
|
local locked = self:lockedInMove(mon)
|
||||||
if locked then return locked end
|
if locked then return locked end
|
||||||
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
local state = self:volatile(mon)
|
||||||
-- (engine/battle/core.asm:561-566).
|
if not state.encore then return nil end
|
||||||
local encored = encoredMove(self:volatile(mon), mon)
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
if encored then return encored end
|
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
|
||||||
return self:fightLockedMove(mon)
|
end
|
||||||
|
-- Encore ends early when the move runs out of PP.
|
||||||
|
state.encore, state.encoreTurns = nil, nil
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function Battle:moveDisabled(mon, moveId)
|
function Battle:moveDisabled(mon, moveId)
|
||||||
@@ -3952,9 +3759,8 @@ function Battle:usableMoves(mon)
|
|||||||
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
||||||
-- that spent its last PP on the opening turn keeps running: no later turn
|
-- that spent its last PP on the opening turn keeps running: no later turn
|
||||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||||
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
-- ends it the moment the encored move runs dry.
|
||||||
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
local locked = self:lockedInMove(mon)
|
||||||
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
|
||||||
local out = {}
|
local out = {}
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||||
@@ -4156,8 +3962,6 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
.. self:monName(outgoing) .. "!" })
|
.. self:monName(outgoing) .. "!" })
|
||||||
self.enemyIndex = target
|
self.enemyIndex = target
|
||||||
self.enemy = self.enemyParty[target]
|
self.enemy = self.enemyParty[target]
|
||||||
-- AI_Switch (engine/battle/ai/items.asm:697)
|
|
||||||
self:resetParticipants()
|
|
||||||
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
||||||
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
||||||
-- the mon coming IN starts from an empty area -- the same pair of clears
|
-- the mon coming IN starts from an empty area -- the same pair of clears
|
||||||
@@ -4165,8 +3969,6 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
self:clearVolatile(self.enemy)
|
self:clearVolatile(self.enemy)
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
|
||||||
level = self.enemy.level, experience = self.enemy.experience,
|
|
||||||
text = (self.trainer.name or "TRAINER") .. " sent out "
|
text = (self.trainer.name or "TRAINER") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
@@ -4248,16 +4050,9 @@ function Battle:vanillaEnemyMove()
|
|||||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||||
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
||||||
-- died there, and no amount of grinding could ever have got past it.
|
-- died there, and no amount of grinding could ever have got past it.
|
||||||
local enemyState = self:volatile(self.enemy)
|
local charged = self:volatile(self.enemy).chargeMove
|
||||||
local charged = enemyState.chargeMove
|
|
||||||
if charged then return charged end
|
if charged then return charged end
|
||||||
|
|
||||||
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
|
|
||||||
-- CheckEnemyLockedIn (:5650).
|
|
||||||
local encored = encoredMove(enemyState, self.enemy)
|
|
||||||
if encored then return encored end
|
|
||||||
if enemyState.bideTurns then return enemyState.bideMove end
|
|
||||||
|
|
||||||
-- Encore and Disable narrow the pool before the AI ever scores it.
|
-- Encore and Disable narrow the pool before the AI ever scores it.
|
||||||
local moves = self:usableMoves(self.enemy)
|
local moves = self:usableMoves(self.enemy)
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -4351,7 +4146,6 @@ local function runTurn(self, action)
|
|||||||
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
||||||
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
||||||
if self.runRefused then return self:takeEvents() end
|
if self.runRefused then return self:takeEvents() end
|
||||||
self:cancelBide(self.player)
|
|
||||||
action = { kind = "skip" }
|
action = { kind = "skip" }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4367,9 +4161,6 @@ local function runTurn(self, action)
|
|||||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||||
Happiness.change(self.player, "USEDXITEM")
|
Happiness.change(self.player, "USEDXITEM")
|
||||||
end
|
end
|
||||||
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
|
|
||||||
-- instead and keeps the store.
|
|
||||||
if action.kind == "item" then self:cancelBide(self.player) end
|
|
||||||
|
|
||||||
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
||||||
-- to rotate or drink a potion spends its whole turn on it.
|
-- to rotate or drink a potion spends its whole turn on it.
|
||||||
@@ -4418,6 +4209,7 @@ local function runTurn(self, action)
|
|||||||
|
|
||||||
local function playerAttack()
|
local function playerAttack()
|
||||||
if action.kind ~= "move" then return end
|
if action.kind ~= "move" then return end
|
||||||
|
if not self:canAct(self.player) then return end
|
||||||
local move = action.move
|
local move = action.move
|
||||||
-- An encored mon has no choice, whatever the menu said.
|
-- An encored mon has no choice, whatever the menu said.
|
||||||
local forced = self:forcedMove(self.player)
|
local forced = self:forcedMove(self.player)
|
||||||
@@ -4437,20 +4229,13 @@ local function runTurn(self, action)
|
|||||||
-- player's own mon spends the rest of the battle underground.
|
-- player's own mon spends the rest of the battle underground.
|
||||||
local stored = self:volatile(self.player).chargeMove
|
local stored = self:volatile(self.player).chargeMove
|
||||||
if stored then move = stored end
|
if stored then move = stored end
|
||||||
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
|
|
||||||
-- engine/battle/effect_commands.asm:193 reads it.
|
|
||||||
if not self:canAct(self.player, move) then return end
|
|
||||||
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
||||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||||
-- Struggle substitution and the obedience roll the same way the second
|
-- Struggle substitution and the obedience roll the same way the second
|
||||||
-- half of a charge move is.
|
-- half of a charge move is.
|
||||||
local charging = self:volatile(self.player).chargeMove == move
|
local charging = self:volatile(self.player).chargeMove == move
|
||||||
or self:lockedInMove(self.player) == move
|
or self:lockedInMove(self.player) == move
|
||||||
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
|
if not charging and not self:hasUsableMoves(self.player) then
|
||||||
-- `checkobedience`.
|
|
||||||
local bideLocked = self:fightLockedMove(self.player) == move
|
|
||||||
if not charging and not bideLocked
|
|
||||||
and not self:hasUsableMoves(self.player) then
|
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(self.player) .. " has no moves left!" })
|
text = self:monName(self.player) .. " has no moves left!" })
|
||||||
move = Battle.STRUGGLE
|
move = Battle.STRUGGLE
|
||||||
@@ -4488,7 +4273,7 @@ local function runTurn(self, action)
|
|||||||
-- against a trainer, could not be escaped either.
|
-- against a trainer, could not be escaped either.
|
||||||
enemyMoveId = Battle.STRUGGLE
|
enemyMoveId = Battle.STRUGGLE
|
||||||
end
|
end
|
||||||
if not self:canAct(self.enemy, enemyMoveId) then return end
|
if not self:canAct(self.enemy) then return end
|
||||||
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
||||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||||
|
|||||||
@@ -326,9 +326,6 @@ function Game:logicSpeed()
|
|||||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
||||||
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
||||||
@@ -478,15 +475,6 @@ function Game.speedCategoryInStack(stack)
|
|||||||
return "menu"
|
return "menu"
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game.isFixedSpeedInStack(stack)
|
|
||||||
local states = stack and stack.states
|
|
||||||
for i = #(states or {}), 1, -1 do
|
|
||||||
local state = states[i]
|
|
||||||
if state and (state.isFixedSpeed or state.isMinigame) then return true end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Whether a state on the stack composes its own screen and so wants the
|
-- Whether a state on the stack composes its own screen and so wants the
|
||||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||||
@@ -609,10 +597,9 @@ function Game:draw()
|
|||||||
-- ...and for the same reason the UI's own scale has to know the world is
|
-- ...and for the same reason the UI's own scale has to know the world is
|
||||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||||
-- gated on this frame's world pass -- which the party menu ends by being
|
-- gated on this frame's world pass -- which the party menu and the bag end
|
||||||
-- opaque (the bag's item box shows the map around it, #1521). Without
|
-- by being opaque. Without this hold they lose the step-down and blit at
|
||||||
-- this hold it loses the step-down and blits at full fit scale over a
|
-- full fit scale over a battle drawn at the zoomed-out one.
|
||||||
-- battle drawn at the zoomed-out one.
|
|
||||||
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
||||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||||
-- instead of letting them dock to the window edge.
|
-- instead of letting them dock to the window edge.
|
||||||
@@ -919,7 +906,16 @@ function Game:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
local isAccelerometer = GamepadMap.isAccelerometer
|
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||||
|
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||||
|
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||||
|
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||||
|
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||||
|
-- never as "* Accelerometer".
|
||||||
|
local function isAccelerometer(joystick)
|
||||||
|
local name = joystick and joystick.getName and joystick:getName()
|
||||||
|
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
||||||
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
||||||
@@ -974,11 +970,7 @@ end
|
|||||||
-- parked the player until every direction was re-pressed (#799).
|
-- parked the player until every direction was re-pressed (#799).
|
||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
if f then
|
if f then Input:reconcile() end
|
||||||
Input:reconcile()
|
|
||||||
local eng = self:syncEngine()
|
|
||||||
if eng then pcall(eng.noteResumed, eng) end
|
|
||||||
end
|
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
@@ -998,8 +990,6 @@ function Game:onResume()
|
|||||||
Input:reconcile()
|
Input:reconcile()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
local eng = self:syncEngine()
|
|
||||||
if eng then pcall(eng.noteResumed, eng) end
|
|
||||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||||
@@ -1207,26 +1197,18 @@ end
|
|||||||
|
|
||||||
function Game:syncEngine()
|
function Game:syncEngine()
|
||||||
if self._syncOff then return nil end
|
if self._syncOff then return nil end
|
||||||
local eng = self._syncEngineRef
|
if self._syncEngineRef then return self._syncEngineRef end
|
||||||
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
|
self._syncOff = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local eng = SyncEngine.shared()
|
||||||
if not eng then
|
if not eng then
|
||||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
self._syncOff = true
|
||||||
if not ok or type(SyncEngine) ~= "table" then
|
return nil
|
||||||
self._syncOff = true
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
eng = SyncEngine.shared()
|
|
||||||
if not eng then
|
|
||||||
self._syncOff = true
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
self._syncEngineRef = eng
|
|
||||||
end
|
|
||||||
if type(eng.protectPlaythrough) == "function" then
|
|
||||||
local meta = self.save and self.save.meta
|
|
||||||
eng:protectPlaythrough(
|
|
||||||
(self.save and self.save.version) or require("src.core.GameVersion").get(),
|
|
||||||
type(meta) == "table" and meta.playthroughId or nil)
|
|
||||||
end
|
end
|
||||||
|
self._syncEngineRef = eng
|
||||||
return eng
|
return eng
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1266,7 +1248,6 @@ function Game:applyOptions(opts)
|
|||||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||||
require("src.core.FaithfulRes").applyOptions(opts)
|
require("src.core.FaithfulRes").applyOptions(opts)
|
||||||
require("src.core.ScreenPosition").applyOptions(opts)
|
|
||||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
-- fpsCap key pace at the standard rate (issue #88)
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
require("src.core.FrameCap").applyOptions(opts)
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
@@ -1381,26 +1362,4 @@ function Game:restoreCheckpointBattle(battle)
|
|||||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Drop every session-owned field so the next Game:load() starts clean when
|
|
||||||
-- the process returns to the launcher in-place (Android / intent_game).
|
|
||||||
-- main.lua must not guess field names: new systems (Game.network, …) are
|
|
||||||
-- cleared automatically because only functions (methods) are kept.
|
|
||||||
function Game:reset()
|
|
||||||
if self.stack and self.stack.clear then
|
|
||||||
pcall(function() self.stack:clear() end)
|
|
||||||
end
|
|
||||||
if self.renderer and self.renderer.releaseCanvases then
|
|
||||||
pcall(function() self.renderer:releaseCanvases() end)
|
|
||||||
end
|
|
||||||
local keys = {}
|
|
||||||
for key, value in pairs(self) do
|
|
||||||
if type(value) ~= "function" then
|
|
||||||
keys[#keys + 1] = key
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for _, key in ipairs(keys) do
|
|
||||||
self[key] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return Game
|
return Game
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
||||||
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
|
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
|
||||||
-- shape and screen registry -- so main.lua's bootGame picks this owner when
|
-- shape and screen registry -- so main.lua's bootGame picks this owner when
|
||||||
-- GameVersion.generation() == 2, and the two never branch into each other.
|
-- GameVersion.isGold(), and the two never branch into each other.
|
||||||
--
|
--
|
||||||
-- Boot: copyright → GameFreak Presents → GS intro stub → title
|
-- Boot: copyright → GameFreak Presents → GS intro stub → title
|
||||||
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
|
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
|
||||||
@@ -18,7 +18,6 @@ local Chrome = require("src.ui.gen2.Chrome")
|
|||||||
local Clock = require("src.core.gen2.Clock")
|
local Clock = require("src.core.gen2.Clock")
|
||||||
local FixedStep = require("src.core.FixedStep")
|
local FixedStep = require("src.core.FixedStep")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local GamepadMap = require("src.core.GamepadMap")
|
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Save = require("src.core.gen2.Save")
|
local Save = require("src.core.gen2.Save")
|
||||||
@@ -58,6 +57,20 @@ Game2.__index = Game2
|
|||||||
|
|
||||||
local function noop() end
|
local function noop() end
|
||||||
|
|
||||||
|
for _, name in ipairs({
|
||||||
|
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||||
|
"joystickadded",
|
||||||
|
}) do
|
||||||
|
Game2[name] = noop
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Not a noop, because the overlay has to come back on its own: a player who
|
||||||
|
-- unplugs the only controller would otherwise have to tap a blind screen to
|
||||||
|
-- get the pad back (src/core/Game.lua:869 does the same).
|
||||||
|
function Game2:joystickremoved()
|
||||||
|
TouchControls:joystickremoved()
|
||||||
|
end
|
||||||
|
|
||||||
-- THE FRAME AND INPUT SEAMS.
|
-- THE FRAME AND INPUT SEAMS.
|
||||||
--
|
--
|
||||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||||
@@ -483,10 +496,8 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
|||||||
if onDone then onDone(learned) end
|
if onDone then onDone(learned) end
|
||||||
end
|
end
|
||||||
if ok then
|
if ok then
|
||||||
-- data/text/common_3.asm:119
|
|
||||||
return self:say(("%s learned\n%s!"):format(name, moveName),
|
return self:say(("%s learned\n%s!"):format(name, moveName),
|
||||||
function() finish(true) end,
|
function() finish(true) end)
|
||||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
|
||||||
end
|
end
|
||||||
if reason ~= "full" then return finish(false) end
|
if reason ~= "full" then return finish(false) end
|
||||||
local askForget, pickMove, askStop
|
local askForget, pickMove, askStop
|
||||||
@@ -541,11 +552,9 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
|||||||
-- The slot is written here rather than through Mon.learnMove, so
|
-- The slot is written here rather than through Mon.learnMove, so
|
||||||
-- pokemon.move_learned is raised here too.
|
-- pokemon.move_learned is raised here too.
|
||||||
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
|
||||||
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
||||||
:format(name, oldName, name, moveName),
|
:format(name, oldName, name, moveName),
|
||||||
function() finish(true) end,
|
function() finish(true) end)
|
||||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -710,9 +719,7 @@ function Game2:usePartyItem(itemId)
|
|||||||
})
|
})
|
||||||
elseif action == "candy" then
|
elseif action == "candy" then
|
||||||
self:consumeItem(itemId)
|
self:consumeItem(itemId)
|
||||||
-- data/text/common_1.asm:86
|
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||||
self:say(result.text, function() self:afterRareCandy(mon, result) end,
|
|
||||||
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
|
|
||||||
else
|
else
|
||||||
self:consumeItem(itemId)
|
self:consumeItem(itemId)
|
||||||
self:say(result.text)
|
self:say(result.text)
|
||||||
@@ -771,10 +778,8 @@ function Game2:useSelectItem()
|
|||||||
local name = (items[itemId] and items[itemId].name) or itemId
|
local name = (items[itemId] and items[itemId].name) or itemId
|
||||||
self:say(Strings("{PLAYER} used the\n%s.", name))
|
self:say(Strings("{PLAYER} used the\n%s.", name))
|
||||||
elseif outcome == "trophy_sent" then
|
elseif outcome == "trophy_sent" then
|
||||||
-- data/text/common_3.asm:372
|
|
||||||
self:say(Strings(
|
self:say(Strings(
|
||||||
"There was a trophy\ninside!\fThe trophy was\nsent home."),
|
"There was a trophy\ninside!\fThe trophy was\nsent home."))
|
||||||
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
|
||||||
end
|
end
|
||||||
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
||||||
-- drives its own presentation off World:step -- nothing left to print here.
|
-- drives its own presentation off World:step -- nothing left to print here.
|
||||||
@@ -786,9 +791,9 @@ end
|
|||||||
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
||||||
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
||||||
-- was a silent extra pop.
|
-- was a silent extra pop.
|
||||||
function Game2:say(text, onDone, opts)
|
function Game2:say(text, onDone)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
self.stack:push(TextBox.new(self, text, onDone, opts))
|
self.stack:push(TextBox.new(self, text, onDone))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The landmark the player is standing in, for the Pokegear map's marker.
|
-- The landmark the player is standing in, for the Pokegear map's marker.
|
||||||
@@ -943,12 +948,6 @@ function Game2:load()
|
|||||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||||
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
||||||
-- The engine's own strings, keyed by the disassembly's label. gen2Text
|
|
||||||
-- above is the script text and is keyed by bank:address for the overworld
|
|
||||||
-- VM, so the two are different tables and both are loaded. This one is
|
|
||||||
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
|
|
||||||
-- helper is shared with Gen 1 and looks up data.text[label].
|
|
||||||
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
|
|
||||||
-- data/generated/events.lua: the side tables a script command NAMES rather
|
-- data/generated/events.lua: the side tables a script command NAMES rather
|
||||||
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
||||||
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
||||||
@@ -1661,9 +1660,8 @@ function Game2:drawScene(w, h)
|
|||||||
-- row to opt into the step-down half, so CENTERED is the whole rule
|
-- row to opt into the step-down half, so CENTERED is the whole rule
|
||||||
-- here.
|
-- here.
|
||||||
local s = self.world:fitScale()
|
local s = self.world:fitScale()
|
||||||
local ox, oy = Chrome.fitOrigin(w, h, s)
|
|
||||||
G.push()
|
G.push()
|
||||||
G.translate(ox, oy)
|
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
|
||||||
G.scale(s, s)
|
G.scale(s, s)
|
||||||
self.stack:draw()
|
self.stack:draw()
|
||||||
G.pop()
|
G.pop()
|
||||||
@@ -1704,7 +1702,7 @@ function Game2:hotkey(key)
|
|||||||
self:writeSave()
|
self:writeSave()
|
||||||
return true
|
return true
|
||||||
elseif key == "f2" then
|
elseif key == "f2" then
|
||||||
local loaded = Save.load()
|
local loaded = Save.load("gold")
|
||||||
if loaded then self:continueGame(loaded) end
|
if loaded then self:continueGame(loaded) end
|
||||||
return true
|
return true
|
||||||
elseif key == "1" then
|
elseif key == "1" then
|
||||||
@@ -1969,11 +1967,7 @@ function Game2:applyOptions()
|
|||||||
local options = self.options or {}
|
local options = self.options or {}
|
||||||
Music.applyOptions(options)
|
Music.applyOptions(options)
|
||||||
require("src.core.Sound").applyOptions(options)
|
require("src.core.Sound").applyOptions(options)
|
||||||
local Zoom = require("src.render.Zoom")
|
require("src.render.Zoom").applyOptions(options)
|
||||||
Zoom.applyOptions(options)
|
|
||||||
local caps = require("src.core.Performance").applyOptions(options)
|
|
||||||
Zoom.allowSurvey = caps.survey
|
|
||||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
|
||||||
require("src.render.Tilt").applyOptions(options)
|
require("src.render.Tilt").applyOptions(options)
|
||||||
require("src.render.GbcPalette").applyOptions(options)
|
require("src.render.GbcPalette").applyOptions(options)
|
||||||
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
||||||
@@ -1988,7 +1982,6 @@ function Game2:applyOptions()
|
|||||||
haptics = options.haptics,
|
haptics = options.haptics,
|
||||||
})
|
})
|
||||||
require("src.core.VideoMode").applyOptions(options)
|
require("src.core.VideoMode").applyOptions(options)
|
||||||
require("src.core.ScreenPosition").applyOptions(options)
|
|
||||||
require("src.core.FrameCap").applyOptions(options)
|
require("src.core.FrameCap").applyOptions(options)
|
||||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
@@ -1998,13 +1991,6 @@ function Game2:applyOptions()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game2:_cycleSpeed(dir)
|
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
|
||||||
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
|
||||||
if self.save then self.save.options = self.options end
|
|
||||||
self:persistOptions()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
|
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
|
||||||
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
|
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
|
||||||
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
|
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
|
||||||
@@ -2015,49 +2001,27 @@ end
|
|||||||
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
|
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
|
||||||
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
|
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
|
||||||
-- to find out killed the process. It reaches Input like every other button now.
|
-- to find out killed the process. It reaches Input like every other button now.
|
||||||
function Game2:gamepadpressed(joystick, button)
|
function Game2:gamepadpressed(_joystick, button)
|
||||||
-- a controller is being used: the touch overlay steps aside until the next
|
-- a controller is being used: the touch overlay steps aside until the next
|
||||||
-- screen touch (mobile only; a no-op elsewhere)
|
-- screen touch (mobile only; a no-op elsewhere)
|
||||||
TouchControls:noteGamepad()
|
TouchControls:noteGamepad()
|
||||||
local selectHeld = Input:isDown("select")
|
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path.
|
||||||
if not selectHeld and joystick and joystick.isGamepadDown then
|
if button == "rightshoulder" or button == "leftshoulder" then
|
||||||
local ok, down = pcall(function()
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
return joystick:isGamepadDown("back")
|
local dir = button == "rightshoulder" and 1 or -1
|
||||||
end)
|
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
||||||
selectHeld = ok and down == true
|
if self.save then self.save.options = self.options end
|
||||||
end
|
self:persistOptions()
|
||||||
-- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881
|
|
||||||
if not selectHeld then
|
|
||||||
if button == "rightshoulder" or button == "righttrigger" then
|
|
||||||
self:_cycleSpeed(1)
|
|
||||||
return
|
|
||||||
elseif button == "leftshoulder" or button == "lefttrigger" then
|
|
||||||
self:_cycleSpeed(-1)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local top = self.stack and self.stack:top()
|
|
||||||
if top and top.onGamepadPressed then
|
|
||||||
top:onGamepadPressed(button)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if selectHeld then
|
|
||||||
local digit = GamepadMap.displayChordDigit(button)
|
|
||||||
if digit then
|
|
||||||
self:keypressed(digit)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- START opens the start menu in the overworld; it used to quit, from before
|
-- START opens the start menu in the overworld; it used to quit, from before
|
||||||
-- there was a menu to open.
|
-- there was a menu to open.
|
||||||
|
|
||||||
Input:gamepadpressed(joystick, button)
|
Input:gamepadpressed(_joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game2:gamepadreleased(joystick, button)
|
function Game2:gamepadreleased(joystick, button)
|
||||||
Input:gamepadreleased(joystick, button)
|
Input:gamepadreleased(joystick, button)
|
||||||
local top = self.stack and self.stack:top()
|
|
||||||
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game2:gamepadaxis(joystick, axis, value)
|
function Game2:gamepadaxis(joystick, axis, value)
|
||||||
@@ -2066,79 +2030,4 @@ function Game2:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
|
|
||||||
local function isRawStick(joystick)
|
|
||||||
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
|
||||||
end
|
|
||||||
|
|
||||||
function Game2:joystickpressed(joystick, button)
|
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
TouchControls:noteGamepad()
|
|
||||||
local top = self.stack and self.stack:top()
|
|
||||||
if isRawStick(joystick) and top and top.onJoystickPressed then
|
|
||||||
top:onJoystickPressed(button)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
Input:joystickpressed(joystick, button)
|
|
||||||
end
|
|
||||||
|
|
||||||
function Game2:joystickreleased(joystick, button)
|
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
Input:joystickreleased(joystick, button)
|
|
||||||
local top = self.stack and self.stack:top()
|
|
||||||
if isRawStick(joystick) and top and top.onJoystickReleased then
|
|
||||||
top:onJoystickReleased(button)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function Game2:joystickaxis(joystick, axis, value)
|
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
|
||||||
Input:joystickaxis(joystick, axis, value)
|
|
||||||
end
|
|
||||||
|
|
||||||
function Game2:joystickhat(joystick, hat, direction)
|
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
if direction ~= "c" then TouchControls:noteGamepad() end
|
|
||||||
Input:joystickhat(joystick, hat, direction)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- src/core/Game.lua:1015 (#799)
|
|
||||||
function Game2:recoverInput()
|
|
||||||
Input:reset()
|
|
||||||
Input:reconcile()
|
|
||||||
TouchControls:reset()
|
|
||||||
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
|
|
||||||
self:cancelPointers()
|
|
||||||
end
|
|
||||||
|
|
||||||
function Game2:joystickadded()
|
|
||||||
self:recoverInput()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The overlay comes back on its own when the last pad is unplugged
|
|
||||||
-- (src/core/Game.lua:1044).
|
|
||||||
function Game2:joystickremoved()
|
|
||||||
self:recoverInput()
|
|
||||||
TouchControls:joystickremoved()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- In-process return-to-launcher (Android / intent_game): drop session fields
|
|
||||||
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
|
|
||||||
-- Methods live on the class table; pairs(self) only sees instance state.
|
|
||||||
function Game2:reset()
|
|
||||||
if self.stack and self.stack.clear then
|
|
||||||
pcall(function() self.stack:clear() end)
|
|
||||||
end
|
|
||||||
local keys = {}
|
|
||||||
for key, value in pairs(self) do
|
|
||||||
if type(value) ~= "function" then
|
|
||||||
keys[#keys + 1] = key
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for _, key in ipairs(keys) do
|
|
||||||
self[key] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return Game2
|
return Game2
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Which game this process is running: Red (the historical default), Blue,
|
-- Which game this process is running: Red (the historical default), Blue,
|
||||||
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
-- Yellow, or Gold. One source of truth for everything that differs by
|
||||||
-- version -- the accepted ROM hash, the import manifest, where the
|
-- version -- the accepted ROM hash, the import manifest, where the
|
||||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||||
-- cache mount, SaveData, title screen and palette all agree.
|
-- cache mount, SaveData, title screen and palette all agree.
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
||||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||||
-- and selected side by side. Gold and Silver are Gen 2 (see
|
-- and selected side by side. Gold is Gen 2 (see docs/gold-phase1.md).
|
||||||
-- docs/gold-phase1.md).
|
|
||||||
--
|
--
|
||||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||||
-- and tests. The active version is a process-global set once at boot from
|
-- and tests. The active version is a process-global set once at boot from
|
||||||
@@ -62,26 +61,13 @@ GameVersion.VERSIONS = {
|
|||||||
manifest = "tools/rom_manifest_gold.json",
|
manifest = "tools/rom_manifest_gold.json",
|
||||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||||
-- Absent reads as 1 (GameVersion.generation)
|
-- The only row that carries one; absent reads as 1 (GameVersion.generation)
|
||||||
generation = 2,
|
|
||||||
},
|
|
||||||
-- Gold's engine with edition-selected data; the manifest is derived from
|
|
||||||
-- Gold's by tools/make_silver_manifest.py.
|
|
||||||
silver = {
|
|
||||||
id = "silver",
|
|
||||||
label = "Silver",
|
|
||||||
displayName = "Pokemon Silver",
|
|
||||||
launcherName = "Silver (Beta)",
|
|
||||||
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
|
||||||
manifest = "tools/rom_manifest_silver.json",
|
|
||||||
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
|
||||||
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
|
||||||
generation = 2,
|
generation = 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Launcher column order.
|
-- Launcher column order.
|
||||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
|
||||||
|
|
||||||
GameVersion.current = "red"
|
GameVersion.current = "red"
|
||||||
|
|
||||||
|
|||||||
@@ -106,17 +106,6 @@ function GamepadMap.ignoreRawForJoystick(joystick)
|
|||||||
return ok and isPad == true
|
return ok and isPad == true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
|
||||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
|
||||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
|
||||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
|
||||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
|
||||||
-- never as "* Accelerometer".
|
|
||||||
function GamepadMap.isAccelerometer(joystick)
|
|
||||||
local name = joystick and joystick.getName and joystick:getName()
|
|
||||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
function GamepadMap.mapRawButton(index)
|
function GamepadMap.mapRawButton(index)
|
||||||
if nxActive() then
|
if nxActive() then
|
||||||
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
||||||
|
|||||||
@@ -281,7 +281,6 @@ end
|
|||||||
|
|
||||||
function Input:joystickpressed(joystick, button)
|
function Input:joystickpressed(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
noteCapture(self, "joy", "pressed", button)
|
noteCapture(self, "joy", "pressed", button)
|
||||||
local btn = self.joyBindings[button]
|
local btn = self.joyBindings[button]
|
||||||
if btn then press(self, btn, "joy:" .. button) end
|
if btn then press(self, btn, "joy:" .. button) end
|
||||||
@@ -289,7 +288,6 @@ end
|
|||||||
|
|
||||||
function Input:joystickreleased(joystick, button)
|
function Input:joystickreleased(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
noteCapture(self, "joy", "released", button)
|
noteCapture(self, "joy", "released", button)
|
||||||
local btn = self.joyBindings[button]
|
local btn = self.joyBindings[button]
|
||||||
if btn then release(self, btn, "joy:" .. button) end
|
if btn then release(self, btn, "joy:" .. button) end
|
||||||
@@ -332,7 +330,6 @@ end
|
|||||||
|
|
||||||
function Input:joystickaxis(joystick, axis, value)
|
function Input:joystickaxis(joystick, axis, value)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
if axis == 1 then
|
if axis == 1 then
|
||||||
self:gamepadaxis(joystick, "leftx", value)
|
self:gamepadaxis(joystick, "leftx", value)
|
||||||
elseif axis == 2 then
|
elseif axis == 2 then
|
||||||
@@ -346,7 +343,6 @@ end
|
|||||||
-- directions on top of a direction rebind.
|
-- directions on top of a direction rebind.
|
||||||
function Input:joystickhat(joystick, hat, direction)
|
function Input:joystickhat(joystick, hat, direction)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
if GamepadMap.isAccelerometer(joystick) then return end
|
|
||||||
local source = "hat:" .. hat
|
local source = "hat:" .. hat
|
||||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||||
release(self, btn, source)
|
release(self, btn, source)
|
||||||
@@ -384,8 +380,7 @@ function Input:reconcile()
|
|||||||
local ok, joysticks = pcall(js.getJoysticks)
|
local ok, joysticks = pcall(js.getJoysticks)
|
||||||
if not ok or type(joysticks) ~= "table" then return end
|
if not ok or type(joysticks) ~= "table" then return end
|
||||||
for _, j in ipairs(joysticks) do
|
for _, j in ipairs(joysticks) do
|
||||||
if GamepadMap.isAccelerometer(j) then
|
if GamepadMap.ignoreRawForJoystick(j) then
|
||||||
elseif GamepadMap.ignoreRawForJoystick(j) then
|
|
||||||
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
||||||
if j.isGamepadDown then
|
if j.isGamepadDown then
|
||||||
for button, btn in pairs(self.padBindings) do
|
for button, btn in pairs(self.padBindings) do
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ local function normalizeVersion(v)
|
|||||||
b = "blue", blue = "blue",
|
b = "blue", blue = "blue",
|
||||||
y = "yellow", yellow = "yellow",
|
y = "yellow", yellow = "yellow",
|
||||||
g = "gold", gold = "gold",
|
g = "gold", gold = "gold",
|
||||||
s = "silver", silver = "silver",
|
|
||||||
}
|
}
|
||||||
v = alias[v] or v
|
v = alias[v] or v
|
||||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ end
|
|||||||
|
|
||||||
function Music.play(data, song, loop, ctx)
|
function Music.play(data, song, loop, ctx)
|
||||||
if not song then return end
|
if not song then return end
|
||||||
if not (love and love.audio) then return end -- headless test stub
|
if not love.audio then return end -- headless test stub
|
||||||
ctx = ctx or {}
|
ctx = ctx or {}
|
||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Screen orientation lock, Android and iOS (#592, #716, #1638).
|
-- Screen orientation lock, Android only (#592, #716).
|
||||||
--
|
--
|
||||||
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
|
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
|
||||||
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
|
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
|
||||||
@@ -10,12 +10,16 @@
|
|||||||
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
|
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
|
||||||
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
|
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
|
||||||
--
|
--
|
||||||
-- Android only re-reads the hint at window creation or on a resizable-flag
|
-- SDL only re-reads the hint when the window is created or its resizable
|
||||||
-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
|
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
|
||||||
-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
|
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
|
||||||
-- always is -- so the hint never reached a running activity (#1638).
|
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
|
||||||
-- apply() sets the hint for a later window, then goes over JNI for the live
|
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
|
||||||
-- one. iOS needs only the hint. Desktop and headless stubs no-op.
|
-- flag off and back on -- each edge makes the Android backend recompute the
|
||||||
|
-- requested orientation, so a change from the launcher or the OPTION menu
|
||||||
|
-- takes hold immediately, and the flag ends where it started (conf.lua sets
|
||||||
|
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
|
||||||
|
-- Info.plist governs there) and headless stubs make this a no-op.
|
||||||
|
|
||||||
local Orientation = {}
|
local Orientation = {}
|
||||||
|
|
||||||
@@ -54,11 +58,6 @@ function Orientation.isAndroid()
|
|||||||
return love.system.getOS() == "Android"
|
return love.system.getOS() == "Android"
|
||||||
end
|
end
|
||||||
|
|
||||||
function Orientation.isIOS()
|
|
||||||
if not love or not love.system or not love.system.getOS then return false end
|
|
||||||
return love.system.getOS() == "iOS"
|
|
||||||
end
|
|
||||||
|
|
||||||
function Orientation.cycle(mode, dir)
|
function Orientation.cycle(mode, dir)
|
||||||
local cur, idx = Orientation.normalize(mode), 1
|
local cur, idx = Orientation.normalize(mode), 1
|
||||||
for i, m in ipairs(Orientation.MODES) do
|
for i, m in ipairs(Orientation.MODES) do
|
||||||
@@ -68,15 +67,6 @@ function Orientation.cycle(mode, dir)
|
|||||||
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
|
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ActivityInfo constants, what setOrientationBis lands on per hint after
|
|
||||||
-- GameActivity's *_SENSOR -> *_USER remap (#716).
|
|
||||||
local REQUESTED = {
|
|
||||||
auto = 13,
|
|
||||||
portrait = 1,
|
|
||||||
landscape = 11,
|
|
||||||
reverseLandscape = 8,
|
|
||||||
}
|
|
||||||
|
|
||||||
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
|
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
|
||||||
-- once and remember whether it took; ffi itself may be absent (plain Lua
|
-- once and remember whether it took; ffi itself may be absent (plain Lua
|
||||||
-- test interpreters), hence the pcall'd require.
|
-- test interpreters), hence the pcall'd require.
|
||||||
@@ -87,79 +77,33 @@ local function sdlFfi()
|
|||||||
if cdefOk == nil then
|
if cdefOk == nil then
|
||||||
cdefOk = pcall(ffi.cdef, [[
|
cdefOk = pcall(ffi.cdef, [[
|
||||||
typedef struct SDL_Window SDL_Window;
|
typedef struct SDL_Window SDL_Window;
|
||||||
typedef union { int32_t i; int64_t pad; } love_jvalue;
|
|
||||||
int SDL_SetHint(const char *name, const char *value);
|
int SDL_SetHint(const char *name, const char *value);
|
||||||
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||||
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
|
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
|
||||||
void *SDL_AndroidGetJNIEnv(void);
|
|
||||||
void *SDL_AndroidGetActivity(void);
|
|
||||||
]])
|
]])
|
||||||
end
|
end
|
||||||
if not cdefOk then return nil end
|
if not cdefOk then return nil end
|
||||||
return ffi
|
return ffi
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Slot numbers in JNINativeInterface (jni.h).
|
-- Push the mode into the live activity. Returns true when the hint reached
|
||||||
local JNI_EXCEPTION_CLEAR = 17
|
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
|
||||||
local JNI_DELETE_LOCAL_REF = 23
|
|
||||||
local JNI_GET_OBJECT_CLASS = 31
|
|
||||||
local JNI_GET_METHOD_ID = 33
|
|
||||||
local JNI_CALL_VOID_METHOD_A = 63
|
|
||||||
|
|
||||||
-- What Android_JNI_SetOrientation reaches, called directly: the hint path
|
|
||||||
-- cannot re-run on a live fullscreen window (SDL_video.c:2237).
|
|
||||||
local function setRequestedOrientation(ffi, requested)
|
|
||||||
local env = ffi.C.SDL_AndroidGetJNIEnv()
|
|
||||||
if env == nil then return false end
|
|
||||||
local activity = ffi.C.SDL_AndroidGetActivity()
|
|
||||||
if activity == nil then return false end
|
|
||||||
local fns = ffi.cast("void***", env)[0]
|
|
||||||
local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS])
|
|
||||||
local getMethodID = ffi.cast(
|
|
||||||
"void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID])
|
|
||||||
local callVoidMethodA = ffi.cast(
|
|
||||||
"void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A])
|
|
||||||
local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF])
|
|
||||||
local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR])
|
|
||||||
|
|
||||||
local ok = false
|
|
||||||
local cls = getObjectClass(env, activity)
|
|
||||||
if cls ~= nil then
|
|
||||||
local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V")
|
|
||||||
if mid ~= nil then
|
|
||||||
local args = ffi.new("love_jvalue[1]")
|
|
||||||
args[0].pad = 0
|
|
||||||
args[0].i = requested
|
|
||||||
callVoidMethodA(env, activity, mid, args)
|
|
||||||
ok = true
|
|
||||||
end
|
|
||||||
exceptionClear(env)
|
|
||||||
deleteLocalRef(env, cls)
|
|
||||||
end
|
|
||||||
deleteLocalRef(env, activity)
|
|
||||||
return ok
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Returns true only when the request actually landed, never unconditionally
|
|
||||||
-- as it once did (#1638).
|
|
||||||
function Orientation.apply(mode)
|
function Orientation.apply(mode)
|
||||||
local android = Orientation.isAndroid()
|
if not Orientation.isAndroid() then return false end
|
||||||
if not (android or Orientation.isIOS()) then return false end
|
|
||||||
local ffi = sdlFfi()
|
local ffi = sdlFfi()
|
||||||
if not ffi then return false end
|
if not ffi then return false end
|
||||||
mode = Orientation.normalize(mode)
|
mode = Orientation.normalize(mode)
|
||||||
local ok, reached = pcall(function()
|
local ok = pcall(function()
|
||||||
-- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
|
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
|
||||||
-- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each
|
-- despite the IOS in the string, the Android backend reads it too.
|
||||||
-- engine ignores the other's key.
|
|
||||||
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
|
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
|
||||||
ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
|
local win = ffi.C.SDL_GL_GetCurrentWindow()
|
||||||
-- On iOS the hint is the lock: UIKit re-asks on every rotation
|
if win ~= nil then
|
||||||
-- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
|
ffi.C.SDL_SetWindowResizable(win, 0)
|
||||||
if not android then return true end
|
ffi.C.SDL_SetWindowResizable(win, 1)
|
||||||
return setRequestedOrientation(ffi, REQUESTED[mode])
|
end
|
||||||
end)
|
end)
|
||||||
return ok and reached == true
|
return ok
|
||||||
end
|
end
|
||||||
|
|
||||||
function Orientation.applyOptions(opts)
|
function Orientation.applyOptions(opts)
|
||||||
|
|||||||
@@ -86,11 +86,8 @@ function Performance.detect()
|
|||||||
local cores = processorCount()
|
local cores = processorCount()
|
||||||
|
|
||||||
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||||
-- already ships a build for): the weakest target here. Desktop ARM
|
-- already ships a build for): the weakest target here.
|
||||||
-- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
|
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||||
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
|
|
||||||
-- OPTIONS so the ZOOM row only offered IN.
|
|
||||||
if isArm and os == "Linux" then
|
|
||||||
return "low"
|
return "low"
|
||||||
end
|
end
|
||||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ function SaveData.gameFolders()
|
|||||||
local src = love.filesystem.getSource and love.filesystem.getSource()
|
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||||
local sbd = love.filesystem.getSourceBaseDirectory
|
local sbd = love.filesystem.getSourceBaseDirectory
|
||||||
and love.filesystem.getSourceBaseDirectory()
|
and love.filesystem.getSourceBaseDirectory()
|
||||||
-- A packaged macOS build nests the game inside gen1recomp.app/Contents/
|
-- A packaged macOS build nests the game inside gen1recomp++.app/Contents/
|
||||||
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
|
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
|
||||||
-- bundle -- not where the player drops portable.txt (next to the .app).
|
-- bundle -- not where the player drops portable.txt (next to the .app).
|
||||||
-- Recover the folder containing the .app so a packaged app finds its
|
-- Recover the folder containing the .app so a packaged app finds its
|
||||||
@@ -285,7 +285,6 @@ function SaveData.defaultOptions()
|
|||||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||||
faithfulRes = 0,
|
faithfulRes = 0,
|
||||||
screenPos = "center",
|
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||||
@@ -1280,26 +1279,13 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
|||||||
local isFresh = save == freshPlaythrough
|
local isFresh = save == freshPlaythrough
|
||||||
if isFresh then freshPlaythrough = nil end
|
if isFresh then freshPlaythrough = nil end
|
||||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
local existing = byVersion and byVersion[scope]
|
id = not isFresh and byVersion and byVersion[scope] or nil
|
||||||
id = not isFresh and existing or nil
|
|
||||||
if type(id) ~= "string" or id == "" then
|
if type(id) ~= "string" or id == "" then
|
||||||
id = SaveData.newPlaythroughId()
|
id = SaveData.newPlaythroughId()
|
||||||
-- A fresh skeleton still gets its own id (two unsaved New Games sharing a
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
-- slot must stay distinct), and it is still persisted when the slot has no
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
-- binding yet -- that is the contract a tool relies on to resolve
|
opts.playthroughIds[version][scope] = id
|
||||||
-- `selected` at the title after a restart, before any normal SAVE.
|
SaveData.saveOptions(opts, injectedFs)
|
||||||
--
|
|
||||||
-- What it must NOT do is OVERWRITE a binding that already exists. newGame()
|
|
||||||
-- marks a skeleton on the boot frame, before any save is loaded, and mods
|
|
||||||
-- initialise inside that window -- so a mod touching storage at init
|
|
||||||
-- replaced the real save's id with a throwaway, stranding that save's mod
|
|
||||||
-- storage and repeating on every launch.
|
|
||||||
if not (isFresh and type(existing) == "string" and existing ~= "") then
|
|
||||||
opts.playthroughIds = opts.playthroughIds or {}
|
|
||||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
|
||||||
opts.playthroughIds[version][scope] = id
|
|
||||||
SaveData.saveOptions(opts, injectedFs)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
save.meta.playthroughId = id
|
save.meta.playthroughId = id
|
||||||
return id
|
return id
|
||||||
@@ -1700,14 +1686,6 @@ local function clamp(n, lo, hi, fallback)
|
|||||||
return n
|
return n
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PP and the PP Up count are unsigned bit fields of one byte
|
|
||||||
-- (constants/pokemon_data_constants.asm:101-102)
|
|
||||||
local function ppInt(v, fallback)
|
|
||||||
local n = tonumber(v)
|
|
||||||
if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end
|
|
||||||
return math.max(0, math.floor(n))
|
|
||||||
end
|
|
||||||
|
|
||||||
local function ensureOrphaned(save)
|
local function ensureOrphaned(save)
|
||||||
if not save.orphaned then
|
if not save.orphaned then
|
||||||
save.orphaned = { mons = {}, items = {} }
|
save.orphaned = { mons = {}, items = {} }
|
||||||
@@ -1771,7 +1749,7 @@ local function scrubKnownMon(mon, data)
|
|||||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||||
-- so the derived stats use a sane level. A complete stat block is
|
-- so the derived stats use a sane level. A save that already has stats is
|
||||||
-- untouched.
|
-- untouched.
|
||||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||||
local moves = mon.moves
|
local moves = mon.moves
|
||||||
@@ -1791,18 +1769,6 @@ local function scrubKnownMon(mon, data)
|
|||||||
moves[1] = { id = fallback, pp = def.pp }
|
moves[1] = { id = fallback, pp = def.pp }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418);
|
|
||||||
-- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266)
|
|
||||||
for j = 1, #moves do
|
|
||||||
local slot = moves[j]
|
|
||||||
if type(slot) == "table" then
|
|
||||||
local mdef = data.moves and data.moves[slot.id]
|
|
||||||
local base = ppInt(mdef and mdef.pp, 0)
|
|
||||||
local ppUps = math.min(3, ppInt(slot.ppUps, 0))
|
|
||||||
if slot.ppUps ~= nil then slot.ppUps = ppUps end
|
|
||||||
slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scrubMonList(list, where, save, data, report)
|
local function scrubMonList(list, where, save, data, report)
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
local ScreenPosition = {}
|
|
||||||
|
|
||||||
ScreenPosition.MODES = { "center", "upper", "top" }
|
|
||||||
ScreenPosition.DEFAULT = "center"
|
|
||||||
ScreenPosition.mode = ScreenPosition.DEFAULT
|
|
||||||
|
|
||||||
local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" }
|
|
||||||
|
|
||||||
function ScreenPosition.normalize(v)
|
|
||||||
if LABELS[v] then return v end
|
|
||||||
return ScreenPosition.DEFAULT
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.label(v)
|
|
||||||
if ScreenPosition.skinActive() then return "SKIN" end
|
|
||||||
return LABELS[ScreenPosition.normalize(v)]
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.cycle(v, dir)
|
|
||||||
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
|
|
||||||
v = ScreenPosition.normalize(v)
|
|
||||||
local modes = ScreenPosition.MODES
|
|
||||||
local cur = 1
|
|
||||||
for i, mode in ipairs(modes) do
|
|
||||||
if mode == v then cur = i break end
|
|
||||||
end
|
|
||||||
return modes[(cur - 1 + (dir or 1)) % #modes + 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.setMode(v)
|
|
||||||
ScreenPosition.mode = ScreenPosition.normalize(v)
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.applyOptions(opts)
|
|
||||||
ScreenPosition.setMode(opts and opts.screenPos)
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.safeTop()
|
|
||||||
local ok, SafeArea = pcall(require, "src.core.SafeArea")
|
|
||||||
if not ok then return 0 end
|
|
||||||
local okr, _, y = pcall(SafeArea.rect)
|
|
||||||
if not okr then return 0 end
|
|
||||||
return math.max(0, tonumber(y) or 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.skinActive(w, h)
|
|
||||||
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
|
||||||
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
|
||||||
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
|
|
||||||
w, h = love.graphics.getDimensions()
|
|
||||||
end
|
|
||||||
local okv, x = pcall(TouchSkin.viewport, w, h)
|
|
||||||
return okv and x ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
function ScreenPosition.lift(viewH, contentH, safeTop)
|
|
||||||
if ScreenPosition.mode == "center" then return 0 end
|
|
||||||
viewH = tonumber(viewH) or 0
|
|
||||||
contentH = tonumber(contentH) or 0
|
|
||||||
local slack = viewH - contentH
|
|
||||||
if slack <= 0 then return 0 end
|
|
||||||
local centered = math.floor(slack / 2)
|
|
||||||
local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4)
|
|
||||||
safeTop = math.floor(tonumber(safeTop) or 0)
|
|
||||||
if safeTop > 0 and target < safeTop then
|
|
||||||
target = math.min(safeTop, centered)
|
|
||||||
end
|
|
||||||
return centered - target
|
|
||||||
end
|
|
||||||
|
|
||||||
return ScreenPosition
|
|
||||||
@@ -154,14 +154,8 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
|
|||||||
return newFileSource(def)
|
return newFileSource(def)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function deviceSuspended()
|
|
||||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
|
||||||
return ChipAudio ~= nil and ChipAudio.isSuspended()
|
|
||||||
end
|
|
||||||
|
|
||||||
local function playPath(data, key, def, pitch, tempo, plain)
|
local function playPath(data, key, def, pitch, tempo, plain)
|
||||||
if not love.audio or not def then return nil end
|
if not love.audio or not def then return nil end
|
||||||
if deviceSuspended() then return nil end
|
|
||||||
local src = cache[key]
|
local src = cache[key]
|
||||||
if src == false then return nil end -- known bad, already logged
|
if src == false then return nil end -- known bad, already logged
|
||||||
if not src then
|
if not src then
|
||||||
@@ -608,7 +602,6 @@ end
|
|||||||
-- cache carries no clips (Red/Blue) or headless.
|
-- cache carries no clips (Red/Blue) or headless.
|
||||||
function Sound.playPikaCry(data, n)
|
function Sound.playPikaCry(data, n)
|
||||||
if not love.audio then return nil end
|
if not love.audio then return nil end
|
||||||
if deviceSuspended() then return nil end
|
|
||||||
local count = data.audio and data.audio.pikaCries
|
local count = data.audio and data.audio.pikaCries
|
||||||
if not count then return nil end
|
if not count then return nil end
|
||||||
n = math.max(1, math.min(count, n or 1))
|
n = math.max(1, math.min(count, n or 1))
|
||||||
@@ -640,7 +633,6 @@ end
|
|||||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||||
function Sound.playCry(data, species, pikaClip)
|
function Sound.playCry(data, species, pikaClip)
|
||||||
if not love.audio then return nil end
|
if not love.audio then return nil end
|
||||||
if deviceSuspended() then return nil end
|
|
||||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||||
-- never used for the species there). Which clip is a property of the
|
-- never used for the species there). Which clip is a property of the
|
||||||
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
|||||||
local ssW = dpadW * 0.30
|
local ssW = dpadW * 0.30
|
||||||
local margin = dpadW * 0.12
|
local margin = dpadW * 0.12
|
||||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
if ok and GameVersion.generation and GameVersion.generation() == 2 then
|
if ok and GameVersion.isGold and GameVersion.isGold() then
|
||||||
margin = math.max(margin, math.min(ww * 0.10, 72))
|
margin = math.max(margin, math.min(ww * 0.10, 72))
|
||||||
end
|
end
|
||||||
return {
|
return {
|
||||||
@@ -293,9 +293,7 @@ function TouchControls:applyOptions(opts)
|
|||||||
-- launcher editor round-trips through config() (#806)
|
-- launcher editor round-trips through config() (#806)
|
||||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||||
TouchSkin.setOverlayLive(self.active)
|
TouchSkin.setOverlayLive(self.active)
|
||||||
-- Off means off everywhere: do not leave a hidden selected skin behind to
|
self:selectSkin(cfg.skin)
|
||||||
-- influence renderer placement on desktop or with a controller attached.
|
|
||||||
self:selectSkin(cfg.enabled and cfg.skin or nil)
|
|
||||||
self.layouts = cfg.layouts
|
self.layouts = cfg.layouts
|
||||||
self.layoutW, self.layoutH = nil, nil
|
self.layoutW, self.layoutH = nil, nil
|
||||||
self.layoutOx, self.layoutOy = nil, nil
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
@@ -337,10 +335,7 @@ function TouchControls:visible()
|
|||||||
local art = TouchSkin.active ~= nil or self.img ~= nil
|
local art = TouchSkin.active ~= nil or self.img ~= nil
|
||||||
if self.preview then return art end
|
if self.preview then return art end
|
||||||
if self.enabled == false or not art then return false end
|
if self.enabled == false or not art then return false end
|
||||||
-- A selected skin is also a desktop/TV bezel. Input remains gated in
|
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
|
||||||
-- touchpressed, but the artwork must not disappear when a controller is
|
|
||||||
-- connected or the platform is not touch-first.
|
|
||||||
if TouchSkin.active then return true end
|
|
||||||
return self.active and not self.controllerHidden
|
return self.active and not self.controllerHidden
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -783,19 +778,12 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
|||||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawCovered(img, x, y, w, h, alpha)
|
local function drawStretched(img, x, y, w, h, alpha)
|
||||||
if not img or alpha <= 0 then return end
|
if not img or alpha <= 0 then return end
|
||||||
local iw, ih = img:getWidth(), img:getHeight()
|
local iw, ih = img:getWidth(), img:getHeight()
|
||||||
if iw <= 0 or ih <= 0 then return end
|
if iw <= 0 or ih <= 0 then return end
|
||||||
-- Cover the assigned box with one uniform scale and crop the excess. The
|
|
||||||
-- old independent X/Y scale made portrait art visibly squash on wide
|
|
||||||
-- displays (and vice versa).
|
|
||||||
local s = math.max(w / iw, h / ih)
|
|
||||||
local dw, dh = iw * s, ih * s
|
|
||||||
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
||||||
love.graphics.setScissor(x, y, w, h)
|
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
|
||||||
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
|
|
||||||
love.graphics.setScissor()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:drawSkin(alphaMul)
|
function TouchControls:drawSkin(alphaMul)
|
||||||
@@ -807,7 +795,7 @@ function TouchControls:drawSkin(alphaMul)
|
|||||||
|
|
||||||
love.graphics.push("all")
|
love.graphics.push("all")
|
||||||
love.graphics.origin()
|
love.graphics.origin()
|
||||||
drawCovered(page.image, bx, by, bw, bh, opacity)
|
drawStretched(page.image, bx, by, bw, bh, opacity)
|
||||||
|
|
||||||
local pressed = {}
|
local pressed = {}
|
||||||
for _, touch in pairs(self.touches or {}) do
|
for _, touch in pairs(self.touches or {}) do
|
||||||
@@ -822,7 +810,7 @@ function TouchControls:drawSkin(alphaMul)
|
|||||||
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
||||||
local alpha = opacity
|
local alpha = opacity
|
||||||
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
||||||
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ function TouchSkin.parse(text)
|
|||||||
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
||||||
w = num(vp[3], 1), h = num(vp[4], 1) }
|
w = num(vp[3], 1), h = num(vp[4], 1) }
|
||||||
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
||||||
|
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||||
end
|
end
|
||||||
|
|
||||||
page.pixelCoords = not page.normalized
|
page.pixelCoords = not page.normalized
|
||||||
@@ -446,6 +447,7 @@ function TouchSkin.parseNative(text)
|
|||||||
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
||||||
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
||||||
page.viewportFill = raw.viewport.fill == true
|
page.viewportFill = raw.viewport.fill == true
|
||||||
|
page.viewportExpand = raw.viewport.expand == true
|
||||||
end
|
end
|
||||||
for _, c in ipairs(raw.controls or {}) do
|
for _, c in ipairs(raw.controls or {}) do
|
||||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||||
@@ -524,6 +526,7 @@ function TouchSkin.toNative(skin)
|
|||||||
x = page.viewport.x, y = page.viewport.y,
|
x = page.viewport.x, y = page.viewport.y,
|
||||||
w = page.viewport.w, h = page.viewport.h,
|
w = page.viewport.w, h = page.viewport.h,
|
||||||
fill = page.viewportFill or nil,
|
fill = page.viewportFill or nil,
|
||||||
|
expand = page.viewportExpand or nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -633,19 +636,6 @@ local function applyPixelScale(page)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- An overlay image is its own design canvas. Older RetroArch cfg files often
|
|
||||||
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
|
|
||||||
-- all of its normalized controls on the same uniform scale.
|
|
||||||
function TouchSkin.applyImageAspect(page)
|
|
||||||
if not page or page.aspectFromCfg or not page.image
|
|
||||||
or not page.image.getDimensions then return false end
|
|
||||||
local iw, ih = page.image:getDimensions()
|
|
||||||
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
|
||||||
page.aspect = iw / ih
|
|
||||||
page.aspectFromImage = true
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchSkin.load(root, id)
|
function TouchSkin.load(root, id)
|
||||||
local cfgPath, format, prefix = findConfig(root)
|
local cfgPath, format, prefix = findConfig(root)
|
||||||
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||||
@@ -675,7 +665,6 @@ function TouchSkin.load(root, id)
|
|||||||
elseif page.pdfPath then
|
elseif page.pdfPath then
|
||||||
rasterizePdfPage(page, root)
|
rasterizePdfPage(page, root)
|
||||||
end
|
end
|
||||||
TouchSkin.applyImageAspect(page)
|
|
||||||
if not applyPixelScale(page) then
|
if not applyPixelScale(page) then
|
||||||
return nil, "could not read " .. tostring(page.imagePath)
|
return nil, "could not read " .. tostring(page.imagePath)
|
||||||
.. ", which " .. page.name .. " measures its coordinates against"
|
.. ", which " .. page.name .. " measures its coordinates against"
|
||||||
@@ -782,28 +771,6 @@ function TouchSkin.find(id)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Remove only a user-installed skin. Bundled skins are shipped with the
|
|
||||||
-- game and intentionally have no delete affordance.
|
|
||||||
function TouchSkin.remove(id)
|
|
||||||
local entry = TouchSkin.find(id)
|
|
||||||
if not entry then return nil, "no skin " .. tostring(id) end
|
|
||||||
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
|
|
||||||
if not (love and love.filesystem and love.filesystem.remove) then
|
|
||||||
return nil, "no writable filesystem"
|
|
||||||
end
|
|
||||||
local function removeTree(path)
|
|
||||||
if isDir(path) and love.filesystem.getDirectoryItems then
|
|
||||||
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
|
|
||||||
local ok, err = removeTree(path .. "/" .. name)
|
|
||||||
if not ok then return nil, err end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local ok, err = love.filesystem.remove(path)
|
|
||||||
return ok and true or nil, err
|
|
||||||
end
|
|
||||||
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchSkin.assetPaths(skin)
|
function TouchSkin.assetPaths(skin)
|
||||||
local out, seen = {}, {}
|
local out, seen = {}, {}
|
||||||
local function add(rel)
|
local function add(rel)
|
||||||
@@ -919,6 +886,7 @@ function TouchSkin.toRetroArchConfig(skin)
|
|||||||
if page.viewport then
|
if page.viewport then
|
||||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||||
|
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
||||||
end
|
end
|
||||||
local controls = {}
|
local controls = {}
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -1316,7 +1284,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
-- full_screen means "relative to the window, not the game viewport".
|
-- full_screen means "relative to the window, not the game viewport".
|
||||||
-- When the cfg also names an aspect_ratio, that window is then fitted
|
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||||
-- to the overlay's design aspect so buttons do not stretch. #1503
|
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||||
local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
|
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
||||||
and page.aspect and page.aspect > 0 and h > 0
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
if fit then
|
if fit then
|
||||||
local displayAspect = w / h
|
local displayAspect = w / h
|
||||||
@@ -1338,11 +1306,6 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
by = oy + extra
|
by = oy + extra
|
||||||
elseif anchor == "top" then
|
elseif anchor == "top" then
|
||||||
by = oy
|
by = oy
|
||||||
elseif page.aspect < 1 then
|
|
||||||
-- A portrait bezel with controls is a controller deck. On an
|
|
||||||
-- unusually tall display, pin the deck to the lower edge and leave
|
|
||||||
-- the additional room for the game above it.
|
|
||||||
by = oy + extra
|
|
||||||
else
|
else
|
||||||
by = oy + extra * 0.5
|
by = oy + extra * 0.5
|
||||||
end
|
end
|
||||||
@@ -1396,10 +1359,8 @@ function TouchSkin.decorativeOnly()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.drawable()
|
function TouchSkin.drawable()
|
||||||
-- A selected skin is a presentation choice, not a mobile-only input mode.
|
if not TouchSkin.active then return false end
|
||||||
-- Its artwork and screen placement therefore belong on every platform;
|
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
|
||||||
-- `overlayLive` still controls whether touch input is available.
|
|
||||||
return TouchSkin.active ~= nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.hasViewport()
|
function TouchSkin.hasViewport()
|
||||||
@@ -1430,16 +1391,6 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
|||||||
return best[1], best[2], best[3], best[4]
|
return best[1], best[2], best[3], best[4]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The deck box pinned to the lower edge (see pageBox) leaves room above it
|
|
||||||
-- that belongs to the game, so a screen rect flush with the top of the deck
|
|
||||||
-- grows into it instead of showing a black band.
|
|
||||||
local function deckHeadroom(y, vh, by, bh, oy, h)
|
|
||||||
if by <= oy + 0.5 then return y, vh end
|
|
||||||
if by + bh < oy + h - 0.5 then return y, vh end
|
|
||||||
if y - by > math.max(2, bh * 0.01) then return y, vh end
|
|
||||||
return oy, vh + (y - oy)
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
if not page then return nil end
|
if not page then return nil end
|
||||||
ox, oy = ox or 0, oy or 0
|
ox, oy = ox or 0, oy or 0
|
||||||
@@ -1449,27 +1400,16 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
|
|||||||
local x, y = bx + v.x * bw, by + v.y * bh
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
local vw, vh = v.w * bw, v.h * bh
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
y, vh = deckHeadroom(y, vh, by, bh, oy, h)
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||||
return x, y, vw, vh, page.viewportFill == true
|
|
||||||
end
|
end
|
||||||
if page.screenFit == "remainder" then
|
if page.screenFit == "remainder" then
|
||||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
if not x then return nil end
|
if not x then return nil end
|
||||||
return x, y, vw, vh, false
|
return x, y, vw, vh, false, false
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Centre of the page's screen cutout. The renderer fits the 160x144
|
|
||||||
-- picture into that rect; this helper is for the studio preview.
|
|
||||||
function TouchSkin.screenCenter(w, h, ox, oy, page)
|
|
||||||
page = page or TouchSkin.page()
|
|
||||||
if not page then return nil end
|
|
||||||
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
|
|
||||||
if not x then return nil end
|
|
||||||
return x + vw * 0.5, y + vh * 0.5
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchSkin.viewport(w, h, ox, oy)
|
function TouchSkin.viewport(w, h, ox, oy)
|
||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
if not page or not TouchSkin.drawable() then return nil end
|
if not page or not TouchSkin.drawable() then return nil end
|
||||||
|
|||||||
@@ -240,8 +240,6 @@ local function rareCandy(mon, data)
|
|||||||
used = true,
|
used = true,
|
||||||
level = newLevel,
|
level = newLevel,
|
||||||
learned = learned,
|
learned = learned,
|
||||||
-- data/text/common_1.asm:86
|
|
||||||
sfx = "Sfx_DexFanfare5079",
|
|
||||||
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local function saveNames(version)
|
local function saveNames(version)
|
||||||
version = version or GameVersion.get()
|
version = version or "gold"
|
||||||
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
|
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
|
||||||
-- the flat save_<version>.lua when no slot is registered.
|
-- the flat save_<version>.lua when no slot is registered.
|
||||||
--
|
--
|
||||||
@@ -133,22 +133,16 @@ local function fs()
|
|||||||
return love.filesystem
|
return love.filesystem
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The blank-name fallback is the first PlayerNameArray row, which differs
|
-- A fresh Gold save. `opts` carries what the intro collected: player name,
|
||||||
-- per edition -- data/player_names.asm:12-23.
|
|
||||||
function Save.defaultPlayerName(version)
|
|
||||||
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
|
|
||||||
-- rival name, and the options the OPTION screen was left on.
|
-- rival name, and the options the OPTION screen was left on.
|
||||||
function Save.newGame(opts)
|
function Save.newGame(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local save = {
|
local save = {
|
||||||
format = Save.FORMAT,
|
format = Save.FORMAT,
|
||||||
version = GameVersion.get(),
|
version = "gold",
|
||||||
generation = 2,
|
generation = 2,
|
||||||
player = {
|
player = {
|
||||||
name = opts.playerName or Save.defaultPlayerName(),
|
name = opts.playerName or "GOLD",
|
||||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||||
-- (engine/menus/intro_menu.asm:41-49).
|
-- (engine/menus/intro_menu.asm:41-49).
|
||||||
id = opts.trainerId or rand(0, 65535),
|
id = opts.trainerId or rand(0, 65535),
|
||||||
@@ -220,9 +214,6 @@ function Save.newGame(opts)
|
|||||||
phoneContacts = {},
|
phoneContacts = {},
|
||||||
tradeFlags = {},
|
tradeFlags = {},
|
||||||
pokedex = { seen = {}, caught = {} },
|
pokedex = { seen = {}, caught = {} },
|
||||||
-- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the
|
|
||||||
-- #DEX reopens in. NEW_MODE is the cart's zero byte.
|
|
||||||
lastDexMode = "NEW",
|
|
||||||
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
||||||
-- record beside the #DEX because the #DEX knows only the species
|
-- record beside the #DEX because the #DEX knows only the species
|
||||||
-- (src/core/gen2/Unown.lua).
|
-- (src/core/gen2/Unown.lua).
|
||||||
@@ -291,7 +282,6 @@ Save.DEFAULT_OPTIONS = {
|
|||||||
musicFilter = 0, -- low-pass steps, 0 = off
|
musicFilter = 0, -- low-pass steps, 0 = off
|
||||||
haptics = "light",
|
haptics = "light",
|
||||||
touchControls = { enabled = true },
|
touchControls = { enabled = true },
|
||||||
screenPos = "center",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Save.defaultOptions()
|
function Save.defaultOptions()
|
||||||
@@ -312,7 +302,7 @@ end
|
|||||||
Save.OPTIONS_KEY = "gold"
|
Save.OPTIONS_KEY = "gold"
|
||||||
|
|
||||||
local SHARED_KEYS = {
|
local SHARED_KEYS = {
|
||||||
touchControls = true, haptics = true, screenPos = true,
|
touchControls = true, haptics = true,
|
||||||
mods = true, modsByVersion = true, modsGen2 = true,
|
mods = true, modsByVersion = true, modsGen2 = true,
|
||||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||||
activeProfile = true,
|
activeProfile = true,
|
||||||
@@ -384,13 +374,10 @@ end
|
|||||||
function Save.normalize(save)
|
function Save.normalize(save)
|
||||||
if type(save) ~= "table" then return nil end
|
if type(save) ~= "table" then return nil end
|
||||||
save.format = save.format or Save.FORMAT
|
save.format = save.format or Save.FORMAT
|
||||||
if not (GameVersion.VERSIONS[save.version]
|
save.version = "gold"
|
||||||
and GameVersion.generation(save.version) == 2) then
|
|
||||||
save.version = GameVersion.get()
|
|
||||||
end
|
|
||||||
save.generation = 2
|
save.generation = 2
|
||||||
save.player = save.player or {}
|
save.player = save.player or {}
|
||||||
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
|
save.player.name = save.player.name or "GOLD"
|
||||||
save.player.id = save.player.id or rand(0, 65535)
|
save.player.id = save.player.id or rand(0, 65535)
|
||||||
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
||||||
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
||||||
@@ -688,12 +675,6 @@ function Save.validate(save)
|
|||||||
scrubEvents(save, report)
|
scrubEvents(save, report)
|
||||||
scrubMapScenes(save, report)
|
scrubMapScenes(save, report)
|
||||||
scrubPlayerState(save, report)
|
scrubPlayerState(save, report)
|
||||||
-- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES);
|
|
||||||
-- a hand-edited value falls back to NEW_MODE, the cart's zero byte
|
|
||||||
if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD"
|
|
||||||
and save.lastDexMode ~= "A-Z" then
|
|
||||||
save.lastDexMode = "NEW"
|
|
||||||
end
|
|
||||||
-- The `mailmsg` structs get the same treatment for the same reason: their
|
-- The `mailmsg` structs get the same treatment for the same reason: their
|
||||||
-- `type` byte is an item id nothing else in the save vouches for, and a
|
-- `type` byte is an item id nothing else in the save vouches for, and a
|
||||||
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
||||||
@@ -772,24 +753,21 @@ end
|
|||||||
-- copy is the witness that survives a crash mid-replace.
|
-- copy is the witness that survives a crash mid-replace.
|
||||||
function Save.save(save)
|
function Save.save(save)
|
||||||
if type(save) ~= "table" then return false, "no save" end
|
if type(save) ~= "table" then return false, "no save" end
|
||||||
Save.normalize(save)
|
if (save.version or "gold") == "gold" then
|
||||||
local version = save.version
|
|
||||||
do
|
|
||||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||||
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
|
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
|
||||||
local id = SaveData.createSlot and SaveData.createSlot(version)
|
local id = SaveData.createSlot and SaveData.createSlot("gold")
|
||||||
if id and SaveData.setActiveSlot then
|
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
|
||||||
SaveData.setActiveSlot(version, id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local main, backup, tmp = saveNames(version)
|
local main, backup, tmp = saveNames(save.version)
|
||||||
local f = fs()
|
local f = fs()
|
||||||
if not f then return false, "no filesystem" end
|
if not f then return false, "no filesystem" end
|
||||||
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
||||||
-- love.filesystem.write does not create missing parent directories.
|
-- love.filesystem.write does not create missing parent directories.
|
||||||
local dir = main:match("^(.*)/[^/]+$")
|
local dir = main:match("^(.*)/[^/]+$")
|
||||||
if dir and f.createDirectory then f.createDirectory(dir) end
|
if dir and f.createDirectory then f.createDirectory(dir) end
|
||||||
|
Save.normalize(save)
|
||||||
save.savedAt = os.time()
|
save.savedAt = os.time()
|
||||||
local encoded = SaveSerializer.encode(save)
|
local encoded = SaveSerializer.encode(save)
|
||||||
if f.getInfo(main) then
|
if f.getInfo(main) then
|
||||||
|
|||||||
@@ -249,12 +249,10 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Shallow listing so we can see if the extract tree exists at all.
|
-- Shallow listing so we can see if the extract tree exists at all.
|
||||||
local roots = { "yellow", "blue", "gold", "silver", "assets",
|
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
|
||||||
"yellow/assets/generated",
|
|
||||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||||
"gold/assets/generated", "gold/assets/generated/sprites",
|
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||||
"gold/data/generated", "silver/assets/generated",
|
"gold/data/generated" }
|
||||||
"silver/data/generated" }
|
|
||||||
for _, dir in ipairs(roots) do
|
for _, dir in ipairs(roots) do
|
||||||
local info = filesystem.getInfo(dir)
|
local info = filesystem.getInfo(dir)
|
||||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||||
|
|||||||
@@ -287,43 +287,6 @@ function CacheFs.write(rel, data)
|
|||||||
return love.filesystem.write(rel, data)
|
return love.filesystem.write(rel, data)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Open a cache-relative file for streaming replacement. The returned handle
|
|
||||||
-- has write(bytes) and close() methods and follows the same portable/save-dir
|
|
||||||
-- routing as CacheFs.write without forcing the caller to hold the whole file
|
|
||||||
-- in one Lua string.
|
|
||||||
function CacheFs.openWrite(rel)
|
|
||||||
rel = withPrefix(rel)
|
|
||||||
local root = CacheFs.root()
|
|
||||||
if root then
|
|
||||||
ensureParents(root, rel)
|
|
||||||
local f, err = io.open(realPath(root, rel), "wb")
|
|
||||||
if not f then return nil, err end
|
|
||||||
return {
|
|
||||||
write = function(_, data)
|
|
||||||
local ok, writeErr = f:write(data)
|
|
||||||
if not ok then return nil, writeErr end
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
close = function() f:close() end,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
if not (love and love.filesystem and love.filesystem.newFile) then
|
|
||||||
return nil, "streaming cache writes are unavailable"
|
|
||||||
end
|
|
||||||
local parent = rel:match("^(.*)/[^/]+$")
|
|
||||||
if parent and not love.filesystem.createDirectory(parent) then
|
|
||||||
local info = love.filesystem.getInfo(parent)
|
|
||||||
local reason = info and ("a " .. info.type .. " already exists there")
|
|
||||||
or "unknown reason"
|
|
||||||
return nil, "could not create " .. parent .. ": " .. reason
|
|
||||||
end
|
|
||||||
local file, makeErr = love.filesystem.newFile(rel)
|
|
||||||
if not file then return nil, makeErr or "could not create cache file" end
|
|
||||||
local ok, openErr = file:open("w")
|
|
||||||
if not ok then return nil, openErr or "could not open cache file" end
|
|
||||||
return file
|
|
||||||
end
|
|
||||||
|
|
||||||
-- read cache-relative `rel`; returns the bytes or nil
|
-- read cache-relative `rel`; returns the bytes or nil
|
||||||
function CacheFs.read(rel)
|
function CacheFs.read(rel)
|
||||||
rel = withPrefix(rel)
|
rel = withPrefix(rel)
|
||||||
@@ -614,18 +577,14 @@ function CacheFs.mountVersion(version)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Undo mountVersion in LIFO order relative to mountVersion: generated-tree
|
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||||
-- overlays first (assets, then data -- reverse of mountGeneratedTrees), then
|
-- boots it, but the launcher can open the save editor on one game's save,
|
||||||
-- the version folder. PHYSFS resolves by stack order; peeling the wrong
|
-- close it, and press Play on another: with the first version's subtree
|
||||||
-- layer first can leave another version's generated files winning a name.
|
-- still prepended, the other's require("data.generated.*") and generated
|
||||||
--
|
-- art would silently resolve to the first game's files. Callers must also
|
||||||
-- A process normally mounts exactly one version and then boots it, but the
|
-- drop the generated modules from package.loaded
|
||||||
-- launcher can open the save editor on one game's save, close it, and press
|
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read
|
||||||
-- Play on another: with the first version's subtree still prepended, the
|
-- path, not what require already cached.
|
||||||
-- other's require("data.generated.*") and generated art would silently
|
|
||||||
-- resolve to the first game's files. Callers must also drop the generated
|
|
||||||
-- modules from package.loaded (src.core.Data:unloadGenerated) -- unmounting
|
|
||||||
-- alone only fixes the read path, not what require already cached.
|
|
||||||
--
|
--
|
||||||
-- Returns true when nothing was mounted or the unmount took.
|
-- Returns true when nothing was mounted or the unmount took.
|
||||||
function CacheFs.unmountVersion(version)
|
function CacheFs.unmountVersion(version)
|
||||||
@@ -637,16 +596,6 @@ function CacheFs.unmountVersion(version)
|
|||||||
base = love.filesystem.getSaveDirectory()
|
base = love.filesystem.getSaveDirectory()
|
||||||
end
|
end
|
||||||
local done = false
|
local done = false
|
||||||
-- LIFO vs mountGeneratedTrees: assets/generated, then data/generated.
|
|
||||||
if love.filesystem and love.filesystem.unmount then
|
|
||||||
local generated = {
|
|
||||||
prefix .. "assets/generated",
|
|
||||||
prefix .. "data/generated",
|
|
||||||
}
|
|
||||||
for _, src in ipairs(generated) do
|
|
||||||
done = love.filesystem.unmount(src) or done
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local fn = resolveUnmount()
|
local fn = resolveUnmount()
|
||||||
if fn and base then
|
if fn and base then
|
||||||
done = fn(base .. SEP .. sub) or done
|
done = fn(base .. SEP .. sub) or done
|
||||||
|
|||||||