Compare commits

..

32 Commits

Author SHA1 Message Date
Adrian Castro f77a98bfe6 fix(ci): update platform artifact workflow gate 2026-08-19 20:32:35 +02:00
Adrian Castro dd024d0e50 ci: comment all platform build artifacts 2026-08-19 20:26:21 +02:00
Adrian Castro 770798b4b6 fix(build): create macOS runtime cache before fetching 2026-08-19 20:21:53 +02:00
Adrian Castro f1e5fa4ec3 build(mac): establish LÖVE 12 Metal parity 2026-08-19 20:14:04 +02:00
Adrian Castro 5cf53bb13f build(mac): use LÖVE 12 Metal runtime 2026-08-19 20:07:54 +02:00
Adrian Castro 95764c7d97 fix(build): strip macOS staging metadata before signing 2026-08-19 20:07:54 +02:00
bryanthaboi def270f7c7 Merge pull request #1560 from castdrian/device-report-bug-tab
feat(launcher): add bug tab and native device reporting
2026-08-19 13:54:38 -04:00
bryanthaboi 93e336b7cb Update video link and thumbnail in README 2026-08-19 13:36:35 -04:00
bryanthaboi 4c8c1cf36b CLOSES #998, CLOSES #1472, CLOSES #1526, CLOSES #1529, CLOSES #1530, CLOSES #1532, CLOSES #1534, CLOSES #1547, CLOSES #1549, CLOSES #1550, CLOSES #1551 2026-08-19 11:19:54 -04:00
Adrian Castro 7d9e99ea18 chore(repo): add code owners 2026-08-19 15:59:42 +02:00
Adrian Castro b27e5ab017 fix(launcher): simplify bug report card title 2026-08-19 15:51:23 +02:00
Adrian Castro fd9f3da91a fix(launcher): use rounded bug report icon 2026-08-19 15:51:23 +02:00
Adrian Castro a7c19be88f fix(launcher): use standard bug report icon 2026-08-19 15:51:22 +02:00
Adrian Castro 9ab80adaca feat(launcher): add bug tab and native device reporting 2026-08-19 15:50:50 +02:00
bryanthaboi 9713977755 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-19 06:41:12 -04:00
bryanthaboi 63448ca640 shamona 2026-08-19 06:41:10 -04:00
bryanthaboi fddf619ed2 Merge pull request #1527 from thibautbus/fix/status-abbreviation-translation
Translate the status abbreviations shown outside battle
2026-08-19 06:01:14 -04:00
bryanthaboi bf83509ef2 Merge pull request #1542 from AverageConsumer/codex/gen2-ball-cache-invalidation
fix(gen2): refresh caches missing trainer HUD balls
2026-08-19 06:00:48 -04:00
bryanthaboi 6c05b854c4 Merge pull request #1543 from AverageConsumer/codex/gen2-party-grid-navigation
fix(gen2): honor battle party grid navigation
2026-08-19 06:00:21 -04:00
bryanthaboi 2baafab027 Merge pull request #1544 from castdrian/safe-mode-report-issue
feat(launcher): add safe mode and issue reporting
2026-08-19 06:00:00 -04:00
bryanthaboi 813f9d959b Merge pull request #1546 from 1Jamie/feat/android-exit-game-to-launcher
feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
2026-08-19 05:59:34 -04:00
bryanthaboi fba87f028c Merge pull request #1552 from thibautbus/fix/pikachu-unhappy-gsub-crash
Fix a crash releasing your own caught Pikachu in Yellow
2026-08-19 05:59:06 -04:00
bryanthaboi cb4647daf0 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-19 05:57:46 -04:00
bryanthaboi 93374fbbbb skin studio updates, save sync CLOSES #1533 2026-08-19 05:57:44 -04:00
thibautbus abe176b26c Fix a crash releasing your own caught Pikachu in Yellow
BoxMenu.lua's release() pushes both its "Once released...OK?" prompt
and its Yellow-only "Pikachu looks unhappy" message through
TextBox.new(game, (t._X or Strings(...)):gsub(...)) -- gsub returns
two values (the text and a substitution count), and since the gsub
call is the last argument in the TextBox.new(...) call with nothing
after it, Lua expands both into the call: the count lands in
TextBox.new's third parameter, onDone. TextBox.lua later calls
onDone() once the box is dismissed; a number is not callable, so
every release of your own caught Pikachu in Yellow crashed --
regardless of its nickname (unlike the separate %-escape gsub bug,
this one needs no special save content, ordinary play reaches it
every time).

Fixed by wrapping the gsub call in an extra pair of parens, which
truncates it to its first return value only -- the same fix already
applied to the neighboring _OnceReleasedText/_MonWasReleasedText
lines on the (separate, unmerged) fix/route-more-messages-through-romtext
branch, where this exact bug shape was first noticed while adding a
third callsite with the same pattern.

tests/engine/pikachu_unhappy_release_crash.lua: registers a fake
Data.pokemon.PIKACHU cloned from the fixture species (ROM-free) so
the species == "PIKACHU" check can be exercised, drives the real
interactive release flow in Yellow on a mon owned by the player, and
confirms the crash. Verified failing pre-fix (exact same
"attempt to call field 'onDone' (a number value)" error) and passing
post-fix.
2026-08-19 11:34:20 +02:00
thibautbus 085180992d Cover the status abbreviation translation fix with a targeted test
Neither tests/parity_status_true_color.lua (SGB recolor rectangle) nor
tests/parity_party_icon_mirror.lua (icon mirroring) check the drawn
status text, so this fix had no coverage. Drive SummaryMenu:draw() and
PartyMenu:draw() with a mod-patched statuses registry and check the
patched label reaches Font.draw instead of the raw status id, plus a
vanilla case confirming the no-mod fallback is unchanged.

