Compare commits

..

65 Commits

Author SHA1 Message Date
bryanthaboi a231657093 Merge pull request #1856 from bryanthaboi/dev
Update HostShell.lua
2026-08-26 17:17:14 -04:00
bryanthaboi 6f587520d4 Update HostShell.lua 2026-08-26 17:15:56 -04:00
github-actions e41f2c528a chore(ios): update app-repo.json [skip ci] 2026-08-26 14:00:12 -04:00
bryanthaboi 7797962f66 Merge pull request #1839 from bryanthaboi/dev
Update release.yml
2026-08-26 13:31:23 -04:00
bryanthaboi 017eafc51b Update release.yml 2026-08-26 13:28:41 -04:00
bryanthaboi c1b7980b4a Merge pull request #1837 from bryanthaboi/dev
palettes. menu reordering. etc.
2026-08-26 13:08:07 -04:00
bryanthaboi 3e4772abf4 big palette energy 2026-08-26 12:04:06 -04:00
bryanthaboi 522d8cecf3 mega big fix for shaders 2026-08-26 10:29:16 -04:00
bryanthaboi 40075ae469 Merge pull request #1830 from ShaneMcGovernIE/feat/sbc-handheld-optimizations
feat(sbc): handheld power governor, audio sample rate, and allocator tuning
2026-08-26 10:03:14 -04:00
Shane McGovern dce0dd32db fix(ci): pass all engine invariant and headless test suites 2026-08-26 13:42:44 +01:00
Shane McGovern 88e24f2c0c feat(core): add global Start+Select 5s emergency force-quit watchdog 2026-08-26 13:26:14 +01:00
Shane McGovern b7b96cd9ef fix(ui): hoist getNavLayer definition before Kit.endFrame to fix nil function crash 2026-08-26 13:23:59 +01:00
Shane McGovern 9886920c7a feat(ui): default controller navigation on and set starting focus to Layer 3 (Import/Play) 2026-08-26 12:50:12 +01:00
Shane McGovern f5f9e60b3c fix(input): resolve 1:1 Nintendo face button mapping on SBC/PortMaster devices 2026-08-26 12:49:01 +01:00
Shane McGovern 247ec919ea feat(ui): 4-layer vertical controller navigation, bright white glowing focus highlights, and handheld updater support 2026-08-26 12:44:20 +01:00
bryanthaboi fe338f47f2 Merge pull request #1820 from 1Jamie/app-image-fixes
feat(linux): ship raw x86 AppImage, Flatpak bundle, and dual-env curl
2026-08-26 07:40:48 -04:00
Shane McGovern 7e4c4eb46e feat(ui): add native controller menu navigation with Y hotkey toggle and soft-glow focus 2026-08-26 12:28:02 +01:00
Shane McGovern 1d735c3291 fix(launcher): render VirtualKeyboard on top of prompts and map Nintendo layout accurately 2026-08-26 12:16:41 +01:00
Shane McGovern dce0f92c2d feat(launcher): add Select button VirtualKeyboard toggle and default Nintendo layout 2026-08-26 12:05:05 +01:00
Shane McGovern f1bc392664 fix(kit): open VirtualKeyboard on pointer click as well as D-pad activation on handhelds 2026-08-26 12:01:43 +01:00
Shane McGovern ee427cd9eb fix(launcher): prioritize in-launcher FileBrowser on handhelds and fix modalUp detection 2026-08-26 11:56:26 +01:00
Shane McGovern 592daafa42 feat(launcher): add in-launcher File Browser and Virtual Keyboard for handhelds 2026-08-26 11:53:01 +01:00
Shane McGovern b78ab6fb50 feat(sbc): add handheld power governor, audio sample rate, and allocator tuning 2026-08-26 11:24:46 +01:00
1jamie 6a02a13f22 ci(linux): build and publish Flatpak on release and path-gated PR CI
Add a ubuntu-24.04 linux-flatpak job that runs build_flatpak.sh from the
shared love payload, wire it into release staging/upload, and mirror a
path-gated Flatpak build in ci.yml when flatpak packaging changes.
2026-08-25 15:32:29 -05:00
1jamie 0e8c93ec75 feat(linux): ship raw x86 AppImage, Flatpak bundle, and dual-env curl
Drop the pointless zip wrapper around the x86_64 AppImage, harden HostShell
so bundled curl keeps APPDIR libs while host curl scrubs LD_LIBRARY_PATH /
Steam LD_PRELOAD, and add a Flatpak channel with --device=all, bundled
curl, and AppStream releases metadata. Portable mode now probes writability
with a unique temp file and falls back soft on RO mounts / Flatpak.
2026-08-25 15:28:44 -05:00
github-actions 7b77dbe3ea chore(ios): update app-repo.json [skip ci] 2026-08-25 16:20:40 -04:00
bryanthaboi 0c6c813e87 Merge pull request #1816 from bryanthaboi/dev
fixes and stuff
2026-08-25 16:04:42 -04:00
bryanthaboi a6c914ecad Update video link and thumbnail in README 2026-08-25 16:02:55 -04:00
bryanthaboi 1f12e4838f Merge pull request #1791 from HighDrexler/fix/mobile-required-import-streaming-v2
Fix/mobile required import streaming v2
2026-08-25 15:55:34 -04:00
bryanthaboi 7fa762871f Merge pull request #1798 from campavao/rfc-0015-battle-rule-hooks
engine: battle.style and catch.nickname rule hooks
2026-08-25 15:55:07 -04:00
bryanthaboi 4d805abb9c Merge pull request #1812 from 1Jamie/save-editor-safe-areas-move-search
fix(save-editor): mobile safe areas, move search picker, and release window titles
2026-08-25 15:54:19 -04:00
bryanthaboi 51908faabf fix(mods): stop the Gen 2 require facade answering the engine
bootGame's require("src.core.Game") was resolving to Gen2Compat's Game
facade after a Gold session, so the next Gen 1 boot got an empty proxy
whose rawget(load) is nil (iOS: "src.core.Game missing load after
reload"; Android: Game:load called on a nil value).

Two independent causes, both needed:

  * callerIsMod decided "not under src/" meant "a mod", and main.lua is
    not under src/, so the engine's own require was gated as a mod's.
    Root chunks main.lua and conf.lua are now matched exactly, which a
    mod's own mods/<id>/main.lua cannot collide with.

  * devShim.generation was only ever set, never cleared, so the
    generation Gold declared outlived Gold's session. Loader.endSession
    drops it from SessionLifecycle.endMountedSession, and the facade
    gate now requires generation == 2 rather than ~= 1.

Only platforms that return to the launcher in-process (Android, and iOS
since #1808) keep the shim alive across sessions; everywhere else the
process restart cleared it.
2026-08-25 15:14:47 -04:00
bryanthaboi 54c7a3aeae Merge pull request #1808 from 1Jamie/fix/mobile-lc
fix(mobile): in-process launcher return for Android and iOS
2026-08-25 15:14:05 -04:00
bryanthaboi 5cc33af354 Merge pull request #1809 from 1Jamie/yellow-fixes
fix(yellow): address version:yellow issues and title pupil bake
2026-08-25 14:00:48 -04:00
1jamie 93c7dff4d6 fix(tests): keep yellow-fixes suites ROM-free and dig-fade accurate
Drop Data:load from #1635/#1743 engine tests so headless CI without
data/generated/ can run them, and relax dig's #916 hide-ratio check for
white fade-in under spinDrop (#1644).
2026-08-25 12:58:03 -05:00
1jamie b796c30278 fix(save-editor): mobile safe areas, move search picker, and release window titles
Inset save-editor chrome and pickers into SafeArea so Save stays tappable on punch-hole phones (#917), replace move cycling with a ranked search modal that reflows on phones/RGxxx, and omit the engine version from window titles on stamped release builds.
2026-08-25 12:43:05 -05:00
HighDrexler ec6b03f84b fix: stream large required imports on mobile 2026-08-25 13:22:45 -04:00
1jamie 69c7ee89b5 fix(yellow): address version:yellow issues and title pupil bake
Seed Mt Moon Super Nerd dialogue (#1743), play item-use heal jingles
(#1635), use white fades for fly/teleport warps (#1644), add a
Yellow-only Advanced palette overlay (#1639), parse Yellow Super Rod
data including Safari Dragonair (#1074), and apply title rOBP0 when
baking Pikachu eye OAM so pupils stay black with white glints.
2026-08-25 11:31:48 -05:00
1jamie 28dc9b9bb0 fix(mobile): in-process launcher return for Android and iOS
Stop joining ChipAudio on every EXIT GAME, always rebuild the Gen1 Game
module on Play-again, and treat iOS like Android for return-to-launcher so
release/mobile LOVE restarts no longer wipe Game.load or crash on quit.
2026-08-25 11:25:08 -05:00
DESKTOP-8SRFDDM\cam95 2ca07cfd09 engine: battle.style and catch.nickname rule hooks (RFC 0015)
Two decisions the OPTION screen and the cart make for the player, made
hookable so a game mode can make them instead: whether a faint offers the
SHIFT free switch, and whether a catch asks for a nickname.  Guarded call
sites, file-local vanilla links, docs and a public-API modkit case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
2026-08-25 09:20:35 -05:00
github-actions b3159c19fd chore(ios): update app-repo.json [skip ci] 2026-08-25 09:48:45 -04:00
bryanthaboi 08121faa3d Merge pull request #1806 from bryanthaboi/dev
bugs and shader fix
2026-08-25 09:33:02 -04:00
bryanthaboi bbe0f0d9ae crystal intro fix 2026-08-25 09:30:37 -04:00
bryanthaboi b35fab845a fix deez tests 2026-08-25 09:21:33 -04:00
bryanthaboi d622e83a8e shaderfix 2026-08-25 09:16:59 -04:00
bryanthaboi 9dd38e06a5 Merge pull request #1767 from MaxTomahawk/adaptive-trainers/dataset-view-api
feat(mod-api): expose imported dataset views
2026-08-25 08:26:51 -04:00
bryanthaboi 6b4796b08f Merge pull request #1782 from AverageConsumer/codex/gen2-party-reorder-api
Add Gen 2 party reorder API parity
2026-08-25 08:10:54 -04:00
bryanthaboi ab61d08fe9 Merge pull request #1786 from 1Jamie/more-android-lc
fix(android): keep Gen1 Game.load after in-process EXIT GAME
2026-08-25 08:10:21 -04:00
bryanthaboi c94ff016d9 Merge pull request #1794 from 1Jamie/pika-surf-title-import
Enhance Surfing Pikachu minigame assets and functionality
2026-08-25 08:09:39 -04:00
bryanthaboi 85e47bc422 Merge pull request #1799 from campavao/rfc-0018-catch-party-full
engine: catch.party_full — custody of a catch the party cannot hold
2026-08-25 08:08:15 -04:00
bryanthaboi 1850b50ee6 CLOSES #941, CLOSES #1081, CLOSES #1349, CLOSES #1602, CLOSES #1618, CLOSES #1624, CLOSES #1630, CLOSES #1633, CLOSES #1661, CLOSES #1669, CLOSES #1676, CLOSES #1731, CLOSES #1739, CLOSES #1740, CLOSES #1742, CLOSES #1744, CLOSES #1753, CLOSES #1757, CLOSES #1758, CLOSES #1760, CLOSES #1761, CLOSES #1762, CLOSES #1763, CLOSES #1765, CLOSES #1770, CLOSES #1777, CLOSES #1781, CLOSES #1783, CLOSES #1784, CLOSES #1785, CLOSES #1787, CLOSES #1788, CLOSES #1789, CLOSES #1790, CLOSES #1792, CLOSES #1797, CLOSES #1803 2026-08-25 07:55:52 -04:00
MaxTomahawk ef62962010 Merge upstream dev into dataset view API 2026-08-25 11:54:22 +00:00
bryanthaboi ba201aa4ad issue template: add Silver and Crystal to the version dropdown 2026-08-25 06:33:08 -04:00
DESKTOP-8SRFDDM\cam95 7d6566fa51 engine: catch.party_full custody for a full-party catch (RFC 0018)
A capture the party cannot hold silently falls through to the box; the
new partyFullDestination seam lets a mode claim custody at that moment
instead, and pokemon.caught reports destination "mod" so the mode can
find the mon again.  Guarded call site, file-local vanilla, docs and a
public-API modkit case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
2026-08-24 21:06:07 -05:00
1jamie 1ad6c810fa fix SurfingMinigame column definitions 2026-08-24 17:30:09 -05:00
1jamie 233082967c Enhance Surfing Pikachu minigame assets and functionality
- Updated .gitignore to include new generated assets for the Surfing Pikachu minigame.
- Added new image assets for the minigame, including title background and intro frames.
- Implemented extraction of Surfing Pikachu title art in RomExtractor.
- Updated CacheContract to include new asset paths.
- Enhanced SurfingMinigame with new constants and functions for improved gameplay mechanics.
- Added unit tests for new features and ensured existing tests pass.
2026-08-24 17:24:11 -05:00
1jamie d76d173b60 fix(android): keep Gen1 Game.load after in-process EXIT GAME
Stop fanning out arbitrary field:release() during Game/Game2 reset — shared
modules use :release as a handle API, and that teardown left the Gen1
singleton unbootable (Game:load nil) on Play-again. Harden bootGame and
endGameSession to rebuild the module if load is missing.
2026-08-24 14:33:43 -05:00
github-actions bc7b96c963 chore(ios): update app-repo.json [skip ci] 2026-08-24 13:18:39 -04:00
AverageConsumer 56fc4653d7 Add Gen 2 party reorder API parity 2026-08-24 19:07:56 +02:00
MaxTomahawk 68fc01dd1c fix(mod-api): silence optional dataset absence 2026-08-24 10:25:37 +02:00
MaxTomahawk cec19db5e0 test(mod-api): cover reserved metadata replace 2026-08-24 10:19:33 +02:00
MaxTomahawk 38d515f547 fix(mod-api): reserve generated metadata ids 2026-08-24 10:10:23 +02:00
MaxTomahawk 7e069df740 fix(mod-api): validate dataset roots lazily 2026-08-24 09:57:57 +02:00
MaxTomahawk ca700c44d7 fix(mod-api): harden imported dataset views 2026-08-24 09:33:51 +02:00
MaxTomahawk b286e6584f feat(mod-api): add imported dataset views 2026-08-24 09:10:35 +02:00
258 changed files with 15008 additions and 1845 deletions
+2
View File
@@ -33,6 +33,8 @@ body:
- Blue - Blue
- Yellow - Yellow
- Gold - Gold
- Silver
- Crystal
- N/A - N/A
validations: validations:
required: true required: true
+50
View File
@@ -333,6 +333,56 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
linux-flatpak-changes:
name: detect Linux Flatpak 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 '^(scripts/build_flatpak\.sh$|flatpak/|scripts/pack_love\.sh$|docs/linux-flatpak\.md$|\.github/workflows/(ci|release)\.yml$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
linux-flatpak-build:
name: Linux Flatpak build
needs: linux-flatpak-changes
if: needs.linux-flatpak-changes.outputs.changed == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Install flatpak-builder deps
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick
- name: Build Flatpak bundle
run: |
set -euo pipefail
scripts/build_flatpak.sh --version 0.0.0
- name: Upload the Flatpak
uses: actions/upload-artifact@v7
with:
name: gen1recomp-linux-flatpak
path: |
dist/flatpak/gen1recomp-0.0.0-linux.flatpak
dist/flatpak/gen1recomp-0.0.0-linux.flatpak.sha256
if-no-files-found: error
retention-days: 7
headless: headless:
name: headless suites (no ROM) name: headless suites (no ROM)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+174 -5
View File
@@ -143,7 +143,7 @@ jobs:
linux-arm64: linux-arm64:
name: build Linux arm64 AppImage name: build Linux arm64 AppImage
needs: [version, love-payload] needs: [version, love-payload, shaderfx-bridge]
# GitHub's free arm64 runner for public repos. It has to be arm64: the # GitHub's free arm64 runner for public repos. It has to be arm64: the
# AppImage compiles LÖVE natively inside a Debian bullseye arm64 # AppImage compiles LÖVE natively inside a Debian bullseye arm64
# container, and the qemu-emulated alternative takes hours. # container, and the qemu-emulated alternative takes hours.
@@ -155,13 +155,22 @@ jobs:
with: with:
name: gen1recomp-release-love name: gen1recomp-release-love
path: .bazinga/work path: .bazinga/work
- name: Download the ShaderFX bridge
uses: actions/download-artifact@v8
with:
name: shaderfx-bridge-linux-arm64
path: dist/native/linux-arm64
- name: Build Linux arm64 AppImage - name: Build Linux arm64 AppImage
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
scripts/build_linux_arm64.sh \ scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \ --version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love --game-love .bazinga/work/game.love
- name: Verify the AppImage is self-contained and bullseye-compatible - name: Verify the AppImage is self-contained and bullseye-compatible
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage" run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
- name: Upload Linux arm64 release - name: Upload Linux arm64 release
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -173,6 +182,45 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
linux-flatpak:
name: build Linux Flatpak
needs: [version, love-payload, shaderfx-bridge]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Install flatpak-builder deps
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: .bazinga/work
- name: Download the ShaderFX bridge
uses: actions/download-artifact@v8
with:
name: shaderfx-bridge-linux-x64
path: dist/native/linux-x64
- name: Build Flatpak bundle
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: |
set -euo pipefail
scripts/build_flatpak.sh \
--version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love
- name: Upload Flatpak release
uses: actions/upload-artifact@v7
with:
name: gen1recomp-linux-flatpak-release
path: |
dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak
dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak.sha256
if-no-files-found: error
retention-days: 1
xbox-uwp: xbox-uwp:
name: build Xbox UWP release name: build Xbox UWP release
needs: [version, love-payload] needs: [version, love-payload]
@@ -286,8 +334,85 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
shaderfx-bridge:
name: build ShaderFX bridge (${{ matrix.plat }})
needs: version
strategy:
fail-fast: false
matrix:
include:
- plat: win-x64
runs-on: windows-2022
lib: librashader_bridge.dll
- plat: mac
runs-on: macos-latest
lib: liblibrashader_bridge.dylib
- plat: linux-x64
runs-on: ubuntu-24.04
lib: liblibrashader_bridge.so
glibc_triple: x86_64-unknown-linux-gnu.2.17
- plat: linux-arm64
runs-on: ubuntu-24.04-arm
lib: liblibrashader_bridge.so
glibc_triple: aarch64-unknown-linux-gnu.2.17
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v7
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry and build dir
uses: Swatinem/rust-cache@v2
with:
workspaces: tools/shaderfx-bridge
key: ${{ matrix.plat }}-${{ matrix.glibc_triple }}
- name: Build the bridge
shell: bash
env:
PLAT: ${{ matrix.plat }}
LIB: ${{ matrix.lib }}
GLIBC_TRIPLE: ${{ matrix.glibc_triple }}
run: |
set -euo pipefail
out="dist/native/$PLAT"
mkdir -p "$out"
cd tools/shaderfx-bridge
case "$PLAT" in
win-x64)
rustup target add x86_64-pc-windows-msvc
cargo build --release --target x86_64-pc-windows-msvc
cp "target/x86_64-pc-windows-msvc/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB"
;;
mac)
rustup target add x86_64-apple-darwin aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
lipo -create -output "$GITHUB_WORKSPACE/$out/$LIB" \
"target/x86_64-apple-darwin/release/$LIB" \
"target/aarch64-apple-darwin/release/$LIB"
lipo -info "$GITHUB_WORKSPACE/$out/$LIB"
;;
linux-x64|linux-arm64)
pipx install cargo-zigbuild
pipx inject cargo-zigbuild ziglang
zigpy="$(pipx environment --value PIPX_LOCAL_VENVS)/cargo-zigbuild/bin/python"
"$zigpy" -m ziglang version
export CARGO_ZIGBUILD_PYTHON_PATH="$zigpy"
triple="$GLIBC_TRIPLE"
rustup target add "${triple%%.*}"
cargo zigbuild --release --target "$triple"
cp "target/${triple%%.*}/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB"
;;
esac
- name: Upload the bridge
uses: actions/upload-artifact@v7
with:
name: shaderfx-bridge-${{ matrix.plat }}
path: dist/native/${{ matrix.plat }}/${{ matrix.lib }}
if-no-files-found: error
retention-days: 1
release: release:
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win] needs: [version, love-payload, xbox-uwp, linux-arm64, linux-flatpak, native-tls-win, shaderfx-bridge]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps: steps:
@@ -309,6 +434,28 @@ jobs:
name: gen1tls-win-x64 name: gen1tls-win-x64
path: dist/native/win-x64 path: dist/native/win-x64
- name: Download ShaderFX bridge libraries
uses: actions/download-artifact@v8
with:
pattern: shaderfx-bridge-*
path: dist/native
- name: Flatten the ShaderFX bridge artifact layout
run: |
set -euo pipefail
shopt -s nullglob
for d in dist/native/shaderfx-bridge-*/; do
d="${d%/}"
plat="${d#dist/native/shaderfx-bridge-}"
mkdir -p "dist/native/$plat"
for f in "$d"/*; do mv "$f" "dist/native/$plat/"; done
rmdir "$d"
done
for plat in mac win-x64 linux-x64 linux-arm64; do
ls "dist/native/$plat" >/dev/null \
|| { echo "::error::no ShaderFX bridge staged for $plat"; exit 1; }
done
ls -lR dist/native
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so # 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 # every release asset ships one identical payload (build.sh's own pack
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json). # would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
@@ -358,6 +505,7 @@ jobs:
- name: Build macOS + Windows + Linux - name: Build macOS + Windows + Linux
env: env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain); # Sign in-build (identity auto-detected from the temp keychain);
@@ -372,6 +520,8 @@ jobs:
--game-love dist/payload/game.love --game-love dist/payload/game.love
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \ unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } || { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
unzip -l dist/win/gen1recomp-win64.zip | grep -F librashader_bridge.dll \
|| { echo "::error::Windows zip is missing librashader_bridge.dll"; exit 1; }
- name: Materialize Android release signing key - name: Materialize Android release signing key
env: env:
@@ -429,6 +579,8 @@ jobs:
--version "${{ needs.version.outputs.version }}" --version "${{ needs.version.outputs.version }}"
- name: Build Anbernic RG34XXSP port - name: Build Anbernic RG34XXSP port
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5 # Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
@@ -442,6 +594,7 @@ jobs:
# builds, while this explicit local override keeps CI source-aligned. # builds, while this explicit local override keeps CI source-aligned.
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }} GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }} GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build # Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
@@ -498,6 +651,13 @@ jobs:
name: gen1recomp-linux-arm64-release name: gen1recomp-linux-arm64-release
path: dist/linux-arm64 path: dist/linux-arm64
- name: Download Linux Flatpak release
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/download-artifact@v8
with:
name: gen1recomp-linux-flatpak-release
path: dist/flatpak
- name: Stage release assets - name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
id: assets id: assets
@@ -509,16 +669,24 @@ jobs:
mkdir -p "$outdir" mkdir -p "$outdir"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip" cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip" cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip" # x86_64 desktop Linux: raw AppImage (no zip wrapper).
x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage"
[ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; }
cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on # arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
# its own runner because LÖVE publishes no aarch64 binary and the # its own runner because LÖVE publishes no aarch64 binary and the
# AppImage has to be compiled natively; ships as a runnable # AppImage has to be compiled natively; ships as a runnable
# AppImage rather than a zip so `chmod +x && ./it` just works. # AppImage so `chmod +x && ./it` just works.
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage" arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
flatpak_bundle="dist/flatpak/gen1recomp-${v}-linux.flatpak"
[ -f "$flatpak_bundle" ] || { echo "::error::$flatpak_bundle not found (expected from the linux-flatpak job)"; exit 1; }
cp "$flatpak_bundle" "$outdir/gen1recomp-${v}-linux.flatpak"
apk="$(find dist/android/release -name '*.apk' | head -1)" apk="$(find dist/android/release -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
@@ -656,8 +824,9 @@ jobs:
release_files=( release_files=(
"dist/release/gen1recomp-${v}-macos.zip" "dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip" "dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip" "dist/release/gen1recomp-${v}-linux-x86_64.AppImage"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage" "dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-linux.flatpak"
"dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp++-${v}-ios.ipa" "dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip" "dist/release/gen1recomp-${v}-switch.zip"
+9 -2
View File
@@ -1,5 +1,9 @@
# Generated from a user-provided Pokemon Red ROM. # Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal).
# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean # Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean
# Yellow Surfing Pikachu minigame art also lands here on import:
# assets/generated/minigame/surf_1{a,b,c}.png
# assets/generated/minigame/title_bg.png
# assets/generated/minigame/intro_pika_{0,1,2,3}.png
data/generated/ data/generated/
assets/generated/ assets/generated/
@@ -62,6 +66,9 @@ mobile/dist/
# ROM cache exactly like data/generated/, so it is never committable. # ROM cache exactly like data/generated/, so it is never committable.
/build/tiled/ /build/tiled/
# Local visual scratch from minigame development (not wired to CI).
/tests/fixtures/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh) # per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local mobile/ios/bundle_id.local
+20 -11
View File
@@ -1,19 +1,19 @@
# Gen1Recomp # Gen1Recomp
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map A native LÖVE2D recreation of Poke Red, Blue, Gold, Silver, and Crystal. The
behavior are hand-written Lua; game data and graphics are decoded from a ROM engine and map behavior are hand-written Lua; game data and graphics are
supplied by the player. decoded from a ROM supplied by the player.
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems*** And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
[Click Here for the AI Use Disclosure!](AIDisclosure.md) [Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION] > [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website. > **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository, the Discord, and https://gen1re.com are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p> <p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu) # [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)
<p align="center"> <p align="center">
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
### Watch the latest update video ### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM) [![Watch the latest update video](https://img.youtube.com/vi/0hgtLDNGpdo/maxresdefault.jpg)](https://youtu.be/0hgtLDNGpdo)
This project does not include a ROM, emulate the Game Boy, transpile assembly, This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
@@ -228,18 +228,27 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
## Linux on arm64 (Raspberry Pi) ## Linux desktop (AppImage / Flatpak)
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle:
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon: - `gen1recomp-*-linux-x86_64.AppImage`
- `gen1recomp-*-linux-arm64.AppImage` (Raspberry Pi 4/5, Armbian, arm64 VMs)
- `gen1recomp-*-linux.flatpak` (see [docs/linux-flatpak.md](docs/linux-flatpak.md))
```sh
chmod +x gen1recomp-*-linux-x86_64.AppImage
./gen1recomp-*-linux-x86_64.AppImage
```
```sh ```sh
chmod +x gen1recomp-*-linux-arm64.AppImage chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage ./gen1recomp-*-linux-arm64.AppImage
``` ```
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the Shared troubleshooting (FUSE, curl, portable mode): [docs/linux-appimage.md](docs/linux-appimage.md).
LÖVE publishes no aarch64 binary of any kind, so the arm64 artifact compiles the
engine — and SDL2, OpenAL and the codecs — from source inside a Debian engine — and SDL2, OpenAL and the codecs — from source inside a Debian
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
+186 -50
View File
@@ -192,6 +192,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
BRIDGE_LIB="liblibrashader_bridge.so"
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
fi
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB"
say "bundled $BRIDGE_LIB for SHADER FX preset conversion"
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
else
warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones"
fi
# Drop a short license pointer for the bundled LÖVE bits. # Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
@@ -203,81 +217,203 @@ EOF
# Resolve the game directory from the launcher so this works with both # Resolve the game directory from the launcher so this works with both
# PortMaster-managed ports directories. # PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF' cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash #!/usr/bin/env bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher # gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter. # Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
export HOME="${HOME:-/root}" export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
SHDIR="$(cd "$(dirname "$0")" && pwd)" SHDIR="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Data/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
fi
if [ ! -f "$controlfolder/control.txt" ]; then
echo "PortMaster control.txt not found under $controlfolder" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
source "${controlfolder}/mod_${CFW_NAME}.txt"
fi
GAMEDIR="$SHDIR/gen1recomp-sbc" GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf" CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR" mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1 cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
# Setup POSIX logging (compatible with busybox ash/dash/bash)
exec 1>"$GAMEDIR/log.txt" 2>&1
echo "========================================="
echo "gen1recomp Handheld Native ARM64 Launcher"
echo "SHDIR: $SHDIR"
echo "GAMEDIR: $GAMEDIR"
echo "Date: $(date 2>/dev/null || echo 'N/A')"
echo "========================================="
# PortMaster's control.txt wants a home tree under /mnt/SDCARD/Data; on a
# fresh card that tree may not exist yet and its mkdir fails noisily (log.txt:
# "mkdir: can't create directory '/mnt/SDCARD/Data/home'"). Pre-create it
# before any PortMaster script runs so the failure cannot occur.
if [ -d /mnt/SDCARD ]; then
mkdir -p /mnt/SDCARD/Data/home 2>/dev/null || true
fi
# 1. Locate PortMaster control directory if present
controlfolder=""
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/PortMaster/PortMaster" ]; then
controlfolder="/mnt/SDCARD/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/PortMaster" ]; then
controlfolder="/mnt/SDCARD/PortMaster"
elif [ -d "/mnt/SDCARD/Apps/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/PORTS/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Emus/PORTS/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/PORTMASTER" ]; then
controlfolder="/mnt/SDCARD/Emus/PORTMASTER"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
elif [ -d "/mnt/SDCARD/Ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Ports/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster"
elif [ -d "/roms/PORTS/PortMaster" ]; then
controlfolder="/roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
fi
if [ -n "$controlfolder" ] && [ -f "$controlfolder/control.txt" ]; then
echo "Found PortMaster control folder: $controlfolder"
# shellcheck disable=SC1090
. "$controlfolder/control.txt"
if type get_controls >/dev/null 2>&1; then
get_controls
fi
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
. "${controlfolder}/mod_${CFW_NAME}.txt"
fi
else
echo "PortMaster control.txt not found, using standalone handheld configuration."
fi
# 2. CPU governor
# Pinning every core to performance keeps handhelds at peak clock even when gameplay
# uses only a fraction of CPU. Schedutil is the default; set POKEPORT_CPU_GOVERNOR=performance
# only for profiling or devices needing an extra boost.
CPU_GOVERNOR_OVERRIDE="${POKEPORT_CPU_GOVERNOR:-schedutil}"
CPU_GOVERNOR_STATE=""
write_cpu_governor() {
local governor_path="$1"
local governor="$2"
if [ -n "${ESUDO:-}" ]; then
printf '%s\n' "$governor" | $ESUDO tee "$governor_path" >/dev/null 2>&1 || true
else
printf '%s\n' "$governor" > "$governor_path" 2>/dev/null || true
fi
}
restore_cpu_governors() {
[ -n "$CPU_GOVERNOR_STATE" ] || return 0
while IFS='|' read -r governor_path governor; do
[ -n "$governor_path" ] || continue
write_cpu_governor "$governor_path" "$governor"
done <<< "$CPU_GOVERNOR_STATE"
CPU_GOVERNOR_STATE=""
}
apply_cpu_governors() {
[ -n "$CPU_GOVERNOR_OVERRIDE" ] || return 0
for governor_path in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
[ -f "$governor_path" ] || continue
old_governor=$(cat "$governor_path" 2>/dev/null || true)
[ -n "$old_governor" ] || continue
CPU_GOVERNOR_STATE="${CPU_GOVERNOR_STATE}${governor_path}|${old_governor}"$'\n'
write_cpu_governor "$governor_path" "$CPU_GOVERNOR_OVERRIDE"
done
echo "CPU governor requested: $CPU_GOVERNOR_OVERRIDE"
}
# 3. Environment & Low-Level OS Tuning
export POKEPORT_HANDHELD=1
export HANDHELD=1
export PORTMASTER=1
export NINTENDO_LAYOUT=1
export XDG_DATA_HOME="$CONFDIR" export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR" export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# GLES is the common path on ARM SBC handhelds; firmware may override it.
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 # Audio buffer size
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true export SDL_AUDIO_SAMPLES=1024
if [ -n "${GPTOKEYB:-}" ]; then # Chip-synth sample rate. The audio worker is the dominant CPU cost while
$GPTOKEYB "love.aarch64" & # idle (music keeps playing when the screen is static), and synthesis cost
# scales linearly with the rate. 22050 Hz roughly halves audio CPU vs the
# 44100 Hz default; the Game Boy's own DAC content is well below 11 kHz, so
# the handheld speaker sounds effectively identical.
export POKEPORT_AUDIO_RATE=22050
# Idle-power render governor: after 10s with no input on static screens,
# presentation drops to IDLE_FPS, cutting idle CPU/GPU compositing ~6x.
# Any button press restores full framerate next frame.
export POKEPORT_IDLE_FPS=6
# Memory allocator tuning (limit arena fragmentation on 1GB RAM SBCs)
export MALLOC_ARENA_MAX=2
export MALLOC_TRIM_THRESHOLD_=131072
# Process scheduling & CPU affinity
renice -n -5 -p $$ >/dev/null 2>&1 || true
taskset -cp 0-3 $$ >/dev/null 2>&1 || true
ionice -c 3 -p $$ >/dev/null 2>&1 || true
# Ensure executable permissions
if [ -n "${ESUDO:-}" ]; then
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || true
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
else
chmod a+x ./bin/love.aarch64 2>/dev/null || true
fi fi
# 4. Platform helper
if type pm_platform_helper >/dev/null 2>&1; then if type pm_platform_helper >/dev/null 2>&1; then
echo "Running pm_platform_helper..."
pm_platform_helper "$GAMEDIR/bin/love.aarch64" pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi fi
# Apply after PortMaster's helper: some firmware helpers reset cpufreq policy
# while preparing the runtime. The saved values are restored on exit.
apply_cpu_governors
trap restore_cpu_governors EXIT
# PulseAudio runtime: PortMaster's helper points it at conf/pulse on the vfat
# SD card, where the runtime symlink fails ("Operation not permitted" -- vfat
# has no symlinks). Force the runtime onto tmpfs (real dirs, no symlinks).
export PULSE_RUNTIME_PATH="/tmp/pulse-$PPID"
mkdir -p "$PULSE_RUNTIME_PATH" 2>/dev/null || true
echo "Launching ./bin/love.aarch64 $GAMEDIR/lovegame..."
echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH"
# 5. Launch
./bin/love.aarch64 "$GAMEDIR/lovegame" ./bin/love.aarch64 "$GAMEDIR/lovegame"
EXIT_CODE=$?
echo "Game exited with code: $EXIT_CODE"
# 6. Cleanup
restore_cpu_governors
if type pm_finish >/dev/null 2>&1; then if type pm_finish >/dev/null 2>&1; then
pm_finish pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi fi
exit $EXIT_CODE
EOF EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME" chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
+14
View File
@@ -156,6 +156,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
BRIDGE_LIB="liblibrashader_bridge.so"
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
fi
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB"
say "bundled $BRIDGE_LIB for SHADER FX preset conversion"
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
else
warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones"
fi
# Drop a short license pointer for the bundled LÖVE bits. # Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
+10 -1
View File
@@ -62,7 +62,16 @@ function love.conf(t)
t.window.minheight = 360 t.window.minheight = 360
end end
t.version = love._os == "iOS" and "12.0" or "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 -- On Linux handhelds with KMSDRM/EGL, driver vsync blocks the CPU thread with
-- busy-waiting spinloops inside eglSwapBuffers. Disabling driver vsync allows
-- love.run to perform kernel nanosleep(), dropping idle CPU from 25% to 4%.
if os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" then
t.window.vsync = 0
else
t.window.vsync = 1
end
t.modules.audio = not companion t.modules.audio = not companion
t.modules.joystick = not companion t.modules.joystick = not companion
t.modules.physics = false t.modules.physics = false
+765
View File
@@ -0,0 +1,765 @@
return {
{ name = "BGB", palettes = {
{ "BGB_v0.3", "142c3c548c74acd494ecfccc" },
{ "Blue", "0000600000c05f5fffc0c0ff" },
{ "GB_Kiosk", "1434046c6c04bcbc1cececb4" },
{ "GB_Light", "004f3b00694a009a7100b581" },
{ "Grey", "1414145c5c5ca4a4a4ececec" },
{ "Greys_High_Contrast", "000000686868b0b0b0ffffff" },
{ "KIG_Green", "20402030703040a04050d050" },
{ "LCD_Green", "0c1c24346c548cc474e4fcd4" },
{ "Red", "600000c00000ff6060ffc0c0" },
{ "Super_GB", "311852ad2921de944affefce" },
{ "Yellow", "202010686830b0a848f8f078" },
{ "Yellow_BGB", "556f3593a73bd2cf4df4ebbf" },
{ "NGM_Brown", "483818987838d8b058f8e088" },
} },
{ name = "BGB/Inverted", palettes = {
{ "BGB_v0.3 - Inverted", "ecfcccacd494548c74142c3c" },
{ "Blue - Inverted", "c0c0ff5f5fff0000c0000060" },
{ "GB_Kiosk - Inverted", "ececb4bcbc1c6c6c04143404" },
{ "GB_Light - Inverted", "00b581009a7100694a004f3b" },
{ "Grey - Inverted", "ecececa4a4a45c5c5c141414" },
{ "Greys_High_Contrast - Inverted", "ffffffb0b0b0686868000000" },
{ "KIG_Green - Inverted", "50d05040a040307030204020" },
{ "LCD_Green - Inverted", "e4fcd48cc474346c540c1c24" },
{ "Red - Inverted", "ffc0c0ff6060c00000600000" },
{ "Super_GB - Inverted", "ffefcede944aad2921311852" },
{ "Yellow - Inverted", "f8f078b0a848686830202010" },
{ "Yellow_BGB- Inverted", "f4ebbfd2cf4d93a73b556f35" },
{ "NGM_Brown - Inverted", "f8e088d8b058987838483818" },
} },
{ name = "GalleryWebApp Palettes", palettes = {
{ "Artistic Caffeinated Lactose", "2416069e754fdea963fdfef5" },
{ "AQP Pikes Peak", "2a201efa2b25868779ebeee7" },
{ "Azure Clouds", "00000012412732b66d47ff99" },
{ "BGB Emulator", "08182034685688c070e0f8d0" },
{ "Black & White", "000000555555aaaaaaffffff" },
{ "Black Zero", "2e463d385d49577b467e8416" },
{ "CGA Palette Crush 1", "000000ff55ff55ffffffffff" },
{ "CGA Palette Crush 2", "000000ff555555ffffffffff" },
{ "CMYKeystone", "373737fb00fa0be8fdffff00" },
{ "Candy Cotton Tower Raid", "3800168f0039e65790e6aec4" },
{ "Caramel Fudge Paranoia", "3f1711b01553cf7163cf9255" },
{ "Childhood in Greenland", "405010708028a0a840d0d058" },
{ "Cyanide Blues", "0e1e3d1e479321aff59efbe3" },
{ "Deep Haze Green", "00000027421f467818a1d909" },
{ "Dies ist meine Wassermelone", "222903558429f27d7affdbcb" },
{ "Drowning at Night", "030c2220293f586164a9b0b3" },
{ "DUN 2000 Remastered", "000000725441c09e7dfbf1cd" },
{ "Flowerfeldstrasse", "171f6275868fc5c5cee9d9cc" },
{ "Floyd Steinberg in Love", "4c1c2dd23c4e5fb1f5eaf5fa" },
{ "GB Light", "0b7a6d16a59619c7b31ddece" },
{ "GB Pocket", "1f1f1f4d533c8b956dc4cfa1" },
{ "Glowing Mountains", "2f1c35514f6ca1a8b8ffbf98" },
{ "Golden Elephant Curry", "0000004f3000c27600ff9c00" },
{ "Grafixkid Gray", "2b2b26706b66a89f94e0dbcd" },
{ "Grafixkid Green", "4c625a7b9278abc396dbf4b4" },
{ "Knee-Deep in the Wood", "2c24103c3ca9d5690ffffe6e" },
{ "LNK late Awakening", "5a39216b8c427bc67bffffb5" },
{ "MTR Aran Remixed", "2c1700b62558047e60aedf1e" },
{ "My Friend from Bavaria", "382977b60077df7925feda1b" },
{ "Nortorious Comadante", "0402ac04aaac54fefcfcfe54" },
{ "Original GB", "0f380f30623077a1129bbc0f" },
{ "Purple Rain", "68006aff00848570b2adfffc" },
{ "Romeros Garden", "323727574431649a57ebc4ab" },
{ "Rusted City Rain", "2c2137764462a96868edb4a1" },
{ "Space Haze Overload", "0b06306b1fb1cc3495f8e3c4" },
{ "Starlit Memories", "4f133f6f20966d53bd869ad9" },
{ "Sunflower Holidays", "000000881400ff5555ffff55" },
{ "Super Hyper Mega GB", "331e50a63725d68e49f7e7c6" },
{ "The death of Yung Columbus", "1d1414462917ff2261b5ff32" },
{ "The starry knight", "123f770f86b637cae5f5db37" },
{ "There is Always Money", "221a09977b25fed638fdfe0a" },
{ "Tramonto al Parco degli Acquedotti", "0c0a3e912978e64a4ef3c677" },
{ "VTB 1985", "000000520000db0000ff0000" },
{ "Waterfront Plaza", "10253342678e6f9edfcecece" },
{ "Youth Ikarus reloaded", "1e00009e0000f78e50cef7f7" },
} },
{ name = "GB Color (Combo Palettes)", palettes = {
{ "GBC - Down + A", "000000ff0000ffff00ffffff" },
{ "GBC - Down + B", "0000007b4a00ffff00ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC - Down", "0000009494ffff9494ffffa5" },
{ "GBC - Left + A", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
{ "GBC - Left + B", "000000525252a5a5a5ffffff" },
{ "GBC - Left", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC - Right + A", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC - Right + B", "ffffffffde00008484000000" },
{ "GBC - Right", "000000ff420052ff00ffffff" },
{ "GBC - Up + A", "000000943a3aff8484ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
{ "GBC - Up + B", "5a3108846b29ce9c84ffe6c5", "000000843100ffad63ffffff", "000000843100ffad63ffffff" },
{ "GBC - Up", "000000843100ffad63ffffff" },
} },
{ name = "GB Color (Unique Palettes)", palettes = {
{ "GBC Palette 001 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 002 (World)", "000000006300ffff00a59cff" },
{ "GBC Palette 003 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 004 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 005 (USA)", "ffffffffde00008484000000" },
{ "GBC Palette 006 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 007 (USA, Europe)", "000000ff0000ff9c00ffffff" },
{ "GBC Palette 008 (World)", "ffffffffff00ff840052de00", "0000ff63a5ffffffffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 009 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 010 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 011 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 012 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 013 (World) (Rev A)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 014 (World)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 015 (Japan)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 016 (USA, Europe)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
{ "GBC Palette 017 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 018 (USA, Europe) (Rev A)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 019 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 020 (World) (Rev A)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 021 (World)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 022 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 023 (World) (Rev A)", "000000843100ffad63ffffff" },
{ "GBC Palette 024 (World)", "000000843100ffad63ffffff" },
{ "GBC Palette 025 (USA)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 026 (Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 027 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 028 (Australia)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 029 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 030 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 031 (Europe)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 032 (USA) (Rev A)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 033 (USA)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 034 (World)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 035 (Japan) (Rev A)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 036 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 037 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 038 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 039 (Japan)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 040 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 041 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 042 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 043 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 044 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 045 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 046 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 047 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
{ "GBC Palette 048 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 049 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 050 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 051 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
{ "GBC Palette 052 (USA, Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 053 (France)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 054 (Germany)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 055 (USA, Europe) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 056 (USA, Europe) (Rev B)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 057 (USA, Europe)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 058 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 059 (Europe)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 060 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 061 (USA, Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 062 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 063 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 064 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 065 (World)", "0000000000ff63a5ffffffff", "000000630000ff0000ffff00", "0000000084007bff31ffffff" },
{ "GBC Palette 066 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
{ "GBC Palette 067 (USA, Europe)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
{ "GBC Palette 068 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 069 (France)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 070 (Germany)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 071 (Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 072 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 073 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 074 (USA)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 075 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 076 (Europe)", "00000052528c8c8cdeffffff", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542" },
{ "GBC Palette 077 (USA)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 078 (Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "4a0000943a00ffd600ffc542" },
{ "GBC Palette 079 (Japan) (Rev A)", "0000009c6300ffce00ffffff" },
{ "GBC Palette 080 (Japan) (Rev 0A)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 081 (Japan) (Rev B)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 082 (Japan) (Rev C)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 083 (Japan) (Rev D)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 084 (Japan) (Rev A)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 085 (Japan)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 086 (Japan)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 087 (Japan) (Rev A)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 088 (Japan)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 089 (Germany)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 090 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 091 (Spain)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 092 (Spain)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 093 (USA, Europe)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 094 (Germany)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 095 (France)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 096 (France)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 097 (Italy)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 098 (Italy)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 099 (World)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 100 (Japan)", "003a3a63947394b5ffffff9c" },
{ "GBC Palette 101 (USA, Europe)", "003a3a63947394b5ffffff9c" },
{ "GBC Palette 102 (Europe) (En,Fr,De)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 103 (World)", "ffffffffde00008484000000" },
{ "GBC Palette 104 (Europe)", "ffffffffde00008484000000" },
{ "GBC Palette 105 (USA)", "ffffffffde00008484000000" },
{ "GBC Palette 106 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 107 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 108 (Japan)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
{ "GBC Palette 109 (World) (Rev A)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
{ "GBC Palette 110 (World)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
{ "GBC Palette 111 (USA, Europe) (Rev A)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 112 (USA, Europe) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 113 (USA, Europe)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 114 (Japan) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 115 (Japan)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 116 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 117 (World)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 118 (World) (Rev A)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 119 (World)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 120 (Europe)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 121 (USA)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 122 (USA)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 123 (USA, Europe) (Rev A)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 124 (USA, Europe)", "000000ff0000ff9c00ffffff" },
{ "GBC Palette 125 (Japan)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 126 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 127 (USA)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 128 (Europe)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 129 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 130 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 131 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 132 (World)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 133 (USA, Europe)", "00000042737badad84ffffff", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 134 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "ff00000084ffffff7bffffff" },
{ "GBC Palette 135 (Japan)", "000000525252a5a5a5ffffff" },
{ "GBC Palette 136 (Japan) (Rev A)", "000000843100ffad63ffffff" },
{ "GBC Palette 137 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 138 (USA)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 139 (Japan)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 140 (Japan)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 141 (Japan)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 142 (USA, Europe)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 143 (Japan) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 144 (Japan)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
} },
{ name = "GB Color (Unused Palettes)", palettes = {
{ "Unused0", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "Unused1", "000000ff0000ffff00ffffff", "0000000000ff63a5ffffffff", "0000ffff00005abdffffffff" },
{ "Unused2", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
{ "Unused3", "ffffffffde00008484000000", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "Unused4", "000000525252a5a5a5ffffff" },
{ "Unused5", "0000009c6300ffce00ffffff" },
} },
{ name = "R.A.Helllord/Limited Edition Pockets", palettes = {
{ "Classic Blue LE", "0000000232741b559e5775af" },
{ "Classic Green LE", "00000001633f0b8a68549d80" },
{ "Classic Indigo LE", "0000002631725457a47b80b4" },
{ "Classic Pink LE", "000000d8648beb8cb3efa1bf" },
{ "Classic Red LE", "00000095030dbc2b2bcb5d5b" },
{ "Classic Silver LE", "000000908a85cececee0dfde" },
{ "Classic Spice Orange LE", "000000c96722e38f48e8a36c" },
{ "Classic Yellow LE", "000000cf8b08d2921de8be63" },
{ "Glow in the Dark LE", "00000005a56d4dcc808cf68a" },
{ "Transparent Blue LE", "0000001e124f361aa6646dcb", "1e124f361aa6646dcb7f8cff", "1e124f361aa6646dcb7f8cff" },
{ "Transparent Clear LE", "0000003e3b395959599c9a99", "3e3b395959599c9a99d7d7d7", "3e3b395959599c9a99d7d7d7" },
{ "Transparent Green LE", "000000003c0600b10003ce09", "003c0600b10003ce09afe4ab", "003c0600b10003ce09afe4ab" },
{ "Transparent Orange LE", "0000003c1405ad2d01e56400", "3c1405ad2d01e56400ebbf8f", "3c1405ad2d01e56400ebbf8f" },
{ "Transparent Purple LE", "0000003c2e498d6ea7a986bb", "3c2e498d6ea7a986bbe0d6e5", "3c2e498d6ea7a986bbe0d6e5" },
{ "Transparent Red LE", "0000006d1a1a991019c51d21", "6d1a1a991019c51d21eab5b1", "6d1a1a991019c51d21eab5b1" },
{ "Transparent Smoke LE", "000000282421363333686464", "282421363333686464bfbfbe", "282421363333686464bfbfbe" },
} },
{ name = "R.A.Helllord/Limited Edition Pockets Inverted", palettes = {
{ "Classic Blue LE - inverted", "5775af1b559e023274000000" },
{ "Classic Green LE - inverted", "549d800b8a6801633f000000" },
{ "Classic Indigo LE - inverted", "7b80b45457a4263172000000" },
{ "Classic Pink LE - inverted", "efa1bfeb8cb3d8648b000000" },
{ "Classic Red LE - inverted", "cb5d5bbc2b2b95030d000000" },
{ "Classic Silver LE - inverted", "e0dfdececece908a85000000" },
{ "Classic Spice Orange LE - inverted", "e8a36ce38f48c96722000000" },
{ "Classic Yellow LE - inverted", "e8be63d2921dcf8b08000000" },
{ "Glow in the Dark LE - inverted", "8cf68a4dcc8005a56d000000" },
{ "Transparent Blue LE - inverted", "646dcb361aa61e124f000000", "7f8cff646dcb361aa61e124f", "7f8cff646dcb361aa61e124f" },
{ "Transparent Clear LE - inverted", "9c9a995959593e3b39000000", "d7d7d79c9a995959593e3b39", "d7d7d79c9a995959593e3b39" },
{ "Transparent Green LE - inverted", "03ce0900b100003c06000000", "afe4ab03ce0900b100003c06", "afe4ab03ce0900b100003c06" },
{ "Transparent Orange LE - inverted", "e56400ad2d013c1405000000", "ebbf8fe56400ad2d013c1405", "ebbf8fe56400ad2d013c1405" },
{ "Transparent Purple LE - inverted", "a986bb8d6ea73c2e49000000", "e0d6e5a986bb8d6ea73c2e49", "e0d6e5a986bb8d6ea73c2e49" },
{ "Transparent Red LE - inverted", "c51d219910196d1a1a000000", "eab5b1c51d219910196d1a1a", "eab5b1c51d219910196d1a1a" },
{ "Transparent Smoke LE - inverted", "686464363333282421000000", "bfbfbe686464363333282421", "bfbfbe686464363333282421" },
} },
{ name = "R.A.Helllord/PPB", palettes = {
{ "PPB - Amber", "ffb13b8c61216043172d1f0b" },
{ "PPB - Blue", "28ccff16708c0f4b5e07242d" },
{ "PPB - Green", "00ee00008e00005f00002f00" },
{ "PPB - White", "c1ffff6a8c8c496060202b2b" },
{ "PPB inverted - Amber", "2d1f0b6043178c6121ffb13b" },
{ "PPB inverted - Blue", "07242d0f4b5e16708c28ccff" },
{ "PPB inverted - Green", "002f00005f00008e0000ee00" },
{ "PPB inverted - White", "202b2b4960606a8c8cc1ffff" },
} },
{ name = "SMY", palettes = {
{ "Desert", "3e2f30746657a49b83d2d0b1" },
{ "Evening", "012636185569539889aee4a6" },
{ "Fake Crystal", "21293129317b4a73bd9ce7f7" },
{ "Fog", "343c3756727386a39dbfd2c3" },
{ "Magic Eggplant", "36213c842e949d69c7b0e4f1" },
{ "Radioactive Pea", "00522106731f349e16b8ce03" },
{ "Seaweed", "15003f32654278a758dfe095" },
{ "Twilight", "15003f861246bd5462e9d397" },
} },
{ name = "Super GB", palettes = {
{ "1-A (Default)", "311852ad2921de944affefce" },
{ "1-B", "000000b55210ceb573dedec6" },
{ "1-C", "39399c9c3963ef9c52ffc6ff" },
{ "1-D", "521800ff0000c6844affffad" },
{ "1-E", "5a39216b8c427bc67bffdeb5" },
{ "1-F", "004210ad0000e78c52deefff" },
{ "1-G", "ffff5a7b7b0000a5ef000052" },
{ "1-H", "311800844200ffbd8cffefe7" },
{ "2-A", "000000297b00c68c4af7cea5" },
{ "2-B", "52005aff3100ffef52ffffff" },
{ "2-C", "29299c7b31efef8c8cffc6ff" },
{ "2-D", "000052ff310000ff00ffffa5" },
{ "2-E", "10081029106394b5e7ffce84" },
{ "2-F", "180000a50000ff9452d6ffff" },
{ "2-G", "001800e7bd84e752426bbd39" },
{ "2-H", "000000737373bdbdbdffffff" },
{ "3-A", "314a63ff632973c6c6ffd69c" },
{ "3-B", "001010005200e78421dedec6" },
{ "3-C", "21215a00bdffffff7be7adce" },
{ "3-D", "00000008ce00e7ad7bf7ffbd" },
{ "3-E", "524a73b57b21e7b56bffffc6" },
{ "3-F", "424242ffd600ff6bff7b7bce" },
{ "3-G", "390000ce3139ffffff63de52" },
{ "3-H", "0818004a8c187bce39e7ffa5" },
{ "4-A", "00007bd600d67badfff7ad6b" },
{ "4-B", "180808427b39efa563f7eff7" },
{ "4-C", "0800009ca5e7dea5d6ffe7e7" },
{ "4-D", "08214a4a6b7b94ceceffffbd" },
{ "4-E", "0021317b5a8ce7ad7bffdead" },
{ "4-F", "3900008400a5de84debdd6d6" },
{ "4-G", "008463291000bd215ab5e718" },
{ "4-H", "425229848c42bdc65affffce" },
} },
{ name = "Super GB 2 - Vaporwave Edition", palettes = {
{ "SGB2VE 1-A Sunset", "1039adef2994f7a552f7f794" },
{ "SGB2VE 1-B Kimochi WRO", "4a3973bd63a5f7a56bf7efbd" },
{ "SGB2VE 1-C Pachinko", "42427b9c42eff7947bf7deef" },
{ "SGB2VE 1-D Cotton Candy", "1039adffa5ff84e7eff7f7f7" },
{ "SGB2VE 1-E Absynthe", "7b4aada5b5ffadff5affff8c" },
{ "SGB2VE 1-F Arizona", "52529cff4273ffcecedefff7" },
{ "SGB2VE 1-G Hot Vibes", "8c18f7f72973f78c18f7ce18" },
{ "SGB2VE 1-H Sorbet Whip", "63184aff6b8cffc684f7ffc6" },
{ "SGB2VE 2-A Bufotoxin", "104a63009c84ef8c84f7de9c" },
{ "SGB2VE 2-B Dank", "4a184263528463b5bdf7f794" },
{ "SGB2VE 2-C Creampuff", "632963a573f7f79cadffdef7" },
{ "SGB2VE 2-D Modern Computing", "101018ff849c6bffbdf7f7f7" },
{ "SGB2VE 2-E Uranium Glass", "4a08ef9c42ef00deded6ff9c" },
{ "SGB2VE 2-F. Tenshi", "2142e7de5aceefadceadf7c6" },
{ "SGB2VE 2-G 87 Ninjas", "42427bd63994bdb5f7c6f7de" },
{ "SGB2VE 2-H Floral", "08101852b59cff849cefe7e7" },
{ "SGB2VE 3-A Mind Game", "4200f700b5f7f784f7f7f7f7" },
{ "SGB2VE 3-B Sewer Magic", "5a00848c7be784f7c6f7f794" },
{ "SGB2VE 3-C Galactic Rabbit Trip", "4a527352a5cebdceeff7f7ef" },
{ "SGB2VE 3-D Frenemy", "1039ad42d6d6ffd6b5bdffe7" },
{ "SGB2VE 3-E Agent Orange", "632963e76b84f7a56bffe742" },
{ "SGB2VE 3-F Backstreet", "294a8cff8cbdffceb5dedeff" },
{ "SGB2VE 3-G Eccojam", "8c42a5ad7be7946cff9cefff" },
{ "SGB2VE 3-H Sakura", "4a39b5ef73d6efb5bdf7efb5" },
{ "SGB2VE 4-A Vector", "1039ad9400f700b5f7f7d684" },
{ "SGB2VE 4-B Slumber", "944ab58c8cf794ceffd6ffff" },
{ "SGB2VE 4-C Lingonberry Butter", "21529c7b84e7ffadcefff7de" },
{ "SGB2VE 4-D Zangetsu", "104a6331a5c684e7eff7efbd" },
{ "SGB2VE 4-E Icy Hot", "8c00ffff39ced6c6ffbdfff7" },
{ "SGB2VE 4-F Night Driver", "214aff6b00c6ff31ef10006b" },
{ "SGB2VE 4-G Bug Hunt", "0839633952bdf7738cf7e7ad" },
{ "SGB2VE 4-H MGB-101", "004a84107ba500bdc621ffde" },
} },
{ name = "TheWolfBunny64/American Pop Culture Pack", palettes = {
{ "Absorbent and Yellow", "343b00687600aec600fff752" },
{ "BMO Ver.", "394c3d607f6699cca3c0ffcc" },
{ "BRB Pink", "480030790050c10080f200a1" },
{ "Bedrock Caveman Vision", "002f37005e6e009eb8ff7f00" },
{ "BKB Ver.", "6060002098f048f8e0f8f880" },
{ "BLS Pink", "492e357a4d59c47c8ef59bb2" },
{ "BBS Blue", "1e3a45326274509cba64c4e9" },
{ "BTC Green", "39422a5f6e4698b070bedc8d" },
{ "DNP Silver", "33383d555d668895a3abbbcc" },
{ "Fairly OddPalette", "3d1b2d7b365bce5a997bb850" },
{ "GRF Vision", "2d1309964220e59436f5ea8b" },
{ "HGW Goldius", "3631215b523891845ab6a571" },
{ "Invincible Yellow and Blue", "113c4622788d39c9ebfee566" },
{ "NCK Orange", "4c1e007f3300cc5200ff6700" },
{ "NJT Green", "28380b435e126b961d86bc25" },
{ "OPP Palette", "0015330047abd92121d3d3d3" },
{ "PTS Pink", "4c26297f4045cc666fff808b" },
{ "Retro Bogeda", "0000006408ffff6cfffbfd1b" },
{ "Rocket Portable Power", "09122d213f99bdd539fee998" },
{ "RGR Playtime Palette", "662d91ed1c24ffde17ffffff" },
{ "Sailor Spinach Green", "2434123d581e628c307bb03c" },
{ "SCD Mystery Ver.", "2a1a318f59a5f79321c6de31" },
{ "Smurfy Blue", "0d3747165c772394bf2cb9ef" },
{ "SPB Yellow", "4a45157b7424c5ba39f7e948" },
{ "SQW Sea Foam Green", "37403d5c6b6694aca4b9d7cd" },
{ "Swampy Ogre Green", "39400d606b179aab24c1d62e" },
{ "TMT Pink", "3815205e2436963957bc486d" },
{ "UNS Blue", "0020430136710256b4036ce2" },
} },
{ name = "TheWolfBunny64/Anime and Manga Pack", palettes = {
{ "ANX BLUE", "0f2d451a4c74297ab93499e8" },
{ "ALM Hero Palette", "212f79d12021f3db43eff0f0" },
{ "Anime DGV Ver.", "1b241d2d3d3148624f5b7b63" },
{ "Anime Expo Red", "47110f771d19be2f28ee3b33" },
{ "Anime Expo Ver.", "242a2d656e729ba3a6e5eaeb" },
{ "BRS Blood", "3806065d0a0a951010bb1414" },
{ "Blue Stripes Ver.", "2d2e2e5b5d5d999b9c8bd3e1" },
{ "CDC Pink", "430427e10e82eac3d6f2f4f7" },
{ "Colorful Horizons", "373939d1525260bdc7f6cf72" },
{ "CRR Orange", "49230a7a3a11c35d1bf47522" },
{ "DKU Alpha Emerald", "11332f1c564f2d8a7e39ad9e" },
{ "DKU Gamma Palette", "24262b166668b6bec8f0e8d6" },
{ "DKU Vigilante Palette", "13131538534e878b92ada89a" },
{ "DMS Gold", "3734195d572b948c44baaf56" },
{ "DRM Tricolor", "450000e6000000a8f4ffe800" },
{ "DGB Orange", "48270878410ec06817f0831d" },
{ "DYN Hero Palette", "16161629463dc84a31d1d0cc" },
{ "EV-01", "303345765898f99b2254cf54" },
{ "EDN Academy Uniform", "483e39cc4c47dcbc76f7f8f5" },
{ "FRG Pretense Palette", "0000008d0000697d738ea99b" },
{ "GDS VISION", "4723008e4700ee77009fa0a0" },
{ "GR5 Fever", "311d3da662cdc5cedff7f7f7" },
{ "GKU GT Gi", "1221313d6ea5f0ac18dbe3e6" },
{ "GKU Gi", "061222173f72e7612cf9f0e1" },
{ "Grand Ivory", "4140396c6b5fadab98d9d6be" },
{ "GRZ Coat", "3d0b32ce26a9fce72dfbfbfa" },
{ "HKG Orange", "462718754129bb6841ea8352" },
{ "KNS Sherbet", "440021890042e5006ef08200" },
{ "KWB Blue", "00283b004462006c9d0088c5" },
{ "Legendary SSJ", "31411b536d2d84ae48a6da5b" },
{ "LSA Orange!", "461c00752f00bc4b00eb5e01" },
{ "Perfect MJN Emperor", "2718328550a9a3b453fdc1bf" },
{ "Perfected ULI", "393c4060646c99a0acc0c8d8" },
{ "PRC Marble Raspberry", "400a1b6b112eab1b49d6225c" },
{ "PTG Gold", "3633275a5541908868b4aa82" },
{ "SYN Beast Silver", "2d2e344b4d57787c8c969baf" },
{ "SHN Green", "1b3a162d6125489c3b5ac34a" },
{ "STH Red", "4a18127c291ec64130f8523c" },
{ "SSJ 3", "4a3c107c641cc6a02cf8c838" },
{ "SSJ Blue Evolved", "082d3e0d4b681578a71b97d1" },
{ "SSJ Blue", "01383d025d660496a305bccc" },
{ "SSJ God", "40001d6b0131ac024ed70362" },
{ "SSJ Rose", "4a34357b5759c58c8ff7afb3" },
{ "SSJ", "4c4b397f7e60cbc99afefcc1" },
{ "SVC Uniform", "321d1a593d34ac7c59acaba9" },
{ "SWA Cyan", "1a3a432c6171479cb459c3e2" },
{ "TMR Uniform", "474f4d755e88e94e60eeefeb" },
{ "Tri DGV Ver.", "272a2442473c697260848f79" },
{ "UAH Uniform", "0c4856a02929a4aaafededed" },
{ "UTV VISION", "262c48e24465f1a7b5f4f4f4" },
{ "ULI Sign", "1b1f212d34374853585a686f" },
{ "URM Green", "1734282657443d8b6c4dae88" },
{ "VGT Armor", "0619321554a9e3c56cefefef" },
{ "ANM vision", "231815385eaaf9be00ffffff" },
{ "HLO blue", "063f5c3368d349c4f2b0edfa" },
} },
{ name = "TheWolfBunny64/Bandai Namco Pack", palettes = {
{ "1st Vision Pastel", "101c33385ead7faf5aea7cb1" },
{ "765P Ver.", "383a445d6272959cb6bbc4e4" },
{ "765P Pink", "481720792736c23f57f34f6d" },
{ "765P TRICOLOR", "0b143f2743d2e22b30b4e04b" },
{ "ARS BLUE", "0e192e182a4e26447c30559c" },
{ "AQR Blue", "0030450050740080ba00a0e9" },
{ "BDN Tricolor", "001f350069b1df4f61f6b700" },
{ "CND Blue", "0b263c1340641e67a02681c8" },
{ "IDOL WORLD TRICOLOR!!!", "0b263c2681c8f34f6dffc30b" },
{ "LLA Purple!", "31152e52234d84387ca5469b" },
{ "MLV GOLD!", "3d351d665930a48e4dcdb261" },
{ "MLN Yellow!", "4c3a037f6105cc9c08ffc30b" },
{ "Moonlight Vision", "1010103050783890e8f8d868" },
{ "MUS Pink", "44002672003fb60065e4007f" },
{ "NMC Idol Pink", "4c22377f3a5ccc5c93ff74b8" },
{ "NJG Orange", "482d00794c00c27900f39800" },
{ "PCM-PALETTE", "420000dc0000ff8c00ffd800" },
{ "PCM Vision", "0000003732ffffb897ffff00" },
{ "PCM Yellow", "4c44007f7100ccb500ffe300" },
{ "RADIANT SMILE RAMP", "1b1c81e6016b01b3c4fff89b" },
{ "RX-78-2 GDM Mode", "333f3fa73736dbad40dbdcd5" },
{ "RYG Sunset", "2b10399238befd99e1ffe43f" },
{ "SHINY Sky Blue", "2a384c465d7f7095cc8dbbff" },
{ "SNS Red", "3911105f1c1b982d2bbf3936" },
{ "School Idol Blue", "2830663960d787adf5f9f9f8" },
{ "School Idol Mix", "31152ea5469b00a0e9f39800" },
{ "SDM Green", "04392c075f4a0c98760fbe94" },
{ "Sunny Passion Paradise", "0920341e6caee06846f0ba40" },
{ "SYL Rose Pink", "452728744243b9696be88486" },
{ "Tarnished Gold", "392f18604e299a7d42c19d53" },
{ "Vulnerable Blue", "ffb897cc93787f5c4b3732ff" },
{ "Wind Ring Emerald", "0c3627145a4220906a28b585" },
} },
{ name = "TheWolfBunny64/Company Pack", palettes = {
{ "AMZ Vision", "252f3e008296ff9900ffffff" },
{ "AND Green", "1242271e6e4230b0693ddc84" },
{ "APL Silver", "30333351555681888aa2aaad" },
{ "DUO Green", "1a3d002c660146a30158cc02" },
{ "DRC Copper", "3c291b64442ea06d4ac8895d" },
{ "GGL Blue", "13274921427a346ac34285f4" },
{ "GGL Green", "0f32181a542929864234a853" },
{ "GGL Red", "46140f75211abb352aea4335" },
{ "GGL Yellow", "4b38017d5e02c89604fbbc05" },
{ "SPT Green", "09401c0f6b3018ac4c1ed760" },
} },
{ name = "TheWolfBunny64/Food and Drink Pack", palettes = {
{ "7EL Color Combo", "062218147350eb0f2aff6c00" },
{ "BJB Beach", "273d3b4e7a7683ccc5dbe441" },
{ "BJB Storm", "1f3a31346152539b8368c2a4" },
{ "BKR Sundae", "40202178284bc04078f05097" },
{ "BGK Color Combo", "502314d62300ff8732f5ebdc" },
{ "CHT Orange", "442300723b00b75e00e57600" },
{ "CRK Color Combo", "460d0a8d1b15ec2e24f99b2a" },
{ "CCL Vision", "000000f40009d7d7d7ffffff" },
{ "Dew Green", "2d40004b6b0078ac0097d700" },
{ "DMP Vision", "000000006491e31837ffffff" },
{ "DRP Red", "290a0e4511186e1b278a2231" },
{ "DNK Vision", "48032b910756f20c90ff6e0c" },
{ "KFR Red", "33070e550c17881325ab182f" },
{ "KKR Vision", "000000166938cf152dffffff" },
{ "Lemon-Lime Green", "18310e30631d51a631f1c545" },
{ "MTD Ver.", "29673cd82a34a1d23fffffff" },
{ "PCS BLUE", "001b35002e59004a8e015db2" },
{ "PPS Vision", "0000001414c8ff1400ffffff" },
{ "PZH Red", "441012711c1fb52c31e3383e" },
{ "SPR Green", "002e17004d27007c3e009b4e" },
} },
{ name = "TheWolfBunny64/Gaming Pack", palettes = {
{ "AVG Raging Volcano", "1c0000a81000f83800f8b800" },
{ "Blue Bomber Vision", "0000000d4dc4639afce2cda7" },
{ "BBN Blue", "093e4b0f687e18a7ca1fd1fd" },
{ "BBG Green", "1f420e356e1854b0276adc31" },
{ "CHE Green", "20800040980080c800a0e000" },
{ "Classic Blurple", "22294138446d5b6dae7289da" },
{ "DMG Pea Green", "204631527f39aec440d7e894" },
{ "EBT Prolouge", "2c17084a290b854a1dc08226" },
{ "GGR Orange", "45230e743b17ba5e25e9762f" },
{ "Giga Kiwi DMG", "384828607028a0a830d0e040" },
{ "Investigation Yellow", "4c4a077f7c0cccc714fff919" },
{ "Phantom Red", "4b0b117e131cca1e2dfd2639" },
{ "PYO Green", "15431024771b39b42b48e236" },
{ "SES Blue", "073e4c0c687f14a7cc19d1ff" },
{ "SGA Tokyo Blue", "00273f00416a0068a90082d4" },
{ "Scarlett Green", "2e48034d78057cc0089bf00b" },
{ "Slime Blue", "0e2a3d1746662570a32f8ccc" },
{ "SNC Mega Blue", "13274120426c3369ad4084d9" },
{ "STM Gray", "3b3a396261609d9c99c5c3c0" },
{ "TYV Brown", "372c1f5c4a34937654b89469" },
{ "Tropical Cyber Space", "1414394344c1ef58f763fdfb" },
{ "XBX Green", "2b3c1249641f74a03192c83e" },
} },
{ name = "TheWolfBunny64/Holiday Pack", palettes = {
{ "Christmas Gold", "39321660542599873cc0a94b" },
{ "Christmas Silver", "2c32354a545977868e95a8b2" },
{ "Christmas Ver.", "300f0fa0323220a465cbb96a" },
{ "Classy Christmas", "534d579e5c5e8bab95e8e7df" },
{ "Clover Green", "1136161c5a252d903b39b54a" },
{ "Cupid's Love Palette", "5e081eb51a3ae48397e4cdd3" },
{ "Festive Christmas", "0a1609224b21cc1902bb9b60" },
{ "Glacial Winter Blue", "2839434360716c9ab487c1e2" },
{ "Grinchy Green", "3639085b5f0e929816b7be1c" },
{ "Halloween Ver.", "2c13319540a5f68c00ffcc00" },
{ "Haunted Halloween", "2f094c9e1fffffa52ecaff37" },
{ "Independence Boy", "0000000a3161b31942ffffff" },
{ "Irish Green", "143923225f3b37985e45be76" },
{ "Spooky Purple", "2f253f4f3e697e63a89e7cd2" },
{ "Winter Christmas", "341113ae3b4065b08fdddddd" },
} },
{ name = "TheWolfBunny64/J-Pop Pack", palettes = {
{ "A48 Pink", "4923317b3b53c45e84f676a6" },
{ "ATG PARETTO", "030a5b7a5375c375bcf4a7eb" },
{ "BBM KITSUNE RED", "2c00034a000677000995000c" },
{ "H46 Blue", "1b39442d5f724898b75bbee5" },
{ "J-Pop Idol Sherbet", "260c2b8129905bbee5f19db5" },
{ "MKU Blue", "05333f08566a0d8aaa11add5" },
{ "NZU PrePlay Palette", "47120fef3f3500b8b0fff200" },
{ "N46 Purple", "260c2b401448672073812990" },
{ "OOR RED", "370f0c5c1a14942920b93429" },
{ "YSB AMARANTH", "480c1b79142dc12048f2285a" },
} },
{ name = "TheWolfBunny64/Japanese Company Pack", palettes = {
{ "ANA Flight Blue", "00146e223f9a3b8bc000b3f0" },
{ "FMM Vision", "00301300602600a040008cd6" },
{ "LWS BLUE", "001f3600345b0053920068b7" },
{ "NHK Silver Gray", "262626404040666666808080" },
{ "SIT SUPER BLUE", "0b2438133d5e1f6196277abc" },
{ "SIT SUPER GREEN", "0634270b5742118b6a16ae85" },
{ "SKO Timely Vision", "2021210050a54393e6ffbf00" },
{ "SB109 PASTEL", "5503a63dacb8fd87b2f2d53f" },
{ "SNR Pink", "83534de74b5af485a1f9c2d0" },
{ "TKS CLOUDY BLUE", "27363b415a6368909f82b5c7" },
{ "NCO sea green", "073a310c6152149c8319c3a4" },
} },
{ name = "TheWolfBunny64/K-Pop Pack", palettes = {
{ "BGT ARMY PURPLE", "26154040246c6639ac8048d8" },
{ "BKP BLINK PINK", "4932377a535dc38594f4a7ba" },
{ "EER COSMIC LATTE", "4c4a457f7c73ccc6b8fff8e7" },
{ "LSS FEARLESS BLUE", "26314a40527c6784c781a5f9" },
{ "NCZ YELLOW", "43451e717333b4b851e2e766" },
{ "SVT COOL CARAT", "2b323e57647d92a8d1f7cac9" },
{ "SHW GREEN", "22413e396d685cafa673dbd0" },
{ "SWN PINK", "48172579273ec13f63f24f7c" },
{ "SKD STAY PINK", "4c142d7f224ccc367aff4499" },
{ "TROPICAL TWC APRICOT", "4c1c30993961ff5fa2fcc89b" },
} },
{ name = "TheWolfBunny64/Legacy Palette Pack", palettes = {
{ "TDS VC Green", "4a4a4a5a8c429cef29bdff21" },
{ "TDS VC Ver.", "2929186b6b52a5a58ccecead" },
{ "BLL PC Screen", "000000783800b86000f87800" },
{ "DMG Ver.", "2a453b365d48577c447f860f" },
{ "DGV Ver.", "2a2a2246463970705c8c8c73" },
{ "GMT Ver.", "12424c255955437a636ba64a" },
{ "GNW LCD", "3b3c336365569ea28ac6cbad" },
{ "GMK Ver.", "184421405d3b6b9c638cce94" },
{ "GBU Classic Ver.", "26302540503f67806481a17e" },
{ "HGM Ver.", "2d2d2b414f53687f84829fa6" },
{ "Light Ver.", "004f3b00694a009a7100b581" },
{ "LKA DX Ver.", "58382068884078c078f8f8b0" },
{ "MBM Ver.", "303030505050808080a0a0a0" },
{ "MGM V Ver.", "08203040689070a0e0d0d0d0" },
{ "NGP Ver.", "101010707070b0b0b0f0f0f0" },
{ "NKA 3310 Ver.", "2231273953425c846973a684" },
{ "Pocket Tales Ver.", "00000038500088a000d0d860" },
{ "Pocket Ver.", "1f1f1f4d533c8b956dc4cfa1" },
{ "PKS Ver.", "2d2d284b4b4378786c969687" },
{ "PKM Pinball Ver.", "181820786030a0b050e8f8b8" },
{ "PKM Ver.", "181010807098f0b088f8e8f8" },
{ "PKM mini Ver.", "1b2d1b525c52849484a5b9a5" },
{ "PKT Ver.", "10281838503050805070b070" },
{ "Rocky-Valley Holiday", "204008805850d89078c0f0f8" },
{ "T83 Ver.", "2e332a4e55467c88709caa8c" },
{ "TMG Ver.", "3c383878787cc0c0c7f1f0f9" },
{ "TGC Ver.", "0000000f4f2f6f8f4fdfff8f" },
{ "Timing Hero Ver.", "202e004c67188c994ccccc99" },
{ "Travel Wood", "482810705030a08058f8d8b0" },
{ "VMU Ver.", "0814804466546ca38688cca8" },
{ "VTB Ver.", "ff0000aa0000550000000000" },
{ "WTS Ver.", "0c322c2c626454a68c7cc67c" },
{ "WDS Ver.", "2c2c2c686868c0c0c0fefefe" },
} },
{ name = "TheWolfBunny64/Misc. Palette Pack", palettes = {
{ "Camouflage Ver.", "37353879533dac7e54bcab90" },
{ "Cherry Blossom Pink", "482534783f58c0648cf07eb0" },
{ "Emerald Green", "183c2428643c40a06050c878" },
{ "Fool's Gold and Silver", "2d30345a606997a1b0c5c66d" },
{ "Glitchy Blue", "0f254b193f7d2864c8337efb" },
{ "Gold, Silver, and Bronze", "2d1f1499684386949abeb049" },
{ "Golden Trophy", "453e0774680cb9a613e8d018" },
{ "Greenscale Ver.", "0c360c2c62346e870a9cbe0c" },
{ "Leprechaun Green", "10281d1b44302c6c4d378861" },
{ "Nightvision Green", "66bf2f519825335f171e390e" },
{ "Rising Sun Red", "38000d5d0016960024bc002d" },
{ "Treasure Gold", "3c360a655a12a2901ccbb524" },
{ "Wild West Vision", "3a160e924a36c3976ad9d7c7" },
} },
{ name = "TheWolfBunny64/Console Pack", palettes = {
{ "Advanced Indigo", "241f373c355d605494796aba" },
{ "Aegis Cherry", "42111e6e1d32b02f50dd3b64" },
{ "Ancient HSU Brown", "352f2b594f488f7f73b39f90" },
{ "ANC Green", "002e25004d3f007c64009b7e" },
{ "Brilliant Diamond Blue", "2638433f5d706595b47fbbe1" },
{ "COLLECTION of SAG Ver.", "041820345d51769a67b2c0a8" },
{ "Champion's Tunic", "281b13875b40009edde2dcb1" },
{ "CHZ Blue", "1735432759703e8fb44eb3e1" },
{ "DKG Arcade Blue", "15304223516f3881b147a2de" },
{ "DKG Barrel Brown", "3a220e613a179c5c25c3742f" },
{ "DMG-099", "3132313f642f6bad1984b510" },
{ "DMG-GOLD", "30361c505a3080904ca1b560" },
{ "DMG-SWITCH", "214231426b296c94218cad28" },
{ "DRL GB Ver.", "48530e788b1db9d03af6ff70" },
{ "EVE Brown", "3c2a0f644619a07028c88d32" },
{ "FMC Disk Yellow", "483a00796100c29b00f3c200" },
{ "FMC Frenzy", "231916a32135d9be72efecda" },
{ "Frog Coin Green", "00390039840000ef00fff7de" },
{ "Fury Blue", "0c1c2d152f4c224c792b5f98" },
{ "GMC Glimmer", "3e1318cf415111a396b6bed3" },
{ "Golden Wild", "372f1e5c4f32947f50b99f65" },
{ "GMB Brown", "331a11552c1d88472faa593b" },
{ "Green Awakening", "000b1636705898db75f1ffdd" },
{ "Green Banana", "2149003969394a9e0063df08" },
{ "INK Tricolor", "1c114c603bffff505eeaff3d" },
{ "LCD Clock Green", "183626285a4040906650b580" },
{ "LBO Fawn", "4033226b5539ac885cd7aa73" },
{ "MRO Red", "430400700700b40c00e10f00" },
{ "Metallic PLD Brass", "302d0f514c1a817929a29834" },
{ "Neon Blue", "033745055c730894b80ab9e6" },
{ "Neon Green", "0942000f6e0018b0001edc00" },
{ "Neon Orange", "4b30017d5002c88004faa005" },
{ "Neon Pink", "4c0f247f193ccc2860ff3278" },
{ "Neon Purple", "3600455a00739000b8b400e6" },
{ "Neon Red", "4c120c7f1e14cc3020ff3c28" },
{ "Neon Yellow", "454c00737f00b8cc00e6ff00" },
{ "Odyssey Boy", "2224215054457e8e67acbe8c" },
{ "Odyssey Gold", "3a30006150009b8000c2a000" },
{ "POCKET SWT", "3038206372518d9c7bb5c69c" },
{ "PKC Yellow", "4c42007f6e00ccb000ffdc00" },
{ "PKD Red", "461918752a28bb4340ea5450" },
{ "Royal Blue", "151949232a7a3844c44655f5" },
{ "Shining Pearl Pink", "3f2a30694750a87180d28ea0" },
{ "Sky Pop Ivory", "52502586825cbebb95e5e0b8" },
{ "Super FMC Supreme", "0846bad9404044ac71feda5a" },
{ "Super Mushroom Vision", "000000923404cc9e22f7cec3" },
{ "Super Star Pink", "440d2372163bb7235fe52c77" },
{ "Superball Ivory", "646432828250bcbc8aeef0bc" },
{ "Timeless Gold and Red", "37090a6f1215b91e23c8aa50" },
{ "Ultra Black", "17181d2629313d414f4d5263" },
{ "WRW MicroBlue", "05293c0844650d6da11189ca" },
{ "Wonder Purple", "451a42732c6fb846b2e658df" },
{ "Yellow Banana", "734900ad6939de9e00ffdf08" },
{ "YSH Egg Green", "1e3a0e336218519c2666c430" },
} },
{ name = "TheWolfBunny64/Sports Pack", palettes = {
{ "MLB Vision", "041e42bf0d3e057affffffff" },
{ "NASCAR Ver.", "000000007ac2e4002bffd659" },
{ "NBA Vision", "000000253b73c8102effffff" },
{ "NCAA Blue", "002e42004e6f007cb1009cde" },
{ "NFL Vision", "000000013369d50a0affffff" },
{ "OLY Bronze", "3d2618664029a46741cd8152" },
{ "OLY Gold", "3f360a6a5b12aa911cd5b624" },
{ "OLY Silver", "2f312e4f524e7e847c9ea59c" },
{ "PGA Tour Vision", "000000003c80f1373dffffff" },
{ "WWE White and Red", "40070c810e19d7182affffff" },
} },
{ name = "TheWolfBunny64/Traditional JPN Colors Pack", palettes = {
{ "Aquatic Iro", "192f603e62ad2ca9e1a0d8ef" },
{ "Fruity Orange", "241a089f563af08300f3bf88" },
{ "Ghostly Aoi", "0f235019448e5a79ba84a2d4" },
{ "Golden Kiiro", "6a5d21a69425dccb18f8e58c" },
{ "Lime Midori", "4759507b8d42aacf53e0ebaf" },
{ "Oni Aka", "640125a22041d9333fec6d71" },
{ "Sakura Pink", "a25768e7609eeebbcbfdeff2" },
{ "Silver Shiro", "383c3c727171afafb0dcdddd" },
{ "Tea Midori", "33363102876088cb7fd6e9ca" },
{ "Wisteria Murasaki", "2e29307058a3a59acadbd0e6" },
} },
{ name = "Trashuncle/Artistic", palettes = {
{ "TU PSL Park", "9200aeb722d4d442f0ec89ff" },
{ "TU Quicksilver", "555555717286a4a5c3bbbce1" },
} },
{ name = "Trashuncle/Crush", palettes = {
{ "TU Blueberry Crush", "0000002811ff4dffffffffff" },
{ "TU Grape Crush", "0000003d43cb0083ebffffff" },
{ "TU Kiwi Crush", "000000059e322eef7affffff" },
{ "TU Lavender Crush", "000000812482ab6a9bffffff" },
{ "TU Orange Crush", "000000c23615ff632cffffff" },
{ "TU Pineapple Crush", "000000ffff00ffff80ffffff" },
{ "TU Strawberry Crush", "000000ea1211ff5250ffffff" },
} },
{ name = "Trashuncle/DMG", palettes = {
{ "TU DMG Bright", "0c3a1d2a61414c7b2b919707" },
{ "TU DMG Clean", "1749022b5402496503788603" },
{ "TU DMG Weak Alt", "2c4d2054733c597730677f31" },
{ "TU DMG Weak", "364e1a5772405e7a406d833d" },
} },
{ name = "Trashuncle/GBP", palettes = {
{ "TU GBP Bright", "5b5d5a879481abb8a7ffffff" },
{ "TU GBP Clean", "3d493b98a291adbfacedfff4" },
{ "TU GBP Weak Alt", "4c4b3170734e757952848459" },
{ "TU GBP Weak", "434738535a42535f49667159" },
} },
{ name = "Trashuncle/Light", palettes = {
{ "TU Light Bright", "00637400a1b100daea00e3f3" },
{ "TU Light Clean", "007577019fa000aeaf01b4b9" },
{ "TU Light Dark", "0076a80092d10098c500a5f2" },
{ "TU Light Idealized", "006e9d0093c500a6cc00ccd5" },
} },
}
+270
View File
@@ -0,0 +1,270 @@
-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua
-- world tables when GameVersion.isYellow(); Red/Blue never load this file's
-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal
-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON).
-- See #1639.
return {
world = {
-- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE
-- (closest Advanced indoor profile); colors lean sand / wood / water.
tileGroups = {
BEACH_HOUSE = {
[0] = 5,
[1] = 0,
[2] = 0,
[3] = 0,
[4] = 1,
[5] = 0,
[6] = 3,
[7] = 3,
[8] = 2,
[9] = 2,
[10] = 2,
[11] = 2,
[12] = 2,
[13] = 2,
[14] = 5,
[15] = 5,
[16] = 0,
[17] = 0,
[18] = 0,
[19] = 0,
[20] = 1,
[21] = 0,
[22] = 3,
[23] = 3,
[24] = 5,
[25] = 5,
[26] = 5,
[27] = 5,
[28] = 0,
[29] = 0,
[30] = 5,
[31] = 5,
[32] = 0,
[33] = 0,
[34] = 3,
[35] = 5,
[36] = 5,
[37] = 5,
[38] = 5,
[39] = 5,
[40] = 5,
[41] = 5,
[42] = 2,
[43] = 2,
[44] = 5,
[45] = 5,
[46] = 5,
[47] = 5,
[48] = 5,
[49] = 5,
[50] = 5,
[51] = 5,
[52] = 5,
[53] = 5,
[54] = 5,
[55] = 5,
[56] = 5,
[57] = 5,
[58] = 5,
[59] = 5,
[60] = 5,
[61] = 5,
[62] = 5,
[63] = 5,
[64] = 0,
[65] = 0,
[66] = 0,
[67] = 0,
[68] = 0,
[69] = 0,
[70] = 5,
[71] = 5,
[72] = 5,
[73] = 5,
[74] = 5,
[75] = 5,
[76] = 5,
[77] = 5,
[78] = 5,
[79] = 5,
[80] = 5,
[81] = 5,
[82] = 5,
[83] = 5,
[84] = 5,
[85] = 5,
[86] = 5,
[87] = 5,
[88] = 5,
[89] = 5,
[90] = 5,
[91] = 5,
[92] = 5,
[93] = 5,
[94] = 5,
[95] = 5,
},
},
groupColors = {
BEACH_HOUSE = {
-- sand / wood floor
{
{ 255, 239, 198 },
{ 206, 173, 115 },
{ 156, 123, 74 },
{ 58, 58, 58 },
},
-- signs / accents (warm coral)
{
{ 255, 239, 198 },
{ 255, 156, 148 },
{ 230, 82, 66 },
{ 58, 58, 58 },
},
-- foliage
{
{ 255, 239, 198 },
{ 123, 189, 82 },
{ 66, 123, 41 },
{ 58, 58, 58 },
},
-- water / windows
{
{ 255, 239, 198 },
{ 99, 189, 230 },
{ 41, 115, 189 },
{ 58, 58, 58 },
},
-- yellow props
{
{ 255, 239, 198 },
{ 255, 255, 82 },
{ 230, 148, 25 },
{ 58, 58, 58 },
},
-- wood walls / furniture
{
{ 255, 239, 198 },
{ 189, 140, 74 },
{ 140, 99, 41 },
{ 58, 58, 58 },
},
-- sky / cool trim
{
{ 255, 239, 198 },
{ 148, 189, 255 },
{ 99, 148, 230 },
{ 58, 58, 58 },
},
-- text
{
{ 255, 255, 255 },
{ 255, 255, 255 },
{ 255, 255, 255 },
{ 0, 0, 0 },
},
},
},
-- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72.
-- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James);
-- Red's ball/fossil/snorlax/... shift to 70-81.
spriteAssignment = {
[0] = 0,
[1] = 1,
[2] = 3,
[3] = "random",
[4] = 0,
[5] = "random",
[6] = "random",
[7] = "random",
[8] = 0,
[9] = "random",
[10] = "random",
[11] = "random",
[12] = "random",
[13] = "random",
[14] = "random",
[15] = 1,
[16] = 1,
[17] = "random",
[18] = "random",
[19] = "random",
[20] = "random",
[21] = 2,
[22] = 1,
[23] = 3,
[24] = "random",
[25] = "random",
[26] = "random",
[27] = "random",
[28] = "random",
[29] = 0,
[30] = 3,
[31] = 3,
[32] = "random",
[33] = "random",
[34] = "random",
[35] = "random",
[36] = "random",
[37] = "random",
[38] = "random",
[39] = "random",
[40] = 0,
[41] = 2,
[42] = "random",
[43] = "random",
[44] = "random",
[45] = "random",
[46] = "random",
[47] = "random",
[48] = "random",
[49] = "random",
[50] = "random",
[51] = "random",
[52] = "random",
[53] = "random",
[54] = "random",
[55] = 0,
[56] = 1,
[57] = 3,
[58] = 0,
[59] = 0,
[60] = 4, -- SPRITE_PIKACHU
[61] = 1, -- SPRITE_OFFICER_JENNY
[62] = 3, -- SPRITE_SANDSHREW
[63] = 2, -- SPRITE_ODDISH
[64] = 2, -- SPRITE_BULBASAUR
[65] = "random", -- SPRITE_JIGGLYPUFF
[66] = "random", -- SPRITE_CLEFAIRY
[67] = "random", -- SPRITE_CHANSEY
[68] = "random", -- SPRITE_JESSIE
[69] = "random", -- SPRITE_JAMES
[70] = 0, -- SPRITE_POKE_BALL
[71] = 0, -- SPRITE_FOSSIL
[72] = 3, -- SPRITE_BOULDER
[73] = 3, -- SPRITE_PAPER
[74] = 0, -- SPRITE_POKEDEX
[75] = 3, -- SPRITE_CLIPBOARD
[76] = 0, -- SPRITE_SNORLAX
[77] = 3, -- SPRITE_UNUSED_OLD_AMBER
[78] = 3, -- SPRITE_OLD_AMBER
[79] = "random",
[80] = "random",
[81] = 3, -- SPRITE_GAMBLER_ASLEEP
},
-- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]).
spritePalettes = {
[4] = {
{ 222, 255, 222 },
{ 255, 255, 0 },
{ 230, 115, 0 },
{ 0, 0, 0 },
},
},
},
}
+21 -25
View File
@@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
local function linkCableHelp(game) local function linkCableHelp(game)
local text = game.data.text or {} local text = game.data.text or {}
local items, showMenu, askHeading local items, menu, openMenu
function showMenu() local function closeAll()
game.stack:push(Menu.new(game, items, game.stack:pop()
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
end
function askHeading()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
showMenu))
end end
items = {} items = {}
for i, label in ipairs(LINK_HEADINGS) do for i, label in ipairs(LINK_HEADINGS) do
items[i] = { label = label, onSelect = function() items[i] = { label = label, keepOpen = true, onSelect = function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
text["_LinkCableInfoText" .. i] or label, askHeading)) text["_LinkCableInfoText" .. i] or label))
end } end }
end end
items[#items + 1] = { label = "STOP READING" } items[#items + 1] = { label = "STOP READING", onSelect = closeAll }
menu = Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2,
onCancel = closeAll })
function openMenu() game.stack:push(menu) end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable", text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
askHeading)) function()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
nil, { stay = { onShown = openMenu } }))
end))
end end
return { return {
@@ -84,21 +86,15 @@ return {
{ "jump_if_false", 5 }, -- 2 { "jump_if_false", 5 }, -- 2
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE", { "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves) "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
{ "jump", 13 }, -- 4 { "jump", 11 }, -- 4
{ "give_pokemon", "EEVEE", 25 }, -- 5 { "give_pokemon", "EEVEE", 25, false, true }, -- 5
{ "jump_if_false", 12 }, -- 6 (party+box full) { "jump_if_false", 10 }, -- 6 (party+box full)
-- flag + HideObject before the jingle and GotMonText: the nickname -- scripts/CeladonMansionRoofHouse.asm:17-20 (#426)
-- prompt inside give_pokemon and the text row both yield, and a
-- script that dies there would leave the EEVEE taken with the ball
-- still on the table and the gift claimable again (#426). The row
-- count is unchanged, so the numeric jump targets still hold.
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE", { "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle) { "jump", 11 }, -- 9
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 { "show_text", "_BoxIsFullText" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
}, },
}, },
} }
+2 -4
View File
@@ -2,12 +2,10 @@
return { return {
CELADON_CITY = { CELADON_CITY = {
talk = { talk = {
-- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far
-- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONCITY_POLIWRATH = { TEXT_CELADONCITY_POLIWRATH = {
{"face_player"}, {"face_player"},
-- pokered/scripts/CeladonCity.asm:90
{"play_cry", "POLIWRATH", true},
{"show_text", "_CeladonCityPoliwrathText"}, {"show_text", "_CeladonCityPoliwrathText"},
}, },
}, },
+6 -12
View File
@@ -2,30 +2,24 @@
return { return {
CELADON_MANSION_1F = { CELADON_MANSION_1F = {
talk = { talk = {
-- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_CLEFAIRY = { TEXT_CELADONMANSION1F_CLEFAIRY = {
{"face_player"}, {"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:27
{"play_cry", "CLEFAIRY", true},
{"show_text", "_CeladonMansion1FClefairyText"}, {"show_text", "_CeladonMansion1FClefairyText"},
}, },
-- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_MEOWTH = { TEXT_CELADONMANSION1F_MEOWTH = {
{"face_player"}, {"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:18
{"play_cry", "MEOWTH", true},
{"show_text", "_CeladonMansion1FMeowthText"}, {"show_text", "_CeladonMansion1FMeowthText"},
}, },
-- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_NIDORANF = { TEXT_CELADONMANSION1F_NIDORANF = {
{"face_player"}, {"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:33
{"play_cry", "NIDORAN_F", true},
{"show_text", "_CeladonMansion1FNidoranFText"}, {"show_text", "_CeladonMansion1FNidoranFText"},
}, },
}, },
@@ -4,11 +4,10 @@
return { return {
LAVENDER_CUBONE_HOUSE = { LAVENDER_CUBONE_HOUSE = {
talk = { talk = {
-- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText, -- LavenderCuboneHouse.asm:10
-- then text_asm plays the CUBONE cry. Cry playback has no Commands
-- equivalent in this port, so just show the line.
TEXT_LAVENDERCUBONEHOUSE_CUBONE = { TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
{ "face_player" }, { "face_player" },
{ "play_cry", "CUBONE", true },
{ "show_text", "_LavenderCuboneHouseCuboneText" }, { "show_text", "_LavenderCuboneHouseCuboneText" },
}, },
+4 -5
View File
@@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = {
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6 { "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
}, },
-- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then -- scripts/MrFujisHouse.asm:56
-- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just
-- show the flavor text.
TEXT_MRFUJISHOUSE_PSYDUCK = { TEXT_MRFUJISHOUSE_PSYDUCK = {
{ "play_cry", "PSYDUCK", true },
{ "show_text", "_MrFujisHousePsyduckText" }, { "show_text", "_MrFujisHousePsyduckText" },
}, },
-- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then -- scripts/MrFujisHouse.asm:63
-- PlayCry(NIDORINO).
TEXT_MRFUJISHOUSE_NIDORINO = { TEXT_MRFUJISHOUSE_NIDORINO = {
{ "play_cry", "NIDORINO", true },
{ "show_text", "_MrFujisHouseNidorinoText" }, { "show_text", "_MrFujisHouseNidorinoText" },
}, },
}, },
+2 -3
View File
@@ -4,10 +4,9 @@
return { return {
ROUTE_16_FLY_HOUSE = { ROUTE_16_FLY_HOUSE = {
talk = { talk = {
-- Route16FlyHouseFearowText: text_asm just prints the one line and -- scripts/Route16FlyHouse.asm:45-51
-- plays the FEAROW cry (no cry-playback command exists in this
-- port's Commands vocabulary, so only the text is ported).
TEXT_ROUTE16FLYHOUSE_FEAROW = { TEXT_ROUTE16FLYHOUSE_FEAROW = {
{ "play_cry", "FEAROW", true },
{ "show_text", "_Route16FlyHouseFearowText" }, { "show_text", "_Route16FlyHouseFearowText" },
}, },
}, },
+1
View File
@@ -1047,6 +1047,7 @@ local championsRoomRivalScript = {
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past -- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction. -- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3 { "jump_if_true", "end" }, -- 3
{ "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after -- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time -- the intro text, before the battle itself (#706); pushBattle's wipe-time
+9 -5
View File
@@ -359,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = {
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done) TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
local t = text(game) local t = text(game)
if game.save.flags.EVENT_GOT_HM02 then if game.save.flags.EVENT_GOT_HM02 then
push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText
or "HM02 is FLY!\fIt will whisk you\nback to any town!", done) or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done)
return return
end end
push(game, t._Route16FlyHouseBrunetteGirlText push(game, t._Route16FlyHouseBrunetteGirlText
or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function() or "Oh, you found my\nsecret retreat!", function()
if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then
push(game, "You don't have\nroom for HM02!", done) push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText
or "You don't have any\nroom for this.", done)
return return
end end
game.save.flags.EVENT_GOT_HM02 = true game.save.flags.EVENT_GOT_HM02 = true
push(game, ("%s got\nHM02!"):format(game.save.player.name), done) -- scripts/Route16FlyHouse.asm:32-35
push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text
or "{PLAYER} received\nHM02!", { player = game.save.player.name }),
done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item"))
end) end)
end, end,
}, },
+58
View File
@@ -0,0 +1,58 @@
# Linux AppImage packaging
Releases ship raw AppImages (no zip wrapper):
- `gen1recomp-<version>-linux-x86_64.AppImage`
- `gen1recomp-<version>-linux-arm64.AppImage`
```sh
chmod +x gen1recomp-*-linux-x86_64.AppImage
./gen1recomp-*-linux-x86_64.AppImage
```
Flatpak users should prefer the `.flatpak` bundle (see
[linux-flatpak.md](linux-flatpak.md)); it avoids host glibc / FUSE / curl
mismatches on immutable desktops.
## Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Won't start / `libfuse.so.2` | No FUSE | `sudo apt install libfuse2` (or `libfuse2t64`) **or** `./app.AppImage --appimage-extract-and-run` |
| Update check / mods / save sync fail | Host `curl` missing or broken by AppImage `LD_LIBRARY_PATH` | Install curl; current builds scrub `LD_LIBRARY_PATH` for **host** curl and keep it for a bundled AppDir curl |
| Settings / sync reset after quit | Portable mode next to a read-only AppImage parent (`/opt`, system dir) | Remove `portable.txt` or move the AppImage to a writable folder; the game falls back to the XDG save dir when the probe write fails |
| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file |
| Steam / Game Mode weirdness after update | Overlay `LD_PRELOAD` / PID change | HostShell unsets `LD_PRELOAD` for children and `execv`s `$APPIMAGE` on restart |
## Network transport (HostShell)
Desktop Linux fetches go through host or bundled `curl`:
1. Flatpak `/app/bin/curl` (bundled, keep sandbox libs)
2. `$APPDIR/usr/bin/curl` or `$APPDIR/bin/curl` (keep `$APPDIR` on `LD_LIBRARY_PATH`)
3. Host `curl` (`env -u LD_LIBRARY_PATH`, and always `-u LD_PRELOAD`)
## Portable mode
Drop `portable.txt` beside the `.AppImage` to keep saves next to the binary.
The launcher probes writability with a unique `.write_probe_<time>_<rand>.tmp`
file. Read-only parents fail soft and use `love.filesystem` XDG saves instead.
Flatpak ignores `portable.txt`.
## Auto-update
In-place updates download `gen1recomp-X.Y.Z.love` into the save directory.
Full shell bumps open the matching AppImage (or Flatpak) download URL — there
is no silent in-place AppImage replace yet.
## Building
```sh
# x86_64 (macOS or Linux host with squashfs-tools)
scripts/build.sh linux --version X.Y.Z
# arm64 (aarch64 host + docker/podman)
scripts/build_linux_arm64.sh --version X.Y.Z
```
See [linux-arm64-build.md](linux-arm64-build.md) for the arm64 builder.
+19
View File
@@ -34,6 +34,25 @@ The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher source
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device. Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
## Power and Performance Tuning
The handheld build applies several optimizations to reduce power draw and ensure smooth 60 FPS frame pacing on low-power ARM SoCs:
- **KMSDRM / EGL Vsync Fix**: Handheld builds disable driver vsync (`vsync = 0`) to prevent GPU driver busy-wait spinloops in `eglSwapBuffers`, allowing `nanosleep()` and dropping idle CPU usage from ~25% to ~4%.
- **Idle Render Governor**: Drops presentation rate when a static screen (menus, text dialogs, stationary scenes) receives no input, cutting compositing work ~6x while preserving full 60 Hz game and audio clocks. Any button press restores full framerate immediately.
- **Sample Rate Scaling**: Audio synthesis is tuned to 22.05 kHz by default (`POKEPORT_AUDIO_RATE=22050`), halving synthesis CPU overhead on Cortex-A53 cores with no audible quality loss on handheld speakers.
- **Dynamic CPU Governor**: Defaults to `schedutil` instead of pinning `performance` on all cores, reducing thermals and extending battery life.
Environment variables for fine-tuning (configured in `gen1recomp-sbc.sh`):
| Variable | Default | Description |
| --- | --- | --- |
| `POKEPORT_IDLE_AFTER` | `10` | Seconds without input before an idle screen drops presentation rate |
| `POKEPORT_IDLE_FPS` | `6` | Framerate while idle |
| `POKEPORT_AUDIO_RATE` | `22050` | Chip-synth sample rate (set to `44100` for full rate) |
| `POKEPORT_CPU_GOVERNOR` | `schedutil` | CPU scaling governor (`schedutil`, `performance`, `ondemand`) |
## Building ## Building
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default: Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
+3 -2
View File
@@ -1,10 +1,11 @@
# Linux arm64 (aarch64) AppImage # Linux arm64 (aarch64) AppImage
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM existing x86_64 `gen1recomp-<version>-linux-x86_64.AppImage`. It targets 64-bit ARM
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
aarch64 handhelds that run a full distro. aarch64 handhelds that run a full distro. See also
[linux-appimage.md](linux-appimage.md) for shared AppImage failure modes.
> The Anbernic RG34XXSP has its own PortMaster-style pack > The Anbernic RG34XXSP has its own PortMaster-style pack
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see > (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
+51
View File
@@ -0,0 +1,51 @@
# Linux Flatpak
Releases may ship `gen1recomp-<version>-linux.flatpak` (x86_64 bundle).
## Install
```sh
flatpak install --user ./gen1recomp-<version>-linux.flatpak
flatpak run com.theboisclub.gen1recomp
```
Or open the `.flatpak` in Discover / GNOME Software. AppStream metainfo
includes a `<releases>` entry so those UIs can show the version.
## Permissions
The manifest requests:
- network (updater, mods, save sync)
- X11 / Wayland / DRI / PulseAudio
- `--device=all` (SDL2 gamepads, rumble, gyro via evdev/hidraw)
- `--filesystem=home` (ROM import)
Curl is bundled at `/app/bin/curl` so the game does not need host curl.
## Updates
Lua/engine payloads still use the in-app `.love` updater. A native shell bump
points at the new `.flatpak` asset (`Download Flatpak update`).
After a `.love` payload download, restart uses `love.event.quit("restart")`
(`execv` of `/proc/self/exe` inside bwrap). If a restart hangs after an
update, fully quit and relaunch — PhysFS can retain a lock on the previous
payload across a bad handoff.
## Building
```sh
scripts/build_flatpak.sh --version X.Y.Z
# → dist/flatpak/gen1recomp-X.Y.Z-linux.flatpak
```
Requires `flatpak` + `flatpak-builder` and the Freedesktop 24.08 runtime from
Flathub (installed automatically on first build).
Release CI builds the bundle on `ubuntu-24.04` (`linux-flatpak` job) from the
shared `game.love` payload and publishes `gen1recomp-<ver>-linux.flatpak`
alongside the AppImages. PR CI path-gates the same script when `flatpak/` or
`scripts/build_flatpak.sh` change.
Flathub store submission is out of scope for the GitHub bundle channel.
+113
View File
@@ -200,6 +200,60 @@ the adapter's own coverage table:
python3 tools/modkit.py gen2check mods/my_mod python3 tools/modkit.py gen2check mods/my_mod
``` ```
## Imported version datasets
A mod can inspect semantic content from another game the player has already
imported without switching the active game or reaching into engine cache
internals:
```lua
local gold, reason = mod.datasets:open("gold")
if not gold then
-- reason is "unknown_version", "not_imported", or "invalid_cache"
return
end
local chikorita = gold.content.pokemon:get("CHIKORITA")
local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST")
local spritePath = gold.assets:path(chikorita.spriteFront)
for id, record in gold.content.pokemon:each() do
-- ids are returned in deterministic lexical order
end
```
`view.version` and `view.generation` identify the selected dataset.
`view.content` exposes the same registry names, aliases, generation routing,
and data-only record shapes as `mod.content`, but only `get`, `has`, and
`each`. Returned records are detached copies and cannot mutate either dataset.
Every generated base record passes the selected generation's existing public
schema before it is returned; extractor metadata beside record maps stays out
of the registry id space and is reserved against `register`, `override`,
`patch`, and `remove` writes through the active registry. A malformed record
makes `get` return nil, `has`
return false, and `each` return no rows, and invalidates that dataset view.
Records containing functions, userdata, threads, metatables, or cycles are not
exposed. Each open call receives an independent facade, so one mod cannot
replace another mod view method. Canonical boot shaping is included, such as
Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and
derived `held_items`.
`open` checks the completion marker, exact version-specific file inventory,
source/cache boundary, and file-size bounds without reading or decoding the
semantic modules. A root is read, bounded-decoded, normalized, and cached only
when a content operation first needs it. Each later view operation rechecks
readiness and the source bytes behind already cached roots; unchanged roots are
not decoded again. Missing, partial, and stale imports return
`nil, "not_imported"`. Malformed syntax, a resource-limit violation, or a
record that fails the public schema is discovered on first root access and
fails that operation closed; a later `open` against the same source returns
`nil, "invalid_cache"`. Generated modules use a bounded literal-only grammar
and are never executed. No raw ROM bytes or generated source are exposed.
`view.assets:path(relative)` and
`view.assets:info(relative)` accept only `assets/generated/...` paths and
keep them under the selected version cache prefix. The API never changes
`mod.game`, the active `Data` table, `GameVersion`, or cache mount state.
## Editing maps in Tiled ## Editing maps in Tiled
Maps are data, not assets, so they can be authored in a real map editor and Maps are data, not assets, so they can be authored in a real map editor and
@@ -451,6 +505,65 @@ Menu choices and moves use the same engine methods as the native controls;
their mutable logic. Tutorial, link, forced, stale, and covered battle states their mutable logic. Tutorial, link, forced, stale, and covered battle states
refuse core intents. Use `mod.input` for ordinary text advance. refuse core intents. Use `mod.input` for ordinary text advance.
## Battle rule hooks
Two decisions the OPTION screen and the cart make for the player, which a game
mode can make instead (RFC 0015). Neither writes the player's saved
preference, so a mode can hold a rule for as long as it is active and hand the
player's own setting back untouched.
`battle.style` wraps the SHIFT/SET read at the moment the foe's Pokémon faints
and the engine would offer a free switch:
```lua
mod.hooks:wrap("battle.style", function(next, battle)
if myMode.active then return "set" end -- no "will you change POKéMON?"
return next(battle) -- the OPTION row, as today
end)
```
Return `"set"` or `"shift"`; anything else reads as the vanilla answer.
`catch.nickname` wraps the `AskName` prompt after a capture (party or box),
the same question `pokemon.before_give`'s `gift.nickname` already answers for
script gifts:
```lua
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
if myMode.active then return false end -- keep the species name
if myNames then return myNames[mon.species] end -- a string names it, no prompt
return next(mon, ctx) -- true: ask, as today
end)
```
`false` keeps the species name with no prompt. A string is the nickname with
no prompt, clipped to the naming grid's ten characters (an empty string names
nothing). Anything else queues the prompt.
## Party-full custody at a catch
When a capture lands on a full party, the cart deposits the mon in storage
without a question. `catch.party_full` (RFC 0018) lets a mode stand in front
of `SendNewMonToBox` and take custody instead:
```lua
mod.hooks:wrap("catch.party_full", function(next, ctx)
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
-- game = <Game> }
if myMode.active then
takeCustody(ctx.mon) -- the mon is the mod's problem now
return true -- nothing is deposited
end
return next(ctx) -- false: deposit, as today
end)
```
A truthy return skips the box entirely -- the mon is neither in the party nor
in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can
find its own custody again. Anything falsy deposits as always, "But every BOX
is full!" included.
## Rendering pipelines ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
+50 -1
View File
@@ -342,6 +342,7 @@ accepted and merged as-is.
| `source` | string | | `source` | string |
| `swarmGrass` | map of string -> {map?, rates, slots} | | `swarmGrass` | map of string -> {map?, rates, slots} |
| `swarmWater` | map of string -> {map?, rate, slots} | | `swarmWater` | map of string -> {map?, rate, slots} |
| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} |
| `treeSets` | map of string -> {common, rare} | | `treeSets` | map of string -> {common, rare} |
| `trees` | map of string -> string | | `trees` | map of string -> string |
| `water` | map of string -> {map?, rate, slots} | | `water` | map of string -> {map?, rate, slots} |
@@ -509,6 +510,26 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })
mod.content.items:patch("POTION", { price = 100 }) mod.content.items:patch("POTION", { price = 100 })
``` ```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.items`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `ball` | balls id | no |
| `effect` | item_effects id | no |
| `id` | string | yes |
| `index` | integer 0..255 | no |
| `machine` | {kind, move, number} | no |
| `name` | string | yes |
| `needsTarget` | boolean | no |
| `price` | integer >= 0 | yes |
| `tossable` | boolean | no |
## landmarks ## landmarks
- semantics: `record` - semantics: `record`
@@ -598,7 +619,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")
| `id` | string | yes | | `id` | string | yes |
| `index` | integer >= 0 | no | | `index` | integer >= 0 | no |
| `label` | string | no | | `label` | string | no |
| `objects` | list of any value | no | | `objects` | list of {pokemon?, ...} | no |
| `palette` | string | no | | `palette` | string | no |
| `signs` | list of any value | no | | `signs` | list of any value | no |
| `tileset` | tilesets id | yes | | `tileset` | tilesets id | yes |
@@ -673,6 +694,34 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f
mod.content.moves:patch("BLIZZARD", { accuracy = 70 }) mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
``` ```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.moves`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `accuracy` | integer 0..100 | yes |
| `anim` | any value | no |
| `category` | one of "physical" | "special" | "status" | no |
| `chargeText` | string | no |
| `counterable` | boolean | no |
| `effect` | move_effects id | yes |
| `fixedDamage` | integer >= 1 | function | no |
| `highCrit` | boolean | no |
| `id` | string | yes |
| `index` | integer 0..255 | no |
| `multiHit` | integer >= 1 | list of integer >= 1 | no |
| `name` | string | yes |
| `power` | integer 0..255 | yes |
| `pp` | integer 0..64 | yes |
| `priority` | integer -7..7 | no |
| `semiInvulnerable` | boolean | no |
| `type` | type_chart id | yes |
## music ## music
- semantics: `record` - semantics: `record`
+110
View File
@@ -0,0 +1,110 @@
# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname`
## Status
Proposed.
## Motivation
Two decisions in a Red/Blue/Yellow battle are made for the player by the
OPTION screen and by the cart, and a game mode has no way to make them
instead:
**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the
battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon
faints, SET does not. The engine reads `save.options.battleStyle` inline at
that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale
where party-as-health is the whole design — can only get it by writing the
player's saved preference, which leaks into their real playthrough the first
time anything calls `Game:writeOptions` (the speed hotkey does, on every
press).
**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox`
both run `AskName` after a capture. A mode with a disposable team, a
randomizer that names what it hands out, a speedrun practice mod — all want to
answer that prompt themselves, and the only way today is to drive the yes/no
box from outside. The script-gift path already has this seam: a mod that sets
`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does
not.
The immediate consumer is a battle-royale mode; neither hook is specific to it.
## The decision it extends
This extends the **additive, guarded seam convention** Route B in
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
`tests/engine/gate_meta_coverage.lua` enforces. `catch.nickname` mirrors a
contract that already exists for gifts (`pokemon.before_give`'s
`gift.nickname`), so the two ways a Pokémon joins the party answer the same
question the same way.
There is no in-repo D-number registry to amend.
## Exact API delta
### New hook: `battle.style`
```lua
mod.hooks:wrap("battle.style", function(next, battle)
if myMode.active then return "set" end
return next(battle) -- the OPTION row, as today
end)
```
Call site: `BattleState:battleStyle()`, called from the enemy send-out path at
the moment the SHIFT prompt would be offered. The vanilla link reads
`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the
inline read did. A return of `"set"` or `"shift"` is used; anything else reads
as the vanilla answer, so a hook that returns nothing by mistake cannot change
the rule. The player's saved preference is never written.
### New hook: `catch.nickname`
```lua
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
if myMode.active then return false end -- keep the species name
if randomizer then return pickName(mon) end -- a string names it
return next(mon, ctx) -- true: ask, as today
end)
```
Call site: `BattleState:offerNickname(mon, displayName)`, called from the
capture path where `AskName` ran, before the prompt is queued. The vanilla
link returns `true`. `false` skips the prompt and keeps the species name; a
string skips the prompt and is the nickname, clipped to the naming grid's ten
characters (an empty string names nothing, like the grid's own empty entry);
anything else queues the prompt as today. The method returns whether a prompt
was queued.
### No other surface changes
Both call sites are guarded by `Runtime.wantsHook`, and both vanilla links are
file-local functions, so a build with nothing wrapped runs the branch exactly
as before and allocates nothing it did not allocate before. `askNicknameUI` is
unchanged and still public.
## Migration
Nothing changes for existing mods. A mod that was writing
`save.options.battleStyle` to force a style should wrap `battle.style` instead
and stop writing the option.
## Verification
- `tests/modkit/cases/battle_rule_hooks.lua` — through the public mod API: the
no-mod answers match the OPTION row and the cart's AskName; a wrapped mod
forces either style without the row being written; `false`, a string, a
too-long string, an empty string, and a fall-through each do what this RFC
says at the catch prompt.
- `tests/engine/gate_hooks.lua` — both names are in the live catalog and pass
the no-mod parity gate (vanilla called exactly once, result unchanged,
nothing allocated).
- `tests/engine/gate_meta_coverage.lua` — both names are covered by the unit
corpus.
## Backward compatibility
Additive. No existing hook, event, registry or manifest field changes shape.
The two new methods on `BattleState` are called only from the paths that
previously inlined their logic; the results with no subscriber are identical.
+154
View File
@@ -0,0 +1,154 @@
# RFC 0015: Read-only imported dataset views
## Status
Proposed.
## Motivation
A cross-version content mod can read only the active merged dataset through
`mod.content`. Even when the player has already imported another supported
game, a mod cannot inspect that version's semantic species, moves, items, type
chart, or generated asset namespace. The available alternatives are private:
mutating `CacheFs.prefix`, mounting another cache over the active one, loading
generated Lua directly, or asking for a second raw-ROM import. They compose
poorly with the active game, expose unstable import layout, and make safe
read-only use impossible from the sandbox.
The concrete consumer is **Adaptive Trainers**, whose approved Phase G Kanto+
sidecar must derive nine Kanto-line continuations, Steel/type and move
definitions, and generated sprites from the player's existing verified Gold
cache while Red, Blue, or Yellow remains active. `mod.content` exposes only the
active R/B/Y dataset; `mod.imports` can read only separately declared raw mod
imports; and `mod.cache` is mod-private generated output. None can inspect the
launcher-owned Gold semantic dataset without a second ROM import and mod-side
ROM interpretation. The engine API remains generic and contains no Adaptive
Trainers policy.
## Decision and plan extended
This implements **D-AT-004: optional Kanto+ content consumes an
active-independent semantic dataset view**. The consuming design is tracked in
the Adaptive Trainers implementation plan,
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 8. The delta is a generic, additive public API and contains no trainer
pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers
policy.
## Exact API delta
Every sandboxed mod receives:
```lua
local view, reason = mod.datasets:open("gold")
```
A known version with the current completed import marker returns a read-only view:
```lua
view = {
version = "gold",
generation = 2,
content = {
pokemon = {
get = function(self, id) end,
has = function(self, id) end,
each = function(self) end,
},
-- every public registry name and alias
},
assets = {
path = function(self, generatedPath) end,
info = function(self, generatedPath) end,
},
}
```
`get` returns a bounded detached data-only copy or nil. `has` reports data-only
semantic presence.
`each` returns ids in lexical order and detached values. The registries use
the selected version's generation routing and the engine's existing
`Schemas`, `Registry`, and `Builtins` normalization, so structured sources
such as type matchups retain the same public ids used by the active
`mod.content` facade and extractor metadata beside record maps is not exposed
as a record id or writable through the active registry's mutation verbs. Every
generated base record is checked with that selected generation's existing
public schema before it can cross `get`, `has`, or
`each`; there is no dataset-specific duplicate schema. No register, patch,
override, or remove verb is exposed. Each call returns an independent facade
over the cached internal dataset, so facade mutation cannot cross mod
boundaries.
`assets:path` returns the selected cache-prefixed virtual path.
`assets:info` returns sanitized `type` and optional `size` metadata. Both
accept only relative paths below `assets/generated/`, reject control
characters, absolute paths, backslashes, and traversal, and expose no byte
reader.
An unknown version returns `nil, "unknown_version"`. `open` checks the current
completion marker, exact version-specific file inventory, source/cache
boundary, and available file sizes; it does not read or decode semantic
modules. A missing, partial, or stale cache returns `nil, "not_imported"`.
The first content operation that needs a root reads it once, applies the
limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 values, 2 MiB
per string, and 250,000 entries per table), decodes the restricted literal
grammar, applies canonical selected-version normalization, and caches the
detached root. Each later content or asset operation rechecks marker/file and
source-bound readiness. It also rereads the source bytes for already cached
roots; unchanged roots are not decoded again, while a changed root clears the
derived registry cache and is decoded on its next use.
Malformed syntax, non-table roots, binary/trailing content, resource-limit
violations, and records that fail the public schema are therefore discovered
on first access rather than during `open`. The triggering `get` returns nil,
`has` returns false, or `each` returns no rows; the whole internal view is
invalidated, and a subsequent `open` against the unchanged source returns
`nil, "invalid_cache"`. Actionable detail is engine-logged but not exposed to
the mod. Generated Lua is never executed. Functions, userdata, threads,
metatables, and cycles cannot cross the facade. The API never exposes raw ROM
bytes, generated source, host paths, or a mount.
## Migration and compatibility
Existing mods change nothing. `mod.datasets` is additive and requires no
permission. The service is allocated lazily on the first explicit
`mod.datasets:open` call. A boot with no mods, or with mods that do not call
it, performs no cross-version cache reads.
Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active
`Data` table, PhysFS mounts, save state, or the selected game's behavior.
Red, Blue, Yellow, Gold, Silver, and Crystal keep their existing active data paths.
The completion marker and per-version required-file rules live in the pure,
injected `CacheContract` shared by the importer and dataset service. It also
defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while
checking readiness. Every `open` revalidates the contract and required module
inventory without semantic decoding. Every view operation rechecks that
readiness before serving cached state; a stale/remove/reimport transition
evicts the previous semantic view.
## Verification
- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through
the public API and covers Red, Blue, Yellow, Gold, Silver, and Crystal independently.
- The test proves semantic registry normalization, deterministic iteration,
detached records, read-only facades, cross-mod facade isolation,
version-prefixed generated assets,
traversal rejection, stable failure reasons, and stale-marker rejection.
- It also proves missing/empty/partial/stale/remove/reimport behavior, hostile
generated-source and malformed-record rejection, canonical Gen
1/Yellow/Gold hydration, active Red/Blue/Yellow isolation while reading Gold,
and the approved Kanto+ Gold records and assets.
- `tests/engine/dataset_views_lazy_validation.lua` counts semantic reads and
decoder calls to prove `open` decodes nothing, unused roots stay unread, and
repeated access does not re-decode an unchanged cached root.
- `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code
is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua`
proves every decoder resource bound.
- `tests/engine/dataset_views_no_mod_parity.lua` is the separate guarded no-mod
parity suite and proves the service stays unallocated with zero cache reads.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.
+113
View File
@@ -0,0 +1,113 @@
# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold
## Status
Proposed.
## Motivation
When a capture lands and the party already holds six Pokémon, the cart does
not ask the player anything: `AddPartyMon` fails and the mon goes to
`SendNewMonToBox` without a stop. The engine mirrors that —
`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game
mode has no way to stand in front of it.
That silence is wrong for any mode where the box is not a thing. A battle
royale locks the Pokémon Center PC for the whole match (the box is a second
health bar: deposit the healthy ones, fight with one, withdraw fresh ones),
so a seventh catch is deposited into storage the player cannot reach — the
mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon
as lost, a randomizer that wants to hand out a replacement instead, a
challenge run that makes the player release someone for the catch — all want
the decision, and today there is no seam to catch it at.
The immediate consumer is a battle-royale mode, where the fix is the game's
own rule: at 6/6 you choose who makes room, and whoever leaves hits the
ground as a ball. Neither the decision nor the spill is specific to it.
## The decision it extends
This extends the **additive, guarded seam convention** Route B in
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
`tests/engine/gate_meta_coverage.lua` enforces. It sits next to
`catch.nickname` (RFC 0015) on the same capture path: that hook answers
*what the mon is called* once its home is decided; this one decides the home
when the party cannot hold it.
There is no in-repo D-number registry to amend.
## Exact API delta
### New hook: `catch.party_full`
```lua
mod.hooks:wrap("catch.party_full", function(next, ctx)
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
-- game = <Game> }
if myMode.active then
takeCustody(ctx.mon) -- the mon is the mod's problem now
return true -- nothing is deposited
end
return next(ctx) -- false: deposit, as today
end)
```
Call site: `BattleState:partyFullDestination(mon)`, called from the capture
path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run.
The vanilla link returns `false`. A truthy return skips the box entirely —
the mon is neither in the party nor in any box, and `pokemon.caught` reports
`destination = "mod"` so the mode can find its own custody again. Anything
falsy deposits as always, "But every BOX is full!" included.
The method returns `"box"` or `"mod"`, and is a *method* on purpose: the
compatibility seam for engines that predate this RFC needs a name to ask for
(see below), and `battleStyle`/`offerNickname` set the precedent.
### One event value, already emitted
`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to
`"party"` and `"box"`. Nothing that reads the event today matches on it.
### No other surface changes
The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a
file-local, so a build with nothing wrapped runs the branch exactly as
before and allocates nothing it did not allocate before.
## Migration
Nothing changes for existing mods. A mode that was fighting the box after
the fact — withdrawing the deposit it could not prevent, or eating the loss —
should wrap `catch.party_full` and take the mon at the moment of the catch.
## Verification
- `tests/modkit/cases/catch_party_full.lua` — through the public mod API:
with no mod a 6/6 catch lands in the box with the transfer text; a wrapped
mod that claims custody leaves the mon out of both party and boxes; a
fall-through deposits; the hook's ctx carries the battle, the mon, the
display name and the game.
- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes
the no-mod parity gate (vanilla called exactly once, result unchanged,
nothing allocated).
- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit
corpus.
## Backward compatibility
Additive. No existing hook, event, registry or manifest field changes shape.
`storeCaughtMon`'s box branch keeps its text, its nickname offer and its
`pokemon.caught` emit; the only reader-visible difference with no subscriber
is one extra method on `BattleState`.
## Compatibility seam for older engines
The call site is mid-function, so a mod on a stock engine cannot see it —
the same problem `world.talk` has. The battle-royale mod's shim covers the
gap by wrapping `Boxes.deposit` (the one call the old branch makes that a
patch can reach): during a match it raises `catch.party_full` first, and a
claim refuses the deposit, so nothing reaches a box even there. The mod then
takes custody from the `pokemon.caught` emit, which carries the mon. What
the shim cannot repair is the text: the old branch answers a refused deposit
with "But every BOX is full!" before the mode's own prompt opens — the wrong
reason for the right decision, and the argument for the seam.
+92 -4
View File
@@ -214,12 +214,99 @@ finally the bare name handed to the system loader. Per-OS names are
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or `librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
`librashader_bridge.so` (Linux and Android). Android resolves the bare name `librashader_bridge.so` (Linux and Android). Android resolves the bare name
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
without a path. The desktop path is still a developer build sitting in cargo's without a path. `ShaderFX.canConvert()` reports whether the library resolved on this machine,
output directory; nothing packages it next to a shipped game yet.
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
and `ShaderFX.bridgeError()` says why not. Activating an already-converted and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this. preset never needs any of this.
**Shipped builds carry it.** `cargo` only ever emits the host's library, and
the macOS release runner packs macOS, Windows and Linux in a single
`scripts/build.sh all`, so release CI cross-builds one cdylib per platform on
its own native runner (the `shaderfx-bridge` matrix in
`.github/workflows/release.yml`) and stages it at `dist/native/<plat>/`.
`bundle_shader_bridge` in `scripts/build.sh` reads that directory, and
`SHADERFX_BRIDGE_<PLAT>` overrides it. Where each artifact puts the library:
| Artifact | Location | Found by |
| --- | --- | --- |
| macOS .app | `Contents/MacOS/` | source base directory |
| Windows zip | next to `love.exe` | source base directory |
| Linux AppImage (both arches) | AppDir root, next to `game.love` | source base directory |
| Flatpak | `/app/share/gen1recomp/` | source base directory |
| RG34XXSP / ARM SBC ports | `libs.aarch64/` | bare name via `LD_LIBRARY_PATH` |
| Android APK | `jniLibs/<abi>/` | bare name |
| iOS | linked into the binary | `ffi.C` |
The macOS library is a universal binary, because LÖVE.app is: an arm64-only
bridge would fail `ffi.load` on Intel Macs the moment they hit CONVERT. The
two Linux libraries are built with `cargo-zigbuild` against
`{x86_64,aarch64}-unknown-linux-gnu.2.17`, below every consumer: the upstream
x86_64 LÖVE AppImage floors at GLIBC_2.29, the arm64 AppImage's own gate is
GLIBC_2.31, PortMaster's aarch64 LÖVE runtime floors at GLIBC_2.17, and ArkOS
is glibc 2.30. 2.17 is the oldest glibc with aarch64 support, so one library
per arch clears the whole fleet. `scripts/linux-arm64/verify_appimage.sh`
discovers every ELF object in the AppDir rather than globbing `bin/love` and
`lib/*.so*`, so the bridge is held to the same floor, resolution and
dlopen-not-linked rules as everything else, then dlopened once to prove
relocations and constructors run.
Setting `SHADERFX_BRIDGE_REQUIRED=1` turns the packagers' "not found" warning
into a hard error. Release CI sets it on every job that ships a bridge, so a
release cannot silently go out without one again.
**Where CONVERT is unavailable.** Switch and Xbox UWP ship without a bridge.
The Switch build uses devkitPro's toolchain, which is not a rustc target; the
UWP appcontainer is unproven for loading a desktop MSVC DLL. On both, presets
converted elsewhere still activate and run normally, and `ShaderFX.canConvert()`
returns false with `ShaderFX.bridgeError()` explaining why.
**Statically linked bridges.** On iOS there is no loadable library at all:
the bridge is linked straight into the app binary, so its symbols live in the
main image and are reachable only through `ffi.C`. The candidate list is empty
on iOS and, on every platform, a failed candidate walk falls back to probing
`ffi.C` for `librashader_translate_preset` after the `ffi.cdef`. If the symbol
is there, `ffi.C` becomes the library handle and `ShaderFX.translate` uses it
unchanged. If it is not, `ShaderFX.bridgeError()` reports that `ffi.C` was
probed as well, followed by the file paths that were tried (omitted on iOS,
where none are).
**Android ships the bridge inside the APK.** `scripts/build_android.sh`
stages `liblibrashader_bridge.so` into
`mobile/android/app/src/main/jniLibs/<abi>/` for the two ABIs the APK ships,
arm64-v8a and armeabi-v7a, so `ffi.load("liblibrashader_bridge.so")` finds it
by bare name in the app's native library directory. The build cross-compiles
the crate with `cargo ndk` against the same NDK the gradle project uses
(25.2.9519653), which needs `cargo install cargo-ndk` and
`rustup target add aarch64-linux-android armv7-linux-androideabi`; the script
names the exact command for any target that is missing. Set
`SHADERFX_BRIDGE_ANDROID_DIR` to a directory holding
`<abi>/liblibrashader_bridge.so` to bundle prebuilt libraries instead. As on
desktop, the bridge is only needed to CONVERT a preset: a build without it
still runs presets converted elsewhere, and the packager warns and continues
rather than failing.
**iOS links the bridge instead of loading it.** An iOS app cannot `dlopen` a
dylib shipped beside its binary, so `tools/shaderfx-bridge` also builds as a
`staticlib` and `scripts/build_ios.sh` links it into the app executable
(`bundle_shader_bridge_ios`, mirroring `bundle_shader_bridge` in
`scripts/build.sh`). `SHADERFX_BRIDGE_IOS` points at a prebuilt archive;
otherwise cargo builds `aarch64-apple-ios` for device builds and every
installed simulator target (`aarch64-apple-ios-sim`, `x86_64-apple-ios`) for
the Simulator, with `IPHONEOS_DEPLOYMENT_TARGET=15.0`, and `ARCHS` is pinned to
what got built. The archive is never linked directly: LÖVE 12 carries its own
glslang for shader validation and the crate drags in a second, incompatible
one, and linking both crashed inside `Shader::validateInternal` at startup.
Each slice is therefore prelinked with `ld -r -all_load
-exported_symbols_list` so only `_librashader_translate_preset` and
`_librashader_free_string` stay external and every other symbol (glslang,
spirv-cross, Rust std) becomes private to the object, then `rust-objcopy`
drops the `__LLVM` bitcode sections rustup's prebuilt std carries, since
Apple's `nm` cannot read them. `OTHER_LDFLAGS` adds `-Wl,-u` on both entry
points to keep them past dead-stripping and `-lc++` for the C++ the crate
needs; no Objective-C framework is involved. A build that linked the object
fails if `nm` cannot find `_librashader_translate_preset` in the finished
binary; a build that could not produce one only warns and lands where the
desktop packages without cargo land: converted presets run, CONVERT does not.
### Fixup ### Fixup
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE `ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
@@ -656,7 +743,8 @@ None of these are theoretical.
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not `bundle_shader_bridge`, building it with cargo when a prebuilt one is not
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
package that can run converted presets but cannot CONVERT new ones, and says package that can run converted presets but cannot CONVERT new ones, and says
so rather than failing. Android ships the `.so` via `jniLibs`. so rather than failing. `scripts/build_android.sh` and `scripts/build_ios.sh`
follow the same rule with `cargo ndk` and the iOS static archive.
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one - **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
manual pass over `handheld/` and is expected to change, most likely to shrink. manual pass over `handheld/` and is expected to change, most likely to shrink.
- **Tilt direction is unverified.** Which way forward and back rocking moves the - **Tilt direction is unverified.** Which way forward and back rocking moves the
+3 -2
View File
@@ -63,8 +63,9 @@ mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets ## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, (`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`,
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and `-linux-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`,
`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
PortMaster archives) plus two assets the updater itself consumes: PortMaster archives) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern - `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=gen1recomp
Comment=Pokémon Gen 1/2 recompilation
Exec=gen1recomp
Icon=com.theboisclub.gen1recomp
Terminal=false
Categories=Game;
StartupWMClass=love
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.theboisclub.gen1recomp</id>
<name>gen1recomp</name>
<summary>Pokémon Gen 1/2 recompilation</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<developer_name>The Bois Club</developer_name>
<description>
<p>
A LÖVE-based recompilation of Pokémon Gen 1 and Gen 2 with a launcher,
ROM importer, mods, and optional cloud save sync. Requires your own
legal cartridge dumps.
</p>
</description>
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
<screenshots>
<screenshot type="default">
<caption>Launcher</caption>
<image>https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png</image>
</screenshot>
</screenshots>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.0.0" date="1970-01-01"/>
</releases>
</component>
+68
View File
@@ -0,0 +1,68 @@
# Template consumed by scripts/build_flatpak.sh (placeholders substituted).
# Do not pass this file directly to flatpak-builder.
app-id: com.theboisclub.gen1recomp
runtime: org.freedesktop.Platform
runtime-version: "24.08"
sdk: org.freedesktop.Sdk
command: gen1recomp
finish-args:
- --share=network
- --share=ipc
- --socket=x11
- --socket=wayland
- --device=dri
- --socket=pulseaudio
# Full input device access so SDL2 sees udev/evdev gamepads + rumble/gyro
- --device=all
- --filesystem=home
modules:
- name: curl
buildsystem: simple
build-commands:
- tar -xJf curl-static.tar.xz
- install -Dm755 curl /app/bin/curl
sources:
- type: file
url: https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-x86_64-musl-8.21.0.tar.xz
sha256: e955f211202ded2536164588331acfc987dc4b7857efa3577717b1ffeab22029
dest-filename: curl-static.tar.xz
- name: love
buildsystem: simple
build-commands:
- chmod +x love-x86_64.AppImage
- ./love-x86_64.AppImage --appimage-extract
- mkdir -p /app/lib/love /app/bin /app/share/gen1recomp
- cp -a squashfs-root/bin/love /app/bin/love
- cp -a squashfs-root/lib/. /app/lib/love/
- |
cat > /app/bin/gen1recomp <<'EOF'
#!/bin/sh
export LD_LIBRARY_PATH="/app/lib/love:${LD_LIBRARY_PATH:-}"
exec /app/bin/love /app/share/gen1recomp/game.love "$@"
EOF
- chmod +x /app/bin/gen1recomp
- install -Dm644 game.love /app/share/gen1recomp/game.love
# BRIDGE-BEGIN
- install -Dm755 liblibrashader_bridge.so /app/share/gen1recomp/liblibrashader_bridge.so
# BRIDGE-END
- install -Dm644 com.theboisclub.gen1recomp.desktop /app/share/applications/com.theboisclub.gen1recomp.desktop
- install -Dm644 com.theboisclub.gen1recomp.metainfo.xml /app/share/metainfo/com.theboisclub.gen1recomp.metainfo.xml
- install -Dm644 icon.png /app/share/icons/hicolor/512x512/apps/com.theboisclub.gen1recomp.png
sources:
- type: file
url: https://github.com/love2d/love/releases/download/11.5/love-11.5-x86_64.AppImage
sha256: 65a673406431eff7167a15a032bf7a2e4ba50108e091eb7b176465831f9b5e00
dest-filename: love-x86_64.AppImage
- type: file
path: game.love
# BRIDGE-BEGIN
- type: file
path: liblibrashader_bridge.so
# BRIDGE-END
- type: file
path: com.theboisclub.gen1recomp.metainfo.xml
- type: file
path: com.theboisclub.gen1recomp.desktop
- type: file
path: icon.png
+132 -11
View File
@@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay") local HostDisplay = require("src.core.HostDisplay")
local GameViewport = require("src.render.GameViewport") local GameViewport = require("src.render.GameViewport")
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
local emergencyQuitTimer = 0
local function checkEmergencyQuit(dt)
local held = false
if love.joystick and love.joystick.getJoysticks then
local joysticks = love.joystick.getJoysticks()
for _, j in ipairs(joysticks) do
if j:isGamepad() then
local start = j:isGamepadDown("start")
local selectBtn = j:isGamepadDown("back") or j:isGamepadDown("guide")
if start and selectBtn then
held = true
break
end
else
local bCount = j:getButtonCount()
local s1 = (bCount >= 7 and j:isDown(7)) or (bCount >= 9 and j:isDown(9))
local s2 = (bCount >= 8 and j:isDown(8)) or (bCount >= 10 and j:isDown(10))
if s1 and s2 then
held = true
break
end
end
end
end
if love.keyboard and love.keyboard.isDown then
if (love.keyboard.isDown("escape") and love.keyboard.isDown("return"))
or (love.keyboard.isDown("lalt") and love.keyboard.isDown("f4")) then
held = true
end
end
if held then
emergencyQuitTimer = emergencyQuitTimer + (dt or 0.016)
if emergencyQuitTimer >= 5.0 then
print("[FORCE QUIT] Start + Select held for 5 seconds. Exiting forcefully.")
pcall(function()
if love.audio and love.audio.stop then love.audio.stop() end
if love.window and love.window.close then love.window.close() end
end)
local exitFn = os["exit"]
exitFn(0)
end
else
emergencyQuitTimer = 0
end
end
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
local defaultErrorHandler = love.errorhandler or love.errhand local defaultErrorHandler = love.errorhandler or love.errhand
@@ -30,10 +80,33 @@ do
if ok and hint and type(msg) == "string" then if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint msg = msg .. "\n\n" .. hint
end end
if love.window and love.window.isOpen and love.window.isOpen() and love.graphics and love.graphics.isActive() then
local fullMsg = tostring(msg) .. "\n\n" .. tostring(debug.traceback()) .. "\n\n[Hold START + SELECT for 5s to Force Quit]"
return function()
love.event.pump()
for e, a in love.event.poll() do
if e == "quit" or (e == "keypressed" and a == "escape") then
return 1
elseif e == "gamepadpressed" and (a == "start" or a == "back") then
return 1
end
end
checkEmergencyQuit(0.016)
love.graphics.origin()
love.graphics.clear(0.10, 0.10, 0.12)
love.graphics.setColor(1, 0.4, 0.4, 1)
love.graphics.printf(fullMsg, 20, 20, love.graphics.getWidth() - 40)
love.graphics.present()
love.timer.sleep(0.016)
end
end
if defaultErrorHandler then if defaultErrorHandler then
return defaultErrorHandler(msg) return defaultErrorHandler(msg)
end end
end end
love.errhand = love.errorhandler
end end
local Game, EditorApp, Importer, TouchEditor, Studio local Game, EditorApp, Importer, TouchEditor, Studio
@@ -384,7 +457,16 @@ function bootGame(version, cartId)
Game = require("src.core.Game2").new() Game = require("src.core.Game2").new()
Game:load() Game:load()
else else
Game = require("src.core.Game") -- Gen1 Game is a module singleton. Always re-require after in-process
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
-- is nil (release Android: bootGame then dies with load-a-nil-value).
-- rawget: type(mod.load) can lie via __index and skip a rebuild.
package.loaded["src.core.Game"] = nil
local gameMod = require("src.core.Game")
if type(rawget(gameMod, "load")) ~= "function" then
error("src.core.Game missing load after reload")
end
Game = gameMod
Game:load() Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then if os.getenv("POKEPORT_AUTOPILOT") then
autopilot = require("tests.autopilot") autopilot = require("tests.autopilot")
@@ -550,6 +632,7 @@ function love.load(args)
end end
function love.update(dt) function love.update(dt)
checkEmergencyQuit(dt)
HostDisplay.update(dt) HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false) SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
@@ -1118,15 +1201,23 @@ function love.quit()
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- docs/modding.md's core.quit_to_launcher entry) may veto returning to
-- this Lua launcher via that hook. Vanilla behavior (used when no mod -- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below. -- claims the hook) is exactly the condition below.
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android") --
-- Android and iOS both tear down LOVE in-process rather than
-- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes
-- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for
-- every quit while warning that leftover threads make that unreliable.
-- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning
-- real -- endProcess joins them, then the native restart still blows up.
local osName = love.system and love.system.getOS and love.system.getOS()
local inProcessReturn = (osName == "Android" or osName == "iOS")
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted return Game and not Importer and not quitToLauncher and not scripted
and (isAndroid or not launchedIntoGame) and (inProcessReturn or not launchedIntoGame)
end) end)
if wouldReturnToLauncher then if wouldReturnToLauncher then
if isAndroid then if inProcessReturn then
returnToLauncher() returnToLauncher()
return true -- abort this quit; the restart lands back in the launcher return true -- abort this quit; stay in the same LOVE run
end end
quitToLauncher = true quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- Tell the fresh boot to ignore any boot-straight-into-a-game option this
@@ -1170,6 +1261,17 @@ function love.run()
-- per-frame sleep-granularity jitter. -- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0 local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0 local dt = 0
local idleFor = 0
local SLEEP_FLOOR = 0.001
local WAKE = {
keypressed = true, keyreleased = true, textinput = true,
mousepressed = true, mousereleased = true, mousemoved = true,
wheelmoved = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickpressed = true, joystickreleased = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
joystickadded = true, joystickremoved = true, filedropped = true,
directorydropped = true, focus = true, visible = true, resize = true,
}
return function() return function()
-- process events -- process events
@@ -1188,17 +1290,36 @@ function love.run()
return a or 0 return a or 0
end end
end end
if WAKE[name] then
idleFor = 0
elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then
idleFor = 0
end
love.handlers[name](a, b, c, d, e, f) love.handlers[name](a, b, c, d, e, f)
end end
end end
-- update dt -- update dt
if love.timer then dt = love.timer.step() end if love.timer then dt = love.timer.step() end
idleFor = idleFor + dt
checkEmergencyQuit(dt)
-- call update and draw -- call update and draw
if love.update then love.update(dt) end if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then local visible = not (love.window and love.window.isVisible)
or love.window.isVisible()
local focused = not (love.window and love.window.hasFocus)
or love.window.hasFocus()
local cap = FrameCap.current
if not visible then
cap = 10
elseif Importer and (not focused or idleFor > 30) then
cap = 15
end
if visible and love.graphics and love.graphics.isActive() then
love.graphics.origin() love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor()) love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end if love.draw then love.draw() end
@@ -1209,9 +1330,9 @@ function love.run()
if paced then if paced then
-- Sleep out the remainder of the frame budget, measured from the -- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays -- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower -- responsive. The pacer yields to vsync inside a 1ms dead band, so
-- than the cap the remainder is <= 0 and this rounds to a no-op. -- when the panel already paces at or below the cap it is a no-op.
local budget = 1 / FrameCap.current local budget = 1 / cap
nextFrame = nextFrame + budget nextFrame = nextFrame + budget
local now = love.timer.getTime() local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the -- A stall (alt-tab, a GC pause, a blocked import) can leave the
@@ -1222,8 +1343,8 @@ function love.run()
end end
while true do while true do
local remaining = nextFrame - love.timer.getTime() local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end if remaining <= SLEEP_FLOOR then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001) love.timer.sleep(0.001)
end end
else else
love.timer.sleep(0.001) love.timer.sleep(0.001)
+5
View File
@@ -100,6 +100,11 @@ love-android 11.5a expects:
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`. `local.properties` when it finds `~/Library/Android/sdk`.
**ShaderFX bridge**: `scripts/build_android.sh` bundles
`liblibrashader_bridge.so` for arm64-v8a and armeabi-v7a via `cargo ndk` (or
from `SHADERFX_BRIDGE_ANDROID_DIR`), and warns and continues when neither is
available; see `docs/shaderfx.md`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone). Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`. Build task: `assembleEmbedNoRecordDebug`.
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
#endif #endif
} }
bool System::pickFile(const char *kind) const bool System::pickFile(const char *kind, const char *destination) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
const char *dest = "picked_rom.gb"; const char *dest = "picked_rom.gb";
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav"; dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0) else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin"; dest = (destination != nullptr && destination[0] != '\0')
? destination : "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0) else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb"; dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them // Unknown kinds used to fall through to the ROM destination. Refuse them
@@ -116,7 +116,7 @@ public:
* "required_import" -> picked_required_import.bin. * "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile(const char *kind = nullptr) const; virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
virtual const char *pickFileKinds() const; virtual const char *pickFileKinds() const;
/** /**
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
const char *kind = luaL_optstring(L, 1, nullptr); const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind)); const char *destination = luaL_optstring(L, 2, nullptr);
luax_pushboolean(L, instance()->pickFile(kind, destination));
return 1; return 1;
} }
@@ -39,6 +39,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.security.MessageDigest;
import android.Manifest; import android.Manifest;
import android.app.AlarmManager; import android.app.AlarmManager;
@@ -111,6 +112,8 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher // basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442). // instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag"; private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Written after a direct required-import copy has been fully published.
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery // Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS // consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER // GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
@@ -576,6 +579,21 @@ public class GameActivity extends SDLActivity {
* picked_rom.gb, picked_mod.zip, picked_save.sav, or * picked_rom.gb, picked_mod.zip, picked_save.sav, or
* picked_required_import.bin) * picked_required_import.bin)
*/ */
private static boolean isDirectRequiredDestination(String relative) {
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
String normalized = relative.replace('\\', '/');
if (!normalized.startsWith("mods/")) return false;
int marker = normalized.indexOf("/baseroms/");
if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) {
return false;
}
return !normalized.contains("//")
&& !normalized.equals("..")
&& !normalized.startsWith("../")
&& !normalized.contains("/../")
&& !normalized.endsWith("/..");
}
/** Legacy single-argument entry; resolves the save dir itself. */ /** Legacy single-argument entry; resolves the save dir itself. */
@Keep @Keep
public static boolean showFilePicker(String destFilename) { public static boolean showFilePicker(String destFilename) {
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
// onActivityResult copies the pick there, not into a recomputed // onActivityResult copies the pick there, not into a recomputed
// (possibly different-volume) root (#604, #839). // (possibly different-volume) root (#604, #839).
self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
// Reject path separators so a hostile JNI caller cannot escape the // Basename destinations keep the historical ROM/mod/save staging path.
// save identity directory. // A nested destination is accepted only for an engine-generated mod
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { // baseroms path, then canonicalized beneath LOVE's mounted save root.
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename); String normalizedDest = destFilename.replace('\\', '/');
boolean nested = normalizedDest.indexOf('/') >= 0;
if (nested && !isDirectRequiredDestination(normalizedDest)) {
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
return false;
}
try {
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
String rootPrefix = rootCanonical.getPath() + File.separator;
if (destCanonical.equals(rootCanonical)
|| !destCanonical.getPath().startsWith(rootPrefix)) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
} catch (IOException e) {
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
return false; return false;
} }
self.pendingPickFilename = destFilename; self.pendingPickFilename = normalizedDest;
if (android.os.Build.VERSION.SDK_INT >= 21) { if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
@@ -1277,13 +1311,7 @@ public class GameActivity extends SDLActivity {
/** Drops a small flag file in the save identity for Lua to consume on focus. */ /** Drops a small flag file in the save identity for Lua to consume on focus. */
private void writeSaveDirFlag(String name, String body) { private void writeSaveDirFlag(String name, String body) {
try { writeFlagFile(saveIdentityDir(), name, body);
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
fos.write(body.getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
} }
/** /**
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "could not create " + destDir); Log.d("GameActivity", "could not create " + destDir);
return; return;
} }
String destName = pendingPickFilename != null final String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME; ? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName); final boolean directRequired = isDirectRequiredDestination(destName);
final File destFile;
try {
File rootCanonical = destDir.getCanonicalFile();
destFile = new File(rootCanonical, destName).getCanonicalFile();
String rootPrefix = rootCanonical.getPath() + File.separator;
if (destFile.equals(rootCanonical)
|| !destFile.getPath().startsWith(rootPrefix)
|| (destName.indexOf('/') >= 0 && !directRequired)) {
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
} catch (IOException e) {
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but // ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
// some OEM shells (ColorOS) offer third-party file managers in a // some OEM shells (ColorOS) offer third-party file managers in a
@@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity {
writeSaveDirFlag(PICK_ERROR_FILENAME, destName); writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return; return;
} }
if (!copyAssetFile(source, destFile.getPath())) { final InputStream pickedSource = source;
final File pickedRoot = destDir;
if (directRequired) {
// Optical-disc-sized imports must not block Android's UI thread and
// must not create a second picked_required_import.bin copy.
new Thread(new Runnable() {
@Override public void run() {
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
if (!result.ok) {
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
return;
}
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
+ Long.toString(result.bytes) + "\n";
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
}
}, "gen1recomp-required-import").start();
return;
}
if (!copyAssetFile(pickedSource, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked file to " + destFile); Log.d("GameActivity", "could not copy picked file to " + destFile);
// A truncated pick would only fail verification later, so drop it
// and report instead.
destFile.delete(); destFile.delete();
writeSaveDirFlag(PICK_ERROR_FILENAME, destName); writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
} }
} }
private static final class PickCopyResult {
boolean ok = false;
long bytes = 0;
String md5 = "";
}
private static String hex(byte[] bytes) {
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff));
return out.toString();
}
private static void writeFlagFile(File dir, String name, String body) {
try {
if (!dir.isDirectory() && !dir.mkdirs()) return;
File tmp = new File(dir, name + ".tmp");
File dest = new File(dir, name);
FileOutputStream out = new FileOutputStream(tmp, false);
out.write(body.getBytes("UTF-8"));
out.getFD().sync();
out.close();
if (dest.exists() && !dest.delete()) { tmp.delete(); return; }
if (!tmp.renameTo(dest)) tmp.delete();
} catch (Exception e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
}
private static PickCopyResult copyRequiredImport(InputStream source, File destination) {
PickCopyResult result = new PickCopyResult();
File parent = destination.getParentFile();
File partial = new File(destination.getPath() + ".part");
BufferedInputStream in = null;
BufferedOutputStream out = null;
FileOutputStream rawOut = null;
try {
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result;
if (partial.exists() && !partial.delete()) return result;
MessageDigest md5 = MessageDigest.getInstance("MD5");
in = new BufferedInputStream(source, 1024 * 1024);
rawOut = new FileOutputStream(partial, false);
out = new BufferedOutputStream(rawOut, 1024 * 1024);
byte[] buf = new byte[1024 * 1024];
int n;
long total = 0;
while ((n = in.read(buf)) != -1) {
if (n == 0) continue;
out.write(buf, 0, n);
md5.update(buf, 0, n);
total += n;
}
out.flush();
rawOut.getFD().sync();
out.close(); out = null; rawOut = null;
in.close(); in = null;
// Publish only a complete same-directory file. Validation still
// happens in Lua against the manifest before a receipt is written.
if (destination.exists() && !destination.delete()) return result;
if (!partial.renameTo(destination)) return result;
result.ok = true;
result.bytes = total;
result.md5 = hex(md5.digest());
Log.d("GameActivity", "direct required import copied " + total
+ " bytes to " + destination);
return result;
} catch (Exception e) {
Log.d("GameActivity", "direct required import failed: " + e.getMessage());
return result;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {}
if (!result.ok && partial.exists()) partial.delete();
}
}
/** /**
* Copies a given file from the assets folder to the destination. * Copies a given file from the assets folder to the destination.
* *
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
assert (source != null && destination != null); assert (source != null && destination != null);
try { try {
byte[] buf = new byte[1024]; byte[] buf = new byte[1024 * 1024];
chunk_read = source.read(buf); while ((chunk_read = source.read(buf)) != -1) {
do { if (chunk_read == 0) continue;
destination.write(buf, 0, chunk_read); destination.write(buf, 0, chunk_read);
bytes_written += chunk_read; bytes_written += chunk_read;
chunk_read = source.read(buf); }
} while (chunk_read != -1);
} catch (IOException e) { } catch (IOException e) {
Log.d("GameActivity", "Copying failed:" + e.getMessage()); Log.d("GameActivity", "Copying failed:" + e.getMessage());
} }
+1
View File
@@ -52,6 +52,7 @@ The script verifies the final app before packaging it:
- the public Documents plist settings are present - the public Documents plist settings are present
- the native picker bridge is present - the native picker bridge is present
- the SHADER FX librashader bridge is linked into the app binary, when this build produced one
- `game.love` exists and is non-empty - `game.love` exists and is non-empty
If the payload is missing, the build fails instead of producing a blank app. If the payload is missing, the build fails instead of producing a blank app.
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.27",
"date": "2026-08-26",
"size": 21365966,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.27/gen1recomp++-0.2.27-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.2.26",
"date": "2026-08-25",
"size": 21326715,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.26/gen1recomp++-0.2.26-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #917 Save editor still unable to save\n- #1074 Yellow ROM Importer may be broken\n- #1635 Missing FX jingle sounds for items\n- #1639 [Yellow] Summer Beach House colors in Advanced mode\n- #1644 Minor bug: Fly's screen fade is black instead of white\n- #1743 Super Nerd in Mt. Moon Wrong Dialogue\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @campavao\n- @HighDrexler"
},
{
"version": "0.2.25",
"date": "2026-08-25",
"size": 21307139,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.25/gen1recomp++-0.2.25-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #941 Screen Size in Portrait\n- #1081 OS Slideshow\n- #1349 Modded Transparent Sprites getting cropped\n- #1602 Default on screen controls are different between RBY and Gold\n- #1618 [Silver] Potions [the item] are able to use rock smash as if they are Pokemon\n- #1624 gen 2 fullscreen\n- #1630 Gold missing attack feedback\n- #1633 #1488 Issue fixed, but a minor blemish remains.\n- #1661 bug: built in controls are missing.\n- #1669 gold item pc cannot hold two stacks of the same item id\n- #1676 Battle size: world\n- #1731 (Gold) 2 turn moves are broken\n- #1739 Town Map Sprite Icon dont have Color in Advanced Color Setting\n- #1740 Misaligned ! Box\n- #1742 Healing Screen\n- #1744 foresight does not allow normal type moves to hit ghosts\n- #1753 Pokémon show up in wide battle layout and use the incorrect colour palette\n- #1757 Potential deviations in game mechanics\n- #1758 Smog animation colors\n- #1760 \"Sent some money to mom\" cut off\n- #1761 Return / Frustration have incorrect base power\n- #1762 No incoming phone calls\n- #1763 [Gen2] Title Screen should restart after song ends\n- #1765 Battle animations all black when Flash isn't used in Rock Tunnel\n- #1770 \"Go!\" text visible when opening item menu\n- #1777 bug: Frame pacing micro stutter above 60Hz on steam deck oled\n- #1781 Missing Pokemon cries in Lavender Town\n- #1783 Order of items displayed is different from inventory when depositing\n- #1784 Pokemon cries not played in Celadon City\n- #1785 Wrong text order when picking up Eevee\n- #1787 Blackboard in Celadon Mansion is a little buggy\n- #1788 Cry of Pokemon that's about to evolve isn't played\n- #1789 Champion Blue doesn't force battle animations\n- #1790 Kris is red in battle screen\n- #1792 Violet City Bellsprout for Onix Trade Offers Bellsprout for Bellsprout\n- #1797 Pokedex diploma has minor visual errors\n- #1802 Weird persistant CPU usage spike after viewing mod list\n- #1803 A couple of issues in the secret house\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @campavao\n- MaxTomahawk"
},
{
"version": "0.2.24",
"date": "2026-08-24",
"size": 14060528,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.2.23", "version": "0.2.23",
"date": "2026-08-24", "date": "2026-08-24",
+101 -4
View File
@@ -13,6 +13,7 @@
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import CryptoKit
@objc(GRPickerBridge) @objc(GRPickerBridge)
public final class GRPickerBridge: NSObject { public final class GRPickerBridge: NSObject {
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
@objc(presentPickerWithKind:saveDir:) @objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?, public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool { saveDir: UnsafePointer<CChar>?) -> Bool {
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
}
@objc(presentPickerWithKind:saveDir:destination:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?) -> Bool {
let kindStr = kind.map { String(cString: $0) } ?? "rom" let kindStr = kind.map { String(cString: $0) } ?? "rom"
guard let dir = resolvedSaveDir(saveDir) else { return false } guard let dir = resolvedSaveDir(saveDir) else { return false }
let requestedDestination = destination.map { String(cString: $0) }
let destName: String let destName: String
var types: [UTType] = [] var types: [UTType] = []
switch kindStr { switch kindStr {
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
case "sav": case "sav":
destName = "picked_save.sav" destName = "picked_save.sav"
case "required_import": case "required_import":
destName = "picked_required_import.bin" if let requestedDestination,
isDirectRequiredDestination(requestedDestination),
safeDestination(in: dir, relative: requestedDestination) != nil {
destName = requestedDestination
} else {
destName = "picked_required_import.bin"
}
// A Nintendo 64 cartridge, for mods that build assets out of one -- // A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this // the voxel mod's Pokemon Stadium battle models are the caller this
// was added for. Its own filename on purpose: an N64 ROM landing on // was added for. Its own filename on purpose: an N64 ROM landing on
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
types.append(.data) types.append(.data)
if !types.contains(.item) { types.append(.item) } if !types.contains(.item) { types.append(.item) }
let directRequired = kindStr == "required_import"
&& destName != "picked_required_import.bin"
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: true) asCopy: !directRequired)
picker.allowsMultipleSelection = false picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in let delegate = PickerDelegate { urls in
guard let src = urls.first else { return } guard let src = urls.first else { return }
copyItem(at: src, into: dir, named: destName) if directRequired {
copyRequiredItemAsync(at: src, into: dir, relative: destName)
} else {
copyItem(at: src, into: dir, named: destName)
}
} }
return present(picker, with: delegate) return present(picker, with: delegate)
} }
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
try? fm.moveItem(at: item, to: target) try? fm.moveItem(at: item, to: target)
} }
private static func isDirectRequiredDestination(_ relative: String) -> Bool {
let normalized = relative.replacingOccurrences(of: "\\", with: "/")
guard normalized.hasPrefix("mods/"),
let range = normalized.range(of: "/baseroms/"),
range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5),
range.upperBound < normalized.endIndex else { return false }
return !normalized.hasPrefix("/")
&& !normalized.contains("//")
&& !normalized.contains("/../")
&& !normalized.hasSuffix("/..")
}
private static func safeDestination(in root: URL, relative: String) -> URL? {
guard isDirectRequiredDestination(relative) else { return nil }
let rootURL = root.standardizedFileURL
let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
guard candidate.path.hasPrefix(rootPath) else { return nil }
return candidate
}
private static func writeFlag(in dir: URL, name: String, body: String) {
try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name),
options: .atomic)
}
private static func copyRequiredItemAsync(at src: URL, into dir: URL,
relative: String) {
DispatchQueue.global(qos: .userInitiated).async {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
guard let dest = safeDestination(in: dir, relative: relative) else {
writeFlag(in: dir, name: "pick_error.flag", body: relative)
return
}
let fm = FileManager.default
ensureDirectory(dest.deletingLastPathComponent())
let partial = URL(fileURLWithPath: dest.path + ".part")
try? fm.removeItem(at: partial)
guard fm.createFile(atPath: partial.path, contents: nil) else {
writeFlag(in: dir, name: "pick_error.flag", body: relative)
return
}
var hasher = Insecure.MD5()
var total: UInt64 = 0
do {
let input = try FileHandle(forReadingFrom: src)
let output = try FileHandle(forWritingTo: partial)
defer {
try? input.close()
try? output.close()
}
while true {
let data = input.readData(ofLength: 1024 * 1024)
if data.isEmpty { break }
hasher.update(data: data)
output.write(data)
total += UInt64(data.count)
}
output.synchronizeFile()
try? fm.removeItem(at: dest)
try fm.moveItem(at: partial, to: dest)
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
let marker = "v1\n" + relative + "\n" + digest + "\n"
+ String(total) + "\n"
writeFlag(in: dir, name: "pick_complete.flag", body: marker)
NSLog("GRPickerBridge: direct required import delivered %llu bytes", total)
} catch {
try? fm.removeItem(at: partial)
NSLog("GRPickerBridge: direct required import failed: \(error)")
writeFlag(in: dir, name: "pick_error.flag", body: relative)
}
}
}
private static func copyItem(at src: URL, into dir: URL, named name: String) { private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource() let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } } defer { if scoped { src.stopAccessingSecurityScopedResource() } }
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
let report = "Could not copy \(src.lastPathComponent): " + let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription error.localizedDescription
try? report.data(using: .utf8)? try? report.data(using: .utf8)?
.write(to: dir.appendingPathComponent("pick_error.txt")) .write(to: dir.appendingPathComponent("pick_error.flag"))
} }
} }
+14 -1
View File
@@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className,
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
const char *kind = luaL_optstring(L, 1, "rom"); const char *kind = luaL_optstring(L, 1, "rom");
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind); const char *destination = luaL_optstring(L, 2, nullptr);
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
const char *);
signed char ok = ((GRPick)objc_msgSend)(
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
kind, gr_saveDirectory(), destination);
lua_pushboolean(L, ok != 0);
return 1;
} }
// love.system.pickFileKinds() -> the comma-separated kinds supported by the // love.system.pickFileKinds() -> the comma-separated kinds supported by the
+72 -26
View File
@@ -11,7 +11,7 @@
# #
# Output: dist/mac/gen1recomp-macos.zip # Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip # dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage) # dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under # dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/) # mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays # dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
@@ -170,27 +170,53 @@ make_ico() { # $1 = output .ico path
# --------------------------------------------------------------- macOS # --------------------------------------------------------------- macOS
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build # ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
# without it still runs presets that were converted elsewhere. # without it still runs presets that were converted elsewhere.
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it. # SHADERFX_BRIDGE_<PLAT> or dist/native/<plat>/ points at a prebuilt library;
# otherwise cargo builds it, host platform only.
shader_bridge_host_plat() {
case "$(uname -s)-$(uname -m)" in
Darwin-*) printf 'mac' ;;
Linux-x86_64) printf 'linux-x64' ;;
Linux-aarch64|Linux-arm64) printf 'linux-arm64' ;;
*) printf '' ;;
esac
}
bundle_shader_bridge() { bundle_shader_bridge() {
local dest="$1" name="$2" local dest="$1" name="$2" plat="$3"
local src="${SHADERFX_BRIDGE:-}"
local crate="$ROOT/tools/shaderfx-bridge" local crate="$ROOT/tools/shaderfx-bridge"
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then local src="" var
src="$crate/target/release/$name" [ -n "$plat" ] || fail "bundle_shader_bridge: no platform key for $name"
var="SHADERFX_BRIDGE_$(printf '%s' "$plat" | tr 'a-z-' 'A-Z_')"
eval "src=\${$var:-}"
if [ -z "$src" ] && [ -f "$DIST/native/$plat/$name" ]; then
src="$DIST/native/$plat/$name"
fi fi
if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then
say "building the ShaderFX bridge with cargo" src="$SHADERFX_BRIDGE"
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then fi
if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then
if [ -f "$crate/target/release/$name" ]; then
src="$crate/target/release/$name" src="$crate/target/release/$name"
elif command -v cargo >/dev/null 2>&1; then
say "building the ShaderFX bridge with cargo"
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
src="$crate/target/release/$name"
fi
fi fi
fi fi
if [ -n "$src" ] && [ -f "$src" ]; then if [ -n "$src" ] && [ -f "$src" ]; then
mkdir -p "$dest" mkdir -p "$dest"
cp "$src" "$dest/$name" cp "$src" "$dest/$name"
say "bundled $name for SHADER FX preset conversion" say "bundled $name for SHADER FX preset conversion ($plat)"
else return 0
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
fi fi
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
fail "$name ($plat) not found: set $var or stage it at dist/native/$plat/$name"
fi
warn "$name not found: this build can run converted presets but not CONVERT new ones (set $var or install cargo)"
} }
build_mac() { build_mac() {
@@ -205,7 +231,7 @@ build_mac() {
# drop any bundled placeholder .love and fuse ours in # drop any bundled placeholder .love and fuse ours in
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love" cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac
local plist="$out_app/Contents/Info.plist" local plist="$out_app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \ /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
@@ -322,7 +348,7 @@ build_win() {
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
fi fi
bundle_shader_bridge "$out_dir" "librashader_bridge.dll" bundle_shader_bridge "$out_dir" "librashader_bridge.dll" win-x64
# The exe's icon lives in love.exe's PE resources, so it must be patched # The exe's icon lives in love.exe's PE resources, so it must be patched
# BEFORE the .love is appended: peresed rewrites the whole file and would # BEFORE the .love is appended: peresed rewrites the whole file and would
@@ -419,7 +445,7 @@ build_linux() {
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love" cp "$LOVE_FILE" "$appdir/game.love"
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64
# Replace LÖVE's own desktop entry rather than keeping it: it says # Replace LÖVE's own desktop entry rather than keeping it: it says
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file # Name=LÖVE / Icon=love, which is what appimaged, app menus and file
@@ -457,15 +483,31 @@ EOF
# file-manager thumbnailers show for the file itself. # file-manager thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC" [ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon" rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null if command -v sips >/dev/null 2>&1; then
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
elif command -v convert >/dev/null 2>&1; then
convert "$ICON_SRC" -resize 512x512 "$appdir/$APP_NAME.png"
else
fail "need sips (macOS) or ImageMagick convert to resize $ICON_SRC"
fi
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon" cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" # sed -i '' is BSD; GNU sed wants sed -i (no empty backup suffix).
if sed --version >/dev/null 2>&1; then
sed -i 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
else
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
fi
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\ local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"|' "$appdir/AppRun" exec "$APPDIR/bin/love"'
if sed --version >/dev/null 2>&1; then
sed -i "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
else
sed -i '' "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
fi
# Match the upstream image's compression (gzip, 128K blocks) so the # Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it. # bundled runtime can read it.
@@ -474,16 +516,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun"
mksquashfs "$appdir" "$sfs_out" \ mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null -comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage" mkdir -p "$DIST/linux"
rm -f "$out_bin" local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage"
rm -f "$out_bin" "$out_bin.sha256"
head -c "$sfs_offset" "$love_appimage" > "$out_bin" head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin" cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin" chmod +x "$out_bin"
if command -v sha256sum >/dev/null 2>&1; then
local zip_out="$DIST/linux/$APP_NAME-linux.zip" printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
rm -f "$zip_out" > "$out_bin.sha256"
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")") else
say "Linux build: $zip_out" printf '%s %s\n' "$(shasum -a 256 "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
> "$out_bin.sha256"
fi
say "Linux build: $out_bin"
} }
# --------------------------------------------------------------- Android # --------------------------------------------------------------- Android
+117
View File
@@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
APPLICATION_ID="$TEST_APPLICATION_ID" APPLICATION_ID="$TEST_APPLICATION_ID"
APP_NAME="$APP_NAME (test)" APP_NAME="$APP_NAME (test)"
fi fi
# Optional overrides for side-by-side test APKs (never used by CI shipping builds).
if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then
APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID"
fi
if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then
APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME"
fi
# --------------------------------------------------------------- preconditions # --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
@@ -461,6 +468,115 @@ pack_game_love() {
fi fi
} }
# --------------------------------------------------------------- ShaderFX bridge
SHADER_BRIDGE_LIB="liblibrashader_bridge.so"
SHADER_BRIDGE_ABIS="arm64-v8a armeabi-v7a"
shader_bridge_rust_target() {
case "$1" in
arm64-v8a) printf 'aarch64-linux-android' ;;
armeabi-v7a) printf 'armv7-linux-androideabi' ;;
x86_64) printf 'x86_64-linux-android' ;;
x86) printf 'i686-linux-android' ;;
*) printf '' ;;
esac
}
shader_bridge_staged_count() {
local jni="$1" abi count=0
for abi in $SHADER_BRIDGE_ABIS; do
[ -f "$jni/$abi/$SHADER_BRIDGE_LIB" ] && count=$((count + 1))
done
printf '%s' "$count"
}
bundle_shader_bridge_android() {
local jni="$ANDROID_DIR/app/src/main/jniLibs"
local crate="$ROOT/tools/shaderfx-bridge"
local abi target
for abi in $SHADER_BRIDGE_ABIS; do
rm -f "$jni/$abi/$SHADER_BRIDGE_LIB"
done
local prebuilt="${SHADERFX_BRIDGE_ANDROID_DIR:-}"
if [ -n "$prebuilt" ]; then
for abi in $SHADER_BRIDGE_ABIS; do
if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then
mkdir -p "$jni/$abi"
cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB"
else
warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB"
fi
done
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)"
return
fi
fi
if [ ! -f "$crate/Cargo.toml" ]; then
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)"
return
fi
if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')"
return
fi
local ndk="${ANDROID_NDK_HOME:-}"
if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then
ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION"
fi
if [ ! -d "$ndk" ]; then
warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)"
return
fi
local installed missing="" buildable=""
installed="$(rustup target list --installed 2>/dev/null || true)"
for abi in $SHADER_BRIDGE_ABIS; do
target="$(shader_bridge_rust_target "$abi")"
if grep -qx "$target" <<< "$installed"; then
buildable="$buildable $abi"
else
missing="$missing $target"
fi
done
if [ -n "$missing" ]; then
warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing"
fi
if [ -z "$buildable" ]; then
warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)"
return
fi
local args=()
for abi in $buildable; do
args+=(-t "$abi")
done
say "building the ShaderFX bridge with cargo-ndk (${buildable# })"
mkdir -p "$jni"
if ! (
cd "$crate"
export ANDROID_NDK_HOME="$ndk"
export ANDROID_NDK_ROOT="$ndk"
export CARGO_PROFILE_RELEASE_STRIP="symbols"
cargo ndk "${args[@]}" -o "$jni" build --release
); then
warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones"
return
fi
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion"
else
warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones"
fi
}
# --------------------------------------------------------------- SDK check # --------------------------------------------------------------- SDK check
require_android_sdk() { require_android_sdk() {
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
@@ -575,5 +691,6 @@ if $PACKAGE_ONLY; then
fi fi
require_android_sdk require_android_sdk
bundle_shader_bridge_android
run_gradle run_gradle
say "done" say "done"
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Build a single-file Flatpak bundle for x86_64 desktop Linux.
#
# Usage: scripts/build_flatpak.sh [--version X.Y.Z] [--game-love PATH]
#
# Output:
# dist/flatpak/gen1recomp-<version>-linux.flatpak
#
# Requires: flatpak, flatpak-builder, and network on first run (Freedesktop
# runtime + LÖVE AppImage download).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP_ID="com.theboisclub.gen1recomp"
VERSION="0.0.0"
GAME_LOVE_IN=""
DIST="$ROOT/dist/flatpak"
BUILD_DIR="$ROOT/.bazinga/flatpak-build"
REPO_DIR="$ROOT/.bazinga/flatpak-repo"
STATE_DIR="$ROOT/.bazinga/flatpak-state"
CACHE="$ROOT/.bazinga/flatpak-cache"
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; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
--game-love) GAME_LOVE_IN="$2"; shift ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
command -v flatpak >/dev/null || fail "flatpak not found"
command -v flatpak-builder >/dev/null || fail "flatpak-builder not found"
mkdir -p "$DIST" "$BUILD_DIR" "$REPO_DIR" "$STATE_DIR" "$CACHE"
LOVE_FILE="$CACHE/game.love"
if [ -n "$GAME_LOVE_IN" ]; then
[ -f "$GAME_LOVE_IN" ] || fail "game.love not found: $GAME_LOVE_IN"
if [ "$(readlink -f "$GAME_LOVE_IN")" != "$(readlink -f "$LOVE_FILE")" ]; then
cp "$GAME_LOVE_IN" "$LOVE_FILE"
fi
else
say "packing game.love"
"$ROOT/scripts/pack_love.sh" --output "$LOVE_FILE" --version "$VERSION"
fi
# Stage local sources next to the generated manifest (flatpak path: is relative).
cp "$ROOT/flatpak/$APP_ID.desktop" "$CACHE/$APP_ID.desktop"
# AppStream rejects icons larger than the declared hicolor size.
if command -v magick >/dev/null 2>&1; then
magick "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
elif command -v convert >/dev/null 2>&1; then
convert "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
else
fail "need ImageMagick (magick/convert) to resize the Flatpak icon to 512x512"
fi
DATE="$(date -u +%Y-%m-%d)"
python3 - "$ROOT/flatpak/$APP_ID.metainfo.xml" "$CACHE/$APP_ID.metainfo.xml" "$VERSION" "$DATE" <<'PY'
import sys, pathlib, re
src, dst, ver, date = sys.argv[1:5]
text = pathlib.Path(src).read_text(encoding="utf-8")
release = f' <release version="{ver}" date="{date}"/>'
text = re.sub(
r"<releases>.*?</releases>",
"<releases>\n" + release + "\n </releases>",
text,
count=1,
flags=re.S,
)
pathlib.Path(dst).write_text(text, encoding="utf-8")
PY
BRIDGE_LIB="liblibrashader_bridge.so"
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_X64:-}"
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-x64/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/dist/native/linux-x64/$BRIDGE_LIB"
fi
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB"
fi
rm -f "$CACHE/$BRIDGE_LIB"
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
cp "$BRIDGE_SRC" "$CACHE/$BRIDGE_LIB"
cp "$ROOT/flatpak/$APP_ID.yml" "$CACHE/$APP_ID.yml"
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_X64 or stage it at dist/native/linux-x64/$BRIDGE_LIB"
else
warn "$BRIDGE_LIB not found: this bundle can run converted presets but not CONVERT new ones"
sed '/# BRIDGE-BEGIN/,/# BRIDGE-END/d' "$ROOT/flatpak/$APP_ID.yml" > "$CACHE/$APP_ID.yml"
fi
say "installing Freedesktop runtime (no-op if present)"
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true
flatpak --user install -y flathub \
org.freedesktop.Platform//24.08 \
org.freedesktop.Sdk//24.08 \
|| warn "runtime install reported an error; flatpak-builder may still reuse cache"
say "building Flatpak ($APP_ID $VERSION)"
# Run from CACHE so path: sources resolve.
(
cd "$CACHE"
flatpak-builder --user --force-clean \
--state-dir="$STATE_DIR" \
--repo="$REPO_DIR" \
"$BUILD_DIR" "$APP_ID.yml"
)
OUT="$DIST/gen1recomp-${VERSION}-linux.flatpak"
rm -f "$OUT"
say "exporting bundle $OUT"
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
chmod 644 "$OUT"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s %s\n' "$(sha256sum "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
else
printf '%s %s\n' "$(shasum -a 256 "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
fi
if [ -f "$CACHE/$BRIDGE_LIB" ]; then
[ -f "$BUILD_DIR/files/share/gen1recomp/$BRIDGE_LIB" ] \
|| fail "$BRIDGE_LIB was staged but is missing from the built /app/share/gen1recomp"
say "verified $BRIDGE_LIB in the Flatpak"
fi
say "Flatpak build: $OUT ($(du -h "$OUT" | cut -f1))"
say "sha256: $(cut -d' ' -f1 "$OUT.sha256")"
+149 -3
View File
@@ -615,6 +615,138 @@ verify_game_payload() {
say "game.love present ($(du -h "$app/game.love" | cut -f1))" say "game.love present ($(du -h "$app/game.love" | cut -f1))"
} }
SHADER_BRIDGE_LIB=""
SHADER_BRIDGE_NAME="liblibrashader_bridge.a"
SHADER_BRIDGE_OBJ="librashader_bridge.o"
SHADER_BRIDGE_ARCHS=""
rust_targets_for_sdk() {
if [ "$1" = "iphoneos" ]; then
printf 'aarch64-apple-ios'
else
printf 'aarch64-apple-ios-sim x86_64-apple-ios'
fi
}
build_shader_bridge_slice() {
local rust_target="$1"
local crate="$ROOT/tools/shaderfx-bridge"
local built="$crate/target/$rust_target/release/$SHADER_BRIDGE_NAME"
if [ -f "$built" ]; then
printf '%s' "$built"
return 0
fi
if command -v cargo >/dev/null 2>&1 \
&& rustup target list --installed 2>/dev/null \
| grep -x "$rust_target" >/dev/null; then
say "building the ShaderFX bridge with cargo ($rust_target)" >&2
if (cd "$crate" && IPHONEOS_DEPLOYMENT_TARGET=15.0 \
cargo build --release --target "$rust_target" >/dev/null 2>&1); then
printf '%s' "$built"
return 0
fi
fi
return 1
}
prelink_shader_bridge_slice() {
local archive="$1" sdk="$2" out="$3"
local arch platform sdk_version syms objcopy tmp
arch="$(lipo -archs "$archive" 2>/dev/null | awk '{print $1}')"
[ -n "$arch" ] || return 1
if [ "$sdk" = "iphoneos" ]; then platform="ios"; else platform="ios-simulator"; fi
sdk_version="$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null)"
[ -n "$sdk_version" ] || return 1
syms="$LIBS_DIR/librashader_bridge.exports"
printf '_librashader_translate_preset\n_librashader_free_string\n' > "$syms"
tmp="$out.tmp"
xcrun ld -r -arch "$arch" -platform_version "$platform" 15.0 "$sdk_version" \
-all_load -exported_symbols_list "$syms" -o "$tmp" "$archive" || return 1
objcopy="$(ls "$(rustc --print sysroot 2>/dev/null)"/lib/rustlib/*/bin/rust-objcopy 2>/dev/null | head -1)"
if [ -n "$objcopy" ] && "$objcopy" --remove-section __LLVM,__bitcode \
--remove-section __LLVM,__cmdline "$tmp" "$out" 2>/dev/null; then
rm -f "$tmp"
else
mv "$tmp" "$out"
fi
}
bundle_shader_bridge_ios() {
local rust_targets="$1" sdk="$2"
local src="${SHADERFX_BRIDGE_IOS:-}"
local slices=() objs=() target slice obj i
SHADER_BRIDGE_LIB=""
SHADER_BRIDGE_ARCHS=""
rm -f "$LIBS_DIR/$SHADER_BRIDGE_NAME" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" "$LIBS_DIR"/librashader_bridge.*.o
if [ -n "$src" ]; then
if [ -f "$src" ]; then
slices+=("$src")
else
warn "SHADERFX_BRIDGE_IOS=$src does not exist"
fi
else
for target in $rust_targets; do
if slice="$(build_shader_bridge_slice "$target")"; then
slices+=("$slice")
fi
done
fi
if [ "${#slices[@]}" -gt 0 ]; then
mkdir -p "$LIBS_DIR"
i=0
for slice in "${slices[@]}"; do
i=$((i + 1))
obj="$LIBS_DIR/librashader_bridge.$i.o"
if prelink_shader_bridge_slice "$slice" "$sdk" "$obj"; then
objs+=("$obj")
else
warn "could not prelink $(basename "$slice") for $sdk"
fi
done
fi
if [ "${#objs[@]}" -eq 1 ]; then
mv "${objs[0]}" "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
elif [ "${#objs[@]}" -gt 1 ]; then
lipo -create "${objs[@]}" -output "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
rm -f "${objs[@]}"
fi
if [ -f "$LIBS_DIR/$SHADER_BRIDGE_OBJ" ]; then
SHADER_BRIDGE_LIB="$LIBS_DIR/$SHADER_BRIDGE_OBJ"
SHADER_BRIDGE_ARCHS="$(lipo -archs "$SHADER_BRIDGE_LIB" 2>/dev/null || true)"
say "linking $SHADER_BRIDGE_OBJ for SHADER FX preset conversion (${SHADER_BRIDGE_ARCHS:-unknown arch})"
else
warn "$SHADER_BRIDGE_NAME not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_IOS, or install cargo plus one of: rustup target add $rust_targets)"
fi
}
verify_shader_bridge() {
local app="$1"
local exe bin
if [ -z "$SHADER_BRIDGE_LIB" ]; then
warn "no SHADER FX bridge in this build: CONVERT stays unavailable, converted presets still run"
return 0
fi
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping SHADER FX bridge check"
return 0
fi
if nm "$bin" 2>/dev/null | grep -E ' _librashader_translate_preset$' >/dev/null \
|| xcrun dyld_info -exports "$bin" 2>/dev/null \
| grep -E ' _librashader_translate_preset$' >/dev/null; then
say "SHADER FX bridge present (librashader_translate_preset)"
return 0
fi
fail "built app does not carry the SHADER FX bridge symbols.
$SHADER_BRIDGE_OBJ was linked but librashader_translate_preset is absent,
so SHADER FX CONVERT would fail at runtime.
Rebuild after: rm -rf tools/shaderfx-bridge/target"
}
run_xcodebuild() { run_xcodebuild() {
local config sdk destination local config sdk destination
if $RELEASE; then if $RELEASE; then
@@ -633,6 +765,8 @@ run_xcodebuild() {
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk"
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires # Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes. # -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) / # Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
@@ -661,6 +795,13 @@ run_xcodebuild() {
ONLY_ACTIVE_ARCH=NO ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
) )
if [ -n "$SHADER_BRIDGE_LIB" ]; then
args+=(OTHER_LDFLAGS="-Wl,-u,_librashader_translate_preset -Wl,-u,_librashader_free_string \"$SHADER_BRIDGE_LIB\" -lc++")
if [ -n "$SHADER_BRIDGE_ARCHS" ]; then
args+=(ARCHS="$SHADER_BRIDGE_ARCHS")
fi
fi
if ! $DEVICE; then if ! $DEVICE; then
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned # Simulator: ad-hoc signing (no certificate needed). A plain unsigned
# build would drop the entitlements file, and HealthKit refuses to run # build would drop the entitlements file, and HealthKit refuses to run
@@ -723,11 +864,14 @@ run_xcodebuild() {
local products="$BUILD_DIR/Build/Products/${config}-${sdk}" local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
local app="" local app=""
local candidate local candidate newest=0 mtime
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
if [ -d "$candidate" ]; then if [ -d "$candidate" ]; then
app="$candidate" mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)"
break if [ "$mtime" -gt "$newest" ]; then
newest="$mtime"
app="$candidate"
fi
fi fi
done done
if [ -z "$app" ]; then if [ -z "$app" ]; then
@@ -736,6 +880,7 @@ run_xcodebuild() {
return 0 return 0
fi fi
if [ "$app" != "$products/$APP_NAME.app" ]; then if [ "$app" != "$products/$APP_NAME.app" ]; then
rm -rf "$products/$APP_NAME.app"
mv "$app" "$products/$APP_NAME.app" mv "$app" "$products/$APP_NAME.app"
app="$products/$APP_NAME.app" app="$products/$APP_NAME.app"
fi fi
@@ -754,6 +899,7 @@ run_xcodebuild() {
verify_game_payload "$app" verify_game_payload "$app"
verify_native_bridge "$app" verify_native_bridge "$app"
verify_shader_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}" local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir" rm -rf "$dist_dir"
+15
View File
@@ -100,6 +100,21 @@ then
fi fi
cp "$GAME_LOVE" "$IN_DIR/game.love" cp "$GAME_LOVE" "$IN_DIR/game.love"
BRIDGE_LIB="liblibrashader_bridge.so"
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
fi
rm -f "$IN_DIR/$BRIDGE_LIB"
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
cp "$BRIDGE_SRC" "$IN_DIR/$BRIDGE_LIB"
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
else
warn "$BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones"
fi
# --------------------------------------------------------------- downloads # --------------------------------------------------------------- downloads
# Fetched on the host and checksum-pinned here so the container never needs # Fetched on the host and checksum-pinned here so the container never needs
# network access and every input is verified in exactly one place. # network access and every input is verified in exactly one place.
+9 -1
View File
@@ -327,6 +327,14 @@ bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')" say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
# ------------------------------------------------- host dependency contract # ------------------------------------------------- host dependency contract
SHADER_BRIDGE=()
if [ -f "$IN/liblibrashader_bridge.so" ]; then
cp "$IN/liblibrashader_bridge.so" "$APPDIR/liblibrashader_bridge.so"
chmod 0755 "$APPDIR/liblibrashader_bridge.so"
SHADER_BRIDGE=("$APPDIR/liblibrashader_bridge.so")
say "bundled liblibrashader_bridge.so for SHADER FX preset conversion"
fi
# The portability promise, stated as an assertion instead of a paragraph in a # The portability promise, stated as an assertion instead of a paragraph in a
# README: these are the ONLY sonames the shipped objects may require from the # README: these are the ONLY sonames the shipped objects may require from the
# host. Everything driver-, session- or audio-related has to be reached # host. Everything driver-, session- or audio-related has to be reached
@@ -339,7 +347,7 @@ say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n'
HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$' HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$'
unexpected="" unexpected=""
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so* ${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}; do
while read -r soname; do while read -r soname; do
[ -n "$soname" ] || continue [ -n "$soname" ] || continue
# Satisfied from inside the AppDir, so not a host requirement at all. # Satisfied from inside the AppDir, so not a host requirement at all.
+28 -3
View File
@@ -20,6 +20,23 @@ for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|| { echo "::error::AppImage is missing $required"; exit 1; } || { echo "::error::AppImage is missing $required"; exit 1; }
done done
bridge="squashfs-root/liblibrashader_bridge.so"
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ] && [ ! -e "$bridge" ]; then
echo "::error::AppImage is missing liblibrashader_bridge.so"
exit 1
fi
# Discovered, not enumerated: bin/love and lib/*.so* miss anything native at
# the AppDir root, which is where ShaderFX's bridge sits and where the next
# root-level library would land too.
scan=()
while IFS= read -r f; do
scan+=("$f")
done < <(find squashfs-root -type f -exec sh -c \
'head -c4 "$1" | od -An -tx1 | tr -d " \n" | grep -q "^7f454c46$"' _ {} \; -print | sort)
[ "${#scan[@]}" -gt 0 ] || { echo "::error::found no ELF objects in the AppDir"; exit 1; }
echo "scanning ${#scan[@]} ELF objects: ${scan[*]#squashfs-root/}"
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is # Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash. # applied; an unresolved soname here is a user-visible launch crash.
# #
@@ -28,14 +45,14 @@ done
# hard-links libpulse/libasound/libX11/libwayland, so it only ever # hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it. # started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \ missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ ldd "${scan[@]}" 2>/dev/null \
| grep 'not found' || true)" | grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; } [ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library: # Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box # those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM. # with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do linked="$(for f in "${scan[@]}"; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}' 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)" done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \ [ -z "$linked" ] \
@@ -45,7 +62,7 @@ done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo
# the builder to a newer base, the glibc floor silently rises and # 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 # every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release. # it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ floor="$(objdump -T "${scan[@]}" 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)" | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor" echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \ [ -n "$floor" ] \
@@ -54,4 +71,12 @@ highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \ [ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; } || { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
# The floor grep proves symbol versions; an actual dlopen additionally proves
# relocations and constructors. Native arch only, so it is skipped elsewhere.
if [ -e "$bridge" ] && [ "$(uname -m)" = "aarch64" ] && command -v python3 >/dev/null 2>&1; then
python3 -c "import ctypes,sys; ctypes.CDLL(sys.argv[1])" "$PWD/$bridge" \
|| { echo "::error::liblibrashader_bridge.so failed to dlopen"; exit 1; }
echo "librashader bridge dlopens cleanly"
fi
echo "AppImage verified: $image" echo "AppImage verified: $image"
+2
View File
@@ -114,9 +114,11 @@ fi
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
run_tier "T0 ShaderFX bridge packaging gate" "$LUA" tests/shaderfx_bridge_packaging_test.lua
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
# Sound.lua / overlay regression is not gated only on switch-changes paths. # Sound.lua / overlay regression is not gated only on switch-changes paths.
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
+216 -70
View File
@@ -34,6 +34,12 @@ local RomText = require("src.core.RomText")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle") local WideBattle = require("src.battle.WideBattle")
local Chrome2
do
local ok, v = pcall(require, "src.ui.gen2.Chrome")
Chrome2 = ok and v or nil
end
local romText = RomText local romText = RomText
local BattleState = {} local BattleState = {}
@@ -97,7 +103,7 @@ end
-- space. -- space.
function BattleState:extendedHUD() function BattleState:extendedHUD()
local options = self.game and self.game.save and self.game.save.options local options = self.game and self.game.save and self.game.save.options
local bg = options and options.battleBg local bg = options and self:bgMode()
return self:wideLayout() return self:wideLayout()
and options and options.battleHud == "extended" and options and options.battleHud == "extended"
and ((options.battleFit == "fixed" and ((options.battleFit == "fixed"
@@ -108,12 +114,12 @@ end
function BattleState:extendedWorldHUD() function BattleState:extendedWorldHUD()
local options = self.game and self.game.save and self.game.save.options local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options return self:extendedHUD() and options
and options.battleFit == "fixed" and options.battleBg == "world" and options.battleFit == "fixed" and self:bgMode() == "world"
end end
function BattleState:extendedBlackHUD() function BattleState:extendedBlackHUD()
local options = self.game and self.game.save and self.game.save.options local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options and options.battleBg == "black" return self:extendedHUD() and options and self:bgMode() == "black"
end end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids -- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
@@ -130,6 +136,10 @@ end
-- opaque 160x144 field over the top, so only the surround changes. -- opaque 160x144 field over the top, so only the surround changes.
function BattleState:bgMode() function BattleState:bgMode()
local options = self.game and self.game.save and self.game.save.options local options = self.game and self.game.save and self.game.save.options
if options and self:wideLayout() and options.battleFit == "fill"
and options.battleHud == "extended" then
return "white"
end
local mode = options and options.battleBg local mode = options and options.battleBg
if mode == "black" or mode == "world" then return mode end if mode == "black" or mode == "world" then return mode end
return "white" return "white"
@@ -212,6 +222,13 @@ local BALL_ANIMS = {
SHAKE_ANIM = true, SHOWPIC_ANIM = true, SHAKE_ANIM = true, SHOWPIC_ANIM = true,
} }
-- engine/battle/effects.asm:552
local ENEMY_STAT_DOWN_MISS = {
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
local imageCache = {} local imageCache = {}
-- The three tables below are keyed by the Image OBJECT, not by a path, and a -- The three tables below are keyed by the Image OBJECT, not by a path, and a
-- running battle holds the pics it built at enter() (battler.sprite, -- running battle holds the pics it built at enter() (battler.sprite,
@@ -229,6 +246,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it -- rebuilt for any battle pic, whatever code loaded it
local imageMeta = setmetatable({}, WEAK_KEYS) local imageMeta = setmetatable({}, WEAK_KEYS)
local function mattedPic(path)
return path:sub(1, 17) == "assets/generated/"
or path:sub(1, 17) == "save/mod-derived/"
end
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy. -- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
-- trueColor art (14 §the 4-shade contract) opts out of the quantize -- trueColor art (14 §the 4-shade contract) opts out of the quantize
-- entirely, so its palette variant collapses back onto the plain path. -- entirely, so its palette variant collapses back onto the plain path.
@@ -254,29 +276,31 @@ local function getImage(path, pal, trueColor)
end) end)
end end
local w, h = id:getDimensions() local w, h = id:getDimensions()
local bottom = h - 1 if mattedPic(Assets.resolve(path)) then
while bottom >= 0 do local bottom = h - 1
local opaque = false while bottom >= 0 do
for x = 0, w - 1 do local opaque = false
local _, _, _, a = id:getPixel(x, bottom) for x = 0, w - 1 do
if a > 0 then opaque = true break end local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
end
if opaque then break end
bottom = bottom - 1
end end
if opaque then break end local left = 0
bottom = bottom - 1 while left < w do
end local opaque = false
local left = 0 for y = 0, h - 1 do
while left < w do local _, _, _, a = id:getPixel(left, y)
local opaque = false if a > 0 then opaque = true break end
for y = 0, h - 1 do end
local _, _, _, a = id:getPixel(left, y) if opaque then break end
if a > 0 then opaque = true break end left = left + 1
end end
if opaque then break end pad = h - 1 - bottom
left = left + 1 padL = left
end end
img = love.graphics.newImage(id) img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else else
img = Assets.image(path) -- headless stub: no pixel access img = Assets.image(path) -- headless stub: no pixel access
end end
@@ -443,7 +467,7 @@ function BattleState:picImage(img)
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode -- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic" or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
if self.grayPics or mono then return grayImage(img) end if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
-- dark with the HP bars while the blackout text is up (#292). The intro -- dark with the HP bars while the blackout text is up (#292). The intro
@@ -2214,6 +2238,9 @@ function BattleState:update(dt)
self.waitFrames = nil self.waitFrames = nil
if destination == "menu" then if destination == "menu" then
self.introSlide = nil self.introSlide = nil
-- engine/battle/core.asm:2007
self.msgHold = nil
self.shown = nil
self.phase = "menu" self.phase = "menu"
elseif destination == "finish" then elseif destination == "finish" then
self:finish() self:finish()
@@ -3041,18 +3068,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1)
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2) local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB) local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4
local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9
-- engine/battle/animations.asm:1090
local function onSgb()
local m = require("src.render.PaletteFX").mode
return m == "gbc" or m == "gbc_inv"
end
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle, -- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1 -- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
-- (FlashScreenLongDelay) -- (FlashScreenLongDelay)
local FLASH_LONG_MAPS = { local FLASH_LONG_SGB = {
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
} }
-- engine/battle/animations.asm:992
local FLASH_LONG_MONO = {
{ [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
}
-- the shade map in force this frame (a running flash wins over the -- the shade map in force this frame (a running flash wins over the
-- persistent palette) -- persistent palette)
function BattleState:activeBgp() function BattleState:activeBgp()
@@ -3186,7 +3228,7 @@ function BattleState:applyAnimEffect(ev)
elseif e == "SE_LIGHT_SCREEN_PALETTE" then elseif e == "SE_LIGHT_SCREEN_PALETTE" then
fx.bgp = BGP_LIGHT fx.bgp = BGP_LIGHT
elseif e == "SE_DARKEN_MON_PALETTE" then elseif e == "SE_DARKEN_MON_PALETTE" then
fx.bgp = BGP_DARKEN fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO
elseif e == "SE_RESET_SCREEN_PALETTE" then elseif e == "SE_RESET_SCREEN_PALETTE" then
fx.bgp = nil fx.bgp = nil
elseif e == "SE_DARK_SCREEN_FLASH" then elseif e == "SE_DARK_SCREEN_FLASH" then
@@ -3196,8 +3238,9 @@ function BattleState:applyAnimEffect(ev)
idx = 1, left = 2 } idx = 1, left = 2 }
elseif e == "SE_FLASH_SCREEN_LONG" then elseif e == "SE_FLASH_SCREEN_LONG" then
local steps = {} local steps = {}
local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO
for cycle = 1, 3 do for cycle = 1, 3 do
for _, m in ipairs(FLASH_LONG_MAPS) do for _, m in ipairs(maps) do
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 } steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
end end
end end
@@ -4139,6 +4182,12 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- pure status moves -- pure status moves
if move.power == 0 and record and record.kind == "primary" and record.run then if move.power == 0 and record and record.kind == "primary" and record.run then
if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer
and self.kind ~= "link" and self.rng(0, 255) < 64 then
self:cancelMoveAnim()
self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
return
end
-- accuracy-checked status effects run MoveHitTest, which has no -- accuracy-checked status effects run MoveHitTest, which has no
-- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- 100%-accuracy early-out (even Thunder Wave misses on the 255
-- roll) and misses outright against a mid-Fly/Dig target; the -- roll) and misses outright against a mid-Fly/Dig target; the
@@ -4417,7 +4466,11 @@ function BattleState:awardExp()
-- new current HP (house convention: potions drain the bar too, see -- new current HP (house convention: potions drain the bar too, see
-- itemUsed) so the bar grows instead. Only the active player battler -- itemUsed) so the bar grows instead. Only the active player battler
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar. -- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
if mon == self.player.mon then self:drainNext() end if mon == self.player.mon then
-- engine/battle/experience.asm:236
self.player.badgeExtraBoosts = nil
self:drainNext()
end
for _, moveId in ipairs(Experience.movesLearnedAt( for _, moveId in ipairs(Experience.movesLearnedAt(
self.data.pokemon[mon.species], lv)) do self.data.pokemon[mon.species], lv)) do
self:learnMove(mon, moveId) self:learnMove(mon, moveId)
@@ -4484,8 +4537,7 @@ function BattleState:enemyMonFainted()
-- remaining HP); SET / single-mon / fainted active skip the prompt. -- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex] local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift") local style = self:battleStyle()
:lower()
local partyCount = #self:playerPartyView() local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
@@ -5053,6 +5105,57 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!") return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
end end
-- ------- battle rules a mode may own
--
-- Two decisions the OPTION screen and the cart make for the player that a
-- game mode may want to make instead: whether a faint offers a free switch,
-- and whether a catch asks for a nickname. Each is a hook around the vanilla
-- answer, so a mode can force it without touching the player's saved
-- preference and without the player being able to change it mid-match.
--
-- The vanilla links are file-locals so an empty chain allocates no closure.
local function styleFromOptions(battle)
return tostring(((battle.game.save or {}).options or {}).battleStyle or "shift")
:lower()
end
local function alwaysAsk() return true end
-- "shift" or "set" for this battle. battle.style wraps the OPTION row: a
-- mod returns "set" or "shift"; anything else reads as the vanilla answer.
function BattleState:battleStyle()
if not Runtime.wantsHook("battle.style") then return styleFromOptions(self) end
local style = Runtime.call("battle.style", styleFromOptions, self)
if style == "set" then return "set" end
if style == "shift" then return "shift" end
return styleFromOptions(self)
end
-- AskName for a catch (AddPartyMon / SendNewMonToBox). Vanilla queues the
-- yes/no prompt. catch.nickname may answer for the player: false keeps the
-- species name and shows nothing; a string is the nickname, shown nothing;
-- anything else asks as usual. Returns whether a prompt was queued.
--
-- The same verdict a script gift already takes from pokemon.before_give's
-- gift.nickname, for the other way a Pokemon joins the party.
function BattleState:offerNickname(mon, displayName)
if Runtime.wantsHook("catch.nickname") then
local verdict = Runtime.call("catch.nickname", alwaysAsk, mon,
{ battle = self, name = displayName, game = self.game })
if verdict == false then return false end
if type(verdict) == "string" then
-- the naming grid's own limit, so a mod cannot hand the party a name
-- the summary screen has no room to draw
verdict = verdict:sub(1, 10)
if #verdict > 0 then mon.nickname = verdict end
return false
end
end
self:uiNext(function() return self:askNicknameUI(mon, displayName) end)
return true
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, -- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party -- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172). -- AddPartyMon and SendNewMonToBox (#172).
@@ -5086,6 +5189,22 @@ end
-- "New POKéDEX data will be added" + the dex entry page, then -- "New POKéDEX data will be added" + the dex entry page, then
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC -- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full. -- transfer text when the party was full.
-- Where a caught mon goes when the party has no room for it (RFC 0018):
-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or
-- "mod" when the catch.party_full hook claims it -- a game mode that has
-- done away with storage hands the decision to the player instead of
-- laundering the catch through a PC it has locked. A method rather than
-- an inline read, so a mod or a compatibility shim can tell a seam engine
-- from a stock one by name.
function BattleState:partyFullDestination(mon)
if not Runtime.wantsHook("catch.party_full") then return "box" end
local claimed = Runtime.call("catch.party_full", function() return false end,
{ battle = self, mon = mon, name = self.enemy and self.enemy.name,
game = self.game })
if claimed then return "mod" end
return "box"
end
function BattleState:storeCaughtMon() function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData -- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the -- (item_effects.asm:472-501), regenerating its move list from the
@@ -5117,28 +5236,26 @@ function BattleState:storeCaughtMon()
end) end)
end end
local function askCaughtNickname() local function askCaughtNickname()
local caught = self.enemy.mon self:offerNickname(self.enemy.mon, self.enemy.name)
local enemyName = self.enemy.name
self:uiNext(function()
return self:askNicknameUI(caught, enemyName)
end)
end end
if Party.add(game.save.party, self.enemy.mon) then if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname() askCaughtNickname()
else else
destination = "box" destination = self:partyFullDestination(self.enemy.mon)
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if destination == "box" then
if boxNum then local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
askCaughtNickname() if boxNum then
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL askCaughtNickname()
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
self:sayNext(self:romText( local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
metBill and "_ItemUseBallText07" or "_ItemUseBallText08", self:sayNext(self:romText(
metBill and "%s was\ntransferred to\nBILL's PC!" metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
or "%s was\ntransferred to\nsomeone's PC!", metBill and "%s was\ntransferred to\nBILL's PC!"
self.enemy.name)) or "%s was\ntransferred to\nsomeone's PC!",
else self.enemy.name))
self:sayNext(Strings("But every BOX\nis full!")) else
self:sayNext(Strings("But every BOX\nis full!"))
end
end end
end end
Runtime.emit("pokemon.caught", { Runtime.emit("pokemon.caught", {
@@ -5878,7 +5995,7 @@ function BattleState:drawZonePass(src, sx, sy)
-- only the other two showed it. Keep this mode set in sync with picImage / -- only the other two showed it. Keep this mode set in sync with picImage /
-- PaletteFX.ensureZones / WideBattle.monoMode. -- PaletteFX.ensureZones / WideBattle.monoMode.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic" or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader) love.graphics.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0 local shaking = sx ~= 0 or sy ~= 0
@@ -5920,7 +6037,9 @@ function BattleState:animSpriteColors(s, px, py)
local P local P
-- engine/battle/animations.asm:551 (.notSGB) -- engine/battle/animations.asm:551 (.notSGB)
if PaletteFX.usesSpriteObp() then if PaletteFX.usesSpriteObp() then
P = PaletteFX.ogObj() -- engine/battle/init_battle_variables.asm:18
P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE
or PaletteFX.GBC_OBJ
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
else else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4)) P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
@@ -6286,8 +6405,35 @@ end
function BattleState:drawTextArea() function BattleState:drawTextArea()
if not self:bottomUIVisible() then return end if not self:bottomUIVisible() then return end
Font.drawBox(0, 12, 20, 6) -- Gold only: routes the command box and its labels through GbcPalette,
love.graphics.setColor(0, 0, 0, 1) -- the same fix TextBox.lua got for dialogue. moveSelect/mimicSelect below
-- aren't migrated yet since they need their own interior-patch handling
-- (see #240).
local gold = self.game and self.game.save
and (self.game.save.generation == 2 or self.game.save.version == "gold")
local Chrome = gold and Chrome2 or nil
local function box(tx, ty, tw, th)
if Chrome then
Chrome.paletteBox(tx, ty, tw, th)
else
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
end
end
local drawGlyph, finishGlyph = Font.drawCode, nil
if Chrome then
local _, dg, fg = Chrome.paletteGlyphs(Chrome.DEFAULT_BOX_PALETTE)
drawGlyph, finishGlyph = dg, fg
end
local function text(str, x, y)
local pen = x
for _, code in ipairs(Font.encode(str)) do
drawGlyph(code, pen, y)
pen = pen + Font.advanceOf(code)
end
end
box(0, 12, 20, 6)
if self.phase == "messages" if self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds -- during the move animation self.current is nil but shown still holds
@@ -6306,48 +6452,47 @@ function BattleState:drawTextArea()
for li, line in ipairs(self.shown or {}) do for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off local y = (ys[li] or 128) + off
for i = 1, #line do for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y) drawGlyph(line[i], 8 + (i - 1) * 8, y)
end end
end end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
-- (_ContText) or a typed-out page (PromptText) holds the box; both write -- (_ContText) or a typed-out page (PromptText) holds the box; both write
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317) -- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
end end
elseif self.phase == "menu" and self.demo then elseif self.phase == "menu" and self.demo then
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the -- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
-- standard menu, with the '▶' hand drawn by the scripted keystrokes -- standard menu, with the '▶' hand drawn by the scripted keystrokes
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
Font.drawBox(8, 12, 12, 6) box(8, 12, 12, 6)
love.graphics.setColor(0, 0, 0, 1) text(Strings("FIGHT", "battle"), 80, 112)
Font.draw(Strings("FIGHT", "battle"), 80, 112) drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
elseif self.phase == "menu" then elseif self.phase == "menu" then
local col = (self.menuIndex - 1) % 2 local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2) local row = math.floor((self.menuIndex - 1) / 2)
if self.safari then if self.safari then
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
-- THROW ROCK RUN" from (2,14) -- THROW ROCK RUN" from (2,14)
Font.drawBox(0, 12, 20, 6) box(0, 12, 20, 6)
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128) text(Strings("THROW ROCK"), 16, 128); text(Strings("RUN", "battle"), 112, 128)
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
-- digits, space padded -- right after the "BALLx" label at columns -- digits, space padded -- right after the "BALLx" label at columns
-- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540) -- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
Font.draw(("%2d"):format(self.safari.balls), 56, 112) text(("%2d"):format(self.safari.balls), 56, 112)
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> / -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
-- ITEM RUN" from (10,14); cursor columns 9 / 15 -- ITEM RUN" from (10,14); cursor columns 9 / 15
Font.drawBox(8, 12, 12, 6) box(8, 12, 12, 6)
Font.draw(Strings("FIGHT", "battle"), 80, 112) text(Strings("FIGHT", "battle"), 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16)
end end
elseif self.phase == "moveSelect" then elseif self.phase == "moveSelect" then
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6, -- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
@@ -6422,6 +6567,7 @@ function BattleState:drawTextArea()
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end end
if finishGlyph then finishGlyph() end
end end
function BattleState:draw() function BattleState:draw()
+30 -8
View File
@@ -41,6 +41,34 @@ local function badgeBoost(battler, stat)
return nil return nil
end end
-- engine/battle/core.asm:6454
function Damage.applyBadgeBoost(battler, stat, value)
if battler.hazeStatReset then return value end
local row = badgeBoost(battler, stat)
if not row then return value end
local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0
for _ = 1, 1 + extra do
value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8)))
end
return value
end
-- engine/battle/effects.asm:498,689
function Damage.reapplyBadgeBoosts(battler, changedStat)
if not battler or not battler.badges then return end
local extra = battler.badgeExtraBoosts
if not extra then extra = {} battler.badgeExtraBoosts = extra end
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if battler.badges[row.badge] then
if row.stat == changedStat then
extra[row.stat] = 0
else
extra[row.stat] = (extra[row.stat] or 0) + 1
end
end
end
end
-- the merged status record for a battler's persistent condition, or nil -- the merged status record for a battler's persistent condition, or nil
local function statusRecord(battler) local function statusRecord(battler)
return Status.recordFor(battler.statuses, battler.mon.status) return Status.recordFor(battler.statuses, battler.mon.status)
@@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
-- Volcano -> special -- Volcano -> special
local atkBoost = badgeBoost(attacker, atkStat) atk = Damage.applyBadgeBoost(attacker, atkStat, atk)
if atkBoost then dfn = Damage.applyBadgeBoost(defender, defStat, dfn)
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
end
local defBoost = badgeBoost(defender, defStat)
if defBoost then
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
end
-- burn halves physical attack (applied as part of the stat in Gen 1; -- burn halves physical attack (applied as part of the stat in Gen 1;
-- the status record's statPenalty names the stat it cuts). -- the status record's statPenalty names the stat it cuts).
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the -- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
+11
View File
@@ -11,6 +11,7 @@
-- is the registry view of all three -- the merged Data.move_effects a -- is the registry view of all three -- the merged Data.move_effects a
-- battle dispatches on serves these same objects. -- battle dispatches on serves these same objects.
local Damage = require("src.battle.Damage")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
@@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run, -- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
-- re-baking the burn/para penalty and ending Haze's temporary lift. -- re-baking the burn/para penalty and ending Haze's temporary lift.
who.hazeStatReset = nil who.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" and who == battle.player then
Damage.reapplyBadgeBoosts(who, stat)
end
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line -- two-stage variants scroll "greatly" onto a third line
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811) local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
@@ -132,6 +137,11 @@ end
local function statDownSide(stat) local function statDownSide(stat)
return function(battle, user, target) return function(battle, user, target)
-- engine/battle/effects.asm:552
if not user.isPlayer and battle.kind ~= "link"
and battle.rng(0, 255) < 64 then
return {}
end
if target.substituteHP then return {} end if target.substituteHP then return {} end
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256) if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest, -- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
@@ -254,6 +264,7 @@ MoveEffects.primary = {
-- Attack-halving and paralysis Speed-quartering on BOTH battlers -- Attack-halving and paralysis Speed-quartering on BOTH battlers
-- until the next stat recompute (a stage change or switch-in). -- until the next stat recompute (a stage change or switch-in).
b.hazeStatReset = true b.hazeStatReset = true
b.badgeExtraBoosts = nil
end end
-- Gen 1 also removes the enemy's major status; if that cured sleep -- Gen 1 also removes the enemy's major status; if that cured sleep
-- or freeze, the target forfeits its move this turn (haze.asm -- or freeze, the target forfeits its move this turn (haze.asm
+1 -9
View File
@@ -14,15 +14,7 @@ local function effectiveSpeed(battler)
battler.stages and battler.stages.speed or 0) battler.stages and battler.stages.speed or 0)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback -- the battler's merged badgeBoosts with the vanilla list as fallback
local badges = battler.badges spd = Damage.applyBadgeBoost(battler, "speed", spd)
if badges then
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == "speed" and badges[row.badge] then
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
break
end
end
end
-- paralysis quarters speed (the status record's statPenalty); -- paralysis quarters speed (the status record's statPenalty);
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats) -- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
-- copied the unmodified speed over the quartered battle stat, lifting -- copied the unmodified speed over the quartered battle stat, lifting
+15 -6
View File
@@ -26,14 +26,10 @@ local WideBattle = {
FIELD_BOTTOM = 104, FIELD_BOTTOM = 104,
} }
-- The forced-mono display modes re-threshold the whole finished frame
-- through the shade shader, and picImage hands them raw DMG grays for that
-- (#207). The wide layout has to know: it exposes a matching whole-surface
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
local function monoMode() local function monoMode()
local m = PaletteFX.mode local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic" return m == "og" or m == "og_inv" or m == "classic"
or PaletteFX.forcesRawGrays()
end end
local function shownHP(battler) local function shownHP(battler)
@@ -97,6 +93,19 @@ local function battleIsTopState(battle)
return not (stack and stack.top) or stack:top() == battle return not (stack and stack.top) or stack:top() == battle
end end
-- engine/menus/party_menu.asm:4
local function coveredByOpaqueState(battle)
local stack = battle.game and battle.game.stack
local states = stack and stack.states
if not states then return false end
local above = false
for i = 1, #states do
if above and states[i] and states[i].isOpaque then return true end
if states[i] == battle then above = true end
end
return false
end
local function anchorHUD(battle, x, y, w, h, anchor) local function anchorHUD(battle, x, y, w, h, anchor)
if not battle:extendedHUD() or not battleIsTopState(battle) then return end if not battle:extendedHUD() or not battleIsTopState(battle) then return end
local renderer = battle.game and battle.game.renderer local renderer = battle.game and battle.game.renderer
@@ -351,7 +360,7 @@ function WideBattle.draw(battle)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end end
-- AskName clears the field the same way the classic layout does -- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end if battle.blankForAskName or coveredByOpaqueState(battle) then return end
local fx = battle.fx local fx = battle.fx
local sx = (fx and fx.shakeX) or 0 local sx = (fx and fx.shakeX) or 0
+64 -7
View File
@@ -645,6 +645,7 @@ function Battle:smartAiState()
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since -- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
-- BattleCommand_LockOn sets the bit on the target it was aimed at. -- BattleCommand_LockOn sets the bit on the target it was aimed at.
playerLockOn = playerState.lockOn or nil, playerLockOn = playerState.lockOn or nil,
playerIdentified = playerState.identified or nil,
playerPhysicalMoves = physical, playerPhysicalMoves = physical,
enemyRage = enemyState.rage, enemyRage = enemyState.rage,
enemyRageCount = enemyState.rageCount, enemyRageCount = enemyState.rageCount,
@@ -1082,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts)
local attackerStages = self.stages[self:sideOf(attacker)] local attackerStages = self.stages[self:sideOf(attacker)]
local defenderStages = self.stages[self:sideOf(defender)] local defenderStages = self.stages[self:sideOf(defender)]
local types = self.data.type_chart and self.data.type_chart.types local types = self.data.type_chart and self.data.type_chart.types
local matchups = self.data.type_chart and self.data.type_chart.matchups local matchups = self:matchupsAgainst(defender)
local heldEffect, heldParam = self:heldEffect(attacker, "damage") local heldEffect, heldParam = self:heldEffect(attacker, "damage")
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a -- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
@@ -1202,6 +1203,8 @@ function Battle:hitOnce(attacker, defender, def, opts)
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
damage = math.max(0, (defender.hp or 0) - 1) damage = math.max(0, (defender.hp or 0) - 1)
end end
-- engine/battle_anims/anim_commands.asm:1200
if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end
return self:dealDamage(attacker, defender, damage, { return self:dealDamage(attacker, defender, damage, {
critical = critical, effectiveness = info.effectiveness, critical = critical, effectiveness = info.effectiveness,
-- Counter answers physical damage and Mirror Coat special, so what kind -- Counter answers physical damage and Mirror Coat special, so what kind
@@ -1690,6 +1693,13 @@ function Battle:useMove(attacker, defender, moveId)
text = ("Magnitude %d!"):format(number) }) text = ("Magnitude %d!"):format(number) })
end end
-- data/moves/effects.asm:1607, :1649
if def.effect == "EFFECT_RETURN" then
powerOverride = Effects.happinessPower(attacker.happiness)
elseif def.effect == "EFFECT_FRUSTRATION" then
powerOverride = Effects.happinessPower(attacker.happiness, true)
end
if not sureHit if not sureHit
and not self:accuracyRoll(def, attacker, defender) then and not self:accuracyRoll(def, attacker, defender) then
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and -- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
@@ -1744,7 +1754,7 @@ function Battle:useMove(attacker, defender, moveId)
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG. -- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups local matchups = self:matchupsAgainst(defender)
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed() self:markMissed()
self:emit({ kind = "message", self:emit({ kind = "message",
@@ -2119,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender)
text = self:monName(attacker) .. " took aim!" }) text = self:monName(attacker) .. " took aim!" })
end end
-- engine/battle/move_effects/foresight.asm
Battle.MOVE_EFFECTS.EFFECT_FORESIGHT = function(self, attacker, defender,
def, _, sureHit)
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
return fail(self)
end
local target = self:volatile(defender)
if target.vanished or target.identified then return fail(self) end
target.identified = true
self:emit({ kind = "message",
text = self:monName(attacker) .. " identified "
.. self:monName(defender) .. "!" })
end
-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next -- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next
-- move aimed at the mon carrying it, whether or not that move was the one the -- move aimed at the mon carrying it, whether or not that move was the one the
-- lock-on was meant for, and whether or not the exception at :1683-1688 then -- lock-on was meant for, and whether or not the exception at :1683-1688 then
@@ -2166,9 +2191,15 @@ function Battle:accuracyRoll(def, attacker, defender, accuracy)
end end
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender) function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
return Damage.rollHit(self:moveAccuracy(accuracy, defender), local acc = self.stages[self:sideOf(attacker)].accuracy
self.stages[self:sideOf(attacker)].accuracy, local eva = self.stages[self:sideOf(defender)].evasion
self.stages[self:sideOf(defender)].evasion, self.random) -- engine/battle/effect_commands.asm:1786
if defender and self:volatile(defender).identified
and (eva or 0) >= (acc or 0) then
acc, eva = 0, 0
end
return Damage.rollHit(self:moveAccuracy(accuracy, defender), acc, eva,
self.random)
end end
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE -- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
@@ -2354,7 +2385,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def
stages = self.stages[self:sideOf(defender)], stages = self.stages[self:sideOf(defender)],
}, },
types = self.data.type_chart and self.data.type_chart.types, types = self.data.type_chart and self.data.type_chart.types,
matchups = self.data.type_chart and self.data.type_chart.matchups, matchups = self:matchupsAgainst(defender),
random = self.random, random = self.random,
}) })
state.futureSight = Effects.FUTURE_SIGHT_TURNS state.futureSight = Effects.FUTURE_SIGHT_TURNS
@@ -3072,6 +3103,30 @@ function Battle:safeguarded(mon)
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0 return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
end end
-- engine/battle/effect_commands.asm:1305
function Battle:matchupsAgainst(defender)
local chart = self.data.type_chart
local rows = chart and chart.matchups
if not rows or not defender then return rows end
if not self:volatile(defender).identified then return rows end
local skipped = chart.foresightMatchups
if not skipped or #skipped == 0 then return rows end
if not self.identifiedMatchups then
local drop = {}
for _, row in ipairs(skipped) do
drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] = true
end
local out = {}
for _, row in ipairs(rows) do
if not drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] then
out[#out + 1] = row
end
end
self.identifiedMatchups = out
end
return self.identifiedMatchups
end
-- `source` is the battler that inflicted it, carried only so -- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does. -- battle.status_inflicted can name it the way Gen 1's does.
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup, -- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
@@ -3084,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status)
end end
local types = (self:speciesDef(defender) or {}).types or defender.types or {} local types = (self:speciesDef(defender) or {}).types or defender.types or {}
if moveType then if moveType then
local matchups = self.data.type_chart and self.data.type_chart.matchups local matchups = self:matchupsAgainst(defender)
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
end end
if status == "poison" or status == "toxic" then if status == "poison" or status == "toxic" then
@@ -3917,6 +3972,8 @@ end
-- check. Split out because playerAttack needs the same answer. -- check. Split out because playerAttack needs the same answer.
function Battle:lockedInMove(mon) function Battle:lockedInMove(mon)
local state = self:volatile(mon) local state = self:volatile(mon)
-- engine/battle/core.asm:543
if state.chargeMove then return state.chargeMove end
if state.rolloutLock then return state.rolloutLock end if state.rolloutLock then return state.rolloutLock end
if state.rampageMove and (state.rampageTurns or 0) > 0 then if state.rampageMove and (state.rampageTurns or 0) > 0 then
return state.rampageMove return state.rampageMove
+8
View File
@@ -284,6 +284,14 @@ function Effects.magnitudePower(random)
return last[2], last[3] return last[2], last[3]
end end
-- engine/battle/move_effects/return.asm:1-24, frustration.asm:1-25
function Effects.happinessPower(happiness, frustration)
local h = happiness or 0
if h < 0 then h = 0 elseif h > 255 then h = 255 end
if frustration then h = 255 - h end
return math.floor(h * 10 / 25)
end
-- ------------------------------------------------------------------- weather -- ------------------------------------------------------------------- weather
-- --
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to -- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
+5 -2
View File
@@ -58,9 +58,12 @@ Prize.AMULET_COIN = "AMULET_COIN"
-- data/text/battle.asm. Declared here and formatted at the call site so -- data/text/battle.asm. Declared here and formatted at the call site so
-- Strings.source is what registers them, the same way Decorations declares -- Strings.source is what registers them, the same way Decorations declares
-- its own five. No line markers: every battle message in this port is one -- its own five. No line markers: every battle message in this port is one
-- flowing string that Chrome.wrap breaks to the box. -- flowing string that Chrome.wrap breaks to the box, except SentSomeToMomText,
-- which keeps the cart's own `line`/`cont` breaks because it does not fit two
-- rows.
local GOT_MONEY = Strings.source("%s got %s%d for winning!") local GOT_MONEY = Strings.source("%s got %s%d for winning!")
local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!") -- data/text/battle.asm:179-185
local SENT_SOME = Strings.source("%s got %s%d\nfor winning!\vSent some to MOM!")
-- The half and all texts really are this short on the cart: they replace the -- The half and all texts really are this short on the cart: they replace the
-- money line rather than following it, which is a quirk no Gold player can -- money line rather than following it, which is a quirk no Gold player can
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F. -- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
+2
View File
@@ -23,4 +23,6 @@ return {
-- (core.asm:426-464): poison/burn/leech seed tick before the slower -- (core.asm:426-464): poison/burn/leech seed tick before the slower
-- mon acts, not in an end-of-round sweep like Gen 3+. -- mon acts, not in an end-of-round sweep like Gen 3+.
residualAfterMove = true, residualAfterMove = true,
-- engine/battle/effects.asm:498,689
badgeBoostReapplyBug = true,
} }
+1 -1
View File
@@ -27,7 +27,7 @@ local BATTLER_FIELDS = {
"chargeReady", "invulnerable", "mustRecharge", "chargeReady", "invulnerable", "mustRecharge",
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded", "thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns", "skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted", "trapMove", "trapDamage", "fainted",
"aiLayer2", "aiLayer2",
} }
+32 -7
View File
@@ -82,8 +82,9 @@ local function copy(value)
return out return out
end end
function Data:applyVersionedFieldData() function Data:applyVersionedFieldData(version)
if require("src.core.GameVersion").isYellow() then version = version or require("src.core.GameVersion").get()
if version == "yellow" then
self.field.trades = copy(YELLOW_TRADES) self.field.trades = copy(YELLOW_TRADES)
-- The old man's catch demo is a RATTATA in Yellow -- The old man's catch demo is a RATTATA in Yellow
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript -- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
@@ -106,7 +107,8 @@ end
-- Fills only what the cache is missing, so an importer that learns to -- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine. -- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults() function Data:seedDefaults(version)
version = version or require("src.core.GameVersion").get()
local constants = self.constants or {} local constants = self.constants or {}
self.constants = constants self.constants = constants
self.field = self.field or {} self.field = self.field or {}
@@ -131,7 +133,7 @@ function Data:seedDefaults()
if constants.dexDigits == nil then if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize)) constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end end
self:applyVersionedFieldData() Data.applyVersionedFieldData(self, version)
local boot = self.field.boot local boot = self.field.boot
if boot == nil then if boot == nil then
boot = {} boot = {}
@@ -144,7 +146,7 @@ function Data:seedDefaults()
-- only the un-overridden default flips, so a total conversion that set -- only the un-overridden default flips, so a total conversion that set
-- field.boot.screens.splash keeps its choice on any version. -- field.boot.screens.splash keeps its choice on any version.
if boot.screens.splash == BOOT_DEFAULTS.screens.splash if boot.screens.splash == BOOT_DEFAULTS.screens.splash
and require("src.core.GameVersion").isYellow() then and version == "yellow" then
boot.screens.splash = "YellowIntro" boot.screens.splash = "YellowIntro"
end end
-- the naming screen presets the importer already extracts but nothing -- the naming screen presets the importer already extracts but nothing
@@ -163,11 +165,15 @@ function Data:seedDefaults()
-- extractor never writes headers for them. Seed the EVENT_BEAT_* / -- extractor never writes headers for them. Seed the EVENT_BEAT_* /
-- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm). -- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders() Data.seedCinnabarGymTrainerHeaders(self)
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor -- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- writes no header for him -- seed one so he engages on sight and has -- writes no header for him -- seed one so he engages on sight and has
-- his defeat / re-talk lines (same idea as the Cinnabar seed above). -- his defeat / re-talk lines (same idea as the Cinnabar seed above).
self:seedFightingDojoKarateMaster() Data.seedFightingDojoKarateMaster(self)
-- #1743: Mt. Moon B2F Super Nerd is text_asm (no def_trainers), so Yellow's
-- extractor never writes his header -- seed one so engageTrainer finds
-- battle/won/after text instead of the "I like shorts!" fallback.
Data.seedMtMoonB2FSuperNerd(self)
-- #189: 1F cabin door order vs rooms map (survey zoom) -- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps) require("src.world.SsAnneLayout").apply(self.maps)
end end
@@ -199,6 +205,25 @@ function Data:seedFightingDojoKarateMaster()
} }
end end
-- Mt. Moon B2F Super Nerd (object index 1) is text_asm with no def_trainers
-- row, so Yellow never gets a trainerHeaders.MtMoonB2F[1] entry (Red/Blue
-- pin one in make_rom_manifest.py). Without it, engageTrainer falls through
-- to the hard-coded "I like shorts!" fallback (#1743). trainerDefeated still
-- tracks him via defeatedTrainers[npc.id]; the fabricated event name matches
-- the shipped Red/Blue manifest pin for consistency with fossil drivers.
function Data:seedMtMoonB2FSuperNerd()
local headers = self.trainer_headers
if not headers then return end
headers.MtMoonB2F = headers.MtMoonB2F or {}
if headers.MtMoonB2F[1] then return end
headers.MtMoonB2F[1] = {
battle = "_MtMoonB2FSuperNerdTheyreBothMineText",
won = "_MtMoonB2FSuperNerdOkIllShareText",
after = "_MtMoonB2FSuperNerdTheresAPokemonLabText",
event = "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
}
end
function Data:seedCinnabarGymTrainerHeaders() function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end if not headers or headers.CinnabarGym then return end
+29
View File
@@ -0,0 +1,29 @@
-- Canonical pure shaping shared by the active boot and inactive dataset views.
-- The caller supplies both the data table and selected version; no global
-- GameVersion, cache prefix, mount, or active Data table is changed.
local GameVersion = require("src.core.GameVersion")
local DatasetHydration = {}
function DatasetHydration.applyGen2(data, moduleLoader)
local chart = data.type_chart or {}
chart.matchups = chart.matchups or {}
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
data.type_chart = chart
data.gen2HeldItems =
(moduleLoader or require)("src.core.gen2.ItemEffects").heldItemsFrom(data.items)
return data
end
function DatasetHydration.apply(data, version, moduleLoader)
if GameVersion.generation(version) == 2 then
return DatasetHydration.applyGen2(data, moduleLoader)
end
require("src.core.Data").seedDefaults(data, version)
return data
end
return DatasetHydration
+19 -1
View File
@@ -6,6 +6,9 @@ local FixedStep = {}
FixedStep.STEP = 1 / 60 FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
local SMOOTH_FRAMES = 4
local SMOOTH_MAX = 1 / 60 * 2.5
local STEP_EPS = 1 / 60 * 0.02
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has -- Phase the accumulator is re-seeded with once an absorbed hitch frame has
-- been paid for. Half a step is the balanced point: a frame has to come in -- been paid for. Half a step is the balanced point: a frame has to come in
@@ -23,6 +26,7 @@ function FixedStep:init(callback)
self.accum = 0 self.accum = 0
self.callback = callback self.callback = callback
self.suppressCatchup = false self.suppressCatchup = false
self.dtHistory, self.dtSum = nil, 0
end end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 -- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -39,12 +43,26 @@ function FixedStep:update(dt)
-- the burst it would otherwise release doesn't play out as a slide. -- the burst it would otherwise release doesn't play out as a slide.
if self.suppressCatchup then if self.suppressCatchup then
self.suppressCatchup = false self.suppressCatchup = false
self.dtHistory, self.dtSum = nil, 0
self.accum = self.STEP * RESEED_PHASE self.accum = self.STEP * RESEED_PHASE
self.callback(self.STEP) self.callback(self.STEP)
return return
end end
if dt > 0 and dt <= SMOOTH_MAX then
local hist = self.dtHistory
if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end
hist[#hist + 1] = dt
self.dtSum = self.dtSum + dt
if #hist > SMOOTH_FRAMES then
self.dtSum = self.dtSum - hist[1]
table.remove(hist, 1)
end
dt = self.dtSum / #hist
else
self.dtHistory, self.dtSum = nil, 0
end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do while self.accum >= self.STEP - STEP_EPS do
self.accum = self.accum - self.STEP self.accum = self.accum - self.STEP
self.callback(self.STEP) self.callback(self.STEP)
end end
+16 -12
View File
@@ -1258,6 +1258,7 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts) require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts) require("src.render.Tilt").applyOptions(opts)
require("src.render.Letterbox").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it -- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive) -- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts) require("src.render.Pipelines").applyOptions(opts)
@@ -1324,9 +1325,7 @@ function Game:restoreSave(loaded, recovered, opts)
self:adoptSave(loaded) self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table -- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options) self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's (after -- saves from before OT/ID stamping: backfill with the player's
-- the scrub, so every mon the stamp loop sees is known)
SaveData.repairTradedOtIds(loaded)
local stamp = require("src.battle.BattleState").stampOT local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do for _, box in ipairs(loaded.boxes or {}) do
@@ -1389,8 +1388,13 @@ end
-- Drop every session-owned field so the next Game:load() starts clean when -- Drop every session-owned field so the next Game:load() starts clean when
-- the process returns to the launcher in-place (Android / intent_game). -- the process returns to the launcher in-place (Android / intent_game).
-- main.lua must not guess field names: new systems (Game.network, …) are --
-- cleared automatically because only functions (methods) are kept. -- Gen1 Game is a MODULE SINGLETON (methods live on this table). Never fan
-- out arbitrary field:release() here: session fields can hold shared modules
-- (Fetch, SyncClient, …) whose :release is a job-handle API, not instance
-- teardown -- calling them as value:release() corrupts process state and has
-- been observed to leave Game.load nil after EXIT GAME on Android.
-- Explicit GPU owners are released below; everything else is just dropped.
function Game:reset() function Game:reset()
if self.stack and self.stack.clear then if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end) pcall(function() self.stack:clear() end)
@@ -1403,23 +1407,23 @@ function Game:reset()
if canvas and canvas.release then pcall(canvas.release, canvas) end if canvas and canvas.release then pcall(canvas.release, canvas) end
end end
end end
if self.renderer and self.renderer.releaseCanvases then if self.renderer then
pcall(function() self.renderer:releaseCanvases() end) local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) end
end end
-- Keep methods; clear every other field (including session scalars like
-- speedOverride). Re-seed module constants afterward.
local skinFast = self.SKIN_FAST_FORWARD
local keys = {} local keys = {}
for key, value in pairs(self) do for key, value in pairs(self) do
if type(value) ~= "function" then if type(value) ~= "function" then
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key keys[#keys + 1] = key
end end
end end
for _, key in ipairs(keys) do for _, key in ipairs(keys) do
self[key] = nil self[key] = nil
end end
self.SKIN_FAST_FORWARD = skinFast or 4
end end
return Game return Game
+11 -7
View File
@@ -350,6 +350,10 @@ function Game2:showTitle()
onContinue = function() onContinue = function()
self:showMainMenu() self:showMainMenu()
end, end,
-- engine/menus/intro_menu.asm:848-889
onTimeout = function()
self:showCopyright()
end,
}) })
end end
@@ -1783,6 +1787,7 @@ function Game2:hotkey(key)
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
GbcPalette.setMode(options.color or "gbc") GbcPalette.setMode(options.color or "gbc")
options.color = GbcPalette.cycle(1) options.color = GbcPalette.cycle(1)
options.palette = ""
persist() persist()
return true return true
elseif key == "3" then elseif key == "3" then
@@ -2036,6 +2041,7 @@ function Game2:applyOptions()
Zoom.allowSurvey = caps.survey Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options) require("src.render.Tilt").applyOptions(options)
require("src.render.Letterbox").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options) require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
Font.setFrame(options.frame or 1) Font.setFrame(options.frame or 1)
@@ -2204,6 +2210,8 @@ end
-- In-process return-to-launcher (Android / intent_game): drop session fields -- In-process return-to-launcher (Android / intent_game): drop session fields
-- so a later Game2.new() + load is not sharing a live stack or mod loader. -- so a later Game2.new() + load is not sharing a live stack or mod loader.
-- Methods live on the class table; pairs(self) only sees instance state. -- Methods live on the class table; pairs(self) only sees instance state.
-- Same rule as Gen1: only release known GPU owners -- never fan out
-- arbitrary field:release() (shared modules use :release as a handle API).
function Game2:reset() function Game2:reset()
if self.stack and self.stack.clear then if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end) pcall(function() self.stack:clear() end)
@@ -2216,17 +2224,13 @@ function Game2:reset()
if canvas and canvas.release then pcall(canvas.release, canvas) end if canvas and canvas.release then pcall(canvas.release, canvas) end
end end
end end
if self.renderer and self.renderer.releaseCanvases then if self.renderer then
pcall(function() self.renderer:releaseCanvases() end) local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) end
end end
local keys = {} local keys = {}
for key, value in pairs(self) do for key, value in pairs(self) do
if type(value) ~= "function" then if type(value) ~= "function" then
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
if type(value) == "table" and value.release then
pcall(value.release, value)
end
end
keys[#keys + 1] = key keys[#keys + 1] = key
end end
end end
+18 -1
View File
@@ -57,7 +57,10 @@ function GamepadMap._setForceNXForTests(v)
end end
local function nxActive() local function nxActive()
if GamepadMap._forceNXForTests then return true end if GamepadMap._forceNXForTests == true then return true end
if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then
return false
end
if love and love._os == "NX" then return true end if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end if love and love.system and love.system.getOS() == "NX" then return true end
return false return false
@@ -78,6 +81,20 @@ function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button] return GamepadMap.gamepadBindings()[button]
end end
function GamepadMap.mapLauncherButton(button)
if nxActive() then
if button == "a" then return "b"
elseif button == "b" then return "a"
elseif button == "x" then return "y"
elseif button == "y" then return "x"
elseif button == "back" then return "select"
end
elseif button == "back" then
return "select"
end
return button
end
-- Select+face display chords (docs / Nintendo UX): -- Select+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT), -- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7". -- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
+144 -25
View File
@@ -4,18 +4,79 @@ local HostShell = {}
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we -- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
-- spawn tries to link against the libraries we're shipping instead of the -- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find -- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir
-- their proper libraries. Only needed when running in an AppImage. -- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a -- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across
-- 64-bit child, so ld.so prints an error into that child's output. -- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load
-- into a 64-bit child.
function HostShell.envPrefix() function HostShell.envPrefix()
return HostShell.curlEnvPrefix("host")
end
-- kind "bundled" = AppDir / Flatpak curl (keep LD_LIBRARY_PATH, scrub preload).
-- kind "host" = system curl (scrub both).
function HostShell.curlEnvPrefix(kind)
local unset = "" local unset = ""
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end if kind ~= "bundled" and os.getenv("APPIMAGE") then
unset = unset .. "-u LD_LIBRARY_PATH "
end
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
if unset == "" then return "" end if unset == "" then return "" end
return "env " .. unset return "env " .. unset
end end
local curlResolved = nil -- { path=, kind= } once per Lua state
local function pathIsExecutable(path)
if type(path) ~= "string" or path == "" then return false end
local f = io.open(path, "rb")
if not f then return false end
f:close()
-- Best-effort: existence is enough; exec bit is checked by the spawn.
return true
end
-- Prefer Flatpak /app/bin/curl, then $APPDIR/{usr/,}bin/curl, else host "curl".
function HostShell.resolveCurl()
if curlResolved ~= nil then return curlResolved.path, curlResolved.kind end
local candidates = {}
if os.getenv("FLATPAK_ID") then
candidates[#candidates + 1] = { "/app/bin/curl", "bundled" }
end
local appdir = os.getenv("APPDIR")
if type(appdir) == "string" and appdir ~= "" then
candidates[#candidates + 1] = { appdir .. "/usr/bin/curl", "bundled" }
candidates[#candidates + 1] = { appdir .. "/bin/curl", "bundled" }
end
for _, c in ipairs(candidates) do
if pathIsExecutable(c[1]) then
curlResolved = { path = c[1], kind = c[2] }
return curlResolved.path, curlResolved.kind
end
end
curlResolved = { path = "curl", kind = "host" }
return curlResolved.path, curlResolved.kind
end
-- Quoted curl argv0 for a shell command, plus the matching env prefix.
function HostShell.curlInvocation()
local path, kind = HostShell.resolveCurl()
return HostShell.curlEnvPrefix(kind) .. HostShell.quote(path), kind
end
-- Diagnostics for support / About screens.
function HostShell.curlDiagnostics()
local path, kind = HostShell.resolveCurl()
return {
path = path,
kind = kind,
appimage = os.getenv("APPIMAGE") ~= nil,
appdir = os.getenv("APPDIR"),
flatpakId = os.getenv("FLATPAK_ID"),
haveCurl = HostShell.haveCurl(),
}
end
-- Windows: every host tool we shell out to (curl for the update and mod-index -- Windows: every host tool we shell out to (curl for the update and mod-index
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is -- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
-- spawned through io.popen / os.execute, which run it under cmd.exe. A -- spawned through io.popen / os.execute, which run it under cmd.exe. A
@@ -126,12 +187,19 @@ local function withPopenLock(fn)
if not okAtomic then fn() end if not okAtomic then fn() end
end end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed.
function HostShell.popen(command, mode) -- opts.envPrefix overrides the default HostShell.envPrefix() (pass "" to skip,
-- or curlEnvPrefix(kind) when spawning a resolved curl binary).
function HostShell.popen(command, mode, opts)
HostShell.releasePointerGrab() HostShell.releasePointerGrab()
local prefix = HostShell.envPrefix()
if type(opts) == "table" and opts.envPrefix ~= nil then
prefix = opts.envPrefix
end
local pipe local pipe
local line = HostShell.shellCommand(prefix .. command)
withPopenLock(function() withPopenLock(function()
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") local ok, p = pcall(io.popen, line, mode or "r")
pipe = (ok and p) or nil pipe = (ok and p) or nil
end) end)
return pipe return pipe
@@ -158,7 +226,15 @@ end
-- and the app dies. There we relaunch through the GameActivity.restartApp -- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent -- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run. -- and kills the process so no native state can leak into the fresh run.
-- On every other platform the in-process restart works, so keep it. -- iOS is the same class of problem with a sharper edge: love.cpp under
-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic
-- exit) and comments that leftover threads make that restart unreliable --
-- which our ChipAudio / Fetch / Check workers are. There is no
-- restartApp bridge on iOS, so callers that want "back to launcher" must
-- use main.lua's in-process returnToLauncher (love.quit aborts the quit);
-- HostShell.restart itself refuses quit("restart") and falls back to a
-- bare quit() so a mod that still calls restart does not pick the worst
-- path on purpose.
function HostShell.restart() function HostShell.restart()
if not (love and love.event and love.event.quit) then return end if not (love and love.event and love.event.quit) then return end
@@ -174,9 +250,23 @@ function HostShell.restart()
love.event.quit() love.event.quit()
return return
end end
if osName == "iOS" then
-- No process-kill bridge. A bare quit still becomes DONE_RESTART in
-- love.cpp, but quit("restart") is the path that also runs our
-- endProcess worker joins first and then re-enters runlove -- the
-- combination that crashes EXIT GAME. Prefer the softer quit.
love.event.quit()
return
end
local appimage = os.getenv("APPIMAGE") local appimage = os.getenv("APPIMAGE")
if not appimage then if not appimage then
-- Flatpak and plain desktop: love.event.quit("restart") uses
-- execv(/proc/self/exe). Inside bwrap that path is valid, but PhysFS
-- locks on a mounted .love payload can survive the exec boundary and
-- hang the next Boot.mount. Callers that just wrote updates/*.love
-- should already have closed archive handles; Boot.run still owns
-- crash-guard rollback if a mid-handoff restart dies.
love.event.quit("restart") love.event.quit("restart")
return return
end end
@@ -274,6 +364,18 @@ local function fetchError(url, status, noise)
return ("fetch failed for %s: %s"):format(url, why) return ("fetch failed for %s: %s"):format(url, why)
end end
function HostShell.isWindows()
return (love and love.system and love.system.getOS
and love.system.getOS() == "Windows") or false
end
function HostShell.shellCommand(command)
command = tostring(command)
if not HostShell.isWindows() then return command end
if command:sub(1, 1) ~= '"' then return command end
return '"' .. command .. '"'
end
-- Shell quoting for one curl argument; cmd.exe has no single-quote form. -- Shell quoting for one curl argument; cmd.exe has no single-quote form.
function HostShell.quote(s) function HostShell.quote(s)
s = tostring(s) s = tostring(s)
@@ -325,7 +427,10 @@ local curlAvailable = nil
function HostShell.haveCurl() function HostShell.haveCurl()
if curlAvailable ~= nil then return curlAvailable end if curlAvailable ~= nil then return curlAvailable end
local pipe = HostShell.popen("curl --version") local path, kind = HostShell.resolveCurl()
local pipe = HostShell.popen(
HostShell.quote(path) .. " --version", "r",
{ envPrefix = HostShell.curlEnvPrefix(kind) })
if not pipe then curlAvailable = false return false end if not pipe then curlAvailable = false return false end
local readOk, out = pcall(function() return pipe:read("*a") end) local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe) HostShell.pclose(pipe)
@@ -333,6 +438,12 @@ function HostShell.haveCurl()
return curlAvailable return curlAvailable
end end
local function curlCmd(flags)
local path, kind = HostShell.resolveCurl()
return HostShell.quote(path) .. " " .. flags,
{ envPrefix = HostShell.curlEnvPrefix(kind) }
end
-- An older mobile build reports nil here and falls back to the "no transport" -- An older mobile build reports nil here and falls back to the "no transport"
-- error the callers already show. -- error the callers already show.
local function haveBridge() local function haveBridge()
@@ -393,9 +504,11 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp" userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then if HostShell.haveCurl() then
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 15 --max-time %d ") ("-fsSL --proto =http,https --proto-redir =http,https "
:format(tonumber(maxTime) or 300) .. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
@@ -407,7 +520,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then return nil, "could not start download" end if not pipe then return nil, "could not start download" end
local readOk, out = pcall(function() return pipe:read("*a") end) local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe) HostShell.pclose(pipe)
@@ -450,16 +563,18 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole -- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which -- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index. -- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 10 --max-time %d ") ("-sSL --proto =http,https --proto-redir =http,https "
:format(tonumber(maxTime) or 40) .. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end end
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then return nil, "could not run curl" end if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end) local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe) HostShell.pclose(pipe)
@@ -546,9 +661,11 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
-- newlines. The body is staged above because io.popen cannot be opened -- newlines. The body is staged above because io.popen cannot be opened
-- for both writing and reading. No -f, matching httpGet: the response -- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis. -- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 10 --max-time %d ") ("-sSL --proto =http,https --proto-redir =http,https "
:format(tonumber(maxTime) or 40) .. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-X POST " .. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then if contentType then
@@ -558,7 +675,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then if not pipe then
pcall(os.remove, bodyPath) pcall(os.remove, bodyPath)
return nil, "could not run curl" return nil, "could not run curl"
@@ -735,8 +852,10 @@ function HostShell.httpRequest(url, opts)
pcall(os.remove, headerPath) pcall(os.remove, headerPath)
end end
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 10 --max-time %d "):format(maxTime) ("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
local cmd = head
.. "-X " .. HostShell.quote(method) .. " " .. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " " .. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then if body then
@@ -745,7 +864,7 @@ function HostShell.httpRequest(url, opts)
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then if not pipe then
cleanup() cleanup()
return nil, "could not run curl" return nil, "could not run curl"
+5 -1
View File
@@ -227,7 +227,11 @@ local function metadata(options, context)
end end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion() local version = appVersion()
add("App", version ~= "" and Version.title() or "gen1recomp") -- Bug reports always want the stamped engine when we have one; window
-- chrome hides it on release builds (Version.title). Unstamped
-- working-tree builds stay as plain "gen1recomp" so the placeholder
-- never lands in a filed issue.
add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp")
add("LÖVE", loveVersion()) add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end if safeMode then add("Safe mode", "on") end
return { return {
+44 -4
View File
@@ -173,13 +173,40 @@ function SaveData.gameFolders()
end end
-- The game folder carrying portable.txt, or false. First candidate holding -- The game folder carrying portable.txt, or false. First candidate holding
-- the marker wins. -- the marker *and* a writable probe wins. Flatpak installs always use the
-- sandbox XDG save dir (portable USB mode does not apply). A read-only
-- parent (system /opt, immutable OS, etc.) falls back to LOVE's save
-- directory without throwing -- probe I/O is fully guarded.
local function dirIsWritable(dir)
if type(dir) ~= "string" or dir == "" then return false end
local ok, writable = pcall(function()
local name = string.format(".write_probe_%d_%d.tmp",
os.time() % 100000000, math.random(0, 999999))
local path = dir .. SEP .. name
local f, err = io.open(path, "wb")
if not f then return false end
local wrote = f:write("ok")
f:close()
pcall(os.remove, path)
return wrote ~= nil
end)
return ok and writable == true
end
local function detectPortable() local function detectPortable()
if portableChecked then return portableBase end if portableChecked then return portableBase end
portableChecked = true portableChecked = true
portableBase = false portableBase = false
-- Flatpak: never honor portable.txt (sandbox home is the persistence root).
if type(os.getenv) == "function" and os.getenv("FLATPAK_ID") then
return portableBase
end
for _, base in ipairs(SaveData.gameFolders()) do for _, base in ipairs(SaveData.gameFolders()) do
if pathExists(base .. SEP .. PORTABLE_MARKER) then local markerOk = false
pcall(function()
markerOk = pathExists(base .. SEP .. PORTABLE_MARKER)
end)
if markerOk and dirIsWritable(base) then
portableBase = base portableBase = base
break break
end end
@@ -187,6 +214,13 @@ local function detectPortable()
return portableBase return portableBase
end end
-- Soft-reset cache so tests can re-run detect after env changes.
function SaveData._resetPortableCacheForTests()
portableChecked = false
portableBase = false
portableFsCache = nil
end
function SaveData.isPortable() function SaveData.isPortable()
return detectPortable() ~= false return detectPortable() ~= false
end end
@@ -279,6 +313,7 @@ function SaveData.defaultOptions()
zoom = 0, zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black -- OVERWORLD beyond-edge fill: trees | water | black
voidFill = "trees", voidFill = "trees",
uiLetterbox = "auto",
-- windowed | borderless (desktop fullscreen); ignored on mobile -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
@@ -1983,6 +2018,11 @@ SaveData.addCoreMigration(3, function(save)
end end
end) end)
-- #1461 repair, one-shot on pre-format-5 saves
SaveData.addCoreMigration(4, function(save)
SaveData.repairTradedOtIds(save)
end)
-- ------- write -- ------- write
-- Game progress only; options are written separately via saveOptions. -- Game progress only; options are written separately via saveOptions.
@@ -2394,8 +2434,8 @@ function SaveData.applyPostGameHome(save, boot)
end end
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved -- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
-- it, which reads back as home-caught. A caught mon can never carry -- it, which reads back as home-caught. Run only as the pre-format-5
-- traded=true, so clearing that pair is safe on every load. #1461 -- migration: a same-id trade partner makes that pair legitimate. #1461
function SaveData.repairTradedOtIds(save) function SaveData.repairTradedOtIds(save)
local playerId = save and save.player and save.player.id local playerId = save and save.player and save.player.id
if playerId == nil then return 0 end if playerId == nil then return 0 end
+70 -15
View File
@@ -70,9 +70,27 @@ local function fail(state, why)
error(("parse error at byte %d: %s"):format(state.pos, why), 0) error(("parse error at byte %d: %s"):format(state.pos, why), 0)
end end
local function limit(state, name)
return state.limits and state.limits[name]
end
local function bumpNode(state)
state.nodes = state.nodes + 1
local maximum = limit(state, "maxNodes")
if maximum and state.nodes > maximum then fail(state, "too many values") end
end
local function skip(state) local function skip(state)
local _, last = state.src:find("^[ \t\r\n]*", state.pos) while true do
state.pos = last + 1 local _, last = state.src:find("^[ \t\r\n]*", state.pos)
state.pos = last + 1
if not (state.limits and state.limits.allowComments
and state.src:sub(state.pos, state.pos + 1) == "--") then
return
end
local newline = state.src:find("\n", state.pos + 2, true)
state.pos = newline and newline + 1 or (#state.src + 1)
end
end end
local function peek(state) local function peek(state)
@@ -90,7 +108,10 @@ local function readString(state)
fail(state, "unterminated string") fail(state, "unterminated string")
elseif c == '"' then elseif c == '"' then
state.pos = i + 1 state.pos = i + 1
return table.concat(out) local value = table.concat(out)
local maximum = limit(state, "maxStringBytes")
if maximum and #value > maximum then fail(state, "string too long") end
return value
elseif c == "\\" then elseif c == "\\" then
local nxt = src:sub(i + 1, i + 1) local nxt = src:sub(i + 1, i + 1)
if nxt:match("%d") then if nxt:match("%d") then
@@ -136,10 +157,14 @@ end
local readValue local readValue
local function readTable(state) local function readTable(state)
bumpNode(state)
state.depth = state.depth + 1 state.depth = state.depth + 1
if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end if state.depth > (limit(state, "maxDepth") or MAX_DEPTH) then
fail(state, "table nesting too deep")
end
state.pos = state.pos + 1 state.pos = state.pos + 1
local out = {} local out = {}
local entries, nextArray = 0, 1
skip(state) skip(state)
if peek(state) == "}" then if peek(state) == "}" then
state.pos = state.pos + 1 state.pos = state.pos + 1
@@ -148,7 +173,7 @@ local function readTable(state)
end end
while true do while true do
skip(state) skip(state)
local key local key, value
local c = peek(state) local c = peek(state)
if c == "[" then if c == "[" then
state.pos = state.pos + 1 state.pos = state.pos + 1
@@ -156,15 +181,38 @@ local function readTable(state)
skip(state) skip(state)
if peek(state) ~= "]" then fail(state, "expected ]") end if peek(state) ~= "]" then fail(state, "expected ]") end
state.pos = state.pos + 1 state.pos = state.pos + 1
skip(state)
if peek(state) ~= "=" then fail(state, "expected =") end
state.pos = state.pos + 1
value = readValue(state)
elseif c:match("[%a_]") then elseif c:match("[%a_]") then
key = readIdent(state) local start = state.pos
local ident = readIdent(state)
skip(state)
if peek(state) == "=" then
key = ident
state.pos = state.pos + 1
value = readValue(state)
elseif state.limits and state.limits.allowArray then
state.pos = start
key = nextArray
value = readValue(state)
else
fail(state, "expected =")
end
elseif state.limits and state.limits.allowArray then
key = nextArray
value = readValue(state)
else else
fail(state, "expected key") fail(state, "expected key")
end end
skip(state) if key == nil then fail(state, "nil table key") end
if peek(state) ~= "=" then fail(state, "expected =") end if out[key] ~= nil then fail(state, "duplicate table key") end
state.pos = state.pos + 1 entries = entries + 1
out[key] = readValue(state) local maximum = limit(state, "maxTableEntries")
if maximum and entries > maximum then fail(state, "too many table entries") end
out[key] = value
if type(key) == "number" and key == nextArray then nextArray = nextArray + 1 end
skip(state) skip(state)
local sep = peek(state) local sep = peek(state)
if sep == "," then if sep == "," then
@@ -189,25 +237,30 @@ readValue = function(state)
skip(state) skip(state)
local c = peek(state) local c = peek(state)
if c == '"' then if c == '"' then
bumpNode(state)
return readString(state) return readString(state)
elseif c == "{" then elseif c == "{" then
return readTable(state) return readTable(state)
elseif c:match("[%a_]") then elseif c:match("[%a_]") then
-- the only bare words in the grammar are the boolean literals -- the only bare words in the grammar are the boolean literals
local word = readIdent(state) local word = readIdent(state)
if word == "true" then return true end if word == "true" then bumpNode(state); return true end
if word == "false" then return false end if word == "false" then bumpNode(state); return false end
state.pos = state.pos - #word state.pos = state.pos - #word
fail(state, "unexpected name '" .. word .. "'") fail(state, "unexpected name '" .. word .. "'")
elseif c:match("[%-%d%.]") then elseif c:match("[%-%d%.]") then
bumpNode(state)
return readNumber(state) return readNumber(state)
end end
fail(state, c == "" and "unexpected end of input" or "unexpected character") fail(state, c == "" and "unexpected end of input" or "unexpected character")
end end
function SaveSerializer.decode(str) function SaveSerializer.decode(str, limits)
if type(str) ~= "string" then return nil, "save must be a string" end if type(str) ~= "string" then return nil, "save must be a string" end
local state = { src = str, pos = 1, depth = 0 } if limits and limits.maxBytes and #str > limits.maxBytes then
return nil, ("input is %d bytes (max %d)"):format(#str, limits.maxBytes)
end
local state = { src = str, pos = 1, depth = 0, nodes = 0, limits = limits }
local ok, result = pcall(function() local ok, result = pcall(function()
skip(state) skip(state)
local word = state.src:match("^[%a_][%w_]*", state.pos) local word = state.src:match("^[%a_][%w_]*", state.pos)
@@ -219,7 +272,9 @@ function SaveSerializer.decode(str)
return value return value
end) end)
if not ok then return nil, result end if not ok then return nil, result end
if type(result) ~= "table" then return nil, "save root must be a table" end if type(result) ~= "table" then
return nil, (limits and limits.rootName or "save") .. " root must be a table"
end
return result return result
end end
+8 -1
View File
@@ -76,7 +76,14 @@ local function sdlFfi()
]]) ]])
if sdlCdefOk then if sdlCdefOk then
local okLoad, lib = pcall(ffi.load, "SDL2") local okLoad, lib = pcall(ffi.load, "SDL2")
sdlLib = okLoad and lib or ffi.C lib = okLoad and lib or ffi.C
local okSyms = pcall(function()
return lib.SDL_InitSubSystem, lib.SDL_NumSensors, lib.SDL_SensorGetDeviceType,
lib.SDL_SensorOpen, lib.SDL_SensorUpdate, lib.SDL_SensorGetData,
lib.SDL_GL_GetCurrentWindow, lib.SDL_GetWindowDisplayIndex,
lib.SDL_GetDisplayOrientation
end)
sdlLib = okSyms and lib or false
end end
end end
if not sdlCdefOk or not sdlLib then return nil end if not sdlCdefOk or not sdlLib then return nil end
+17 -1
View File
@@ -30,6 +30,8 @@ function SessionLifecycle.endMountedSession(version)
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
if Runtime.reset then Runtime.reset() end if Runtime.reset then Runtime.reset() end
if Assets.installLoader then Assets.installLoader(nil) end if Assets.installLoader then Assets.installLoader(nil) end
local Loader = package.loaded["src.mods.Loader"]
if Loader and Loader.endSession then Loader.endSession() end
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
end end
@@ -64,11 +66,18 @@ end
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the -- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
-- live game instance so map/GPU holders are gone before endMountedSession. -- live game instance so map/GPU holders are gone before endMountedSession.
--
-- ChipAudio's worker stays alive across game sessions (see tier comment
-- above). shutdown() joins the thread and is process-exit only -- calling
-- it here on every Android EXIT GAME has been observed to leave the Gen1
-- Game singleton unbootable (Game.load nil) on the next Play of a version
-- already opened this process, while a debug APK with a different liblove
-- did not reproduce.
function SessionLifecycle.endGameSession(game) function SessionLifecycle.endGameSession(game)
pcall(function() require("src.core.Music").stop() end) pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end) pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown) pcall(package.loaded["src.core.ChipAudio"].stopMusic)
end end
if package.loaded["src.core.DiscordPresence"] then if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown) pcall(package.loaded["src.core.DiscordPresence"].shutdown)
@@ -87,6 +96,13 @@ function SessionLifecycle.endGameSession(game)
if game and game.reset then if game and game.reset then
pcall(function() game:reset() end) pcall(function() game:reset() end)
end end
-- Gen1 Game is the module singleton. Always drop the cached module after
-- a session that owned it so the next bootGame require rebuilds a clean
-- table. A type(game.load) check is not enough: a wrong table parked in
-- package.loaded (e.g. with __index) can still look like it has load.
if game and package.loaded["src.core.Game"] == game then
package.loaded["src.core.Game"] = nil
end
local Input = require("src.core.Input") local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
+7
View File
@@ -371,6 +371,13 @@ function Sound.waitSfxDone()
curSfx = nil curSfx = nil
end end
-- SFXChannelsOff (home/audio.asm:545)
function Sound.sfxChannelsOff()
if not curSfx then return end
pcall(curSfx.src.stop, curSfx.src)
curSfx = nil
end
local function startSfx(data, name, def) local function startSfx(data, name, def)
local src = playPath(data, name, def) local src = playPath(data, name, def)
if not src then return end if not src then return end
-4
View File
@@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
local abW = dpadW * 0.46 local abW = dpadW * 0.46
local ssW = dpadW * 0.30 local ssW = dpadW * 0.30
local margin = dpadW * 0.12 local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion.generation and GameVersion.generation() == 2 then
margin = math.max(margin, math.min(ww * 0.10, 72))
end
return { return {
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW }, dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW }, a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
+16 -4
View File
@@ -18,14 +18,26 @@ local Version = {
-- exceeds the shell it provides. -- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`) modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format saveFormat = 5, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
} }
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds) -- True for the working-tree placeholder and any stamped "-dev" pre-release.
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
function Version.isDev()
local engine = tostring(Version.engine or "")
return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil
end
-- Window / chrome title. Dev builds keep the version visible
-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux
-- window chrome and taskbars do not read "gen1recomp 0.1.73".
function Version.title(base) function Version.title(base)
return (base or "gen1recomp") base = base or "gen1recomp"
.. " v" .. Version.engine if Version.isDev() then
return base .. " v" .. Version.engine
end
return base
end end
return Version return Version
+8 -3
View File
@@ -89,10 +89,12 @@ local function handle(cmd)
return false return false
end end
local idleWait = false
while true do while true do
-- drain every pending command first, so a stop/new-play is seen promptly -- drain every pending command first, so a stop/new-play is seen promptly
local quit = false local quit = false
local cmd = cmdCh:pop() local cmd = idleWait and cmdCh:demand(0.05) or cmdCh:pop()
while cmd do while cmd do
if handle(cmd) then quit = true end if handle(cmd) then quit = true end
cmd = cmdCh:pop() cmd = cmdCh:pop()
@@ -100,6 +102,7 @@ while true do
if quit then break end if quit then break end
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
idleWait = false
local activeGen = gen local activeGen = gen
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2) local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
if not ok then if not ok then
@@ -113,8 +116,10 @@ while true do
finished = true finished = true
end end
end end
elseif engine and not finished and gen then
idleWait = false
love.timer.sleep(0.005)
else else
-- nothing to do (idle, or the look-ahead is full): yield the core idleWait = true
love.timer.sleep(0.001)
end end
end end
+6 -8
View File
@@ -164,14 +164,12 @@ local function receiveItemToPc(save, id, data)
save.pcItems = save.pcItems or {} save.pcItems = save.pcItems or {}
local pc = save.pcItems local pc = save.pcItems
local held = pc[id] or 0 local held = pc[id] or 0
if held == 0 then local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY
local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end
local stacks = 0 local used = 0
for _ in pairs(pc) do stacks = stacks + 1 end for _, count in pairs(pc) do used = used + stacksFor(count) end
if stacks >= cap then return false end -- engine/items/items.asm:156 PutItemInPocket
elseif held + 1 > MAX_STACK then if used + stacksFor(held + 1) - stacksFor(held) > cap then return false end
return false
end
pc[id] = held + 1 pc[id] = held + 1
return true return true
end end
+6 -6
View File
@@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = {
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which -- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name. -- means something different, hence the different name.
color = "gbc", color = "gbc",
palette = "",
videoMode = "windowed", videoMode = "windowed",
fpsCap = 60, fpsCap = 60,
-- BATTLE BG (#1709): white | black, the surround around the battle screen. -- BATTLE BG (#1709): white | black, the surround around the battle screen.
@@ -316,6 +317,7 @@ Save.DEFAULT_OPTIONS = {
-- VOID FILL: fade | water | trees | black. fade is each map header's own -- VOID FILL: fade | water | trees | black. fade is each map header's own
-- border block with the dissolve across a boundary (#1418). -- border block with the dissolve across a boundary (#1418).
voidFill = "fade", voidFill = "fade",
uiLetterbox = "auto",
musicVol = 7, -- 0-7, like the GB's NR50 master volume musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7 sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off musicFilter = 0, -- low-pass steps, 0 = off
@@ -343,6 +345,7 @@ Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = { local SHARED_KEYS = {
touchControls = true, haptics = true, screenPos = true, touchControls = true, haptics = true, screenPos = true,
videoMode = true,
mods = true, modsByVersion = true, modsGen2 = true, mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true, modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true, activeProfile = true,
@@ -361,12 +364,9 @@ function Save.loadOptions(fs)
end end
if type(loaded) == "table" then if type(loaded) == "table" then
for key in pairs(SHARED_KEYS) do for key in pairs(SHARED_KEYS) do
if loaded[key] ~= nil then options[key] = loaded[key] end if loaded[key] ~= nil then
end options[key] = loaded[key]
end elseif type(stored) == "table" and stored[key] ~= nil then
if type(stored) == "table" then
for key in pairs(SHARED_KEYS) do
if options[key] == nil and stored[key] ~= nil then
options[key] = stored[key] options[key] = stored[key]
end end
end end
+119 -1
View File
@@ -10,7 +10,7 @@ local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:" CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.VERSION_FORMAT = { CacheContract.VERSION_FORMAT = {
crystal = "rom-cache-v10-crystal2:", crystal = "rom-cache-v10-crystal3:",
} }
CacheContract.MARKER_PATH = "rom-cache.complete" CacheContract.MARKER_PATH = "rom-cache.complete"
@@ -34,6 +34,11 @@ CacheContract.VERSION_REQUIRED_FILES = {
"assets/generated/battle/trainers/jessie_james.png", "assets/generated/battle/trainers/jessie_james.png",
"assets/generated/battle/profoakb.png", "assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png", "assets/generated/pikachu/pikapic_1.png",
"assets/generated/minigame/surf_1a.png",
"assets/generated/minigame/surf_1b.png",
"assets/generated/minigame/surf_1c.png",
"assets/generated/minigame/title_bg.png",
"assets/generated/minigame/intro_pika_0.png",
}, },
} }
@@ -133,12 +138,70 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
local SEMANTIC_MODULES = {
[1] = {
"constants", "maps", "tilesets", "text", "text_pointers",
"trainer_headers", "font", "sprites", "pokemon", "moves", "items",
"type_chart", "trainers", "encounters", "field", "battle_anims",
},
[2] = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "trainers", "encounters", "sprites", "palettes",
"icons", "battle_anims", "constants", "landmarks",
},
}
local OPTIONAL_SEMANTIC_MODULES = {
[1] = { "audio", "palettes", "icons" },
[2] = {},
}
local function copy(values)
local out = {}
for index, value in ipairs(values or {}) do out[index] = value end
return out
end
function CacheContract.requiredFilesFor(version) function CacheContract.requiredFilesFor(version)
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version] local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end if override then return override, true end
return CacheContract.REQUIRED_FILES, false return CacheContract.REQUIRED_FILES, false
end end
-- A detached, version-specific inventory for read-only consumers. Semantic
-- consumers additionally require every generated module that backs the public
-- registry surface; optional semantic roots are discovered lazily.
function CacheContract.requiredFiles(version, semantic)
local base, isOverride = CacheContract.requiredFilesFor(version)
local files = copy(base)
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
files[#files + 1] = path
end
end
if semantic then
for _, name in ipairs(SEMANTIC_MODULES[GameVersion.generation(version)] or {}) do
files[#files + 1] = "data/generated/" .. name .. ".lua"
end
end
local seen, out = {}, {}
for _, path in ipairs(files) do
if not seen[path] then
seen[path] = true
out[#out + 1] = path
end
end
return out
end
function CacheContract.semanticModules(version)
return copy(SEMANTIC_MODULES[GameVersion.generation(version)])
end
function CacheContract.optionalSemanticModules(version)
return copy(OPTIONAL_SEMANTIC_MODULES[GameVersion.generation(version)])
end
function CacheContract.formatFor(version) function CacheContract.formatFor(version)
return CacheContract.VERSION_FORMAT[version] or CacheContract.FORMAT return CacheContract.VERSION_FORMAT[version] or CacheContract.FORMAT
end end
@@ -260,4 +323,59 @@ function CacheContract.sourceTreeHasData(version)
return true return true
end end
-- Inspect another version without mutating CacheFs.prefix, GameVersion, mounts,
-- or the active Data table. The injected filesystem addresses exact paths.
local function readAt(fs, path)
if fs.readAt then return fs.readAt(path) end
if fs.read then return fs.read(path) end
return nil
end
local function isFileAt(fs, path)
if fs.getInfo then
local info = fs.getInfo(path, "file")
return info ~= nil and (info.type == nil or info.type == "file")
end
if fs.existsAt then return fs.existsAt(path) == true end
return false
end
local function hasExactFiles(version, fs, prefix, semantic)
for _, path in ipairs(CacheContract.requiredFiles(version, semantic)) do
if not isFileAt(fs, prefix .. path) then return false, path end
end
return true
end
local function sourceReady(version, fs, semantic)
if not (fs.getInfo and fs.getRealDirectory and fs.getSource) then return nil end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local complete = hasExactFiles(version, fs, prefix, semantic)
if not complete then return nil end
local first = prefix .. CacheContract.requiredFiles(version, semantic)[1]
if fs.getRealDirectory(first) ~= fs.getSource() then return nil end
return { kind = "source", prefix = prefix }
end
function CacheContract.inspect(version, fs, opts)
opts = opts or {}
if not (GameVersion.VERSIONS[version] and fs) then
return nil, "not_imported", "unsupported cache inspection"
end
if opts.allowSource then
local source = sourceReady(version, fs, opts.semantic)
if source then return source end
end
local prefix = GameVersion.cachePrefix(version)
local marker = readAt(fs, prefix .. CacheContract.MARKER_PATH)
if not CacheContract.markerMatches(version, marker) then
return nil, "not_imported", "completion marker is missing or stale"
end
local complete, missing = hasExactFiles(version, fs, prefix, opts.semantic)
if not complete then
return nil, "not_imported", "required cache file is missing: " .. tostring(missing)
end
return { kind = "cache", prefix = prefix, marker = marker }
end
return CacheContract return CacheContract
+14 -5
View File
@@ -324,9 +324,9 @@ function CacheFs.openWrite(rel)
return file return file
end end
-- read cache-relative `rel`; returns the bytes or nil -- Read an exact version-qualified path without consulting CacheFs.prefix.
function CacheFs.read(rel) -- Readiness checks use this to inspect another version without global state.
rel = withPrefix(rel) function CacheFs.readAt(rel)
local root = CacheFs.root() local root = CacheFs.root()
if root then if root then
local f = io.open(realPath(root, rel), "rb") local f = io.open(realPath(root, rel), "rb")
@@ -341,6 +341,11 @@ function CacheFs.read(rel)
return love.filesystem.read(rel) return love.filesystem.read(rel)
end end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
return CacheFs.readAt(withPrefix(rel))
end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide -- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load -- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then -- already used: active version prefix with CacheFs.prefix cleared, then
@@ -386,8 +391,7 @@ function CacheFs.loadActive(rel)
end end
-- does cache-relative `rel` exist as a file? -- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel) function CacheFs.existsAt(rel)
rel = withPrefix(rel)
local root = CacheFs.root() local root = CacheFs.root()
if root then if root then
local f = io.open(realPath(root, rel), "rb") local f = io.open(realPath(root, rel), "rb")
@@ -398,6 +402,11 @@ function CacheFs.exists(rel)
return love.filesystem.getInfo(rel, "file") ~= nil return love.filesystem.getInfo(rel, "file") ~= nil
end end
function CacheFs.exists(rel)
return CacheFs.existsAt(withPrefix(rel))
end
-- remove a single cache-relative file -- remove a single cache-relative file
function CacheFs.remove(rel) function CacheFs.remove(rel)
rel = withPrefix(rel) rel = withPrefix(rel)
+22
View File
@@ -7,6 +7,28 @@ local SHADES = {
{ 0, 0, 0, 1 }, { 0, 0, 0, 1 },
} }
ImageWriter.SHADES = SHADES
-- Title screen rOBP0 = %11100000 ($E0): OBJ shades 1 and 2 draw as white,
-- shade 3 as black (pokeyellow engine/movie/title.asm after PlacePikachu).
-- Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
-- shade-1 glints become body yellow under the title palette.
function ImageWriter.applyTitleObp0(image)
local mid, dark = SHADES[2][1], SHADES[3][1]
local w, h = image:getWidth(), image:getHeight()
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = image:getPixel(x, y)
if a ~= 0 then
if math.abs(r - mid) < 0.02 or math.abs(r - dark) < 0.02 then
image:setPixel(x, y, 1, 1, 1, 1)
end
end
end
end
return image
end
local function assertDimensions(raw, width, height, bits) local function assertDimensions(raw, width, height, bits)
assert(width % 8 == 0 and height % 8 == 0, assert(width % 8 == 0 and height % 8 == 0,
("%dbpp dimensions must be tile-aligned: %dx%d") ("%dbpp dimensions must be tile-aligned: %dx%d")
+13 -24
View File
@@ -23,6 +23,11 @@ local SaveData = require("src.core.SaveData")
local LauncherSettings = {} local LauncherSettings = {}
local function bgLocked(opts)
return opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended"
end
local function wrapIndex(i, n) local function wrapIndex(i, n)
i = i % n i = i % n
if i < 0 then i = i + n end if i < 0 then i = i + n end
@@ -156,8 +161,6 @@ local function coreRows(opts, hooks)
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide" opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then if opts.battleLayout ~= "wide" then
opts.battleHud = "standard" opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end end
return true return true
end) end)
@@ -167,10 +170,6 @@ local function coreRows(opts, hooks)
end, end,
function() function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill" opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true return true
end) end)
add(Strings("BATTLE HUD"), add(Strings("BATTLE HUD"),
@@ -185,28 +184,17 @@ local function coreRows(opts, hooks)
return false return false
end end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended" opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true return true
end) end)
add(Strings("BATTLE BG"), add(Strings("BATTLE BG"),
function() function()
if opts.battleLayout == "wide" and opts.battleFit == "fill" if bgLocked(opts) then return Strings("AUTO (FILL HUD)") end
and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if opts.battleBg == "black" then return Strings("BLACK") end if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE") return Strings("WHITE")
end, end,
function(dir) function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill" if bgLocked(opts) then return false end
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
local order = { "white", "black", "world" } local order = { "white", "black", "world" }
local cur = 1 local cur = 1
for i, mode in ipairs(order) do for i, mode in ipairs(order) do
@@ -563,7 +551,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold" local GEN2_KEY = "gold"
local function gen2Rows(opts, hooks) local function gen2Rows(opts, hooks, shared)
local rows = {} local rows = {}
local function add(label, value, step) local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step } rows[#rows + 1] = { label = label, value = value, step = step }
@@ -617,6 +605,7 @@ local function gen2Rows(opts, hooks)
end end
opts.color = opts.color =
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1] GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
opts.palette = ""
return true return true
end) end)
end end
@@ -669,9 +658,9 @@ local function gen2Rows(opts, hooks)
local okVm, VideoMode = pcall(require, "src.core.VideoMode") local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then if okVm then
add(Strings("VIDEO MODE"), add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end, function() return VideoMode.modeLabel(shared.videoMode) end,
function(dir) function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir) shared.videoMode = VideoMode.cycle(shared.videoMode, dir)
return true return true
end) end)
end end
@@ -690,7 +679,7 @@ local function gen2Rows(opts, hooks)
add(Strings("BATTLE BG"), ladder(opts, "battleBg", add(Strings("BATTLE BG"), ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white")) { { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
addTouchRows(rows, add, opts, hooks) addTouchRows(rows, add, shared, hooks)
return rows return rows
end end
@@ -718,7 +707,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block opts[GEN2_KEY] = block
end end
sections = { sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) }, { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks, opts) },
} }
else else
sections = { sections = {
+100 -16
View File
@@ -377,12 +377,19 @@ end
-- visible while typing, and blinks a caret on the importer's pulse clock. -- visible while typing, and blinks a caret on the importer's pulse clock.
local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action) local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action)
Kit._audit("control", x, y, w, h, key) Kit._audit("control", x, y, w, h, key)
Kit.focusable(key, x, y, w, h) local isFocused = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1) Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line, if isFocused then
focused and Theme.A.focus or local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
focused and 2 or 1) Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2)
else
Theme.stroke(x, y, w, h, PAL.line,
focused and Theme.A.focus or
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
focused and 2 or 1)
end
local pad = math.floor(10 * Kit.scale) local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2 local ty = y + (h - Kit.textHeight("button")) / 2
local text = rawText or "" local text = rawText or ""
@@ -397,8 +404,35 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
Kit.textHeight("button"), PAL.ink, 1) Kit.textHeight("button"), PAL.ink, 1)
end end
end end
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then if (Kit.press(x, y, w, h) or Kit._activateId == key) then
queueAction(imp, key, action) if Kit.VirtualKeyboard then
Kit.VirtualKeyboard.open({
text = text,
targetId = key,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then
if imp._indexPrompt and key:find("index") then
imp._indexPrompt.text = newText
elseif imp._rename and key:find("rename") then
imp._rename.text = newText
elseif imp._profileRenamePrompt and key:find("profren") then
imp._profileRenamePrompt.text = newText
elseif imp._profileSavePrompt and key:find("profsave") then
imp._profileSavePrompt.text = newText
elseif imp._settingsText and key:find("settext") then
imp._settingsText.text = newText
elseif imp.tab == "find" then
imp._findQuery = newText
if imp._refreshFind then imp:_refreshFind() end
end
end
end
})
end
if action then
queueAction(imp, key, action)
end
end end
end end
@@ -984,6 +1018,17 @@ local function modScopeChipsWidth(options, gap, m)
return need return need
end end
local function profileState(imp)
if imp._profileCache == nil then
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local list, cur = LauncherMods.getProfiles(options)
imp._profileCache = { options = options, list = list, active = cur }
end
return imp._profileCache
end
local function buildModScopeRow(imp, x, y, w, m) local function buildModScopeRow(imp, x, y, w, m)
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
@@ -994,7 +1039,7 @@ local function buildModScopeRow(imp, x, y, w, m)
local options = modScopeOptions(imp) local options = modScopeOptions(imp)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local _, activeProf = LauncherMods.getProfiles() local activeProf = profileState(imp).active
local isCompact = (w < math.floor(500 * m.s)) local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default") local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText) local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
@@ -3395,13 +3440,19 @@ local function buildFooter(imp, m, y)
local bx = m.x + math.floor((m.w - topW) / 2) local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 2) local my = cy + math.floor((rowH - dh) / 2)
local chipY = cy + math.floor((rowH - chipH) / 2) local chipY = cy + math.floor((rowH - chipH) / 2)
local isFocused = Kit.focusable("bcg", bx, my, dw, dh)
local hot = Kit.hover(bx, my, dw, dh) local hot = Kit.hover(bx, my, dw, dh)
if isFocused then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
Theme.strokeRounded(bx - 3, my - 3, dw + 6, dh + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
Theme.strokeRounded(bx, my, dw, dh, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
end
love.graphics.setShader(imp.invertShader) love.graphics.setShader(imp.invertShader)
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85)
love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale) love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale)
love.graphics.setShader() love.graphics.setShader()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if Kit.press(bx, my, dw, dh) then if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end end
local cx = bx + dw local cx = bx + dw
@@ -3698,9 +3749,8 @@ local function buildSingleProfileActionsModal(imp, m)
if not pName then imp._singleProfileActions = nil return end if not pName then imp._singleProfileActions = nil return end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData") local prof = profileState(imp)
local options = SaveData.loadOptions() local options, profiles = prof.options, prof.list
local profiles, active = LauncherMods.getProfiles(options)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad) local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
@@ -3715,6 +3765,7 @@ local function buildSingleProfileActionsModal(imp, m)
action = function() action = function()
LauncherMods.duplicateProfile(pName, options) LauncherMods.duplicateProfile(pName, options)
imp._singleProfileActions = nil imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end end
}, },
{ {
@@ -3736,6 +3787,7 @@ local function buildSingleProfileActionsModal(imp, m)
imp:pressDelete("profile", pName, nil, function() imp:pressDelete("profile", pName, nil, function()
LauncherMods.deleteProfile(pName, options) LauncherMods.deleteProfile(pName, options)
imp._singleProfileActions = nil imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end) end)
end end
} }
@@ -3771,9 +3823,8 @@ end
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete) -- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
local function buildProfilesModal(imp, m) local function buildProfilesModal(imp, m)
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData") local prof = profileState(imp)
local options = SaveData.loadOptions() local options, profiles, active = prof.options, prof.list, prof.active
local profiles, active = LauncherMods.getProfiles(options)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad) local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
@@ -5593,6 +5644,8 @@ end
-- a click on the scrim lands on whatever button happens to be behind it. -- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below. -- Keep this list in sync with buildModals below.
local function modalUp(imp) local function modalUp(imp)
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
return (imp._settingsText or imp._settings or imp._rename return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes or imp._appPatchNotes
@@ -5605,6 +5658,14 @@ local function modalUp(imp)
end end
local function buildModals(imp, m) local function buildModals(imp, m)
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.draw(m)
return true
end
if Kit.FileBrowser and Kit.FileBrowser.active then
Kit.FileBrowser.draw(m)
return true
end
if imp._profileRenamePrompt then if imp._profileRenamePrompt then
buildPrompt(imp, m, { buildPrompt(imp, m, {
key = "profren", title = Strings("Rename profile"), key = "profren", title = Strings("Rename profile"),
@@ -5776,6 +5837,10 @@ end
local function drawPadCursor(imp) local function drawPadCursor(imp)
if not imp._padCursorActive then return end if not imp._padCursorActive then return end
if (Kit.FileBrowser and Kit.FileBrowser.active)
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
return
end
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
-- framebuffer when the stick advances by fractional pixels each frame. -- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y local x, y = imp._padCursor.x, imp._padCursor.y
@@ -5959,6 +6024,25 @@ function LauncherView.draw(imp)
Kit.endFrame() Kit.endFrame()
drawPadCursor(imp) drawPadCursor(imp)
if imp._cursorModeToast and imp._cursorModeToastTime then
local elapsed = love.timer.getTime() - imp._cursorModeToastTime
if elapsed < 2.2 then
local alpha = 1.0
if elapsed > 1.7 then alpha = math.max(0, (2.2 - elapsed) / 0.5) end
local msg = imp._cursorModeToast
local tw = Kit.textWidth("small", msg) + 36 * m.s
local th = 34 * m.s
local tx = (m.W - tw) / 2
local ty = 16 * m.s
Theme.fillRounded(tx, ty, tw, th, PAL.surface, 0.95 * alpha, 6)
Theme.strokeRounded(tx - 2, ty - 2, tw + 4, th + 4, PAL.railBlue, 0.35 * alpha, 2, 8)
Theme.strokeRounded(tx, ty, tw, th, PAL.lineStrong, 0.85 * alpha, 1.5, 6)
Kit.textCenterBold("small", msg, tx, ty + 8 * m.s, tw, PAL.heading)
else
imp._cursorModeToast = nil
end
end
end end
return LauncherView return LauncherView
+118 -2
View File
@@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt()
local bg = sheetTiles("TitlePikachuBGGraphics", 64) local bg = sheetTiles("TitlePikachuBGGraphics", 64)
local ob = sheetTiles("TitlePikachuOBGraphics", 12) local ob = sheetTiles("TitlePikachuOBGraphics", 12)
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true) local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
-- Title rOBP0=$E0: remap eye OAM shades 1/2 → white before baking into the
-- MEWMON BG composition (otherwise glints read as body yellow).
local eyeOb = {}
for i, tile in ipairs(obClear) do
local copy = ImageWriter.blank(8, 8, 0, 0, 0, 0)
ImageWriter.blit(copy, tile, 0, 0)
eyeOb[i] = ImageWriter.applyTitleObp0(copy)
end
local function tileFor(id) local function tileFor(id)
if id < 0x80 then return logo[id + 1] end if id < 0x80 then return logo[id + 1] end
if id < 0xF0 then return bg[id - 0x80 + 1] end if id < 0xF0 then return bg[id - 0x80 + 1] end
@@ -1623,13 +1631,13 @@ function RomExtractor:extractYellowTitleArt()
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0) local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16) ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
for _, e in ipairs(EYE_LAYOUT) do for _, e in ipairs(EYE_LAYOUT) do
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4]) blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4])
end end
overlays[suffix] = overlay overlays[suffix] = overlay
end end
-- open eyes bake into pikachu.png AFTER the blank-face crops -- open eyes bake into pikachu.png AFTER the blank-face crops
for _, e in ipairs(EYE_LAYOUT) do for _, e in ipairs(EYE_LAYOUT) do
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4]) blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4])
end end
self:save(pikachu, "title/pikachu.png") self:save(pikachu, "title/pikachu.png")
for suffix, overlay in pairs(overlays) do for suffix, overlay in pairs(overlays) do
@@ -1637,6 +1645,113 @@ function RomExtractor:extractYellowTitleArt()
end end
end end
function RomExtractor:extractSurfingPikachuTitleArt()
-- engine/minigame/surfing_pikachu.asm
-- DrawSurfingPikachuMinigameIntroBackground: compose the 160x144
-- "Pikachu's Beach" title from SurfingMinigame_* tilemaps over
-- SurfingPikachu1Graphics3 tiles (mirrors tools/build_rom_data.py).
if not self.symbols["SurfingPikachu1Graphics3"] then return end
if not self.symbols["SurfingMinigame_BeachIntroTilemap"] then return end
local beachIntro = self:symbol("SurfingMinigame_BeachIntroTilemap")
local useCtrlPad = self:symbol("SurfingMinigame_UseControlPadTilemap")
local toSurfRad = self:symbol("SurfingMinigame_ToSurfRadTilemap")
local titleMap = self:symbol("SurfingMinigame_TitleTilemap")
local beachBytes = self.rom:bytes(
beachIntro.bank, beachIntro.address, 12 * 20)
local useCtrlBytes = self.rom:bytes(
useCtrlPad.bank, useCtrlPad.address, 15)
local toSurfRadBytes = self.rom:bytes(
toSurfRad.bank, toSurfRad.address, 13)
local titleMapBytes = self.rom:bytes(
titleMap.bank, titleMap.address, 6 * 12)
local screen = {}
for _ = 1, 20 * 18 do screen[#screen + 1] = 0xff end
for i = 1, #beachBytes do
screen[6 * 20 + i] = beachBytes[i]
end
for r = 0, 5 do
for c = 0, 11 do
screen[r * 20 + (4 + c) + 1] = titleMapBytes[r * 12 + c + 1]
end
end
for r = 0, 2 do
for c = 0, 14 do
screen[(7 + r) * 20 + (3 + c) + 1] = 0xff
end
end
for i = 1, #useCtrlBytes do
screen[7 * 20 + 3 + i] = useCtrlBytes[i]
end
for i = 1, #toSurfRadBytes do
screen[9 * 20 + 4 + i] = toSurfRadBytes[i]
end
local gfx3 = self:symbol("SurfingPikachu1Graphics3")
local rawGfx3 = self.rom:bytes(gfx3.bank, gfx3.address, 144 * 16)
local tiles = {}
for tile = 0, 143 do
local one = {}
for j = 1, 16 do one[j] = rawGfx3[tile * 16 + j] end
tiles[tile + 1] = ImageWriter.decode2bpp(one, 8, 8, false)
end
local blank = ImageWriter.blank(8, 8, 1, 1, 1, 1)
local titleBg = ImageWriter.blank(160, 144, 1, 1, 1, 1)
for r = 0, 17 do
for c = 0, 19 do
local tileId = screen[r * 20 + c + 1]
local tileImg = blank
if tileId ~= 0xff then
local idx = tileId >= 0x80 and (tileId - 0x80 + 1) or (128 + tileId + 1)
if idx >= 1 and idx <= 144 then tileImg = tiles[idx] end
end
ImageWriter.blit(titleBg, tileImg, c * 8, r * 8)
end
end
self:save(titleBg, "minigame/title_bg.png")
-- Intro paddling Pikachu frames (surfing_pikachu_oam.asm .IntroPikachu).
local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c }
local INTRO_PIKA_OAM = {
{ -12, -16, 0x03, true }, { -12, -8, 0x02, true },
{ -12, 0, 0x01, true }, { -12, 8, 0x00, true },
{ -4, -16, 0x13, true }, { -4, -8, 0x12, true },
{ -4, 0, 0x11, true }, { -4, 8, 0x10, true },
{ 4, -16, 0x23, true }, { 4, -8, 0x22, true },
{ 4, 0, 0x21, true }, { 4, 8, 0x20, true },
}
local function blitIntroTile(target, tile, tx, ty, flipX)
for y = 0, 7 do
for x = 0, 7 do
local sx = flipX and (7 - x) or x
local r, g, b, a = tile:getPixel(sx, y)
local px, py = tx + x, ty + y
if a ~= 0 and px >= 0 and py >= 0
and px < target:getWidth() and py < target:getHeight() then
target:setPixel(px, py, r, g, b, a)
end
end
end
end
for frame, vramBase in ipairs(INTRO_PIKA_FRAME_BASE) do
local pose = ImageWriter.blank(32, 24, 1, 1, 1, 0)
local sheetBase = vramBase - 0x80
for _, sp in ipairs(INTRO_PIKA_OAM) do
local dy, dx, rel, flipX = sp[1], sp[2], sp[3], sp[4]
local tileImg = tiles[sheetBase + rel + 1]
if tileImg then
blitIntroTile(pose, tileImg,
16 + dx + (flipX and 8 or 0), 12 + dy, flipX)
end
end
self:save(pose, ("minigame/intro_pika_%d.png"):format(frame - 1))
end
end
function RomExtractor:raw2bpp(label, width, height, relative, options) function RomExtractor:raw2bpp(label, width, height, relative, options)
options = options or {} options = options or {}
local expected = width * height / 4 local expected = width * height / 4
@@ -1978,6 +2093,7 @@ function RomExtractor:extractField()
self:save(image, spec[5]) self:save(image, spec[5])
end end
end end
self:extractSurfingPikachuTitleArt()
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per -- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in -- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
+18 -1
View File
@@ -151,6 +151,19 @@ local TEXT_BUFFERS = {
[0xc602] = "wOTTrademonSpeciesName", [0xc602] = "wOTTrademonSpeciesName",
[0xc618] = "wOTTrademonSenderName", [0xc618] = "wOTTrademonSenderName",
} }
-- ../pokecrystal/ram/wram.asm:1925,2333
local TEXT_BUFFERS_CRYSTAL = {
[0xd050] = "wMonOrItemNameBuffer",
[0xd073] = "wStringBuffer1",
[0xd086] = "wStringBuffer2",
[0xd099] = "wStringBuffer3",
[0xd0ac] = "wStringBuffer4",
[0xd0bf] = "wStringBuffer5",
[0xc6d1] = "wPlayerTrademonSpeciesName",
[0xc6e7] = "wPlayerTrademonSenderName",
[0xc703] = "wOTTrademonSpeciesName",
[0xc719] = "wOTTrademonSenderName",
}
-- The text commands that print nothing and carry no argument -- The text commands that print nothing and carry no argument
-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL, -- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL,
-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles. -- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles.
@@ -2295,6 +2308,8 @@ function RomExtractorGen2:extractTitle()
trailBobAmplitude = silver and 3 or 2, trailBobAmplitude = silver and 3 or 2,
trailPhaseStep = silver and 7 or 3, trailPhaseStep = silver and 7 or 3,
trailPhase = silver and 0 or nil, trailPhase = silver and 0 or nil,
-- TitleScreenTimer (engine/menus/intro_menu.asm:951-966).
timeoutFrames = silver and (73 * 60 + 36) or (84 * 60 + 16),
} }
self:write("title", data) self:write("title", data)
return data return data
@@ -2805,6 +2820,8 @@ end
-- is which alongside the text lets a caller fill them in order without -- is which alongside the text lets a caller fill them in order without
-- changing a marker every screen already reads. -- changing a marker every screen already reads.
function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers) function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
local textBuffers = (self.edition == "crystal") and TEXT_BUFFERS_CRYSTAL
or TEXT_BUFFERS
local out = {} local out = {}
local i = 0 local i = 0
local hops = 0 local hops = 0
@@ -2840,7 +2857,7 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = "{STRBUF}" out[#out + 1] = "{STRBUF}"
if buffers then if buffers then
local target = self.rom:word(bank, address + i + 1) local target = self.rom:word(bank, address + i + 1)
buffers[#buffers + 1] = TEXT_BUFFERS[target] or target buffers[#buffers + 1] = textBuffers[target] or target
end end
i = i + 2 i = i + 2
elseif b == 0x4e or b == 0x4f then elseif b == 0x4e or b == 0x4f then
+430 -35
View File
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
local PICK_COMPLETE_FILENAME = "pick_complete.flag"
local finishDirectRequiredImport
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on -- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
-- builds that compiled one (Android, and iOS builds patched by -- builds that compiled one (Android, and iOS builds patched by
-- mobile/ios/patch_love_src.py). A build without it must fall back to the -- mobile/ios/patch_love_src.py). A build without it must fall back to the
@@ -1260,7 +1263,12 @@ end
-- up the background worker or reach out to the network. -- up the background worker or reach out to the network.
local function updaterAllowed() local function updaterAllowed()
if not Platform.networkValidated() then return false end if not Platform.networkValidated() then return false end
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then
return false
end
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true return true
@@ -1582,8 +1590,11 @@ function RomImporter:focus(f)
.. love.filesystem.getSaveDirectory() .. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import" and self.pickerPendingKind == "required_import"
if pickError:find("picked_required_import", 1, true) if self.pickerPendingKind == "required_import"
or pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true) or pickError:find("picked_stadium", 1, true)
or pickError:find("/baseroms/", 1, true)
or pickError:find("\\baseroms\\", 1, true)
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text } self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil self.pickerPendingKind = nil
@@ -1606,6 +1617,22 @@ function RomImporter:focus(f)
end end
return return
end end
-- Current mobile bridge: the native picker has already streamed a raw
-- dependency into mods/<id>/baseroms and published its digest/size marker.
local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file")
and love.filesystem.read(PICK_COMPLETE_FILENAME)
if completedRequired then
love.filesystem.remove(PICK_COMPLETE_FILENAME)
self.pickPending = nil
finishDirectRequiredImport(self, completedRequired)
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
return
end
local requiredName = findPendingRequiredImport(self) local requiredName = findPendingRequiredImport(self)
if requiredName then if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
@@ -2033,8 +2060,42 @@ function RomImporter:chooseMod()
end end
return return
end end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if isHandheld then
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select Mod (.zip)",
mode = "mod",
onSelect = function(pickedPath)
self:_installMod(pickedPath)
end,
})
return
end
end
local path = chooseZip() local path = chooseZip()
if path then self:_installMod(path) end if path then
self:_installMod(path)
return
end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select Mod (.zip)",
mode = "mod",
onSelect = function(pickedPath)
self:_installMod(pickedPath)
end,
})
return
end
end end
local function requiredManifest(self, modId) local function requiredManifest(self, modId)
@@ -2059,6 +2120,122 @@ local function requiredImportNotice(self, modId, importId, text)
} }
end end
-- Current Android/iOS bridges can stream a raw required import directly from
-- the system document provider into its engine-owned baseroms destination.
-- The bridge hashes/counts the same bytes and then publishes this tiny marker:
-- v1\n<relative destination>\n<md5>\n<byte count>\n
-- Older mobile builds still stage picked_required_import.bin, so this is an
-- additive completion path rather than a replacement for the legacy one.
local function directRequiredImportTarget(self, marker)
local fields = {}
for line in tostring(marker or ""):gmatch("[^\r\n]+") do
fields[#fields + 1] = line
if #fields > 4 then break end
end
local version, path, digest, count = fields[1], fields[2], fields[3], fields[4]
if version ~= "v1" or not path or not digest or not count or #digest ~= 32
or not digest:match("^%x+$") or not count:match("^%d+$") then
return nil, "The completed dependency marker was malformed."
end
digest = digest:lower()
count = tonumber(count)
if not count then return nil, "The completed dependency size was invalid." end
if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end
local RequiredImports = require("src.mods.RequiredImports")
local function matchRow(row, wantedImportId)
local manifest = row and row.manifest
if not manifest then return nil end
for _, spec in ipairs(RequiredImports.specs(manifest)) do
if (not wantedImportId or spec.id == wantedImportId)
and RequiredImports.path(manifest, spec) == path then
return manifest, spec, row.id or manifest.id
end
end
return nil
end
-- Normal resume: bind the marker to exactly the request that opened picker.
if self.pickerPendingModId and self.pickerPendingImportId then
for _, row in ipairs(self.mods or {}) do
if row.id == self.pickerPendingModId then
local manifest, spec, modId = matchRow(row, self.pickerPendingImportId)
if manifest then return path, digest, count, manifest, spec, modId end
end
end
return nil, "The completed dependency did not match the pending import request."
end
-- Android may recreate GameActivity while DocumentsUI is open. If Lua was
-- restarted too, recover by the exact engine-owned destination. The path
-- includes the mod id and only a manifest-declared import can match it.
for _, row in ipairs(self.mods or {}) do
local manifest, spec, modId = matchRow(row)
if manifest then return path, digest, count, manifest, spec, modId end
end
return nil, "The completed dependency did not match an installed mod request."
end
finishDirectRequiredImport = function(self, marker)
local path, digestOrErr, nativeSize, manifest, spec, modId =
directRequiredImportTarget(self, marker)
if not path then
self.modNotice = { ok = false, text = tostring(digestOrErr) }
return nil
end
local digest = digestOrErr
local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(path, "file")
if not info or type(info.size) ~= "number" then
requiredImportNotice(self, modId, spec.id,
"The completed dependency file was not found.")
self.modNotice = nil
return nil
end
if info.size ~= nativeSize then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id,
("Dependency copy size changed after completion (expected %d bytes, found %d).")
:format(nativeSize, info.size))
self.modNotice = nil
return nil
end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id, sizeErr)
self.modNotice = nil
return nil
end
-- No second 665 MiB / 1.46 GiB read: native calculated this digest while
-- streaming the selected document into the one final copy.
local ok, detail = RequiredImports.acceptStoredDigest(
manifest, spec.id, digest, love.filesystem)
if not ok then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id, detail)
self.modNotice = nil
return nil
end
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
function RomImporter:_importRequiredData(modId, importId, data) function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId) local manifest = requiredManifest(self, modId)
if not manifest then if not manifest then
@@ -2218,7 +2395,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingModId = modId self.pickerPendingModId = modId
self.pickerPendingImportId = importId self.pickerPendingImportId = importId
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then -- Raw imports can go straight to their final private destination on current
-- Android/iOS bridges. N64 stays on staging because the launcher still has
-- to canonicalize byte order/copier headers before storing it.
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
and spec.format ~= "n64"
and type(manifest.path) == "string" and manifest.path ~= "")
and require("src.mods.RequiredImports").path(manifest, spec) or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
directDestination) then
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingModId = nil self.pickerPendingModId = nil
self.pickerPendingImportId = nil self.pickerPendingImportId = nil
@@ -2333,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
end end
return return
end end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if isHandheld then
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select Save (.sav)",
mode = "save",
onSelect = function(pickedPath)
self:_importSave(version, pickedPath)
end,
})
return
end
end
local path = chooseSav() local path = chooseSav()
if path then self:_importSave(version, path) end if path then
self:_importSave(version, path)
return
end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select Save (.sav)",
mode = "save",
onSelect = function(pickedPath)
self:_importSave(version, pickedPath)
end,
})
return
end
end end
-- "Export save" button: write the active slot back out to a raw .sav in the save -- "Export save" button: write the active slot back out to a raw .sav in the save
@@ -2475,11 +2694,42 @@ function RomImporter:choose(version)
end end
return return
end end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if isHandheld then
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"),
mode = "rom",
onSelect = function(pickedPath)
self:startPath(pickedPath)
end,
})
return
end
end
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName) local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then if path then
self:startPath(path) self:startPath(path)
return return
end end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"),
mode = "rom",
onSelect = function(pickedPath)
self:startPath(pickedPath)
end,
})
return
end
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or -- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan -- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
-- used on Android, which works when the game is launched as an unpacked -- used on Android, which works when the game is launched as an unpacked
@@ -2549,6 +2799,8 @@ function RomImporter:_pollPickedFiles(dt)
return return
end end
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
or love.filesystem.getInfo("pick_error.flag", "file") ~= nil
or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil
if not found then if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower() local n = name:lower()
@@ -2850,6 +3102,14 @@ function RomImporter:_cycleTab(delta)
end end
function RomImporter:_updatePadCursor(dt) function RomImporter:_updatePadCursor(dt)
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if (Kit.FileBrowser and Kit.FileBrowser.active)
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
return
end
end
if self.isNX then if self.isNX then
self:_ensureNxPointerBridge() self:_ensureNxPointerBridge()
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
@@ -2892,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny)) self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
-- The cursor is clamped to the safe area above, so on a short window the
-- rows below the fold are unreachable on a stickless handheld: no mouse
-- wheel, no touchscreen, and no right stick to feed the existing wheel
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
-- nothing, it has to be actively pushed -- and this block only runs on pad
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
-- LauncherView.draw multiplies back out.
local overY = 0 local overY = 0
if ny > oy + h then overY = ny - (oy + h) if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end elseif ny < oy then overY = ny - oy end
@@ -2907,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
if overY ~= 0 and self._flex then if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end end
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords -- skip setPosition.
if not self.isNX and love.mouse.setPosition then if not self.isNX and love.mouse.setPosition then
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
end end
end end
-- Right stick scrolls whatever the pad pointer sits over, through the
-- view's wheel path, so the page and the modal scrollers all behave like a
-- mouse wheel would.
local ry = self._padAxis.righty or 0 local ry = self._padAxis.righty or 0
if math.abs(ry) > PAD_DEAD and self._flex then if math.abs(ry) > PAD_DEAD and self._flex then
self:_activatePadCursor() self:_activatePadCursor()
@@ -2926,30 +3173,168 @@ function RomImporter:_updatePadCursor(dt)
end end
function RomImporter:gamepadpressed(_, button) function RomImporter:gamepadpressed(_, button)
self:_activatePadCursor() local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B. local okKit, Kit = pcall(require, "src.ui.kit.Kit")
local action = GamepadMap.mapGamepadButton(button) if okKit then
if action == "a" then if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
-- Instant click at the virtual pointer: dispatched straight into the if Kit.VirtualKeyboard.gamepadpressed(action) then return end
-- view, since the launcher no longer hit-tests presses itself.
if self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end end
elseif button == "leftshoulder" then if Kit.FileBrowser and Kit.FileBrowser.active then
if Kit.FileBrowser.gamepadpressed(action) then return end
end
end
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
if action == "y" or button == "y" then
self._padCursorActive = not self._padCursorActive
if okKit then Kit._ringShown = not self._padCursorActive end
self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
self._cursorModeToastTime = love.timer.getTime()
return
end
-- Select button: toggle Virtual Keyboard
if button == "back" or button == "select" or action == "select" then
if okKit and Kit.VirtualKeyboard then
if Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.close(false)
return
end
if self._indexPrompt then
Kit.VirtualKeyboard.open({
text = self._indexPrompt.text or "",
title = "Add a mod index",
onDone = function(newText, confirmed)
if confirmed and self._indexPrompt then self._indexPrompt.text = newText end
end
})
return
elseif self._rename then
Kit.VirtualKeyboard.open({
text = self._rename.text or "",
title = "Name save slot",
onDone = function(newText, confirmed)
if confirmed and self._rename then self._rename.text = newText end
end
})
return
elseif self._profileRenamePrompt then
Kit.VirtualKeyboard.open({
text = self._profileRenamePrompt.text or "",
title = "Rename profile",
onDone = function(newText, confirmed)
if confirmed and self._profileRenamePrompt then self._profileRenamePrompt.text = newText end
end
})
return
elseif self._profileSavePrompt then
Kit.VirtualKeyboard.open({
text = self._profileSavePrompt.text or "",
title = "Save mod profile",
onDone = function(newText, confirmed)
if confirmed and self._profileSavePrompt then self._profileSavePrompt.text = newText end
end
})
return
elseif self._settingsText then
Kit.VirtualKeyboard.open({
text = self._settingsText.text or "",
title = (self._settingsText.row and self._settingsText.row.label) or "Edit Text",
onDone = function(newText, confirmed)
if confirmed and self._settingsText then self._settingsText.text = newText end
end
})
return
elseif self.tab == "find" then
Kit.VirtualKeyboard.open({
text = self._findQuery or "",
title = "Search Mods",
onDone = function(newText, confirmed)
if confirmed then
self._findQuery = newText
if self._refreshFind then self:_refreshFind() end
end
end
})
return
else
Kit.VirtualKeyboard.open({
text = "",
title = "Virtual Keyboard",
onDone = function() end
})
return
end
end
end
-- Shoulder buttons: cycle tabs
if button == "leftshoulder" then
self:_cycleTab(-1) self:_cycleTab(-1)
return
elseif button == "rightshoulder" then elseif button == "rightshoulder" then
self:_cycleTab(1) self:_cycleTab(1)
elseif button == "dpup" or button == "dpdown" return
or button == "dpleft" or button == "dpright" then end
self._padDir[button] = true
elseif button == "start" or button == "back" then -- Start button: Play / Choose ROM
-- Start / Select: Play if ready, else Choose ROM on the active game tab. if button == "start" then
if self.workState == "working" then return end if self.workState == "working" then return end
local version = self.tab local version = self.tab
if GameVersion.VERSIONS[version] then if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:_romAction(version) end if self.ready[version] then self:play(version) else self:_romAction(version) end
end end
return
end
if not self._padCursorActive and not self.isNX then
if okKit then Kit._ringShown = true end
if action == "a" then
if okKit and Kit.focusId then
Kit.activateFocused()
elseif self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end
return
elseif action == "b" then
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
end
elseif button == "dpup" or action == "dpup" then
if okKit then Kit.navigate("up") end
return
elseif button == "dpdown" or action == "dpdown" then
if okKit then Kit.navigate("down") end
return
elseif button == "dpleft" or action == "dpleft" then
if okKit then Kit.navigate("left") end
return
elseif button == "dpright" or action == "dpright" then
if okKit then Kit.navigate("right") end
return
end
else
self:_activatePadCursor()
if action == "a" then
if self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end
elseif action == "b" then
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
end
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = true
end
end end
end end
@@ -2963,9 +3348,9 @@ end
function RomImporter:gamepadaxis(_, axis, value) function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value self._padAxis[axis] = value
if math.abs(value) > PAD_DEAD then if self._padCursorActive and math.abs(value) > PAD_DEAD then
self:_activatePadCursor() self:_activatePadCursor()
elseif axis == "lefty" then elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
self._padStickCentered = true self._padStickCentered = true
end end
end end
@@ -3914,6 +4299,15 @@ function RomImporter:fileUrl(path)
end end
function RomImporter:keypressed(key) function RomImporter:keypressed(key)
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if Kit.FileBrowser and Kit.FileBrowser.active then
if Kit.FileBrowser.keypressed(key) then return end
end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
if Kit.VirtualKeyboard.keypressed(key) then return end
end
end
if self._profileSavePrompt then if self._profileSavePrompt then
if key == "backspace" then if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "") self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
@@ -4803,6 +5197,7 @@ function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
self._cartPlan = nil self._cartPlan = nil
self._profileCache = nil
self.findInstalled = nil self.findInstalled = nil
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player -- Once per session, ahead of the first listing: pull in any mod the player
+34 -16
View File
@@ -184,6 +184,14 @@ local function adjacentSleepingSnorlax(save, ow)
return nil return nil
end end
local function itemUseLine(data, save, name)
return romText(data, "_ItemUseText001", "%s used\n%s!", save.player.name, name)
end
-- PrintItemUseTextAndRemoveItem (item_effects.asm): used-line + SFX_HEAL_AILMENT.
-- BagMenu plays Heal_Ailment via TextBox.soundOpts when extra.useJingle is set.
local USE_JINGLE = { useJingle = true }
-- Use an item on a target party mon (target may be nil for targetless -- Use an item on a target party mon (target may be nil for targetless
-- items). data = generated data tables; battle = BattleState when used -- items). data = generated data tables; battle = BattleState when used
-- mid-battle; ow = the overworld (OverworldState), needed only to check -- mid-battle; ow = the overworld (OverworldState), needed only to check
@@ -280,7 +288,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"All sleeping\nPOKéMON woke up!") } "All sleeping\nPOKéMON woke up!") }
end end
-- battle-only items -- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then or itemId == "POKE_DOLL" then
if not battle then if not battle then
@@ -293,43 +301,53 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
require("src.world.PikachuFollower") require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDXITEM", b and b.mon) .modifyHappiness(save, "USEDXITEM", b and b.mon)
end end
local used = itemUseLine(data, save, name)
if itemId == "X_ACCURACY" then if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage) -- (not an accuracy stage); vanilla prints only the used line
b.xAccuracy = true b.xAccuracy = true
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) } return "consumed", { used }, USE_JINGLE
end end
if X_ITEMS[itemId] then if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId] local stat = X_ITEMS[itemId]
local cur = b.stages[stat] or 0 local cur = b.stages[stat] or 0
-- ItemUseXStat removes the item BEFORE running the stat-up -- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
-- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!"
if cur >= 6 then if cur >= 6 then
return "consumed", { romText(data, "_NothingHappenedText", return "consumed", { used }, {
"Nothing happened!") } useJingle = true,
afterMessages = { romText(data, "_NothingHappenedText",
"Nothing happened!") },
}
end end
b.stages[stat] = cur + 1 b.stages[stat] = cur + 1
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } b.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" then
require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
end
return "consumed", { used }, {
useJingle = true,
afterMessages = { Strings("%s's\n%s rose!", b.name,
Strings(STAT_LABEL[stat])) },
}
end end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active -- the item, even when it is already active; vanilla prints only used
if itemId == "DIRE_HIT" then if itemId == "DIRE_HIT" then
b.focusEnergy = true b.focusEnergy = true
return "consumed", { romText(data, "_GettingPumpedText", return "consumed", { used }, USE_JINGLE
"%s's\ngetting pumped!", b.name) }
end end
if itemId == "GUARD_SPEC" then if itemId == "GUARD_SPEC" then
b.mist = true b.mist = true
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) } return "consumed", { used }, USE_JINGLE
end end
if itemId == "POKE_DOLL" then if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { notTime(data, save) } return "failed", { notTime(data, save) }
end end
return "consumed_escape", { romText(data, "_WildRanText", -- PrintItemUseTextAndRemoveItem then escape
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) } return "consumed_escape", { used }, USE_JINGLE
end end
end end
@@ -608,7 +626,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if REPELS[itemId] then if REPELS[itemId] then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps save.repelSteps = steps
return "consumed", { Strings("%s used\n%s!", save.player.name, name) } return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
end end
return "failed", { notTime(data, save) } return "failed", { notTime(data, save) }
+5 -5
View File
@@ -163,8 +163,8 @@ end
-- a module the build dropped disables its registry rather than the game: -- a module the build dropped disables its registry rather than the game:
-- the consumer still reads its own table, so vanilla keeps working -- the consumer still reads its own table, so vanilla keeps working
local function load(path) local function load(path, moduleLoader)
local ok, module = pcall(require, path) local ok, module = pcall(moduleLoader or require, path)
if ok then return module end if ok then return module end
Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module)) Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module))
return nil return nil
@@ -187,14 +187,14 @@ local function isolate(registry)
}, { __index = registry }) }, { __index = registry })
end end
function Builtins.install(content, data, generation) function Builtins.install(content, data, generation, moduleLoader)
for _, entry in ipairs(registrantsFor(generation)) do for _, entry in ipairs(registrantsFor(generation)) do
local registry = content[entry.name] and isolate(content[entry.name]) local registry = content[entry.name] and isolate(content[entry.name])
if registry then if registry then
if entry.install then if entry.install then
local modules, complete = {}, true local modules, complete = {}, true
for i, path in ipairs(entry.modules) do for i, path in ipairs(entry.modules) do
modules[i] = load(path) modules[i] = load(path, moduleLoader)
if modules[i] == nil then complete = false end if modules[i] == nil then complete = false end
end end
-- data is the fourth argument, not the second, so the existing -- data is the fourth argument, not the second, so the existing
@@ -204,7 +204,7 @@ function Builtins.install(content, data, generation)
entry.install(registry, modules, Builtins.OWNER, data) entry.install(registry, modules, Builtins.OWNER, data)
end end
else else
local module = load(entry.from) local module = load(entry.from, moduleLoader)
-- entry.fn names the entry point for a module that owns more than one -- entry.fn names the entry point for a module that owns more than one
-- registry (Gold's Battle owns statuses and move_effects); the default -- registry (Gold's Battle owns statuses and move_effects); the default
-- is the registerInto every single-registry module exposes -- is the registerInto every single-registry module exposes
+412
View File
@@ -0,0 +1,412 @@
-- Read-only, bounded views over version-scoped generated datasets.
local CacheContract = require("src.import.CacheContract")
local DatasetHydration = require("src.core.DatasetHydration")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local SaveSerializer = require("src.core.SaveSerializer")
local Builtins = require("src.mods.Builtins")
local Registry = require("src.mods.Registry")
local Schemas = require("src.mods.Schemas")
local DatasetViews = {}
DatasetViews.__index = DatasetViews
-- A generated module is ROM-sized data, never an arbitrary program. These
-- caps bound both one malicious file and the aggregate work one open performs.
local DECODE_LIMITS = {
allowArray = true,
allowComments = true,
maxBytes = 8 * 1024 * 1024,
maxDepth = 64,
maxNodes = 500000,
maxStringBytes = 2 * 1024 * 1024,
maxTableEntries = 250000,
rootName = "generated data",
}
local MAX_AGGREGATE_BYTES = 48 * 1024 * 1024
local GEN1_ROOTS = {
constants = "constants", maps = "maps", tilesets = "tilesets",
text = "text", text_pointers = "text_pointers",
trainer_headers = "trainer_headers", font = "font", sprites = "sprites",
pokemon = "pokemon", moves = "moves", items = "items",
type_chart = "type_chart", trainers = "trainers",
encounters = "encounters", field = "field",
battle_anims = "battle_anims", audio = "audio",
palettes = "palettes", icons = "icons",
}
local GEN2_ROOTS = {
pokemon = "pokemon", moves = "moves", items = "items",
type_chart = "type_chart", audio = "audio", font = "font",
gen2Maps = "maps", gen2Tilesets = "tilesets", gen2Text = "text",
gen2Trainers = "trainers", gen2Encounters = "encounters",
gen2Sprites = "sprites", gen2Palettes = "palettes",
gen2Icons = "icons", gen2BattleAnims = "battle_anims",
gen2Constants = "constants", gen2Landmarks = "landmarks",
}
local function resolvePath(root, suffix)
local node = root
for key in suffix:gmatch("[^.]+") do
if type(node) ~= "table" then return nil end
node = node[key]
end
return node
end
local function assetRelative(path)
if type(path) ~= "string" or path == "" then
error("dataset asset path is required", 3)
end
if path:find("\\", 1, true) or path:sub(1, 1) == "/"
or path:find("[%z\1-\31]") then
error("dataset asset path must be a generated relative path", 3)
end
if path:sub(1, 17) ~= "assets/generated/" then
error("dataset assets are limited to assets/generated/", 3)
end
for segment in path:gmatch("[^/]+") do
if segment == "." or segment == ".." then
error("dataset asset path may not traverse directories", 3)
end
end
return path
end
local function copyData(value, state, depth)
state = state or { seen = {}, nodes = 0 }
state.nodes = state.nodes + 1
if state.nodes > DECODE_LIMITS.maxNodes then return nil, false end
local kind = type(value)
if kind == "nil" or kind == "boolean" or kind == "number" then return value end
if kind == "string" then
if #value > DECODE_LIMITS.maxStringBytes then return nil, false end
return value
end
if kind ~= "table" or getmetatable(value) ~= nil then return nil, false end
depth = (depth or 0) + 1
if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return nil, false end
state.seen[value] = true
local out, entries = {}, 0
for key, child in pairs(value) do
entries = entries + 1
if entries > DECODE_LIMITS.maxTableEntries then return nil, false end
local keyCopy, keyOk = copyData(key, state, depth)
local childCopy, childOk = copyData(child, state, depth)
if keyOk == false or childOk == false or keyCopy == nil then return nil, false end
out[keyCopy] = childCopy
end
state.seen[value] = nil
return out, true
end
local function isDataOnly(value, state, depth)
state = state or { seen = {}, nodes = 0 }
state.nodes = state.nodes + 1
if state.nodes > DECODE_LIMITS.maxNodes then return false end
local kind = type(value)
if kind == "nil" or kind == "boolean" or kind == "number" then return true end
if kind == "string" then return #value <= DECODE_LIMITS.maxStringBytes end
if kind ~= "table" or getmetatable(value) ~= nil then return false end
depth = (depth or 0) + 1
if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return false end
state.seen[value] = true
local entries = 0
for key, child in pairs(value) do
entries = entries + 1
if entries > DECODE_LIMITS.maxTableEntries
or not isDataOnly(key, state, depth)
or not isDataOnly(child, state, depth) then
return false
end
end
state.seen[value] = nil
return true
end
function DatasetViews.new(fs, engineRequire, decoder)
assert(fs and fs.read and fs.getInfo,
"DatasetViews.new requires a readable filesystem")
return setmetatable({ fs = fs, engineRequire = engineRequire or require,
decoder = decoder or SaveSerializer.decode, datasets = {} }, DatasetViews)
end
function DatasetViews:_preflight(version, inspected)
local paths, aggregate = {}, 0
local modules = CacheContract.semanticModules(version)
for _, name in ipairs(CacheContract.optionalSemanticModules(version)) do
local path = inspected.prefix .. "data/generated/" .. name .. ".lua"
if self.fs.getInfo(path, "file") then modules[#modules + 1] = name end
end
for _, name in ipairs(modules) do
local path = inspected.prefix .. "data/generated/" .. name .. ".lua"
local info = self.fs.getInfo(path, "file")
local size = info and info.size
if size and size > DECODE_LIMITS.maxBytes then return nil, name .. ": size limit" end
aggregate = aggregate + (size or 0)
if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end
paths[name] = path
end
return { paths = paths, key = table.concat(modules, "\n") }
end
local function resetInternal(view)
view.moduleCache, view.data, view.registries = {}, nil, nil
end
function DatasetViews:_reject(view, moduleName, source, detail)
view.invalid = { module = moduleName, source = source, detail = detail }
view.data, view.registries = nil, nil
Logger.warn("dataset %s cache rejected: %s", view.version, tostring(detail))
return nil
end
function DatasetViews:_ready(view)
if view.invalid or view.unavailable then return false end
local inspected, _, detail = CacheContract.inspect(view.version, self.fs, {
allowSource = true, semantic = true,
})
if not inspected then
view.unavailable = true
if self.datasets[view.version] == view then self.datasets[view.version] = nil end
Logger.warn("dataset %s unavailable: %s", view.version, detail)
return false
end
local plan, invalid = self:_preflight(view.version, inspected)
if not plan then
self:_reject(view, nil, nil, invalid)
return false
end
if view.prefix ~= inspected.prefix or view.plan.key ~= plan.key then
view.prefix, view.plan = inspected.prefix, plan
resetInternal(view)
else
view.plan = plan
end
for name, cached in pairs(view.moduleCache) do
local source = self.fs.read(plan.paths[name])
if type(source) ~= "string" then
self:_reject(view, name, source, name .. ": unreadable generated module")
return false
end
if source ~= cached.source then
resetInternal(view)
break
end
end
return not view.invalid
end
function DatasetViews:_module(view, root)
local moduleName = view.modules[root]
if not moduleName then return nil end
local cached = view.moduleCache[moduleName]
if cached then return cached.value end
local path = view.plan.paths[moduleName]
if not path then return nil end
local source = self.fs.read(path)
if type(source) ~= "string" then
return self:_reject(view, moduleName, source,
moduleName .. ": unreadable generated module")
end
local aggregate = #source
for _, loaded in pairs(view.moduleCache) do aggregate = aggregate + #loaded.source end
if aggregate > MAX_AGGREGATE_BYTES then
return self:_reject(view, moduleName, source, "aggregate size limit")
end
local value, err = self.decoder(source, DECODE_LIMITS)
if type(value) ~= "table" then
return self:_reject(view, moduleName, source,
moduleName .. ": " .. tostring(err or "non-table root"))
end
view.moduleCache[moduleName] = { source = source, value = value }
return value
end
function DatasetViews:_data(view)
if view.data then return view.data end
local data = {}
setmetatable(data, {
__index = function(target, root)
local value = self:_module(view, root)
if value ~= nil then rawset(target, root, value) end
return value
end,
})
DatasetHydration.apply(data, view.version, self.engineRequire)
if view.invalid then error(view.invalid.detail, 0) end
view.data = data
return data
end
local function registryBase(data, target)
return function() return resolvePath(data, target) end
end
function DatasetViews:_registries(view)
if view.registries then return view.registries end
local data = self:_data(view)
local registries = {}
for name, catalogSpec in pairs(Schemas.REGISTRIES) do
local spec = Schemas.shapeFor(name, catalogSpec, view.generation)
local registry = Registry.new(name, spec)
local target = Schemas.targetFor(name, catalogSpec, view.generation)
if target then registry.base = registryBase(data, target) end
registries[name] = registry
end
Builtins.install(registries, data, view.generation, self.engineRequire)
for _, registry in pairs(registries) do registry:freeze() end
view.registries = registries
return registries
end
function DatasetViews:_registry(view, name)
local service = self
local function registryForRead()
if not service:_ready(view) then return nil end
local ok, registries = pcall(service._registries, service, view)
if not ok then
if not view.invalid then service:_reject(view, nil, nil, registries) end
return nil
end
if view.invalid then return nil end
return registries[name]
end
local function validate(registry, id, value)
if value == nil then return true end
local ok, detail = Schemas.check(registry.spec, registry.name, id,
value, "override")
if ok then return true end
local target = registry.spec.target
or Schemas.targetFor(registry.name, registry.spec, view.generation)
local root = target and target:match("^[^%.]+")
local moduleName = root and view.modules[root]
if root == "gen2HeldItems" then moduleName = "items" end
local cached = moduleName and view.moduleCache[moduleName]
service:_reject(view, moduleName, cached and cached.source,
"invalid " .. registry.name .. " record: " .. detail)
return false
end
local function rawAt(id)
local registry = registryForRead()
local value = registry and registry:get(id)
if value == nil or not validate(registry, id, value)
or not isDataOnly(value) then return nil end
return value
end
local function valueAt(id)
local value = rawAt(id)
if value == nil then return nil end
return (copyData(value))
end
return {
get = function(_, id)
if type(id) ~= "string" or id == "" then return nil end
return valueAt(id)
end,
has = function(_, id)
if type(id) ~= "string" or id == "" then return false end
return rawAt(id) ~= nil
end,
each = function()
local ids = {}
local registry = registryForRead()
if registry then
for id, value in registry:each() do
if not validate(registry, id, value) then
ids = {}
break
end
if type(id) == "string" and isDataOnly(value) then
ids[#ids + 1] = id
end
end
end
table.sort(ids)
local index = 0
return function()
index = index + 1
local id = ids[index]
if id == nil then return nil end
return id, valueAt(id)
end
end,
}
end
function DatasetViews:_assets(view)
local service = self
local assets = {}
function assets:path(path)
if not service:_ready(view) then return nil end
return view.prefix .. assetRelative(path)
end
function assets:info(path)
local full = self:path(path)
if not full then return nil end
local info = service.fs.getInfo and service.fs.getInfo(full, "file")
if not info or (info.type and info.type ~= "file") then return nil end
local out = { type = "file" }
if info.size ~= nil then out.size = info.size end
return out
end
return assets
end
function DatasetViews:open(version)
if type(version) ~= "string" or not GameVersion.VERSIONS[version] then
return nil, "unknown_version"
end
local inspected, reason = CacheContract.inspect(version, self.fs, {
allowSource = true, semantic = true,
})
if not inspected then
self.datasets[version] = nil
return nil, reason
end
local plan, invalid = self:_preflight(version, inspected)
if not plan then
Logger.warn("dataset %s cache rejected: %s", version, invalid)
return nil, "invalid_cache"
end
local internal = self.datasets[version]
if internal and internal.invalid then
local bad = internal.invalid
local path = bad.module and plan.paths[bad.module]
local source = path and self.fs.read(path)
if source == bad.source then return nil, "invalid_cache" end
internal = nil
self.datasets[version] = nil
end
local changed = not internal or internal.prefix ~= inspected.prefix
or internal.plan.key ~= plan.key
if changed then
if internal then internal.unavailable = true end
internal = {
version = version,
generation = GameVersion.generation(version),
prefix = inspected.prefix,
plan = plan,
modules = GameVersion.generation(version) == 2 and GEN2_ROOTS or GEN1_ROOTS,
moduleCache = {},
}
self.datasets[version] = internal
else
internal.plan = plan
end
local view = { version = version, generation = internal.generation, content = {} }
for name in pairs(Schemas.REGISTRIES) do
view.content[name] = self:_registry(internal, name)
end
for alias, canonical in pairs(Schemas.ALIASES) do
view.content[alias] = view.content[canonical]
end
view.assets = self:_assets(internal)
return view
end
return DatasetViews
+23 -2
View File
@@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = {
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "") local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "") :gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
local ENGINE_CHUNKS = {
["main.lua"] = true,
["conf.lua"] = true,
}
local function callerIsMod(level) local function callerIsMod(level)
if ENGINE_PREFIX == "" then return false end if ENGINE_PREFIX == "" then return false end
local info = debug.getinfo(level, "S") local info = debug.getinfo(level, "S")
local source = info and info.source local source = info and info.source
if not source or source:sub(1, 1) ~= "@" then return false end if not source or source:sub(1, 1) ~= "@" then return false end
return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX local path = source:sub(2)
if ENGINE_CHUNKS[path] then return false end
return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX
end end
local function scanRequire(name) local function scanRequire(name)
@@ -207,6 +214,11 @@ local function engineRequire(name)
return module return module
end end
function Loader.endSession()
devShim.generation = nil
devShim.errors = nil
end
function Loader:_installDevShim() function Loader:_installDevShim()
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet devShim.permissions[id] = mod.manifest.permissionSet
@@ -234,7 +246,7 @@ function Loader:_installDevShim()
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. -- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776 -- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it. -- requires src.core.Game on both generations and means it.
if devShim.generation ~= 1 and Gen2Compat.serves(name) if devShim.generation == 2 and Gen2Compat.serves(name)
and (owner or callerIsMod(3)) then and (owner or callerIsMod(3)) then
local adapter = Gen2Compat.resolve(name, Runtime.currentMod) local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then if adapter then
@@ -1154,6 +1166,15 @@ function Loader:_api(mod)
developer = loader.dev == true, developer = loader.dev == true,
-- a deep copy: what a mod does to its own view never reaches the loader -- a deep copy: what a mod does to its own view never reaches the loader
manifest = Merge.deepCopy(mod.manifest), manifest = Merge.deepCopy(mod.manifest),
datasets = {
open = function(_, version)
if not loader.datasetViews then
local DatasetViews = engineRequire("src.mods.DatasetViews")
loader.datasetViews = DatasetViews.new(loader.fs, engineRequire)
end
return loader.datasetViews:open(version)
end,
},
content = {}, content = {},
exports = {}, exports = {},
DELETE = Registry.DELETE, DELETE = Registry.DELETE,
+7
View File
@@ -12,6 +12,12 @@ Registry.__index = Registry
-- exposed to mods as mod.DELETE: a patch value that unsets a field -- exposed to mods as mod.DELETE: a patch value that unsets a field
Registry.DELETE = Merge.DELETE Registry.DELETE = Merge.DELETE
local function assertWritable(self, id)
if self.spec.reservedIds and self.spec.reservedIds[id] then
error(("%s id is reserved engine metadata: %s"):format(self.name, id))
end
end
-- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps -- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps
-- the v1 record behavior for standalone use in tests and tools -- the v1 record behavior for standalone use in tests and tools
function Registry.new(name, spec) function Registry.new(name, spec)
@@ -33,6 +39,7 @@ local function append(self, id, op, value, owner)
error(self.name .. ": content is frozen after load") error(self.name .. ": content is frozen after load")
end end
assert(type(id) == "string" and id ~= "", self.name .. " id is required") assert(type(id) == "string" and id ~= "", self.name .. " id is required")
assertWritable(self, id)
local list = self.ops[id] local list = self.ops[id]
if not list then if not list then
list = {} list = {}
+37 -3
View File
@@ -676,9 +676,10 @@ end
-- --
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry -- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside -- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` / -- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` /
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here -- `example` / `notes` the matching `gen2*`. Absent means "the Gen 1 shape is
-- too", which is the common case and why most registries carry none of this. -- right here too", which is the common case and why most registries carry
-- none of this.
-- The registry NAME, the verbs and (wherever the id space allows it) the ids -- The registry NAME, the verbs and (wherever the id space allows it) the ids
-- stay shared, exactly as the routing table keeps them shared. -- stay shared, exactly as the routing table keeps them shared.
-- --
@@ -700,6 +701,7 @@ local GEN2_SHAPE = {
gen2KeyValue = "keyValue", gen2Extra = "extra", gen2KeyValue = "keyValue", gen2Extra = "extra",
gen2Semantics = "semantics", gen2Write = "write", gen2Semantics = "semantics", gen2Write = "write",
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds", gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
gen2ReservedIds = "reservedIds",
gen2Example = "example", gen2Notes = "notes", gen2Example = "example", gen2Notes = "notes",
} }
@@ -757,6 +759,32 @@ end
local R = {} local R = {}
Schemas.REGISTRIES = R Schemas.REGISTRIES = R
-- Some generated Gen 2 modules keep extractor metadata beside their public
-- record maps. These callbacks are the registry normalization boundary: the
-- metadata remains available to engine consumers through Data, but is not an
-- id a mod can read or overwrite through the record registry.
local function recordMapExcept(...)
local excluded = {}
for index = 1, select("#", ...) do excluded[select(index, ...)] = true end
return function(base, id)
if excluded[id] then return nil end
return base[id]
end, function(base)
local ids = {}
for id in pairs(base) do
if not excluded[id] then ids[#ids + 1] = id end
end
return ids
end, excluded
end
local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds =
recordMapExcept("growthRates", "tmhmMoves")
local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds =
recordMapExcept("generation", "source")
local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds =
recordMapExcept("generation", "source", "pockets")
-- ------- shared Gen 2 leaves -- ------- shared Gen 2 leaves
-- --
-- The ROM name spaces Gold's tables key by. They are enums rather than -- The ROM name spaces Gold's tables key by. They are enums rather than
@@ -779,6 +807,8 @@ local gen2PaletteRow = f.list(gen2Color)
R.pokemon = { R.pokemon = {
semantics = "record", target = "pokemon", semantics = "record", target = "pokemon",
gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds,
gen2ReservedIds = pokemonGen2ReservedIds,
fields = { fields = {
id = f.str, name = f.str, dex = f.int(1), id = f.str, name = f.str, dex = f.int(1),
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
@@ -870,6 +900,8 @@ R.pokemon = {
R.moves = { R.moves = {
semantics = "record", target = "moves", semantics = "record", target = "moves",
gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds,
gen2ReservedIds = movesGen2ReservedIds,
fields = { fields = {
id = f.str, name = f.str, id = f.str, name = f.str,
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
@@ -894,6 +926,8 @@ R.moves = {
R.items = { R.items = {
semantics = "record", target = "items", semantics = "record", target = "items",
gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds,
gen2ReservedIds = itemsGen2ReservedIds,
fields = { fields = {
id = f.str, name = f.str, id = f.str, name = f.str,
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
+40 -5
View File
@@ -18,8 +18,6 @@
-- colour IS its palettes -- so one substitution at this seam recolours the -- colour IS its palettes -- so one substitution at this seam recolours the
-- whole game without a single screen knowing about it: -- whole game without a single screen knowing about it:
-- --
-- GBC the cart's own palettes. The default: this is a Game Boy
-- Color game and its colour is the point.
-- DMG the original grey Game Boy. Every palette collapses to the -- DMG the original grey Game Boy. Every palette collapses to the
-- four hardware shades, and the sheets that are drawn straight -- four hardware shades, and the sheets that are drawn straight
-- (chrome, text) are already those shades, so the screen is -- (chrome, text) are already those shades, so the screen is
@@ -32,9 +30,12 @@
local GbcPalette = {} local GbcPalette = {}
GbcPalette.MODES = { "gbc", "dmg", "classic" } GbcPalette.MODES = { "gbc", "dmg", "classic" }
GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" } GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC",
custom = "GBC" }
GbcPalette.mode = "gbc" GbcPalette.mode = "gbc"
GbcPalette.CUSTOM_MODE = "custom"
-- rBGP's four shades as the hardware shows them. -- rBGP's four shades as the hardware shows them.
local DMG_SHADES = { local DMG_SHADES = {
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }, { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
@@ -162,11 +163,22 @@ local function channel(colors, index)
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
end end
GbcPalette.customRamp = nil
function GbcPalette.setCustomRamp(ramp)
local prev = GbcPalette.customRamp
GbcPalette.customRamp = ramp
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
end
end
-- What a palette actually draws as under the current COLOR mode. Anything -- What a palette actually draws as under the current COLOR mode. Anything
-- that reads a colour out of a palette directly -- a canvas cleared to BG -- that reads a colour out of a palette directly -- a canvas cleared to BG
-- colour 0, say -- has to go through this too, or the backdrop would keep its -- colour 0, say -- has to go through this too, or the backdrop would keep its
-- cart colour while everything on top of it went grey. -- cart colour while everything on top of it went grey.
function GbcPalette.resolve(colors) function GbcPalette.resolve(colors)
if GbcPalette.customRamp then return GbcPalette.customRamp end
if GbcPalette.mode == "gbc" then return colors end if GbcPalette.mode == "gbc" then return colors end
return DMG_SHADES return DMG_SHADES
end end
@@ -249,6 +261,10 @@ function GbcPalette.presentColors()
end end
function GbcPalette.setMode(mode) function GbcPalette.setMode(mode)
if mode == GbcPalette.CUSTOM_MODE then
GbcPalette.mode = mode
return mode
end
for _, name in ipairs(GbcPalette.MODES) do for _, name in ipairs(GbcPalette.MODES) do
if name == mode then if name == mode then
GbcPalette.mode = mode GbcPalette.mode = mode
@@ -260,7 +276,7 @@ function GbcPalette.setMode(mode)
end end
function GbcPalette.modeLabel(mode) function GbcPalette.modeLabel(mode)
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC" return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2"
end end
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so -- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
@@ -273,11 +289,20 @@ function GbcPalette.cycle(delta)
local count = #GbcPalette.MODES local count = #GbcPalette.MODES
at = (at - 1 + (delta or 1)) % count + 1 at = (at - 1 + (delta or 1)) % count + 1
GbcPalette.mode = GbcPalette.MODES[at] GbcPalette.mode = GbcPalette.MODES[at]
GbcPalette.setCustomRamp(nil)
return GbcPalette.mode return GbcPalette.mode
end end
function GbcPalette.applyOptions(opts) function GbcPalette.applyOptions(opts)
return GbcPalette.setMode(opts and opts.color or "gbc") local mode = GbcPalette.setMode(opts and opts.color or "gbc")
local id = opts and opts.palette
if id and id ~= "" then
local ok, Palette = pcall(require, "src.render.Palette")
GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil)
else
GbcPalette.setCustomRamp(nil)
end
return mode
end end
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching -- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
@@ -431,4 +456,14 @@ function GbcPalette.with(colors, body)
return applied return applied
end end
function GbcPalette.withRaw(colors, body)
local previous = love and love.graphics and love.graphics.getShader
and love.graphics.getShader() or nil
local applied = GbcPalette.useRaw(colors)
local ok, err = pcall(body)
if love and love.graphics then love.graphics.setShader(previous) end
if not ok then error(err, 0) end
return applied
end
return GbcPalette return GbcPalette
+72
View File
@@ -0,0 +1,72 @@
-- UI LETTERBOX: what fills the window around a 160x144 screen.
--
-- AUTO keeps what each screen was authored with (Gen 1 black, Gen 2 mostly
-- white, the Pokedex and credits black); the other three override every
-- screen uniformly. PALETTE takes the active COLORS ramp's paper shade, so
-- the bars match the panel they frame instead of a fixed grey.
local Letterbox = {}
-- Set from applyOptions on both generations, the same shape GbcPalette.setMode
-- and Tilt.setLevel use, so a draw call needs no options table in hand.
Letterbox.mode = "auto"
Letterbox.MODES = { "auto", "black", "white", "palette" }
local LABELS = {
auto = "AUTO", black = "BLACK", white = "WHITE", palette = "PALETTE",
}
function Letterbox.normalize(mode)
for _, id in ipairs(Letterbox.MODES) do
if mode == id then return id end
end
return "auto"
end
function Letterbox.label(mode)
return LABELS[Letterbox.normalize(mode)]
end
function Letterbox.cycle(mode, dir)
local at = 1
for i, id in ipairs(Letterbox.MODES) do
if id == Letterbox.normalize(mode) then at = i break end
end
local n = #Letterbox.MODES
return Letterbox.MODES[(at - 1 + (dir or 1)) % n + 1]
end
-- r, g, b for `mode`, falling back to the screen's authored colour on AUTO.
--
-- `paper` is supplied by the caller, not looked up here: the two generations
-- keep their picked palette in different modules (Gen 1 in PaletteFX, Gen 2 in
-- GbcPalette) and GbcPalette.available() is true on both, so anything that
-- sniffed for it answered with Gen 2's white default on a Gen 1 game.
function Letterbox.color(mode, r, g, b, paper)
mode = Letterbox.normalize(mode)
if mode == "black" then return 0, 0, 0 end
if mode == "white" then return 1, 1, 1 end
if mode == "palette" and paper then
local pr, pg, pb = paper()
if pr then return pr, pg, pb end
end
return r or 0, g or 0, b or 0
end
function Letterbox.setMode(mode)
Letterbox.mode = Letterbox.normalize(mode)
return Letterbox.mode
end
function Letterbox.applyOptions(options)
return Letterbox.setMode(options and options.uiLetterbox)
end
-- What a drawWidescreen should paint the window with, given the colour that
-- screen was authored to use and its generation's paper reader.
function Letterbox.fill(r, g, b, paper)
return Letterbox.color(Letterbox.mode, r, g, b, paper)
end
return Letterbox
+249
View File
@@ -0,0 +1,249 @@
local Palette = {}
local pack = nil
function Palette.packs()
if pack == nil then
local ok, data = pcall(require, "data.gb_palettes")
pack = ok and data or false
end
return pack or {}
end
local MONO_STEPS = { 1.00, 0.72, 0.38, 0.12 }
Palette.MONO_COLOURS = {
{ "GREEN", 0x9b, 0xbc, 0x0f }, -- DMG
{ "LIME", 0xa8, 0xd8, 0x38 },
{ "OLIVE", 0xa6, 0xac, 0x84 }, -- Pocket
{ "AMBER", 0xff, 0xb0, 0x00 }, -- plasma terminal
{ "ORANGE", 0xff, 0x80, 0x30 },
{ "RED", 0xff, 0x50, 0x50 },
{ "ROSE", 0xff, 0x90, 0xb0 },
{ "MAGENTA", 0xf0, 0x60, 0xd0 },
{ "PURPLE", 0xb0, 0x70, 0xf0 },
{ "INDIGO", 0x80, 0x80, 0xf0 },
{ "BLUE", 0x50, 0xa0, 0xff },
{ "CYAN", 0x40, 0xe0, 0xe0 }, -- VFD
{ "TEAL", 0x40, 0xc0, 0xa0 },
{ "MINT", 0x90, 0xe0, 0xb0 },
{ "SAND", 0xe0, 0xc8, 0x90 },
{ "BROWN", 0xc0, 0x90, 0x50 },
{ "WHITE", 0xff, 0xff, 0xff }, -- plain DMG greys
{ "SILVER", 0xd0, 0xd8, 0xe0 },
}
local function monoRamp(r, g, b)
local out = {}
for i = 1, 4 do
local f = MONO_STEPS[i]
out[i] = { math.floor(r * f + 0.5),
math.floor(g * f + 0.5),
math.floor(b * f + 0.5) }
end
return out
end
Palette.monoRamp = monoRamp -- named for the suite
function Palette.packId(group, name)
return "p:" .. group .. "/" .. name
end
function Palette.monoId(r, g, b)
return ("m:%02x%02x%02x"):format(r, g, b)
end
local function unhex(text)
if type(text) ~= "string" or #text ~= 24 then return nil end
local out = {}
for i = 1, 4 do
local at = (i - 1) * 6
local r = tonumber(text:sub(at + 1, at + 2), 16)
local g = tonumber(text:sub(at + 3, at + 4), 16)
local b = tonumber(text:sub(at + 5, at + 6), 16)
if not (r and g and b) then return nil end
out[i] = { r, g, b }
end
return out
end
Palette.unhex = unhex -- named for the suite
local RAMP_INDEX = 2
local GREY_CHROMA = 12
local HUE_ARC = 50
Palette.GREY_CHROMA = GREY_CHROMA -- named for the suite
Palette.HUE_ARC = HUE_ARC
local function hueChroma(c)
local r, g, b = c[1] or 0, c[2] or 0, c[3] or 0
local mx = math.max(r, g, b)
local mn = math.min(r, g, b)
local chroma = mx - mn
if chroma <= 0 then return 0, 0 end
local h
if mx == r then h = ((g - b) / chroma) % 6
elseif mx == g then h = (b - r) / chroma + 2
else h = (r - g) / chroma + 4 end
return h * 60, chroma
end
Palette.hueChroma = hueChroma -- named for the suite
local function hueArc(hues)
local best = 360
for i = 1, #hues do
local widest = 0
for j = 1, #hues do
local d = (hues[j] - hues[i]) % 360
if d > widest then widest = d end
end
if widest < best then best = widest end
end
return best
end
Palette.hueArc = hueArc -- named for the suite
function Palette.categoryOf(ramp)
if type(ramp) ~= "table" or not ramp[4] then return nil end
local hues = {}
for i = 1, 4 do
local h, chroma = hueChroma(ramp[i])
if chroma > GREY_CHROMA then hues[#hues + 1] = h end
end
if #hues == 0 then return "grey" end
return hueArc(hues) <= HUE_ARC and "single" or "full"
end
Palette.CATEGORIES = {
{ key = "full", label = "FULL COLOUR" },
{ key = "single", label = "SINGLE COLOUR" },
{ key = "grey", label = "GREYSCALE" },
}
local byId, buckets = nil, nil
local function buildIndex()
byId, buckets = {}, {}
for _, category in ipairs(Palette.CATEGORIES) do buckets[category.key] = {} end
for _, p in ipairs(Palette.packs()) do
for _, entry in ipairs(p.palettes or {}) do
local name = entry[1]
if type(name) == "string" then
local id = Palette.packId(p.name, name)
byId[id] = entry
local raw = unhex(entry[RAMP_INDEX] or entry[2])
local bucket = raw and buckets[Palette.categoryOf(raw) or ""] or nil
if bucket then
bucket[#bucket + 1] = { name = name, pack = p.name, id = id }
end
end
end
end
end
local function ensureIndex()
if not byId then buildIndex() end
end
function Palette.categories()
ensureIndex()
local out = {}
for i, category in ipairs(Palette.CATEGORIES) do
out[i] = { key = category.key, name = category.label,
palettes = buckets[category.key] or {} }
end
return out
end
function Palette.category(key)
ensureIndex()
if not buckets[key] then return nil end
for _, category in ipairs(Palette.CATEGORIES) do
if category.key == key then
return { key = key, name = category.label, palettes = buckets[key] }
end
end
return nil
end
function Palette.locate(id)
if type(id) ~= "string" or id == "" then return nil end
ensureIndex()
for _, category in ipairs(Palette.CATEGORIES) do
for i, item in ipairs(buckets[category.key] or {}) do
if item.id == id then return category.key, i end
end
end
return nil
end
local function findPack(group, name)
ensureIndex()
return byId[Palette.packId(group, name)]
end
Palette.findPack = findPack -- named for the suite
function Palette.label(id)
if type(id) ~= "string" or id == "" then return nil end
local hex = id:match("^m:(%x+)$")
if hex then
local r = tonumber(hex:sub(1, 2), 16)
local g = tonumber(hex:sub(3, 4), 16)
local b = tonumber(hex:sub(5, 6), 16)
for _, c in ipairs(Palette.MONO_COLOURS) do
if c[2] == r and c[3] == g and c[4] == b then return c[1] end
end
return "MONO"
end
local name = id:match("^p:.*/([^/]+)$")
return name or id
end
local cacheId, cacheRamp = nil, nil
function Palette.ramp(id)
if type(id) ~= "string" or id == "" then return nil end
if id == cacheId then return cacheRamp end
local out = nil
local hex = id:match("^m:(%x+)$")
if hex and #hex == 6 then
out = monoRamp(tonumber(hex:sub(1, 2), 16) or 0,
tonumber(hex:sub(3, 4), 16) or 0,
tonumber(hex:sub(5, 6), 16) or 0)
else
local group, name = id:match("^p:(.*)/([^/]+)$")
if group then
local entry = findPack(group, name)
local ramp = entry and unhex(entry[RAMP_INDEX] or entry[2])
if ramp then
out = { ramp[4], ramp[3], ramp[2], ramp[1] }
end
end
end
cacheId, cacheRamp = id, out
return out
end
function Palette.invalidate()
cacheId, cacheRamp = nil, nil
byId, buckets = nil, nil
end
function Palette.swatch(id)
local ramp = Palette.ramp(id)
if not ramp then return nil end
return { ramp[4], ramp[3], ramp[2], ramp[1] }
end
return Palette
+99 -35
View File
@@ -20,6 +20,8 @@ local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support) local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet local gbcPack -- false = missing; nil = not loaded yet
local yellowPack -- false = missing; nil = not loaded yet local yellowPack -- false = missing; nil = not loaded yet
local gbcYellowPack -- Yellow Advanced deltas; false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations -- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy, -- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
@@ -300,6 +302,38 @@ function PaletteFX.yellowPack()
return yellowPack or nil return yellowPack or nil
end end
-- Yellow-only Advanced deltas (BEACH_HOUSE, sprite remap, YELLOWMON). Never
-- consulted on Red/Blue (#1639).
function PaletteFX.gbcYellowPack()
if gbcYellowPack == nil then
local ok, pack = pcall(require, "data.palettes_gbc_yellow")
gbcYellowPack = ok and pack or false
end
return gbcYellowPack or nil
end
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
-- via __index so shared Red tilesets stay identical byte-for-byte.
function PaletteFX.worldPack()
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
if not w then return nil end
if not GameVersion.isYellow() then return w end
local y = PaletteFX.gbcYellowPack()
local yw = y and y.world
if not yw then return w end
return {
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
roofGroup = w.roofGroup,
roofByMapIndex = w.roofByMapIndex,
spriteAssignment = yw.spriteAssignment or w.spriteAssignment,
spritePalettes = yw.spritePalettes
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
or w.spritePalettes,
}
end
function PaletteFX.usesGbcPack(mode) function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode mode = mode or PaletteFX.mode
return mode == "redpp" return mode == "redpp"
@@ -453,7 +487,10 @@ function PaletteFX.pal(data, name)
if fromRom then return fromRom end if fromRom then return fromRom end
end end
if GameVersion.isYellow() then if GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then -- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes
-- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World
-- tile bake still comes from the Advanced GBC pack via worldPack (#1639).
if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then
local fromCgb = yellowCgbNamedPal(data, name) local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end if fromCgb then return fromCgb end
end end
@@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed)
if pal then return pal end if pal then return pal end
end end
local name = p.pokemon[species] or "MEWMON" local name = p.pokemon[species] or "MEWMON"
if PaletteFX.usesYellowCgb() then if PaletteFX.usesYellowCgb()
or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then
local yc = PaletteFX.pal(data, name) local yc = PaletteFX.pal(data, name)
if yc then return yc end if yc then return yc end
end end
@@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel
-- (false for a mod tileset with no pokered-gbc counterpart, or when the -- (false for a mod tileset with no pokered-gbc counterpart, or when the
-- pack failed to load at all) -- pack failed to load at all)
function PaletteFX.hasWorldTileset(tileset) function PaletteFX.hasWorldTileset(tileset)
local pack = PaletteFX.gbcPack() local w = PaletteFX.worldPack()
local w = pack and pack.world
return (w and w.tileGroups[tileset]) ~= nil return (w and w.tileGroups[tileset]) ~= nil
end end
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset, -- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
-- with the current map's tile-id exceptions (if any) applied first -- with the current map's tile-id exceptions (if any) applied first
function PaletteFX.worldGroupAt(tileset, mapId, tileId) function PaletteFX.worldGroupAt(tileset, mapId, tileId)
local pack = PaletteFX.gbcPack() local w = PaletteFX.worldPack()
local w = pack and pack.world
local groups = w and w.tileGroups[tileset] local groups = w and w.tileGroups[tileset]
if not groups then return nil end if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId] local exc = TILE_GROUP_EXCEPTIONS[mapId]
@@ -642,8 +678,7 @@ end
-- Saffron's roof colors while the player stands in its top 2 cell rows, -- Saffron's roof colors while the player stands in its top 2 cell rows,
-- like pokered's wYCoord check -- data is Game.data, for the map lookup) -- like pokered's wYCoord check -- data is Game.data, for the map lookup)
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local pack = PaletteFX.gbcPack() local w = PaletteFX.worldPack()
local w = pack and pack.world
local base = w and w.groupColors[tileset] local base = w and w.groupColors[tileset]
if not base then return nil end if not base then return nil end
if not w.roofGroup[tileset] then return darkGroups(base) end if not w.roofGroup[tileset] then return darkGroups(base) end
@@ -678,17 +713,18 @@ end
-- here): a stable hash instead, so the same NPC instance always shows the -- here): a stable hash instead, so the same NPC instance always shows the
-- same one of the 4 SPR_PAL_* colors. -- same one of the 4 SPR_PAL_* colors.
function PaletteFX.spriteObp(spriteDef, seed) function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack() local w = PaletteFX.worldPack()
local w = pack and pack.world
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]")) local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite and SurfingPikachuSprite load outside -- RedBikeSprite and SurfingPikachuSprite load outside
-- SpriteSheetPointerTable, so their source has no bracketed index; -- SpriteSheetPointerTable, so their source has no bracketed index;
-- they wear the player's OBP palette (spriteAssignment[0]). -- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
if not idx and (src:find("RedBikeSprite", 1, true) -- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
or src:find("SurfingPikachuSprite", 1, true)) then if not idx and src:find("RedBikeSprite", 1, true) then
idx = 0 idx = 0
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
idx = GameVersion.isYellow() and 60 or 0
end end
local group = idx and w.spriteAssignment[idx] local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end if group == nil then return nil end
@@ -761,6 +797,18 @@ function PaletteFX.shadeMap()
return shadeMap return shadeMap
end end
local function invalidateColorCaches()
pcall(function() require("src.battle.BattleState").invalidate() end)
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
pcall(function()
require("src.world.MapLoader").invalidateAll()
local Game = require("src.core.Game")
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
end
end)
end
function PaletteFX.setMode(mode) function PaletteFX.setMode(mode)
local prev = PaletteFX.mode local prev = PaletteFX.mode
local ok = false local ok = false
@@ -772,27 +820,35 @@ function PaletteFX.setMode(mode)
end end
end end
if not ok then PaletteFX.mode = "gbc" end if not ok then PaletteFX.mode = "gbc" end
-- battle pics and overworld sprites bake the active pack into ImageData; if prev ~= PaletteFX.mode then invalidateColorCaches() end
-- drop those caches when the pack (or any COLORS mode) changes so the end
-- next draw re-tints
if prev ~= PaletteFX.mode then PaletteFX.customRamp = nil
pcall(function() require("src.battle.BattleState").invalidate() end)
pcall(function() require("src.render.SpriteRenderer").invalidate() end) function PaletteFX.setCustomRamp(ramp)
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once local prev = PaletteFX.customRamp
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer PaletteFX.customRamp = ramp
-- dropped and the currently-visible one rebuilt in place -- otherwise if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
-- the on-screen map keeps its stale (wrong-mode) atlas until the next invalidateColorCaches()
-- map transition happens to reload it.
pcall(function()
require("src.world.MapLoader").invalidateAll()
local Game = require("src.core.Game")
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
end
end)
end end
end end
function PaletteFX.pickerActive()
local ok, Game = pcall(require, "src.core.Game")
local states = ok and Game.stack and Game.stack.states
local top = states and states[#states]
return top ~= nil and top.screenId == "PaletteScreen"
end
-- Whether the active COLORS state needs a battle pic rendered as raw DMG
-- grays instead of real colour, the same "forced-mono" contract OG/OG
-- INV/CLASSIC already use (issue #207). A custom palette needs this too,
-- since ensureZones re-thresholds the already-drawn frame. Keep this in
-- sync with ensureZones and the mode checks in BattleState and WideBattle.
function PaletteFX.forcesRawGrays()
return PaletteFX.customRamp ~= nil
end
function PaletteFX.cycleMode() function PaletteFX.cycleMode()
local cur = PaletteFX.mode or "gbc" local cur = PaletteFX.mode or "gbc"
local idx = 1 local idx = 1
@@ -805,6 +861,13 @@ end
function PaletteFX.applyOptions(opts) function PaletteFX.applyOptions(opts)
PaletteFX.setMode(opts and opts.colors or "gbc") PaletteFX.setMode(opts and opts.colors or "gbc")
local id = opts and opts.palette
if id and id ~= "" then
local ok, Palette = pcall(require, "src.render.Palette")
PaletteFX.setCustomRamp(ok and Palette.ramp(id) or nil)
else
PaletteFX.setCustomRamp(nil)
end
end end
function PaletteFX.modeLabel(mode) function PaletteFX.modeLabel(mode)
@@ -817,12 +880,11 @@ function PaletteFX.modeLabel(mode)
end end
-- When a state exposes no SGB zones but COLORS needs a forced palette -- When a state exposes no SGB zones but COLORS needs a forced palette
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
function PaletteFX.ensureZones(zones) function PaletteFX.ensureZones(zones)
if zones and zones[1] then return zones end if zones and zones[1] then return zones end
local mode = PaletteFX.mode or "gbc" local mode = PaletteFX.mode or "gbc"
if mode == "og" or mode == "og_inv" or mode == "classic" then if mode == "og" or mode == "og_inv" or mode == "classic"
or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then
return { PaletteFX.whole(PaletteFX.GRAYS) } return { PaletteFX.whole(PaletteFX.GRAYS) }
end end
return zones return zones
@@ -852,7 +914,9 @@ function PaletteFX.effectiveColors(c)
if not c then return nil end if not c then return nil end
local mode = PaletteFX.mode or "gbc" local mode = PaletteFX.mode or "gbc"
local out = c local out = c
if mode == "og" then if PaletteFX.customRamp and not PaletteFX.pickerActive() then
out = PaletteFX.customRamp
elseif mode == "og" then
out = PaletteFX.GRAYS out = PaletteFX.GRAYS
elseif mode == "og_inv" then elseif mode == "og_inv" then
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
+13 -3
View File
@@ -338,6 +338,7 @@ function Renderer:beginFrame(transparent)
-- warp-fade overlay from Transition (issue #121); cleared each frame so -- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil -- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil self.worldFadeAlpha = nil
self.worldFadeColor = nil
-- battle-transition wipe, drawn over the whole surface (BattleTransition) -- battle-transition wipe, drawn over the whole surface (BattleTransition)
self.battleWipe = nil self.battleWipe = nil
-- whole-surface veil in screen space (battle-transition flash, the -- whole-surface veil in screen space (battle-transition flash, the
@@ -817,7 +818,7 @@ function Renderer:frameRects()
-- GB pixel stops being a whole number of screen pixels, which is the trade -- GB pixel stops being a whole number of screen pixels, which is the trade
-- the setting exists to offer. Clamped on the horizontal too, so a narrow -- the setting exists to offer. Clamped on the horizontal too, so a narrow
-- window scales to fit instead of overflowing off both sides. -- window scales to fit instead of overflowing off both sides.
if self.uiFill then if self.uiFill and not FaithfulRes.scaleCap() then
Up = math.min(ph / uih, pw / uiw) Up = math.min(ph / uih, pw / uiw)
end end
if uiw * Up > pw or uih * Up > ph then if uiw * Up > pw or uih * Up > ph then
@@ -971,6 +972,13 @@ function Renderer:endFrame(zones, worldZones)
and not FaithfulRes.scaleCap() then and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end end
-- UI LETTERBOX overrides whatever the rules above settled on, except
-- under FAITHFUL RATIO's mobile lock, which promises black bars.
if not FaithfulRes.scaleCap() then
local Letterbox = require("src.render.Letterbox")
clearR, clearG, clearB = Letterbox.fill(clearR, clearG, clearB,
function() return PaletteFX.paperShade(Game and Game.data) end)
end
end end
if cut then if cut then
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
@@ -1045,7 +1053,8 @@ function Renderer:endFrame(zones, worldZones)
-- the screen-space overlays the flat path draws over its composite -- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
if fade and fade > 0 then if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade) local c = self.worldFadeColor or { 0, 0, 0 }
love.graphics.setColor(c[1], c[2], c[3], fade)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -1132,7 +1141,8 @@ function Renderer:endFrame(zones, worldZones)
-- composite normally if one is ever stacked that way. -- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
if fade and fade > 0 then if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade) local c = self.worldFadeColor or { 0, 0, 0 }
love.graphics.setColor(c[1], c[2], c[3], fade)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+17 -3
View File
@@ -264,7 +264,9 @@ ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge"
local function libNames() local function libNames()
local osName = (love and love.system and love.system.getOS local osName = (love and love.system and love.system.getOS
and love.system.getOS()) or "" and love.system.getOS()) or ""
if osName == "Windows" then if osName == "iOS" then
return {}
elseif osName == "Windows" then
return { "librashader_bridge.dll" } return { "librashader_bridge.dll" }
elseif osName == "OS X" then elseif osName == "OS X" then
return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" } return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" }
@@ -313,11 +315,13 @@ end
-- Every place the bridge may sit, most specific first. -- Every place the bridge may sit, most specific first.
local function libCandidates() local function libCandidates()
local names = libNames()
if #names == 0 then return {} end
local out = {} local out = {}
local override = os.getenv("LIBRASHADER_BRIDGE_DLL") local override = os.getenv("LIBRASHADER_BRIDGE_DLL")
if override and override ~= "" then out[#out + 1] = override end if override and override ~= "" then out[#out + 1] = override end
local dirs, save = sourceDirs(), saveDir() local dirs, save = sourceDirs(), saveDir()
for _, name in ipairs(libNames()) do for _, name in ipairs(names) do
for _, dir in ipairs(dirs) do for _, dir in ipairs(dirs) do
out[#out + 1] = dir .. "/" .. name out[#out + 1] = dir .. "/" .. name
out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name
@@ -353,7 +357,17 @@ local function ensureLib()
end end
tried[#tried + 1] = path tried[#tried + 1] = path
end end
libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ") local okSym, sym = pcall(function()
return ffi.C and ffi.C.librashader_translate_preset
end)
if okSym and sym ~= nil then
lib = ffi.C
return lib
end
libError = "librashader bridge not found; ffi.C has no librashader_translate_preset"
if #tried > 0 then
libError = libError .. "; looked in " .. table.concat(tried, ", ")
end
return nil, libError return nil, libError
end end
+37 -9
View File
@@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing") local Timing = require("src.core.Timing")
local Chrome2
do
local ok, v = pcall(require, "src.ui.gen2.Chrome")
Chrome2 = ok and v or nil
end
local TextBox = {} local TextBox = {}
TextBox.__index = TextBox TextBox.__index = TextBox
TextBox.isTextBox = true TextBox.isTextBox = true
@@ -470,8 +476,21 @@ function TextBox:draw()
-- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile -- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile
-- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil. -- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil.
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1) local gold = self.game and self.game.save
and (self.game.save.generation == 2 or self.game.save.version == "gold")
local Chrome = gold and Chrome2 or nil
local drawGlyph, finishGlyph = Font.drawCode, nil
if Chrome then
local base = paper and { paper, paper, paper, { 0, 0, 0 } }
or Chrome.DEFAULT_BOX_PALETTE
Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base)
local _, dg, fg = Chrome.paletteGlyphs(base)
drawGlyph, finishGlyph = dg, fg
else
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1)
end
if self.scrollPx and self.scrollPx > 0 then if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2 self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end if self.scrollPx <= 0 then self.scrollPx = nil end
@@ -491,17 +510,25 @@ function TextBox:draw()
-- measured with; every fixed-width page still lands on the 8px grid -- measured with; every fixed-width page still lands on the 8px grid
local pen = self.textX local pen = self.textX
for _, code in ipairs(line) do for _, code in ipairs(line) do
Font.drawCode(code, pen, y) drawGlyph(code, pen, y)
pen = pen + Font.advanceOf(code) pen = pen + Font.advanceOf(code)
end end
end end
if self.money then if self.money then
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at -- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
-- hlcoord 11,0, the amount right-aligned on its middle row -- hlcoord 11,0, the amount right-aligned on its middle row
Font.drawBox(11, 0, 9, 3) if Chrome then
love.graphics.setColor(0, 0, 0, 1) Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE)
else
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
end
local money = ("¥%d"):format(self.money() or 0) local money = ("¥%d"):format(self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8) local pen = 152 - Font.width(money)
for _, code in ipairs(Font.encode(money)) do
drawGlyph(code, pen, 8)
pen = pen + Font.advanceOf(code)
end
end end
if (self.waiting or (self.done and not self.choice and not self.auto if (self.waiting or (self.done and not self.choice and not self.auto
and (not self.stay and (not self.stay
@@ -509,10 +536,11 @@ function TextBox:draw()
and self.blink < 30 then and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow -- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm) -- the original prints via `ld a, "▼"` (home/text.asm)
Font.drawCode(Theme.moreArrow or 0xEE, drawGlyph(Theme.moreArrow or 0xEE,
(self.boxTx + self.boxTw - 2) * 8, (self.boxTx + self.boxTw - 2) * 8,
(self.boxTy + self.boxTh - 1) * 8 - 4) (self.boxTy + self.boxTh - 1) * 8 - 4)
end end
if finishGlyph then finishGlyph() end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+14 -4
View File
@@ -65,16 +65,24 @@ end
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call -- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the -- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4). -- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
function Transition.new(game, onMidpoint, onDone, warp) --
-- opts.color = {r,g,b} in 0..1 (default black). opts.frames / opts.framesIn
-- override duration. Fly/Teleport/Dig/Escape Rope use white
-- GBFadeOutToWhite / GBFadeInFromWhite (#1644).
function Transition.new(game, onMidpoint, onDone, warp, opts)
opts = opts or {}
local self = setmetatable({}, Transition) local self = setmetatable({}, Transition)
self.game = game self.game = game
self.onMidpoint = onMidpoint self.onMidpoint = onMidpoint
self.onDone = onDone self.onDone = onDone
self.t = 0 self.t = 0
self.phase = "out" self.phase = "out"
self.color = opts.color or { 0, 0, 0 }
local style = styleOf(game, "warp_fade") local style = styleOf(game, "warp_fade")
self.frames = style.frames or FRAMES self.frames = opts.frames or style.frames or FRAMES
if warp then if opts.framesIn ~= nil then
self.framesIn = opts.framesIn
elseif warp then
-- a style may still ask for a fade in (mods, and the record is -- a style may still ask for a fade in (mods, and the record is
-- data-driven); the built-in warp is 0, matching hardware -- data-driven); the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN self.framesIn = style.framesIn or FRAMES_IN
@@ -125,6 +133,7 @@ end
function Transition:draw() function Transition:draw()
local alpha = self:alpha() local alpha = self:alpha()
local c = self.color or { 0, 0, 0 }
-- Survey zoom draws the overworld into a window-filling world canvas -- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the -- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world -- UI canvas only darkens that center box (issue #121); when the world
@@ -133,9 +142,10 @@ function Transition:draw()
local r = self.game and self.game.renderer local r = self.game and self.game.renderer
if r and r.worldActive then if r and r.worldActive then
r.worldFadeAlpha = alpha r.worldFadeAlpha = alpha
r.worldFadeColor = c
return return
end end
love.graphics.setColor(0, 0, 0, alpha) love.graphics.setColor(c[1], c[2], c[3], alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end

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