Compare commits

..

6 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
120 changed files with 1459 additions and 21758 deletions
+114 -9
View File
@@ -12,11 +12,10 @@ name: ci
#
on:
push:
# Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
# PRs into dev only: a dev -> main ship PR reuses the required checks the
# dev push already put on the same head SHA, so it needs no second run.
pull_request:
branches: [dev]
# a force-push while CI is mid-run should cancel the stale run, not queue
concurrency:
@@ -27,6 +26,64 @@ permissions:
contents: read
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:
name: detect iOS changes
runs-on: ubuntu-latest
@@ -121,7 +178,7 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
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"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
@@ -319,10 +376,52 @@ jobs:
run: |
set -euo pipefail
scripts/build_linux_arm64.sh --version 0.0.0
# Shared with the release workflow so shipped images get the same
# self-contained / glibc-floor checks as PR builds.
- name: Verify the AppImage is self-contained and bullseye-compatible
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
run: |
set -euo pipefail
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
# --appimage-extract needs no FUSE, so this works on a runner
# without /dev/fuse and still exercises the real payload.
"$image" --appimage-extract >/dev/null
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
[ -e "squashfs-root/$required" ] \
|| { echo "::error::AppImage is missing $required"; exit 1; }
done
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash.
#
# This runs on a HEADLESS runner on purpose, and that is the point.
# The first version of this build bundled Debian's SDL2, which
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
# The whole point of compiling on bullseye. If a future change moves
# the builder to a newer base, the glibc floor silently rises and
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
- name: Upload the AppImage
uses: actions/upload-artifact@v7
with:
@@ -359,6 +458,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
# the fixture PNGs are committed (they are 8x8 placeholders, not
@@ -379,8 +479,13 @@ jobs:
print(f"\n{len(paths)} fixture assets valid")
PY
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
# run in the headless job via run_engine; this job only guards the PNGs
# the fingerprint golden is the parity tripwire; prove it still
# matches the dataset on a clean checkout
- name: fingerprint gate
run: luajit tests/engine/gate_fingerprint.lua
- name: parity-guarantee meta-test
run: luajit tests/engine/gate_meta_coverage.lua
# Only the differ is under test here, and the job is named for that. The
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
@@ -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 }}
+12 -17
View File
@@ -161,8 +161,6 @@ jobs:
scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love
- name: Verify the AppImage is self-contained and bullseye-compatible
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
- name: Upload Linux arm64 release
uses: actions/upload-artifact@v7
with:
@@ -287,7 +285,7 @@ jobs:
retention-days: 1
release:
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
@@ -309,15 +307,6 @@ jobs:
name: gen1tls-win-x64
path: dist/native/win-x64
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
# every release asset ships one identical payload (build.sh's own pack
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: dist/payload
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
@@ -355,9 +344,16 @@ jobs:
echo "Identities available to codesign:"
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
env:
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: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
@@ -368,8 +364,7 @@ jobs:
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
exit 1
fi
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
--game-love dist/payload/game.love
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
@@ -440,8 +435,8 @@ jobs:
set -a; . "$ci_dir/notary.env"; set +a
echo "::add-mask::$APPLE_APP_PASSWORD"
app=".bazinga/work/gen1recomp.app"
zip="dist/mac/gen1recomp-macos.zip"
app="$RUNNER_TEMP/gen1recomp-mac-stage/gen1recomp++.app"
zip="dist/mac/gen1recomp++-macos.zip"
[ -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
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
@@ -486,7 +481,7 @@ jobs:
outdir="dist/release"
rm -rf "$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/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 }}
+11 -11
View File
@@ -140,17 +140,17 @@ ship text.
### 4. `games` (and the legacy `gen2compat`)
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
overworld, script VM and save format. The mod API is shared across both
generations (same hook names, same event names, same registry names) but Gen 2
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
VM and save format. The mod API is shared across both generations (same hook
names, same event names, same registry names) but Gold cannot serve all of it
yet, so Gen 2 is opt-in. Say which games the mod is for:
```json
"games": ["gen1", "gen2"]
```
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
generation (`"gen1"`, `"gen2"`) or `"all"`;
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
gen1,gen2` writes the key for you. The mod still installs to one directory,
@@ -159,10 +159,10 @@ gen1,gen2` writes the key for you. The mod still installs to one directory,
Absent means Gen 1 only, which is what every mod written before the key existed
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
have actually run your mod on Gold or Silver.
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
run your mod on Gold.
Every token is enforced, per game: the loader gates on the same
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
writes with a report, and which hooks and events are still to come.
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
+13 -18
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'
have_love() {
command -v love >/dev/null 2>&1 && return 0
[ -x "/Applications/love.app/Contents/MacOS/love" ] && return 0
[ -x "$HOME/Applications/love.app/Contents/MacOS/love" ] && return 0
local app version
for app in ".bazinga/love12/love.app" "/Applications/love12.app" "$HOME/Applications/love12.app" \
"/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
}
@@ -57,22 +63,11 @@ if ! command -v python3 >/dev/null 2>&1; then
fi
fi
# ----------------------------------------------------------------- Homebrew
if ! have_love && ! command -v brew >/dev/null 2>&1 \
&& [ ! -x /opt/homebrew/bin/brew ] && [ ! -x /usr/local/bin/brew ]; then
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
fi
if ! have_love && ! command -v xcodebuild >/dev/null 2>&1; then
warn "LÖVE 12 is not installed and Xcode is required to build it for macOS."
warn "Install Xcode from the App Store, launch it once, then run this again."
pause_exit 1
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
echo
+12 -11
View File
@@ -55,15 +55,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[![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,
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
Silver ROM is the only game content input.
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
is the only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
## Quick Start
@@ -71,14 +72,13 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
accepted. The importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -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
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
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only
(next to `gen1recomp++.app` on macOS or `gen1recomp.exe` on Windows, or next
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
runs from a read-only package.
@@ -219,7 +220,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| Option | Effect |
| --- | --- |
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
| `--slot=2` | load that save slot; takes a slot number or a slot id |
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

-2
View File
@@ -133,8 +133,6 @@ mkdir -p "$GAME_SRC"
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
-23
View File
@@ -92,7 +92,6 @@ mkdir -p "$GAME_SRC"
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
printf '%s\n' "$payload_list" \
@@ -100,8 +99,6 @@ printf '%s\n' "$payload_list" \
&& fail "payload unexpectedly contains generated ROM data"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing tools/rom_manifest_gold.json"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|| fail "payload is missing tools/rom_manifest_silver.json"
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
@@ -196,26 +193,6 @@ get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp"
# Anbernic stock keeps the launcher and the game folder side by side, so the
# SHDIR-relative path above is correct there and is tried first.
#
# Other firmwares (muOS, and PortMaster's layout on several devices) keep
# launcher scripts and port data in SEPARATE trees -- scripts under roms/ports,
# data under ports -- so the sibling folder holds no game.
#
# Probe for the BINARY, not the directory: on a split layout this script has
# usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
# failed run (see mkdir/tee below), so an existence test matches a decoy of our
# own making. Stock is unaffected -- its sibling holds the real binary and wins
# on the first test.
if [ ! -f "$GAMEDIR/bin/love.aarch64" ]; then
for candidate in "/$directory/ports/gen1recomp" \
"/mnt/sdcard/ports/gen1recomp" \
"/mnt/mmc/ports/gen1recomp" \
"/roms/ports/gen1recomp"; do
if [ -f "$candidate/bin/love.aarch64" ]; then GAMEDIR="$candidate"; break; fi
done
fi
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
+1 -1
View File
@@ -61,7 +61,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
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.modules.audio = not companion
t.modules.joystick = not companion
+3 -6
View File
@@ -25,10 +25,8 @@
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
-- extractor now collects any top-level label in a dedicated text file
-- regardless (tools/extract/text.py), so this is the real ROM label --
-- the literal below is only the fallback for a catalog without it.
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
-- extractor never collects it and the pamphlet's text is inlined.
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
.. "There are 50 TMs\nin all.\f"
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
@@ -72,8 +70,7 @@ return {
return true
end
if fx == 3 and fy == 4 then
local text = game.data.text or {}
game.stack:push(TextBox.new(game, text.TMNotebookText or TM_NOTEBOOK_TEXT))
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
return true
end
return false
+7 -10
View File
@@ -15,10 +15,10 @@ return {
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
-- collects them regardless (tools/extract/text.py); the literals
-- below are only the fallback for a catalog without them.
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
-- into data/generated/text.lua (no leading underscore in
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
-- ported here verbatim.
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
local t = game.data.text
push(game, t._SSAnneKitchenCook7MainCourseIsText
@@ -27,16 +27,13 @@ return {
local dish
if roll <= 2 then
-- bit 7 of hRandomAdd set (~50%)
dish = t.SSAnneKitchenCook7SalmonDuSaladText
or "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
elseif roll == 3 then
-- bit 4 set, bit 7 clear (~25%)
dish = t.SSAnneKitchenCook7EelsAuBarbecueText
or "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
else
-- neither bit set (~25%)
dish = t.SSAnneKitchenCook7PrimeBeefSteakText
or "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
end
push(game, dish, done)
end)
+10 -11
View File
@@ -60,23 +60,22 @@ M.VIRIDIAN_CITY = {
-- you want to know about the two kinds of caterpillar Pokemon;
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
-- ViridianCityYoungster2OkThenText and
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
-- without a leading underscore in pokered/text/ViridianCity.asm, but
-- tools/extract/text.py now collects them regardless -- the literal
-- strings below are only the fallback for a catalog without them.
-- Those fallbacks have to carry the extractor's markers, not plain
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
-- \n and \n\n put all six lines on one page with nothing to wait on,
-- so the whole speech scrolled past without a button press (#250).
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to
-- the literal strings from pokered. Those fallbacks have to carry the
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
-- one page with nothing to wait on, so the whole speech scrolled past
-- without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then
push(game, t.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
or "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
else
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
push(game, "Oh, OK then!", done)
end
end)
end,
+5 -6
View File
@@ -122,8 +122,9 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore; tools/extract/text.py now collects it regardless,
-- so preFallback below is just the safety net for a catalog without it.
-- leading underscore, and on Red it sits outside the extractor's symbol
-- set, so the literal from text/ViridianCity.asm rides along as the
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_FISHER = gift({
@@ -145,11 +146,9 @@ M.SILPH_CO_2F = {
talk = {
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
-- the label carries no leading underscore (#393); collected like any
-- other text/*.asm label now, preFallback is just the safety net
-- the label carries no leading underscore: pokered keeps this one in
-- the script bank, not the far-text bank (#393)
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
preFallback = "Eeek!\nNo! Stop! Help!\fOh, you're not\nwith TEAM ROCKET."
.. "\vI thought...\vI'm sorry. Here,\vplease take this!",
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
+8 -11
View File
@@ -105,9 +105,8 @@ trixie.
This is a statement about the *compile environment*, not about where the
artifact runs — building on your own newer distro would silently raise that
floor and strand every user on an older one, with no symptom until they
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
both CI (`linux-arm64-build`) and the release workflow: the build fails if
the highest required glibc symbol version climbs above 2.31.
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
required glibc symbol version climbs above 2.31.
### Why five libraries are built from source
@@ -173,15 +172,13 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
exclude list still classifies known sonames correctly, that AppRun still
launches `game.love` with `--fused`, and that the host-arch guard actually
fires. Needs no container and no arm64 machine.
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
the layout, that every bundled object resolves under AppRun's
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
AppImage for 7 days.
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
the artifact and asserts the layout, that every bundled object resolves
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
Uploads the AppImage for 7 days.
- **release**`linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
`game.love` from the `love-payload` job, runs the same
`verify_appimage.sh` checks on the shipped image, and the AppImage is
staged and published like every other release asset.
`game.love` from the `love-payload` job, and the AppImage is staged and
published like every other release asset.
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
it runs on fork PRs too.
+5 -7
View File
@@ -55,10 +55,9 @@ The short version, for an author deciding what to write:
```
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
and `GameVersion.generation`, so nothing anywhere restates the game list.
`"gen2"` now expands to both Gold and Silver.
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
`GameVersion.generation`, so nothing anywhere restates the game list.
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads.
@@ -542,9 +541,8 @@ gains a field instead of the name gaining a prefix.
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible`,
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
difference: Gen 1's vanilla
`battle.catch_exp`, `battle.bottom_ui_visible` and
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
`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
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
+1 -1
View File
@@ -81,7 +81,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
-2
View File
@@ -11,12 +11,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports**
## Gen 2 Specifics
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
+1 -1
View File
@@ -176,7 +176,7 @@ something the filesystem encodes.
| token | means |
| --- | --- |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has |
+2 -4
View File
@@ -1,8 +1,7 @@
# What This Port Requires
The packaged desktop app requires one user-supplied input on first boot: a
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM, or a canonical 2 MiB US
Pokemon Gold or Silver ROM.
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
for specific hashes). Other revisions and Virtual Console releases are rejected
@@ -16,8 +15,7 @@ Python and Pillow are not required by the packaged app.
Assembly removes high-level names and some relationships that the Lua port
needs. The version-specific files `tools/rom_manifest.json`,
`tools/rom_manifest_blue.json`, `tools/rom_manifest_yellow.json`,
`tools/rom_manifest_gold.json`, and `tools/rom_manifest_silver.json` therefore
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
contain:
- the ROM symbol addresses actually read by the extractor
+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.
3. On successful PR fused builds, a follow-up workflow posts a PR comment
linking the Actions artifact named `gen1recomp-switch-nro`
(comment tag `switch-build-result`; see
`.github/workflows/switch-artifact-comment.yml`).
(comment tag `platform-build-result`; see
`.github/workflows/platform-artifact-comment.yml`).
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
+9 -11
View File
@@ -91,14 +91,14 @@ Do **not** launch from the Album applet path for normal play.
This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All five can sit in the
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All four can sit in the
same folder.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
never imports from the Yellow tab (and vice versa). Gold and Silver are
Beta in the launcher; a clean US dump of either is enough to Play.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
imports from the Yellow tab (and vice versa). Gold is Beta in the
launcher; a clean US Gold dump is enough to Play.
## 5. Import / Export a raw `.sav`
@@ -111,12 +111,10 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
| Gold | `imports/saves/gold/` | `exports/gold/` |
| Silver | `imports/saves/silver/` | `exports/silver/` |
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
exist so MTP browsing matches the other games. Gold and Silver progress still
saves in-engine.)
Gold cart `.sav` import/export is not supported yet -- the folders exist so
MTP browsing matches the other games. Gold progress still saves in-engine.)
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
+3 -4
View File
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -54,8 +54,7 @@ macOS, not a Mac-only requirement.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow|gold|silver>/`, or
`exports/<red|blue|yellow|gold|silver>/`
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
+5 -6
View File
@@ -386,12 +386,11 @@ function bootGame(version)
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
-- tables, save shape and screen registry -- so Gold and Silver boot their
-- own service owner, which mounts src/world/gen2 (walk / warps /
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
-- wiring.
if GameVersion.generation() == 2 then
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
-- tables, save shape and screen registry -- so Gold boots its own service
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
if GameVersion.isGold() then
Game = require("src.core.Game2").new()
Game:load()
else
+4 -4
View File
@@ -109,10 +109,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
Android packer verifies the Yellow, Gold, and Silver manifests before it
packages; if a partial source export omitted one, it restores the file from
this checkout's Git data and then falls back to the project's GitHub copy. Generated game data,
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
packer verifies the Yellow manifest before it packages; if a partial source
export omitted it, it restores the file from this checkout's Git data and then
falls back to the project's GitHub copy. Generated game data,
scripts, tests, and mobile build sources are excluded.
## Branding (applied by the build script)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -8,5 +8,4 @@
<color name="shortcut_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color>
<color name="shortcut_silver">#BEC6D2</color>
</resources>
-91
View File
@@ -12,97 +12,6 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.10",
"date": "2026-08-19",
"size": 13662645,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.10/gen1recomp++-0.2.10-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #998 Jingles not finishing before game proceeds\n- #1472 Message for sending out Pokemon not closing automatically\n- #1526 No screen shake when getting poisoned\n- #1529 Messages missing when interacting with PC\n- #1530 No message for interacting with bikes in the bike shop\n- #1532 Thrash animation incomplete\n- #1534 Dialogue missing when switching out Pokemon\n- #1547 Save states can be used to bypass certain NPCs\n- #1549 Menu Cartridge 3D model has visual issues\n- #1550 Nugget Bridge Rocket repeating dialogue\n- #1551 No scripted dialogue after beating Nugget Bridge Rocket\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian"
},
{
"version": "0.2.9",
"date": "2026-08-19",
"size": 13656293,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.8",
"date": "2026-08-19",
"size": 13653911,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
},
{
"version": "0.2.7",
"date": "2026-08-18",
"size": 13597177,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
},
{
"version": "0.2.6",
"date": "2026-08-18",
"size": 13589036,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.6/gen1recomp++-0.2.6-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1393 [Launcher -> Mods] Only the pages that you manually clicked to are used for sorting\n- #1418 (Pokémon Gold) Framerate and void fill options missing\n- #1430 [Gold] Shop ui off because of a border\n- #1519 Poison damage after battle inconsistent\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.5",
"date": "2026-08-18",
"size": 13586158,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.4",
"date": "2026-08-18",
"size": 13582747,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.3",
"date": "2026-08-18",
"size": 13579254,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
},
{
"version": "0.2.2",
"date": "2026-08-18",
"size": 13575387,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.2.1",
"date": "2026-08-17",
"size": 13575320,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.0",
"date": "2026-08-17",
"size": 13575299,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.99",
"date": "2026-08-17",
"size": 11391467,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
},
{
"version": "0.1.98",
"date": "2026-08-16",
"size": 11380117,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.97",
"date": "2026-08-16",
+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.
+96 -55
View File
@@ -6,10 +6,9 @@
#
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# [--notary-profile NAME] [--no-notarize]
# [--game-love PATH] # fuse a prebuilt payload (scripts/pack_love.sh) instead of packing one
# [--release] # ios only: release config instead of debug
#
# Output: dist/mac/gen1recomp-macos.zip
# Output: dist/mac/gen1recomp++-macos.zip
# dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
@@ -28,21 +27,41 @@ ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
APP_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
MAC_APP_NAME="gen1recomp++"
MAC_BUNDLE_ID="com.theboisclub.gen1recompplusplus"
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_EXPLICIT=false
IDENTITY=""
TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
FETCH_MAC_RUNTIME=false
IOS_RELEASE=false
IOS_IPA=false
GAME_LOVE_IN=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
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
case "$1" in
mac|win|linux|android|ios|all) TARGET="$1" ;;
@@ -50,7 +69,7 @@ while [ $# -gt 0 ]; do
--identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
--fetch) FETCH_MAC_RUNTIME=true ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
@@ -65,23 +84,16 @@ mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# launcher's Edit button on a save row opens it in-process (main.lua), and
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive.
say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
if [ -n "$GAME_LOVE_IN" ]; then
[ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
say "using prebuilt payload: $GAME_LOVE_IN"
cp "$GAME_LOVE_IN" "$LOVE_FILE"
else
say "packing game.love"
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
# the vendored libs/flexlove tree it replaced is gone.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
fi
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
# the vendored libs/flexlove tree it replaced is gone.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
# Materialize the listing once and grep the file: piping unzip straight into
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
# match, and the pipeline's failure reads as "missing <file>" for whichever
@@ -100,8 +112,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua \
src/ui/kit/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json; do
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json; do
grep -qxF "$required" "$LOVE_LISTING" \
|| fail "game.love is missing $required"
done
@@ -116,26 +127,18 @@ say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
if [ -n "$GAME_LOVE_IN" ]; then
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "prebuilt payload does not report engine $VERSION (pack it with pack_love.sh --version $VERSION)"
say "prebuilt payload already stamped: $VERSION"
else
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
fi
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
@@ -169,24 +172,55 @@ make_ico() { # $1 = output .ico path
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
local love_app="${LOVE_APP:-/Applications/love.app}"
[ -d "$love_app" ] || fail "LÖVE.app not found at $love_app (install it or set LOVE_APP=/path/to/love.app)"
local love_app="${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"
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
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
local plist="$out_app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $MAC_APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleName string $MAC_APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $MAC_APP_NAME" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $MAC_APP_NAME" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $MAC_APP_NAME" "$plist" 2>/dev/null \
|| /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 "Add :CFBundleShortVersionString string $VERSION" "$plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
@@ -208,6 +242,11 @@ build_mac() {
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "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"
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)"
@@ -229,9 +268,9 @@ build_mac() {
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 ..."
else
local notarize_zip="$WORK/$APP_NAME-notarize.zip"
local notarize_zip="$stage_dir/$MAC_APP_NAME-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)"
xcrun notarytool submit "$notarize_zip" --keychain-profile "$NOTARY_PROFILE" --wait
say "stapling notarization ticket"
@@ -240,9 +279,11 @@ build_mac() {
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"
(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"
}
-54
View File
@@ -30,8 +30,6 @@ YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
VERSION=""
PACKAGE_ONLY=false
@@ -179,54 +177,6 @@ ensure_gold_manifest() {
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
}
silver_manifest_is_valid() {
local path="$1"
python3 - "$path" <<'PY'
import json, pathlib, sys
try:
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
raise SystemExit(0 if manifest.get("romSha1") ==
"49b163f7e57702bc939d642a18f591de55d92dae" else 1)
PY
}
ensure_silver_manifest() {
local manifest="$ROOT/$SILVER_MANIFEST_RELATIVE"
local staged
staged="$(mktemp)"
if silver_manifest_is_valid "$manifest"; then
rm -f "$staged"
return
fi
warn "Silver import manifest is missing or invalid; recovering it before packaging"
if git -C "$ROOT" show "HEAD:$SILVER_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
&& silver_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "restored Silver import manifest from this checkout's Git data"
return
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$SILVER_MANIFEST_URL" \
&& silver_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "downloaded Silver import manifest from the project repository"
return
fi
rm -f "$staged"
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
}
# --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing
@@ -292,7 +242,6 @@ pack_game_love() {
say "packing game.love for love-android embed flavor"
ensure_yellow_manifest
ensure_gold_manifest
ensure_silver_manifest
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
@@ -307,7 +256,6 @@ pack_game_love() {
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# List once and match against the captured text: piping unzip straight into
@@ -329,8 +277,6 @@ pack_game_love() {
|| fail "game.love is missing the Yellow ROM import manifest"
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|| fail "game.love is missing the Gold ROM import manifest"
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|| fail "game.love is missing the Silver ROM import manifest"
# This gate exists because the launcher's UI toolkit once lived outside
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
# no other packager, so Android and iOS built an APK/IPA whose launcher
+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"
-2
View File
@@ -366,8 +366,6 @@ cp "$IN/game.love" "$APPDIR/game.love"
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_silver.json"
# The .desktop's Icon= resolves against the AppDir root by basename, and
# .DirIcon is what appimaged and file-manager thumbnailers read.
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
@@ -169,7 +169,5 @@ unzip -p "$temp_dir/game.love" src/core/Version.lua \
|| fail "shared payload version was not stamped"
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_silver.json"
say "Linux arm64 self-test passed"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Verifies a built arm64 AppImage is self-contained and bullseye-compatible.
# Usage: scripts/linux-arm64/verify_appimage.sh <AppImage>
set -euo pipefail
image="${1:?usage: verify_appimage.sh <AppImage>}"
[ -f "$image" ] || { echo "::error::no such AppImage: $image"; exit 1; }
image="$(cd "$(dirname "$image")" && pwd)/$(basename "$image")"
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
cd "$workdir"
# --appimage-extract needs no FUSE, so this works on a runner
# without /dev/fuse and still exercises the real payload.
"$image" --appimage-extract >/dev/null
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
[ -e "squashfs-root/$required" ] \
|| { echo "::error::AppImage is missing $required"; exit 1; }
done
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash.
#
# This runs on a HEADLESS runner on purpose, and that is the point.
# The first version of this build bundled Debian's SDL2, which
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
# The whole point of compiling on bullseye. If a future change moves
# the builder to a newer base, the glibc floor silently rises and
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
echo "AppImage verified: $image"
-2
View File
@@ -50,7 +50,6 @@ rm -f "$OUTPUT"
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
@@ -95,7 +94,6 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
src/ui/kit/Kit.lua \
src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \
+25 -5
View File
@@ -17,9 +17,7 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
fail "generated data missing, run scripts/setup.sh first"
fi
@@ -34,8 +32,30 @@ find_love() {
return 1
}
LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
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)" \
|| 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;
# 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
}
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"
elif [ "$(uname -s)" = "Darwin" ] && command -v brew >/dev/null 2>&1; then
say "installing LÖVE via Homebrew"
brew install --cask love
else
fail "LÖVE 11.x is not installed; install it from https://love2d.org"
fi
+4 -10
View File
@@ -71,15 +71,15 @@ First install or update (same steps):
your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
switch/gen1recomp/pokemon-love2d/imports/
then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF
@@ -92,7 +92,7 @@ write_readme() {
}
write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
@@ -103,8 +103,6 @@ write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
@@ -113,8 +111,6 @@ write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
rm -f "$OUT_ZIP"
(
@@ -144,12 +140,10 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
)
for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
+1 -3
View File
@@ -271,12 +271,10 @@ for rel in \
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done
-1
View File
@@ -67,7 +67,6 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
-13
View File
@@ -326,9 +326,6 @@ function Game:logicSpeed()
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
@@ -478,15 +475,6 @@ function Game.speedCategoryInStack(stack)
return "menu"
end
function Game.isFixedSpeedInStack(stack)
local states = stack and stack.states
for i = #(states or {}), 1, -1 do
local state = states[i]
if state and (state.isFixedSpeed or state.isMinigame) then return true end
end
return false
end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
@@ -1260,7 +1248,6 @@ function Game:applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
require("src.core.ScreenPosition").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
+3 -5
View File
@@ -3,7 +3,7 @@
-- everything under src/*/gen2 reaches shared services through here. Gen 1
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
-- shape and screen registry -- so main.lua's bootGame picks this owner when
-- GameVersion.generation() == 2, and the two never branch into each other.
-- GameVersion.isGold(), and the two never branch into each other.
--
-- Boot: copyright → GameFreak Presents → GS intro stub → title
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
@@ -1660,9 +1660,8 @@ function Game2:drawScene(w, h)
-- row to opt into the step-down half, so CENTERED is the whole rule
-- here.
local s = self.world:fitScale()
local ox, oy = Chrome.fitOrigin(w, h, s)
G.push()
G.translate(ox, oy)
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
G.scale(s, s)
self.stack:draw()
G.pop()
@@ -1703,7 +1702,7 @@ function Game2:hotkey(key)
self:writeSave()
return true
elseif key == "f2" then
local loaded = Save.load()
local loaded = Save.load("gold")
if loaded then self:continueGame(loaded) end
return true
elseif key == "1" then
@@ -1983,7 +1982,6 @@ function Game2:applyOptions()
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.ScreenPosition").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
+4 -18
View File
@@ -1,5 +1,5 @@
-- Which game this process is running: Red (the historical default), Blue,
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
-- Yellow, or Gold. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
@@ -8,8 +8,7 @@
-- saves are untouched, but its extracted cache lives under red/ like Blue,
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
-- and selected side by side. Gold and Silver are Gen 2 (see
-- docs/gold-phase1.md).
-- and selected side by side. Gold is Gen 2 (see docs/gold-phase1.md).
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
@@ -62,26 +61,13 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest_gold.json",
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
-- Absent reads as 1 (GameVersion.generation)
generation = 2,
},
-- Gold's engine with edition-selected data; the manifest is derived from
-- Gold's by tools/make_silver_manifest.py.
silver = {
id = "silver",
label = "Silver",
displayName = "Pokemon Silver",
launcherName = "Silver (Beta)",
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
manifest = "tools/rom_manifest_silver.json",
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
-- The only row that carries one; absent reads as 1 (GameVersion.generation)
generation = 2,
},
}
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
GameVersion.current = "red"
-1
View File
@@ -39,7 +39,6 @@ local function normalizeVersion(v)
b = "blue", blue = "blue",
y = "yellow", yellow = "yellow",
g = "gold", gold = "gold",
s = "silver", silver = "silver",
}
v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
+1 -1
View File
@@ -230,7 +230,7 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not (love and love.audio) then return end -- headless test stub
if not love.audio then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
+6 -20
View File
@@ -140,7 +140,7 @@ function SaveData.gameFolders()
local src = love.filesystem.getSource and love.filesystem.getSource()
local sbd = 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
-- bundle -- not where the player drops portable.txt (next to the .app).
-- Recover the folder containing the .app so a packaged app finds its
@@ -285,7 +285,6 @@ function SaveData.defaultOptions()
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
-- src/core/FaithfulRes.lua. Ignored on mobile.
faithfulRes = 0,
screenPos = "center",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
@@ -1280,26 +1279,13 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
local existing = byVersion and byVersion[scope]
id = not isFresh and existing or nil
id = not isFresh and byVersion and byVersion[scope] or nil
if type(id) ~= "string" or id == "" then
id = SaveData.newPlaythroughId()
-- A fresh skeleton still gets its own id (two unsaved New Games sharing a
-- slot must stay distinct), and it is still persisted when the slot has no
-- binding yet -- that is the contract a tool relies on to resolve
-- `selected` at the title after a restart, before any normal SAVE.
--
-- What it must NOT do is OVERWRITE a binding that already exists. newGame()
-- marks a skeleton on the boot frame, before any save is loaded, and mods
-- initialise inside that window -- so a mod touching storage at init
-- replaced the real save's id with a throwaway, stranding that save's mod
-- storage and repeating on every launch.
if not (isFresh and type(existing) == "string" and existing ~= "") then
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
save.meta.playthroughId = id
return id
-66
View File
@@ -1,66 +0,0 @@
local ScreenPosition = {}
ScreenPosition.MODES = { "center", "upper", "top" }
ScreenPosition.DEFAULT = "center"
ScreenPosition.mode = ScreenPosition.DEFAULT
local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" }
function ScreenPosition.normalize(v)
if LABELS[v] then return v end
return ScreenPosition.DEFAULT
end
function ScreenPosition.label(v)
return LABELS[ScreenPosition.normalize(v)]
end
function ScreenPosition.cycle(v, dir)
v = ScreenPosition.normalize(v)
local modes = ScreenPosition.MODES
local cur = 1
for i, mode in ipairs(modes) do
if mode == v then cur = i break end
end
return modes[(cur - 1 + (dir or 1)) % #modes + 1]
end
function ScreenPosition.setMode(v)
ScreenPosition.mode = ScreenPosition.normalize(v)
end
function ScreenPosition.applyOptions(opts)
ScreenPosition.setMode(opts and opts.screenPos)
end
function ScreenPosition.safeTop()
local ok, SafeArea = pcall(require, "src.core.SafeArea")
if not ok then return 0 end
local okr, _, y = pcall(SafeArea.rect)
if not okr then return 0 end
return math.max(0, tonumber(y) or 0)
end
function ScreenPosition.skinActive(w, h)
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
local okv, x = pcall(TouchSkin.viewport, w, h)
return okv and x ~= nil
end
function ScreenPosition.lift(viewH, contentH, safeTop)
if ScreenPosition.mode == "center" then return 0 end
viewH = tonumber(viewH) or 0
contentH = tonumber(contentH) or 0
local slack = viewH - contentH
if slack <= 0 then return 0 end
local centered = math.floor(slack / 2)
local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4)
safeTop = math.floor(tonumber(safeTop) or 0)
if safeTop > 0 and target < safeTop then
target = math.min(safeTop, centered)
end
return centered - target
end
return ScreenPosition
+1 -1
View File
@@ -218,7 +218,7 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion.generation and GameVersion.generation() == 2 then
if ok and GameVersion.isGold and GameVersion.isGold() then
margin = math.max(margin, math.min(ww * 0.10, 72))
end
return {
+13 -26
View File
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
}
local function saveNames(version)
version = version or GameVersion.get()
version = version or "gold"
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
-- the flat save_<version>.lua when no slot is registered.
--
@@ -133,22 +133,16 @@ local function fs()
return love.filesystem
end
-- The blank-name fallback is the first PlayerNameArray row, which differs
-- per edition -- data/player_names.asm:12-23.
function Save.defaultPlayerName(version)
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
end
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
-- A fresh Gold save. `opts` carries what the intro collected: player name,
-- rival name, and the options the OPTION screen was left on.
function Save.newGame(opts)
opts = opts or {}
local save = {
format = Save.FORMAT,
version = GameVersion.get(),
version = "gold",
generation = 2,
player = {
name = opts.playerName or Save.defaultPlayerName(),
name = opts.playerName or "GOLD",
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535),
@@ -288,7 +282,6 @@ Save.DEFAULT_OPTIONS = {
musicFilter = 0, -- low-pass steps, 0 = off
haptics = "light",
touchControls = { enabled = true },
screenPos = "center",
}
function Save.defaultOptions()
@@ -309,7 +302,7 @@ end
Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = {
touchControls = true, haptics = true, screenPos = true,
touchControls = true, haptics = true,
mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true,
@@ -381,13 +374,10 @@ end
function Save.normalize(save)
if type(save) ~= "table" then return nil end
save.format = save.format or Save.FORMAT
if not (GameVersion.VERSIONS[save.version]
and GameVersion.generation(save.version) == 2) then
save.version = GameVersion.get()
end
save.version = "gold"
save.generation = 2
save.player = save.player or {}
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
save.player.name = save.player.name or "GOLD"
save.player.id = save.player.id or rand(0, 65535)
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
@@ -763,24 +753,21 @@ end
-- copy is the witness that survives a crash mid-replace.
function Save.save(save)
if type(save) ~= "table" then return false, "no save" end
Save.normalize(save)
local version = save.version
do
if (save.version or "gold") == "gold" then
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
local id = SaveData.createSlot and SaveData.createSlot(version)
if id and SaveData.setActiveSlot then
SaveData.setActiveSlot(version, id)
end
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
local id = SaveData.createSlot and SaveData.createSlot("gold")
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
end
end
local main, backup, tmp = saveNames(version)
local main, backup, tmp = saveNames(save.version)
local f = fs()
if not f then return false, "no filesystem" end
-- saveNames may now return a saves/<version>/<slot>.lua path, and
-- love.filesystem.write does not create missing parent directories.
local dir = main:match("^(.*)/[^/]+$")
if dir and f.createDirectory then f.createDirectory(dir) end
Save.normalize(save)
save.savedAt = os.time()
local encoded = SaveSerializer.encode(save)
if f.getInfo(main) then
+2 -4
View File
@@ -249,12 +249,10 @@ function SwitchDiagnostics.probeAssets(version)
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "gold", "silver", "assets",
"yellow/assets/generated",
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated", "silver/assets/generated",
"silver/data/generated" }
"gold/data/generated" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
+1 -2
View File
@@ -27,8 +27,7 @@ local ok, err = pcall(function()
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor =
require("src.core.GameVersion").generation(version) == 2
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
+4 -18
View File
@@ -332,17 +332,6 @@ local function coreRows(opts, hooks)
end)
end
local okSp, ScreenPos = pcall(require, "src.core.ScreenPosition")
if okSp then
add(Strings("SCREEN POS"),
function() return Strings(ScreenPos.label(opts.screenPos)) end,
function(dir)
opts.screenPos = ScreenPos.cycle(opts.screenPos, dir)
ScreenPos.setMode(opts.screenPos)
return true
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
@@ -561,10 +550,9 @@ end
-- opened on the Gold tab used to offer a dozen controls that did nothing
-- and hide the seven that the cart itself has.
--
-- The block lives in options.lua under `gold` (the historical key; Gold and
-- Silver share it the way the Gen 1 games share the flat namespace), which is
-- exactly where src/core/gen2/Save.lua loadOptions reads it, so an edit here
-- is live on the next boot the same way a Gen 1 edit is. Ladders mirror
-- The block lives in options.lua under `gold`, which is exactly where
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
-- next boot the same way a Gen 1 edit is. Ladders mirror
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
@@ -711,9 +699,7 @@ end
function LauncherSettings.open(hooks, version)
local opts = SaveData.loadOptions()
local sections
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS[version]
and GameVersion.generation(version) == 2 then
if version == "gold" then
local block = opts[GEN2_KEY]
if type(block) ~= "table" then
block = {}
+193 -217
View File
@@ -14,9 +14,9 @@
-- height, and flex-shrink compressing text until it overlapped.
--
-- THE RULES THIS FILE FOLLOWS:
-- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
-- installed-mod list is one continuous scroll instead, drawing only the
-- rows inside the region viewport so the window bounds the frame cost.
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside
-- its viewport, so each of its pages can hold at least ten entries without
-- requiring a tall window. Pages still bound how many mod rows we visit.
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
-- the queue, so an action that tears the view down (Play, Edit save)
-- never runs inside the frame that dispatched it.
@@ -45,6 +45,9 @@ local COMMUNITY_URL = "https://bois.icu"
local ACT_DEDUP = 0.35
-- Finger travel past this (px) is a drag, not a tap.
local TAP_SLOP2 = 16 * 16
-- Installed mods should not turn into a one- or two-item pager on a compact
-- display. Keep a useful page size, then let the list viewport scroll.
local MIN_MODS_PER_PAGE = 10
local MIN_SKIN_ROWS = 4
local SKIN_FORMAT_LABEL = {
native = "GEN1",
@@ -78,6 +81,15 @@ local function setTabScroll(imp, value)
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
end
local function modListWantsWheel(imp, wheel)
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
if wheel < 0 then return at < imp._modScrollMax end
return at > 0
end
-- ------------------------------------------------------------- lifecycle
local function ensureState(imp)
@@ -180,6 +192,9 @@ function LauncherView.touchpressed(imp, id, x, y)
imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = {
x = x, y = y,
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
and inRect(imp._modListRect, x, y)
and inRect(imp._tabRegionRect, x, y),
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
}
end
@@ -192,9 +207,19 @@ function LauncherView.touchmoved(imp, id, x, y)
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
-- A drag that began in the installed-mod viewport scrolls that page's
-- rows. Its pager remains available for moving to the next ten-plus
-- entries; a drag elsewhere keeps the normal short-window page scroll.
if start.dragged then
local last = start.lastY or start.y
local move = -(y - last)
if start.modsList then
local listMax = imp._modScrollMax or 0
local at, leftover = Kit.scrollHandoff(
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
imp.modScroll = at
move = leftover
end
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
@@ -233,22 +258,6 @@ function LauncherView.clickAt(imp, x, y)
imp._clickPt = { x = x, y = y }
end
-- Event-driven click: a macOS trackpad tap delivers press+release inside one
-- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click
-- from the press event under the poll's own suppression rules, and mark the
-- press seen so the poll cannot mint a second one when isDown does catch it.
function LauncherView.mousepressed(imp, x, y)
if not imp._flex then return end
local now = love.timer.getTime()
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
if touching or now < (imp._suppressMouseUntil or 0)
or now < (imp._suppressClickUntil or 0) then
return
end
imp._clickPt = { x = x, y = y }
imp._prevMouseDown = true
end
-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm
-- the ring; Enter only activates a focused control once the user has actually
-- used the arrows this session, so the long-standing "Enter plays the visible
@@ -334,7 +343,7 @@ end
local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber, silver = PAL.railSilver,
gold = PAL.railAmber,
}
local function cartColor(version)
return CART_COLOR[version] or PAL.green
@@ -409,13 +418,13 @@ end
local function cartPill(project, x, y, w, h, z, color, alpha)
local points, radius = {}, h / 2
for i = 0, 10 do
local a = -math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
local a = math.pi + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
for i = 0, 10 do
local a = math.pi / 2 + math.pi * i / 10
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
local a = math.pi * i / 10
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
y + radius + math.sin(a) * radius, z) }
end
cartPolygon(points, color, alpha)
@@ -596,7 +605,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
end
local halfW, halfH = w / 2, h / 2
local depth = math.max(6, w * 0.10)
local depth = math.max(8, w * 0.14)
local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale)
@@ -639,7 +648,6 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
cartPolygon({ mainFront[4], mainFront[1], mainBack[1], mainBack[4] }, side, 1)
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
@@ -649,72 +657,22 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
else
cartPolygon(mainBack, side, 1)
cartPolygon(capBack, side, 1)
-- The tri-wing security screw: a domed brass head with three teardrop
-- recesses pinwheeled at 120 degrees.
local backZ = -(depth + 0.8)
local sd = math.min(w, h) * 0.11
cartPill(project, -sd * 0.62, -sd * 0.62, sd * 1.24, sd * 1.24, backZ,
{ math.floor(shell[1] * 0.4), math.floor(shell[2] * 0.4),
math.floor(shell[3] * 0.4) }, 0.9)
cartPill(project, -sd / 2, -sd / 2, sd, sd, backZ - 0.4,
{ 196, 186, 148 }, 1)
cartPill(project, -sd * 0.32, -sd * 0.32, sd * 0.64, sd * 0.64,
backZ - 0.6, { 220, 212, 178 }, 0.8)
local r = sd / 2
for k = 0, 2 do
local a = -math.pi / 2 + k * (2 * math.pi / 3)
local ux, uy = math.cos(a), math.sin(a)
local vx, vy = -uy, ux
local r0, r1 = r * 0.16, r * 0.82
local w0, w1 = r * 0.13, r * 0.3
cartPolygon({
{ project(ux * r0 + vx * w0, uy * r0 + vy * w0, backZ - 0.8) },
{ project(ux * r0 - vx * w0, uy * r0 - vy * w0, backZ - 0.8) },
{ project(ux * r1 - vx * w1, uy * r1 - vy * w1, backZ - 0.8) },
{ project(ux * r1 + vx * w1, uy * r1 + vy * w1, backZ - 0.8) },
}, { 112, 104, 76 }, 1)
end
end
if frontFacing then
local faceZ = depth + 0.8
-- The shell's grip grooves: a stack beside the label recess on the left,
-- and one below the top-right corner notch, like the DMG cart.
local grooveW = w * 0.115
local grooveH = math.max(1, h * 0.009)
for i = 0, 5 do
local ry = mainTop + h * 0.014 + i * h * 0.021
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
grooveW, grooveH, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
grooveW, grooveH, faceZ), side, 0.7)
for i = 0, 4 do
local ry = mainTop + 7 + i * h * 0.025
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
end
-- The thin diagonal mold ridge cut into each long side a little below
-- the grip grooves, mirrored left/right.
local function diagonal(x0, y0, x1, y1)
local dx, dy = x1 - x0, y1 - y0
local len = math.sqrt(dx * dx + dy * dy)
local nx, ny = -dy / len, dx / len
local t = math.max(0.6, h * 0.004)
cartPolygon({
{ project(x0 + nx * t, y0 + ny * t, faceZ) },
{ project(x0 - nx * t, y0 - ny * t, faceZ) },
{ project(x1 - nx * t, y1 - ny * t, faceZ) },
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
}, side, 0.7)
end
local dgY = mainTop + h * 0.20
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
local pillX, pillW = -halfW + w * 0.17, w * 0.62
local pillY, pillH = mainTop + h * 0.024, h * 0.115
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
local inX, inY = w * 0.008, h * 0.008
cartPill(project, pillX + inX, pillY + inY,
pillW - 2 * inX, pillH - 2 * inY, faceZ + 0.8,
{ math.floor(shell[1] * 0.92), math.floor(shell[2] * 0.92),
math.floor(shell[3] * 0.92) }, 1)
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
local recessW, recessH = w * 0.64, h * 0.24
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
local labelX, labelY = -w * 0.33, -h * 0.20
local labelW, labelH = w * 0.66, h * 0.55
@@ -984,8 +942,6 @@ local GAME_TABS = {
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
label = "Silver" },
}
local HEADER_TABS = {
@@ -1158,11 +1114,16 @@ local function buildHeader(imp, m)
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown: just the game's initial and the caret; the
-- popup list carries the full names
-- the cartridge dropdown, sized to its longest label so switching games
-- never reflows the row
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
local dropW = math.min(tabRight - tabLeft, tabH + math.floor(24 * m.s))
local labelW = 0
for _, g in ipairs(GAME_TABS) do
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
end
local dropW = math.min(tabRight - tabLeft,
tabH + labelW + math.floor(34 * m.s))
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id
@@ -1172,7 +1133,7 @@ local function buildHeader(imp, m)
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
@@ -1186,7 +1147,7 @@ local function buildHeader(imp, m)
end
tx = tx + dropW + tabGap
local function headerTab(t)
for _, t in ipairs(tabs) do
local w = tabH
if tx > tabLeft and tx + w > tabRight then
tx = tabLeft
@@ -1199,11 +1160,6 @@ local function buildHeader(imp, m)
btn(imp, tx, ty, w, tabH, t.key, "", o)
tx = tx + w + tabGap
end
-- The bug-report chip sits LAST, past the sync chip.
local bugTab
for _, t in ipairs(tabs) do
if t.id == "bug" then bugTab = t else headerTab(t) end
end
do
local w = tabH
@@ -1225,7 +1181,6 @@ local function buildHeader(imp, m)
end
tx = tx + w + tabGap
end
if bugTab then headerTab(bugTab) end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s)
@@ -2009,6 +1964,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
cy = cy + buildModScopeRow(imp, x, cy, w, m)
if #mods == 0 then
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
return (cy - y) + math.floor(110 * m.s)
end
@@ -2049,139 +2005,159 @@ local function buildModsPanel(imp, x, y, w, availH, m)
end
-- A mod row is a fixed height: its details first, then a dedicated second
-- line of per-game checkboxes. Fixed row heights are what make the
-- cull below plain arithmetic.
-- line of per-game checkboxes. Fixed because a page of uniform rows is
-- what lets perPage come from the viewport.
local togH = math.floor(26 * m.s)
local gamesLabel = Strings("Enable for:")
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_MODS_PER_PAGE, 20)
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
setPage(imp, "mods", cur)
local listTop = cy
local shown = math.max(0, last - first + 1)
local contentH = shown * rowH + math.max(0, shown - 1) * gap
local scrollMax = math.max(0, contentH - listH)
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
local lr = imp._modListRect
if not lr then lr = {}; imp._modListRect = lr end
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
imp._modScrollMax = scrollMax
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
and Kit.hit(x, listTop, w, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
local wheelPage = Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)
if wheelPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", wheelPage)
end
imp.modScroll = scroll
-- One continuous list: every row is laid out, the region scroll moves
-- through all of it, and only rows inside the region's viewport draw --
-- so the per-frame cost stays bounded by the window, not the list.
local view = imp._tabRegionRect
local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH)
for i = 1, #mods do
Kit.pushClip(x, listTop, w, listH)
for i = first, last do
local mod = mods[i]
local ry = listTop + (i - 1) * (rowH + gap)
if ry + rowH >= viewTop and ry <= viewBot then
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
local ry = listTop + (i - first) * (rowH + gap) - scroll
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
end
tx = tx + togH + togGap
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
end
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
tx = tx + togH + togGap
end
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue)
end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue)
end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- version + status + update state
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- version + status + update state
local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow)
elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
-- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance)
if info and info.downloads then
local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
end
segLine("small", segs, px, ly, textW)
elseif (mod.description or "") ~= "" then
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
px, ly, PAL.detail)
end
end
Kit.popClip()
local contentH = #mods * rowH + (#mods - 1) * gap
return (listTop + contentH + gap) - y
local pagerY = listTop + listH + gap
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
if newPage ~= cur then imp.modScroll = 0 end
setPage(imp, "mods", newPage)
return pagerY + newPagerH - y
end
-- ---------------------------------------------------------- find mods panel
@@ -2913,8 +2889,6 @@ local function buildConfirmModal(imp, m)
imp:_setAllMods(true, true)
elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true)
elseif c.kind == "largeImport" then
imp:_importRequiredSource(c.modId, c.importId, c.source, true)
else
imp:_toggleMod(c.id, true, c.version)
end
@@ -4877,7 +4851,8 @@ function LauncherView.draw(imp)
Kit.blockClicks = modalUp(imp)
local step = Kit.scrollStep(m.s)
do
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
if not nested then
local rect = imp._tabRegionRect
if rect then
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
@@ -4885,7 +4860,8 @@ function LauncherView.draw(imp)
end
end
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
and not Kit.blockClicks then
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
if moved ~= scroll then
scroll = moved
@@ -4893,7 +4869,7 @@ function LauncherView.draw(imp)
end
end
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
if (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
and tabScrollMax(imp) > 0 then
local was = tabScrollAt(imp)
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
+27 -198
View File
@@ -4,7 +4,6 @@
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
local bit = require("bit")
local GameVersion = require("src.core.GameVersion")
local ImageWriter = require("src.import.ImageWriter")
local LuaWriter = require("src.import.LuaWriter")
local Rom = require("src.import.Rom")
@@ -169,10 +168,6 @@ function RomExtractorGen2.new(romData, manifest, progress)
symbols = manifest.symbols,
progress = progress,
stage = 0,
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
-- them is not (gfx/misc.asm:9-20 vs :46-57).
edition = GameVersion.forSha1(manifest.romSha1) == "silver"
and "silver" or "gold",
}, RomExtractorGen2)
end
@@ -1646,40 +1641,22 @@ function RomExtractorGen2:extractTitle()
return 3
end
local silver = self.edition == "silver"
-- pret gfx/title/title_bg_gold.pal / title_bg_silver.pal (5 BG pals);
-- GSTitleBGPals is the edition-selected include (engine/gfx/color.asm:1234).
local BG_PALS = silver and {
{ { 31, 31, 31 }, { 0, 12, 15 }, { 4, 8, 21 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 15, 17, 15 }, { 4, 8, 21 }, { 0, 0, 17 } },
{ { 31, 31, 31 }, { 31, 0, 0 }, { 4, 8, 21 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 24, 23, 25 }, { 4, 8, 21 }, { 8, 8, 9 } },
{ { 31, 31, 31 }, { 5, 10, 11 }, { 0, 12, 15 }, { 0, 0, 0 } },
} or {
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
local BG_PALS = {
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
}
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
-- title_fg.pal: pal 0 = Ho-Oh silhouette (shades 1-3 are the same brown);
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
local OBJ_HOOH = {
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
}
local OBJ_TRAIL = {
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
}
-- engine/movie/title.asm:134-141 + CopyPals (home/palettes.asm:190):
-- DmgToCgbObjPal0 %11100000 makes Silver's OBJ pal 0 {c0, c0, c2, c3}.
if silver then
OBJ_HOOH = {
OBJ_HOOH[1], OBJ_HOOH[1], OBJ_HOOH[3], OBJ_HOOH[4],
}
-- .OAMData_GSTitleTrail is attribute 0, not OAM_PAL1 (oam.asm:834-837).
OBJ_TRAIL = OBJ_HOOH
end
local function palColor(pal, shade)
local c = pal[shade + 1] or pal[4]
@@ -1694,9 +1671,8 @@ function RomExtractorGen2:extractTitle()
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
local DMG_BGP = { 0, 2, 1, 3 }
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
local DMG_OBP0 = silver and { 0, 0, 3, 3 } or { 3, 3, 3, 3 }
local DMG_OBP1 = silver and { 0, 0, 3, 3 } or { 0, 2, 3, 3 }
local DMG_OBP0 = { 3, 3, 3, 3 }
local DMG_OBP1 = { 0, 2, 3, 3 }
-- ImageWriter's four hardware shades, by shade number.
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
@@ -1800,28 +1776,6 @@ function RomExtractorGen2:extractTitle()
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
-- .OAMData_GSIntroLugia1 / 2 (data/sprite_anims/oam.asm:736-773); the
-- spriteanimoam vtile offset is added per frame (core.asm:224-227).
local LUGIA_1 = {
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
{ 3, -1, 0, 0, 0x1c }, { 4, -1, 0, 0, 0x1e },
}
local LUGIA_2 = {
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
{ 3, -2, 0, 0, 0x1c }, { 4, -2, 0, 0, 0x1e },
}
local HOOH_FRAMES = {
{ -- 1
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
@@ -1869,36 +1823,22 @@ function RomExtractorGen2:extractTitle()
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
},
}
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
local LUGIA_FRAMES = {
{ LUGIA_1, 0x00 }, { LUGIA_1, 0x20 }, { LUGIA_2, 0x40 },
{ LUGIA_2, 0x60 }, { LUGIA_1, 0x00 },
}
-- Frameset_GSIntroHoOhLugia (data/sprite_anims/framesets.asm:376-396):
-- Gold 1,2,3,4,3,5; Silver 2,1,2,3,3,4,4,3,2 on a faster clock.
local HOOH_SEQUENCE = silver and {
{ 2, 3 }, { 1, 7 }, { 2, 7 }, { 3, 7 }, { 3, 7 },
{ 4, 7 }, { 4, 7 }, { 3, 7 }, { 2, 3 },
} or {
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations.
local HOOH_SEQUENCE = {
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
}
local hoohPaths, hoohGrayPaths = {}, {}
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
local originX, originY = silver and 40 or 32, 24
local poseW = silver and 80 or 64
local frames = silver and LUGIA_FRAMES or HOOH_FRAMES
for fi, entry in ipairs(frames) do
local oam = silver and entry[1] or entry
local base = silver and entry[2] or 0
local originX, originY = 32, 24
for fi, oam in ipairs(HOOH_FRAMES) do
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
-- wherever it falls, so a gap enclosed by the bird shows the sky through
-- exactly like one outside it, and there is no matte to flood-fill.
local pose = ImageWriter.blank(poseW, 64, 0, 0, 0, 0)
local pose = ImageWriter.blank(64, 64, 0, 0, 0, 0)
for _, spr in ipairs(oam) do
local px = originX + spr[1] * 8 + spr[3]
local py = originY + spr[2] * 8 + spr[4]
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
blitSprite(pose, hoohTiles[spr[5] + 1], px, py)
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8)
end
local tinted = colorize(pose, function() return OBJ_HOOH end)
local rel = ("title/hooh_%d.png"):format(fi)
@@ -1915,20 +1855,14 @@ function RomExtractorGen2:extractTitle()
end
self:tick("Title screen", 3, 5)
-- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal.
local trailSym = self:symbol("TitleScreenGFX3")
local trailTileCount = silver and 4 or 8
local trailRaw =
self.rom:bytes(trailSym.bank, trailSym.address, trailTileCount * 16)
local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16)
local trailTiles = tilesFrom2bpp(trailRaw, true)
local trail = ImageWriter.blank(silver and 16 or 8, 16, 0, 0, 0, 0)
local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0)
blitSprite(trail, trailTiles[1], 0, 0)
blitSprite(trail, trailTiles[2], 0, 8)
if silver then
blitSprite(trail, trailTiles[3], 8, 0)
blitSprite(trail, trailTiles[4], 8, 8)
end
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
self:save(trailTint, "title/trail.png")
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
@@ -1974,11 +1908,8 @@ function RomExtractorGen2:extractTitle()
cloudsGray = "assets/generated/title/clouds_gray.png",
hoohFramesGray = hoohGrayPaths,
trailGray = "assets/generated/title/trail_gray.png",
-- Frameset_GSIntroHoOhLugia, frame index 1-based + duration frames.
-- Frameset_GSIntroHoOhLugia (Gold), frame index 1-based + duration frames.
hoohSequence = HOOH_SEQUENCE,
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
hoohBobAmplitude = silver and 8 or 2,
hoohBobStep = silver and -1 or 1,
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
-- fallen into both, which is what put Ho-Oh off-centre:
-- * ldpixel's own comment calls its first tile argument the X one and is
@@ -1988,45 +1919,19 @@ function RomExtractorGen2:extractTitle()
-- the cursor two rows up on the box screen. So this is x 88, y 96.
-- * those are OAM coordinates, which are biased; a drawn object sits at
-- (x - 8, y - 16) on screen.
-- The pose canvas holds its own origin, so the sheet's corner is
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
hoohX = 80 - originX,
hoohY = 80 - originY,
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands
-- centred on the screen, 48 to 112.
hoohX = 48,
hoohY = 56,
trail = "assets/generated/title/trail.png",
copyright = "assets/generated/title/copyright.png",
copyrightSplash = "assets/generated/title/copyright_splash.png",
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
-- right. Silver does the same decrement every frame.
cloudScrollEvery = silver and 1 or 8,
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every
-- 8 vblanks, so the strip slides 1px right. Silver does the same
-- decrement every frame.
cloudScrollEvery = 8,
cloudY = 88,
-- BG pal 0 colour 2: the sky the widescreen bands have to match.
sky = {
BG_PALS[1][3][1] / 31, BG_PALS[1][3][2] / 31, BG_PALS[1][3][3] / 31,
},
-- The fill under the cloud/wave band: Gold's cloud field is BG pal 0
-- colour 0 (white), Silver's sea floor is colour 3 (black).
below = silver and {
BG_PALS[1][4][1] / 31, BG_PALS[1][4][2] / 31, BG_PALS[1][4][3] / 31,
} or {
BG_PALS[1][1][1] / 31, BG_PALS[1][1][2] / 31, BG_PALS[1][1][3] / 31,
},
-- UpdateTitleTrailSprite (engine/menus/intro_menu.asm:1069-1124). Silver's
-- `depixel 15, 11, 4, 0` is OAM (88, 124), less the bias and the (-16, -8)
-- corner .OAMData_GSTitleTrail draws from.
trailMode = silver and "silver" or "gold",
trailSpawns = silver and { { 72, 100 } } or {
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
{ 120, 88 }, { 88, 88 },
},
trailSpawnEvery = 4,
trailStepX = 4,
trailStepY = silver and 0 or 1,
-- AnimSeq_GSTitleTrail (functions.asm:784-813) with wIntroSceneTimer 0.
trailBobAmplitude = silver and 3 or 2,
trailPhaseStep = silver and 7 or 3,
trailPhase = silver and 0 or nil,
}
self:write("title", data)
return data
@@ -5231,82 +5136,6 @@ function RomExtractorGen2:extractMenuGfx()
end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
-- Goldenrod Game Corner: Slot Machine graphics assets
if self.symbols["Slots1LZ"] then
local raw1 = self:decompressLz3Symbol("Slots1LZ")
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
end
if self.symbols["Slots2LZ"] then
local raw2 = self:decompressLz3Symbol("Slots2LZ")
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
for i = 1, math.min(64, #raw2) do
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
end
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
end
if self.symbols["Slots3LZ"] then
local raw3 = self:decompressLz3Symbol("Slots3LZ")
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
-- Y=0: Golem 1 (Standing, 24x32)
-- Y=32: Golem 2 (Ball, 24x32)
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
-- Y=96: Chansey 2 (Step 2, 24x32)
-- Y=128: Chansey 3 (Step 3, 24x32)
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
-- Y=224: Egg (8x16 at X=0)
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
end
if self.symbols["SlotsTilemap"] then
local symbol = self:symbol("SlotsTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
self:save(tm, "slots/gold_slots.tilemap")
end
-- Goldenrod Game Corner: Card Flip graphics assets
if self.symbols["CardFlipLZ01"] then
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
end
if self.symbols["CardFlipLZ02"] then
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
end
if self.symbols["CardFlipLZ03"] then
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
end
if self.symbols["CardFlipOnButtonGFX"] then
local symbol = self:symbol("CardFlipOnButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
end
if self.symbols["CardFlipOffButtonGFX"] then
local symbol = self:symbol("CardFlipOffButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
end
if self.symbols["CardFlipTilemap"] then
local symbol = self:symbol("CardFlipTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
self:save(tm, "card_flip/card_flip.tilemap")
end
out.slots = {
sheet1 = "assets/generated/slots/gold_slots_1.png",
sheet2 = "assets/generated/slots/gold_slots_2.png",
sheet3 = "assets/generated/slots/gold_slots_3.png",
tilemap = "assets/generated/slots/gold_slots.tilemap",
}
out.cardFlip = {
sheet1 = "assets/generated/card_flip/card_flip_1.png",
sheet2 = "assets/generated/card_flip/card_flip_2.png",
sheet3 = "assets/generated/card_flip/card_flip_3.png",
on = "assets/generated/card_flip/on.png",
off = "assets/generated/card_flip/off.png",
tilemap = "assets/generated/card_flip/card_flip.tilemap",
}
self:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1)
return out
+15 -42
View File
@@ -142,8 +142,6 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
"assets/generated/audio/programs.bin",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game.
@@ -337,7 +335,7 @@ function RomImporter.syncAndroidShortcuts(activeVersion)
return false
end
local allVersions = GameVersion.ORDER
local allVersions = { "red", "blue", "yellow", "gold" }
local ready = {}
local seen = {}
@@ -1363,10 +1361,11 @@ function RomImporter.new(onComplete, opts)
saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modNotice is the last install/delete result { ok, text }.
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them.
mods = nil, modNotice = nil, issueNotice = nil,
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
@@ -1423,8 +1422,7 @@ function RomImporter.new(onComplete, opts)
self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version)
self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
and ".gbc" or ".gb")
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab()
@@ -1689,7 +1687,7 @@ function RomImporter:startData(data, displayName)
end
if not isAcceptedRomSize(#data) then
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
.. "2 MiB Game Boy Color ROM (Gold); this file is %.2f MiB.")
:format(#data / 1024 / 1024))
return
end
@@ -1697,8 +1695,7 @@ function RomImporter:startData(data, displayName)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
.. "\"fixed\" dumps "
.. "Red, Blue, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
return
end
@@ -1775,7 +1772,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = GameVersion.generation(version) == 2
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest,
@@ -2060,7 +2057,7 @@ function RomImporter:_importRequiredData(modId, importId, data)
return nil
end
function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
function RomImporter:_importRequiredSource(modId, importId, source)
local manifest = requiredManifest(self, modId)
local spec = manifest and requiredSpec(manifest, importId)
if not spec then
@@ -2068,31 +2065,14 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
self.modNotice = nil
return nil
end
local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source)
local sizeErr = RequiredImports.sizeError(spec, size, false)
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr)
self.modNotice = nil
return nil
end
if not confirmed and type(size) == "number"
and size > RequiredImports.LARGE_WARN_BYTES then
self._modConfirm = {
kind = "largeImport",
modId = modId, importId = importId, source = source,
title = Strings("Large import"),
lines = {
Strings("This is a large import (%s).",
RequiredImports.sizeLabel(size)),
Strings("Please ensure you have enough space on your"),
Strings("device before doing this."),
},
yesLabel = Strings("I understand"),
}
return nil
end
local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end
if not data then
@@ -2135,13 +2115,8 @@ function RomImporter:chooseRequiredImport(modId, importId)
if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name
local info = love.filesystem.getInfo(path, "file")
local RequiredImports = require("src.mods.RequiredImports")
local sizeErr = info
and RequiredImports.sizeError(spec, info.size, false)
if info and not sizeErr
and info.size > RequiredImports.LARGE_WARN_BYTES then
return self:_importRequiredSource(modId, importId, path)
end
local sizeErr = info and require("src.mods.RequiredImports")
.sizeError(spec, info.size, false)
local data = not sizeErr and love.filesystem.read(path) or nil
if data and self:_importRequiredData(modId, importId, data) then return end
if sizeErr then lastError = sizeErr
@@ -2782,8 +2757,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "silver",
"mods", "find", "skins", "bug" }
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -3120,10 +3094,8 @@ end
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
-- the overlay hosts. Touch move/press/release must still reach
-- FlexLove.touch* or scroll containers never drag on phones.
function RomImporter:mousepressed(x, y, button)
function RomImporter:mousepressed()
self._padCursorActive = false
if button ~= 1 or not self._flex then return end
require("src.import.LauncherView").mousepressed(self, x, y)
end
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
@@ -3151,6 +3123,7 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput()
-- 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
+2 -2
View File
@@ -195,8 +195,8 @@ local function parseImports(value, field, required)
if value == nil then return end
assert(type(value) == "number" and value > 0 and value % 1 == 0,
field .. " " .. label .. " must be a positive integer")
assert(value <= 2 * 1024 * 1024 * 1024,
field .. " " .. label .. " exceeds the 2 GiB hard limit")
assert(value <= 128 * 1024 * 1024,
field .. " " .. label .. " exceeds the 128 MiB hard limit")
end
validateSize(size, "size")
validateSize(maxSize, "max_size")
+1 -7
View File
@@ -23,17 +23,11 @@ local function isRequired(spec)
end
RequiredImports.specs = allSpecs
RequiredImports.MAX_BYTES = 2 * 1024 * 1024 * 1024
-- Past this, the launcher interposes a free-space warning before importing.
RequiredImports.LARGE_WARN_BYTES = 128 * 1024 * 1024
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
local function sizeLabel(bytes)
if bytes >= 1024 * 1024 * 1024 then
return ("%.1f GiB"):format(bytes / (1024 * 1024 * 1024))
end
return ("%.1f MiB"):format(bytes / (1024 * 1024))
end
RequiredImports.sizeLabel = sizeLabel
-- Check size before a caller reads an external or stored file into one large
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
+4 -14
View File
@@ -15,7 +15,6 @@ local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
local ScreenPosition = require("src.core.ScreenPosition")
local Playfield = require("src.render.Playfield")
local Renderer = {}
@@ -95,11 +94,6 @@ local function displayMetrics()
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
end
local function positionLift(ph, contentPx, dpiY, cut)
if cut then return 0 end
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
end
function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
@@ -275,7 +269,7 @@ end
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize()
local _, _, pw, ph, _, dpiY, _, _, cut, grow = displayMetrics()
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
-- WHOLE display, so letterbox voids become more map instead of black bars.
-- That is why the lock appeared to do nothing in the overworld: it shrank
@@ -299,9 +293,6 @@ function Renderer:worldViewSize()
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
local _, uih = self:uiSize()
local lift = positionLift(ph, uih * self:fitScale(), dpiY, cut)
if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end
if Tilt.active() then
local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -783,9 +774,8 @@ function Renderer:frameRects()
r.uiw, r.uih = uiw, uih
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
r.lift = positionLift(ph, uih * Sp, dpiY, cut)
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
r.oy = (vy + math.floor((ph - uih * Sp) / 2) - r.lift) / dpiY
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -805,7 +795,7 @@ function Renderer:frameRects()
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
r.uoy = (vy + math.max(0, math.floor((ph - uih * Up) / 2) - r.lift)) / dpiY
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
return r
end
@@ -1012,7 +1002,7 @@ function Renderer:endFrame(zones, worldZones)
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
+1 -4
View File
@@ -228,10 +228,7 @@ SaveConvert.mergeDefaults = mergeDefaults
-- codec exists yet. Both directions answer with a plain message the
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
-- table through Gen 1 offsets and surfacing a codec traceback.
local GEN2_SAV_UNSUPPORTED = {
gold = "Pokemon Gold",
silver = "Pokemon Silver",
}
local GEN2_SAV_UNSUPPORTED = { gold = "Pokemon Gold" }
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
+1 -1
View File
@@ -5,7 +5,7 @@ SyncMods.REV = 1
local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
return { "red", "blue", "yellow", "gold", "silver" }
return { "red", "blue", "yellow", "gold" }
end
local function defaultDeps()
-11
View File
@@ -21,7 +21,6 @@ local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local Orientation = require("src.core.Orientation")
local FaithfulRes = require("src.core.FaithfulRes")
local ScreenPosition = require("src.core.ScreenPosition")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
@@ -444,16 +443,6 @@ local function buildRows(game)
FaithfulRes.apply(o.faithfulRes)
return true
end },
{ id = "screenPos", label = Strings("SCREEN POS"),
value = function(g)
return Strings(ScreenPosition.label(g.save.options.screenPos))
end,
step = function(g, dir)
local o = g.save.options
o.screenPos = ScreenPosition.cycle(o.screenPos, dir)
ScreenPosition.setMode(o.screenPos)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
+197 -543
View File
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -73,10 +73,7 @@ function Editor.load(opts)
}
local optsTbl = SaveData.loadOptions()
local applied = optsTbl
local GameVersion = require("src.core.GameVersion")
local gen2 = GameVersion.VERSIONS[opts.version]
and GameVersion.generation(opts.version) == 2
if gen2 then
if opts.version == "gold" then
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
applied = {
touchControls = gold.touchControls,
@@ -154,9 +151,7 @@ local function persist()
skin = cfg.skin,
layouts = cfg.layouts,
}
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS[Editor.version]
and GameVersion.generation(Editor.version) == 2 then
if Editor.version == "gold" then
opts.gold = type(opts.gold) == "table" and opts.gold or {}
opts.gold.touchControls = block
else
@@ -574,7 +569,7 @@ function Editor.new(game)
local state = { game = game, isOpaque = true }
Editor.hostPoll = true
Editor.load({
version = require("src.core.GameVersion").get(),
version = "gold",
hostPoll = true,
onClose = function()
Editor.hostPoll = false
+1 -22
View File
@@ -44,12 +44,6 @@ local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
function BattleState:moveGridNavigation()
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
return Runtime.call("battle.move_grid_navigation", function() return false end,
self) == true
end
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
-- positive value means "hold until A/B"; the cart never times these out, so
-- the victory jingle can keep looping through the post-win prompts.
@@ -2050,22 +2044,7 @@ function BattleState:update(_dt)
if self.phase == "moves" then
local moves = self:playerMoves()
local grid
if self:moveGridNavigation() then
local index, count = self.moveIndex, #moves
if input:wasPressed("left") or input:wasPressed("right") then
local other = math.floor((index - 1) / 2) * 2
+ (1 - (index - 1) % 2) + 1
grid = other <= count and other or index
elseif input:wasPressed("up") or input:wasPressed("down") then
local other = (1 - math.floor((index - 1) / 2)) * 2
+ (index - 1) % 2 + 1
grid = other <= count and other or index
end
end
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
if input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
+2 -2
View File
@@ -619,8 +619,8 @@ function BattleTransition:grid(w, h)
scale = math.max(1, math.floor(math.min(w / 160, h / 144)))
end
local size = 8 * scale
local Chrome = require("src.ui.gen2.Chrome")
local ox, oy = Chrome.fitOrigin(w, h, scale)
local ox = math.floor((w - 160 * scale) / 2)
local oy = math.floor((h - 144 * scale) / 2)
return size, ox, oy
end
+54 -385
View File
@@ -303,12 +303,12 @@ local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2
-- data/text/common_3.asm; none of these are in the cache's text.lua, because no
-- script bytecode the extractor walks points at them.
CardFlip.TEXTS = {
playWithThree = { "Play with", "3 coins?" },
notEnough = { "Not enough", "coins." },
chooseACard = { "Choose a", "card." },
placeYourBet = { "Place", "your bet" },
playAgain = { "Play", "again?" },
shuffled = { "The cards", "shuffled." },
playWithThree = { "Play with three", "coins?" },
notEnough = { "Not enough coins…" },
chooseACard = { "Choose a card." },
placeYourBet = { "Place your bet." },
playAgain = { "Want to play", "again?" },
shuffled = { "The cards have", "been shuffled." },
yeah = { "Yeah!" },
darn = { "Darn…" },
}
@@ -401,34 +401,14 @@ function CardFlip:enterBet()
self.lines = CardFlip.TEXTS.placeYourBet
end
-- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence
-- .CheckTheCard: the dealt card is turned face up and marked on the discard
-- pile, which is what blanks its cell on the odds board.
function CardFlip:flip()
local card = CardFlip.dealt(self.deck, self.played, self.which)
self.faceUp = card
self.discarded[card] = true
local won = CardFlip.payout(self.cursorX, self.cursorY, card)
self.payoutLeft = won
self.payoutTick = 0
self.phase = "flipping"
self.flipTimer = 0
self.targetCard = card
end
function CardFlip:updateFlipping()
self.flipTimer = (self.flipTimer or 0) + 1
if self.flipTimer == 4 then
self:sfx(SFX_CHOOSE)
elseif self.flipTimer >= 12 then
if (self.payoutLeft or 0) > 0 then
self.phase = "payout"
self.lines = CardFlip.TEXTS.yeah
self:sfx(SFX_WIN)
else
self.phase = "result"
self.lines = CardFlip.TEXTS.darn
self:sfx(SFX_WRONG)
end
end
self:sfx(SFX_CHOOSE)
self:tabulate()
end
function CardFlip:tabulate()
@@ -492,21 +472,7 @@ function CardFlip:quit()
if self.onClose then self.onClose() end
end
function CardFlip:update(dt)
-- Support both fixed-tick 60Hz loop and variable dt accumulator
if dt and dt > 0 then
self.dtAccum = (self.dtAccum or 0) + dt
local TICK = 1 / 60
while self.dtAccum >= TICK do
self.dtAccum = self.dtAccum - TICK
self:tick()
end
else
self:tick()
end
end
function CardFlip:tick()
function CardFlip:update(_dt)
local input = self.game and self.game.input
if not input then return end
local phase = self.phase
@@ -573,11 +539,6 @@ function CardFlip:tick()
return
end
if phase == "flipping" then
self:updateFlipping()
return
end
if phase == "payout" then
self:updatePayout()
return
@@ -591,370 +552,79 @@ function CardFlip:tick()
end
-- ------------------------------------------------------------------- draw
--
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
local TileSheet = require("src.ui.gen2.TileSheet")
local GbcPalette = require("src.render.GbcPalette")
local CARDFLIP_PALS = {
bg = {
[0] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 0: Base / Table green
[1] = { { 255, 255, 255 }, { 239, 206, 0 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 1: Pikachu (Yellow)
[2] = { { 255, 255, 255 }, { 255, 107, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 2: Jigglypuff (Pink)
[3] = { { 255, 255, 255 }, { 66, 140, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 3: Poliwag (Blue)
[4] = { { 255, 255, 255 }, { 66, 255, 66 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 4: Oddish (Green)
[5] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 5: Level header
[6] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 6: Border
[7] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 7: Textbox
},
obj = {
[0] = { { 255, 255, 255 }, { 248, 56, 40 }, { 248, 56, 40 }, { 248, 56, 40 } }, -- Authentic GBC Red OAM
}
}
local TILEMAP = nil
local function getCardFlipTilemap()
if TILEMAP == nil then
local path = "assets/generated/card_flip/card_flip.tilemap"
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
TILEMAP = {}
for i = 1, #data do
TILEMAP[i] = string.byte(data, i)
end
else
TILEMAP = false
end
end
return TILEMAP or nil
end
function CardFlip:sheets()
if self.sheet1 == nil then
self.sheet1 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_1.png", wide = 16, firstTile = 0 })
self.sheet2 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_2.png", wide = 3, firstTile = 0 })
self.sheet3 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_3.png", wide = 1, firstTile = 0 })
self.sheetOn = TileSheet.new({ path = "assets/generated/card_flip/on.png", wide = 1, firstTile = 0 })
self.sheetOff = TileSheet.new({ path = "assets/generated/card_flip/off.png", wide = 1, firstTile = 0 })
end
return self.sheet1, self.sheet2, self.sheet3, self.sheetOn, self.sheetOff
end
function CardFlip:cursorQuads()
if self.s3Image == nil then
local _, _, s3 = self:sheets()
self.s3Image = s3:image()
if self.s3Image and love and love.graphics then
local G = love.graphics
self.quadCorner = G.newQuad(0, 0, 8, 8, 8, 56) -- Tile 0: 1px corner
self.quadVEdge = G.newQuad(0, 8, 8, 8, 8, 56) -- Tile 1: 1px vertical edge
self.quadHEdge = G.newQuad(0, 16, 8, 8, 8, 56) -- Tile 2: 1px horizontal edge
end
end
return self.s3Image, self.quadCorner, self.quadVEdge, self.quadHEdge
end
local HEADER_TILE_MAP = {
[0x3E] = 0, [0x3F] = 1,
[0x40] = 3, [0x41] = 4,
[0x42] = 6, [0x43] = 7,
[0x44] = 9, [0x45] = 10,
[0x46] = 12, [0x47] = 13,
[0x48] = 15, [0x49] = 16,
[0x4A] = 18, [0x4B] = 19,
[0x4C] = 21, [0x4D] = 22,
}
-- Draw an authentic GBC red cursor bounding frame using OAM sprite tiles.
-- On real GBC hardware OAM color 0 is always transparent, so only the 1px
-- dark edges of tiles 0-2 are visible; the board shows through the middle.
-- We replicate this by drawing in "multiply" blend mode: white (255,255,255)
-- pixels multiply to the board colour unchanged, black (0,0,0) pixels tinted
-- to GBC red draw the border, and nothing fills the interior.
function CardFlip:drawOamBox(px, py, w, h)
local img, qCorner, qVEdge, qHEdge = self:cursorQuads()
local G = love.graphics
if not (img and qCorner and qVEdge and qHEdge) then
-- Fallback: plain 1px red outline
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
G.rectangle("fill", px, py, w, 1)
G.rectangle("fill", px, py + h - 1, w, 1)
G.rectangle("fill", px, py, 1, h)
G.rectangle("fill", px + w - 1, py, 1, h)
G.setColor(1, 1, 1, 1)
return
end
local prevBlend, prevAlpha = G.getBlendMode()
G.setBlendMode("multiply", "premultiplied")
-- Tint: black (0) → GBC red, white (255) → white (passthrough = transparent)
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
-- 4 Corners
G.draw(img, qCorner, px, py, 0, 1, 1)
G.draw(img, qCorner, px + w, py, 0, -1, 1)
G.draw(img, qCorner, px, py + h, 0, 1, -1)
G.draw(img, qCorner, px + w, py + h, 0, -1, -1)
-- Top & Bottom horizontal edges
if w > 16 then
for x = px + 8, px + w - 16, 8 do
G.draw(img, qHEdge, x, py, 0, 1, 1)
G.draw(img, qHEdge, x, py + h, 0, 1, -1)
end
end
-- Left & Right vertical edges
if h > 16 then
for y = py + 8, py + h - 16, 8 do
G.draw(img, qVEdge, px, y, 0, 1, 1)
G.draw(img, qVEdge, px + w, y, 0, -1, 1)
end
end
G.setBlendMode(prevBlend, prevAlpha)
G.setColor(1, 1, 1, 1)
end
function CardFlip:drawBoard()
local s1, s2, s3, sOn, sOff = self:sheets()
local tm = getCardFlipTilemap()
local G = love.graphics
-- Green background fill
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
G.rectangle("fill", 0, 0, 160, 144)
if not tm or not s2:available() then
-- Fallback simple board
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end
for x = 2, 5 do
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
end
for y = 2, 7 do
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local row = 3 + pair * 3 + (isBottom and 1 or 0)
Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do
local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", MON_COL[x], row)
end
end
return
-- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand
-- being played and every one before it stays off.
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
end
-- Draw the 11x12 board tilemap at (9, 0)
for ty = 0, 11 do
for tx = 0, 10 do
local idx = ty * 11 + tx + 1
local tileId = tm[idx]
local screenX = 9 + tx
local screenY = ty
-- Attribute palette
local pal = 0
if screenY >= 1 and screenY <= 2 then
if screenX == 12 or screenX == 13 then pal = 1 -- Pikachu
elseif screenX == 14 or screenX == 15 then pal = 2 -- Jigglypuff
elseif screenX == 16 or screenX == 17 then pal = 3 -- Poliwag
elseif screenX == 18 or screenX == 19 then pal = 4 -- Oddish
end
elseif screenX == 9 then
pal = 1 -- Lights
end
local colors = CARDFLIP_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
s3.palette = colors
if screenX == 9 then
-- Column 9: Light buttons
if screenY == self.played then
sOn.palette = colors
sOn:draw(0, screenX, screenY)
else
sOff.palette = colors
sOff:draw(0, screenX, screenY)
end
elseif tileId >= 0x3e then
-- Board graphics from card_flip_2 (using accurate 2x2 header tile mapping)
local mappedId = HEADER_TILE_MAP[tileId] or (tileId - 0x3e)
s2:draw(mappedId, screenX, screenY)
elseif tileId < 0x3e then
-- Graphics from card_flip_1
s1:draw(tileId, screenX, screenY)
end
end
for x = 2, 5 do
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
-- The pair headers sit above the two Pokemon they cover.
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
end
-- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px)
for y = 2, 7 do
local level = y - 2
local pair = math.floor(level / 2)
local isBottom = (level % 2 == 1)
local py = 24 + pair * 24 + (isBottom and 12 or 0)
local row = LEVEL_ROW[y]
Chrome.print(tostring(y - 1), LEVEL_COL, row)
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
for x = 2, 5 do
local mon = x - 2
local card = CardFlip.card(level, mon)
if self.discarded[card] then
-- Discarded cover over full 16x12 stacked card cell
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
G.rectangle("fill", MON_COL[x] * 8, py, 16, 12)
end
-- A still-in-the-deck cell stands in for the card back until the art
-- lands. It cannot be '#': that is charmap.asm $54, the text command
-- that places "POKé", not a one-tile glyph.
local card = CardFlip.card(y - 2, x - 2)
Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row)
end
end
end
function CardFlip:cursorBounds()
function CardFlip:cursorCell()
local x, y = self.cursorX, self.cursorY
if y == 0 then
-- Pokemon Pair: spans 4 columns (32px), 1 row (8px)
local px = (MON_COL[x] or 12) * 8
local py = MON_PAIR_ROW * 8
return px, py, 32, 8
elseif y == 1 then
-- Single Pokemon: 2x2 tiles (16x16 px)
local px = (MON_COL[x] or 12) * 8
local py = MON_ROW * 8
return px, py, 16, 16
elseif x == 0 then
-- Level Pair: 1 column (8px), spans 24px across the full pair
local pair = math.floor((y - 2) / 2)
local px = LEVEL_PAIR_COL * 8
local py = 24 + pair * 24
return px, py, 8, 24
elseif x == 1 then
-- Single Level: 1 column (8px), 12px tall for each stacked card
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local px = LEVEL_COL * 8
local py = 24 + pair * 24 + (isBottom and 12 or 0)
return px, py, 8, 12
else
-- Exact Card: 2 columns (16px), 12px tall for each stacked card
local pair = math.floor((y - 2) / 2)
local isBottom = ((y - 2) % 2 == 1)
local px = (MON_COL[x] or 12) * 8
local py = 24 + pair * 24 + (isBottom and 12 or 0)
return px, py, 16, 12
end
local col
if x == 0 then col = LEVEL_PAIR_COL
elseif x == 1 then col = LEVEL_COL
else col = MON_COL[x] end
local row
if y == 0 then row = MON_PAIR_ROW
elseif y == 1 then row = MON_ROW
else row = LEVEL_ROW[y] end
return col, row
end
local FACE_DOWN_TILES = {
{ 0x08, 0x09, 0x09, 0x09, 0x0a },
{ 0x0b, 0x28, 0x2b, 0x28, 0x0c },
{ 0x0b, 0x2c, 0x2d, 0x2e, 0x0c },
{ 0x0b, 0x2f, 0x30, 0x31, 0x0c },
{ 0x0b, 0x32, 0x33, 0x34, 0x0c },
{ 0x0d, 0x0e, 0x0e, 0x0e, 0x0f },
}
local FACE_UP_TILES = {
{ 0x18, 0x19, 0x19, 0x19, 0x1a },
{ 0x1b, 0x35, 0x28, 0x28, 0x1c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
{ 0x1d, 0x1e, 0x1e, 0x1e, 0x1f },
}
local MON_ANCHORS = {
[0] = 24, -- Pikachu (tiles 24..32 in card_flip_2)
[1] = 33, -- Jigglypuff (tiles 33..41 in card_flip_2)
[2] = 42, -- Poliwag (tiles 42..50 in card_flip_2)
[3] = 51, -- Oddish (tiles 51..59 in card_flip_2)
}
-- Draw face-down, flipping, or face-up card at (2, 0) or (2, 6)
-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the
-- 3x3 Pokepic one row further down.
function CardFlip:drawCards()
local s1, s2 = self:sheets()
for slot = 1, 2 do
local box = CARD_BOX[slot]
local bx, by = box.x * 8, box.y * 8
Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H)
local chosen = (slot - 1) == self.which
local isFlipping = (self.phase == "flipping") and chosen
local isFaceUp = (self.faceUp and chosen)
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then
local activeCard = self.faceUp or self.targetCard or 0
local lvl = CardFlip.level(activeCard) + 1
local mon = CardFlip.mon(activeCard)
local monPal = CARDFLIP_PALS.bg[mon + 1] or CARDFLIP_PALS.bg[1]
s1.palette = CARDFLIP_PALS.bg[0]
for cy = 1, 6 do
for cx = 1, 5 do
local tid = FACE_UP_TILES[cy][cx]
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
end
end
-- Level digit at (box.x + 3, box.y + 1)
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 8) then
Chrome.print(tostring(lvl), box.x + 3, box.y + 1)
-- Draw 3x3 Pokemon pic from card_flip_2 (s2) at (box.x + 1, box.y + 2)
s2.palette = monPal
local anchor = MON_ANCHORS[mon] or 24
for py = 0, 2 do
for px = 0, 2 do
local tid = anchor + py * 3 + px
s2:draw(tid, box.x + 1 + px, box.y + 2 + py)
end
end
end
else
s1.palette = CARDFLIP_PALS.bg[0]
for cy = 1, 6 do
for cx = 1, 5 do
local tid = FACE_DOWN_TILES[cy][cx]
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
end
end
if self.phase == "choose" and chosen then
-- Authentic OAM red selection box around 5x6 card (40x48 px)
self:drawOamBox(bx, by, 40, 48)
end
if self.faceUp and chosen then
Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3,
box.y + 1)
Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)],
box.x + 1, box.y + 3)
elseif self.phase == "choose" and chosen then
Chrome.cursor(box.x, box.y + 3)
end
end
end
function CardFlip:drawPanel()
Chrome.clear()
self:drawBoard()
self:drawCards()
-- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4)
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2)
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
if self.lines then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, 8, 4)
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
for i, line in ipairs(self.lines) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end
end
-- Coin box at (9, 15), 11 wide, 3 tall (interior 9x1)
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, 9, 1)
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
if self.phase == "bet" then
self.betBlink = (self.betBlink or 0) + 1
if (self.betBlink % 32) < 24 then
local px, py, w, h = self:cursorBounds()
self:drawOamBox(px, py, w, h)
end
local col, row = self:cursorCell()
Chrome.cursor(col - 1, row)
end
if self.phase == "ask" or self.phase == "again" then
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
Chrome.textbox(14, 7, 4, 3)
@@ -982,4 +652,3 @@ function CardFlip:drawWidescreen(winW, winH)
end
return CardFlip
-9
View File
@@ -66,15 +66,6 @@ function Chrome.fitOrigin(winW, winH, scale)
local x, y, w, h = playfieldRect(winW, winH)
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
- Chrome.positionLift(winW, winH, scale)
end
function Chrome.positionLift(winW, winH, scale)
local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition")
if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end
local _, _, _, h = playfieldRect(winW, winH)
return ScreenPosition.lift(h, Chrome.SCREEN_H * 8 * (scale
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
end
-- A bordered box, tile coords. Leaves the draw color black for text.
+5 -12
View File
@@ -189,18 +189,11 @@ defineString("CREDIT_END", "END")
-- STAFF and everything after it is a heading. Anything BELOW this id is a
-- person as far as ParseCredits is concerned.
-- Credits_Staff differs per edition, including the centring spaces
-- (data/credits_strings.asm:54-60).
Credits.STAFF = defineString("STAFF",
require("src.core.GameVersion").get() == "silver" and {
" #MON",
" SILVER VERSION",
" PORT STAFF",
} or {
" #MON",
" GOLD VERSION",
" PORT STAFF",
})
Credits.STAFF = defineString("STAFF", {
" #MON",
" GOLD VERSION",
" PORT STAFF",
})
defineString("DIRECTOR", " DIRECTOR")
defineString("PROGRAMMING", " PROGRAMMING")
defineString("ENGINE_DESIGN", " ENGINE DESIGN")
+2 -2
View File
@@ -147,8 +147,8 @@ function GoldSilverIntro.new(game, opts)
-- the movie's full 2335 frames over an empty screen and reads exactly
-- like "the intro does not work". Say so instead: the fix is a
-- re-import, and nothing else in the boot chain will mention it.
Logger.warn("gen2 intro: no intro.lua in the cache -- re-import "
.. "this version or the movie plays blank")
Logger.warn("gold intro: no intro.lua in the cache -- re-import Gold "
.. "or the movie plays blank")
end
self.images = {}
self.sheets = {}
+1 -1
View File
@@ -56,7 +56,7 @@ function MainMenu.new(game, opts)
self.save = opts.save
if self.save == nil and opts.hasSave ~= false then
local loaded = Save.load()
local loaded = Save.load("gold")
self.save = loaded
end
self.hasSave = opts.hasSave
+4 -12
View File
@@ -34,14 +34,8 @@ local NamePick = {}
NamePick.__index = NamePick
NamePick.isOpaque = true
-- data/player_names.asm PlayerNameArray, one half of the IF per edition.
-- data/player_names.asm PlayerNameArray, Gold's half of the IF.
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" }
local function presetsFor()
local silver = require("src.core.GameVersion").get() == "silver"
return silver and PRESETS_SILVER or PRESETS
end
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11
@@ -66,7 +60,7 @@ function NamePick.new(game, opts)
self.game = game
self.onDone = opts.onDone
self.items = { "NEW NAME" }
for _, name in ipairs(opts.presets or presetsFor()) do
for _, name in ipairs(opts.presets or PRESETS) do
self.items[#self.items + 1] = name
end
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
@@ -112,7 +106,7 @@ function NamePick:openNaming()
if name and #name > 0 then
self:choose(name)
else
self:choose(self.items[2] or presetsFor()[1])
self:choose(self.items[2] or "GOLD")
end
end,
})
@@ -152,7 +146,7 @@ function NamePick:update(_dt)
if self.fontOk then
self:openNaming()
else
self:choose(self.items[2] or presetsFor()[1])
self:choose(self.items[2] or "GOLD")
end
else
-- A preset returns through MovePlayerPicLeft, so the pic walks back
@@ -233,7 +227,5 @@ function NamePick:drawWidescreen(winW, winH)
end
NamePick.PRESETS = PRESETS
NamePick.PRESETS_SILVER = PRESETS_SILVER
NamePick.presetsFor = presetsFor
return NamePick
+4 -4
View File
@@ -72,8 +72,8 @@ end
-- Naming presets are boot config the same way Gen 1 reads them
-- (field.boot.namePresets), so a total conversion that replaces the list once
-- replaces it for both games; NamePick.presetsFor (data/player_names.asm
-- PlayerNameArray) is the running edition's fallback.
-- replaces it for both games; NamePick.PRESETS (data/player_names.asm
-- PlayerNameArray) is Gold's fallback.
local function namePresets(game, who, fallback)
local boot = game and game.data and game.data.field
and game.data.field.boot
@@ -351,9 +351,9 @@ function OakSpeech:openNamePick(step)
picColors = self.playerColors,
presets = step.presets
or namePresets(self.game, step.presetsWho or step.who or "player",
step.presetsFallback or NamePick.presetsFor()),
step.presetsFallback or NamePick.PRESETS),
onDone = function(name)
name = name or NamePick.presetsFor()[1]
name = name or "GOLD"
self.game.save.player.name = name
self.game.stack:pop() -- NamePick
self.busy = false
-9
View File
@@ -196,15 +196,6 @@ local ROWS = {
return VideoMode.normalize(options.videoMode) == "borderless"
and "FULL" or "WINDOWED"
end },
{ label = "SCREEN POS", key = "screenPos", port = true,
cycle = function(options, delta)
local ScreenPosition = require("src.core.ScreenPosition")
options.screenPos = ScreenPosition.cycle(options.screenPos, delta)
ScreenPosition.setMode(options.screenPos)
end,
text = function(options)
return require("src.core.ScreenPosition").label(options.screenPos)
end },
{ id = "touchControls", label = "TOUCH PAD", port = true,
text = function(options)
local tc = options.touchControls
+1 -1
View File
@@ -181,7 +181,7 @@ function PcMenu:beginChangeBox(index)
self.saveTimer = 0
self.saved = nil
local existed = self.saveExists
if existed == nil then existed = Save.exists() end
if existed == nil then existed = Save.exists("gold") end
self.existed = existed
end
+1 -1
View File
@@ -71,7 +71,7 @@ function SaveMenu.new(game, opts)
self.onDone = opts.onDone
self.writer = opts.writer or Save.save
local existed = opts.existed
if existed == nil then existed = Save.exists() end
if existed == nil then existed = Save.exists("gold") end
self.existed = existed
-- confirm -> overwrite (only when a file exists) -> saving -> done
self.phase = "confirm"
+80 -535
View File
@@ -219,16 +219,10 @@ local TWO_LINES = {
function SlotMachine.matchFirstTwo(bet, r1, r2)
local building = SlotMachine.NO_MATCH
local matchingSevens = false
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
if r1[line[1]] == r2[line[2]] then
building = r1[line[1]]
if building == SlotMachine.SEVEN then
matchingSevens = true
end
end
if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end
end
return building, matchingSevens
return building, building == SlotMachine.SEVEN
end
-- ------------------------------------------------------------------- bias
@@ -409,14 +403,13 @@ end
-- matching SEVENs on a line this bet buys.
function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
local strip = SlotMachine.REELS[2]
local pos = SlotMachine.advance(position)
for _ = 1, SEARCH_LIMIT do
local window = { SlotMachine.window(strip, pos) }
local window = { SlotMachine.window(strip, position) }
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
if building ~= SlotMachine.NO_MATCH and sevens then return pos end
pos = SlotMachine.advance(pos)
if building ~= SlotMachine.NO_MATCH and sevens then return position end
position = SlotMachine.advance(position)
end
return pos
return position
end
-- ------------------------------------------------------- reel 3's theatre
@@ -587,8 +580,8 @@ end
-- ------------------------------------------------------------------ layout
local COINS_X, COINS_Y = 5, 1
local PAYOUT_X, PAYOUT_Y = 11, 1
-- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14)
local REEL_X = { 5, 9, 13 }
-- REEL_X_COORD / TILE_WIDTH.
local REEL_X = { 6, 10, 14 }
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the
-- fourth symbol that only half shows.
@@ -624,7 +617,7 @@ SlotMachine.TEXTS = TEXTS
-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the
-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout.
local function linedUpLines(payout)
return { " lined up!", ("Won %d coins!"):format(payout) }
return { "lined up!", ("Won %d coins!"):format(payout) }
end
-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what
@@ -736,41 +729,30 @@ function SlotMachine:startSpin()
self.reel = 1
self.delay = 32
self.message = TEXTS.start
self.stopped = nil
self.matched = SlotMachine.NO_MATCH
self.matchingSevens = false
self.reel3Action = nil
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
for i = 1, 3 do
self.rate[i] = 4 -- ReelAction_NormalRate
self.stops[i] = nil
self.distance[i] = 0
end
self:sfx(SFX_START)
end
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
-- boundary, and the position advances whenever 16 pixels are traversed.
-- boundary, and the position advances when the distance's low nibble wraps.
function SlotMachine:spinReels()
for i = 1, 3 do
local rate = self.rate[i]
if rate > 0 then
self.distance[i] = (self.distance[i] or 0) + rate
while self.distance[i] >= 16 do
self.distance[i] = self.distance[i] - 16
self.distance[i] = (self.distance[i] + rate) % 256
if self.distance[i] % 16 == 0 then
self.positions[i] = SlotMachine.advance(self.positions[i])
-- A reel with a resting slot chosen stops the moment it reaches it.
if self.stops[i] and self.positions[i] == self.stops[i] then
self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {}
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
self.positions[i]) }
self:sfx(SFX_STOP)
self:reelStopped(i)
break
end
end
end
@@ -793,17 +775,11 @@ function SlotMachine:pressStop()
self.stops[1] = SlotMachine.stopReel1(here, self.bias)
elseif i == 2 then
local r1 = self.stopped[1]
local hereWindow = { SlotMachine.window(SlotMachine.REELS[2], here) }
local _, hereSevens = SlotMachine.matchFirstTwo(self.bet, r1, hereWindow)
local doSkip = SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random)
if hereSevens and not doSkip then
self.stops[2] = here
elseif doSkip then
if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
-- fast-spins it at double rate; the pause is the tell.
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
self.reel2Pause = 32
self.rate[2] = 0 -- paused during the 32-frame tell
self.rate[2] = 8
else
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
end
@@ -813,54 +789,32 @@ function SlotMachine:pressStop()
self.matchingSevens = sevens
local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
self.reel3Action = action
if action == SlotMachine.REEL3_STOP or action == "stop" then
if action == SlotMachine.REEL3_STOP then
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
elseif action == SlotMachine.REEL3_SLOW or action == "slowAdvance" then
elseif action == SlotMachine.REEL3_SLOW then
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl
elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then
self.rate[3] = 1 -- ReelAction_QuarterRate
elseif action == SlotMachine.REEL3_GOLEM then
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
self.random)
if count == 0 then count = 3 end
local target = here
for _ = 1, count do target = SlotMachine.advance(target) end
self.stops[3] = target
self.golems = count
self.golemAnim = {
count = count,
state = "falling",
var1 = 48,
x = 100,
y = 44 - 112,
animFrame = 0,
animTimer = 0,
}
self.rate[3] = 0 -- reel 3 stepped by each golem impact
self.rate[3] = 8
else
local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
self.stops[3] = target
self.chanseyAnim = {
state = "walking",
xcoord = 0,
x = -24,
y = 44,
animTimer = 0,
animPose = 0,
}
self.rate[3] = 0 -- paused until Chansey drops egg
self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop
end
end
-- A reel already sitting on its resting slot has nowhere to turn.
-- Only trigger immediate halt if no active pause tell or special animation is running.
if not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
if self.stops[i] == self.positions[i] then
self.rate[i] = 0
self.distance[i] = 0
self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP)
self:reelStopped(i)
end
if self.stops[i] == self.positions[i] then
self.rate[i] = 0
self.stopped = self.stopped or {}
self.stopped[i] = self:reelWindow(i)
self:sfx(SFX_STOP)
self:reelStopped(i)
end
end
@@ -869,9 +823,6 @@ function SlotMachine:reelStopped(i)
self.reel = i + 1
return
end
self.golemAnim = nil
self.chanseyAnim = nil
self.reel2Pause = nil
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
-- before the payout is counted out; a loss skips straight past it.
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
@@ -955,134 +906,13 @@ function SlotMachine:update(_dt)
if phase == "spinning" then
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
-- bet menu cannot stop reel one.
if (self.delay or 0) > 0 then
if self.delay > 0 then
self.delay = self.delay - 1
self:spinReels()
return
end
if self.reel2Pause and self.reel2Pause > 0 then
self.reel2Pause = self.reel2Pause - 1
if self.reel2Pause <= 0 then
self.reel2Pause = nil
self.rate[2] = 8 -- resume fast-spin after the 32-frame tell
end
end
if self.golemAnim then
local g = self.golemAnim
-- Cycle animation frames every 8 ticks (~7.5 fps) matching the original pacing
g.animTimer = (g.animTimer or 0) + 1
if g.animTimer >= 8 then
g.animTimer = 0
g.animFrame = ((g.animFrame or 0) + 1) % 4
end
if g.state == "falling" then
if g.var1 > 32 then
g.var1 = g.var1 - 1
local angle = (g.var1 * math.pi) / 32
local yOffset = math.floor(112 * math.sin(angle) + 0.5)
g.y = 44 + yOffset
g.x = 100
else
-- Landed on Reel 3!
g.y = 44
g.x = 100
g.state = "rolling"
g.xoffset = 0
g.animTimer = 0
g.animFrame = 0
self:sfx("Sfx_PlacePuzzlePieceDown")
-- Advance reel 3 by 1 slot per Golem impact
self.positions[3] = SlotMachine.advance(self.positions[3])
self.distance[3] = 0
end
elseif g.state == "rolling" then
g.xoffset = (g.xoffset or 0) + 1
g.x = 100 - g.xoffset
if g.xoffset >= 88 then
-- Rolled past reel 1 (100 - 88 = 12px) off the screen -> restart or end
g.count = g.count - 1
if g.count > 0 then
g.state = "falling"
g.var1 = 48
g.x = 100
g.y = 44 - 112
else
-- All Golems finished; halt reel 3 at target
self.golemAnim = nil
self.rate[3] = 0
self.distance[3] = 0
self.stopped = self.stopped or {}
self.stopped[3] = self:reelWindow(3)
self:sfx(SFX_STOP)
self:reelStopped(3)
end
end
end
end
if self.chanseyAnim then
local c = self.chanseyAnim
if c.state == "walking" then
c.xcoord = (c.xcoord or 0) + 1
c.x = c.xcoord - 24
c.y = 44
-- Cycle walking poses 0->1->2->3 (maps to Chansey 1->2->3->4) every 6 frames
c.animTimer = (c.animTimer or 0) + 1
if c.animTimer >= 6 then
c.animTimer = 0
c.animPose = ((c.animPose or 0) + 1) % 4
end
if c.xcoord % 16 == 0 then self:sfx("Sfx_JumpOverLedge") end
if c.x >= 88 then
-- Reached reel 3! Switch to tell pause (pose 4 = arm raised)
c.x = 88
c.state = "egg_pause"
c.delay = 14
c.animPose = 3
end
elseif c.state == "egg_pause" then
c.delay = (c.delay or 14) - 1
if c.delay <= 0 then
-- Switch to Chansey 5 (egg drop pose) and spawn egg
c.animPose = 4
c.state = "egg_drop"
c.eggTimer = 0
c.eggStartX = c.x + 14
c.eggStartY = c.y + 8
c.eggX = c.eggStartX
c.eggY = c.eggStartY
c.eggVisible = true
self:sfx("Sfx_Present")
end
elseif c.state == "egg_drop" then
c.eggTimer = (c.eggTimer or 0) + 1
local t = c.eggTimer / 16
if t > 1 then t = 1 end
c.eggX = c.eggStartX + t * (108 - c.eggStartX)
c.eggY = c.eggStartY + t * (56 - c.eggStartY) - math.sin(t * math.pi) * 8
if c.eggTimer >= 16 then
-- Egg landed on Reel 3!
c.eggVisible = false
c.state = "spinning"
self:sfx("Sfx_PlacePuzzlePieceDown")
self.rate[3] = 16 -- fast drop reel 3 to jackpot
end
end
end
self.message = nil
if input:wasPressed("a") then
if not self.stops[self.reel] and not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
self:pressStop()
end
end
if input:wasPressed("a") then self:pressStop() end
self:spinReels()
return
end
@@ -1129,302 +959,57 @@ end
-- ------------------------------------------------------------------- draw
--
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
local TileSheet = require("src.ui.gen2.TileSheet")
local GbcPalette = require("src.render.GbcPalette")
local GBC_PALS = {
bg = {
[0] = { { 255, 255, 255 }, { 198, 206, 231 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 0: Base Frame
[1] = { { 255, 255, 255 }, { 247, 82, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 1: Vileplume / Active Lights
[2] = { { 255, 255, 255 }, { 123, 255, 0 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 2: Bet 3 Indicators
[3] = { { 255, 255, 255 }, { 255, 123, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 3: Bet 2 Indicators
[4] = { { 255, 255, 255 }, { 123, 173, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 4: Bet 1 Indicators
[5] = { { 255, 255, 90 }, { 255, 255, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 5: Yellow Highlights
[6] = { { 255, 255, 255 }, { 132, 156, 239 }, { 206, 181, 0 }, { 0, 0, 0 } }, -- 6: Textbox frame
[7] = { { 255, 255, 255 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } }, -- 7: Inactive / Gray
},
obj = {
[0] = { { 255, 255, 255 }, { 247, 82, 49 }, { 255, 0, 0 }, { 0, 0, 0 } }, -- 0: Seven (Red)
[1] = { { 255, 255, 255 }, { 99, 206, 8 }, { 41, 115, 0 }, { 0, 0, 0 } }, -- 1: Pokeball (Green/Red)
[2] = { { 255, 255, 255 }, { 99, 206, 8 }, { 247, 82, 49 }, { 0, 0, 0 } }, -- 2: Cherry
[3] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 3: Pikachu (Yellow)
[4] = { { 255, 255, 255 }, { 255, 255, 49 }, { 123, 173, 255 }, { 0, 0, 0 } }, -- 4: Squirtle (Blue/Yellow)
[5] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 5: Staryu / Golem (Rock)
[6] = { { 255, 255, 255 }, { 255, 198, 173 }, { 255, 107, 255 }, { 0, 0, 0 } }, -- 6: Chansey (Pink)
[7] = { { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, { 0, 0, 0 } }, -- 7: Flashing
}
}
local TILEMAP = nil
local function getTilemap()
if TILEMAP == nil then
local path = "assets/generated/slots/gold_slots.tilemap"
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
TILEMAP = {}
for i = 1, #data do
TILEMAP[i] = string.byte(data, i)
end
-- The cart's reel art is unextracted (see the header), so a symbol draws as its
-- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches
-- this to the real tiles without any other change.
function SlotMachine:sheet()
if self.sheetCache == nil then
local data = self.game and self.game.data
local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots
if gfx and gfx.image then
local TileSheet = require("src.ui.gen2.TileSheet")
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16,
firstTile = gfx.firstTile or 0 })
else
TILEMAP = false
self.sheetCache = false
end
end
return TILEMAP or nil
return self.sheetCache or nil
end
function SlotMachine:sheets()
if self.sheet1 == nil then
self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 })
self.sheet3 = TileSheet.new({ path = "assets/generated/slots/gold_slots_3.png", wide = 3, firstTile = 0 })
end
return self.sheet1, self.sheet2, self.sheet3
end
function SlotMachine:drawBackground()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then
-- Fallback simple background if assets unavailable
Chrome.clear()
return
end
local bet = self.bet or 0
for ty = 0, 11 do
for tx = 0, 19 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
-- Palette attribution matching _CGB_SlotMachine
local pal = 0
if (tx <= 2 or tx >= 17) and ty >= 2 and ty <= 11 then
if ty >= 6 and ty <= 7 then pal = 4
elseif ty >= 4 and ty <= 9 then pal = 3
else pal = 2 end
elseif tx >= 4 and tx <= 15 and ty >= 2 and ty <= 3 then
pal = 1 -- Vileplume
elseif (tx == 3 or tx == 16) and ty >= 2 and ty <= 11 then
local isLit = false
if ty == 6 or ty == 7 then isLit = (bet >= 1)
elseif ty == 4 or ty == 5 or ty == 8 or ty == 9 then isLit = (bet >= 2)
elseif ty == 2 or ty == 3 or ty == 10 or ty == 11 then isLit = (bet >= 3)
end
if isLit then
pal = 1
-- Use lit lights tile
if tileId == 0x23 then tileId = 0x14
elseif tileId == 0x24 then tileId = 0x15 end
else
pal = 0
end
end
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
local function cell(tx, ty, label)
local G = love.graphics
G.setColor(0, 0, 0, 1)
G.rectangle("line", tx * 8, ty * 8, 16, 16)
Chrome.print(label, tx, ty + 1)
end
function SlotMachine:drawReels()
local _, s2 = self:sheets()
local G = love.graphics
for i = 1, 3 do
local strip = SlotMachine.REELS[i]
local pos = self.positions[i]
local a = pos
if a == 0 then a = 0x0f end
a = (a - 1) % 16
local rx = REEL_X[i] * 8
local dy = math.floor(self.distance[i] or 0)
-- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
for row = 0, 3 do
local sym = strip[a + row + 1]
local py = 64 - (row * 16) + dy
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
s2.palette = pal
-- 2x2 tiles in 2-wide sheet:
-- sym + 0 = top-left (col 0, row 0)
-- sym + 1 = top-right (col 1, row 0)
-- sym + 2 = bottom-left (col 0, row 1)
-- sym + 3 = bottom-right (col 1, row 1)
local t0 = s2:quad(sym + 0)
local t1 = s2:quad(sym + 1)
local t2 = s2:quad(sym + 2)
local t3 = s2:quad(sym + 3)
local img = s2:image()
if img and t0 and t1 and t2 and t3 then
local function drawSym()
G.draw(img, t0, rx, py)
G.draw(img, t1, rx + 8, py)
G.draw(img, t2, rx, py + 8)
G.draw(img, t3, rx + 8, py + 8)
end
if GbcPalette.available() then
GbcPalette.with(pal, drawSym)
else
drawSym()
end
else
-- Fallback label
cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?")
end
local position = self.positions[i]
-- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays
-- them bottom upward, which is what the three repeated entries at the end
-- of each strip are for: position 14 reads indices 14, 15, 16 and 17
-- without wrapping. Only the lower three are on a pay line; the fourth
-- half-shows at the top of the window.
for row = 1, 4 do
local symbol = strip[position + row]
cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?")
end
end
end
function SlotMachine:actorsImage()
if self.actorsLoaded == nil then
local Assets = require("src.render.Assets")
local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png")
if not (ok and img) then
ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_3.png")
end
self.actorsLoaded = (ok and img) or false
if self.actorsLoaded then
local G = love.graphics
-- 24x240 sheet:
-- Y=0: Golem 1 (Standing, 24x32)
-- Y=32: Golem 2 (Ball, 24x32)
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
-- Y=96: Chansey 2 (Step 2, 24x32)
-- Y=128: Chansey 3 (Step 3, 24x32)
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
-- Y=224: Egg (8x16 at X=0)
self.quadGolemStand = G.newQuad(0, 0, 24, 32, 24, 240)
self.quadGolemBall = G.newQuad(0, 32, 24, 32, 24, 240)
self.quadChansey1 = G.newQuad(0, 64, 24, 32, 24, 240)
self.quadChansey2 = G.newQuad(0, 96, 24, 32, 24, 240)
self.quadChansey3 = G.newQuad(0, 128, 24, 32, 24, 240)
self.quadChansey4 = G.newQuad(0, 160, 24, 32, 24, 240)
self.quadChanseyDrop = G.newQuad(0, 192, 24, 32, 24, 240)
self.quadEgg = G.newQuad(0, 224, 8, 16, 24, 240)
end
function SlotMachine:drawLights()
local lit = {}
-- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller
-- one: `dec a / jr z` falls through from three to two to one.
for bet = 1, (self.bet or 0) do
for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end
end
return self.actorsLoaded or nil
end
-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11)
-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does.
function SlotMachine:drawOverlays()
local s1, s2 = self:sheets()
local tm = getTilemap()
if not tm or not s1:available() then return end
local G = love.graphics
-- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15
G.setColor(198 / 255, 198 / 255, 74 / 255, 1)
G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8)
G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8)
G.setColor(1, 1, 1, 1)
for _, ty in ipairs({ 2, 3, 10, 11 }) do
for tx = 4, 15 do
local idx = ty * 20 + tx + 1
local tileId = tm[idx]
local pal = (ty <= 3) and 1 or 0
local colors = GBC_PALS.bg[pal]
s1.palette = colors
s2.palette = colors
if tileId < 0x25 then
s1:draw(tileId, tx, ty)
else
s2:draw(tileId - 0x25, tx, ty)
end
end
end
-- Draw Golem sprite animation
if self.golemAnim then
local actors = self:actorsImage()
local g = self.golemAnim
if actors then
local quad = self.quadGolemBall
local scaleX = 1
local scaleY = 1
if g.state == "falling" then
quad = self.quadGolemBall
elseif g.state == "rolling" then
-- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip
local rotFrame = (g.animFrame or 0) % 4
if rotFrame == 0 then
quad = self.quadGolemStand
scaleX = 1
scaleY = 1
elseif rotFrame == 1 then
quad = self.quadGolemBall
scaleX = 1
scaleY = 1
elseif rotFrame == 2 then
quad = self.quadGolemStand
scaleX = 1
scaleY = -1
elseif rotFrame == 3 then
quad = self.quadGolemBall
scaleX = -1
scaleY = 1
end
end
G.setColor(1, 1, 1, 1)
local function drawGolem()
-- Draw rotated around center (ox=12, oy=16)
G.draw(actors, quad,
math.floor(g.x + 12),
math.floor(g.y + 16),
0, scaleX, scaleY, 12, 16)
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[5], drawGolem)
else
drawGolem()
end
end
end
-- Draw Chansey & Egg sprite animation
if self.chanseyAnim then
local actors = self:actorsImage()
local c = self.chanseyAnim
if actors then
local quad = self.quadChansey1
if c.state == "walking" then
local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 }
quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1
elseif c.state == "egg_pause" then
quad = self.quadChansey4
elseif c.state == "egg_drop" or c.state == "spinning" then
quad = self.quadChanseyDrop
end
G.setColor(1, 1, 1, 1)
local function drawChansey()
G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44))
if c.eggVisible and c.eggX and c.eggY then
G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY))
end
end
if GbcPalette.available() then
GbcPalette.with(GBC_PALS.obj[6], drawChansey)
else
drawChansey()
end
for _, row in ipairs({ 2, 4, 6, 8, 10 }) do
for _, col in ipairs(LIGHT_COLS) do
Chrome.print(lit[row] and "*" or "-", col, row)
end
end
end
@@ -1437,79 +1022,39 @@ function SlotMachine:drawMessage()
end
if self.matched and self.matched ~= SlotMachine.NO_MATCH
and self.phase == "payoutText" then
local _, s2 = self:sheets()
local sym = self.matched
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
s2.palette = pal
local t0 = s2:quad(sym + 0)
local t1 = s2:quad(sym + 1)
local t2 = s2:quad(sym + 2)
local t3 = s2:quad(sym + 3)
local img = s2:image()
local G = love.graphics
local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8
if img and t0 and t1 and t2 and t3 then
local function drawWin()
G.setColor(1, 1, 1, 1)
G.draw(img, t0, px, py)
G.draw(img, t1, px + 8, py)
G.draw(img, t2, px, py + 8)
G.draw(img, t3, px + 8, py + 8)
end
G.setColor(1, 1, 1, 1)
if GbcPalette.available() then
GbcPalette.with(pal, drawWin)
else
drawWin()
end
else
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?")
end
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y,
SlotMachine.LABELS[self.matched] or "?")
end
end
function SlotMachine:drawPanel()
self:drawBackground()
self:drawReels()
self:drawOverlays()
Chrome.clear()
self:drawLights()
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
self:drawReels()
if self.phase == "bet" then
-- Left speech textbox for "Bet how many coins?"
Chrome.textbox(0, 12, 12, 4)
Chrome.print(TEXTS.betHowMany[1], 1, 14)
Chrome.print(TEXTS.betHowMany[2], 1, 16)
-- Right menu for bet choices (14, 10 to 19, 17)
Chrome.textbox(14, 10, 4, 6)
Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2)
for i, label in ipairs(BET_ROWS) do
local ty = 12 + (i - 1) * 2
if i == self.betIndex then Chrome.cursor(15, ty) end
Chrome.print(label, 16, ty)
local ty = BET_LABEL_Y + (i - 1) * BET_SPACING
if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end
Chrome.print(label, BET_LABEL_X, ty)
end
if self.message then
-- If "Not enough coins." message is shown, overlay full speech box
Chrome.textbox(0, 12, 18, 4)
for i, line in ipairs(self.message) do
if not self.message then
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
for i, line in ipairs(TEXTS.betHowMany) do
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
end
end
elseif self.phase == "again" then
-- Speech box: "Play again?"
Chrome.textbox(0, 12, 18, 4)
Chrome.print(TEXTS.playAgain[1], TEXT_X, TEXT_Y)
-- PlaceYesNoBox at (14, 12): 6x5 box with YES at (16,13), NO at (16,15)
end
self:drawMessage()
if self.phase == "again" then
-- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13).
Chrome.textbox(14, 12, 4, 3)
Chrome.print("YES", 16, 13)
Chrome.print("NO", 16, 15)
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
else
self:drawMessage()
end
end
+25 -60
View File
@@ -2,7 +2,6 @@
-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue.
-- drawWidescreen fills the window with sky/clouds so widescreen has no
-- pillarbox voids; the 160x144 art stays aspect-centered on top.
-- Every Gold/Silver difference arrives as a title.lua key, defaulted to Gold.
-- src/render/Assets.lua is the mod-override choke point: a raw
-- love.graphics.newImage skips overrides/ and AssetTransform output.
@@ -17,7 +16,7 @@ local TitleState = {}
TitleState.__index = TitleState
TitleState.isOpaque = true
-- title_bg_gold.pal mid-sky shade, for a cache built before title.sky existed.
-- title_bg_gold.pal mid-sky shade (sampled from composed title_screen.png).
local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 }
-- ...and its grey stand-in, for when COLOR is not GBC. The title art is the
-- one thing in the port baked with its colours in (see the extractor), so the
@@ -62,15 +61,6 @@ function TitleState.new(game, opts)
self.hoohY = tonumber(title.hoohY) or 56
self.cloudY = tonumber(title.cloudY) or 88
self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
self.hoohBobAmplitude = tonumber(title.hoohBobAmplitude) or 2
self.hoohBobStep = tonumber(title.hoohBobStep) or 1
local sky = title.sky
self.sky = (type(sky) == "table" and #sky >= 3)
and { sky[1], sky[2], sky[3], 1 } or SKY
local below = title.below
self.below = (type(below) == "table" and #below >= 3)
and { below[1], below[2], below[3], 1 } or { 1, 1, 1, 1 }
self.hoohColor, self.hoohGray = {}, {}
local paths = title.hoohFrames
@@ -90,12 +80,8 @@ function TitleState.new(game, opts)
self.sequence = title.hoohSequence or {
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
}
-- A frame shows duration + 1 ticks: GetSpriteAnimFrame stores the byte on
-- the advancing tick and only decrements on the ones after
-- (engine/sprite_anims/core.asm:400-434). Both editions' framesets total
-- 64 ticks with it, locking the wing beat to the 64-tick sine bob.
self.seqIndex = 1
self.seqLeft = (self.sequence[1] and self.sequence[1][2] or 10) + 1
self.seqLeft = self.sequence[1] and self.sequence[1][2] or 10
self.frame = 1
-- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1.
@@ -103,21 +89,12 @@ function TitleState.new(game, opts)
self.frameCounter = 0
self.cloudScroll = 0
self.trails = {}
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm:1069-1124), in
-- pixels.
self.trailSpawns = title.trailSpawns or {
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm), in pixels.
self.trailSpawns = {
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
{ 120, 88 }, { 88, 88 },
}
self.trailSpawnIndex = 1
-- AnimSeq_GSTitleTrail (engine/sprite_anims/functions.asm:720-818).
self.trailMode = title.trailMode or "gold"
self.trailSpawnEvery = tonumber(title.trailSpawnEvery) or 4
self.trailStepX = tonumber(title.trailStepX) or 4
self.trailStepY = tonumber(title.trailStepY) or 1
self.trailBobAmplitude = tonumber(title.trailBobAmplitude) or 2
self.trailPhaseStep = tonumber(title.trailPhaseStep) or 3
self.trailPhase = tonumber(title.trailPhase)
-- How far past the 160px frame trails may fly (GB pixels); set each draw.
self.trailMaxX = 200
self.musicStarted = false
@@ -142,56 +119,47 @@ function TitleState:enter()
end
end
-- Sprites_Sine hands back the byte the ASM leaves in a, so the down half of
-- the wave arrives in two's complement and is a signed pixel delta here.
local function signed(value)
if value >= 0x80 then return value - 0x100 end
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm): VAR1 counts up
-- one per frame and the struct's Y offset becomes `d * sin(VAR1 * pi/32)` with
-- d = 2 on Gold (Silver counts DOWN with d = 8). Sprites_Sine hands back the
-- byte the ASM leaves in a, so the down half of the wave arrives in two's
-- complement and has to be read as a signed pixel delta here.
function TitleState:hoohBob()
local value = SpriteAnims.sine(self.hoohPhase, 2)
if value >= 0x80 then value = value - 0x100 end
return value
end
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
function TitleState:hoohBob()
return signed(SpriteAnims.sine(self.hoohPhase, self.hoohBobAmplitude))
end
function TitleState:advanceHooh()
self.hoohPhase = (self.hoohPhase + self.hoohBobStep) % 256
self.hoohPhase = (self.hoohPhase + 1) % 256
self.seqLeft = self.seqLeft - 1
if self.seqLeft > 0 then return end
self.seqIndex = self.seqIndex + 1
if self.seqIndex > #self.sequence then self.seqIndex = 1 end
local step = self.sequence[self.seqIndex]
self.frame = step[1]
self.seqLeft = step[2] + 1
self.seqLeft = step[2]
end
function TitleState:spawnTrail()
if not (self.trailColor or self.trailGray) then return end
if #self.trailSpawns == 0 then return end
if self.frameCounter % self.trailSpawnEvery ~= 0 then return end
if self.frameCounter % 4 ~= 0 then return end
local spawn = self.trailSpawns[self.trailSpawnIndex]
self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1
if not spawn then return end
self.trails[#self.trails + 1] = {
x = spawn[1], y = spawn[2],
phase = self.trailPhase or love.math.random(0, 255),
x = spawn[1], y = spawn[2], phase = love.math.random(0, 255),
}
end
function TitleState:stepTrails()
local alive = {}
local maxX = self.trailMaxX or 200
local silver = self.trailMode == "silver"
for _, t in ipairs(self.trails) do
t.x = t.x + self.trailStepX
t.y = t.y + self.trailStepY
t.phase = t.phase + self.trailPhaseStep
if silver then
t.drawY = t.y + signed(SpriteAnims.sine(t.phase, self.trailBobAmplitude))
else
t.drawY = t.y
+ math.floor(math.sin(t.phase / 16) * self.trailBobAmplitude)
end
t.x = t.x + 4
t.y = t.y + 1
t.phase = t.phase + 3
t.drawY = t.y + math.floor(math.sin(t.phase / 16) * 2)
if t.x < maxX then alive[#alive + 1] = t end
end
self.trails = alive
@@ -286,17 +254,14 @@ function TitleState:drawWidescreen(winW, winH)
-- Let trails fly into the side bands.
self.trailMaxX = math.ceil((winW - ox) / scale) + 16
-- Sky above the cloud line, title.below under it (Gold's white cloud
-- field, Silver's black sea) : edge to edge. The fill has to match
-- whichever baked set is showing, or the surround would stay blue around a
-- grey screen.
local sky = self:gray() and SKY_GRAY or self.sky
-- Sky above the cloud line, paper white below : edge to edge. The fill has
-- to match whichever baked set is showing, or the surround would stay blue
-- around a grey screen.
local sky = self:gray() and SKY_GRAY or SKY
G.setColor(sky[1], sky[2], sky[3], 1)
G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop))
local below = self.below
G.setColor(below[1], below[2], below[3], 1)
G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
-- Clouds across the full window width, aligned to the GB cloud band.
G.push()
+2 -5
View File
@@ -64,7 +64,6 @@ local PAL = {
railBlue = { 70, 150, 255 },
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
}
-- Semantic aliases kept so ported call sites read the same as before.
PAL.cardBorder = PAL.line
@@ -322,12 +321,10 @@ function Theme.meter(x, y, w, h, pct, c)
end
-- The 4px version rail across the top of both windows: the only brand
-- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours).
-- colour on screen (Red / Blue / Yellow / Gold cartridge colours).
function Theme.versionRail(x, y, w, h)
if not G then return end
local bars = {
PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver,
}
local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber }
local seg = w / #bars
for i, c in ipairs(bars) do
Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1)
+11 -26
View File
@@ -1569,11 +1569,11 @@ function World:readVar(varId)
end
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
-- GS_VERSION).
-- GS_VERSION). Only the Goldenrod prize counters and a handful of gift mons
-- read it, and the port has no Silver cache yet, so an unset version is Gold.
function World:gsVersion()
local GameVersion = require("src.core.GameVersion")
local save = self.game and self.game.save
local version = (save and save.version) or GameVersion.get()
local version = (save and save.version) or "gold"
return version == "silver" and 1 or 0
end
@@ -6633,9 +6633,8 @@ function World:nameRival(onDone)
if save then
save.rival = save.rival or {}
-- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call
-- InitName`, and .DefaultName is "SILVER@" on Gold and "GOLD@" on
-- Silver (engine/events/specials.asm:80-94). The default has to be
-- written
-- InitName`, and .DefaultName is "SILVER@" on Gold
-- (engine/events/specials.asm:80-91). The default has to be written
-- HERE rather than ridden in from the seed: wRivalName starts as
-- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the
-- pre-naming Cherrygrove battle prints.
@@ -6647,7 +6646,7 @@ function World:nameRival(onDone)
if name and name:gsub(" ", "") ~= "" then
save.rival.name = name
else
save.rival.name = self:gsVersion() == 1 and "GOLD" or "SILVER"
save.rival.name = "SILVER"
end
end
if onDone then onDone(name) end
@@ -10027,11 +10026,8 @@ function World:draw()
G.clear(0.07, 0.05, 0.02, 1)
if not self.mapImage or not self.player then
local silver = self:gsVersion() == 1
if silver then G.setColor(0.74, 0.78, 0.83, 1)
else G.setColor(0.85, 0.57, 0.13, 1) end
G.printf(silver and "POKEMON SILVER" or "POKEMON GOLD",
0, math.floor(h * 0.38), w, "center")
G.setColor(0.85, 0.57, 0.13, 1)
G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center")
G.setColor(0.92, 0.90, 0.82, 1)
G.printf(self.status or "No map.",
0, math.floor(h * 0.48), w, "center")
@@ -10068,15 +10064,6 @@ function World:draw()
if self.shake then
self.camera.y = self.camera.y + (self.shake.phase or 0)
end
local ScreenPosition = require("src.core.ScreenPosition")
local posLift = 0
if not ScreenPosition.skinActive(w, h) then
posLift = ScreenPosition.lift(h, 144 * self:fitScale(),
ScreenPosition.safeTop())
end
if posLift > 0 then
self.camera.y = self.camera.y + posLift / s
end
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
@@ -10102,7 +10089,7 @@ function World:draw()
local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7]
G.push()
G.translate(math.floor((w - 160 * sPic) / 2),
math.floor((h - 144 * sPic) / 2) - posLift)
math.floor((h - 144 * sPic) / 2))
G.scale(sPic, sPic)
G.setColor(1, 1, 1, 1)
local function body()
@@ -10153,10 +10140,8 @@ function World:draw()
-- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV
-- (or the F3 toggle) shows it, a normal boot does not.
if self.showDebugHud then
local silver = self:gsVersion() == 1
if silver then G.setColor(0.74, 0.78, 0.83, 1)
else G.setColor(0.85, 0.57, 0.13, 1) end
G.printf(silver and "POKEMON SILVER" or "POKEMON GOLD", 0, 10, w, "center")
G.setColor(0.85, 0.57, 0.13, 1)
G.printf("POKEMON GOLD", 0, 10, w, "center")
G.setColor(0.92, 0.90, 0.82, 1)
local label = string.format("%s (%d,%d) %s · %s · zoom %s",
self.map.id, p.cellX, p.cellY, p.facing,
@@ -41,21 +41,12 @@ end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
check(ok == true, "syncAndroidShortcuts returns true on Android")
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"the rest follow GameVersion.ORDER until the cap")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
@@ -87,4 +78,4 @@ end
love.system.getLaunchGame = savedGetLaunchGame
love.system.updateShortcuts = nil
T.finish("android_shortcuts_payload_test")
print("8/8 checks passed (android_shortcuts_payload_test)")
-2
View File
@@ -23,7 +23,6 @@ T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
@@ -423,7 +422,6 @@ local GEN2_HOOKS = {
-- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible",
"battle.move_grid_navigation",
-- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
+34 -45
View File
@@ -39,54 +39,45 @@ end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
for _, version in ipairs({ "gold", "silver" }) do
local model = LauncherSettings.open(hooks, version)
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(model, "VIBRATION"), version .. " and VIBRATION")
check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
check(has(model, "VOID FILL"), version .. " and VOID FILL")
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
check(has(gold, "VOID FILL"), "and VOID FILL")
local voidFill = findRow(model, "VOID FILL")
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
voidFill.step(1)
eq(model.opts.gold.voidFill, "water",
version .. " right stores water in the gold block")
eq(voidFill.value(), "WATER", version .. " and the row reads WATER")
voidFill.step(-1)
eq(model.opts.gold.voidFill, "fade", version .. " left restores fade")
local voidFill = findRow(gold, "VOID FILL")
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
voidFill.step(1)
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
eq(voidFill.value(), "WATER", "and the row reads WATER")
voidFill.step(-1)
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
-- Every write has to land in the gen2 block: the flat keys beside it are
-- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2
-- rows leave them exactly as they found them.
local flatPad = model.opts.touchControls
local flatBuzz = model.opts.haptics
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
local pad = findRow(model, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it")
check(type(model.opts.gold) == "table", version .. " into the gold block")
eq(model.opts.gold.touchControls.enabled, false,
version .. " which now carries enabled")
eq(model.opts.touchControls, flatPad,
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level")
eq(model.opts.gold.haptics,
TouchControls.normalizeHaptics(model.opts.gold.haptics),
version .. " VIBRATION stores a level the shared module knows")
eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
edited = 0
findRow(model, "TOUCH CONTROLS").action()
eq(edited, 1, version .. " the editor row reaches the host hook")
end
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
@@ -103,8 +94,6 @@ eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
+38 -41
View File
@@ -155,34 +155,28 @@ modImp.mods = mods
modImp._ensureMods = function() return mods end
LauncherView.draw(modImp)
LauncherView.draw(modImp)
local reg = modImp._tabRegionRect
check((modImp._tabScrollMax.mods or 0) > reg.h,
"60 installed mods are ONE continuous list: the region's travel spans "
.. "the whole list, not one page of it")
eq(modImp._pages.mods, nil, "and no page state is ever minted for it")
pointer(reg.x + 10, reg.y + 40)
check((modImp._modScrollMax or 0) > 0,
"60 mods overflow the list viewport inside the panel")
local list = modImp._modListRect
check(list.x + list.w
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(),
"the rows stop short of the region's scrollbar gutter")
pointer(list.x + 10, list.y + 10)
modImp._wheelY = -1
LauncherView.draw(modImp)
check((modImp._tabScroll.mods or 0) > 0,
"a notch over the region scrolls the list like any other tab")
eq(modImp._pageScroll, 0, "without reaching the page behind it")
modImp._tabScroll.mods = modImp._tabScrollMax.mods
LauncherView.draw(modImp)
pointer(reg.x + 10, reg.y + reg.h * 0.5)
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp._pages.mods, nil,
"bottoming the list out never auto-advances any pager")
eq(modImp._tabScroll.mods, modImp._tabScrollMax.mods,
"the list just rests at its end")
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list")
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it")
eq(modImp._pageScroll, 0, "and not the page behind that")
modImp._modActions = "mod1"
local shieldedAt = modImp._tabScroll.mods
local shielded = modImp.modScroll
local shieldedPage = modImp._pageScroll
pointer(list.x + 10, list.y + 10)
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp._tabScroll.mods, shieldedAt,
"a shielded mod list ignores the notch under the scrim")
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch")
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim")
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
modImp._modActions = nil
modImp._wheelY = 0
LauncherView.draw(modImp)
@@ -231,27 +225,32 @@ dragMods.mods = mods
dragMods._ensureMods = function() return mods end
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
local dreg = dragMods._tabRegionRect
local dlist = dragMods._modListRect
local dListMax = dragMods._modScrollMax
local dRegionMax = dragMods._tabScrollMax.mods
check(dRegionMax > 0, "the mods region scrolls its overscan like any tab")
LauncherView.touchpressed(dragMods, 7, dreg.x + 20, dreg.y + 30)
LauncherView.touchmoved(dragMods, 7, dreg.x + 20, dreg.y + 30 - 60)
eq(dragMods._tabScroll.mods, math.min(60, dRegionMax),
"a drag moves the region by the finger's travel")
LauncherView.touchmoved(dragMods, 7, dreg.x + 20,
dreg.y + 30 - 60 - dRegionMax * 2)
eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
check(dListMax > 0 and dRegionMax > 0,
"the mods tab has both an inner list and a region to scroll")
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30)
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60)
eq(dragMods.modScroll, math.min(60, dListMax),
"the first pixels of the drag move the list")
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else")
LauncherView.touchmoved(dragMods, 7, dlist.x + 20,
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
eq(dragMods._tabScroll.mods, dRegionMax,
"then the same gesture walks the region to its bottom")
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900)
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
end
dragMods._ensureSkins = function() return dragMods._skins end
local heldModsPage = dragMods._pages.mods or 1
local heldModsAt = dragMods._tabScroll.mods
local overList = dreg.y + 60
dragMods.modScroll = 0
local heldModScroll = dragMods.modScroll
local overList = dlist.y + 30
dragMods:_switchTab("skins")
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
@@ -261,10 +260,8 @@ LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
check((dragMods._tabScroll.skins or 0) > 0,
"a drag on the skins tab scrolls the skins tab")
eq(dragMods._pages.mods or 1, heldModsPage,
"and leaves the mod list on the page the player parked it")
eq(dragMods._tabScroll.mods, heldModsAt,
"with its region offset held for the return trip")
eq(dragMods.modScroll, heldModScroll,
"and leaves the mod list where the player parked it")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
love.graphics.polygon = love.graphics.polygon or function() end
@@ -311,8 +308,8 @@ local view = read("src/import/LauncherView.lua")
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
"the panel dispatch opens a scroll region")
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
check(view:find("modListWantsWheel", 1, true) == nil,
"no nested list steals the wheel from the region any more")
check(view:find("modListWantsWheel", 1, true) ~= nil,
"the nested mod list is asked before the region takes a notch")
check(view:find("start.region", 1, true) ~= nil,
"a touch drag that began in the region scrolls the region")
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
+1 -1
View File
@@ -15,7 +15,7 @@ love = love or require("tests.love_stub")
-- scripts/build.sh mac layout: the game is an archive inside the .app, the
-- player's portable folder is the one holding the .app.
local PORTABLE = "/Users/p/Games"
local APP = PORTABLE .. "/gen1recomp.app"
local APP = PORTABLE .. "/gen1recomp++.app"
local SOURCE = APP .. "/Contents/Resources/game.love"
local BASE = APP .. "/Contents/MacOS"
+8 -12
View File
@@ -32,13 +32,11 @@ do
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
eq(table.concat(ModTargets.expand("gen2"), ","), "gold",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
@@ -58,7 +56,7 @@ end
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -69,14 +67,14 @@ end
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(list(gen2), "gold", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
eq(list(both), "red,blue,yellow,gold", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold",
"an old gen2compat beside a new games list still adds its game")
end
@@ -226,12 +224,10 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, crystal = { a = true },
red = "nope" } },
gold = { a = true }, silver = { a = true }, red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
@@ -137,8 +137,6 @@ check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
check(createdDirs["imports/saves/gold"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
check(createdDirs["imports/saves/silver"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/silver/")
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter()
-113
View File
@@ -1,113 +0,0 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local ScreenPosition = require("src.core.ScreenPosition")
local TouchSkin = require("src.core.TouchSkin")
local Renderer = require("src.render.Renderer")
local Chrome = require("src.ui.gen2.Chrome")
local Zoom = require("src.render.Zoom")
eq(ScreenPosition.normalize(nil), "center", "no setting is CENTER")
eq(ScreenPosition.normalize("junk"), "center", "garbage degrades to CENTER")
eq(ScreenPosition.normalize("top"), "top", "top passes through")
eq(ScreenPosition.label("upper"), "UPPER", "upper reads UPPER")
local seen, v = {}, "center"
for _ = 1, 3 do
seen[#seen + 1] = ScreenPosition.label(v)
v = ScreenPosition.cycle(v, 1)
end
eq(table.concat(seen, ","), "CENTER,UPPER,TOP", "the row cycles CENTER,UPPER,TOP")
eq(ScreenPosition.cycle("top", 1), "center", "and wraps back to CENTER")
eq(ScreenPosition.cycle("center", -1), "top", "stepping back lands on TOP")
ScreenPosition.setMode("center")
eq(ScreenPosition.lift(640, 288), 0, "CENTER never lifts")
ScreenPosition.setMode("top")
eq(ScreenPosition.lift(640, 288), 176, "TOP lifts the centered origin to 0")
eq(ScreenPosition.lift(288, 288), 0, "no slack, no lift")
eq(ScreenPosition.lift(144, 288), 0, "negative slack, no lift")
eq(ScreenPosition.lift(640, 288, 40), 136, "TOP stops at the safe-area inset")
eq(ScreenPosition.lift(640, 288, 999), 0,
"a safe inset past center degrades to centered")
ScreenPosition.setMode("upper")
eq(ScreenPosition.lift(640, 288), 88, "UPPER lands halfway between")
eq(ScreenPosition.lift(640, 288, 40), 88, "a small inset leaves UPPER alone")
eq(ScreenPosition.lift(640, 288, 120), 56, "a large inset pushes UPPER down")
ScreenPosition.applyOptions({ screenPos = "top" })
eq(ScreenPosition.mode, "top", "applyOptions takes the stored key")
ScreenPosition.applyOptions(nil)
eq(ScreenPosition.mode, "center", "applyOptions without options is CENTER")
local function setWindow(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
setWindow(360, 640)
TouchSkin.setActive(nil)
Renderer:init()
Zoom.offset = 0
ScreenPosition.setMode("center")
local r = Renderer:frameRects()
eq(r.Sp, 2, "360x640 fits two whole GB pixels")
eq(r.lift, 0, "CENTER: no lift")
eq(r.oy, 176, "CENTER: the letterbox is centered")
local _, vhCenter = Renderer:worldViewSize()
ScreenPosition.setMode("top")
r = Renderer:frameRects()
eq(r.lift, 176, "TOP: the full centered slack lifts away")
eq(r.oy, 0, "TOP: the letterbox sits at the top edge")
eq(r.uoy, 0, "TOP: the UI letterbox follows")
eq(r.ox, math.floor((360 - 320) / 2), "TOP: horizontal centering is untouched")
local _, vhTop = Renderer:worldViewSize()
eq(vhTop, vhCenter + 2 * math.ceil(176 / 2),
"TOP: the world canvas grows to keep the bottom covered")
ScreenPosition.setMode("upper")
r = Renderer:frameRects()
eq(r.oy, 88, "UPPER: the letterbox centers in the upper half")
ScreenPosition.setMode("top")
local ox, oy = Chrome.fitOrigin(360, 640)
eq(oy, 0, "TOP: Gold's letterbox sits at the top edge")
eq(ox, math.floor((360 - 320) / 2), "TOP: Gold's horizontal centering is untouched")
ScreenPosition.setMode("center")
local _, cy = Chrome.fitOrigin(360, 640)
eq(cy, 176, "CENTER: Gold's letterbox is centered")
ScreenPosition.setMode("top")
local skin = assert(TouchSkin.parse([[
overlays = 1
overlay0_name = "bezel"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_viewport = "0.1,0.1,0.8,0.4"
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
]]))
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
r = Renderer:frameRects()
eq(r.cut, true, "the skin viewport cuts the playfield")
eq(r.lift, 0, "a skin viewport disables the lift")
eq(select(1, ScreenPosition.skinActive(360, 640)), true,
"skinActive sees the viewport")
local _, sy = Chrome.fitOrigin(360, 640)
local _, cy2 = (function()
ScreenPosition.setMode("center")
return Chrome.fitOrigin(360, 640)
end)()
eq(sy, cy2, "with a skin the Gold origin ignores the mode")
TouchSkin.setActive(nil)
ScreenPosition.setMode("center")
T.finish("screen_position")
+2 -4
View File
@@ -81,8 +81,7 @@ local function importer(ready)
}, RomImporter)
end
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
local allReady = importer({ red = true, blue = true, yellow = true, gold = true })
allReady:_queueBaseRomScan()
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
eq(listings, 0, "ready launcher does not enumerate baseroms")
@@ -124,8 +123,7 @@ eq(picks, 0, "missing detected ROM does not open the picker unexpectedly")
missing:choose("red")
eq(picks, 1, "the next import attempt falls back to the native picker")
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true })
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
rescanned:reimport("red")
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")
-112
View File
@@ -1233,118 +1233,6 @@ check("a full wallet fills it", Chrome.money(999999), "\xc2\xa5999999")
check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true),
"0050")
-- ============================================================ multi-game stress tests
--
-- Verify that 500 consecutive Slot Machine games and 500 consecutive Card Flip hands
-- run to completion with zero softlocks, zero infinite spin loops (issue #1520),
-- and correct coin accounting across all random biases and near-miss theatres.
local mockSave = {
player = { name = "GOLD", coins = 5000 },
}
local mockInput = {
pressed = {},
wasPressed = function(self, key)
local v = self.pressed[key]
self.pressed[key] = false
return v or false
end,
press = function(self, key)
self.pressed[key] = true
end,
}
local mockSlotGame = {
save = mockSave,
input = mockInput,
data = {},
}
-- Test Slot Machine 500-spin continuous loop
local sm = SlotMachine.new(mockSlotGame, { lucky = true })
local completedSpins = 0
for spin = 1, 500 do
mockSave.player.coins = 5000 -- ensure test player always has coins
if sm.phase == "quit" or sm.phase == "ranOut" then
sm = SlotMachine.new(mockSlotGame, { lucky = true })
end
-- Enter bet phase
check("slot machine in bet phase at spin start", sm.phase, "bet")
mockInput:press("a") -- bet 3 coins
sm:update(1/60)
check("slot machine entered spinning phase", sm.phase, "spinning")
local frameCount = 0
local maxFrames = 5000 -- safety bound per spin
while sm.phase == "spinning" and frameCount < maxFrames do
frameCount = frameCount + 1
if frameCount % 30 == 0 then
mockInput:press("a") -- press stop button
end
sm:update(1/60)
end
check(("spin %d must not hang in spinning"):format(spin), frameCount < maxFrames, true)
-- Resolve flash and payout phases
while (sm.phase == "flash" or sm.phase == "payoutText") and frameCount < maxFrames do
frameCount = frameCount + 1
if sm.phase == "payoutText" and sm.matched == SlotMachine.NO_MATCH then
mockInput:press("a")
end
sm:update(1/60)
end
check(("spin %d reached again phase"):format(spin), sm.phase == "again" or sm.phase == "bet", true)
if sm.phase == "again" then
mockInput:press("a") -- choose YES to play again
sm:update(1/60)
completedSpins = completedSpins + 1
elseif sm.phase == "bet" then
completedSpins = completedSpins + 1
end
end
check("all 500 slot machine spins completed without softlock", completedSpins >= 490, true)
-- Test Card Flip 500-hand continuous loop
local cf = CardFlip.new(mockSlotGame)
local completedHands = 0
for hand = 1, 500 do
mockSave.player.coins = 5000
if cf.phase == "quit" then
cf = CardFlip.new(mockSlotGame)
end
local frameCount = 0
local maxFrames = 500
while cf.phase ~= "again" and cf.phase ~= "quit" and frameCount < maxFrames do
frameCount = frameCount + 1
if cf.phase == "ask" or cf.phase == "message" or cf.phase == "result"
or cf.phase == "choose" or cf.phase == "bet" then
mockInput:press("a")
end
cf:update(1/60)
end
check(("hand %d completed in bounds"):format(hand), frameCount < maxFrames, true)
if cf.phase == "again" then
mockInput:press("a") -- play again
cf:update(1/60)
completedHands = completedHands + 1
end
end
check("all 500 card flip hands completed cleanly", completedHands >= 490, true)
print(("gen2 game corner: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
-- here would take the whole tier down and silently skip every suite after it.
-6
View File
@@ -31,14 +31,10 @@ require("src.core.Logger").warn = function() end
local Core = require("src.core.gen2.HallOfFame")
local Credits = require("src.ui.gen2.Credits")
local GameVersion = require("src.core.GameVersion")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
local priorVersion = GameVersion.get()
GameVersion.set("gold")
-- ---- fixtures -------------------------------------------------------------
local POKEMON = {
@@ -715,6 +711,4 @@ else
eq(creditsData.palettes[1][1][1], 255, "and set 1 colour 0 is white")
end
GameVersion.set(priorVersion)
S.finish()

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