Also cover the hudLabel-shadowing bug directly through the real
Registry:patch (not a hand-built table): a label-only patch, the exact
shape a translation mod would send, must reach Status.hudLabelFor for
all five vanilla ids. Confirmed both regressions: reverting
src/ui/*.lua and src/battle/*.lua to dev's pre-fix content fails 2 of
the draw-site checks; reverting only the vanilla hudLabel removal in
Status.lua fails the 3 checks whose French label differs from English
(FRZ/BRN/SLP).
2026-08-19 08:08:20 +02:00
thibautbus 9984958193 Translate the status abbreviations shown outside battle
src/ui/SummaryMenu.lua:148 and src/ui/PartyMenu.lua:824 drew mon.status
(PSN/PAR/BRN/FRZ/SLP) as a bare literal, bypassing translation. Unlike
plain text, a mod translates status labels through the statuses content
registry (mod.content.statuses:patch(id, { label = value }), the same
registry src/battle/BattleState.lua:statusLabel already reads in battle.
Route both screens through the same lookup, extracted as
Status.hudLabelFor(statuses, id) and shared with BattleState:statusLabel
so the hudLabel-or-label fallback rule lives in one place, with the raw
status id kept as the fallback when no record overrides it.

Found along the way: Status.RECORDS' five vanilla entries duplicated
hudLabel = label ("FRZ", hudLabel = "FRZ", ...) for no functional
reason. Since hudLabelFor reads hudLabel before label, and
Registry:patch only overrides fields a mod actually passes, a
translation mod's label-only patch (the natural shape for a status
catalog carrying one string per id, with no separate hudLabel data to
patch) was silently shadowed by the untouched vanilla hudLabel -- the
translation was stored but never displayed, in or out of battle. This
affected BattleState:statusLabel too, before this change and
independently of it. Dropped the redundant hudLabel field from all
five vanilla records: it's declared optional in the schema, and
nothing in this codebase ever gives it a value different from label --
setting it here only recreated the shadowing trap for no observed
benefit. Left a comment above Status.RECORDS warning against
re-adding it.
2026-08-19 08:08:20 +02:00
1jamie 5871469002 fix(tests): avoid false positive Game: pattern match in skin_studio test 2026-08-18 21:16:32 -05:00
1jamie 302b2c9591 feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher 2026-08-18 20:37:25 -05:00
Adrian Castro 67a170fd6e feat(launcher): add safe mode and issue reporting 2026-08-19 00:44:30 +02:00
AverageConsumer 66079686fc fix(gen2): honor battle party grid navigation 2026-08-19 00:35:39 +02:00
AverageConsumer cc5ff987ac fix(gen2): refresh caches missing trainer HUD balls 2026-08-18 23:52:25 +02:00
177 changed files with 14164 additions and 867 deletions
+1
View File
@@ -0,0 +1 @@
* @bryanthaboi
+59 -1
View File
@@ -26,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
@@ -120,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"
@@ -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 }}
+10 -3
View File
@@ -344,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);
@@ -428,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."
@@ -474,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"
@@ -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 }}
+12 -17
View File
@@ -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"
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 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
+4 -3
View File
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
### Watch the latest update video ### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA) [![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](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,
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+1 -1
View File
@@ -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
+19
View File
@@ -5,8 +5,27 @@
-- voucher exchange and the BICYCLE/CANCEL price window need more than -- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568). -- command rows (#568).
local TextBox = require("src.render.TextBox")
-- data/events/hidden_events.asm:542
local BIKE_DISPLAYS = {
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
}
return { return {
BIKE_SHOP = { BIKE_SHOP = {
-- engine/events/hidden_events/new_bike.asm:1
onInteract = function(game, ow, fx, fy)
for _, c in ipairs(BIKE_DISPLAYS) do
if c[1] == fx and c[2] == fy then
game.stack:push(TextBox.new(game,
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
return true
end
end
return false
end,
talk = { talk = {
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm): -- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
-- always shows the same flavor line, no branching. -- always shows the same flavor line, no branching.
+1
View File
@@ -168,6 +168,7 @@ return {
{ "jump_if_true", "come_see" }, { "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false }, { "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, { "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" }, { "jump", "end" },
+11 -1
View File
@@ -516,7 +516,17 @@ M.ROUTE_24 = {
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText, push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done) done)
else else
ow:engageTrainer(npc, done) -- scripts/Route24.asm:125
ow:engageTrainer(npc, function()
if ow:trainerDefeated(npc) then
-- scripts/Route24.asm:62
push(game,
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
done()
end
end, text(game)._Route24CooltrainerM1DefeatedText, true)
end end
end end
if not flags.EVENT_GOT_NUGGET then if not flags.EVENT_GOT_NUGGET then
+5 -4
View File
@@ -7,9 +7,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- PrintText on a text_end string returns with the box still drawn and -- PrintText on a text_end string returns with the box still drawn and
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
if yes == machine.yes then if yes == machine.yes then
-- CinnabarGymQuizCorrectText: item jingle, then the gate -- CinnabarGymQuizCorrectText: item jingle, then the gate
-- slides open (SFX_GO_INSIDE) if it was still locked -- slides open (SFX_GO_INSIDE) if it was still locked
Sound.play(game.data, "Get_Item1")
push(game, t._CinnabarGymQuizCorrectText push(game, t._CinnabarGymQuizCorrectText
or "You're absolutely\ncorrect!\fGo on through!", function() or "You're absolutely\ncorrect!\fGo on through!", function()
if not game.save.flags[gymGateFlag(index)] then if not game.save.flags[gymGateFlag(index)] then
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
Sound.play(game.data, "Go_Inside") Sound.play(game.data, "Go_Inside")
end end
applyGymGates(game, ow) applyGymGates(game, ow)
end) end, { preSound = function()
return Sound.play(game.data, "Get_Item1")
end })
return return
end end
Sound.play(game.data, "Denied") Sound.play(game.data, "Denied")
+6 -6
View File
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
return nil return nil
end end
local function push(game, text, done) local function push(game, text, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done)) game.stack:push(TextBox.new(game, text, done, opts))
end end
-- the two-variant posters: the surf-capable line once a surfing -- the two-variant posters: the surf-capable line once a surfing
@@ -69,11 +69,11 @@ return {
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done) TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text local t = game.data.text
-- scripts/SummerBeachHouse.asm:68
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!", push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
function() done, { auto = { wait = true, delay = 0, sound = function()
require("src.core.Sound").playCry(game.data, "PIKACHU") return require("src.core.Sound").playCry(game.data, "PIKACHU")
done() end } })
end)
end, end,
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1), TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
+5 -5
View File
@@ -512,8 +512,9 @@ gains a field instead of the name gaining a prefix.
id under Gen 1's `name` key, which is the one payload difference the id under Gen 1's `name` key, which is the one payload difference the
numeric flag space forces. numeric flag space forces.
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`, - *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`, `ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script `ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
`ui.list_menu` covers Gold's script
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
menus draw with does not raise it yet, so those two are composed through menus draw with does not raise it yet, so those two are composed through
their own hooks only. their own hooks only.
@@ -540,9 +541,8 @@ gains a field instead of the name gaining a prefix.
`battle.crit`, `battle.accuracy`, `battle.turn_order`, `battle.crit`, `battle.accuracy`, `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
+1 -2
View File
@@ -11,9 +11,8 @@ 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
* **Touch skins** in RetroArch overlay format, with bezel art, 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**
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
## Gen 2 Specifics ## Gen 2 Specifics
+101 -18
View File
@@ -4,17 +4,23 @@ A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. 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` (the screen viewport), (draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
`src/ui/SkinStudio.lua` (the desktop 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_image_import.lua`, `tests/engine/skin_studio_image_import.lua`,
`tests/engine/launcher_skins_tab.lua`. `tests/engine/skin_format_import_test.lua`,
`tests/engine/launcher_skins_tab.lua`,
`tests/engine/launcher_skins_ux.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name. opens the studio. `options.touchControls.skin` holds the folder name.
## Formats ## Formats
Two load. `skin.lua` wins when a folder has both. Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta
`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher
badges each installed skin with the format it was read from.
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads **RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys: as-is. Supported keys:
@@ -41,6 +47,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
control that holds both. A `nul` desc is decoration: it draws and never control that holds both. A `nul` desc is decoration: it draws and never
captures a touch. captures a touch.
The area desc types are expanded rather than ignored: `dpad_area`,
`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over
the same area, one per 45 degree sector measured from its centre, the way
RetroArch resolves them: there is no neutral middle, and the four corner
sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and
the per-side reach are honoured, and the desc's own art is kept as decoration
over the top. Exporting a cfg folds the eight back into the one area desc they
came from. `retrok_<key>` is a keyboard bind.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
image sits at the overlay opacity, and a pressed control's image swaps to image sits at the overlay opacity, and a pressed control's image swaps to
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1 `opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
@@ -71,6 +86,32 @@ return {
} }
``` ```
**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an
`info.json` plus its art. The `representations` tree is walked
device / display type / orientation, and every orientation that exists becomes
a page; `page.orient` is the orientation key, so a portrait/landscape pair
auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
`mappingSize` points and are converted to the native centre plus half extent;
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
corners fire two directions. `screens[1].outputFrame` (or the legacy
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
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
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
stretch to the window the way Delta does. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
identifiers are accepted, and a non Game Boy system warns instead of failing.
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
Image-to-PDF skins, Preview exports, and the like). Import extracts that
JPEG and draws it; a true vector PDF with no embedded image is still refused,
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
are an older, incompatible schema and are refused by name.
## Bindable actions ## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`, The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
@@ -119,10 +160,22 @@ them. Anything that binds a button still follows the usual mobile /
## Installing ## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on Four roads, all of them landing in `skins/` in the save directory:
the launcher window while the Skins tab is open. A zip is mounted in place, so
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg` * **Import** on the Skins tab opens the host file picker for a `.zip` or a
(`overlay.cfg` is preferred when there are several) and the images it names. `.deltaskin`.
* **Paste a skin link** in the tab's URL row, then **Add**. The download runs
on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the
row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped
into an archive on the way in. This is the road that works on a phone, where
there is no file picker to speak of.
* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is
open.
* Copy a folder or archive into `skins/` by hand.
An archive is mounted in place, so there is nothing to unpack. It needs one
`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or
`info.json`, plus the images it names.
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0: Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
@@ -157,23 +210,40 @@ The Super Game Boy preset locks the viewport to the real screen window,
160x144 at (48,40), so an SGB border cannot be drawn out of register. 160x144 at (48,40), so an SGB border cannot be drawn out of register.
**Editing.** Click a control to select it, drag to move, eight handles to **Editing.** Click a control to select it, drag to move, eight handles to
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and a control is dragged it snaps to the centres and edges of the other controls
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
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
pressed images are per control; the bezel, the pages and the screen cutout are pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock. per page. The cutout is itself a draggable element with a 10:9 lock.
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
decoration. 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
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
actions. `L` toggles the bind captions drawn on the canvas.
Each page can **Lock** to portrait or landscape. With **Match canvas** on Each page can **Lock** to portrait or landscape. With **Match canvas** on
(the default), Next page picks a matching mock device and the canvas preset (the default), the page list picks a matching mock device and the canvas preset
picks a matching page. Turn Match canvas off to look at a portrait page on a picks a matching page. Turn Match canvas off to look at a portrait page on a
landscape device. landscape device. **Pages** opens the page list, where a page is selected,
renamed or deleted.
Starting a new skin, opening another one or closing the studio with unsaved
edits prompts first, with Save first / Discard / Cancel.
A RetroArch overlay whose pages are already named portrait / landscape A RetroArch overlay whose pages are already named portrait / landscape
(the auto-rotate convention) locks those pages and turns Match canvas on (the auto-rotate convention) locks those pages and turns Match canvas on
when you open it. You do not have to click Lock first. when you open it. You do not have to click Lock first.
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the **Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
images already in the skin folder; the **Import** button beside each one opens thumbnail grid of the images already in the skin folder, with `(none)` first;
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
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
@@ -185,11 +255,24 @@ buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it. 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** packs it as one zip skin names, so the folder stands alone. **Export** offers three formats, and
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the the Skins tab's gear offers the same three for any installed skin:
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch. | Export | Contents |
| --- | --- |
| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one |
| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images |
| Delta `.deltaskin` | an `info.json` generated from the model, plus the images |
All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/`
in the save directory, which is outside the folder the skin list scans, so an
export can never shadow the skin it came from. The notice names the full path
so a phone can find the file in its own file manager. On desktop **Show the
exported file** opens that folder.
## Not implemented ## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
a PDF renderer this engine does not carry, so they are refused with a message
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
skin case, extract on import.
+2 -2
View File
@@ -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.
+103 -15
View File
@@ -291,6 +291,78 @@ function closeSkinStudio()
end end
end end
local function makeLauncher()
local RomImporter = require("src.import.RomImporter")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
return RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end
local function returnToLauncher()
if not Game then return end
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
end
if package.loaded["src.core.gen2.Clock"] then
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
end
if package.loaded["src.net.Gen1Tls"] then
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
end
if love.audio and love.audio.stop then
pcall(love.audio.stop)
end
local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get()
if currentVersion then
require("src.import.CacheFs").unmountVersion(currentVersion)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime")
if Runtime.reset then
Runtime.reset()
end
Game = nil
autopilot = nil
driverCo = nil
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
Input:reset()
TouchControls:reset()
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
end
Importer = makeLauncher()
end
function bootGame(version) function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red. -- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
@@ -382,7 +454,7 @@ function love.load(args)
-- Apply the persisted Android orientation lock (#592) before the launcher -- Apply the persisted Android orientation lock (#592) before the launcher
-- shows: SDL created the window with no orientation hint, so without this -- shows: SDL created the window with no orientation hint, so without this
-- the launcher would rotate freely until Game:applyOptions runs at boot. -- the launcher would rotate freely until options are applied at boot.
-- No-op on desktop / iOS / when options.lua does not exist yet. -- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions( require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions()) require("src.core.SaveData").loadOptions())
@@ -442,8 +514,8 @@ function love.load(args)
-- (#767) only pays off if something fills that catalog this early, and no -- (#767) only pays off if something fills that catalog this early, and no
-- restart could: the ordering is the same on every launch. Read the -- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a -- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the -- translation reaches the launcher too. The active game's loader replaces
-- real merged catalog once a version boots. -- this with the real merged catalog once a version boots.
do do
local preload = require("src.mods.LauncherMods").translationStrings() local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end if preload then require("src.core.Strings").load({ strings = preload }) end
@@ -484,17 +556,7 @@ function love.load(args)
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold -- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
-- Edit on a save row opens the bundled editor on that slot (openEditor). -- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version) Importer = makeLauncher()
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end end
function love.update(dt) function love.update(dt)
@@ -827,6 +889,27 @@ function love.handlers.audioreset()
if Sound then pcall(Sound.onDeviceReset) end if Sound then pcall(Sound.onDeviceReset) end
end end
function love.handlers.intent_game(version)
if type(version) ~= "string" or version == "" then return end
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
local RomImporter = require("src.import.RomImporter")
if not RomImporter.isReady(version) then return end
local currentVersion = GameVersion.get()
if Game and currentVersion == version then
return
end
if Game then
returnToLauncher()
end
Importer = nil
bootGame(version)
end
function love.touchpressed(id, x, y, dx, dy, pressure) function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here -- iOS synthesizes mousepressed for the primary touch; forwarding here
@@ -1032,11 +1115,16 @@ function love.quit()
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- docs/modding.md's core.quit_to_launcher entry) may veto returning to
-- this Lua launcher via that hook. Vanilla behavior (used when no mod -- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below. -- claims the hook) is exactly the condition below.
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted return Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame and (isAndroid or not launchedIntoGame)
end) end)
if wouldReturnToLauncher then if wouldReturnToLauncher then
if isAndroid then
returnToLauncher()
return true -- abort this quit; the restart lands back in the launcher
end
quitToLauncher = true quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- Tell the fresh boot to ignore any boot-straight-into-a-game option this
-- once, so the restart really does land in the launcher (#887). A failed -- once, so the restart really does land in the launcher (#887). A failed
@@ -29,7 +29,8 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/love" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="${NAME}" > android:label="${NAME}" >
<meta-data <meta-data
android:name="android.allow_multiple_resumed_activities" android:name="android.allow_multiple_resumed_activities"
@@ -39,7 +40,7 @@
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:label="${NAME}" android:label="${NAME}"
android:launchMode="singleInstance" android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}" android:screenOrientation="${ORIENTATION}"
android:resizeableActivity="false" android:resizeableActivity="false"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" > android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -3,4 +3,9 @@
<color name="colorPrimary">#3F51B5</color> <color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color> <color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color> <color name="colorAccent">#FF4081</color>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="shortcut_red">#E53935</color>
<color name="shortcut_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color>
</resources> </resources>
@@ -48,6 +48,7 @@
#include "common/Module.h" #include "common/Module.h"
#include "audio/Audio.h" #include "audio/Audio.h"
#include "audio/openal/Audio.h" #include "audio/openal/Audio.h"
#include "event/Event.h"
namespace love namespace love
{ {
@@ -282,6 +283,70 @@ bool restartApp()
return result; return result;
} }
bool updateAppShortcuts(const std::vector<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return false;
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jclass stringClass = env->FindClass("java/lang/String");
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
for (size_t i = 0; i < versions.size(); ++i)
{
jstring jstr = env->NewStringUTF(versions[i].c_str());
env->SetObjectArrayElement(array, (jsize) i, jstr);
env->DeleteLocalRef(jstr);
}
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
env->DeleteLocalRef(array);
env->DeleteLocalRef(stringClass);
env->DeleteLocalRef(activity);
return result;
}
std::string getLaunchGame()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return "";
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return "";
}
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
if (jgame == nullptr)
{
env->DeleteLocalRef(activity);
return "";
}
const char *str = env->GetStringUTFChars(jgame, nullptr);
std::string result = (str != nullptr) ? str : "";
if (str != nullptr)
env->ReleaseStringUTFChars(jgame, str);
env->DeleteLocalRef(jgame);
env->DeleteLocalRef(activity);
return result;
}
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
{ {
if (url == nullptr || destPath == nullptr) if (url == nullptr || destPath == nullptr)
@@ -378,6 +443,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
return result; return result;
} }
bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent, std::string &out)
{
out.clear();
if (url == nullptr)
return false;
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// Same resolution rule as httpDownload: the activity's own class via
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
// runs on a love.thread worker, whose class loader cannot see them.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
// Old APK / new liblove skew: report "no transport" instead of aborting
// on a missing method (#597).
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
if (method_id == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jobjectArray jheaders = nullptr;
if (headerPairCount > 0)
{
// java/lang/String, unlike an app class, resolves from any thread.
jclass stringClass = env->FindClass("java/lang/String");
if (stringClass == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
env->DeleteLocalRef(stringClass);
if (jheaders == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
for (int i = 0; i < headerPairCount; i++)
{
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
env->SetObjectArrayElement(jheaders, (jsize) i, field);
if (field != nullptr)
env->DeleteLocalRef(field);
}
}
jstring jurl = env->NewStringUTF(url);
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
// raw bytes across the bridge, as httpPost does: a request body is JSON
// carrying a base64 save, and a jstring would run it through modified UTF-8
jbyteArray jbody = nullptr;
if (body != nullptr && bodyLen >= 0)
{
jbody = env->NewByteArray((jsize) bodyLen);
if (jbody != nullptr && bodyLen > 0)
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
}
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
jheaders, jbody, jua);
env->DeleteLocalRef(jurl);
env->DeleteLocalRef(jmethod);
if (jheaders != nullptr)
env->DeleteLocalRef(jheaders);
if (jbody != nullptr)
env->DeleteLocalRef(jbody);
env->DeleteLocalRef(jua);
env->DeleteLocalRef(activity);
if (result == nullptr)
return false;
jbyteArray bytes = (jbyteArray) result;
jsize length = env->GetArrayLength(bytes);
if (length > 0)
{
out.resize((size_t) length);
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
}
env->DeleteLocalRef(result);
return true;
}
/* /*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's * TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a * own class, never FindClass -- and the same tolerance for an old APK: a
@@ -1390,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
love::audio::openal::pushAudioResetEvent(); love::audio::openal::pushAudioResetEvent();
} }
static void pushGameIntentEvent(const char *game)
{
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || game == nullptr)
return;
std::vector<love::Variant> args;
args.push_back(love::Variant(std::string(game)));
love::event::Message *msg = new love::event::Message("intent_game", args);
eventmodule->push(msg);
msg->release();
}
extern "C" JNIEXPORT void JNICALL
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
{
(void) cls;
if (game == nullptr)
return;
const char *str = env->GetStringUTFChars(game, nullptr);
if (str != nullptr)
{
pushGameIntentEvent(str);
env->ReleaseStringUTFChars(game, str);
}
}
#endif // LOVE_ANDROID #endif // LOVE_ANDROID
@@ -90,6 +90,16 @@ bool syncHealthSteps();
**/ **/
bool restartApp(); bool restartApp();
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
bool updateAppShortcuts(const std::vector<std::string> &versions);
/**
* Returns the game version requested via initial launch Intent (if any).
**/
std::string getLaunchGame();
/** /**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there * no curl binary, so this is the transport src/core/HostShell.lua uses there
@@ -106,6 +116,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
**/ **/
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent); bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
/**
* Blocking HTTPS request with a method, headers and a byte body
* (GameActivity.httpRequest). What save sync needs and neither of the two
* above can give it: PUT, per-request auth headers, and the response body of
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
* headerPairCount entries; body/userAgent may be null. `out` receives the
* Java side's envelope -- a head line of "STATUS <code>" or "ERROR <text>",
* a newline, then the raw response bytes. False means the platform has no
* such bridge at all (an old APK under a newer liblove), which the Lua side
* reports as "update the app" rather than as a failed request.
**/
bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent, std::string &out);
/** /**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -245,6 +245,25 @@ bool System::restartApp() const
#endif #endif
} }
bool System::updateShortcuts(const std::vector<std::string> &versions) const
{
#ifdef LOVE_ANDROID
return love::android::updateAppShortcuts(versions);
#else
LOVE_UNUSED(versions);
return false;
#endif
}
std::string System::getLaunchGame() const
{
#ifdef LOVE_ANDROID
return love::android::getLaunchGame();
#else
return "";
#endif
}
bool System::httpDownload(const char *url, const char *destPath, bool System::httpDownload(const char *url, const char *destPath,
const char *userAgent, const char *accept) const const char *userAgent, const char *accept) const
{ {
@@ -274,6 +293,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
#endif #endif
} }
bool System::httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent,
std::string &out) const
{
#ifdef LOVE_ANDROID
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
body, bodyLen, userAgent, out);
#else
LOVE_UNUSED(url);
LOVE_UNUSED(method);
LOVE_UNUSED(headerPairs);
LOVE_UNUSED(headerPairCount);
LOVE_UNUSED(body);
LOVE_UNUSED(bodyLen);
LOVE_UNUSED(userAgent);
out.clear();
return false;
#endif
}
int System::tlsOpen(const char *host, int port) const int System::tlsOpen(const char *host, int port) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -143,6 +143,9 @@ public:
**/ **/
virtual bool restartApp() const; virtual bool restartApp() const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
/** /**
* Blocking HTTPS GET into an absolute host path (Android only; false * Blocking HTTPS GET into an absolute host path (Android only; false
* elsewhere). Android has no curl, which is what every other platform * elsewhere). Android has no curl, which is what every other platform
@@ -159,6 +162,18 @@ public:
virtual bool httpPost(const char *url, const char *body, int bodyLen, virtual bool httpPost(const char *url, const char *body, int bodyLen,
const char *contentType = nullptr, const char *userAgent = nullptr) const; const char *contentType = nullptr, const char *userAgent = nullptr) const;
/**
* Blocking HTTPS request with a method, headers and a byte body (Android
* only; false elsewhere). Save sync needs PUT, auth headers and the body
* of a 4xx, none of which the two bridges above can express. headerPairs
* is a flat name, value array; `out` receives the response envelope
* ("STATUS <code>" or "ERROR <text>", a newline, then the raw body).
**/
virtual bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent,
std::string &out) const;
/** /**
* TLS client sockets (Android only; every call fails elsewhere, where * TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract: * LuaSec or another provider is the answer). Non-blocking by contract:
@@ -22,6 +22,9 @@
#include "wrap_System.h" #include "wrap_System.h"
#include "sdl/System.h" #include "sdl/System.h"
#include <string>
#include <vector>
namespace love namespace love
{ {
namespace system namespace system
@@ -150,6 +153,57 @@ int w_httpPost(lua_State *L)
return 1; return 1;
} }
/*
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
*
* `headers` is a flat array of alternating header name and value strings, so
* it maps straight onto the Java bridge's String[] without any parsing here.
* The single return is the response envelope -- a head line of
* "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
* where the build has no bridge, which src/core/HostShell.lua turns into an
* "update the app" notice rather than a failed request.
*/
int w_httpRequest(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *method = luaL_optstring(L, 2, "GET");
std::vector<std::string> fields;
if (!lua_isnoneornil(L, 3))
{
luaL_checktype(L, 3, LUA_TTABLE);
size_t count = luax_objlen(L, 3);
for (size_t i = 1; i <= count; i++)
{
lua_rawgeti(L, 3, (int) i);
const char *field = lua_tostring(L, -1);
fields.push_back(field != nullptr ? field : "");
lua_pop(L, 1);
}
}
std::vector<const char *> pairs;
for (size_t i = 0; i < fields.size(); i++)
pairs.push_back(fields[i].c_str());
size_t bodyLen = 0;
const char *body = nullptr;
if (!lua_isnoneornil(L, 4))
body = luaL_checklstring(L, 4, &bodyLen);
const char *ua = luaL_optstring(L, 5, nullptr);
std::string out;
bool ok = instance()->httpRequest(url, method,
pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(),
body, (int) bodyLen, ua, out);
if (!ok)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, out.data(), out.size());
return 1;
}
int w_hasBackgroundMusic(lua_State *L) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -229,6 +283,34 @@ int w_tlsClose(lua_State *L)
return 0; return 0;
} }
int w_updateShortcuts(lua_State *L)
{
if (!lua_istable(L, 1))
return luaL_error(L, "Expected table of game version strings");
std::vector<std::string> versions;
int len = (int) luax_objlen(L, 1);
for (int i = 1; i <= len; ++i)
{
lua_rawgeti(L, 1, i);
if (lua_isstring(L, -1))
versions.push_back(lua_tostring(L, -1));
lua_pop(L, 1);
}
luax_pushboolean(L, instance()->updateShortcuts(versions));
return 1;
}
int w_getLaunchGame(lua_State *L)
{
std::string game = instance()->getLaunchGame();
if (game.empty())
lua_pushnil(L);
else
luax_pushstring(L, game);
return 1;
}
static const luaL_Reg functions[] = static const luaL_Reg functions[] =
{ {
{ "getOS", w_getOS }, { "getOS", w_getOS },
@@ -243,8 +325,11 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost }, { "httpPost", w_httpPost },
{ "httpRequest", w_httpRequest },
{ "tlsOpen", w_tlsOpen }, { "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus }, { "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend }, { "tlsSend", w_tlsSend },
@@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@@ -36,6 +37,7 @@ import java.net.URL;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import android.Manifest; import android.Manifest;
@@ -67,8 +69,11 @@ import android.os.Vibrator;
import android.provider.Settings; import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
import android.view.*; import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.drawable.Icon;
import android.view.*;
import androidx.annotation.Keep; import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
@@ -157,6 +162,10 @@ public class GameActivity extends SDLActivity {
private static native void nativeAudioDeviceChanged(); private static native void nativeAudioDeviceChanged();
private static native void nativeOnGameIntent(String game);
private static String initialGame = "";
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null; private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
private Object audioFocusRequest = null; private Object audioFocusRequest = null;
private Object audioDeviceCallback = null; private Object audioDeviceCallback = null;
@@ -226,6 +235,10 @@ public class GameActivity extends SDLActivity {
embed = getResources().getBoolean(R.bool.embed); embed = getResources().getBoolean(R.bool.embed);
needToCopyGameInArchive = embed; needToCopyGameInArchive = embed;
Intent startIntent = getIntent();
if (startIntent != null && startIntent.hasExtra("game")) {
initialGame = startIntent.getStringExtra("game");
}
if (!embed) { if (!embed) {
Intent intent = getIntent(); Intent intent = getIntent();
handleIntent(intent); handleIntent(intent);
@@ -259,6 +272,12 @@ public class GameActivity extends SDLActivity {
@Override @Override
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
Log.d("GameActivity", "onNewIntent() with " + intent); Log.d("GameActivity", "onNewIntent() with " + intent);
if (intent != null && intent.hasExtra("game")) {
String game = intent.getStringExtra("game");
if (game != null && !game.isEmpty()) {
nativeOnGameIntent(game);
}
}
if (!embed) { if (!embed) {
handleIntent(intent); handleIntent(intent);
resetNative(); resetNative();
@@ -671,6 +690,95 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest return true; // unreachable, but keeps the JNI signature honest
} }
@Keep
public static String getLaunchGame() {
return initialGame != null ? initialGame : "";
}
@Keep
public static boolean updateAppShortcuts(String[] readyVersions) {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT < 25) return false;
try {
Context context = self.getApplicationContext();
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
if (shortcutManager == null) return false;
if (readyVersions == null || readyVersions.length == 0) {
shortcutManager.removeAllDynamicShortcuts();
return true;
}
List<ShortcutInfo> shortcuts = new ArrayList<>();
int maxShortcuts = Math.min(readyVersions.length, 4);
for (int i = 0; i < maxShortcuts; i++) {
String ver = readyVersions[i];
if (ver == null || ver.isEmpty()) continue;
String lower = ver.toLowerCase();
String shortLabel;
String longLabel;
int iconResId;
switch (lower) {
case "red":
shortLabel = "Play Red";
longLabel = "Play Red";
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
break;
case "blue":
shortLabel = "Play Blue";
longLabel = "Play Blue";
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
break;
case "yellow":
shortLabel = "Play Yellow";
longLabel = "Play Yellow";
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
break;
case "gold":
shortLabel = "Play Gold";
longLabel = "Play Gold";
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
break;
default:
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
shortLabel = "Play " + capitalized;
longLabel = "Play " + capitalized;
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
break;
}
if (iconResId == 0) {
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
}
Intent intent = new Intent(context, GameActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("game", lower);
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIntent(intent);
if (iconResId != 0) {
builder.setIcon(Icon.createWithResource(context, iconResId));
}
shortcuts.add(builder.build());
}
shortcutManager.setDynamicShortcuts(shortcuts);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
return false;
}
}
/** /**
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload * Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so * and used by src/core/HostShell.lua. Android ships no curl binary, so
@@ -847,6 +955,149 @@ public class GameActivity extends SDLActivity {
} }
} }
/** Response ceiling for httpRequest; anything larger is refused, not buffered. */
private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024;
/** Builds an httpRequest envelope: one head line, a newline, then the body. */
private static byte[] httpEnvelope(String head, byte[] payload) {
byte[] prefix;
try {
prefix = (head + "\n").getBytes("UTF-8");
} catch (Exception e) {
prefix = (head + "\n").getBytes();
}
if (payload == null || payload.length == 0) return prefix;
byte[] out = new byte[prefix.length + payload.length];
System.arraycopy(prefix, 0, out, 0, prefix.length);
System.arraycopy(payload, 0, out, prefix.length, payload.length);
return out;
}
/** One-line, CR/LF-free failure text, so an envelope head stays one line. */
private static String httpErrorText(Exception e) {
String text = e.getMessage();
if (text == null || text.length() == 0) text = e.getClass().getSimpleName();
text = text.replace('\r', ' ').replace('\n', ' ');
if (text.length() > 160) text = text.substring(0, 160);
return text;
}
/**
* Blocking HTTPS request with a chosen method, headers and byte body,
* exposed as love.system.httpRequest and used by src/core/HostShell.lua
* for save sync. Sync needs PUT, per-request auth headers and the response
* body of a 4xx as well as a 2xx (a conflict answers 409 with the save
* that won), none of which httpDownload or httpPost above can express.
*
* Same rules as those two: https only, redirects followed by hand
* (re-sending method and body on each hop), 15s connect / 60s read, and
* blocking on the Lua/worker thread -- never the UI thread. Headers arrive
* as a flat name, value array; a field carrying CR or LF is refused rather
* than sent, so a header value can never inject a second header.
*
* The reply is an envelope: a head line of "STATUS &lt;code&gt;" or
* "ERROR &lt;text&gt;", a newline, then the raw response bytes.
*/
@Keep
public static byte[] httpRequest(String url, String method, String[] headerPairs,
byte[] body, String userAgent) {
if (url == null) return httpEnvelope("ERROR missing url", null);
String verb = method == null ? "GET" : method.toUpperCase(Locale.US);
if (!"GET".equals(verb) && !"POST".equals(verb)
&& !"PUT".equals(verb) && !"DELETE".equals(verb)) {
return httpEnvelope("ERROR unsupported request method", null);
}
if (headerPairs != null) {
if ((headerPairs.length % 2) != 0) {
return httpEnvelope("ERROR bad request header", null);
}
for (int i = 0; i < headerPairs.length; i++) {
String field = headerPairs[i];
if (field == null) return httpEnvelope("ERROR bad request header", null);
if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) {
return httpEnvelope("ERROR bad request header", null);
}
if ((i % 2) == 0 && field.length() == 0) {
return httpEnvelope("ERROR bad request header", null);
}
}
}
HttpURLConnection conn = null;
try {
String current = url;
for (int hop = 0; hop < 5; hop++) {
URL parsed = new URL(current);
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
return httpEnvelope("ERROR https only", null);
}
conn = (HttpURLConnection) parsed.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setRequestMethod(verb);
conn.setRequestProperty("User-Agent",
userAgent == null ? "gen1recomp" : userAgent);
if (headerPairs != null) {
for (int i = 0; i + 1 < headerPairs.length; i += 2) {
conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]);
}
}
if (body != null && !"GET".equals(verb)) {
conn.setDoOutput(true);
conn.setFixedLengthStreamingMode(body.length);
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
try {
out.write(body);
} finally {
try { out.close(); } catch (IOException ignored) {}
}
}
int code = conn.getResponseCode();
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
String next = conn.getHeaderField("Location");
conn.disconnect();
conn = null;
if (next == null) {
return httpEnvelope("ERROR redirect without a location", null);
}
current = new URL(parsed, next).toString();
continue;
}
// A rejection's body is the diagnosis the caller wants, so 4xx
// and 5xx are read through getErrorStream rather than dropped.
InputStream in;
try {
in = conn.getInputStream();
} catch (IOException e) {
in = conn.getErrorStream();
}
ByteArrayOutputStream sink = new ByteArrayOutputStream();
if (in != null) {
InputStream reader = new BufferedInputStream(in);
try {
byte[] buf = new byte[16384];
int n;
while ((n = reader.read(buf)) > 0) {
if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) {
return httpEnvelope("ERROR the reply was too large", null);
}
sink.write(buf, 0, n);
}
} finally {
try { reader.close(); } catch (IOException ignored) {}
}
}
return httpEnvelope("STATUS " + code, sink.toByteArray());
}
return httpEnvelope("ERROR too many redirects", null);
} catch (Exception e) {
Log.d("GameActivity", "httpRequest failed: " + e.getMessage());
return httpEnvelope("ERROR " + httpErrorText(e), null);
} finally {
if (conn != null) conn.disconnect();
}
}
/** /**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive / * (pending_export.sav in the app save identity) to Downloads / Drive /
-63
View File
@@ -12,69 +12,6 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"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",
+21
View File
@@ -6,6 +6,27 @@
// Registered from a constructor so no LÖVE/SDL source needs to know about it. // Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h> #import <UIKit/UIKit.h>
#import <sys/utsname.h>
@interface GRDeviceBridge : NSObject
+ (NSString *)deviceModel;
@end
@implementation GRDeviceBridge
+ (NSString *)deviceModel
{
#if TARGET_OS_SIMULATOR
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
if (simulatorModel.length > 0) return simulatorModel;
#endif
struct utsname systemInfo;
if (uname(&systemInfo) == 0) {
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
if (model.length > 0) return model;
}
return @"";
}
@end
__attribute__((constructor)) __attribute__((constructor))
static void GRBootstrapInstall(void) static void GRBootstrapInstall(void)
+118
View File
@@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject {
return succeeded return succeeded
} }
// MARK: - General HTTP request (love.system.httpRequest)
private static let httpMaxResponse = 4 * 1024 * 1024
// URLSession turns a 301/302/303 POST into a GET on its own. Save sync
// signs a method and a body, so every hop re-sends the original request
// against the new URL instead, and only over https.
private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
guard let original = task.originalRequest,
let target = request.url,
target.scheme?.lowercased() == "https" else {
completionHandler(nil)
return
}
var next = original
next.url = target
completionHandler(next)
}
}
private static let httpSession = URLSession(configuration: .ephemeral,
delegate: GRRedirectKeeper(),
delegateQueue: nil)
private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData {
var out = Data((head + "\n").utf8)
if let payload { out.append(payload) }
return out as NSData
}
private static func httpErrorText(_ error: Error) -> String {
var text = error.localizedDescription
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
if text.isEmpty { text = "the request failed" }
if text.count > 160 { text = String(text.prefix(160)) }
return text
}
/// Blocking HTTPS request with a chosen method, headers and byte body, the
/// iOS half of love.system.httpRequest (see the Android GameActivity one).
/// Headers arrive as "name: value" lines joined by newlines. The reply is
/// an envelope: a head line of "STATUS <code>" or "ERROR <text>", a
/// newline, then the raw response bytes -- read for 4xx and 5xx as well,
/// because a sync conflict answers 409 with the save that won.
@objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:)
public static func httpRequest(url: UnsafePointer<CChar>?,
method: UnsafePointer<CChar>?,
headers: UnsafePointer<CChar>?,
body: UnsafePointer<UInt8>?,
bodyLength: Int32,
userAgent: UnsafePointer<CChar>?) -> NSData? {
guard let url, let requestURL = URL(string: String(cString: url)) else {
return httpEnvelope("ERROR missing url", nil)
}
guard requestURL.scheme?.lowercased() == "https" else {
return httpEnvelope("ERROR https only", nil)
}
let verb = (method.map { String(cString: $0) } ?? "GET").uppercased()
guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else {
return httpEnvelope("ERROR unsupported request method", nil)
}
var request = URLRequest(url: requestURL)
request.httpMethod = verb
request.timeoutInterval = 60
request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp",
forHTTPHeaderField: "User-Agent")
if let headers, headers.pointee != 0 {
for line in String(cString: headers).split(separator: "\n") {
guard let colon = line.firstIndex(of: ":") else {
return httpEnvelope("ERROR bad request header", nil)
}
let name = line[line.startIndex..<colon]
.trimmingCharacters(in: .whitespaces)
let value = line[line.index(after: colon)...]
.trimmingCharacters(in: .whitespaces)
if name.isEmpty {
return httpEnvelope("ERROR bad request header", nil)
}
request.setValue(value, forHTTPHeaderField: name)
}
}
if verb != "GET", let body, bodyLength > 0 {
request.httpBody = Data(bytes: body, count: Int(bodyLength))
}
let semaphore = DispatchSemaphore(value: 0)
var envelope = httpEnvelope("ERROR no response", nil)
let task = httpSession.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error {
envelope = httpEnvelope("ERROR " + httpErrorText(error), nil)
return
}
guard let http = response as? HTTPURLResponse else {
envelope = httpEnvelope("ERROR no response", nil)
return
}
let payload = data ?? Data()
if payload.count > httpMaxResponse {
envelope = httpEnvelope("ERROR the reply was too large", nil)
return
}
envelope = httpEnvelope("STATUS \(http.statusCode)", payload)
}
task.resume()
guard semaphore.wait(timeout: .now() + 65) == .success else {
task.cancel()
return httpEnvelope("ERROR the request timed out", nil)
}
return envelope
}
// MARK: - Entry points called from liblove (C strings on purpose) // MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:) @objc(presentPickerWithKind:saveDir:)
+109 -2
View File
@@ -9,7 +9,8 @@ What it does:
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift, 1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree. GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile, 2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
love.system.createFile, and love.system.syncHealthSteps on iOS (each love.system.createFile, love.system.syncHealthSteps,
love.system.httpDownload and love.system.httpRequest on iOS (each
calls a GR*Bridge Swift class through the Objective-C runtime, so calls a GR*Bridge Swift class through the Objective-C runtime, so
liblove never links against Swift directly). liblove never links against Swift directly).
3. Patches love.xcodeproj so the love-ios app target compiles the native 3. Patches love.xcodeproj so the love-ios app target compiles the native
@@ -50,6 +51,7 @@ WRAP_INCLUDES = """
#include <objc/runtime.h> #include <objc/runtime.h>
#include <objc/message.h> #include <objc/message.h>
#include <string> #include <string>
#include <vector>
#include "filesystem/Filesystem.h" #include "filesystem/Filesystem.h"
#endif #endif
""" % MARKER """ % MARKER
@@ -154,11 +156,13 @@ int w_syncHealthSteps(lua_State *L)
""" % MARKER """ % MARKER
WRAP_REGISTRATION = """#ifdef LOVE_IOS WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds }, { "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif #endif
""" """
@@ -199,13 +203,42 @@ int w_syncHealthSteps(lua_State *L)
""" """
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "getDeviceModel", w_getDeviceModel },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif #endif
""" """
BRIDGE_EXTRA_FUNCS = """ BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS #ifdef LOVE_IOS
int w_getDeviceModel(lua_State *L)
{
Class cls = objc_getClass("GRDeviceBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRObj)(Class, SEL);
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
if (value == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(value,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\\0')
{
lua_pushnil(L);
return 1;
}
lua_pushstring(L, bytes);
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);
@@ -226,6 +259,80 @@ int w_httpDownload(lua_State *L)
lua_pushboolean(L, ok != 0); lua_pushboolean(L, ok != 0);
return 1; return 1;
} }
// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
//
// The transport save sync needs: a chosen method, per-request auth headers,
// and the response body of a 4xx as well as a 2xx. `headers` is a flat array
// of alternating name and value strings, joined into "name: value" lines here
// because the Swift bridge takes C strings and no Foundation type may be
// NAMED in this translation unit (see w_pickFileKinds above).
//
// The single return is the response envelope -- a head line of
// "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
// where the build carries no bridge at all, which src/core/HostShell.lua
// turns into an "update the app" notice rather than a failed request.
int w_httpRequest(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *method = luaL_optstring(L, 2, "GET");
std::string headerBlob;
if (!lua_isnoneornil(L, 3))
{
luaL_checktype(L, 3, LUA_TTABLE);
std::vector<std::string> fields;
size_t count = luax_objlen(L, 3);
for (size_t i = 1; i <= count; i++)
{
lua_rawgeti(L, 3, (int) i);
const char *field = lua_tostring(L, -1);
fields.push_back(field != nullptr ? field : "");
lua_pop(L, 1);
}
for (size_t i = 0; i + 1 < fields.size(); i += 2)
headerBlob += fields[i] + ": " + fields[i + 1] + "\\n";
}
size_t bodyLen = 0;
const char *body = nullptr;
if (!lua_isnoneornil(L, 4))
body = luaL_checklstring(L, 4, &bodyLen);
const char *ua = luaL_optstring(L, 5, "gen1recomp");
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRRequest)(Class, SEL, const char *, const char *,
const char *, const unsigned char *, int,
const char *);
id reply = ((GRRequest)objc_msgSend)(
cls,
sel_registerName("httpRequestWithUrl:method:headers:body:bodyLength:userAgent:"),
url, method, headerBlob.c_str(), (const unsigned char *) body,
(int) bodyLen, ua);
if (reply == nullptr)
{
lua_pushnil(L);
return 1;
}
// NSData read through the runtime, for the same reason as above: the
// bytes are copied out immediately, before any autorelease pool drains.
typedef const void *(*GRBytes)(id, SEL);
typedef unsigned long (*GRLength)(id, SEL);
const void *bytes = ((GRBytes)objc_msgSend)(reply, sel_registerName("bytes"));
unsigned long length = ((GRLength)objc_msgSend)(reply, sel_registerName("length"));
if (bytes == nullptr || length == 0)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, (const char *) bytes, (size_t) length);
return 1;
}
#endif #endif
""" """
@@ -308,7 +415,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1) text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text) WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched " print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps/httpDownload)") "(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)")
def patch_public_documents(): def patch_public_documents():
+1
View File
@@ -0,0 +1 @@
a5522634f1e581a1ebab73bf3ab4bd7a853b7a3e
+1
View File
@@ -0,0 +1 @@
97d4465e8c81099f79696ea4b4bb8b1f9083bc1a
+1
View File
@@ -0,0 +1 @@
12.0
+19
View File
@@ -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.

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 318 B

Before

Width:  |  Height:  |  Size: 687 B

After

Width:  |  Height:  |  Size: 687 B

@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+75 -15
View File
@@ -8,7 +8,7 @@
# [--notary-profile NAME] [--no-notarize] # [--notary-profile NAME] [--no-notarize]
# [--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
@@ -27,13 +27,18 @@ 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
@@ -41,6 +46,22 @@ 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" ;;
@@ -48,6 +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 ;;
--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" ;;
@@ -150,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 \
@@ -189,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)"
@@ -210,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"
@@ -221,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"
} }
+152
View File
@@ -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"
+22
View File
@@ -32,8 +32,30 @@ find_love() {
return 1 return 1
} }
find_love12() {
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)" \ LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" || 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.
+25 -4
View File
@@ -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
+85 -16
View File
@@ -1008,9 +1008,23 @@ end
-- flickers the OBJ palette, DoBallTossSpecialEffects) -- flickers the OBJ palette, DoBallTossSpecialEffects)
function BattleState:animNext(name, isPlayer, shakes, ball) function BattleState:animNext(name, isPlayer, shakes, ball)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes, ball = ball }
ball = ball }) table.insert(self.queue, self.nextInsert, row)
return row
end
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
function BattleState:animBeforeMove(name, isPlayer)
local at
for i, item in ipairs(self.queue) do
if item == self.moveAnimRow then at = i break end
end
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, at or self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
hit = { animType = isPlayer and 6 or 3 } })
end end
-- insert an act right after the current queue item -- insert an act right after the current queue item
@@ -1554,15 +1568,41 @@ end
function BattleState:sendOutText(name) function BattleState:sendOutText(name)
local e = self.enemy and self.enemy.mon local e = self.enemy and self.enemy.mon
local pct = 100 local pct = 100
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then if e and e.hp > 0 then
-- the same routine stamps wLastSwitchInEnemyMonHP
-- (engine/battle/common_text.asm:105-110)
self.lastSwitchInEnemyHP = e.hp
if math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4)) pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end end
end
if pct >= 70 then return Strings("Go! %s!", name) end if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return Strings("Do it! %s!", name) end if pct >= 40 then return Strings("Do it! %s!", name) end
if pct >= 10 then return Strings("Get'm! %s!", name) end if pct >= 10 then return Strings("Get'm! %s!", name) end
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name) return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end end
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
-- adjective reads the enemy HP lost since this mon switched in
function BattleState:withdrawText(name)
local e = self.enemy and self.enemy.mon
local drop = 0
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
/ math.floor(e.stats.hp / 4))
end
local word = ""
if drop <= 0 then
word = self:romText("_EnoughText", "enough!")
elseif drop >= 70 then
word = self:romText("_GoodText", "good!")
elseif drop >= 30 then
word = self:romText("_OKExclamationText", "OK!")
end
return self:romText("_PlayerMon2Text", "%s ", name) .. word
.. self:romText("_ComeBackText", "\nCome back!")
end
-- The cry a mon makes as it takes the field. Yellow does not run its -- The cry a mon makes as it takes the field. Yellow does not run its
-- starter Pikachu through PlayCry at all: SendOutMon branches to -- starter Pikachu through PlayCry at all: SendOutMon branches to
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM -- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
@@ -1816,7 +1856,8 @@ function BattleState:enter()
self.enemySendingOut = true self.enemySendingOut = true
self:slidePic("foe") self:slidePic("foe")
end) end)
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name)) -- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
self:sayAuto(Strings("%s sent\nout %s!", 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
@@ -1845,7 +1886,8 @@ function BattleState:enter()
self.sendingOut = true self.sendingOut = true
self:slidePic("back") self:slidePic("back")
end) end)
self:say(self:sendOutText(self.player.name)) -- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
self:sayAuto(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry -- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
self:queueSendOutAnim(true) self:queueSendOutAnim(true)
@@ -2527,11 +2569,7 @@ end
-- the HUD label drawn in place of the level for a statused mon -- the HUD label drawn in place of the level for a statused mon
function BattleState:statusLabel(mon) function BattleState:statusLabel(mon)
local record = Status.recordFor(self.data.statuses, mon.status) return Status.hudLabelFor(self.data.statuses, mon.status)
if record then
return record.hudLabel or record.label or mon.status
end
return mon.status
end end
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy -- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
@@ -2663,6 +2701,11 @@ function BattleState:resolveSwitch(newMon)
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
self:act(function() self:act(function()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
-- outgoing pic and holds 50 frames before the mon is recalled
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
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
self.player = makeBattler(self.data, newMon, true, self.game.save) self.player = makeBattler(self.data, newMon, true, self.game.save)
@@ -2677,9 +2720,10 @@ function BattleState:resolveSwitch(newMon)
self:markParticipant() self:markParticipant()
sendOutMonCursors(self) sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false) self:queueSendOutAnim(false)
end) end)
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)
@@ -2707,7 +2751,12 @@ function BattleState:residualFor(b, opp)
if b.residualDone then return end if b.residualDone then return end
b.residualDone = true b.residualDone = true
local msgs = Status.residual(b, opp, self) local msgs = Status.residual(b, opp, self)
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
-- engine/battle/core.asm:490-493
if rec and rec.residual then
self:animNext("BURN_PSN_ANIM", b.isPlayer)
end
if b.leechSeeded and b.mon.hp > 0 then if b.leechSeeded and b.mon.hp > 0 then
-- the drain plays the ABSORB animation from the healing side -- the drain plays the ABSORB animation from the healing side
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation) -- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
@@ -3591,7 +3640,17 @@ function BattleState:executeAction(user, target, action)
markSeen(self.game, self.enemy.mon.species) markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!" -- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) -- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
self.enemySendingOut = true
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end)
return return
end end
@@ -4281,6 +4340,9 @@ function BattleState:enemyMonFainted()
self:act(function() self:act(function()
local previous = self.enemy local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
self.lastSwitchInEnemyHP = self.enemy.mon.hp
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player) clearTrapping(self.player)
self:syncSides() self:syncSides()
@@ -4300,7 +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:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:sayNextAuto(Strings("%s sent\nout %s!", 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)
@@ -4313,6 +4375,12 @@ function BattleState:enemyMonFainted()
self:act(function() self:act(function()
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
-- hold, then the recall and the send-out
self.nextInsert = 0
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
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)
clearTrapping(self.enemy) clearTrapping(self.enemy)
@@ -4339,9 +4407,10 @@ function BattleState:enemyMonFainted()
self.nextInsert = 0 self.nextInsert = 0
sendOutMonCursors(self) sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false) self:queueSendOutAnim(false)
end) end)
end)
return return
end end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -4530,7 +4599,7 @@ function BattleState:openReplacementMenu()
self.nextInsert = 0 self.nextInsert = 0
sendOutMonCursors(self) sendOutMonCursors(self)
self.sendingOut = true self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name)) self:sayNextAuto(self:sendOutText(self.player.name))
self:queueSendOutAnim(false) self:queueSendOutAnim(false)
end, end,
}) })
+20 -1
View File
@@ -92,6 +92,20 @@ local function hitCount(ctx, record)
return dist[r + 1] return dist[r + 1]
end end
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
local function secondaryStatusFx(battle, user, status)
if status == "PSN" then
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
or "SHAKE_SCREEN_ANIM", user.isPlayer)
row.animDelayed = true
row.hit = { animType = user.isPlayer and 6 or 3 }
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
end
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
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> -- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
@@ -318,7 +332,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- secondary side effects (blocked by fainting) -- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary" if record and record.run and record.kind ~= "primary"
and target.mon.hp > 0 and totalDealt > 0 then and target.mon.hp > 0 and totalDealt > 0 then
for _, m in ipairs(record.run(ctx)) do local hadStatus = target.mon.status
local msgs = record.run(ctx)
if target.mon.status and target.mon.status ~= hadStatus then
secondaryStatusFx(battle, user, target.mon.status)
end
for _, m in ipairs(msgs) do
battle:sayNext(m) battle:sayNext(m)
end end
end end
+9
View File
@@ -581,6 +581,15 @@ MoveEffects.full = {
end, end,
}, },
THRASH_PETAL_DANCE_EFFECT = { THRASH_PETAL_DANCE_EFFECT = {
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
-- (data/battle/special_effects.asm:22) and animates the setup turn
beforeAccuracy = function(ctx)
local user = ctx.user
if not user.thrashTurns then
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end
end,
afterDamage = function(ctx) afterDamage = function(ctx)
local user = ctx.user local user = ctx.user
if not user.thrashTurns then if not user.thrashTurns then
+20 -5
View File
@@ -54,6 +54,13 @@ end
-- freeze the English. They are already translatable through the -- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })). -- statuses registry (mod.content.statuses:patch(id, { label = ... })).
-- --
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
-- hudLabel before label, and Registry:patch only overrides the fields a
-- mod actually passes, so a label-only translation patch would be
-- shadowed by this hudLabel forever. Nothing in this codebase gives
-- hudLabel a value different from label -- setting it here only recreates
-- that trap for no observed benefit.
--
-- The five persistent conditions as records: the beforeMove gauntlet, the -- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -61,7 +68,7 @@ end
-- read these fields, so a mod's sixth status plugs into every consumer. -- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = { Status.RECORDS = {
SLP = { SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP", id = "SLP", label = "SLP",
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40, beforeMovePriority = 40,
beforeMove = function(battler, _, battle) beforeMove = function(battler, _, battle)
@@ -82,7 +89,7 @@ Status.RECORDS = {
end, end,
}, },
FRZ = { FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ", id = "FRZ", label = "FRZ",
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30, beforeMovePriority = 30,
beforeMove = function(battler, _, battle) beforeMove = function(battler, _, battle)
@@ -96,7 +103,7 @@ Status.RECORDS = {
end, end,
}, },
PSN = { PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN", id = "PSN", label = "PSN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("_HurtByPoisonText", residual = damageOverTime("_HurtByPoisonText",
Strings.source("%s's\nhurt by poison!")), Strings.source("%s's\nhurt by poison!")),
@@ -112,7 +119,7 @@ Status.RECORDS = {
end, end,
}, },
BRN = { BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN", id = "BRN", label = "BRN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 }, statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("_HurtByBurnText", residual = damageOverTime("_HurtByBurnText",
@@ -124,7 +131,7 @@ Status.RECORDS = {
end, end,
}, },
PAR = { PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR", id = "PAR", label = "PAR",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 }, statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10, beforeMovePriority = 10,
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
return (statuses or Status.RECORDS)[id] return (statuses or Status.RECORDS)[id]
end end
-- the HUD label for a status id: a mod's patched hudLabel/label if the
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
function Status.hudLabelFor(statuses, id)
local record = Status.recordFor(statuses, id)
return record and (record.hudLabel or record.label) or id
end
local function battleStatuses(battle) local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses return battle and battle.data and battle.data.statuses
end end
+538
View File
@@ -0,0 +1,538 @@
local Json = require("src.link.Json")
local TouchSkin = require("src.core.TouchSkin")
local DeltaSkin = {}
DeltaSkin.INFO_NAME = "info.json"
DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024
DeltaSkin.GAME_TYPE_PREFIXES = {
"com.rileytestut.delta.game.",
"public.aoshuang.game.",
}
DeltaSkin.SYSTEMS = { gb = true, gbc = true }
DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" }
DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" }
DeltaSkin.ORIENTATIONS = { "portrait", "landscape" }
DeltaSkin.SIDES = { "up", "down", "left", "right" }
DeltaSkin.ASSET_LADDER = { "small", "medium", "large" }
DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 }
DeltaSkin.DEFAULT_TARGET_WIDTH = 1080
DeltaSkin.INPUTS = {
a = "a", b = "b", start = "start", select = "select",
up = "up", down = "down", left = "left", right = "right",
menu = "menu_toggle",
fastforward = "hold_fast_forward",
togglefastforward = "toggle_fast_forward",
}
DeltaSkin.OUTPUT_HOTKEYS = {
menu = "menu",
fast_forward_hold = "fastForward",
fast_forward_toggle = "toggleFastForward",
}
DeltaSkin.MAPPING = {
portrait = { width = 1080, height = 1920 },
landscape = { width = 1920, height = 1080 },
}
DeltaSkin.SCREEN_WIDTH = 160
DeltaSkin.SCREEN_HEIGHT = 144
local function pick(t, key)
if type(t) ~= "table" then return nil end
local direct = t[key]
if direct ~= nil then return direct end
local want = tostring(key):lower()
for k, v in pairs(t) do
if tostring(k):lower() == want then return v end
end
return nil
end
local function numOr(v, fallback)
local n = tonumber(v)
if not n or n ~= n then return fallback end
return n
end
local function round(n)
return math.floor(numOr(n, 0) + 0.5)
end
local function isArray(t)
return type(t) == "table" and t[1] ~= nil
end
local function addWarning(list, text)
if type(list) ~= "table" then return end
for _, existing in ipairs(list) do
if existing == text then return end
end
list[#list + 1] = text
end
function DeltaSkin.findInfo(root)
local direct = root .. "/" .. DeltaSkin.INFO_NAME
if TouchSkin.readFile(direct) then return direct, "" end
local items = TouchSkin.listDir(root)
for _, name in ipairs(items) do
if tostring(name):lower() == DeltaSkin.INFO_NAME then
return root .. "/" .. name, ""
end
end
table.sort(items)
for _, name in ipairs(items) do
local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME
if TouchSkin.readFile(nested) then return nested, name .. "/" end
end
return nil
end
function DeltaSkin.resolveName(name, opts)
name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "")
if name == "" then return nil end
local names = opts and opts.names
if type(names) == "table" then
local want = name:lower()
for _, entry in ipairs(names) do
if tostring(entry):lower() == want then
name = tostring(entry)
break
end
end
end
return ((opts and opts.prefix) or "") .. name
end
function DeltaSkin.pickAsset(assets, opts, pdfFiles)
if type(assets) ~= "table" then return nil end
pdfFiles = pdfFiles or {}
local raster = {}
local pdfName
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
local name = pick(assets, key)
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
if type(name) == "string" and name ~= "" then
if name:lower():match("%.pdf$") then
pdfName = name
pdfFiles[#pdfFiles + 1] = name
else
raster[#raster + 1] = { key = key, name = name }
end
end
end
local resizable = pick(assets, "resizable")
if type(resizable) == "string" and resizable ~= "" then
if resizable:lower():match("%.pdf$") then
pdfName = resizable
pdfFiles[#pdfFiles + 1] = resizable
else
raster[#raster + 1] = { key = "large", name = resizable }
end
end
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
if #raster == 0 then return nil, pdfPath end
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
local chosen
for _, cand in ipairs(raster) do
if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then
chosen = cand.name
end
end
if not chosen then chosen = raster[#raster].name end
return DeltaSkin.resolveName(chosen, opts), nil
end
function DeltaSkin.mergeEdges(base, item)
local out = { top = 0, bottom = 0, left = 0, right = 0 }
for _, side in ipairs({ "top", "bottom", "left", "right" }) do
local v = pick(item, side)
if v == nil then v = pick(base, side) end
out[side] = numOr(v, 0)
end
return out
end
function DeltaSkin.representation(reps, orient)
for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do
local dev = pick(reps, device)
if type(dev) == "table" then
for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do
local shown = pick(dev, display)
if type(shown) == "table" then
local obj = pick(shown, orient)
if type(obj) == "table" then return obj, device, display end
end
end
local flat = pick(dev, orient)
if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then
return flat, device, nil
end
end
end
return nil
end
function DeltaSkin.directionalInputs(inputs)
if type(inputs) ~= "table" or isArray(inputs) then return nil end
local out, found = {}, 0
for _, side in ipairs(DeltaSkin.SIDES) do
local v = pick(inputs, side)
if type(v) == "string" then
local lower = v:lower()
local mapped = DeltaSkin.INPUTS[lower]
if not mapped and lower:find(side, 1, true) then mapped = side end
if mapped then
out[side] = mapped
found = found + 1
end
end
end
if found >= 2 then return out end
return nil
end
function DeltaSkin.specFor(inputs)
local parts = {}
local function add(v)
if type(v) ~= "string" then return end
local mapped = DeltaSkin.INPUTS[v:lower()]
if mapped then parts[#parts + 1] = mapped end
end
if type(inputs) == "string" then
add(inputs)
elseif type(inputs) == "table" then
if isArray(inputs) then
for _, v in ipairs(inputs) do add(v) end
else
local keys = {}
for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
for _, k in ipairs(keys) do add(inputs[k]) end
end
end
if #parts == 0 then return "nul" end
return table.concat(parts, "|")
end
function DeltaSkin.screenRect(obj, mapW, mapH)
local frame
local screens = pick(obj, "screens")
if type(screens) == "table" and type(screens[1]) == "table" then
frame = pick(screens[1], "outputFrame")
end
if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end
if type(frame) ~= "table" then return nil end
local w = numOr(pick(frame, "width"), 0)
local h = numOr(pick(frame, "height"), 0)
if w <= 0 or h <= 0 then return nil end
return {
x = numOr(pick(frame, "x"), 0) / mapW,
y = numOr(pick(frame, "y"), 0) / mapH,
w = w / mapW, h = h / mapH,
}
end
function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
if type(item) ~= "table" then return end
local frame = pick(item, "frame")
if type(frame) ~= "table" then return end
local fw = numOr(pick(frame, "width"), 0)
local fh = numOr(pick(frame, "height"), 0)
if fw <= 0 or fh <= 0 then return end
local fx = numOr(pick(frame, "x"), 0)
local fy = numOr(pick(frame, "y"), 0)
local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges"))
local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH
local w, h = fw / mapW, fh / mapH
local reachLeft = 1 + edges.left / (fw * 0.5)
local reachRight = 1 + edges.right / (fw * 0.5)
local reachUp = 1 + edges.top / (fh * 0.5)
local reachDown = 1 + edges.bottom / (fh * 0.5)
local inputs = pick(item, "inputs")
local dirs = DeltaSkin.directionalInputs(inputs)
if dirs then
local base = {
x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5,
rangeMod = 1, alphaMod = page.alphaMod, shape = "rect",
reachLeft = reachLeft, reachRight = reachRight,
reachUp = reachUp, reachDown = reachDown,
}
for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do
page.controls[#page.controls + 1] = ctl
end
return
end
local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect"
local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape)
ctl.alphaMod = page.alphaMod
ctl.reachLeft, ctl.reachRight = reachLeft, reachRight
ctl.reachUp, ctl.reachDown = reachUp, reachDown
page.controls[#page.controls + 1] = ctl
end
function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
local mapping = pick(obj, "mappingSize")
local mapW = numOr(pick(mapping, "width"), 0)
local mapH = numOr(pick(mapping, "height"), 0)
if mapW <= 0 or mapH <= 0 then
mapW, mapH = 320, 240
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
end
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
local page = {
name = orient,
orient = orient,
imagePath = imagePath,
pdfPath = pdfPath,
fullScreen = true,
normalized = true,
pixelCoords = false,
rangeMod = 1,
alphaMod = pick(obj, "translucent") == true and 0.7 or 1,
aspect = mapW / mapH,
aspectFromCfg = false,
rect = { x = 0, y = 0, w = 1, h = 1 },
mappingWidth = mapW,
mappingHeight = mapH,
controls = {},
}
local screen = DeltaSkin.screenRect(obj, mapW, mapH)
if screen then
page.viewport = screen
page.viewportFill = false
else
-- mappingSize is the overlay, not the device. Portrait controller
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
-- that aspect, sit at the bottom, and leave the leftover for the
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
page.aspectFromCfg = true
page.screenFit = "remainder"
if orient == "portrait" then page.anchor = "bottom" end
end
local baseEdges = pick(obj, "extendedEdges")
local items = pick(obj, "items")
if type(items) == "table" then
for _, item in ipairs(items) do
DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
end
end
return page
end
function DeltaSkin.systemOf(gameType)
if type(gameType) ~= "string" or gameType == "" then return nil end
for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do
if gameType:sub(1, #prefix) == prefix then
return gameType:sub(#prefix + 1):lower()
end
end
return nil
end
function DeltaSkin.parse(text, opts)
opts = opts or {}
local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES)
if type(info) ~= "table" then
return nil, "info.json does not parse: " .. tostring(err)
end
local gameType = info.gameTypeIdentifier
if type(gameType) ~= "string" or gameType == "" then
return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier"
end
if gameType:lower():find("gba4ios", 1, true) then
return nil, "old GBA4iOS skin, not supported"
end
local system = DeltaSkin.systemOf(gameType)
if not system then
return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType
end
local warnings = {}
if not DeltaSkin.SYSTEMS[system] then
addWarning(warnings, "this skin is for " .. system .. ", not Game Boy")
end
local reps = info.representations
if type(reps) ~= "table" then return nil, "info.json has no representations" end
local pdfFiles, pages = {}, {}
for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do
local obj = DeltaSkin.representation(reps, orient)
if obj then
local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
page.index = #pages + 1
pages[#pages + 1] = page
end
end
if #pages == 0 then return nil, "info.json has no usable representation" end
return {
pages = pages,
name = info.name,
author = info.author,
notes = info.notes,
format = "delta",
system = system,
identifier = info.identifier,
warnings = warnings,
pdfFiles = pdfFiles,
}
end
function DeltaSkin.needsConversion(skin)
if type(skin) ~= "table" then return nil end
local files = skin.pdfFiles
if type(files) ~= "table" or #files == 0 then return nil end
for _, page in ipairs(skin.pages or {}) do
-- A raster asset, or a JPEG recovered from the PDF at load, means the
-- skin can draw. Parse-only callers still see pdfOnly because they
-- have not run extract yet.
if page.rasterData then return nil end
if page.imagePath then return nil end
end
return { pdfOnly = true, files = files }
end
function DeltaSkin.outputInputs(ctl)
local out = {}
for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end
for _, h in ipairs(ctl.hotkeys or {}) do
local mapped = DeltaSkin.OUTPUT_HOTKEYS[h]
if mapped then out[#out + 1] = mapped end
end
return out
end
function DeltaSkin.buildRepresentation(page, orient, warnings)
local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait
local mapW, mapH = map.width, map.height
local items, files = {}, {}
for _, ctl in ipairs(page.controls or {}) do
local names = DeltaSkin.outputInputs(ctl)
if ctl.sector and ctl.sector ~= 1 then
names = {}
elseif ctl.sector and ctl.areaNames then
local TouchSkin = require("src.core.TouchSkin")
local dirs = {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()]
if mapped then dirs[side] = mapped end
end
names = next(dirs) and dirs or {}
end
if names.up or names.down or names.left or names.right or #names > 0 then
local item = {
inputs = names,
frame = {
x = round((ctl.x - ctl.rangeX) * mapW),
y = round((ctl.y - ctl.rangeY) * mapH),
width = round(ctl.rangeX * 2 * mapW),
height = round(ctl.rangeY * 2 * mapH),
},
}
if ctl.shape == "radial" then item.mask = "circle" end
local edges, any = {}, false
local pairsList = {
{ key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW },
{ key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW },
{ key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH },
{ key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH },
}
for _, side in ipairs(pairsList) do
local reach = numOr(side.reach, 1)
if reach ~= 1 then
edges[side.key] = round((reach - 1) * side.half)
any = true
end
end
if any then item.extendedEdges = edges end
items[#items + 1] = item
elseif ctl.imagePath then
addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image")
end
end
local obj = {
items = items,
mappingSize = { width = mapW, height = mapH },
extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 },
translucent = false,
}
if page.imagePath then
obj.assets = {
small = page.imagePath, medium = page.imagePath, large = page.imagePath,
}
files[#files + 1] = page.imagePath
end
if page.viewport then
obj.screens = { {
inputFrame = { x = 0, y = 0,
width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT },
outputFrame = {
x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH),
width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH),
},
} }
end
return obj, files
end
function DeltaSkin.build(skin, opts)
if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then
return nil, "skin has no pages"
end
opts = opts or {}
local standard, edgeToEdge = {}, {}
local assets, warnings, used = {}, {}, {}
for _, page in ipairs(skin.pages) do
local orient = TouchSkin.pageOrient(page)
if orient ~= "portrait" and orient ~= "landscape" then
orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape"
end
if not used[orient] then
used[orient] = true
local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings)
standard[orient] = obj
edgeToEdge[orient] = obj
for _, rel in ipairs(files) do assets[#assets + 1] = rel end
end
end
local system = tostring(opts.system or "gbc")
local info = {
name = skin.name or skin.id or "skin",
identifier = opts.identifier
or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")),
gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system,
debug = false,
representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } },
}
return info, assets, warnings
end
function DeltaSkin.encodeInfo(skin, opts)
local info, assets, warnings = DeltaSkin.build(skin, opts)
if not info then return nil, assets end
return Json.encode(info), assets, warnings
end
return DeltaSkin
+37 -2
View File
@@ -34,6 +34,7 @@ end
function Game:load() function Game:load()
self.data = Data self.data = Data
self.sessionStartedAt = os.time()
Data:load() Data:load()
-- Mods are a native engine subsystem. They load after the verified ROM -- Mods are a native engine subsystem. They load after the verified ROM
@@ -155,6 +156,7 @@ function Game:makeTitleState()
onNewGame = function() onNewGame = function()
while self.stack:top() do self.stack:pop() end while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences -- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig()) self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must -- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead -- not leak into a fresh slot; mods seed via save.created instead
@@ -174,6 +176,7 @@ function Game:makeTitleState()
self:restoreSave(loaded, recovered, { freshBoot = true }) self:restoreSave(loaded, recovered, { freshBoot = true })
end end
end, end,
onExit = self.onExit,
}) })
title.screenId = title.screenId or "TitleState" title.screenId = title.screenId or "TitleState"
return title return title
@@ -356,6 +359,7 @@ function Game:update(dt)
-- reason: they are presentational, so fast-forward must not speed them up -- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt) require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end) pcall(function() require("src.core.DiscordPresence").update(dt) end)
self:updateSync(dt)
-- Steady-state memory backstop: advance the incremental collector one -- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed -- small step every rendered frame. The heavy GPU objects are now freed
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
@@ -1178,11 +1182,41 @@ function Game:writeSave()
-- stamp here so the save.writing payload carries the exact meta the -- stamp here so the save.writing payload carries the exact meta the
-- file gets; mods snapshot runtime state into their namespace now -- file gets; mods snapshot runtime state into their namespace now
self.save.meta = SaveData.buildMeta( self.save.meta = SaveData.buildMeta(
self.modStatus and self.modStatus.loaded, self.save.meta) self.modStatus and self.modStatus.loaded, self.save.meta,
self.sessionStartedAt)
if ModRuntime.wants("save.writing") then if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end end
return SaveData.save(self.save) local written = SaveData.save(self.save)
if written then
local eng = self:syncEngine()
if eng then pcall(eng.noteSaveWritten, eng) end
end
return written
end
function Game:syncEngine()
if self._syncOff then return nil end
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
self._syncOff = true
return nil
end
self._syncEngineRef = eng
return eng
end
function Game:updateSync(dt)
local eng = self:syncEngine()
if not eng then return end
if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end
pcall(eng.update, eng, dt)
end end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
@@ -1241,6 +1275,7 @@ function Game:applyOptions(opts)
end end
function Game:restoreSave(loaded, recovered, opts) function Game:restoreSave(loaded, recovered, opts)
self.sessionStartedAt = os.time()
if ModRuntime.wants("save.loading") then if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded }) ModRuntime.emit("save.loading", { raw = loaded })
end end
+24 -9
View File
@@ -36,6 +36,7 @@ local World = require("src.world.gen2.World")
-- other engine file, so a call site here is the same call site Gen 1 has. -- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport") local GameViewport = require("src.render.GameViewport")
local Playfield = require("src.render.Playfield")
-- Only for the mod-supplied save migrations and the mods-changed report, which -- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save -- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua. -- IO is src/core/gen2/Save.lua.
@@ -195,6 +196,7 @@ end
function Game2:persistOptions() function Game2:persistOptions()
pcall(Save.saveOptions, self.options) pcall(Save.saveOptions, self.options)
end end
Game2.writeOptions = Game2.persistOptions
-- Point the loader's mod.save backing at this save's modData so per-mod state -- Point the loader's mod.save backing at this save's modData so per-mod state
-- persists with the slot. Same contract and same three call sites as Gen 1 -- persists with the slot. Same contract and same three call sites as Gen 1
@@ -318,6 +320,7 @@ function Game2:showMainMenu()
onNewGame = function() self:newGame() end, onNewGame = function() self:newGame() end,
onContinue = function(save) self:continueGame(save) end, onContinue = function(save) self:continueGame(save) end,
onOption = function() self:showOptions(function() self:showMainMenu() end) end, onOption = function() self:showOptions(function() self:showMainMenu() end) end,
onExit = self.onExit,
}) })
end end
@@ -1160,7 +1163,8 @@ end
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to -- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
-- one screen pixel a cell out at survey range. -- one screen pixel a cell out at survey range.
function Game2:pixelScale(w, h) function Game2:pixelScale(w, h)
return math.max(1, math.floor(math.min(w / 160, h / 144))) local _, _, pw, ph = Playfield.rect(w, h)
return math.max(1, math.floor(math.min(pw / 160, ph / 144)))
end end
-- A window-sized canvas the whole frame is composed into, so the post passes -- A window-sized canvas the whole frame is composed into, so the post passes
@@ -1277,7 +1281,8 @@ end
function Game2:blitZones(canvas, zones, w, h) function Game2:blitZones(canvas, zones, w, h)
local G = love.graphics local G = love.graphics
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local sx, sy = w / 160, h / 144 local px, py, pw, ph = Playfield.rect(w, h)
local sx, sy = pw / 160, ph / 144
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
for _, z in ipairs(zones) do for _, z in ipairs(zones) do
-- a colors == false zone is the true-colour opt-out; anything the shader -- a colors == false zone is the true-colour opt-out; anything the shader
@@ -1296,11 +1301,11 @@ function Game2:blitZones(canvas, zones, w, h)
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones -- whose contract differs from Gen 1's. Whole-screen and half-screen zones
-- come out of this at exactly the pixels the plain floor/ceil pair gave -- come out of this at exactly the pixels the plain floor/ceil pair gave
-- them, so the vanilla picture is untouched. -- them, so the vanilla picture is untouched.
local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
local x1 = math.floor(math.max(zx, 0)) local x1 = math.floor(math.max(zx, px))
local y1 = math.floor(math.max(zy, 0)) local y1 = math.floor(math.max(zy, py))
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w)) local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw))
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h)) local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
if x2 > x1 and y2 > y1 then if x2 > x1 and y2 > y1 then
G.setScissor(x1, y1, x2 - x1, y2 - y1) G.setScissor(x1, y1, x2 - x1, y2 - y1)
G.draw(canvas, 0, 0) G.draw(canvas, 0, 0)
@@ -1402,7 +1407,7 @@ function Game2:drawViewportFrame()
scene = self:presentCanvas(1, w, h) scene = self:presentCanvas(1, w, h)
end end
if not scene then if not scene then
self:drawScene(w, h) self:drawContained(w, h)
self:drawHud(w, h) self:drawHud(w, h)
return return
end end
@@ -1413,7 +1418,7 @@ function Game2:drawViewportFrame()
G.origin() G.origin()
G.setCanvas(scene) G.setCanvas(scene)
G.clear(0, 0, 0, 1) G.clear(0, 0, 0, 1)
self:drawScene(w, h) self:drawContained(w, h)
G.setCanvas(previous) G.setCanvas(previous)
if composing and self:compose(scene, zones, w, h) then if composing and self:compose(scene, zones, w, h) then
@@ -1462,6 +1467,8 @@ function Game2:drawViewportFrame()
generation = 2, generation = 2,
}) == true }) == true
if not outputHandled then if not outputHandled then
local cx, cy, cw, ch = Playfield.cutout(w, h)
if cx then G.setScissor(cx, cy, cw, ch) end
if fx then if fx then
GBCFX.present(source, self:pixelScale(w, h)) GBCFX.present(source, self:pixelScale(w, h))
else else
@@ -1469,6 +1476,7 @@ function Game2:drawViewportFrame()
G.draw(source, 0, 0) G.draw(source, 0, 0)
G.setShader() G.setShader()
end end
if cx then G.setScissor() end
end end
end end
G.pop() G.pop()
@@ -1499,6 +1507,13 @@ function Game2:textboxPaper()
return nil return nil
end end
function Game2:drawContained(w, h)
local pw, ph = Playfield.push(w, h)
local ok, err = pcall(self.drawScene, self, pw, ph)
Playfield.pop()
if not ok then error(err, 0) end
end
function Game2:drawScene(w, h) function Game2:drawScene(w, h)
local G = love.graphics local G = love.graphics
-- render.compose reads this after the scene is drawn; the plain overworld -- render.compose reads this after the scene is drawn; the plain overworld
+181
View File
@@ -350,11 +350,23 @@ local function haveBridge()
return osName == "Android" or osName == "iOS" or osName == "UWP" return osName == "Android" or osName == "iOS" or osName == "UWP"
end end
local function haveRequestBridge()
if not (love and love.system and type(love.system.httpRequest) == "function") then
return false
end
local osName = love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS" or osName == "UWP"
end
-- Is any transport available at all? Callers gate on this, never on curl. -- Is any transport available at all? Callers gate on this, never on curl.
function HostShell.canFetch() function HostShell.canFetch()
return HostShell.haveCurl() or haveBridge() return HostShell.haveCurl() or haveBridge()
end end
function HostShell.canHttpRequest()
return (HostShell.haveCurl() or haveRequestBridge()) and true or false
end
-- Download url to an absolute host path. Returns true, or nil plus an error. -- Download url to an absolute host path. Returns true, or nil plus an error.
-- The curl branch deliberately ignores curl's exit code, as the download paths -- The curl branch deliberately ignores curl's exit code, as the download paths
-- always did: callers judge the result by the file they got. -- always did: callers judge the result by the file they got.
@@ -557,4 +569,173 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
return nil, "no POST transport on this platform" return nil, "no POST transport on this platform"
end end
local function requestHeaderList(headers)
local out = {}
if type(headers) == "table" then
if #headers > 0 then
for _, line in ipairs(headers) do
if type(line) == "string" then out[#out + 1] = line end
end
else
local names = {}
for name in pairs(headers) do names[#names + 1] = tostring(name) end
table.sort(names)
for _, name in ipairs(names) do
out[#out + 1] = name .. ": " .. tostring(headers[name])
end
end
end
for _, line in ipairs(out) do
if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end
end
return out
end
local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true }
local function requestHeaderPairs(lines)
local out = {}
for _, line in ipairs(lines) do
local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$")
if not name or name == "" then return nil end
if name:find("[\r\n]") or value:find("[\r\n]") then return nil end
out[#out + 1] = name
out[#out + 1] = value
end
return out
end
local function bridgeRequest(url, method, headers, body, userAgent)
if not BRIDGE_METHODS[method] then
return nil, "no request transport for " .. method .. " on this platform"
end
local fields = requestHeaderPairs(headers)
if not fields then return nil, "bad request header" end
local ok, envelope = pcall(love.system.httpRequest, url, method, fields,
body, userAgent)
if not ok or type(envelope) ~= "string" or envelope == "" then
return nil, "this app build cannot make signed requests: update the app to use save sync"
end
local head, rest = envelope:match("^([^\n]*)\n(.*)$")
if not head then
return nil, fetchError(url, nil, "unreadable reply from the network bridge")
end
local status = tonumber(head:match("^STATUS (%d+)$"))
if status then return rest or "", nil, status end
return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head)
end
local requestSeq = 0
local function requestStagingPath(kind)
local dir
if love and love.filesystem and love.filesystem.getSaveDirectory then
local ok, saveDir = pcall(love.filesystem.getSaveDirectory)
if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end
end
if not dir then
dir = os.getenv("TEMP") or os.getenv("TMP")
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
end
local sep = dir:find("\\") and "\\" or "/"
requestSeq = requestSeq + 1
return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format(
kind, os.time() % 1000000, requestSeq, math.random(0, 999999))
end
local function writeStagingFile(kind, text)
local path = requestStagingPath(kind)
local file, openErr = io.open(path, "wb")
if not file then
return nil, "could not create the request " .. kind .. ": " .. tostring(openErr)
end
local wrote, writeErr = pcall(function()
assert(file:write(text))
assert(file:close())
end)
if not wrote then
pcall(function() file:close() end)
pcall(os.remove, path)
return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr)
end
return path
end
function HostShell.httpRequest(url, opts)
opts = type(opts) == "table" and opts or {}
if type(url) ~= "string" or url == "" then return nil, "missing url" end
local method = tostring(opts.method or "GET"):upper()
if not method:match("^%u+$") then return nil, "bad request method" end
local headers = requestHeaderList(opts.headers)
if not headers then return nil, "bad request header" end
local body = opts.body
if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end
local userAgent = opts.userAgent or "gen1recomp"
local maxTime = tonumber(opts.maxTime) or 30
if not HostShell.haveCurl() then
if haveRequestBridge() then
return bridgeRequest(url, method, headers, body, userAgent)
end
if method == "GET" and #headers == 0 then
local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime)
if not got then return nil, err end
return got, nil, 200
end
if haveBridge() then
return nil, "this app build cannot make signed requests: update the app to use save sync"
end
return nil, "no request transport on this platform"
end
local bodyPath, stageErr
if body then
bodyPath, stageErr = writeStagingFile("body", body)
if not bodyPath then return nil, stageErr end
end
local lines = { "User-Agent: " .. userAgent }
for _, line in ipairs(headers) do lines[#lines + 1] = line end
if body then
lines[#lines + 1] = "Content-Length: " .. tostring(#body)
end
local headerPath
headerPath, stageErr = writeStagingFile("head",
table.concat(lines, "\n") .. "\n")
if not headerPath then
if bodyPath then pcall(os.remove, bodyPath) end
return nil, stageErr
end
local function cleanup()
if bodyPath then pcall(os.remove, bodyPath) end
pcall(os.remove, headerPath)
end
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
.. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then
cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
end
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
if not pipe then
cleanup()
return nil, "could not run curl"
end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
cleanup()
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
local respBody, status, noise = splitCurlOutput(out)
if not status then return nil, fetchError(url, nil, noise) end
return respBody or "", nil, status
end
return HostShell return HostShell
+292
View File
@@ -0,0 +1,292 @@
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml"
local APPLE_MODELS = {
["iPhone14,2"] = "iPhone 13 Pro",
["iPhone14,3"] = "iPhone 13 Pro Max",
["iPhone14,4"] = "iPhone 13 mini",
["iPhone14,5"] = "iPhone 13",
["iPhone14,7"] = "iPhone 14",
["iPhone14,8"] = "iPhone 14 Plus",
["iPhone15,2"] = "iPhone 14 Pro",
["iPhone15,3"] = "iPhone 14 Pro Max",
["iPhone15,4"] = "iPhone 15",
["iPhone15,5"] = "iPhone 15 Plus",
["iPhone16,1"] = "iPhone 15 Pro",
["iPhone16,2"] = "iPhone 15 Pro Max",
["iPhone17,1"] = "iPhone 16 Pro",
["iPhone17,2"] = "iPhone 16 Pro Max",
["iPhone17,3"] = "iPhone 16",
["iPhone17,4"] = "iPhone 16 Plus",
["iPhone17,5"] = "iPhone 16e",
["Mac14,2"] = "MacBook Air (13-inch, M2)",
["Mac14,3"] = "Mac mini (M2)",
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
["Mac14,12"] = "Mac mini (M2 Pro)",
["Mac14,13"] = "Mac Studio (M2 Max)",
["Mac14,14"] = "Mac Studio (M2 Ultra)",
["Mac14,15"] = "MacBook Air (15-inch, M2)",
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
["Mac15,12"] = "MacBook Air (13-inch, M3)",
["Mac15,13"] = "MacBook Air (15-inch, M3)",
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
["Mac16,10"] = "Mac mini (M4)",
["Mac16,11"] = "Mac mini (M4 Pro)",
}
local function clean(value)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
if text == "" or text == "unknown" or text == "Unknown" then return nil end
return text
end
local function call(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, a, b, c, d, e = pcall(fn, ...)
if not ok then return nil end
return a, b, c, d, e
end
local function invoke(fn, ...)
if type(fn) ~= "function" then return false end
local ok, result = pcall(fn, ...)
return ok, result
end
local function commandValue(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*l")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function commandText(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*a")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value)
local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
return ("%%%02X"):format(char:byte())
end))
end
local function formOS(raw)
local values = {
["OS X"] = "macOS",
macOS = "macOS",
Windows = "Windows",
Linux = "Linux",
Android = "Android",
iOS = "iOS",
NX = "Nintendo Switch",
UWP = "Xbox",
Xbox = "Xbox",
}
return values[raw] or ""
end
local function loveVersion()
local major, minor, revision, codename = call(love and love.getVersion)
if not major then return "" end
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
return result
end
local function friendlyModel(identifier)
identifier = clean(identifier)
if not identifier then return nil end
return APPLE_MODELS[identifier] or identifier
end
local function macModel()
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
if details then
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
if name and chip and not name:find(chip, 1, true) then
return name .. " (" .. chip .. ")"
end
if name then return name end
end
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
return version
end
local function deviceModel(rawOS, system)
local nativeModel = clean(call(system.getDeviceModel))
if nativeModel then return friendlyModel(nativeModel) end
if rawOS == "OS X" or rawOS == "macOS" then
return macModel()
end
local model = clean(call(system.getModel))
if model and not model:lower():find("gpu", 1, true)
and not model:lower():find("renderer", 1, true) then
return friendlyModel(model)
end
if rawOS == "Windows" then
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
end
if rawOS == "Linux" then
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
end
if rawOS == "Android" then
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
end
return nil
end
local function modRows(context)
if context and type(context.mods) == "table" then return context.mods end
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
if ok and LauncherMods and LauncherMods.list then
local listed = call(LauncherMods.list)
if type(listed) == "table" then return listed end
end
return {}
end
local function modNames(rows, safeMode)
local enabled = {}
for _, mod in ipairs(rows or {}) do
if type(mod) == "table" then
local name = clean(mod.name or mod.id)
if name and not safeMode and mod.enabled == true then
enabled[#enabled + 1] = name
end
end
end
table.sort(enabled)
return enabled
end
local function metadata(options, context)
local system = love and love.system or {}
local graphics = love and love.graphics or {}
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system)
local renderer, rendererVersion = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode)
local safeMode = SaveData.isSafeMode(options)
local rows = modRows(context or {})
local enabledMods = modNames(rows, safeMode)
local lines = { "Diagnostics:" }
local function add(label, value)
value = clean(value)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end
add("Platform", formOS(rawOS))
add("Device", model)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
end
add("Renderer", rendererDetails)
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
if displayWidth and displayHeight then
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
end
if pixelWidth and pixelHeight
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion()
add("App", version ~= "" and Version.title() or "gen1recomp")
add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end
return {
rawOS = rawOS,
os = formOS(rawOS),
device = model,
version = version,
safeMode = safeMode,
enabledMods = enabledMods,
metadata = table.concat(lines, "\n"),
}
end
function IssueReport.build(options, context)
options = options or SaveData.loadOptions()
context = context or {}
local info = metadata(options, context)
local fields = {
summary = "",
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
version = info.version or "",
location = "",
screenshot = "",
steps = "",
expected = "",
extra = info.metadata,
}
local params = {
"template=" .. percentEncode(TEMPLATE),
"title=" .. percentEncode("bug: replace this with a meaningful title"),
}
local order = { "summary", "mods_which",
"version", "location", "screenshot", "steps", "expected", "extra" }
for _, key in ipairs(order) do
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
end
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
end
function IssueReport.open(options, context)
local url = IssueReport.build(options, context)
local system = love and love.system or {}
local opened, openResult = invoke(system.openURL, url)
if opened and openResult ~= false then
return true, url
end
local copied, copyResult = invoke(system.setClipboardText, url)
if copied and copyResult ~= false then
return true, url, "Issue URL copied to the clipboard."
end
local filesystem = love and love.filesystem or {}
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
if written and writeResult ~= false then
return true, url, "Issue URL saved to issue-report-url.txt."
end
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
end
IssueReport.percentEncode = percentEncode
IssueReport.metadata = metadata
return IssueReport
+13
View File
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
return false return false
end end
local cachedIntentGame = nil
-- Returns version, slotId (either may be nil). Command line wins over env, -- Returns version, slotId (either may be nil). Command line wins over env,
-- so a shortcut can override a machine-wide default. -- so a shortcut can override a machine-wide default.
function LaunchOptions.resolve(argv) function LaunchOptions.resolve(argv)
if cachedIntentGame == nil then
if love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.getLaunchGame then
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
else
cachedIntentGame = false
end
end
local intentGame = cachedIntentGame or nil
local game = normalizeVersion(argValue(argv, "game")) local game = normalizeVersion(argValue(argv, "game"))
or intentGame
or normalizeVersion(os.getenv("POKEPORT_GAME")) or normalizeVersion(os.getenv("POKEPORT_GAME"))
or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT") local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
+115
View File
@@ -0,0 +1,115 @@
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
-- import pulls the embedded image out instead of refusing the skin. True
-- vector PDFs (no Image XObject, no JPEG) still fail.
local PdfImage = {}
local function isPdf(bytes)
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
end
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
-- `endstream` also contains the letters "stream", so skip that match.
local function streamDataStart(bytes, from)
local s, e = bytes:find("stream", from, true)
while s do
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
local p = e + 1
if bytes:sub(p, p) == "\r" then p = p + 1 end
if bytes:sub(p, p) == "\n" then p = p + 1 end
return p, s
end
s, e = bytes:find("stream", e + 1, true)
end
return nil
end
local function dictWindow(bytes, imageAt)
local from = imageAt > 400 and (imageAt - 400) or 1
local to = math.min(#bytes, imageAt + 800)
return bytes:sub(from, to)
end
local function dictNumber(window, key)
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
end
local function dictFilter(window)
local named = window:match("/Filter%s*/(%w+)")
if named then return named end
return window:match("/Filter%s*%[%s*/(%w+)")
end
local function jpegIn(bytes, from, to)
if from < 1 then from = 1 end
if not to or to > #bytes then to = #bytes end
if to < from then return nil end
local region = bytes:sub(from, to)
local soi = region:find("\255\216\255", 1, true)
if not soi then return nil end
local eoi = region:find("\255\217", soi + 3, true)
if not eoi then return nil end
return region:sub(soi, eoi + 1)
end
local function candidate(data, width, height, ext)
if not data or data == "" then return nil end
return {
data = data,
ext = ext or "jpg",
width = width or 0,
height = height or 0,
}
end
local function bigger(a, b)
if not a then return b end
if not b then return a end
local as = (a.width or 0) * (a.height or 0)
local bs = (b.width or 0) * (b.height or 0)
if bs ~= as then return bs > as and b or a end
return #b.data > #a.data and b or a
end
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
local function fromImageXObjects(bytes)
local best
local i = 1
while true do
local s, e = bytes:find("/Subtype%s*/Image", i)
if not s then break end
local window = dictWindow(bytes, s)
local filter = dictFilter(window)
local width = dictNumber(window, "Width")
local height = dictNumber(window, "Height")
local dataStart = streamDataStart(bytes, e)
i = e + 1
if dataStart and filter == "DCTDecode" then
local es = bytes:find("endstream", dataStart, true)
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
best = bigger(best, candidate(jpeg, width, height, "jpg"))
end
end
return best
end
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
-- body even when /Length is an indirect object we do not resolve.
local function fromBareJpeg(bytes)
local jpeg = jpegIn(bytes, 1, #bytes)
if not jpeg then return nil end
return candidate(jpeg, 0, 0, "jpg")
end
function PdfImage.extract(bytes)
if not isPdf(bytes) then return nil, "not a pdf" end
local best = fromImageXObjects(bytes)
if not best then best = fromBareJpeg(bytes) end
if not best then return nil, "no extractable image" end
return best
end
return PdfImage
+40 -3
View File
@@ -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
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data. -- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default. -- Missing entries mean enabled so newly installed mods work by default.
mods = {}, mods = {},
safeMode = false,
-- Mods the player forced past the target gate (Loader:_gateGeneration). -- Mods the player forced past the target gate (Loader:_gateGeneration).
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the -- modsGen2[id][version] = true, one answer per game; a bare `true` is the
-- pre-per-game shape and means the Gen 2 games only (see modForced). -- pre-per-game shape and means the Gen 2 games only (see modForced).
@@ -356,6 +357,8 @@ function SaveData.defaultOptions()
-- rewind presentation preferences. -- rewind presentation preferences.
dateFormat = "device", -- device | dmy | mdy | ymd dateFormat = "device", -- device | dmy | mdy | ymd
timeFormat = "device", -- device | 24h | 12h timeFormat = "device", -- device | 24h | 12h
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
pendingConflicts = {} },
} }
end end
@@ -384,6 +387,16 @@ function SaveData.mergeOptions(loaded)
return opts return opts
end end
function SaveData.isSafeMode(options)
return type(options) == "table" and options.safeMode == true
end
function SaveData.setSafeMode(options, enabled)
if type(options) ~= "table" then return false end
options.safeMode = enabled == true
return options.safeMode
end
function SaveData.encode(data) function SaveData.encode(data)
return SaveSerializer.encode(data) return SaveSerializer.encode(data)
end end
@@ -1013,6 +1026,22 @@ function SaveData.listSlots(version)
return out return out
end end
function SaveData.readSlotSource(version, slotId, injectedFs)
version = version or GameVersion.get()
if not knownVersion(version) or type(slotId) ~= "string" then return nil end
local fs = persistFs(injectedFs)
local main, bak, tmp = slotNames(version, slotId)
for _, name in ipairs({ main, tmp, bak }) do
if fs.getInfo(name) then
local body = fs.read(name)
if type(body) == "string" and body ~= "" then
if SaveSerializer.decode(body) then return body end
end
end
end
return nil
end
-- Give a registered slot a custom label (#205: "a way to name save slots so -- Give a registered slot a custom label (#205: "a way to name save slots so
-- you can see that in the launcher"). The label lives in the options -- you can see that in the launcher"). The label lives in the options
-- registry next to list/active, never in the save file itself, so renaming -- registry next to list/active, never in the save file itself, so renaming
@@ -1327,7 +1356,7 @@ end
-- loaded list sorted by id and is the ground truth for the load-time -- loaded list sorted by id and is the ground truth for the load-time
-- mod-set diff. A nil mods list keeps the previous stamp's set so a -- mod-set diff. A nil mods list keeps the previous stamp's set so a
-- headless writer (the save editor) never wipes it. -- headless writer (the save editor) never wipes it.
function SaveData.buildMeta(mods, previous) function SaveData.buildMeta(mods, previous, sessionStart)
local list local list
if mods ~= nil then if mods ~= nil then
list = {} list = {}
@@ -1338,10 +1367,18 @@ function SaveData.buildMeta(mods, previous)
else else
list = (type(previous) == "table" and previous.mods) or {} list = (type(previous) == "table" and previous.mods) or {}
end end
local started = tonumber(sessionStart)
if not started or started ~= started or started <= 0
or started == math.huge then
started = type(previous) == "table" and tonumber(previous.sessionStart) or nil
end
local savedAt = os.time()
if started and started > savedAt then started = savedAt end
return { return {
format = Version.saveFormat, format = Version.saveFormat,
engine = Version.engine, engine = Version.engine,
savedAt = os.time(), savedAt = savedAt,
sessionStart = started,
playthroughId = type(previous) == "table" and previous.playthroughId or nil, playthroughId = type(previous) == "table" and previous.playthroughId or nil,
mods = list, mods = list,
} }
+6 -4
View File
@@ -616,13 +616,15 @@ local function exitControl(self, ctl)
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end end
function skinHitSet(self, x, y) function skinHitSet(self, x, y, prev)
local page = TouchSkin.page() local page = TouchSkin.page()
if not page then return nil end if not page then return nil end
local ww, wh, ox, oy = surfaceRect() local ww, wh, ox, oy = surfaceRect()
local set = nil local set = nil
for _, ctl in ipairs(page.controls) do for _, ctl in ipairs(page.controls) do
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then local held = (prev and prev[ctl]) == true
if not ctl.decorative
and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then
set = set or {} set = set or {}
set[ctl] = true set[ctl] = true
end end
@@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y)
return return
end end
if TouchSkin.active then if TouchSkin.active then
local set = skinHitSet(self, x, y) local set = skinHitSet(self, x, y, nil)
if not set then return end if not set then return end
local touch = { control = "skin" } local touch = { control = "skin" }
self.touches[id] = touch self.touches[id] = touch
@@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y)
local touch = self.touches[id] local touch = self.touches[id]
if not touch then return end if not touch then return end
if touch.control == "skin" then if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y)) applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
return return
end end
-- only the d-pad tracks movement (slide between directions without -- only the d-pad tracks movement (slide between directions without
+544 -53
View File
@@ -2,6 +2,7 @@ local TouchSkin = {}
TouchSkin.BUNDLED_ROOT = "assets/skins" TouchSkin.BUNDLED_ROOT = "assets/skins"
TouchSkin.USER_ROOT = "skins" TouchSkin.USER_ROOT = "skins"
TouchSkin.EXPORT_ROOT = "skins/_export"
TouchSkin.GB_BUTTONS = { TouchSkin.GB_BUTTONS = {
a = "a", b = "b", start = "start", select = "select", a = "a", b = "b", start = "start", select = "select",
@@ -83,6 +84,110 @@ local function parseBinds(spec)
return buttons, hotkeys, keys, decorative return buttons, hotkeys, keys, decorative
end end
TouchSkin.AREA_DEFAULTS = {
dpad_area = { up = "up", down = "down", left = "left", right = "right" },
abxy_area = { up = "x", down = "b", left = "y", right = "a" },
analog_left = { up = "up", down = "down", left = "left", right = "right" },
analog_right = { up = "up", down = "down", left = "left", right = "right" },
}
local DIRECTIONAL_CELLS = {
{ col = 1, row = 1, h = "left", v = "up" },
{ col = 2, row = 1, v = "up" },
{ col = 3, row = 1, h = "right", v = "up" },
{ col = 1, row = 2, h = "left" },
{ col = 3, row = 2, h = "right" },
{ col = 1, row = 3, h = "left", v = "down" },
{ col = 2, row = 3, v = "down" },
{ col = 3, row = 3, h = "right", v = "down" },
}
local function outwardReach(reach)
return 1 + 3 * ((num(reach, 1)) - 1)
end
function TouchSkin.expandDirectional(base, names)
names = names or {}
local cellX = math.abs(num(base.rangeX, 0.05)) / 3
local cellY = math.abs(num(base.rangeY, 0.05)) / 3
local out = {}
for _, cell in ipairs(DIRECTIONAL_CELLS) do
local parts = {}
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
local ctl = TouchSkin.newControl(spec,
num(base.x, 0.5) + (cell.col - 2) * cellX * 2,
num(base.y, 0.5) + (cell.row - 2) * cellY * 2,
cellX * 2, cellY * 2, "rect")
ctl.rangeMod = num(base.rangeMod, 1)
ctl.alphaMod = num(base.alphaMod, 1)
ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1
ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1
ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1
ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1
ctl.pixelCoords = base.pixelCoords
ctl.movable = base.movable
ctl.exclusive = base.exclusive
out[#out + 1] = ctl
end
return out
end
local SECTOR_CELLS = {
{ h = "right" },
{ h = "right", v = "down" },
{ v = "down" },
{ h = "left", v = "down" },
{ h = "left" },
{ h = "left", v = "up" },
{ v = "up" },
{ h = "right", v = "up" },
}
TouchSkin.SECTOR_SPAN = math.pi / 4
function TouchSkin.sectorHit(sector, dx, dy)
local span = TouchSkin.SECTOR_SPAN
local start = (sector - 1) * span - span * 0.5
local a = (math.atan2(dy, dx) - start) % (math.pi * 2)
return a < span
end
function TouchSkin.expandSectors(base, names)
names = names or {}
local out = {}
for i, cell in ipairs(SECTOR_CELLS) do
local parts = {}
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5),
math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2,
base.shape)
ctl.sector = i
ctl.areaKind = base.areaKind
ctl.areaNames = base.areaNames
ctl.rangeMod = num(base.rangeMod, 1)
ctl.alphaMod = num(base.alphaMod, 1)
ctl.reachLeft = num(base.reachLeft, 1)
ctl.reachRight = num(base.reachRight, 1)
ctl.reachUp = num(base.reachUp, 1)
ctl.reachDown = num(base.reachDown, 1)
ctl.pixelCoords = base.pixelCoords
ctl.movable = base.movable
ctl.exclusive = base.exclusive
out[#out + 1] = ctl
end
return out
end
local function areaSide(kv, prefix, side, fallback)
local v = kv[prefix .. "_" .. side]
if v == nil or trim(v) == "" then return fallback end
return trim(v)
end
local function parseDesc(kv, prefix, page) local function parseDesc(kv, prefix, page)
local spec = kv[prefix] local spec = kv[prefix]
if not spec then return nil end if not spec then return nil end
@@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page)
imagePath = kv[prefix .. "_overlay"], imagePath = kv[prefix .. "_overlay"],
pressedImagePath = kv[prefix .. "_overlay_pressed"], pressedImagePath = kv[prefix .. "_overlay_pressed"],
nextTarget = kv[prefix .. "_next_target"], nextTarget = kv[prefix .. "_next_target"],
movable = toBool(kv[prefix .. "_movable"]) or nil,
exclusive = (toBool(kv[prefix .. "_exclusive"])
or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil,
saturatePct = num(kv[prefix .. "_saturate_pct"], nil),
} }
if ctl.imagePath == "" then ctl.imagePath = nil end if ctl.imagePath == "" then ctl.imagePath = nil end
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
local normalized = kv[prefix .. "_normalized"]
if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end
local areaKind = trim(t[1]):lower()
local defaults = TouchSkin.AREA_DEFAULTS[areaKind]
if defaults then
ctl.areaKind = areaKind
ctl.areaNames = {
up = areaSide(kv, prefix, "up", defaults.up),
down = areaSide(kv, prefix, "down", defaults.down),
left = areaSide(kv, prefix, "left", defaults.left),
right = areaSide(kv, prefix, "right", defaults.right),
}
end
return ctl return ctl
end end
@@ -127,7 +251,14 @@ function TouchSkin.parse(text)
local count = math.floor(num(kv.overlays, 0)) local count = math.floor(num(kv.overlays, 0))
if count <= 0 then return nil, "no overlays" end if count <= 0 then return nil, "no overlays" end
local pages = {} local pages, warnings = {}, {}
local function warn(text)
for _, existing in ipairs(warnings) do
if existing == text then return end
end
warnings[#warnings + 1] = text
end
for i = 0, count - 1 do for i = 0, count - 1 do
local p = "overlay" .. i local p = "overlay" .. i
local page = { local page = {
@@ -166,10 +297,34 @@ function TouchSkin.parse(text)
page.viewportExpand = toBool(kv[p .. "_viewport_expand"]) page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end end
page.pixelCoords = not page.normalized
if page.pixelCoords and not page.imagePath then
page.pixelCoords = false
warn(page.name .. " has no base image: desc coordinates read as normalized")
end
local descs = math.floor(num(kv[p .. "_descs"], 0)) local descs = math.floor(num(kv[p .. "_descs"], 0))
for d = 0, descs - 1 do for d = 0, descs - 1 do
local ctl = parseDesc(kv, p .. "_desc" .. d, page) local ctl = parseDesc(kv, p .. "_desc" .. d, page)
if ctl then page.controls[#page.controls + 1] = ctl end if not ctl then
warn(page.name .. " is missing desc " .. d)
elseif ctl.areaKind then
if ctl.imagePath or ctl.pressedImagePath then
local art = TouchSkin.newControl("nul", ctl.x, ctl.y,
ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
art.imagePath = ctl.imagePath
art.pressedImagePath = ctl.pressedImagePath
art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod
art.pixelCoords = ctl.pixelCoords
art.movable, art.exclusive = ctl.movable, ctl.exclusive
page.controls[#page.controls + 1] = art
end
for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do
page.controls[#page.controls + 1] = cell
end
else
page.controls[#page.controls + 1] = ctl
end
end end
pages[#pages + 1] = page pages[#pages + 1] = page
end end
@@ -180,7 +335,7 @@ function TouchSkin.parse(text)
if not page.orient then page.orient = TouchSkin.pageOrient(page) end if not page.orient then page.orient = TouchSkin.pageOrient(page) end
end end
return { pages = pages } return { pages = pages, warnings = warnings }
end end
local function readFile(path) local function readFile(path)
@@ -219,18 +374,26 @@ end
TouchSkin.NATIVE_NAME = "skin.lua" TouchSkin.NATIVE_NAME = "skin.lua"
TouchSkin.readFile = readFile
TouchSkin.listDir = listDir
TouchSkin.isDir = isDir
local function findConfig(root) local function findConfig(root)
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
return root .. "/" .. TouchSkin.NATIVE_NAME, "native" return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
end end
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
for _, name in ipairs(named) do for _, name in ipairs(named) do
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end if readFile(root .. "/" .. name) then
return root .. "/" .. name, "retroarch", ""
end end
end
local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
if infoPath then return infoPath, "delta", prefix end
local items = listDir(root) local items = listDir(root)
table.sort(items) table.sort(items)
for _, name in ipairs(items) do for _, name in ipairs(items) do
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end
end end
return nil return nil
end end
@@ -262,12 +425,17 @@ function TouchSkin.parseNative(text)
imagePath = raw.image, imagePath = raw.image,
fullScreen = raw.fullScreen ~= false, fullScreen = raw.fullScreen ~= false,
normalized = true, normalized = true,
pixelCoords = false,
rangeMod = num(raw.rangeMod, 1), rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1), alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT), aspect = num(raw.aspect, DEFAULT_ASPECT),
aspectFromCfg = raw.fitAspect == true, aspectFromCfg = raw.fitAspect == true,
orient = (raw.orient == "portrait" or raw.orient == "landscape" orient = (raw.orient == "portrait" or raw.orient == "landscape"
or raw.orient == "any") and raw.orient or nil, or raw.orient == "any") and raw.orient or nil,
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
or raw.anchor == "left" or raw.anchor == "right")
and raw.anchor or nil,
rect = { x = 0, y = 0, w = 1, h = 1 }, rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {}, controls = {},
} }
@@ -283,7 +451,24 @@ function TouchSkin.parseNative(text)
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")
local sector = tonumber(c.sector)
if sector then
sector = math.floor(sector)
if sector < 1 or sector > #SECTOR_CELLS then sector = nil end
end
local areaNames
if type(c.areaNames) == "table" then
areaNames = {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
if type(c.areaNames[side]) == "string" then
areaNames[side] = c.areaNames[side]
end
end
end
page.controls[#page.controls + 1] = { page.controls[#page.controls + 1] = {
sector = sector,
areaKind = type(c.areaKind) == "string" and c.areaKind or nil,
areaNames = areaNames,
spec = tostring(c.bind or "nul"), spec = tostring(c.bind or "nul"),
buttons = buttons, hotkeys = hotkeys, keys = keys, buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative, decorative = decorative,
@@ -298,6 +483,8 @@ function TouchSkin.parseNative(text)
imagePath = c.image, imagePath = c.image,
pressedImagePath = c.imagePressed, pressedImagePath = c.imagePressed,
nextTarget = c.nextTarget, nextTarget = c.nextTarget,
movable = c.movable == true or nil,
exclusive = c.exclusive == true or nil,
} }
end end
if not page.orient then page.orient = TouchSkin.pageOrient(page) end if not page.orient then page.orient = TouchSkin.pageOrient(page) end
@@ -324,6 +511,8 @@ function TouchSkin.toNative(skin)
alphaMod = page.alphaMod, alphaMod = page.alphaMod,
aspect = page.aspect, aspect = page.aspect,
fitAspect = page.aspectFromCfg or nil, fitAspect = page.aspectFromCfg or nil,
screenFit = page.screenFit == "remainder" and "remainder" or nil,
anchor = page.anchor,
orient = (page.orient == "portrait" or page.orient == "landscape" orient = (page.orient == "portrait" or page.orient == "landscape"
or page.orient == "any") and page.orient or nil, or page.orient == "any") and page.orient or nil,
controls = {}, controls = {},
@@ -355,6 +544,14 @@ function TouchSkin.toNative(skin)
image = ctl.imagePath, image = ctl.imagePath,
imagePressed = ctl.pressedImagePath, imagePressed = ctl.pressedImagePath,
nextTarget = ctl.nextTarget, nextTarget = ctl.nextTarget,
movable = ctl.movable or nil,
exclusive = ctl.exclusive or nil,
sector = ctl.sector,
areaKind = ctl.areaKind,
areaNames = ctl.areaNames and {
up = ctl.areaNames.up, down = ctl.areaNames.down,
left = ctl.areaNames.left, right = ctl.areaNames.right,
} or nil,
} }
end end
out.pages[#out.pages + 1] = p out.pages[#out.pages + 1] = p
@@ -379,14 +576,78 @@ local function loadImage(path)
return img return img
end end
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
local function loadImageFromBytes(bytes, name)
if not bytes or bytes == "" then return nil end
if not (love and love.graphics and love.graphics.newImage) then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
local cached = imageCache[key]
if cached then return cached end
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
if not okFd or not fd then return nil end
local ok, img = pcall(love.graphics.newImage, fd)
if not ok or not img then
if love.image and love.image.newImageData then
local okData, data = pcall(love.image.newImageData, fd)
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
end
end
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[key] = img
return img
end
local function rasterizePdfPage(page, root)
if not page or page.image or not page.pdfPath then return end
local pdf = readFile(joinPath(root, page.pdfPath))
local raster = require("src.core.PdfImage").extract(pdf)
if not raster then return end
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
page.rasterData = raster.data
page.rasterName = name:match("([^/]+)$") or name
page.image = loadImageFromBytes(raster.data, page.rasterName)
end
local function pixelScalePending(page)
if page.pixelCoords then return true end
for _, ctl in ipairs(page.controls or {}) do
if ctl.pixelCoords then return true end
end
return false
end
local function applyPixelScale(page)
if not pixelScalePending(page) then return true end
if 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
for _, ctl in ipairs(page.controls or {}) do
local pixel = ctl.pixelCoords
if pixel == nil then pixel = page.pixelCoords end
if pixel then
ctl.x, ctl.y = ctl.x / iw, ctl.y / ih
ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih
ctl.pixelCoords = false
end
end
page.pixelCoords = false
return true
end
function TouchSkin.load(root, id) function TouchSkin.load(root, id)
local cfgPath, format = findConfig(root) local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
local text = readFile(cfgPath) local text = readFile(cfgPath)
if not text then return nil, "unreadable " .. cfgPath end if not text then return nil, "unreadable " .. cfgPath end
local skin, err local skin, err
if format == "native" then if format == "native" then
skin, err = TouchSkin.parseNative(text) skin, err = TouchSkin.parseNative(text)
elseif format == "delta" then
local dir = cfgPath:match("^(.*)/[^/]+$") or root
skin, err = require("src.core.DeltaSkin").parse(text,
{ prefix = prefix or "", names = listDir(dir) })
else else
skin, err = TouchSkin.parse(text) skin, err = TouchSkin.parse(text)
end end
@@ -401,6 +662,12 @@ function TouchSkin.load(root, id)
for _, page in ipairs(skin.pages) do for _, page in ipairs(skin.pages) do
if page.imagePath then if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath)) page.image = loadImage(joinPath(root, page.imagePath))
elseif page.pdfPath then
rasterizePdfPage(page, root)
end
if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath)
.. ", which " .. page.name .. " measures its coordinates against"
end end
for _, ctl in ipairs(page.controls) do for _, ctl in ipairs(page.controls) do
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
@@ -418,14 +685,30 @@ local function mountZip(archive, point)
return ok and mounted == true return ok and mounted == true
end end
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
TouchSkin.PDF_ONLY_MESSAGE =
"This skin uses PDF artwork with no extractable image. "
.. "Ask the author for a PNG version."
function TouchSkin.archiveId(name)
name = tostring(name or "")
local ext = name:match("%.([%w]+)$")
if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end
local id = name:sub(1, #name - #ext - 1)
if id == "" then return nil end
return id, ext:lower()
end
function TouchSkin.list() function TouchSkin.list()
local out, seen = {}, {} local out, seen = {}, {}
local function scan(root, source) local function scan(root, source)
for _, name in ipairs(listDir(root)) do for _, name in ipairs(listDir(root)) do
local id = name:gsub("%.zip$", "") local archiveId = TouchSkin.archiveId(name)
if not seen[id] then local id = archiveId or name
if not seen[id] and name:sub(1, 1) ~= "_" then
local path = root .. "/" .. name local path = root .. "/" .. name
if name:match("%.zip$") then if archiveId then
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
if mountZip(path, point) and findConfig(point) then if mountZip(path, point) and findConfig(point) then
seen[id] = true seen[id] = true
@@ -447,17 +730,20 @@ function TouchSkin.list()
return out return out
end end
-- Drop a .zip into <save>/skins and report the id it will list under. -- Drop a .zip or .deltaskin into <save>/skins and report the id it lists under.
function TouchSkin.installArchive(name, data) function TouchSkin.installArchive(name, data)
if not data or data == "" then return nil, "empty archive" end if not data or data == "" then return nil, "empty archive" end
if not (love and love.filesystem and love.filesystem.write) then if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem" return nil, "no writable filesystem"
end end
name = tostring(name or ""):match("([^/\\]+)$") or "" name = tostring(name or ""):match("([^/\\]+)$") or ""
name = name:gsub("[^%w%._%-]", "_") name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
if not name:lower():match("%.zip$") then return nil, "not a .zip" end local legacy = name:match("%.([%w]+)$")
local id = name:gsub("%.[Zz][Ii][Pp]$", "") if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
if id == "" then return nil, "bad archive name" end return nil, "old GBA4iOS skin, not supported"
end
local id = TouchSkin.archiveId(name)
if not id then return nil, "not a .zip or .deltaskin" end
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
local dest = TouchSkin.USER_ROOT .. "/" .. name local dest = TouchSkin.USER_ROOT .. "/" .. name
@@ -467,9 +753,14 @@ function TouchSkin.installArchive(name, data)
local entry = TouchSkin.find(id) local entry = TouchSkin.find(id)
if not entry then if not entry then
love.filesystem.remove(dest) love.filesystem.remove(dest)
return nil, "no skin.lua or .cfg inside " .. name return nil, "no skin.lua, .cfg or info.json inside " .. name
end end
return id local skin = TouchSkin.load(entry.root, entry.id)
if skin and require("src.core.DeltaSkin").needsConversion(skin) then
love.filesystem.remove(dest)
return nil, TouchSkin.PDF_ONLY_MESSAGE
end
return id, skin and skin.warnings or nil
end end
function TouchSkin.find(id) function TouchSkin.find(id)
@@ -498,29 +789,13 @@ function TouchSkin.assetPaths(skin)
return out return out
end end
function TouchSkin.export(skin, destPath) local function writeArchive(entries, destPath)
if not skin then return nil, "no skin" end local blob = require("src.core.SkinZip").encode(entries)
local SkinZip = require("src.core.SkinZip")
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local missing = {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
local blob = SkinZip.encode(entries)
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
if not absolute and love and love.filesystem and love.filesystem.createDirectory then
local dir = destPath:match("^(.*)/[^/]+$")
if dir then pcall(love.filesystem.createDirectory, dir) end
end
if not absolute and love and love.filesystem and love.filesystem.write then if not absolute and love and love.filesystem and love.filesystem.write then
local ok, err = love.filesystem.write(destPath, blob) local ok, err = love.filesystem.write(destPath, blob)
if not ok then return nil, tostring(err) end if not ok then return nil, tostring(err) end
@@ -530,9 +805,167 @@ function TouchSkin.export(skin, destPath)
handle:write(blob) handle:write(blob)
handle:close() handle:close()
end end
return destPath
end
local function collectAssets(skin, rels)
local entries, missing = {}, {}
for _, rel in ipairs(rels) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
return entries, missing
end
function TouchSkin.export(skin, destPath)
if not skin then return nil, "no skin" end
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip")
local written, err = writeArchive(entries, destPath)
if not written then return nil, err end
return destPath, missing return destPath, missing
end end
local function fmtNum(n)
n = tonumber(n) or 0
if n == math.floor(n) then return string.format("%d", n) end
local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "")
return s
end
local function fmtRect(r)
return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h))
end
local function cfgSpec(spec)
local parts = {}
for raw in tostring(spec or ""):gmatch("[^|]+") do
local name = trim(raw)
local key = name:lower():match("^key:(.+)$")
parts[#parts + 1] = key and ("retrok_" .. key) or name
end
return table.concat(parts, "|")
end
function TouchSkin.toRetroArchConfig(skin)
local pages = (skin and skin.pages) or {}
local out = { "overlays = " .. #pages }
for i, page in ipairs(pages) do
local p = "overlay" .. (i - 1)
out[#out + 1] = ""
out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"'
if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end
out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false")
out[#out + 1] = p .. "_normalized = true"
if num(page.rangeMod, 1) ~= 1 then
out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod)
end
if num(page.alphaMod, 1) ~= 1 then
out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod)
end
if page.aspectFromCfg and page.aspect and page.aspect > 0 then
out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect)
end
local r = page.rect
if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then
out[#out + 1] = p .. "_rect = " .. fmtRect(r)
end
if page.viewport then
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
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
local controls = {}
for _, ctl in ipairs(page.controls or {}) do
if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end
end
out[#out + 1] = p .. "_descs = " .. #controls
for j, ctl in ipairs(controls) do
local d = p .. "_desc" .. (j - 1)
local spec = ctl.areaKind and ctl.sector and ctl.areaKind
or cfgSpec(ctl.spec)
if spec == "" then spec = "nul" end
out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec,
fmtNum(ctl.x), fmtNum(ctl.y),
ctl.shape == "radial" and "radial" or "rect",
fmtNum(ctl.rangeX), fmtNum(ctl.rangeY))
if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end
if ctl.pressedImagePath then
out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath
end
if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then
out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod)
end
if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then
out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod)
end
for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown,
left = ctl.reachLeft, right = ctl.reachRight }) do
if num(value, 1) ~= 1 then
out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value)
end
end
if ctl.movable then out[#out + 1] = d .. "_movable = true" end
if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end
if ctl.nextTarget then
out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"'
end
if ctl.areaKind and ctl.sector and ctl.areaNames then
local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
local name = ctl.areaNames[side]
if name and name ~= defaults[side] then
out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"'
end
end
end
end
end
return table.concat(out, "\n") .. "\n"
end
function TouchSkin.exportRetroArch(skin, destPath)
if not skin then return nil, "no skin" end
local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } }
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip")
local written, err = writeArchive(entries, destPath)
if not written then return nil, err end
return destPath, missing
end
function TouchSkin.exportDelta(skin, opts)
if not skin then return nil, "no skin" end
opts = opts or {}
local DeltaSkin = require("src.core.DeltaSkin")
local info, assetRels, warnings = DeltaSkin.build(skin, opts)
if not info then return nil, assetRels end
local entries = {
{ name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) },
}
local assets, missing = collectAssets(skin, assetRels)
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
local destPath = opts.path
or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin")
local written, err = writeArchive(entries, destPath)
if not written then return nil, err end
return destPath, missing, warnings
end
TouchSkin.BINDS = { TouchSkin.BINDS = {
"nul", "nul",
"up", "down", "left", "right", "up", "down", "left", "right",
@@ -597,6 +1030,7 @@ function TouchSkin.clone(skin)
id = skin.id, name = skin.name, root = skin.root, format = skin.format, id = skin.id, name = skin.name, root = skin.root, format = skin.format,
author = skin.author, notes = skin.notes, configPath = skin.configPath, author = skin.author, notes = skin.notes, configPath = skin.configPath,
source = skin.source, pages = {}, source = skin.source, pages = {},
warnings = skin.warnings and copyTable(skin.warnings) or nil,
} }
for i, page in ipairs(skin.pages or {}) do for i, page in ipairs(skin.pages or {}) do
local p = copyTable(page) local p = copyTable(page)
@@ -676,7 +1110,8 @@ function TouchSkin.listImages(root)
local function scan(dir, prefix) local function scan(dir, prefix)
for _, name in ipairs(listDir(dir)) do for _, name in ipairs(listDir(dir)) do
local path = dir .. "/" .. name local path = dir .. "/" .. name
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then local lower = name:lower()
if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then
out[#out + 1] = prefix .. name out[#out + 1] = prefix .. name
elseif isDir(path) and prefix == "" then elseif isDir(path) and prefix == "" then
scan(path, name .. "/") scan(path, name .. "/")
@@ -853,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
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
local anchor = page.anchor
if displayAspect > page.aspect then if displayAspect > page.aspect then
bw = h * page.aspect bw = h * page.aspect
bx = ox + (w - bw) * 0.5 local extra = w - bw
if anchor == "right" then
bx = ox + extra
elseif anchor == "left" then
bx = ox
else
bx = ox + extra * 0.5
end
else else
bh = w / page.aspect bh = w / page.aspect
by = oy + (h - bh) * 0.5 local extra = h - bh
if anchor == "bottom" then
by = oy + extra
elseif anchor == "top" then
by = oy
else
by = oy + extra * 0.5
end
end end
end end
local r = page.rect local r = page.rect
@@ -872,17 +1322,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
return cx, cy, halfW, halfH return cx, cy, halfW, halfH
end end
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy) function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held)
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local left = halfW * ctl.reachLeft * ctl.rangeMod local mod = held == false and 1 or ctl.rangeMod
local right = halfW * ctl.reachRight * ctl.rangeMod local left = halfW * ctl.reachLeft * mod
local up = halfH * ctl.reachUp * ctl.rangeMod local right = halfW * ctl.reachRight * mod
local down = halfH * ctl.reachDown * ctl.rangeMod local up = halfH * ctl.reachUp * mod
local down = halfH * ctl.reachDown * mod
local dx = px - cx local dx = px - cx
local dy = py - cy local dy = py - cy
local rx = dx < 0 and left or right local rx = dx < 0 and left or right
local ry = dy < 0 and up or down local ry = dy < 0 and up or down
if rx <= 0 or ry <= 0 then return false end if rx <= 0 or ry <= 0 then return false end
if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then
return false
end
if ctl.shape == "radial" then if ctl.shape == "radial" then
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
end end
@@ -911,18 +1365,55 @@ end
function TouchSkin.hasViewport() function TouchSkin.hasViewport()
local page = TouchSkin.page() local page = TouchSkin.page()
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable() if not page or not TouchSkin.drawable() then return false end
return page.viewport ~= nil or page.screenFit == "remainder"
end end
function TouchSkin.viewport(w, h, ox, oy) -- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
local page = TouchSkin.page() local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not page or not page.viewport or not TouchSkin.drawable() then return nil end local right, bottom = ox + w, oy + h
local v = page.viewport local cand = {
{ ox, oy, w, by - oy },
{ ox, by + bh, w, bottom - (by + bh) },
{ ox, oy, bx - ox, h },
{ bx + bw, oy, right - (bx + bw), h },
}
local best, bestArea
for _, r in ipairs(cand) do
if r[3] > 1 and r[4] > 1 then
local area = r[3] * r[4]
if not best or area > bestArea then
best, bestArea = r, area
end
end
end
if not best then return nil end
return best[1], best[2], best[3], best[4]
end
function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end
ox, oy = ox or 0, oy or 0
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
if page.viewport then
local v = page.viewport
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
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end end
if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end
return x, y, vw, vh, false, false
end
return nil
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return nil end
return TouchSkin.pageViewport(page, w, h, ox, oy)
end
return TouchSkin return TouchSkin
+29 -2
View File
@@ -301,6 +301,13 @@ end
-- gear edit these before the game starts (src/import/LauncherSettings.lua). -- gear edit these before the game starts (src/import/LauncherSettings.lua).
Save.OPTIONS_KEY = "gold" Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = {
touchControls = true, haptics = true,
mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true,
}
function Save.loadOptions(fs) function Save.loadOptions(fs)
local options = Save.defaultOptions() local options = Save.defaultOptions()
local ok, SaveData = pcall(require, "src.core.SaveData") local ok, SaveData = pcall(require, "src.core.SaveData")
@@ -308,7 +315,21 @@ function Save.loadOptions(fs)
local loaded = SaveData.loadOptions(fs) local loaded = SaveData.loadOptions(fs)
local stored = loaded and loaded[Save.OPTIONS_KEY] local stored = loaded and loaded[Save.OPTIONS_KEY]
if type(stored) == "table" then if type(stored) == "table" then
for key, value in pairs(stored) do options[key] = value end for key, value in pairs(stored) do
if not SHARED_KEYS[key] then options[key] = value end
end
end
if type(loaded) == "table" then
for key in pairs(SHARED_KEYS) do
if loaded[key] ~= nil then options[key] = loaded[key] end
end
end
if type(stored) == "table" then
for key in pairs(SHARED_KEYS) do
if options[key] == nil and stored[key] ~= nil then
options[key] = stored[key]
end
end
end end
return options return options
end end
@@ -321,7 +342,13 @@ function Save.saveOptions(options, fs)
if not ok then return false end if not ok then return false end
local file = SaveData.loadOptions(fs) or {} local file = SaveData.loadOptions(fs) or {}
local block = {} local block = {}
for key, value in pairs(options) do block[key] = value end for key, value in pairs(options) do
if SHARED_KEYS[key] then
file[key] = value
else
block[key] = value
end
end
file[Save.OPTIONS_KEY] = block file[Save.OPTIONS_KEY] = block
SaveData.saveOptions(file, fs) SaveData.saveOptions(file, fs)
return true return true
+18 -1
View File
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
-- except experimental mods, which stay off until opted in. -- except experimental mods, which stay off until opted in.
local flag = require("src.core.SaveData").modEnabled(opts, m.id) local flag = require("src.core.SaveData").modEnabled(opts, m.id)
local enabled = flag == true or (flag == nil and not m.experimental) local enabled = flag == true or (flag == nil and not m.experimental)
if enabled then if enabled and not SaveData.isSafeMode(opts) then
local chunk = fs.load(path .. "/" .. m.options_schema) local chunk = fs.load(path .. "/" .. m.options_schema)
if chunk then if chunk then
local okR, schema = pcall(chunk) local okR, schema = pcall(chunk)
@@ -521,6 +521,23 @@ local function modRows(opts, mod)
return true return true
end } end }
end end
for _, row in ipairs(rows) do
row.safeModeBlocked = true
if row.step then
local step = row.step
row.step = function(dir)
if SaveData.isSafeMode(opts) then return false end
return step(dir)
end
end
if row.setText then
local setText = row.setText
row.setText = function(text)
if SaveData.isSafeMode(opts) then return false end
return setText(text)
end
end
end
return rows return rows
end end
File diff suppressed because it is too large Load Diff
+518 -19
View File
@@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- costs nothing on a current cache and is the difference between every -- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none. -- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png", "assets/generated/battle/trainers/falkner.png",
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
-- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin", "assets/generated/audio/programs.bin",
}, },
} }
@@ -326,6 +329,32 @@ function RomImporter.isReady(version)
return marker == markerFor(version) and allRequiredFilesExist(version) return marker == markerFor(version) and allRequiredFilesExist(version)
end end
function RomImporter.syncAndroidShortcuts(activeVersion)
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.updateShortcuts) then
return false
end
local allVersions = { "red", "blue", "yellow", "gold" }
local ready = {}
local seen = {}
if activeVersion and RomImporter.isReady(activeVersion) then
table.insert(ready, activeVersion)
seen[activeVersion] = true
end
for _, v in ipairs(allVersions) do
if not seen[v] and RomImporter.isReady(v) then
table.insert(ready, v)
seen[v] = true
if #ready >= 4 then break end
end
end
return love.system.updateShortcuts(ready)
end
-- Load the import manifest for a version and confirm it matches that ROM. -- Load the import manifest for a version and confirm it matches that ROM.
local function sha1(data) local function sha1(data)
local digest = love.data.hash("sha1", data) local digest = love.data.hash("sha1", data)
@@ -1111,18 +1140,18 @@ local function chooseZip()
end end
local function chooseSkinZip() local function chooseSkinZip()
local prompt = shellSafe(Strings("Choose a skin .zip")) local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin"))
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]]) ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]])
:format(prompt)) :format(prompt))
elseif platform == "Windows" then elseif platform == "Windows" then
local script = table.concat({ local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;", "Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';", "$d.Title='" .. prompt .. "';",
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';", "$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){", "if($d.ShowDialog() -eq 'OK'){",
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
"$t=Join-Path $env:TEMP $n;", "$t=Join-Path $env:TEMP $n;",
@@ -1134,11 +1163,11 @@ local function chooseSkinZip()
'powershell -NoProfile -STA -Command "' .. script .. '"') 'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then elseif platform == "Linux" then
local path = commandOutput( local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]]) ([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]])
:format(prompt)) :format(prompt))
if path then return path end if path then return path end
return commandOutput( return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]]) [[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]])
end end
return nil return nil
end end
@@ -1237,6 +1266,7 @@ end
function RomImporter:_applyLastVersionTab() function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions") local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function() local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions() return require("src.core.SaveData").loadOptions()
end) end)
@@ -1308,7 +1338,7 @@ function RomImporter.new(onComplete, opts)
-- player at least arrives on the tab they asked for (src/core/LaunchOptions). -- player at least arrives on the tab they asked for (src/core/LaunchOptions).
tab = (function() tab = (function()
local okLO, LO = pcall(require, "src.core.LaunchOptions") local okLO, LO = pcall(require, "src.core.LaunchOptions")
return (okLO and LO.pendingTab) or "red" return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
end)(), end)(),
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"),
@@ -1335,7 +1365,8 @@ function RomImporter.new(onComplete, opts)
-- in draw); modNotice is the last install/delete result { ok, text }. -- in draw); modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation -- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them. -- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil, mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil = -- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here" -- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua). -- verdict against it (src/mods/ModTargets.lua).
@@ -1349,6 +1380,7 @@ function RomImporter.new(onComplete, opts)
findLoaded = false, findSources = nil, findIndex = nil, findLoaded = false, findSources = nil, findIndex = nil,
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
_findSearchFocus = false, _findThumbs = nil, _findSearchFocus = false, _findThumbs = nil,
skinUrl = "", _skinUrlFocus = false,
-- Page scroll offset (px) for the column under the tab bar -- panel, updater -- Page scroll offset (px) for the column under the tab bar -- panel, updater
-- banner and footer -- used only while that column is taller than the window -- banner and footer -- used only while that column is taller than the window
-- (see draw()). Clamped against content in draw, reset on a tab change. -- (see draw()). Clamped against content in draw, reset on a tab change.
@@ -1392,6 +1424,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb") .. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab() self:_applyLastVersionTab()
self:_queueBaseRomScan() self:_queueBaseRomScan()
@@ -1786,6 +1819,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
self.workState = "complete" self.workState = "complete"
self.completeVersion = version self.completeVersion = version
self.status = "Ready" self.status = "Ready"
RomImporter.syncAndroidShortcuts(version)
-- NX launcher stays put: keep the imports/ cleanup hint instead of -- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here. -- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then if self.launcher and self.isNX and type(displayName) == "string" then
@@ -1851,10 +1885,14 @@ end
function RomImporter:filedropped(file) function RomImporter:filedropped(file)
if self.workState == "working" then return end if self.workState == "working" then return end
-- A dropped .zip is a mod archive: hand it straight to the mods installer -- A dropped .zip is a mod archive: hand it straight to the mods installer
-- (which mounts + validates it). Everything else is treated as a ROM. The -- (which mounts + validates it). A .deltaskin is only ever a skin, and
-- dropped file itself is passed through -- installZip opens it the same way -- everything else is treated as a ROM. The dropped file itself is passed
-- readDroppedFile does here. -- through -- installZip opens it the same way readDroppedFile does here.
local name = file:getFilename() or "" local name = file:getFilename() or ""
if name:lower():match("%.deltaskin$") then
self:_installSkinZip(file)
return
end
if name:lower():match("%.zip$") then if name:lower():match("%.zip$") then
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive. -- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
if self.tab == "skins" then if self.tab == "skins" then
@@ -2481,6 +2519,8 @@ function RomImporter:update(dt)
self:_pumpModInfoFetch() self:_pumpModInfoFetch()
self:_pumpFindStats() self:_pumpFindStats()
self:_pumpFindThumbs() self:_pumpFindThumbs()
self:_pumpSkinFetch()
self:_pumpSync(dt)
self:_pumpModCheck() self:_pumpModCheck()
self:_pumpModInstall() self:_pumpModInstall()
self:_pumpExtract() self:_pumpExtract()
@@ -2717,7 +2757,7 @@ function RomImporter:resumeAfterOverlay()
end end
function RomImporter:_cycleTab(delta) function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" } local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
local idx = 1 local idx = 1
for i, id in ipairs(order) do for i, id in ipairs(order) do
if id == self.tab then idx = i; break end if id == self.tab then idx = i; break end
@@ -3082,6 +3122,8 @@ end
function RomImporter:_switchTab(id) function RomImporter:_switchTab(id)
self.tab = id self.tab = id
self._findSearchFocus = false self._findSearchFocus = false
self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput() self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back -- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit -- (an export, a hand-dropped folder), so re-read it on every visit
@@ -3107,9 +3149,11 @@ function RomImporter:_ensureSkins(force)
out[#out + 1] = { out[#out + 1] = {
id = entry.id, id = entry.id,
source = entry.source, source = entry.source,
format = skin and skin.format or nil,
pages = skin and #skin.pages or 0, pages = skin and #skin.pages or 0,
controls = controls, controls = controls,
screen = page ~= nil and page.viewport ~= nil, screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"),
ok = skin ~= nil, ok = skin ~= nil,
} }
end end
@@ -3140,7 +3184,6 @@ end
function RomImporter:_installSkinZip(source) function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end if self.workState == "working" then return end
self.tab = "skins" self.tab = "skins"
local TouchSkin = require("src.core.TouchSkin")
local name, data, readError local name, data, readError
if type(source) == "string" then if type(source) == "string" then
name = source name = source
@@ -3160,13 +3203,368 @@ function RomImporter:_installSkinZip(source)
.. tostring(readError or name) } .. tostring(readError or name) }
return return
end end
local id, err = TouchSkin.installArchive(name, data) self:_installSkinData(name, data)
end
local MAX_SKIN_URL = 300
local SKIN_TEMP_DIR = "skins/_download"
function RomImporter.skinUrlName(url)
local path = tostring(url or ""):gsub("[?#].*$", "")
local base = (path:match("([^/\\]+)$") or ""):gsub("[^%w%._%-]", "_")
local ext = base:match("%.([%w]+)$")
if not ext then
return (base ~= "" and base or "skin") .. ".zip"
end
ext = ext:lower()
local TouchSkin = require("src.core.TouchSkin")
if TouchSkin.ARCHIVE_EXTS[ext] or ext == "cfg" then return base end
return (base:gsub("%.[%w]+$", "")) .. ".zip"
end
function RomImporter.wrapSkinPayload(name, data)
name = tostring(name or "")
if not name:lower():match("%.cfg$") then return name, data end
if not data then return name, data end
if data:sub(1, 2) == "PK" then
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", data
end
local blob = require("src.core.SkinZip").encode({
{ name = "overlay.cfg", data = data },
})
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", blob
end
function RomImporter:_installSkinData(name, data)
local TouchSkin = require("src.core.TouchSkin")
if not data or data == "" then
self._skinNotice = { ok = false, text = Strings("The skin file was empty.") }
return nil
end
local wrappedName, payload = RomImporter.wrapSkinPayload(name, data)
local id, note = TouchSkin.installArchive(wrappedName, payload)
self:_ensureSkins(true) self:_ensureSkins(true)
if not id then if not id then
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) } self._skinNotice = { ok = false, text = "Import failed: " .. tostring(note) }
return nil
end
local text = "Imported " .. id
if type(note) == "table" and note[1] then
text = text .. ": " .. tostring(note[1])
end
self._skinNotice = { ok = true, text = text }
return id
end
function RomImporter:_toggleSkinUrlFocus()
self._skinUrlFocus = not self._skinUrlFocus
if self._skinUrlFocus then
self:_armTextInput()
else
self:_disarmTextInput()
end
end
function RomImporter:_pasteSkinUrl()
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
end
end
function RomImporter:_addSkinFromUrl(url)
if self._skinFetch then return false end
url = tostring(url or self.skinUrl or ""):gsub("%s", "")
if url == "" then
self._skinNotice = { ok = false,
text = Strings("Paste a link to a skin archive first.") }
return false
end
if not url:match("^https?://") then
self._skinNotice = { ok = false,
text = Strings("A skin link has to start with http:// or https://") }
return false
end
if not require("src.core.Platform").canFetchRemote() then
self._skinNotice = { ok = false,
text = Strings("Downloading needs a network transport this build has not got.") }
return false
end
local name = RomImporter.skinUrlName(url)
local Fetch = require("src.net.Fetch")
self._skinFetch = {
url = url, name = name, dest = SKIN_TEMP_DIR .. "/" .. name,
job = Fetch.download(url, SKIN_TEMP_DIR .. "/" .. name,
{ userAgent = "gen1recomp-skin", maxSeconds = 90 }),
}
self._skinNotice = { ok = true, text = Strings("Downloading %s...", name) }
return true
end
function RomImporter:_pumpSkinFetch()
local f = self._skinFetch
if not f then return end
local Fetch = require("src.net.Fetch")
local st = Fetch.poll(f.job)
if st.status == "pending" then
self._skinFetchProgress = st.progress
return return
end end
self._skinNotice = { ok = true, text = "Imported " .. id } Fetch.release(f.job)
self._skinFetch, self._skinFetchProgress = nil, nil
if st.status ~= "ok" or not st.path then
self._skinNotice = { ok = false,
text = "Download failed: " .. tostring(st.err or "no data") }
return
end
local data = love.filesystem.read(st.path)
love.filesystem.remove(st.path)
if self:_installSkinData(f.name, data) then
self.skinUrl = ""
end
end
function RomImporter:_exportSkin(id, kind)
local TouchSkin = require("src.core.TouchSkin")
local entry = id and TouchSkin.find(id)
if not entry then
self._skinNotice = { ok = false, text = Strings("That skin is gone.") }
return nil
end
local skin = TouchSkin.load(entry.root, entry.id)
if not skin then
self._skinNotice = { ok = false,
text = Strings("Could not read %s", tostring(id)) }
return nil
end
local path, missing, warnings
if kind == "retroarch" then
path, missing = TouchSkin.exportRetroArch(skin)
elseif kind == "delta" then
path, missing, warnings = TouchSkin.exportDelta(skin)
else
path, missing = TouchSkin.export(skin)
end
if not path then
self._skinNotice = { ok = false,
text = "Export failed: " .. tostring(missing) }
return nil
end
local dir = love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory() or nil
self._skinExport = { path = path, dir = dir }
local text = Strings("Exported to %s", (dir and (dir .. "/") or "") .. path)
if type(missing) == "table" and missing[1] then
text = text .. " (" .. #missing .. " image(s) missing)"
end
if type(warnings) == "table" and warnings[1] then
text = text .. " " .. tostring(warnings[1])
end
self._skinNotice = { ok = true, text = text }
return path
end
function RomImporter:_revealSkinExport()
local e = self._skinExport
if not e or not e.dir then return false end
if love.system and love.system.openURL then
pcall(love.system.openURL, fileUrl(e.dir))
end
return true
end
local MAX_SYNC_CODE = 8
local MAX_SHARE_CODE = 6
function RomImporter.syncDigits(text)
local digits = tostring(text or ""):gsub("[^%d]", "")
return digits:sub(1, MAX_SYNC_CODE)
end
function RomImporter.syncShareCode(text)
local out = tostring(text or ""):upper():gsub("[^A-Z2-9]", "")
return out:sub(1, MAX_SHARE_CODE)
end
function RomImporter:_syncDeviceLabel()
local name = love.system and love.system.getOS and love.system.getOS()
if type(name) ~= "string" or name == "" then return "device" end
return name
end
function RomImporter:_syncEngine()
if self._sync ~= nil then return self._sync or nil end
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._sync = false
return nil
end
local made, eng = pcall(SyncEngine.shared)
if not made or type(eng) ~= "table" then
self._sync = false
return nil
end
self._sync = eng
return eng
end
function RomImporter:_syncSupported()
if self._syncTransportOk ~= nil then return self._syncTransportOk end
local ok, HostShell = pcall(require, "src.core.HostShell")
if not ok or type(HostShell) ~= "table"
or type(HostShell.canHttpRequest) ~= "function" then
self._syncTransportOk = true
return true
end
local asked, can = pcall(HostShell.canHttpRequest)
self._syncTransportOk = (not asked) or (can and true or false)
return self._syncTransportOk
end
function RomImporter:_pumpSync(dt)
if self._sync == nil then
if not self.launcher or self._syncBooted then return end
if not self:_syncSupported() then return end
self._syncBooted = true
local booted = self:_syncEngine()
if booted and booted.state.enabled and booted:linked() then
pcall(booted.syncNow, booted)
end
end
local eng = self._sync
if not eng then return end
pcall(eng.update, eng, dt)
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
if not self._syncModal and not self._syncConflictShown then
self._syncConflictShown = true
self:_openSync()
end
else
self._syncConflictShown = nil
end
end
function RomImporter:_openSync()
self:_syncEngine()
self._syncModal = self._syncModal
or { view = "home", code1 = "", code2 = "", share = "" }
self._syncFocus = nil
self:_disarmTextInput()
end
function RomImporter:_closeSync()
self._syncModal = nil
self._syncFocus = nil
self:_disarmTextInput()
end
function RomImporter:_syncView(view)
if not self._syncModal then return end
self._syncModal.view = view
self._syncFocus = nil
self:_disarmTextInput()
end
function RomImporter:_syncFocusField(field)
if not self._syncModal then return end
if self._syncFocus == field then
self._syncFocus = nil
self:_disarmTextInput()
return
end
self._syncFocus = field
self:_armTextInput()
end
function RomImporter:_syncTypeInto(field, text)
local mo = self._syncModal
if not mo or not field then return end
if field == "share" then
mo.share = RomImporter.syncShareCode((mo.share or "") .. tostring(text or ""))
else
mo[field] = RomImporter.syncDigits((mo[field] or "") .. tostring(text or ""))
end
end
function RomImporter:_syncPaste()
local field = self._syncFocus
if not field then return end
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then self:_syncTypeInto(field, text) end
end
function RomImporter:_syncCreate()
local eng = self:_syncEngine()
if not eng then return false end
return eng:createAccount(self:_syncDeviceLabel())
end
function RomImporter:_syncLink()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng or not mo then return false end
local ok = eng:linkDevice(mo.code1, mo.code2, self:_syncDeviceLabel())
if ok then
mo.code1, mo.code2, mo.view = "", "", "home"
self._syncFocus = nil
self:_disarmTextInput()
end
return ok
end
function RomImporter:_syncNow()
local eng = self:_syncEngine()
if not eng then return false end
return eng:syncNow()
end
function RomImporter:_syncUnlink()
local eng = self:_syncEngine()
if not eng then return false end
eng:unlink()
if self._syncModal then self._syncModal.view = "home" end
return true
end
function RomImporter:_syncUnlinkDevice(deviceId)
local eng = self:_syncEngine()
if not eng or type(eng.unlinkDevice) ~= "function" then return false end
return eng:unlinkDevice(deviceId)
end
function RomImporter:_syncShareMods()
local eng = self:_syncEngine()
if not eng then return false end
return eng:shareMods()
end
function RomImporter:_syncGetShare()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng or not mo then return false end
return eng:fetchShare(mo.share or "")
end
function RomImporter:_syncApplyMods()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng then return false end
local ok, err = eng:applyModPlan(function(done, total, label, finished)
if not mo then return end
if finished then
mo.progress = nil
if self._refreshMods then self:_refreshMods() end
else
mo.progress = { done = done, total = total, label = label }
end
end)
if mo then mo.progress = nil end
if ok and self._refreshMods then self:_refreshMods() end
return ok, err
end
function RomImporter:_syncResolve(key, choice)
local eng = self:_syncEngine()
if not eng then return false end
return eng:resolveConflict(key, choice)
end end
function RomImporter:_skinsImportButtonLabel() function RomImporter:_skinsImportButtonLabel()
@@ -3233,6 +3631,7 @@ function RomImporter:_openSettings()
-- The tab rides along: the editor persists the layout into that game's own -- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100). -- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {} local hooks = {}
local version = self.tab
if self.onEditTouchControls then if self.onEditTouchControls then
local version = self.tab local version = self.tab
hooks.editTouchControls = function() hooks.editTouchControls = function()
@@ -3250,11 +3649,12 @@ function RomImporter:_openSettings()
-- The tab the gear was opened on decides the row set: Gold reads a -- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a -- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows). -- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
local version = self.tab
local ok, model = pcall(function() local ok, model = pcall(function()
return require("src.import.LauncherSettings").open(hooks, version) return require("src.import.LauncherSettings").open(hooks, version)
end) end)
if ok and model then self._settings = model end if ok and model then
self._settings = model
end
end end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's -- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
@@ -3265,10 +3665,54 @@ function RomImporter:_quitApp()
end end
function RomImporter:_closeSettings() function RomImporter:_closeSettings()
if self._settings then self._settings.save() end local model = self._settings
if model then
model.save()
end
self._settings = nil self._settings = nil
end end
function RomImporter:_safeModeEnabled()
if self.safeMode == nil then
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
end
return self.safeMode == true
end
function RomImporter:_toggleSafeMode()
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local enabled = not SaveData.isSafeMode(options)
SaveData.setSafeMode(options, enabled)
SaveData.saveOptions(options)
self.safeMode = enabled
self.mods = nil
self._modSortCache = nil
self._modInfoFetch = nil
self.modNotice = nil
end
function RomImporter:_reportIssue(options, version)
self.issueNotice = nil
local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then
self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
return false
end
local opened, url, reason = IssueReport.open(options, {
version = version,
mods = self.mods,
})
if not opened then
self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
return false
end
self._lastIssueReportURL = url
if reason then self.issueNotice = { ok = true, text = reason } end
return true
end
function RomImporter:_commitSettingsText() function RomImporter:_commitSettingsText()
local st = self._settingsText local st = self._settingsText
self._settingsText = nil self._settingsText = nil
@@ -3338,6 +3782,27 @@ function RomImporter:keypressed(key)
if key == "escape" then self:_closeSettings() end if key == "escape" then self:_closeSettings() end
return return
end end
if self._syncModal then
local field = self._syncFocus
if field then
local mo = self._syncModal
if key == "backspace" then
mo[field] = tostring(mo[field] or ""):sub(1, -2)
elseif key == "return" or key == "kpenter" or key == "escape" then
self._syncFocus = nil
self:_disarmTextInput()
elseif key == "v"
and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
self:_syncPaste()
end
return
end
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then self:_closeSync() end
return
end
if self._rename then if self._rename then
if key == "backspace" then if key == "backspace" then
self._rename.text = utf8Back(self._rename.text) self._rename.text = utf8Back(self._rename.text)
@@ -3387,6 +3852,21 @@ function RomImporter:keypressed(key)
end end
return return
end end
if self._skinUrlFocus then
if key == "backspace" then
self.skinUrl = utf8Back(self.skinUrl or "")
elseif key == "return" or key == "kpenter" then
self._skinUrlFocus = false
self:_disarmTextInput()
self:_addSkinFromUrl()
elseif key == "escape" then
self._skinUrlFocus = false
self:_disarmTextInput()
elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
self:_pasteSkinUrl()
end
return
end
if self._findSearchFocus then if self._findSearchFocus then
if key == "backspace" then if key == "backspace" then
self.findQuery = utf8Back(self.findQuery or "") self.findQuery = utf8Back(self.findQuery or "")
@@ -3494,6 +3974,10 @@ function RomImporter:_commitRename()
end end
function RomImporter:textinput(text) function RomImporter:textinput(text)
if self._syncModal and self._syncFocus then
self:_syncTypeInto(self._syncFocus, text)
return
end
if self._profileSavePrompt then if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL) self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return return
@@ -3514,6 +3998,11 @@ function RomImporter:textinput(text)
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL) utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
return return
end end
if self._skinUrlFocus then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
return
end
if self._findSearchFocus then if self._findSearchFocus then
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY) self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
self.findScroll = 0 self.findScroll = 0
@@ -3561,6 +4050,8 @@ end
-- so a still list costs nothing after the first paint. -- so a still list costs nothing after the first paint.
function RomImporter:_refreshMods() function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player -- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install -- unzipped beside the executable, which an ordinary (non-portable) install
-- has no way to read. It happens here rather than behind a button because -- has no way to read. It happens here rather than behind a button because
@@ -3700,6 +4191,10 @@ end
-- so that game's checkbox and status chips reflect the new resolution. -- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game. -- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version) function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
@@ -3740,6 +4235,10 @@ end
-- must not be the way around it. Disabling needs no confirm -- it is the -- must not be the way around it. Disabling needs no confirm -- it is the
-- recovery action, and Delete is the only destructive one on this panel. -- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed) function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
+16 -3
View File
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
local ordered = {} local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end) table.sort(ordered, function(a, b) return a.id < b.id end)
local safeMode = SaveData.isSafeMode(options)
-- the override is one answer per game (SaveData.modForced), the same scope -- the override is one answer per game (SaveData.modForced), the same scope
-- the loader resolves it under -- the loader resolves it under
@@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version)
-- matching the loader -- except experimental mods, which stay off until -- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what -- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads for the selected game. -- setEnabled writes and the loader loads for the selected game.
if not safeMode then
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end if decided then enabledSet[m.id] = true end
end end
end
local out = {} local out = {}
for _, m in ipairs(ordered) do for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true local enabled = not safeMode and enabledSet[m.id] == true
local forced = forcedFor(m.id) local forced = forcedFor(m.id)
local status, detail = local status, detail
if safeMode then
status, detail = "safe_mode", "Disabled by safe mode"
else
status, detail =
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
end
-- nil, not false, when the panel is showing every game at once -- nil, not false, when the panel is showing every game at once
local here = nil local here = nil
if version then here = ModTargets.runsHere(m, version, nil, forced) end if version then here = ModTargets.runsHere(m, version, nil, forced) end
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
local answers = {} local answers = {}
for _, game in ipairs(GameVersion.ORDER) do for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game) local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental) answers[game] = not safeMode
and (answer == true or (answer == nil and not m.experimental))
end end
return answers return answers
end)(), end)(),
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- panel is showing (src/mods/ModTargets.lua) -- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m), targets = ModTargets.chip(m),
targetsHere = here, targetsHere = here,
safeMode = safeMode,
} }
end end
return out return out
@@ -586,6 +596,7 @@ end
-- answer. The loader and the in-game manager use the same scope on next boot. -- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version) function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options) SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options) LauncherMods.syncActiveProfile(options)
@@ -599,6 +610,7 @@ end
-- and leaves a half-applied state behind if one of them fails. -- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled, version) function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local scope = SaveData.modScope(version) local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do for _, id in ipairs(ids or {}) do
if scope then if scope then
@@ -1184,6 +1196,7 @@ end
function LauncherMods.applyProfile(profileName, options) function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions() options = options or SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local profiles = options.modProfiles or {} local profiles = options.modProfiles or {}
local targetProfile local targetProfile
for _, p in ipairs(profiles) do for _, p in ipairs(profiles) do
+10 -1
View File
@@ -259,6 +259,7 @@ function Loader.new(opts)
modInput = {}, modEnv = {}, stepsQueues = {}, modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem), fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev, dev = dev,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the -- Which generation this boot is (1 or 2). Fixed at construction: the
-- active version is set once in main.lua's bootGame before anything -- active version is set once in main.lua's bootGame before anything
-- builds a loader, and a run never changes generation underneath one. -- builds a loader, and a run never changes generation underneath one.
@@ -300,6 +301,8 @@ end
function Loader:_loadState() function Loader:_loadState()
self.disabled = {} self.disabled = {}
local options = SaveData.loadOptions(self.fs) local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
local scope = self:_enableScope() local scope = self:_enableScope()
local ids = {} local ids = {}
for id in pairs(options.mods or {}) do ids[id] = true end for id in pairs(options.mods or {}) do ids[id] = true end
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
end end
function Loader:setEnabled(id, enabled) function Loader:setEnabled(id, enabled)
if self.safeMode then return false end
if not self.mods[id] then return false end if not self.mods[id] then return false end
self.disabled[id] = not enabled self.disabled[id] = not enabled
self.mods[id].enabled = enabled self.mods[id].enabled = enabled
@@ -427,6 +431,7 @@ end
-- choice could not be persisted for a game, so the caller does not promise a -- choice could not be persisted for a game, so the caller does not promise a
-- restart will honour it. -- restart will honour it.
function Loader:setGen2Forced(id, forced) function Loader:setGen2Forced(id, forced)
if self.safeMode then return false, false end
if not self.mods[id] then return false, false end if not self.mods[id] then return false, false end
self.gen2Forced[id] = forced or nil self.gen2Forced[id] = forced or nil
self:_saveState() self:_saveState()
@@ -1539,6 +1544,9 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation) require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState() self:_loadState()
self:_discover() self:_discover()
if self.safeMode then
for id in pairs(self.mods) do self.disabled[id] = true end
end
-- Existing installs stored one shared answer. Once their manifests are -- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game -- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same -- toggle can change one independently. _loadState already used the same
@@ -1574,7 +1582,7 @@ function Loader:load(data)
-- the one build where its env var is set. -- the one build where its env var is set.
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env local envName = mod.manifest.force_enable_env
if envName and os.getenv(envName) == "1" then if not self.safeMode and envName and os.getenv(envName) == "1" then
self.disabled[id] = nil self.disabled[id] = nil
end end
end end
@@ -1740,6 +1748,7 @@ function Loader:status()
local manifest = {} local manifest = {}
for key, value in pairs(mod.manifest) do manifest[key] = value end for key, value in pairs(mod.manifest) do manifest[key] = value end
manifest.enabled = mod.enabled ~= false manifest.enabled = mod.enabled ~= false
manifest.safeMode = self.safeMode == true
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
manifest.error = mod.failure manifest.error = mod.failure
-- set instead of `error` when the mod was left out for a reason that is -- set instead of `error` when the mod was left out for a reason that is

Some files were not shown because too many files have changed in this diff Show More